From d1be1d9f00f4fa2feb4a71cf535998d7c0104088 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 17 Jul 2026 12:36:24 -0500 Subject: [PATCH] Enhance citizen job handling by implementing live inventory checks and dynamic labeling. Introduce new status chips for dossiers and improve identity tag formatting. Update harness scenarios to validate job labels and statuses. Increment version to 0.25.93 in mod.json. --- .cursor/skills/idle-spectator-e2e/SKILL.md | 23 +- IdleSpectator/ActivityAssetCatalog.cs | 263 ++++++++++ IdleSpectator/ActivityInterestTable.cs | 13 +- IdleSpectator/ActivityProse.cs | 19 +- IdleSpectator/AgentHarness.cs | 118 ++++- IdleSpectator/ChronicleHud.cs | 50 +- IdleSpectator/HarnessScenarios.cs | 32 +- IdleSpectator/SpectatorMode.cs | 26 +- IdleSpectator/UnitDossier.cs | 291 +++++++++-- IdleSpectator/WatchCaption.cs | 552 ++++++++++++++++----- IdleSpectator/mod.json | 4 +- 11 files changed, 1198 insertions(+), 193 deletions(-) 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" }