diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index 36ac07a..b58602f 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -19,6 +19,26 @@ Quality bar (also in `.cursor/rules/idle-spectator-quality.mdc`): 2. **Long-term / maintainable** - no brittle shortcuts. 3. **Always live-test** - open the game, spawn/drop entities, statuses, wars, plots; do not stop at code review or a single assert. +## Live inventory before special cases (required) + +When a fix touches **labels, prose, taxonomy, interest, or presentation** for a game asset class +(jobs, statuses, traits, species, tasks, happiness, plots, disasters, WorldLog ids, etc.): + +1. **Enumerate first.** Dump the live `AssetManager` library (or equivalent scanner) for that class. + Do not invent an id list from the tip you saw or from memory. +2. **Derive the rule from the inventory shape**, not from one awkward string. + Example: citizen jobs - discover `P_*` families from `citizen_job_library`, do not hardcode `gatherer_`. +3. **Gate the whole set.** Harness assert (or TSV dump under `.harness/`) must cover every live id, + plus 1-2 representative samples in the dossier/caption path. + One `dossier_force_job=farmer` green is not enough. +4. **Reject id-branch patches.** `if (id.StartsWith("gatherer"))`, one-off locale key probes, + and tip-string special cases are fails unless the branch is generated from live discovery. +5. **Self-check before coding:** "If the next unauthored id in this library appeared tomorrow, would this still be correct?" + If no, keep going - do not ship the example patch. + +Reference pattern: `ActivityAssetCatalog.EnumerateLiveCitizenJobIds` + `citizen_job_labels_ok` +(writes `.harness/citizen-job-labels.tsv`). + ## Live playtest (required for features/fixes) Harness scenarios are the gate, not a substitute for poking the world. @@ -66,6 +86,7 @@ Vanilla `Tooltip` / `TooltipLibrary` (`tooltips/tooltip_actor`, etc.) may be reu After any feature or bug fix in `IdleSpectator/`: 1. Design for full coverage (discovery / class rules), not the single failing tip. + For asset-class work, complete **Live inventory before special cases** above before writing branches. 2. Bump `IdleSpectator/mod.json` version (NML recompiles on game load). 3. If the game was already running, recompile with: ```bash @@ -191,7 +212,7 @@ Use `fast_timing` / `director_run` instead. - `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`) - `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain` - `chronicle_force`, `chronicle_open`, `chronicle_jump` -- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows` +- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `dossier_statuses_ok`, `citizen_job_labels_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows` - `snapshot`, `reset_counters`, `scenario` ## Process safety diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs index 31a63e6..2afbd4b 100644 --- a/IdleSpectator/ActivityAssetCatalog.cs +++ b/IdleSpectator/ActivityAssetCatalog.cs @@ -85,6 +85,269 @@ public static class ActivityAssetCatalog : (string.IsNullOrEmpty(speciesId) ? "" : speciesId.Replace('_', ' ').Trim()); } + /// Title Case species for dossier nametag: Unicorn, not unicorn. + public static string SpeciesNametagLabel(string speciesId) + { + string raw = SpeciesDisplayLabel(speciesId); + return TitleCaseWords(string.IsNullOrEmpty(raw) ? speciesId : raw); + } + + /// + /// Live prefixes like gatherer_* where ≥2 jobs share P_* and bare P + /// is not itself a job. Rebuilt from . + /// + private static HashSet _resourceRoleJobPrefixes; + private static int _resourceRoleJobPrefixesCount = -1; + + public static CitizenJobAsset TryGetCitizenJobAsset(string jobId) + { + if (string.IsNullOrEmpty(jobId)) + { + return null; + } + + try + { + return AssetManager.citizen_job_library?.get(jobId.Trim()); + } + catch + { + return null; + } + } + + public static List EnumerateLiveCitizenJobIds() + { + var ids = new List(); + try + { + var lib = AssetManager.citizen_job_library; + if (lib?.list == null) + { + return ids; + } + + for (int i = 0; i < lib.list.Count; i++) + { + CitizenJobAsset asset = lib.list[i]; + if (asset != null && !string.IsNullOrEmpty(asset.id)) + { + ids.Add(asset.id); + } + } + } + catch + { + // Library can be unavailable during early boot. + } + + return ids; + } + + /// + /// Live citizen-job display for dossier from citizen_job_library ids. + /// Resource-role families discovered from the library + /// (gatherer_herbsHerb Gatherer); everything else is Title Case id + /// (miner_depositMiner Deposit). Never probes invented locale keys. + /// + public static string JobDisplayLabel(string jobId) + { + if (string.IsNullOrEmpty(jobId)) + { + return ""; + } + + string id = jobId.Trim(); + CitizenJobAsset asset = TryGetCitizenJobAsset(id); + if (asset != null && !string.IsNullOrEmpty(asset.id)) + { + id = asset.id; + } + + return FormatCitizenJobIdLabel(id); + } + + public static string JobDisplayLabel(CitizenJobAsset asset) + { + if (asset == null || string.IsNullOrEmpty(asset.id)) + { + return ""; + } + + return FormatCitizenJobIdLabel(asset.id); + } + + /// + /// Title Case + discovered resource-role reorder for a citizen job id. + /// Public for harness audits over the full live library. + /// + public static string FormatCitizenJobIdLabel(string jobId) + { + if (string.IsNullOrEmpty(jobId)) + { + return ""; + } + + string id = jobId.Trim(); + if (id.StartsWith("job_", StringComparison.OrdinalIgnoreCase)) + { + id = id.Substring(4); + } + + EnsureResourceRoleJobPrefixes(); + int us = id.IndexOf('_'); + if (us > 0 + && us < id.Length - 1 + && _resourceRoleJobPrefixes != null + && _resourceRoleJobPrefixes.Contains(id.Substring(0, us))) + { + string resource = SingularizeResourcePhrase(id.Substring(us + 1).Replace('_', ' ')); + if (!string.IsNullOrEmpty(resource)) + { + return TitleCaseWords(resource + " " + id.Substring(0, us)); + } + } + + return TitleCaseWords(id.Replace('_', ' ')); + } + + private static void EnsureResourceRoleJobPrefixes() + { + List ids = EnumerateLiveCitizenJobIds(); + if (_resourceRoleJobPrefixes != null && _resourceRoleJobPrefixesCount == ids.Count) + { + return; + } + + var idSet = new HashSet(StringComparer.OrdinalIgnoreCase); + var prefixCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < ids.Count; i++) + { + string id = ids[i]; + if (string.IsNullOrEmpty(id)) + { + continue; + } + + idSet.Add(id); + int us = id.IndexOf('_'); + if (us <= 0 || us >= id.Length - 1) + { + continue; + } + + string prefix = id.Substring(0, us); + if (prefixCounts.TryGetValue(prefix, out int count)) + { + prefixCounts[prefix] = count + 1; + } + else + { + prefixCounts[prefix] = 1; + } + } + + var families = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair kv in prefixCounts) + { + // Family: several P_* jobs and no bare job id P (avoids miner + miner_deposit → Deposit Miner). + if (kv.Value >= 2 && !idSet.Contains(kv.Key)) + { + families.Add(kv.Key); + } + } + + _resourceRoleJobPrefixes = families; + _resourceRoleJobPrefixesCount = ids.Count; + } + + private static string SingularizeResourcePhrase(string phrase) + { + if (string.IsNullOrEmpty(phrase)) + { + return ""; + } + + string s = phrase.Trim(); + int lastSpace = s.LastIndexOf(' '); + if (lastSpace < 0) + { + return SingularizeResourceWord(s); + } + + string head = s.Substring(0, lastSpace).Trim(); + string last = SingularizeResourceWord(s.Substring(lastSpace + 1).Trim()); + return string.IsNullOrEmpty(last) ? head : head + " " + last; + } + + private static string SingularizeResourceWord(string word) + { + if (string.IsNullOrEmpty(word)) + { + return ""; + } + + string w = word.Trim(); + if (w.EndsWith("hes", StringComparison.OrdinalIgnoreCase) && w.Length > 3) + { + // bushes → bush + return w.Substring(0, w.Length - 2); + } + + if (w.EndsWith("s", StringComparison.OrdinalIgnoreCase) + && !w.EndsWith("ss", StringComparison.OrdinalIgnoreCase) + && w.Length > 2) + { + // herbs → herb; honey stays honey + return w.Substring(0, w.Length - 1); + } + + return w; + } + + /// Title-case whitespace-separated words for nametag identity tags. + public static string TitleCaseWords(string raw) + { + if (string.IsNullOrEmpty(raw)) + { + return ""; + } + + string s = raw.Trim().Replace('_', ' '); + while (s.IndexOf(" ", StringComparison.Ordinal) >= 0) + { + s = s.Replace(" ", " "); + } + + if (string.IsNullOrEmpty(s)) + { + return ""; + } + + char[] chars = s.ToCharArray(); + bool newWord = true; + for (int i = 0; i < chars.Length; i++) + { + if (char.IsWhiteSpace(chars[i]) || chars[i] == '-' || chars[i] == '/') + { + newWord = true; + continue; + } + + if (newWord) + { + chars[i] = char.ToUpperInvariant(chars[i]); + newWord = false; + } + else + { + chars[i] = char.ToLowerInvariant(chars[i]); + } + } + + return new string(chars); + } + public static List EnumerateLiveActorAssetIds() { var ids = new List(); diff --git a/IdleSpectator/ActivityInterestTable.cs b/IdleSpectator/ActivityInterestTable.cs index 393ec8f..2dadee5 100644 --- a/IdleSpectator/ActivityInterestTable.cs +++ b/IdleSpectator/ActivityInterestTable.cs @@ -114,11 +114,18 @@ public static class ActivityInterestTable return ActivityBand.Hot; } + // Mundane civic chores: never warm enough to steal ambient camera focus. if (ContainsAny(s, "farm", "harvest", "plant", "fertiliz", "mine", "gather", "woodcut", "chop", - "build", "construct", "road", "smith", "blacksmith", "pollinat", "bee", - "honey", "herb", "bush", "social", "lover", "breed", "fish", "trade", - "clean", "fertiliz", "bucket", "hoe")) + "herb", "bush", "honey", "clean", "bucket", "hoe", "road", "manure")) + { + return ActivityBand.Cold; + } + + // Notable civic / social work can still warm the ambient fill. + if (ContainsAny(s, + "build", "construct", "smith", "blacksmith", "pollinat", "bee", + "social", "lover", "breed", "fish", "trade", "unload", "throw_resource")) { return ActivityBand.Warm; } diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs index 38e6eb0..429c5fc 100644 --- a/IdleSpectator/ActivityProse.cs +++ b/IdleSpectator/ActivityProse.cs @@ -146,7 +146,8 @@ public static class ActivityProse public static string HumanizeJobPublic(string jobId) { - return HumanizeJob(jobId); + // Live citizen_job library + Title Case id fallback. + return ActivityAssetCatalog.JobDisplayLabel(jobId); } /// @@ -289,22 +290,6 @@ public static class ActivityProse return t.Trim(); } - private static string HumanizeJob(string jobId) - { - if (string.IsNullOrEmpty(jobId)) - { - return ""; - } - - string s = jobId.Replace('_', ' ').Trim(); - if (s.StartsWith("job ", System.StringComparison.OrdinalIgnoreCase)) - { - s = s.Substring(4).Trim(); - } - - return LowerFirst(s); - } - private static string LowerFirst(string s) { if (string.IsNullOrEmpty(s)) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index d6fc2ce..fdf032c 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -264,6 +264,12 @@ public static class AgentHarness { bool on = ParseBool(cmd.value, defaultValue: true); string expect = (cmd.expect ?? "").Trim().ToLowerInvariant(); + // Manual idle toggle while Lore is open must not be undone by Lore close. + if (ChronicleHud.Visible) + { + ChronicleHud.NotifyManualIdleToggle(); + } + SpectatorMode.SetActive(on); bool ok; string detail; @@ -5936,6 +5942,97 @@ public static class AgentHarness detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)} reason='{dossier?.ReasonLine}'"; break; } + case "citizen_job_labels_ok": + { + // Full live citizen_job_library: non-empty Title Case labels; resource-role + // families reorder; bare-prefix siblings (miner_deposit) do not. + List ids = ActivityAssetCatalog.EnumerateLiveCitizenJobIds(); + int empty = 0; + int underscored = 0; + int familyBad = 0; + int siblingBad = 0; + var lines = new List(ids.Count + 1) { "id\tlabel" }; + var prefixCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var idSet = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < ids.Count; i++) + { + string rawId = ids[i]; + if (string.IsNullOrEmpty(rawId)) + { + continue; + } + + idSet.Add(rawId); + int us = rawId.IndexOf('_'); + if (us > 0 && us < rawId.Length - 1) + { + string p = rawId.Substring(0, us); + prefixCounts[p] = prefixCounts.TryGetValue(p, out int c) ? c + 1 : 1; + } + } + + for (int i = 0; i < ids.Count; i++) + { + string id = ids[i]; + string label = ActivityAssetCatalog.FormatCitizenJobIdLabel(id); + lines.Add(id + "\t" + label); + if (string.IsNullOrEmpty(label)) + { + empty++; + continue; + } + + if (label.IndexOf('_') >= 0) + { + underscored++; + } + + int us = id.IndexOf('_'); + if (us <= 0 || us >= id.Length - 1) + { + continue; + } + + string prefix = id.Substring(0, us); + bool family = prefixCounts.TryGetValue(prefix, out int pc) + && pc >= 2 + && !idSet.Contains(prefix); + string titlePrefix = ActivityAssetCatalog.TitleCaseWords(prefix); + if (family) + { + // gatherer_herbs → Herb Gatherer (role last) + if (!label.EndsWith(titlePrefix, StringComparison.Ordinal) + || label.StartsWith(titlePrefix + " ", StringComparison.Ordinal)) + { + familyBad++; + } + } + else if (idSet.Contains(prefix) + && label.EndsWith(titlePrefix, StringComparison.Ordinal) + && !label.StartsWith(titlePrefix, StringComparison.Ordinal)) + { + // miner_deposit must stay Miner Deposit, not Deposit Miner + siblingBad++; + } + } + + try + { + System.IO.File.WriteAllLines( + System.IO.Path.Combine(HarnessDir(), "citizen-job-labels.tsv"), + lines); + } + catch + { + // diagnostic dump only + } + + pass = ids.Count > 0 && empty == 0 && underscored == 0 && familyBad == 0 && siblingBad == 0; + detail = + $"jobs={ids.Count} empty={empty} underscored={underscored} " + + $"familyBad={familyBad} siblingBad={siblingBad} sample='{(ids.Count > 0 ? ActivityAssetCatalog.FormatCitizenJobIdLabel(ids[0]) : "")}'"; + break; + } case "dossier_not_contains": { string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; @@ -6106,7 +6203,7 @@ public static class AgentHarness } case "caption_layout_ok": { - float maxH = 220f; + float maxH = 250f; pass = WatchCaption.LastLayoutOk && WatchCaption.LastPanelSize.y > 20f && WatchCaption.LastPanelSize.y < maxH @@ -6123,7 +6220,7 @@ public static class AgentHarness string preview = WatchCaption.LastTraitsPreview ?? ""; int count = dossier != null ? dossier.TopTraits.Count : 0; bool noEllipsis = preview.IndexOf("...", System.StringComparison.Ordinal) < 0; - bool bounded = count >= 0 && count <= 3; + bool bounded = count >= 0 && count <= UnitDossier.MaxTraitChips; bool labelsMatch = true; if (dossier != null) { @@ -6142,6 +6239,23 @@ public static class AgentHarness detail = $"traits={count} preview='{preview}' layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}"; break; } + case "dossier_statuses_ok": + { + UnitDossier dossier = WatchCaption.Current; + string preview = WatchCaption.LastStatusesPreview ?? ""; + int count = dossier != null ? dossier.TopStatuses.Count : 0; + bool noEllipsis = preview.IndexOf("...", System.StringComparison.Ordinal) < 0; + bool bounded = count >= 0 && count <= UnitDossier.MaxStatusChips; + int wantMin = ParseCountExpect(cmd, defaultValue: 0); + string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant(); + bool countOk = cmp == "exact" || cmp == "==" + ? count == wantMin + : count >= wantMin; + pass = bounded && noEllipsis && countOk; + detail = + $"statuses={count} want={wantMin} cmp={cmp} preview='{preview}' layoutOk={WatchCaption.LastLayoutOk}"; + break; + } case "dossier_history_contains": { string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs index 05834e8..a773e0c 100644 --- a/IdleSpectator/ChronicleHud.cs +++ b/IdleSpectator/ChronicleHud.cs @@ -72,6 +72,8 @@ public static class ChronicleHud private static int _builtAtRevision = int.MinValue; private static long _detailUnitId; private static bool _followFocus; + /// True when Lore itself paused idle; close should auto-resume. + private static bool _pausedIdleForLore; private static GameObject _backBtn; private static Text _backBtnLabel; private static GameObject _detailPaneBar; @@ -637,16 +639,25 @@ public static class ChronicleHud } ApplyRootPlacement(); + bool wasVisible = _visible; _visible = visible; _root.SetActive(visible); if (visible) { Rebuild(force: true); } - else if (_searchField != null && EventSystem.current != null - && EventSystem.current.currentSelectedGameObject == _searchField.gameObject) + else { - EventSystem.current.SetSelectedGameObject(null); + if (_searchField != null && EventSystem.current != null + && EventSystem.current.currentSelectedGameObject == _searchField.gameObject) + { + EventSystem.current.SetSelectedGameObject(null); + } + + if (wasVisible) + { + TryResumeIdleAfterLoreClose(); + } } } @@ -662,7 +673,32 @@ public static class ChronicleHud } /// - /// Open Lore for the focused / dossier unit (locks idle like the books button). + /// Clear Lore→idle resume sticky when the user toggles Idle with I while Lore is open. + /// + public static void NotifyManualIdleToggle() + { + _pausedIdleForLore = false; + } + + private static void TryResumeIdleAfterLoreClose() + { + if (!_pausedIdleForLore) + { + return; + } + + _pausedIdleForLore = false; + if (!ModSettings.Enabled || SpectatorMode.Active) + { + return; + } + + SpectatorMode.SetActive(true, quiet: true); + LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle"); + } + + /// + /// Open Lore for the focused / dossier unit (locks idle; close auto-resumes). /// With no subject, opens the Characters browser. /// public static void OpenSyncedFromFocus() @@ -725,8 +761,14 @@ public static class ChronicleHud if (pauseIdle && SpectatorMode.Active) { + _pausedIdleForLore = true; SpectatorMode.SetActive(false, quiet: true); } + else if (pauseIdle) + { + // Lore opened while idle was already off - do not auto-resume on close. + _pausedIdleForLore = false; + } bool live = Chronicle.FocusCameraForBrowse(unitId); _tab = live ? LoreTab.Living : LoreTab.Fallen; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 0360f8e..037f196 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -2123,16 +2123,32 @@ internal static class HarnessScenarios Step("act16h", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat", expect: "Barkley"), Step("act16i", "assert", expect: "activity_log_contains", value: "Barkley"), Step("act16i2", "assert", expect: "activity_log_not_contains", value: "with another"), - // JobLabel on dossier reason row (deterministic harness force). + // JobLabel on nametag identity tag (Species/Job); reason stays story-only. + // Labels come from live citizen_job_library discovery (not job-id allowlists). + Step("act16j0", "assert", expect: "citizen_job_labels_ok"), Step("act16j", "dossier_force_job", value: "farmer"), - Step("act16k", "assert", expect: "dossier_contains", value: "farmer"), + Step("act16k", "assert", expect: "dossier_contains", value: "Farmer"), + Step("act16k2", "assert", expect: "dossier_contains", value: "/"), + Step("act16k3", "dossier_force_job", value: "gatherer_herbs"), + Step("act16k4", "assert", expect: "dossier_contains", value: "Herb Gatherer"), + Step("act16k5", "dossier_force_job", value: "gatherer_bushes"), + Step("act16k6", "assert", expect: "dossier_contains", value: "Bush Gatherer"), + Step("act16k7", "dossier_force_job", value: "miner_deposit"), + Step("act16k8", "assert", expect: "dossier_contains", value: "Miner Deposit"), Step("act16l", "screenshot", value: "hud-dossier-job-only.png"), Step("act16m", "wait", wait: 0.45f), Step("act16n", "dossier_force_job", value: "farmer", label: "Story"), - Step("act16o", "assert", expect: "dossier_contains", value: "Story · farmer"), + Step("act16o", "assert", expect: "dossier_contains", value: "Story"), + Step("act16o2", "assert", expect: "dossier_contains", value: "Farmer"), Step("act16p", "screenshot", value: "hud-dossier-reason-job.png"), Step("act16q", "wait", wait: 0.45f), Step("act16r", "dossier_clear_job"), + // Statuses row (priority, max 4). + Step("act16s", "status_apply", value: "cursed", label: "20"), + Step("act16t", "status_apply", value: "poisoned", label: "20"), + Step("act16u", "wait", wait: 0.25f), + Step("act16v", "assert", expect: "dossier_statuses_ok", value: "1", label: "min"), + Step("act16w", "assert", expect: "dossier_contains", value: "Cursed"), Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"), Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1), // Live-relevance peek: after soft window, aged hunt drops; newest unload remains. @@ -2194,6 +2210,10 @@ internal static class HarnessScenarios Step("act51", "assert", expect: "idle", value: "false"), Step("act52", "assert", expect: "caption_layout_ok"), Step("act53", "assert", expect: "no_bad"), + // Closing Lore (opened via L while idle) auto-resumes spectator. + Step("act54", "lore_close"), + Step("act55", "wait", wait: 0.2f), + Step("act56", "assert", expect: "idle", value: "true"), Step("act99", "snapshot"), }; } @@ -2299,7 +2319,7 @@ internal static class HarnessScenarios Step("ch18f", "assert", expect: "is_favorite", value: "true"), Step("ch18g", "dossier_favorite", value: "false"), Step("ch18h", "assert", expect: "is_favorite", value: "false"), - // Dossier peeks 3 lines; books button opens full history in Lore and pauses idle. + // Dossier peeks 3 lines; L / lore_open_focus opens full history and pauses idle. Step("ch18h1", "chronicle_force", label: "Hist", count: 40), Step("ch18h2", "wait", wait: 0.25f), Step("ch18k", "assert", expect: "dossier_history_shown", value: "3", label: "exact"), @@ -2337,8 +2357,10 @@ internal static class HarnessScenarios Step("ch18f5", "wait", wait: 0.35f), Step("ch18f6", "assert", expect: "lore_follows_focus"), Step("ch18f7", "assert", expect: "lore_follow", value: "true"), - // Books button still opens Follow mode. + // lore_open_focus / dossier_history_open still open Follow mode (books button removed). Step("ch18p0", "lore_close"), + Step("ch18p0a", "wait", wait: 0.15f), + Step("ch18p0a2", "assert", expect: "idle", value: "true"), Step("ch18p0b", "dossier_history_open"), Step("ch18p0c", "wait", wait: 0.2f), Step("ch18p0d", "assert", expect: "dossier_lore_history_match"), diff --git a/IdleSpectator/SpectatorMode.cs b/IdleSpectator/SpectatorMode.cs index 6ac056b..9f74d8e 100644 --- a/IdleSpectator/SpectatorMode.cs +++ b/IdleSpectator/SpectatorMode.cs @@ -33,16 +33,16 @@ public static class SpectatorMode } Active = active; - if (Active) - { - WatchCaption.ClearPausePin(); - InterestDirector.OnSpectatorEnabled(); - SpeciesDiscovery.OnSpectatorEnabled(); - Chronicle.OnSpectatorEnabled(); - ChronicleHud.OnIdleResumed(); - FocusRelationshipArrows.OnSpectatorEnabled(); - LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)"); - } + if (Active) + { + WatchCaption.ClearPausePin(); + InterestDirector.OnSpectatorEnabled(); + SpeciesDiscovery.OnSpectatorEnabled(); + Chronicle.OnSpectatorEnabled(); + ChronicleHud.OnIdleResumed(); + FocusRelationshipArrows.OnSpectatorEnabled(); + LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)"); + } else { InterestDirector.OnSpectatorDisabled(); @@ -84,6 +84,12 @@ public static class SpectatorMode if (Input.GetKeyDown(ToggleKey)) { + // Manual I while Lore is open must not be undone by Lore close auto-resume. + if (ChronicleHud.Visible) + { + ChronicleHud.NotifyManualIdleToggle(); + } + Toggle(); } } diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 2652955..8d78b87 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -17,6 +17,8 @@ public sealed class UnitDossier public string DetailLine = ""; public string CaptionText = ""; public string JobLabel = ""; + /// Nametag parenthesis: Species or Species/Job. + public string IdentityTag = ""; public float Score; public int Kills; public int Age; @@ -35,6 +37,10 @@ public sealed class UnitDossier public string CityName = ""; public readonly List ScoreReasons = new List(); public readonly List TopTraits = new List(); + public readonly List TopStatuses = new List(); + + public const int MaxTraitChips = 4; + public const int MaxStatusChips = 4; public sealed class TraitChip { @@ -43,6 +49,13 @@ public sealed class UnitDossier public ActorTrait Trait; } + public sealed class StatusChip + { + public string Id = ""; + public string Name = ""; + public StatusAsset Status; + } + /// Harness: force JobLabel on the next dossier build (no live citizen_job). public static string HarnessJobLabelOverride = ""; @@ -68,9 +81,8 @@ public sealed class UnitDossier d.UnitId = unitId; d.Name = string.IsNullOrEmpty(name) ? "Nameless" : name; d.SpeciesId = string.IsNullOrEmpty(speciesId) ? "creature" : speciesId; - d.Headline = string.IsNullOrEmpty(d.SpeciesId) - ? d.Name - : $"{d.Name} ({d.SpeciesId})"; + d.IdentityTag = BuildIdentityTag(d.SpeciesId, jobLabel: ""); + d.Headline = BuildHeadline(d.Name, d.IdentityTag); string mannerLabel = Chronicle.DeathMannerLabel(manner); d.ReasonLine = string.IsNullOrEmpty(mannerLabel) || manner == DeathManner.None ? "Fallen" @@ -112,14 +124,13 @@ public sealed class UnitDossier FillSex(d, actor); d.TaskText = SafeTask(actor); FillTopTraits(d, actor); + FillTopStatuses(d, actor); CollectReasons(d, actor, speciesCounts); d.JobLabel = ResolveJobLabel(actor); - - // Species icon is shown separately; keep id in the title for scanability. - d.Headline = string.IsNullOrEmpty(d.SpeciesId) - ? d.Name - : $"{d.Name} ({d.SpeciesId})"; - d.ReasonLine = ComposeReasonWithJob(BuildStoryBeat(d, watchLabel, actor), d.JobLabel); + d.IdentityTag = BuildIdentityTag(d.SpeciesId, d.JobLabel); + d.Headline = BuildHeadline(d.Name, d.IdentityTag); + // Orange reason is story beat only; job lives in the nametag identity tag. + d.ReasonLine = BuildStoryBeat(d, watchLabel, actor) ?? ""; d.DetailLine = BuildDetailLine(d); d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine); return d; @@ -129,37 +140,83 @@ public sealed class UnitDossier { if (!string.IsNullOrEmpty(HarnessJobLabelOverride)) { - return HarnessJobLabelOverride.Trim(); + // Same discovery path as live jobs (resource-role families, Title Case id). + return ActivityAssetCatalog.JobDisplayLabel(HarnessJobLabelOverride.Trim()); + } + + try + { + if (actor?.citizen_job != null) + { + string fromAsset = ActivityAssetCatalog.JobDisplayLabel(actor.citizen_job); + if (!string.IsNullOrEmpty(fromAsset)) + { + return fromAsset; + } + } + } + catch + { + // ignore } string jobId = ActivityInterestTable.SafeJobId(actor); - return ActivityProse.HumanizeJobPublic(jobId); + return ActivityAssetCatalog.JobDisplayLabel(jobId); } /// - /// Job sits on the orange reason row: alone when there is no watch beat, otherwise - /// {reason} · {job}. + /// Combined nametag tag: Species or Species/Job. /// + public static string BuildIdentityTag(string speciesId, string jobLabel) + { + string species = ActivityAssetCatalog.SpeciesNametagLabel(speciesId); + string job = ActivityAssetCatalog.TitleCaseWords(jobLabel ?? ""); + if (string.IsNullOrEmpty(species)) + { + return job; + } + + if (string.IsNullOrEmpty(job)) + { + return species; + } + + if (species.Equals(job, StringComparison.OrdinalIgnoreCase)) + { + return species; + } + + return species + "/" + job; + } + + public static string BuildHeadline(string name, string identityTag) + { + string n = string.IsNullOrEmpty(name) ? "Nameless" : name.Trim(); + string tag = (identityTag ?? "").Trim(); + return string.IsNullOrEmpty(tag) ? n : n + " (" + tag + ")"; + } + + /// Legacy helper: orange reason is story-only (job ignored). public static string ComposeReasonWithJob(string reason, string job) { - string r = (reason ?? "").Trim(); - string j = (job ?? "").Trim(); - if (string.IsNullOrEmpty(j)) + return (reason ?? "").Trim(); + } + + /// Public for live nametag refresh when citizen job changes. + public static string ReadLiveJobLabel(Actor actor) + { + return ResolveJobLabel(actor); + } + + /// Public for live dossier status chip refresh. + public static void RefreshTopStatuses(UnitDossier dossier, Actor actor) + { + if (dossier == null) { - return r; + return; } - if (string.IsNullOrEmpty(r)) - { - return j; - } - - if (r.IndexOf(j, StringComparison.OrdinalIgnoreCase) >= 0) - { - return r; - } - - return r + " · " + j; + FillTopStatuses(dossier, actor); } public bool ContainsIgnoreCase(string needle) @@ -174,6 +231,7 @@ public sealed class UnitDossier || (ReasonLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (DetailLine ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (JobLabel ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 + || (IdentityTag ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (Name ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (SpeciesId ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0 || (TaskText ?? "").IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) @@ -204,6 +262,23 @@ public sealed class UnitDossier } } + for (int i = 0; i < TopStatuses.Count; i++) + { + StatusChip chip = TopStatuses[i]; + if (chip == null) + { + continue; + } + + if ((!string.IsNullOrEmpty(chip.Name) + && chip.Name.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrEmpty(chip.Id) + && chip.Id.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)) + { + return true; + } + } + return false; } @@ -1091,7 +1166,7 @@ public sealed class UnitDossier ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor))); - for (int i = 0; i < ranked.Count && d.TopTraits.Count < 3; i++) + for (int i = 0; i < ranked.Count && d.TopTraits.Count < MaxTraitChips; i++) { ActorTrait trait = ranked[i]; d.TopTraits.Add(new TraitChip @@ -1108,6 +1183,164 @@ public sealed class UnitDossier } } + private static void FillTopStatuses(UnitDossier d, Actor actor) + { + d.TopStatuses.Clear(); + if (actor == null) + { + return; + } + + try + { + var ids = actor.getStatusesIds(); + if (ids == null) + { + return; + } + + List ranked = new List(); + foreach (string rawId in ids) + { + if (string.IsNullOrEmpty(rawId)) + { + continue; + } + + string id = rawId.Trim(); + StatusAsset asset = ActivityAssetCatalog.TryGetStatusAsset(id); + ranked.Add(new StatusChip + { + Id = id, + Name = StatusDisplayName(id, asset), + Status = asset + }); + } + + ranked.Sort((a, b) => StatusInterestScore(b).CompareTo(StatusInterestScore(a))); + + for (int i = 0; i < ranked.Count && d.TopStatuses.Count < MaxStatusChips; i++) + { + d.TopStatuses.Add(ranked[i]); + } + } + catch + { + // ignore status access failures on exotic units + } + } + + /// + /// Spectator-facing status priority: authored strength / camera, lasting afflictions, + /// demote brief combat FX. Scales to unknown live ids via heuristics. + /// + private static int StatusInterestScore(StatusChip chip) + { + if (chip == null || string.IsNullOrEmpty(chip.Id)) + { + return int.MinValue; + } + + string id = chip.Id; + int score = 0; + StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(id); + if (entry != null && !entry.IsFallback) + { + score += MathfRound(entry.EventStrength); + if (entry.CreatesInterest) + { + score += 40; + } + + string cat = entry.Category ?? ""; + if (cat.Equals("StatusTransformation", StringComparison.OrdinalIgnoreCase) + || cat.Equals("Grief", StringComparison.OrdinalIgnoreCase) + || cat.Equals("LifeChapter", StringComparison.OrdinalIgnoreCase)) + { + score += 30; + } + else if (cat.Equals("StatusAmbient", StringComparison.OrdinalIgnoreCase) + || cat.Equals("Emotion", StringComparison.OrdinalIgnoreCase)) + { + score += 5; + } + } + else + { + score += 20; + } + + if (StatusOutbreakFeed.LooksLikeAfflictionCluster(id)) + { + score += 45; + } + + if (StatusOutbreakFeed.IsBriefCombatOrSpellFx(id)) + { + score -= 80; + } + + try + { + StatusAsset asset = chip.Status ?? ActivityAssetCatalog.TryGetStatusAsset(id); + if (asset != null && asset.duration > 0f) + { + if (asset.duration <= 6f) + { + score -= 35; + } + else if (asset.duration >= 30f) + { + score += 20; + } + else + { + score += 8; + } + } + } + catch + { + // ignore + } + + return score; + } + + private static int MathfRound(float value) + { + return (int)(value + (value >= 0f ? 0.5f : -0.5f)); + } + + private static string StatusDisplayName(string statusId, StatusAsset asset) + { + try + { + if (asset != null) + { + string locale = asset.getLocaleID(); + if (!string.IsNullOrEmpty(locale)) + { + string localized = LocalizedTextManager.getText(locale); + if (!string.IsNullOrEmpty(localized) + && !localized.Equals(locale, StringComparison.Ordinal) + && localized.IndexOf('_') < 0) + { + return ActivityAssetCatalog.TitleCaseWords(localized.Trim()); + } + } + } + } + catch + { + // fall through + } + + string prose = ActivityStatusProse.StatusLabelFromId(statusId); + return ActivityAssetCatalog.TitleCaseWords( + string.IsNullOrEmpty(prose) ? statusId : prose); + } + /// /// Spectator-facing interest: Legendary/Epic over Normal, non-default over species baseline, /// Positive/Negative over bland Other. Ties break on rarer birth rates. diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 1b5c46f..d9cea8f 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; @@ -7,7 +8,7 @@ using UnityEngine.UI; namespace IdleSpectator; /// -/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, packed traits, reason. +/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason. /// public static class WatchCaption { @@ -17,13 +18,13 @@ public static class WatchCaption private const float LiveMax = 44f; private const float ChipIcon = 12f; /// Fixed nametag task label slot so text swaps do not autofit-tick the header. - private const float TaskLabelW = 72f; + private const float TaskLabelW = 78f; private const float TraitIcon = 12f; private const float HistoryIcon = 10f; private const float HistoryColMinW = 118f; private const float NameMinW = 28f; - /// Hard cap so long "Name (species)" lines cannot paint under the level chip. - private const float NameMaxW = 210f; + /// Hard cap so long "Name (Species/Job)" lines cannot paint under the level chip. + private const float NameMaxW = 230f; private const float PadX = 4f; /// Inset from the canvas top-right corner (grows left via pivot). private const float ScreenInset = 12f; @@ -31,6 +32,7 @@ public static class WatchCaption private const float HeaderH = 18f; private const float BodyH = 44f; private const float TraitsH = 14f; + private const float StatusesH = 14f; private const float ReasonH = 14f; private const float HistoryLineMinH = 13f; private const float HistoryLineMaxH = 100f; @@ -50,6 +52,7 @@ public static class WatchCaption private static readonly Color TaskColor = new Color(0.26f, 1f, 0.26f, 1f); private static readonly Color ReasonColor = new Color(0.95f, 0.72f, 0.38f, 1f); private static readonly Color TraitNameColor = new Color(0.78f, 0.8f, 0.84f, 1f); + private static readonly Color StatusNameColor = new Color(0.62f, 0.82f, 0.92f, 1f); private static readonly Color HistoryTextColor = new Color(0.82f, 0.84f, 0.86f, 1f); private static readonly Color StatusBannerColor = new Color(1f, 0.55f, 0.18f, 1f); @@ -65,8 +68,11 @@ public static class WatchCaption private static Image _taskIcon; private static Text _taskText; - private static readonly TraitSlot[] _traitSlots = new TraitSlot[3]; + private static readonly TraitSlot[] _traitSlots = new TraitSlot[UnitDossier.MaxTraitChips]; private static GameObject _traitsRow; + private static readonly TraitSlot[] _statusSlots = new TraitSlot[UnitDossier.MaxStatusChips]; + private static GameObject _statusesRow; + private static int _lastStatusesFingerprint = int.MinValue; private static GameObject _historyCol; private static Image _activityBoxBg; @@ -82,9 +88,9 @@ public static class WatchCaption private static Button _favoriteBtn; private static Image _favoriteIcon; - private static Button _historyBtn; - private static Image _historyIcon; private static bool _pinnedWhilePaused; + /// Harness ForceNametagHeadline lock - skip live identity overwrite until rebuild. + private static bool _headlineLocked; private static UnitDossier _current; private static Actor _boundActor; @@ -147,6 +153,9 @@ public static class WatchCaption /// Harness: comma-joined top trait labels currently shown. public static string LastTraitsPreview { get; private set; } = ""; + /// Harness: comma-joined top status labels currently shown. + public static string LastStatusesPreview { get; private set; } = ""; + public static Vector2 LastPanelSize { get; private set; } public static bool LastLayoutOk { get; private set; } @@ -211,7 +220,7 @@ public static class WatchCaption return false; } - if (IsPointerOverButton(_favoriteBtn) || IsPointerOverButton(_historyBtn)) + if (IsPointerOverButton(_favoriteBtn)) { return true; } @@ -298,7 +307,7 @@ public static class WatchCaption _pinnedWhilePaused = false; } - /// Open this unit's full history in the Lore panel and pause idle auto-follow. + /// Open this unit's full history in the Lore panel (L / harness); pause idle auto-follow. public static void OpenFullHistoryInLore() { // Prefer the live camera focus so a pinned dossier cannot open the wrong unit's lore. @@ -350,10 +359,13 @@ public static class WatchCaption LastHistoryFillsBody = false; _pinnedWhilePaused = false; LastTraitsPreview = ""; + LastStatusesPreview = ""; LastPanelSize = Vector2.zero; LastLayoutOk = false; _lastHistoryCount = -1; _lastHistorySubjectId = 0; + _lastStatusesFingerprint = int.MinValue; + _headlineLocked = false; _historyColW = HistoryColMinW; _statusBanner = ""; _statusBannerUntil = 0f; @@ -396,10 +408,15 @@ public static class WatchCaption // Re-fill from the current subject so Relayout cannot resurrect stale history slots // from a previously focused unit (activeSelf stays true while the column is hidden). int hist = _current != null ? FillHistory(_current.UnitId) : 0; - int traits = CountActiveTraitSlots(); bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; bool hasBody = _boundActor != null || hist > 0 || _current != null; - Relayout(hasBody, traits, hasTask, hasReason: true, hist); + Relayout( + hasBody, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask, + hasReason: true, + hist); SetVisible(true); LogService.LogInfo("[IdleSpectator][CAPTION] status=" + message); } @@ -429,7 +446,7 @@ public static class WatchCaption _nameText.text = LastHeadline; } - Relayout(false, 0, false, false, 0); + Relayout(false, 0, 0, false, false, 0); SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active); } @@ -512,6 +529,8 @@ public static class WatchCaption RefreshLivePortrait(); RefreshLiveTask(); + RefreshLiveIdentity(); + RefreshLiveStatuses(); RefreshOwnedReason(); RefreshHistoryIfChanged(); return; @@ -555,7 +574,7 @@ public static class WatchCaption else if (_nameText != null) { _nameText.text = LastHeadline; - Relayout(false, 0, false, false, 0); + Relayout(false, 0, 0, false, false, 0); } SetVisible(true); @@ -566,6 +585,8 @@ public static class WatchCaption ReconcileDossierToFocus(); RefreshLivePortrait(); RefreshLiveTask(); + RefreshLiveIdentity(); + RefreshLiveStatuses(); RefreshOwnedReason(); RefreshHistoryIfChanged(); } @@ -663,6 +684,20 @@ public static class WatchCaption return n; } + private static int CountActiveStatusSlots() + { + int n = 0; + for (int i = 0; i < _statusSlots.Length; i++) + { + if (_statusSlots[i]?.Root != null && _statusSlots[i].Root.activeSelf) + { + n++; + } + } + + return n; + } + private static void RefreshLivePortrait() { if (!_visible) @@ -696,9 +731,7 @@ public static class WatchCaption return; } - string next = UnitDossier.ComposeReasonWithJob( - UnitDossier.OwnedEventReason(actor, _current), - _current.JobLabel); + string next = UnitDossier.OwnedEventReason(actor, _current) ?? ""; string prev = _current.ReasonLine ?? ""; if (next == prev) { @@ -717,9 +750,13 @@ public static class WatchCaption } bool hasTask = !string.IsNullOrEmpty(_current.TaskText); - int traits = _current.TopTraits != null ? _current.TopTraits.Count : 0; - int hist = CountActiveHistorySlots(); - Relayout(_current.UnitId != 0, traits, hasTask, hasReason, hist); + Relayout( + _current.UnitId != 0, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask, + hasReason, + CountActiveHistorySlots()); } private static string JoinCaptionLines(string headline, string reason, string detail) @@ -792,12 +829,145 @@ public static class WatchCaption { bool hasBody = _current.UnitId != 0; bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; - Relayout(hasBody, CountActiveTraitSlots(), hasTask: true, hasReason, CountActiveHistorySlots()); + Relayout( + hasBody, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask: true, + hasReason, + CountActiveHistorySlots()); } BringHeaderFront(); } + /// Keep nametag Species/Job identity tag in sync with live citizen job. + private static void RefreshLiveIdentity() + { + if (!_visible || _current == null || HasStatusBanner() || _headlineLocked) + { + return; + } + + Actor actor = ResolveBoundLiveActor(); + if (actor == null) + { + return; + } + + string job = UnitDossier.ReadLiveJobLabel(actor); + string tag = UnitDossier.BuildIdentityTag(_current.SpeciesId, job); + string headline = UnitDossier.BuildHeadline(_current.Name, tag); + if (headline == (_current.Headline ?? "") + && tag == (_current.IdentityTag ?? "") + && job == (_current.JobLabel ?? "")) + { + return; + } + + _current.JobLabel = job; + _current.IdentityTag = tag; + _current.Headline = headline; + LastHeadline = headline; + LastCaptionText = JoinCaptionLines(headline, _current.ReasonLine, _current.DetailLine); + if (_nameText != null) + { + _nameText.text = headline; + } + + bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; + bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; + Relayout( + _current.UnitId != 0, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask, + hasReason, + CountActiveHistorySlots()); + BringHeaderFront(); + } + + /// Keep status chips in sync with live status set (fingerprint on top-4 ids). + private static void RefreshLiveStatuses() + { + if (!_visible || _current == null) + { + return; + } + + Actor actor = ResolveBoundLiveActor(); + if (actor == null) + { + return; + } + + UnitDossier probe = new UnitDossier(); + UnitDossier.RefreshTopStatuses(probe, actor); + int fp = StatusFingerprint(probe); + if (fp == _lastStatusesFingerprint + && StatusChipsMatch(_current.TopStatuses, probe.TopStatuses)) + { + return; + } + + UnitDossier.RefreshTopStatuses(_current, actor); + int statusCount = ApplyStatusChips(_current); + _lastStatusesFingerprint = fp; + bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; + bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; + Relayout( + _current.UnitId != 0, + CountActiveTraitSlots(), + statusCount, + hasTask, + hasReason, + CountActiveHistorySlots()); + } + + private static int StatusFingerprint(UnitDossier dossier) + { + if (dossier == null || dossier.TopStatuses == null || dossier.TopStatuses.Count == 0) + { + return 0; + } + + unchecked + { + int h = 17; + for (int i = 0; i < dossier.TopStatuses.Count; i++) + { + string id = dossier.TopStatuses[i]?.Id ?? ""; + h = h * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(id); + } + + return h; + } + } + + private static bool StatusChipsMatch( + System.Collections.Generic.List a, + System.Collections.Generic.List b) + { + int na = a != null ? a.Count : 0; + int nb = b != null ? b.Count : 0; + if (na != nb) + { + return false; + } + + for (int i = 0; i < na; i++) + { + string idA = a[i]?.Id ?? ""; + string idB = b[i]?.Id ?? ""; + if (!idA.Equals(idB, System.StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + private static Actor ResolveBoundLiveActor() { Actor actor = _boundActor; @@ -902,6 +1072,7 @@ public static class WatchCaption { EnsureBuilt(); string text = string.IsNullOrEmpty(headline) ? "Nobody" : headline.Trim(); + _headlineLocked = true; LastHeadline = text; if (_nameText != null) { @@ -916,7 +1087,13 @@ public static class WatchCaption bool hasBody = _current != null && _current.UnitId != 0; bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; - Relayout(hasBody, CountActiveTraitSlots(), hasTask, hasReason, CountActiveHistorySlots()); + Relayout( + hasBody, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask, + hasReason, + CountActiveHistorySlots()); } private static void BringHeaderFront() @@ -938,7 +1115,6 @@ public static class WatchCaption Front(_taskText); Front(_sexIcon); Front(_favoriteBtn); - Front(_historyBtn); } /// Harness: rebuild the dossier peek column after activity injects. @@ -955,7 +1131,7 @@ public static class WatchCaption } /// - /// Harness: force JobLabel (and optional watch reason) onto the focused dossier reason row. + /// Harness: force JobLabel onto the nametag identity tag (and optional orange reason beat). /// public static bool ForceJobLabelOnFocus(string jobLabel, string reasonOverride = "") { @@ -968,7 +1144,8 @@ public static class WatchCaption UnitDossier.HarnessReasonOverride = reasonOverride ?? ""; SetFromActor(MoveCamera._focus_unit); return _current != null - && (!string.IsNullOrEmpty(_current.JobLabel) || !string.IsNullOrEmpty(_current.ReasonLine)); + && !string.IsNullOrEmpty(_current.JobLabel) + && (_current.Headline ?? "").IndexOf(_current.JobLabel, System.StringComparison.OrdinalIgnoreCase) >= 0; } public static void ClearHarnessJobOverrides() @@ -999,19 +1176,13 @@ public static class WatchCaption bool hasBody = _current != null; bool hasTask = _taskText != null && _taskText.gameObject.activeSelf; bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf; - int traits = 0; - if (_traitsRow != null && _traitsRow.activeSelf) - { - for (int i = 0; i < _traitSlots.Length; i++) - { - if (_traitSlots[i] != null && _traitSlots[i].Root != null && _traitSlots[i].Root.activeSelf) - { - traits++; - } - } - } - - Relayout(hasBody, traits, hasTask, hasReason, shown); + Relayout( + hasBody, + CountActiveTraitSlots(), + CountActiveStatusSlots(), + hasTask, + hasReason, + shown); } private static void ApplyVisual(Actor actor, UnitDossier dossier) @@ -1022,6 +1193,7 @@ public static class WatchCaption return; } + _headlineLocked = false; bool hasLive = actor != null && actor.isAlive() && dossier != null @@ -1105,60 +1277,9 @@ public static class WatchCaption bool hasTask = dossier != null && !string.IsNullOrEmpty(dossier.TaskText); ApplyTaskChip(hasTask ? dossier.TaskText : ""); - int traitCount = 0; - LastTraitsPreview = ""; - if (_traitsRow != null) - { - if (dossier != null && dossier.TopTraits.Count > 0) - { - _traitsRow.SetActive(true); - System.Text.StringBuilder traitPreview = new System.Text.StringBuilder(); - for (int i = 0; i < _traitSlots.Length; i++) - { - TraitSlot slot = _traitSlots[i]; - if (slot == null || slot.Root == null) - { - continue; - } - - if (i < dossier.TopTraits.Count) - { - UnitDossier.TraitChip chip = dossier.TopTraits[i]; - slot.Root.SetActive(true); - Sprite traitSprite = chip != null ? HudIcons.FromTrait(chip.Trait) : null; - if (traitSprite == null && chip != null && !string.IsNullOrEmpty(chip.Id)) - { - traitSprite = HudIcons.FromUiIcon(chip.Id) ?? HudIcons.FromUiIcon("icon" + chip.Id); - } - - HudIcons.Apply(slot.Icon, traitSprite); - string name = chip.Name ?? ""; - if (slot.Label != null) - { - slot.Label.text = name; - } - - if (traitPreview.Length > 0) - { - traitPreview.Append(", "); - } - - traitPreview.Append(name); - traitCount++; - } - else - { - slot.Root.SetActive(false); - } - } - - LastTraitsPreview = traitPreview.ToString(); - } - else - { - _traitsRow.SetActive(false); - } - } + int traitCount = ApplyTraitChips(dossier); + int statusCount = ApplyStatusChips(dossier); + _lastStatusesFingerprint = StatusFingerprint(dossier); int historyCount = 0; if (dossier != null) @@ -1189,10 +1310,136 @@ public static class WatchCaption _reasonText.gameObject.SetActive(hasReason); } - Relayout(hasBody, traitCount, hasTask, hasReason, historyCount); + Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount); RefreshFavoriteVisual(hasLive ? actor : null); } + private static int ApplyTraitChips(UnitDossier dossier) + { + int traitCount = 0; + LastTraitsPreview = ""; + if (_traitsRow == null) + { + return 0; + } + + if (dossier == null || dossier.TopTraits.Count <= 0) + { + _traitsRow.SetActive(false); + return 0; + } + + _traitsRow.SetActive(true); + System.Text.StringBuilder traitPreview = new System.Text.StringBuilder(); + for (int i = 0; i < _traitSlots.Length; i++) + { + TraitSlot slot = _traitSlots[i]; + if (slot == null || slot.Root == null) + { + continue; + } + + if (i < dossier.TopTraits.Count) + { + UnitDossier.TraitChip chip = dossier.TopTraits[i]; + slot.Root.SetActive(true); + Sprite traitSprite = chip != null ? HudIcons.FromTrait(chip.Trait) : null; + if (traitSprite == null && chip != null && !string.IsNullOrEmpty(chip.Id)) + { + traitSprite = HudIcons.FromUiIcon(chip.Id) ?? HudIcons.FromUiIcon("icon" + chip.Id); + } + + HudIcons.Apply(slot.Icon, traitSprite); + string name = chip != null ? (chip.Name ?? "") : ""; + if (slot.Label != null) + { + slot.Label.text = name; + } + + if (traitPreview.Length > 0) + { + traitPreview.Append(", "); + } + + traitPreview.Append(name); + traitCount++; + } + else + { + slot.Root.SetActive(false); + } + } + + LastTraitsPreview = traitPreview.ToString(); + return traitCount; + } + + private static int ApplyStatusChips(UnitDossier dossier) + { + int statusCount = 0; + LastStatusesPreview = ""; + if (_statusesRow == null) + { + return 0; + } + + if (dossier == null || dossier.TopStatuses == null || dossier.TopStatuses.Count <= 0) + { + _statusesRow.SetActive(false); + for (int i = 0; i < _statusSlots.Length; i++) + { + if (_statusSlots[i]?.Root != null) + { + _statusSlots[i].Root.SetActive(false); + } + } + + return 0; + } + + _statusesRow.SetActive(true); + System.Text.StringBuilder preview = new System.Text.StringBuilder(); + for (int i = 0; i < _statusSlots.Length; i++) + { + TraitSlot slot = _statusSlots[i]; + if (slot == null || slot.Root == null) + { + continue; + } + + if (i < dossier.TopStatuses.Count) + { + UnitDossier.StatusChip chip = dossier.TopStatuses[i]; + slot.Root.SetActive(true); + Sprite sprite = chip != null + ? (HudIcons.FromStatus(chip.Status) ?? HudIcons.FromStatusId(chip.Id)) + : null; + HudIcons.Apply(slot.Icon, sprite); + string name = chip != null ? (chip.Name ?? "") : ""; + if (slot.Label != null) + { + slot.Label.text = name; + slot.Label.color = StatusNameColor; + } + + if (preview.Length > 0) + { + preview.Append(", "); + } + + preview.Append(name); + statusCount++; + } + else + { + slot.Root.SetActive(false); + } + } + + LastStatusesPreview = preview.ToString(); + return statusCount; + } + private static int FillHistory(long unitId) { // Activity first (prominent), then Chronicle Life fills remaining peek slots. @@ -1582,14 +1829,20 @@ public static class WatchCaption return needed; } - private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount) + private static void Relayout( + bool hasBody, + int traitCount, + int statusCount, + bool hasTask, + bool hasReason, + int historyCount) { float headerW = MeasureHeaderWidth(hasTask); float traitsW = 0f; - bool traitsBeside = hasBody && historyCount <= 0 && traitCount > 0; + // Traits beside avatar only when history and statuses leave the body-right slot free. + bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0; if (traitCount > 0 && !traitsBeside) { - // Traits row spans panel; width contribution is handled after panel size. traitsW = HistoryColMinW; } else if (traitsBeside) @@ -1658,6 +1911,12 @@ public static class WatchCaption } } + if (statusCount > 0) + { + PlaceStatuses(y, statusCount); + y += StatusesH + Gap; + } + if (traitCount > 0 && !traitsBeside) { PlaceTraits(y, traitCount); @@ -1687,11 +1946,13 @@ public static class WatchCaption if (!LastLayoutOk) { LogService.LogInfo( - $"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} hist={historyCount} headerW={headerW} histFill={LastHistoryFillsBody} histW={_historyColW}"); + $"[IdleSpectator][CAPTION][LAYOUT_BAD] size={LastPanelSize} body={hasBody} traits={traitCount} statuses={statusCount} hist={historyCount} headerW={headerW} histFill={LastHistoryFillsBody} histW={_historyColW}"); } } - /// Left-pack nametag on the root: [species] Name [lv]n [task] [sex]. + /// + /// Nametag: left-pack [species] Name [lv]n [task]; right-pin [sex] [★] to the panel edge. + /// private static float PlaceHeader(float yFromTop, bool hasTask) { float x = PadX; @@ -1744,18 +2005,17 @@ public static class WatchCaption } } + // Sex + favorite always hug the dossier's top-right (right-justified). + float right = Mathf.Max(_panelWidth, LiveMax + PadX * 2f) - PadX; + PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop); + right -= BtnSize; if (_sexIcon != null && _sexIcon.gameObject.activeSelf) { - PlaceLeftChip(_sexIcon.rectTransform, x, yFromTop + 2f, SexSize, SexSize); - x += SexSize; + right -= 2f; + PlaceLeftChip(_sexIcon.rectTransform, right - SexSize, yFromTop + 2f, SexSize, SexSize); + right -= SexSize; } - x += 6f; - PlaceHeaderButton(_favoriteBtn, x, yFromTop); - x += BtnSize + 2f; - PlaceHeaderButton(_historyBtn, x, yFromTop); - x += BtnSize; - RefreshFavoriteVisual(_boundActor); BringHeaderFront(); @@ -1786,12 +2046,15 @@ public static class WatchCaption x += TaskLabelW + 4f; } + // Reserve a gap + right cluster so sex/favorite can pin to the panel edge. + const float minGap = 8f; + float rightCluster = BtnSize; if (_sexIcon != null && _sexIcon.gameObject.activeSelf) { - x += SexSize; + rightCluster += 2f + SexSize; } - x += 6f + BtnSize + 2f + BtnSize; + x += minGap + rightCluster; return Mathf.Max(0f, x - PadX); } @@ -2077,6 +2340,40 @@ public static class WatchCaption } } + private static float PlaceStatuses(float yFromTop, int statusCount) + { + if (_statusesRow == null) + { + return 0f; + } + + RectTransform row = _statusesRow.GetComponent(); + row.anchorMin = new Vector2(0f, 1f); + row.anchorMax = new Vector2(1f, 1f); + row.pivot = new Vector2(0.5f, 1f); + row.offsetMin = new Vector2(PadX, -(yFromTop + StatusesH)); + row.offsetMax = new Vector2(-PadX, -yFromTop); + + float x = 0f; + for (int i = 0; i < _statusSlots.Length; i++) + { + TraitSlot slot = _statusSlots[i]; + if (slot == null || slot.Root == null || !slot.Root.activeSelf) + { + continue; + } + + float labelW = slot.Label != null + ? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f) + : 28f; + float slotW = TraitIcon + 2f + labelW; + PlaceTraitSlot(slot, x, 0f, slotW, StatusesH); + x += slotW + 3f; + } + + return x > 0f ? x - 3f : 0f; + } + private static float PlaceTraits(float yFromTop, int traitCount) { if (_traitsRow == null) @@ -2200,6 +2497,8 @@ public static class WatchCaption return LineInside(_nameText.GetComponent(), panelHeight) && (_reasonText == null || !_reasonText.gameObject.activeSelf || LineInside(_reasonText.GetComponent(), panelHeight)) + && (_statusesRow == null || !_statusesRow.activeSelf + || LineInside(_statusesRow.GetComponent(), panelHeight)) && (_traitsRow == null || !_traitsRow.activeSelf || LineInside(_traitsRow.GetComponent(), panelHeight)) && (_historyCol == null || !_historyCol.activeSelf @@ -2249,13 +2548,15 @@ public static class WatchCaption { if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform || _historyCol == null - || _historyBtn == null + || _statusesRow == null + || _favoriteBtn == null + || _root.transform.Find("HistoryBtn") != null || _root.transform.Find("HistoryScroll") != null)) { - // Stale HUD from an older build (nested nametag row / non-scroll history) - rebuild. + // Stale HUD from an older build (books button / nested history) - rebuild. try { - Object.Destroy(_root); + UnityEngine.Object.Destroy(_root); } catch { @@ -2273,13 +2574,12 @@ public static class WatchCaption _taskIcon = null; _taskText = null; _traitsRow = null; + _statusesRow = null; _historyCol = null; _activityBoxBg = null; _lifeSep = null; _favoriteBtn = null; _favoriteIcon = null; - _historyBtn = null; - _historyIcon = null; DossierAvatar.ResetHost(); } @@ -2347,6 +2647,23 @@ public static class WatchCaption slotGo.SetActive(false); } + _statusesRow = new GameObject("StatusesRow", typeof(RectTransform)); + _statusesRow.transform.SetParent(_root.transform, false); + for (int i = 0; i < _statusSlots.Length; i++) + { + GameObject slotGo = new GameObject("Status" + i, typeof(RectTransform)); + slotGo.transform.SetParent(_statusesRow.transform, false); + Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon); + Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8); + label.color = StatusNameColor; + label.alignment = TextAnchor.MiddleLeft; + label.horizontalOverflow = HorizontalWrapMode.Overflow; + label.resizeTextMinSize = 6; + label.resizeTextMaxSize = 8; + _statusSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label }; + slotGo.SetActive(false); + } + _traitsRow = new GameObject("TraitsRow", typeof(RectTransform)); _traitsRow.transform.SetParent(_root.transform, false); for (int i = 0; i < _traitSlots.Length; i++) @@ -2397,28 +2714,23 @@ public static class WatchCaption DossierAvatar.SetActive(false); _historyCol.SetActive(false); + _statusesRow.SetActive(false); _traitsRow.SetActive(false); _reasonText.gameObject.SetActive(false); _levelIcon.gameObject.SetActive(false); _levelValue.gameObject.SetActive(false); _taskIcon.gameObject.SetActive(false); _taskText.gameObject.SetActive(false); - Relayout(false, 0, false, false, 0); + Relayout(false, 0, 0, false, false, 0); _root.SetActive(false); _visible = false; - LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag chips + mini history)"); + LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits)"); } private static void BuildHeaderButtons() { _favoriteBtn = BuildIconButton(_root.transform, "FavoriteBtn", HudIcons.Favorite(), ToggleFavorite); _favoriteIcon = _favoriteBtn.transform.Find("Icon")?.GetComponent(); - _historyBtn = BuildIconButton(_root.transform, "HistoryBtn", HudIcons.ExpandHistory(), OpenFullHistoryInLore); - _historyIcon = _historyBtn.transform.Find("Icon")?.GetComponent(); - if (_historyIcon != null) - { - _historyIcon.color = new Color(0.75f, 0.78f, 0.85f, 1f); - } } private static Button BuildIconButton(Transform parent, string name, Sprite icon, UnityAction onClick) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index f762339..c4f62fc 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.87", - "description": "AFK Idle Spectator (I) + Lore (L). Durable combat pair ownership; affliction-only outbreaks; FixedDwell beat cooldown.", + "version": "0.25.93", + "description": "AFK Idle Spectator (I) + Lore (L). Citizen job labels from live library discovery.", "GUID": "com.dazed.idlespectator" }