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_herbs → Herb Gatherer); everything else is Title Case id
+ /// (miner_deposit → Miner 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"
}