using System; using System.Collections; using System.Collections.Generic; using System.IO; using Godot; using MegaCrit.Sts2.Core.Runs; namespace STS2MobileIos.Patches; // In-game card-reward advisor (the mobile-native answer to the Steam "STS2 Adviser" // overlay). Because we run INSIDE the game we read the whole run straight from // RunState — every card in the deck plus every relic — and score the offered // candidates against the archetype(s) the run is committed to, then draw a small // grade + reason above each candidate card. No game value is ever modified: this // only READS card/relic data and ADDS a label node. // // Hook: postfix on NCardRewardSelectionScreen.ShowScreen and .RefreshOptions // (RefreshOptions also fires on reroll, so both entry points are covered; a // duplicate-guard makes a double call idempotent — it updates the existing label). public static class CardAdvisorPatch { // postfix on NCardRewardSelectionScreen.RefreshOptions (populates the reward card // row; fires on initial display and reroll). public static void RefreshOptionsPostfix(object __instance) => ScheduleRefresh(__instance); // postfix on NMerchantInventory.Initialize (the shop; FillSlot populates the // NMerchantCard slots). Same advisor, scoring the cards for sale. public static void MerchantInitPostfix(object __instance) => ScheduleRefresh(__instance); // postfix on NChooseACardSelectionScreen.ShowScreen — EVENT card choices (choose 1 // of 5, choose 2 of 8, ...). Uses NCardHolder-subclass holders, so the same // collect/score/label pipeline applies unchanged. public static void ChooseACardPostfix(object __instance) => ScheduleRefresh(__instance); // postfix on NChooseABundleSelectionScreen.ShowScreen — bundle (card package) choices. public static void ChooseABundlePostfix(object __instance) => ScheduleRefresh(__instance); // postfix on NCardGridSelectionScreen._Ready — the generic N-of-M card GRID (event // "choose 1 of 5 / 2 of 8" pickers). Scores also help on remove-style grids (drop // the low-scoring card). public static void GridSelectPostfix(object __instance) => ScheduleRefresh(__instance); // postfix on NOverlayStack.Push(IOverlayScreen screen) — the UNIVERSAL entry: every // overlay the game opens passes through here. Any overlay that contains card holders // gets advisor labels; card-less overlays no-op after one cheap walk. This covers // every current and future card-choice surface without hunting screen classes one by // one (the per-screen hooks above remain for their extra refresh timing, e.g. reroll). public static void OverlayPushPostfix(object screen) { try { if (screen != null) Trace($"overlay: {screen.GetType().Name}"); } catch { } ScheduleRefresh(screen); } // Run Refresh now, then keep refreshing on a persistent Timer for as long as the // screen lives. Why persistent (not a fixed burst): cards deal/animate in, the shop // has an entrance animation and hover/scale tweens, and cards can be purchased — // a fixed 3s burst stops maintaining labels and they "flash then vanish". A repeating // Timer (child of the screen, so it dies with it) keeps every label correct the whole // time. Refresh reuses/hides labels, so re-running is cheap and never flickers. private static void ScheduleRefresh(object screenObj) { Refresh(screenObj); try { if (screenObj is Node screen && screen.IsInsideTree()) { if (screen.GetNodeOrNull("AdvisorTimer") != null) return; // already running var timer = new Godot.Timer { Name = "AdvisorTimer", WaitTime = 0.25, OneShot = false }; screen.AddChild(timer); timer.Timeout += () => { if (GodotObject.IsInstanceValid(screen) && screen.IsInsideTree()) Refresh(screen); }; timer.Start(); } } catch (Exception ex) { PatchHelper.Log($"[Advisor] timer setup failed: {ex.Message}"); } } // Per-screen-type holder-count trace (bounded: writes only when the count CHANGES). // Diagnoses "hook fires but no labels": trace shows holders=0, and for Choose screens // a one-shot child-tree dump reveals where the card nodes actually live. private static readonly Dictionary _lastHolderCount = new(); private static void Trace(string line) { try { File.AppendAllText(Path.Combine(OS.GetUserDataDir(), "advisor_trace.txt"), line + "\n"); } catch { /* diagnostics must never hurt the game */ } } private static void Refresh(object screenObj) { try { var screen = screenObj as Node; if (screen == null) return; // 1) Collect the on-screen candidate card holders (and their CardModels). var holders = new List<(Node holder, Node card, object model)>(); CollectHolders(screen, holders); string st = screen.GetType().Name; if (!_lastHolderCount.TryGetValue(st, out var last) || last != holders.Count) { _lastHolderCount[st] = holders.Count; Trace($"{st} holders={holders.Count}"); // First time any hooked screen comes up empty, dump its child tree once — // shows where its card nodes actually live. if (holders.Count == 0) DumpTree(screen); } if (holders.Count == 0) return; // 2) Read the full run: deck + relics. var deckInfos = ReadDeck(out var relicEntries); // The character's STARTING relic is always relic[0] — auto-granted, present // every run, never a build choice. Counting it would lock the recommended // direction to its archetype from turn 1. Exclude it from archetype weighting; // only ACQUIRED relics (chosen/found) signal build intent. Full list still // logged for verification. var acquiredRelics = relicEntries.Count > 1 ? relicEntries.GetRange(1, relicEntries.Count - 1) : new List(); var profile = CardAdvisor.BuildProfile(deckInfos, acquiredRelics); // Screen context: on the deck-UPGRADE screen (campfire smith / events) the // question is "which card gains most from upgrading", a different metric // from "which card to pick" — switch the scorer accordingly. bool upgradeMode = st == "NDeckUpgradeSelectScreen"; // 3) Score every candidate, remember the best for a "★ pick" highlight. var scored = new List<(Node holder, Node card, CardAdvisor.CardInfo info, CardAdvisor.ScoreResult res)>(); foreach (var (holder, card, model) in holders) { var info = ExtractCardInfo(model); if (info == null) continue; var res = upgradeMode ? CardAdvisor.ScoreUpgrade(info, profile) : CardAdvisor.Score(info, profile); scored.Add((holder, card, info, res)); } if (scored.Count == 0) return; int bestIdx = 0; for (int i = 1; i < scored.Count; i++) if (scored[i].res.Score > scored[bestIdx].res.Score) bestIdx = i; bool zh = IsChinese(); // 4) Draw / update a grade label above each candidate. Labels live under a // layer parented to the SCREEN — NOT the card holders. NGridCardHolders // are pooled and reused (e.g. by a later event's card-enchant screen), so // a label parented to a holder would linger as a ghost on the next screen. // Tied to the screen, labels die when the reward screen closes. var layer = GetAdvisorLayer(screen); for (int i = 0; i < scored.Count; i++) { var (holder, card, _, res) = scored[i]; bool isBest = i == bestIdx && scored.Count > 1; DrawLabel(layer, i, holder as Control, res, isBest, zh); } // Hide (don't free) labels beyond the current count — e.g. a shop card that // was just purchased, or a smaller reward on a reused screen. Hiding + reusing // avoids create/destroy churn (which flickers on a 0.25s refresh). for (int i = scored.Count; ; i++) { var extra = layer.GetNodeOrNull