diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md
index b58602f..7863956 100644
--- a/.cursor/skills/idle-spectator-e2e/SKILL.md
+++ b/.cursor/skills/idle-spectator-e2e/SKILL.md
@@ -212,7 +212,8 @@ 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`, `dossier_statuses_ok`, `citizen_job_labels_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`, `tooltip_library_ok`, `history_other_rows`, `lore_detail_is_other`, `reason_names_colored`, `fallen_killer_banner`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows`
+- `dossier_click_history_other`, `dossier_force_named_reason` - dossier row → Lore other; gold names in orange reason
- `snapshot`, `reset_counters`, `scenario`
## Process safety
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..446dad8
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,19 @@
+# Commit message generation (SCM sparkle button)
+
+When Cursor auto-generates a Git commit message for this repository:
+
+1. Write a short imperative subject line (why, ~72 chars).
+2. Add a blank line.
+3. Summarize the main changes as bullet points (2-6 bullets).
+4. Do not put everything in one line.
+5. Do not add co-author trailers or agent signatures.
+
+Example shape:
+
+```
+Fix Lore row clicks dropping camera follow.
+
+- Pause idle before focusing the browse unit
+- Assert camera follow matches Lore detail after row click
+- Leave status banners non-clickable
+```
diff --git a/IdleSpectator/ActivityInterestTable.cs b/IdleSpectator/ActivityInterestTable.cs
index 2dadee5..96f3cdb 100644
--- a/IdleSpectator/ActivityInterestTable.cs
+++ b/IdleSpectator/ActivityInterestTable.cs
@@ -232,6 +232,44 @@ public static class ActivityInterestTable
return isActor ? name : "";
}
+ /// Living interactee unit id when resolves (0 otherwise).
+ public static long ActorTargetId(Actor actor)
+ {
+ if (actor == null)
+ {
+ return 0;
+ }
+
+ try
+ {
+ if (actor.has_attack_target && actor.attack_target != null && actor.attack_target.isAlive()
+ && actor.attack_target.isActor())
+ {
+ Actor foe = actor.attack_target.a;
+ if (foe != null && foe.isAlive())
+ {
+ return EventFeedUtil.SafeId(foe);
+ }
+ }
+
+ if (actor.beh_actor_target != null && actor.beh_actor_target.isAlive()
+ && actor.beh_actor_target.isActor())
+ {
+ Actor other = actor.beh_actor_target.a;
+ if (other != null && other.isAlive())
+ {
+ return EventFeedUtil.SafeId(other);
+ }
+ }
+ }
+ catch
+ {
+ return 0;
+ }
+
+ return 0;
+ }
+
/// Building/tile/settlement label only - never an actor name.
public static string PlaceOrObjectLabel(Actor actor)
{
diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs
index 019365b..972025c 100644
--- a/IdleSpectator/ActivityLog.cs
+++ b/IdleSpectator/ActivityLog.cs
@@ -16,6 +16,8 @@ public sealed class ActivityEntry
{
public float CreatedAt;
public long SubjectId;
+ /// Other living/fallen unit named by this line (0 when none / unresolved).
+ public long RelatedId;
public ActivityKind Kind;
public string TaskId = "";
public string JobId = "";
@@ -419,7 +421,8 @@ public static class ActivityLog
string actionKey,
string rawFact,
string actorTarget = "",
- string placeHint = "")
+ string placeHint = "",
+ long relatedId = 0)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(actionKey))
{
@@ -504,6 +507,7 @@ public static class ActivityLog
{
CreatedAt = now,
SubjectId = id,
+ RelatedId = relatedId,
Kind = ActivityKind.ActionBeat,
TaskId = actionKey,
JobId = jobId,
@@ -519,7 +523,12 @@ public static class ActivityLog
///
/// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor.
///
- public static void NoteMilestone(Actor actor, string actionKey, string rawLine, string target = "")
+ public static void NoteMilestone(
+ Actor actor,
+ string actionKey,
+ string rawLine,
+ string target = "",
+ long relatedId = 0)
{
if (actor == null || string.IsNullOrEmpty(actionKey) || string.IsNullOrEmpty(rawLine))
{
@@ -612,6 +621,7 @@ public static class ActivityLog
{
CreatedAt = now,
SubjectId = id,
+ RelatedId = relatedId,
Kind = ActivityKind.ActionBeat,
TaskId = actionKey,
JobId = "",
@@ -673,7 +683,8 @@ public static class ActivityLog
actorName = "creature";
}
- ForceStatusNote(id, statusId.Trim(), gained, actorName);
+ ResolveStatusRelated(actor, out long relatedId, out string relatedName);
+ ForceStatusNote(id, statusId.Trim(), gained, actorName, relatedId, relatedName);
try
{
HappinessEventRouter.NoteStatusPhase(id, statusId.Trim());
@@ -693,12 +704,50 @@ public static class ActivityLog
}
}
+ ///
+ /// Related unit only when a happiness/relationship hook stamped the context bag
+ /// (e.g. becomeLoversWith → fell_in_love). Do not invent lover links for every status.
+ ///
+ private static void ResolveStatusRelated(Actor actor, out long relatedId, out string relatedName)
+ {
+ relatedId = 0;
+ relatedName = "";
+ if (actor == null)
+ {
+ return;
+ }
+
+ try
+ {
+ if (!HappinessContextBag.TryGetRelated(actor, out Actor bagRelated, out _)
+ || bagRelated == null
+ || !bagRelated.isAlive())
+ {
+ return;
+ }
+
+ relatedId = EventFeedUtil.SafeId(bagRelated);
+ relatedName = bagRelated.getName() ?? "";
+ if (string.IsNullOrEmpty(relatedName) && bagRelated.asset != null)
+ {
+ relatedName = bagRelated.asset.id ?? "";
+ }
+ }
+ catch
+ {
+ relatedId = 0;
+ relatedName = "";
+ }
+ }
+
/// Harness / forced path for status lines without a live transition.
public static bool ForceStatusNote(
long subjectId,
string statusId,
bool gained,
- string actorName = "")
+ string actorName = "",
+ long relatedId = 0,
+ string relatedName = "")
{
if (subjectId == 0 || string.IsNullOrEmpty(statusId))
{
@@ -709,9 +758,30 @@ public static class ActivityLog
string name = string.IsNullOrEmpty(actorName) ? "Someone" : actorName;
string phrase = ActivityStatusProse.Phrase(id, gained, out _);
string clause = LowerFirst(phrase);
+ if (gained
+ && !string.IsNullOrEmpty(relatedName)
+ && !string.IsNullOrEmpty(clause)
+ && clause.IndexOf(relatedName, System.StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ // Keep short status verbs readable when we know who they are about.
+ clause = clause + " with " + relatedName;
+ }
+
string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause;
- string displayRich = ActivityProse.ColorName(name)
- + (string.IsNullOrEmpty(clause) ? "" : " " + clause);
+ string displayRich;
+ if (!string.IsNullOrEmpty(relatedName)
+ && !string.IsNullOrEmpty(clause)
+ && clause.IndexOf(relatedName, System.StringComparison.Ordinal) >= 0)
+ {
+ displayRich = ActivityProse.ColorName(name) + " "
+ + clause.Replace(relatedName, ActivityProse.ColorName(relatedName));
+ }
+ else
+ {
+ displayRich = ActivityProse.ColorName(name)
+ + (string.IsNullOrEmpty(clause) ? "" : " " + clause);
+ }
+
string key = ActivityStatusProse.TaskKey(id, gained);
string fact = (gained ? "Gained " : "Lost ") + id;
@@ -732,15 +802,21 @@ public static class ActivityLog
}
}
+ if (relatedId == 0 && !string.IsNullOrEmpty(relatedName))
+ {
+ relatedId = ResolveLivingUnitIdByName(relatedName, subjectId);
+ }
+
RecordSample(key, isBeat: true);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
+ RelatedId = relatedId != subjectId ? relatedId : 0,
Kind = gained ? ActivityKind.StatusGain : ActivityKind.StatusLoss,
TaskId = key,
JobId = "",
- TargetLabel = id,
+ TargetLabel = !string.IsNullOrEmpty(relatedName) ? relatedName : id,
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
@@ -772,10 +848,17 @@ public static class ActivityLog
}
RecordSample(key, isBeat: true);
+ long relatedId = occ.RelatedId;
+ if (relatedId == 0 && !string.IsNullOrEmpty(occ.RelatedName))
+ {
+ relatedId = ResolveLivingUnitIdByName(occ.RelatedName, occ.SubjectId);
+ }
+
Append(occ.SubjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = occ.SubjectId,
+ RelatedId = relatedId != occ.SubjectId ? relatedId : 0,
Kind = ActivityKind.Happiness,
TaskId = key,
JobId = occ.Amount.ToString(),
@@ -1032,10 +1115,17 @@ public static class ActivityLog
idx,
out string display,
out string displayRich);
+ long relatedId = 0;
+ if (ctx != null && ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName))
+ {
+ relatedId = ResolveLivingUnitIdByName(ctx.TargetName, subjectId);
+ }
+
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
+ RelatedId = relatedId,
Kind = kind,
TaskId = taskOrAction,
JobId = ctx.JobId ?? "",
@@ -1047,6 +1137,45 @@ public static class ActivityLog
return true;
}
+ /// Living unit id with exact name match, or 0. Used by dossier row links.
+ public static long ResolveLivingUnitIdByName(string name, long excludeId)
+ {
+ if (string.IsNullOrEmpty(name) || World.world?.units == null)
+ {
+ return 0;
+ }
+
+ try
+ {
+ foreach (Actor unit in World.world.units)
+ {
+ if (unit == null || !unit.isAlive())
+ {
+ continue;
+ }
+
+ long id = EventFeedUtil.SafeId(unit);
+ if (id == 0 || id == excludeId)
+ {
+ continue;
+ }
+
+ string n = unit.getName();
+ if (!string.IsNullOrEmpty(n)
+ && string.Equals(n, name, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return id;
+ }
+ }
+ }
+ catch
+ {
+ return 0;
+ }
+
+ return 0;
+ }
+
private static void RecordSample(string key, bool isBeat)
{
if (_clearedAt < 0f)
diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs
index 429c5fc..30e8bb2 100644
--- a/IdleSpectator/ActivityProse.cs
+++ b/IdleSpectator/ActivityProse.cs
@@ -6,7 +6,11 @@ namespace IdleSpectator;
///
public static class ActivityProse
{
- public const string NameColorHex = "#F0C14A";
+ ///
+ /// Bright lemon for person names - must read clearly on dark dossier chrome and
+ /// against orange reason text (#F2B861).
+ ///
+ public const string NameColorHex = "#FFE566";
public static string ColorName(string name)
{
@@ -15,7 +19,76 @@ public static class ActivityProse
return "";
}
- return "" + name + "";
+ return "" + name + "";
+ }
+
+ ///
+ /// Wrap known person names in gold for orange reason beats (rest inherits ReasonColor).
+ /// Longer names first so partial overlaps do not corrupt replacements.
+ ///
+ public static string ColorizePersonNames(string plain, params string[] names)
+ {
+ if (string.IsNullOrEmpty(plain) || names == null || names.Length == 0)
+ {
+ return plain ?? "";
+ }
+
+ var unique = new System.Collections.Generic.List();
+ for (int i = 0; i < names.Length; i++)
+ {
+ string n = names[i];
+ if (string.IsNullOrEmpty(n))
+ {
+ continue;
+ }
+
+ bool seen = false;
+ for (int j = 0; j < unique.Count; j++)
+ {
+ if (string.Equals(unique[j], n, System.StringComparison.Ordinal))
+ {
+ seen = true;
+ break;
+ }
+ }
+
+ if (!seen)
+ {
+ unique.Add(n);
+ }
+ }
+
+ unique.Sort((a, b) => b.Length.CompareTo(a.Length));
+ string result = plain;
+ for (int i = 0; i < unique.Count; i++)
+ {
+ string n = unique[i];
+ // Skip if already wrapped from a prior pass.
+ if (result.IndexOf(NameColorHex + ">" + n + "", System.StringComparison.Ordinal) >= 0)
+ {
+ continue;
+ }
+
+ result = ReplaceNameLiteral(result, n, ColorName(n));
+ }
+
+ return result;
+ }
+
+ private static string ReplaceNameLiteral(string haystack, string name, string rich)
+ {
+ if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(name))
+ {
+ return haystack;
+ }
+
+ int idx = haystack.IndexOf(name, System.StringComparison.Ordinal);
+ if (idx < 0)
+ {
+ return haystack;
+ }
+
+ return haystack.Substring(0, idx) + rich + haystack.Substring(idx + name.Length);
}
public static void Format(
diff --git a/IdleSpectator/ActorActivityPatches.cs b/IdleSpectator/ActorActivityPatches.cs
index beae0d0..ba91eaa 100644
--- a/IdleSpectator/ActorActivityPatches.cs
+++ b/IdleSpectator/ActorActivityPatches.cs
@@ -92,7 +92,8 @@ public static class ActorActivityPatches
pActor,
"BehAttackActorHuntingTarget",
string.IsNullOrEmpty(actorTarget) ? "Hunts prey" : "Hunts " + actorTarget,
- actorTarget: actorTarget);
+ actorTarget: actorTarget,
+ relatedId: ActivityInterestTable.ActorTargetId(pActor));
}
[HarmonyPatch(typeof(BehCityActorRemoveFire), nameof(BehCityActorRemoveFire.execute))]
@@ -126,7 +127,8 @@ public static class ActorActivityPatches
pActor,
"BehTryToSocialize",
string.IsNullOrEmpty(actorTarget) ? "Tries to socialize" : "Tries to socialize with " + actorTarget,
- actorTarget: actorTarget);
+ actorTarget: actorTarget,
+ relatedId: ActivityInterestTable.ActorTargetId(pActor));
}
[HarmonyPatch(typeof(BehPlantCrops), nameof(BehPlantCrops.execute))]
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index fdf032c..307aadc 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -412,6 +412,7 @@ public static class AgentHarness
bool ok = Chronicle.ForceLifeMilestoneOnFocus(kind, other);
if (ok)
{
+ WatchCaption.ForceRefreshHistory();
_cmdOk++;
}
else
@@ -421,7 +422,7 @@ public static class AgentHarness
long id = Chronicle.CurrentHistorySubjectId();
Emit(cmd, ok, detail:
- $"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)}");
+ $"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)} otherRows={WatchCaption.CountHistoryOtherRows()}");
break;
}
@@ -569,6 +570,124 @@ public static class AgentHarness
break;
}
+ case "dossier_force_named_reason":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ string name = "";
+ try
+ {
+ name = focus != null ? (focus.getName() ?? "") : "";
+ }
+ catch
+ {
+ name = "";
+ }
+
+ string clause = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "is fighting";
+ if (string.IsNullOrEmpty(name))
+ {
+ _cmdFail++;
+ Emit(cmd, false, detail: "no_focus_name");
+ break;
+ }
+
+ // Optional related: label/expect name, else remembered happiness partner.
+ string relatedName = !string.IsNullOrEmpty(cmd.expect)
+ ? cmd.expect.Trim()
+ : (!string.IsNullOrEmpty(cmd.label) ? cmd.label.Trim() : "");
+ long relatedId = 0;
+ Actor relatedActor = null;
+ if (string.Equals(relatedName, "auto", System.StringComparison.OrdinalIgnoreCase)
+ || string.IsNullOrEmpty(relatedName))
+ {
+ relatedActor = _happinessPartner != null && _happinessPartner.isAlive()
+ ? _happinessPartner
+ : null;
+ if (relatedActor != null)
+ {
+ relatedId = EventFeedUtil.SafeId(relatedActor);
+ relatedName = SafeName(relatedActor);
+ }
+ else
+ {
+ relatedName = "";
+ }
+ }
+ else
+ {
+ relatedId = ActivityLog.ResolveLivingUnitIdByName(
+ relatedName,
+ EventFeedUtil.SafeId(focus));
+ }
+
+ string reason = name + " " + clause.Trim();
+ if (!string.IsNullOrEmpty(relatedName)
+ && reason.IndexOf(relatedName, System.StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ reason = reason + " " + relatedName;
+ }
+
+ bool ok = WatchCaption.ForceJobLabelOnFocus(
+ !string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "farmer",
+ reason,
+ relatedId,
+ relatedName);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail:
+ $"reason='{WatchCaption.Current?.ReasonLine}' relatedId={relatedId} clickable={WatchCaption.ReasonOtherClickable}");
+ break;
+ }
+
+ case "dossier_click_reason_other":
+ {
+ bool ok = WatchCaption.ClickReasonOther(out long otherId);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail: $"clicked={ok} otherId={otherId} lore={ChronicleHud.DetailUnitId}");
+ break;
+ }
+
+ case "dossier_click_history_other":
+ {
+ WatchCaption.ForceRefreshHistory();
+ bool ok = WatchCaption.ClickFirstHistoryOtherRow(out long otherId);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail: $"clicked={ok} otherId={otherId} otherRows={WatchCaption.CountHistoryOtherRows()} lore={ChronicleHud.DetailUnitId}");
+ break;
+ }
+
case "dossier_clear_job":
{
WatchCaption.ClearHarnessJobOverrides();
@@ -684,6 +803,7 @@ public static class AgentHarness
}
bool expectBlocked = (cmd.expect ?? "").IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0;
+ bool optional = (cmd.expect ?? "").IndexOf("optional", StringComparison.OrdinalIgnoreCase) >= 0;
bool alive = false;
try
{
@@ -695,7 +815,19 @@ public static class AgentHarness
}
bool applied = alive && StatusGameApi.TryAddStatus(focus, statusId, timer);
+ // Some units briefly refuse a status (immunity / race); one retry covers that class.
+ if (!applied && alive && !expectBlocked)
+ {
+ applied = StatusGameApi.TryAddStatus(focus, statusId, timer);
+ }
+
bool ok = expectBlocked ? !applied : applied;
+ if (optional)
+ {
+ // Seed-only second statuses: dossier only needs one chip for the assert.
+ ok = true;
+ }
+
if (ok)
{
_cmdOk++;
@@ -710,7 +842,7 @@ public static class AgentHarness
cmd,
ok,
detail:
- $"status={statusId} applied={applied} expectBlocked={expectBlocked} alive={alive} "
+ $"status={statusId} applied={applied} expectBlocked={expectBlocked} optional={optional} alive={alive} "
+ $"gains={ActivityLog.StatusGainsSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear} "
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
break;
@@ -5942,6 +6074,139 @@ public static class AgentHarness
detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)} reason='{dossier?.ReasonLine}'";
break;
}
+ case "tooltip_library_ok":
+ {
+ pass = DossierAssetTips.RunTooltipInventoryAudit(HarnessDir(), out string tipDetail);
+ detail = tipDetail;
+ break;
+ }
+ case "history_other_rows":
+ {
+ WatchCaption.ForceRefreshHistory();
+ int have = WatchCaption.CountHistoryOtherRows();
+ int want = 1;
+ if (!string.IsNullOrEmpty(cmd.value)
+ && int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
+ {
+ want = parsed;
+ }
+
+ string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
+ pass = cmp == "exact" ? have == want : have >= want;
+ long subjectId = WatchCaption.CurrentUnitId;
+ if (subjectId == 0)
+ {
+ subjectId = ResolveActivitySubjectId();
+ }
+
+ int withRelated = 0;
+ var bits = new System.Text.StringBuilder();
+ IReadOnlyList ring = ActivityLog.LatestForSubject(subjectId, 8);
+ for (int i = 0; i < ring.Count; i++)
+ {
+ ActivityEntry e = ring[i];
+ if (e == null)
+ {
+ continue;
+ }
+
+ if (e.RelatedId != 0 && e.RelatedId != subjectId)
+ {
+ withRelated++;
+ }
+
+ if (bits.Length > 0)
+ {
+ bits.Append(';');
+ }
+
+ bits.Append(e.TaskId ?? "")
+ .Append(':')
+ .Append(e.RelatedId)
+ .Append(':')
+ .Append(e.TargetLabel ?? "");
+ }
+
+ detail =
+ $"otherRows={have} want={want} cmp={cmp} subject={subjectId} visible={WatchCaption.Visible} "
+ + $"ringRelated={withRelated} ring=[{bits}] hist='{WatchCaption.LastHistoryJoined}'";
+ break;
+ }
+ case "lore_detail_is_other":
+ {
+ long loreId = ChronicleHud.DetailUnitId;
+ long dossierId = WatchCaption.CurrentUnitId;
+ long cameraId = 0;
+ bool cameraFollow = false;
+ try
+ {
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ cameraId = MoveCamera._focus_unit.getID();
+ cameraFollow = MoveCamera.isCameraFollowingUnit(MoveCamera._focus_unit);
+ }
+ }
+ catch
+ {
+ cameraId = 0;
+ cameraFollow = false;
+ }
+
+ bool fallenKiller = (Chronicle.LastFallenFocusDetail ?? "")
+ .StartsWith("killer:", System.StringComparison.Ordinal);
+ // After row-click OpenUnitHistory(other): Lore + dossier + camera on that living unit.
+ bool dossierOk = loreId != 0 && loreId == dossierId;
+ bool cameraOk = fallenKiller
+ || (cameraId != 0 && cameraId == loreId && cameraFollow);
+ pass = dossierOk && cameraOk;
+ detail =
+ $"loreId={loreId} dossierId={dossierId} cameraId={cameraId} cameraFollow={cameraFollow} "
+ + $"follow={ChronicleHud.FollowFocus} fallenKiller={fallenKiller}";
+ break;
+ }
+ case "reason_names_colored":
+ {
+ string reason = WatchCaption.Current?.ReasonLine ?? "";
+ bool hasColor = reason.IndexOf(ActivityProse.NameColorHex, System.StringComparison.OrdinalIgnoreCase) >= 0
+ || reason.IndexOf("= 0;
+ pass = hasColor && !string.IsNullOrEmpty(reason);
+ detail = $"colored={hasColor} reason='{reason}'";
+ break;
+ }
+ case "reason_other_clickable":
+ {
+ bool want = string.IsNullOrEmpty(cmd.value)
+ || !string.Equals(cmd.value.Trim(), "false", System.StringComparison.OrdinalIgnoreCase);
+ bool have = WatchCaption.ReasonOtherClickable;
+ pass = have == want;
+ detail =
+ $"clickable={have} want={want} relatedId={WatchCaption.Current?.ReasonRelatedId ?? 0} "
+ + $"reason='{WatchCaption.Current?.ReasonLine}'";
+ break;
+ }
+ case "fallen_killer_banner":
+ {
+ string banner = WatchCaption.LastCaptionText ?? "";
+ string reason = "";
+ try
+ {
+ // Banner is shown on reason text while status banner is active.
+ reason = WatchCaption.Current?.ReasonLine ?? "";
+ }
+ catch
+ {
+ reason = "";
+ }
+
+ string hay = (banner + " " + reason).ToLowerInvariant();
+ bool has = hay.IndexOf("camera on killer", System.StringComparison.Ordinal) >= 0;
+ bool killerFocus = (Chronicle.LastFallenFocusDetail ?? "")
+ .StartsWith("killer:", System.StringComparison.Ordinal);
+ pass = killerFocus && has;
+ detail =
+ $"fallenFocus={Chronicle.LastFallenFocusDetail} bannerHit={has} caption='{banner}'";
+ break;
+ }
case "citizen_job_labels_ok":
{
// Full live citizen_job_library: non-empty Title Case labels; resource-role
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index eeb09a0..46b012a 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -1227,7 +1227,8 @@ public static class Chronicle
string victimName = SafeName(victim);
string line = $"Killed {victimName} ({SpeciesOf(victim)})";
- ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName);
+ long victimId = EventFeedUtil.SafeId(victim);
+ ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName, victimId);
try
{
HappinessEventRouter.NoteCanonicalMilestone(killer.getID(), "milestone_kill");
@@ -1276,7 +1277,8 @@ public static class Chronicle
string line = FormatDeathCause(victim, attackType, killer);
string killerName = killer != null ? SafeName(killer) : "";
- ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName);
+ long killerId = EventFeedUtil.SafeId(killer);
+ ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName, killerId);
if (ModSettings.ChronicleEnabled)
{
AppendHistory(ChronicleKind.Death, victim, killer, line, attackType);
@@ -1302,8 +1304,8 @@ public static class Chronicle
string nameB = SafeName(b);
string lineA = $"Became lovers with {nameB}";
string lineB = $"Became lovers with {nameA}";
- ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB);
- ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA);
+ ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB, EventFeedUtil.SafeId(b));
+ ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA, EventFeedUtil.SafeId(a));
try
{
HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_lover");
@@ -1348,8 +1350,8 @@ public static class Chronicle
string nameB = SafeName(b);
string lineA = $"Befriended {nameB}";
string lineB = $"Befriended {nameA}";
- ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB);
- ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA);
+ ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB, EventFeedUtil.SafeId(b));
+ ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA, EventFeedUtil.SafeId(a));
try
{
HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_friend");
@@ -1931,6 +1933,37 @@ public static class Chronicle
/// Harness: world position used for the last place-style fallen focus.
public static Vector3 LastFallenFocusPosition { get; private set; } = Vector3.zero;
+ ///
+ /// Pause banner after fallen Lore open - must say when camera is on the killer.
+ ///
+ public static string FallenPauseBanner()
+ {
+ string detail = LastFallenFocusDetail ?? "";
+ if (detail.StartsWith("killer:", System.StringComparison.Ordinal))
+ {
+ string killerLabel = "";
+ if (detail.Length > 7
+ && long.TryParse(detail.Substring(7), out long killerId)
+ && killerId != 0)
+ {
+ Actor killer = FindUnitById(killerId);
+ if (killer != null)
+ {
+ killerLabel = SafeName(killer);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(killerLabel))
+ {
+ return "Paused (viewing fallen - camera on killer " + killerLabel + ")";
+ }
+
+ return "Paused (viewing fallen - camera on killer)";
+ }
+
+ return "Paused (viewing fallen)";
+ }
+
/// Snapshot living dossier just before die() - for Fallen archive UI.
public static void CaptureFinalDossier(Actor victim)
{
diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs
index a773e0c..82cdbdf 100644
--- a/IdleSpectator/ChronicleHud.cs
+++ b/IdleSpectator/ChronicleHud.cs
@@ -759,6 +759,8 @@ public static class ChronicleHud
_followFocus = followFocus;
_detailPane = DetailPane.Life;
+ // Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
+ // would instantly drop the camera off the browse subject.
if (pauseIdle && SpectatorMode.Active)
{
_pausedIdleForLore = true;
@@ -771,22 +773,18 @@ public static class ChronicleHud
}
bool live = Chronicle.FocusCameraForBrowse(unitId);
- _tab = live ? LoreTab.Living : LoreTab.Fallen;
- if (live)
+ if (!live)
{
- WatchCaption.PinWhilePaused();
- }
- else
- {
- // Dead / missing: keep dossier if any; pan to killer or death place.
- WatchCaption.PinWhilePaused();
Chronicle.FocusFallenSubject(unitId);
}
+ _tab = live ? LoreTab.Living : LoreTab.Fallen;
+ WatchCaption.PinWhilePaused();
+
if (pauseIdle)
{
WatchCaption.ShowStatusBanner(
- live ? "Paused (viewing history)" : "Paused (viewing fallen)");
+ live ? "Paused (viewing history)" : Chronicle.FallenPauseBanner());
}
SetVisible(true);
diff --git a/IdleSpectator/DossierAssetTips.cs b/IdleSpectator/DossierAssetTips.cs
new file mode 100644
index 0000000..9814598
--- /dev/null
+++ b/IdleSpectator/DossierAssetTips.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.UI;
+
+namespace IdleSpectator;
+
+///
+/// Vanilla trait/status tooltips for dossier chips.
+/// Tip types come from live (not invented keys).
+///
+public static class DossierAssetTips
+{
+ public const string TraitTipType = "trait";
+ public const string StatusTipType = "status";
+
+ private static HashSet _liveTipTypes;
+ private static int _liveTipTypesCount = -1;
+
+ public static List EnumerateLiveTooltipTypeIds()
+ {
+ var ids = new List();
+ try
+ {
+ TooltipLibrary lib = AssetManager.tooltips;
+ if (lib?.list == null)
+ {
+ return ids;
+ }
+
+ for (int i = 0; i < lib.list.Count; i++)
+ {
+ TooltipAsset 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;
+ }
+
+ public static bool HasLiveTooltipType(string tipType)
+ {
+ if (string.IsNullOrEmpty(tipType))
+ {
+ return false;
+ }
+
+ EnsureLiveTipTypes();
+ return _liveTipTypes != null && _liveTipTypes.Contains(tipType);
+ }
+
+ ///
+ /// Harness audit: dump live tooltip ids; require trait + status tip types exist.
+ ///
+ public static bool RunTooltipInventoryAudit(string outputDirectory, out string detail)
+ {
+ List ids = EnumerateLiveTooltipTypeIds();
+ var lines = new List(ids.Count + 1) { "id" };
+ for (int i = 0; i < ids.Count; i++)
+ {
+ lines.Add(ids[i]);
+ }
+
+ try
+ {
+ if (!string.IsNullOrEmpty(outputDirectory))
+ {
+ Directory.CreateDirectory(outputDirectory);
+ File.WriteAllLines(Path.Combine(outputDirectory, "tooltip-library-ids.tsv"), lines);
+ }
+ }
+ catch
+ {
+ // diagnostic dump only
+ }
+
+ bool hasTrait = HasLiveTooltipType(TraitTipType);
+ bool hasStatus = HasLiveTooltipType(StatusTipType);
+ bool pass = ids.Count > 0 && hasTrait && hasStatus;
+ detail = $"tips={ids.Count} trait={hasTrait} status={hasStatus}";
+ return pass;
+ }
+
+ public static void ShowTraitTip(GameObject host, ActorTrait trait)
+ {
+ if (host == null || trait == null || !HasLiveTooltipType(TraitTipType))
+ {
+ return;
+ }
+
+ try
+ {
+ if (!Config.tooltips_active)
+ {
+ return;
+ }
+
+ Tooltip.show(host, TraitTipType, new TooltipData { trait = trait });
+ }
+ catch
+ {
+ // Tip surface can be unavailable mid-load.
+ }
+ }
+
+ public static void ShowStatusTip(GameObject host, Actor actor, StatusAsset asset, string statusId)
+ {
+ if (host == null || !HasLiveTooltipType(StatusTipType))
+ {
+ return;
+ }
+
+ StatusAsset resolved = asset;
+ if (resolved == null && !string.IsNullOrEmpty(statusId))
+ {
+ resolved = ActivityAssetCatalog.TryGetStatusAsset(statusId);
+ }
+
+ if (resolved == null)
+ {
+ return;
+ }
+
+ Status live = FindLiveStatus(actor, resolved.id ?? statusId);
+ if (live == null)
+ {
+ // Vanilla status tip needs a Status instance (time bar + stats).
+ return;
+ }
+
+ try
+ {
+ if (!Config.tooltips_active)
+ {
+ return;
+ }
+
+ string locale = "";
+ string description = "";
+ try
+ {
+ locale = resolved.getLocaleID() ?? "";
+ description = resolved.getDescriptionID() ?? "";
+ }
+ catch
+ {
+ // fall through with empty locale keys - showStatus still uses status.asset stats
+ }
+
+ Tooltip.show(host, StatusTipType, new TooltipData
+ {
+ tip_name = locale,
+ tip_description = description,
+ status = live
+ });
+ }
+ catch
+ {
+ // Tip surface can be unavailable mid-load.
+ }
+ }
+
+ public static void HideTip()
+ {
+ try
+ {
+ Tooltip.hideTooltip();
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ private static Status FindLiveStatus(Actor actor, string statusId)
+ {
+ if (actor == null || string.IsNullOrEmpty(statusId))
+ {
+ return null;
+ }
+
+ try
+ {
+ if (!actor.isAlive())
+ {
+ return null;
+ }
+
+ foreach (Status status in actor.getStatuses())
+ {
+ if (status?.asset != null
+ && string.Equals(status.asset.id, statusId, StringComparison.Ordinal))
+ {
+ return status;
+ }
+ }
+ }
+ catch
+ {
+ return null;
+ }
+
+ return null;
+ }
+
+ private static void EnsureLiveTipTypes()
+ {
+ List ids = EnumerateLiveTooltipTypeIds();
+ if (_liveTipTypes != null && _liveTipTypesCount == ids.Count)
+ {
+ return;
+ }
+
+ _liveTipTypes = new HashSet(StringComparer.Ordinal);
+ for (int i = 0; i < ids.Count; i++)
+ {
+ if (!string.IsNullOrEmpty(ids[i]))
+ {
+ _liveTipTypes.Add(ids[i]);
+ }
+ }
+
+ _liveTipTypesCount = ids.Count;
+ }
+}
+
+/// Pointer hover on a dossier trait/status chip root.
+public sealed class DossierChipTip : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
+{
+ public enum Kind
+ {
+ Trait,
+ Status
+ }
+
+ public Kind ChipKind;
+ public string AssetId = "";
+ public ActorTrait Trait;
+ public StatusAsset Status;
+
+ public void BindTrait(ActorTrait trait, string id)
+ {
+ ChipKind = Kind.Trait;
+ Trait = trait;
+ Status = null;
+ AssetId = id ?? (trait != null ? trait.id : "");
+ EnsureHitTarget();
+ }
+
+ public void BindStatus(StatusAsset status, string id)
+ {
+ ChipKind = Kind.Status;
+ Status = status;
+ Trait = null;
+ AssetId = id ?? (status != null ? status.id : "");
+ EnsureHitTarget();
+ }
+
+ public void Clear()
+ {
+ Trait = null;
+ Status = null;
+ AssetId = "";
+ }
+
+ public void OnPointerEnter(PointerEventData eventData)
+ {
+ if (ChipKind == Kind.Trait)
+ {
+ ActorTrait trait = Trait;
+ if (trait == null && !string.IsNullOrEmpty(AssetId))
+ {
+ try
+ {
+ trait = AssetManager.traits?.get(AssetId);
+ }
+ catch
+ {
+ trait = null;
+ }
+ }
+
+ DossierAssetTips.ShowTraitTip(gameObject, trait);
+ return;
+ }
+
+ Actor actor = WatchCaption.BoundActor;
+ DossierAssetTips.ShowStatusTip(gameObject, actor, Status, AssetId);
+ }
+
+ public void OnPointerExit(PointerEventData eventData)
+ {
+ DossierAssetTips.HideTip();
+ }
+
+ private void OnDisable()
+ {
+ DossierAssetTips.HideTip();
+ }
+
+ private void EnsureHitTarget()
+ {
+ Image hit = GetComponent();
+ if (hit == null)
+ {
+ hit = gameObject.AddComponent();
+ }
+
+ hit.color = new Color(1f, 1f, 1f, 0.001f);
+ hit.raycastTarget = true;
+ }
+}
diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs
index 4bf9b2e..adc02db 100644
--- a/IdleSpectator/DossierAvatar.cs
+++ b/IdleSpectator/DossierAvatar.cs
@@ -18,6 +18,8 @@ public static class DossierAvatar
private static bool _usingVanilla;
private static bool _loggedMode;
private static long _shownActorId = -1;
+ /// Egg/baby/adult + species - same unit id can change visual form.
+ private static string _shownFormKey = "";
public static bool UsingVanilla => _usingVanilla;
@@ -48,6 +50,7 @@ public static class DossierAvatar
_usingVanilla = false;
_loggedMode = false;
_shownActorId = -1;
+ _shownFormKey = "";
}
public static void EnsureHost(Transform parent, float size)
@@ -117,9 +120,11 @@ public static class DossierAvatar
}
long id = actor.getID();
- if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf)
+ string formKey = ActorFormKey(actor);
+ if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf
+ && formKey == _shownFormKey)
{
- // Always clear species fallback - it can linger above the live portrait.
+ // Same unit + same life stage: only refresh the floor tile underfoot.
HideFallback();
try
{
@@ -157,8 +162,10 @@ public static class DossierAvatar
_instance.avatarLoader.avatarSize = 0.55f;
}
+ // Re-load avatar whenever form changes (egg → chick) even if unit id is unchanged.
_instance.show(actor);
_shownActorId = id;
+ _shownFormKey = formKey;
DisableInteractions(_instance.gameObject);
return;
}
@@ -166,17 +173,65 @@ public static class DossierAvatar
{
LogService.LogInfo("[IdleSpectator] Vanilla avatar show failed, using fallback: " + ex.Message);
FallbackToSprite(actor);
+ _shownActorId = id;
+ _shownFormKey = formKey;
return;
}
}
FallbackToSprite(actor);
_shownActorId = id;
+ _shownFormKey = formKey;
+ }
+
+ ///
+ /// Visual form key for the live portrait. Same actor id can change (egg → baby → adult).
+ ///
+ private static string ActorFormKey(Actor actor)
+ {
+ if (actor == null)
+ {
+ return "";
+ }
+
+ string species = "";
+ try
+ {
+ species = actor.asset != null ? (actor.asset.id ?? "") : "";
+ }
+ catch
+ {
+ species = "";
+ }
+
+ string stage = "adult";
+ try
+ {
+ if (actor.isEgg())
+ {
+ stage = "egg";
+ }
+ else if (actor.isBaby())
+ {
+ stage = "baby";
+ }
+ else if (!actor.isAdult())
+ {
+ stage = "young";
+ }
+ }
+ catch
+ {
+ stage = "unknown";
+ }
+
+ return species + "|" + stage;
}
public static void ClearActor()
{
_shownActorId = -1;
+ _shownFormKey = "";
HideVanillaInstance();
HideFallback();
}
diff --git a/IdleSpectator/FocusTooltipPatches.cs b/IdleSpectator/FocusTooltipPatches.cs
index 2e23000..76d6b37 100644
--- a/IdleSpectator/FocusTooltipPatches.cs
+++ b/IdleSpectator/FocusTooltipPatches.cs
@@ -26,7 +26,14 @@ internal static class FocusTooltipPatches
return true;
}
- // Dossier chrome (not the sprite): no tip.
+ // Trait/status chips: DossierChipTip owns the tip; skip gameplay actor tip / hide.
+ if (WatchCaption.IsPointerOverAssetChip())
+ {
+ __result = true;
+ return false;
+ }
+
+ // Dossier chrome (not the sprite / chips): no tip.
if (WatchCaption.IsPointerOverPanel())
{
Tooltip.hideTooltip();
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 037f196..0d705b2 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -296,8 +296,10 @@ internal static class HarnessScenarios
Step("hp32", "assert", expect: "activity_log_contains", value: "Ava"),
Step("hp33", "assert", expect: "activity_happiness_task_id", value: "happiness:death_child"),
- // Real lover grief path
- Step("hp40", "spawn", asset: "human"),
+ // Real lover grief path (+ dossier row → other Lore).
+ // Idle must be on *before* the bond: first spectator enable can ClearSession the ring.
+ Step("hp40", "spectator", value: "on"),
+ Step("hp40b", "spawn", asset: "human"),
Step("hp41", "happiness_remember_partner"),
Step("hp42", "spawn", asset: "human"),
Step("hp43", "focus", asset: "auto"),
@@ -306,6 +308,16 @@ internal static class HarnessScenarios
Step("hp46", "happiness_bond_lovers"),
Step("hp46b", "assert", expect: "activity_happiness_count", value: "2", label: "min"),
Step("hp46c", "assert", expect: "activity_log_contains", value: "love"),
+ Step("hp46d", "focus", asset: "auto"),
+ Step("hp46e", "wait", wait: 0.2f),
+ Step("hp46f", "assert", expect: "history_other_rows", value: "1", label: "min"),
+ Step("hp46g", "remember_focus"),
+ Step("hp46h", "dossier_click_history_other"),
+ Step("hp46i", "wait", wait: 0.2f),
+ Step("hp46j", "assert", expect: "lore_detail", value: "true"),
+ Step("hp46k", "assert", expect: "lore_detail_is_other"),
+ Step("hp46l", "lore_close"),
+ Step("hp46m", "spectator", value: "off"),
Step("hp47", "wait", value: "0.5"),
Step("hp48", "happiness_kill_partner"),
Step("hp49", "wait", value: "0.5"),
@@ -2145,7 +2157,8 @@ internal static class HarnessScenarios
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"),
+ // Optional second chip - some units refuse poison; cursed alone still gates the row.
+ Step("act16t", "status_apply", value: "poisoned", label: "20", expect: "optional"),
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"),
@@ -2180,6 +2193,42 @@ internal static class HarnessScenarios
Step("act20g", "chronicle_milestone", value: "lover"),
Step("act20h", "assert", expect: "activity_log_contains", value: "Became lovers"),
Step("act20i", "assert", expect: "chronicle_latest_contains", value: "Became lovers"),
+ // Whole-row other-person click → Lore biography (then restore original focus).
+ Step("act20j", "assert", expect: "tooltip_library_ok"),
+ Step("act20j2", "remember_focus"),
+ Step("act20k", "assert", expect: "history_other_rows", value: "1", label: "min"),
+ Step("act20l", "dossier_click_history_other"),
+ Step("act20m", "wait", wait: 0.3f),
+ Step("act20n", "assert", expect: "lore_detail", value: "true"),
+ Step("act20o", "assert", expect: "lore_detail_is_other"),
+ Step("act20p", "lore_close"),
+ Step("act20q", "spectator", value: "on"),
+ Step("act20r", "wait", wait: 0.2f),
+ // Return to a living subject so later activity-count asserts stay valid.
+ // Orange reason click → related unit Lore (same contract as history rows).
+ Step("act20r2", "spawn", asset: "human", count: 1),
+ Step("act20r2b", "happiness_remember_partner"),
+ Step("act20r3", "spawn", asset: "human", count: 1),
+ Step("act20r4", "pick_unit", asset: "human"),
+ Step("act20r5", "focus", asset: "auto"),
+ Step("act20r6", "wait", wait: 0.2f),
+ Step("act20s", "dossier_force_named_reason", value: "is fighting", expect: "auto"),
+ Step("act20t", "assert", expect: "reason_names_colored"),
+ Step("act20t2", "assert", expect: "reason_other_clickable"),
+ Step("act20t3", "remember_focus"),
+ Step("act20t4", "dossier_click_reason_other"),
+ Step("act20t5", "wait", wait: 0.2f),
+ Step("act20t6", "assert", expect: "lore_detail", value: "true"),
+ Step("act20t7", "assert", expect: "lore_detail_is_other"),
+ Step("act20t8", "lore_close"),
+ Step("act20t9", "spectator", value: "on"),
+ Step("act20t10", "focus", asset: "auto"),
+ Step("act20u", "dossier_clear_job"),
+ // Re-seed activity ring after the other-person Lore detour.
+ Step("act20v", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 2, tier: "beat"),
+ Step("act20w", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
+ Step("act20x", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat",
+ expect: "Barkley"),
Step("act21", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
Step("act22", "assert", expect: "dossier_history_not_contains", value: "Harness pollen"),
Step("act23", "assert", expect: "caption_layout_ok"),
@@ -2433,6 +2482,7 @@ internal static class HarnessScenarios
Step("ch18k11d", "assert", expect: "dossier_species", value: "skeleton"),
Step("ch18k11e", "assert", expect: "dossier_avatar_species"),
Step("ch18k11f", "assert", expect: "fallen_killer_camera"),
+ Step("ch18k11g", "assert", expect: "fallen_killer_banner"),
Step("ch18k12", "screenshot", value: "hud-lore-fallen-slain.png"),
Step("ch18k13", "wait", wait: 0.55f),
// Regression: fallen species portrait must not stick onto the next living dossier.
@@ -2490,6 +2540,8 @@ internal static class HarnessScenarios
Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"),
Step("ch18ft5", "assert", expect: "fallen_list_before", value: "Slainbone", label: "Oldbone"),
// Snapshot stays put until Refresh (new deaths do not auto-rebuild).
+ // Refresh first so live world deaths during the long scenario do not leave stale=true.
+ Step("ch18ft5a", "lore_refresh"),
Step("ch18ft5b", "assert", expect: "lore_list_stale", value: "false"),
Step(
"ch18ft5c",
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index 8d78b87..eec24b0 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -14,6 +14,8 @@ public sealed class UnitDossier
public string SpeciesId = "";
public string Headline = "";
public string ReasonLine = "";
+ /// Other unit named by the orange reason (0 when none).
+ public long ReasonRelatedId;
public string DetailLine = "";
public string CaptionText = "";
public string JobLabel = "";
@@ -62,10 +64,18 @@ public sealed class UnitDossier
/// Harness: optional watch-reason fragment used with .
public static string HarnessReasonOverride = "";
+ /// Harness: related unit for forced reason clicks / gold names.
+ public static long HarnessReasonRelatedId;
+
+ /// Harness: related display name for forced reason prose.
+ public static string HarnessReasonRelatedName = "";
+
public static void ClearHarnessOverrides()
{
HarnessJobLabelOverride = "";
HarnessReasonOverride = "";
+ HarnessReasonRelatedId = 0;
+ HarnessReasonRelatedName = "";
}
///
@@ -321,7 +331,23 @@ public sealed class UnitDossier
{
if (!string.IsNullOrEmpty(HarnessReasonOverride))
{
- return HarnessReasonOverride.Trim();
+ string plain = HarnessReasonOverride.Trim();
+ string subject = d != null ? (d.Name ?? "") : "";
+ if (string.IsNullOrEmpty(subject) && actor != null)
+ {
+ subject = EventFeedUtil.SafeName(actor);
+ }
+
+ string relatedName = HarnessReasonRelatedName ?? "";
+ if (d != null)
+ {
+ d.ReasonRelatedId = ResolveReasonRelatedId(
+ actor,
+ HarnessReasonRelatedId,
+ relatedName);
+ }
+
+ return ActivityProse.ColorizePersonNames(plain, subject, relatedName);
}
return OwnedEventReason(actor, d);
@@ -341,6 +367,11 @@ public sealed class UnitDossier
InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor);
if (scene == null)
{
+ if (dossier != null)
+ {
+ dossier.ReasonRelatedId = 0;
+ }
+
return "";
}
@@ -348,6 +379,11 @@ public sealed class UnitDossier
if (scene.LeadKind == InterestLeadKind.CharacterLed
&& !InterestScoring.IsCombatAction(scene))
{
+ if (dossier != null)
+ {
+ dossier.ReasonRelatedId = 0;
+ }
+
return "";
}
@@ -367,7 +403,51 @@ public sealed class UnitDossier
}
}
- return EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
+ string beat = EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
+ if (string.IsNullOrEmpty(beat))
+ {
+ d.ReasonRelatedId = 0;
+ return "";
+ }
+
+ // Gold names inside the orange reason row (same palette as history/activity).
+ string subjectName = d != null ? (d.Name ?? "") : "";
+ if (string.IsNullOrEmpty(subjectName))
+ {
+ subjectName = EventFeedUtil.SafeName(actor);
+ }
+
+ string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
+ long relatedId = scene.RelatedId != 0
+ ? scene.RelatedId
+ : EventFeedUtil.SafeId(scene.RelatedUnit);
+ d.ReasonRelatedId = ResolveReasonRelatedId(actor, relatedId, relatedName);
+ return ActivityProse.ColorizePersonNames(beat, subjectName, relatedName);
+ }
+
+ private static long ResolveReasonRelatedId(Actor subject, long relatedId, string relatedName)
+ {
+ long subjectId = 0;
+ try
+ {
+ subjectId = subject != null ? subject.getID() : 0;
+ }
+ catch
+ {
+ subjectId = 0;
+ }
+
+ if (relatedId != 0 && relatedId != subjectId)
+ {
+ return relatedId;
+ }
+
+ if (string.IsNullOrEmpty(relatedName))
+ {
+ return 0;
+ }
+
+ return ActivityLog.ResolveLivingUnitIdByName(relatedName, subjectId);
}
///
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index d9cea8f..b60b270 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -62,6 +62,7 @@ public static class WatchCaption
private static Image _sexIcon;
private static Text _nameText;
private static Text _reasonText;
+ private static long _reasonOtherId;
private static Image _levelIcon;
private static Text _levelValue;
@@ -103,6 +104,7 @@ public static class WatchCaption
public GameObject Root;
public Image Icon;
public Text Label;
+ public DossierChipTip Tip;
}
private sealed class HistorySlot
@@ -110,10 +112,16 @@ public static class WatchCaption
public GameObject Root;
public Image Icon;
public Text Label;
+ public Button Button;
+ public Image Hit;
+ public long OtherId;
}
public static UnitDossier Current => _current;
+ /// Living actor currently bound to the dossier (null for fallen archive).
+ public static Actor BoundActor => _boundActor;
+
public static string LastHeadline { get; private set; } = "";
public static string LastDetail { get; private set; } = "";
@@ -209,6 +217,45 @@ public static class WatchCaption
return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
}
+ /// True if the mouse is over a trait/status chip (vanilla tip host).
+ public static bool IsPointerOverAssetChip()
+ {
+ if (!_visible || _root == null || !_root.activeInHierarchy)
+ {
+ return false;
+ }
+
+ Vector2 mouse = Input.mousePosition;
+ for (int i = 0; i < _traitSlots.Length; i++)
+ {
+ if (IsPointerOverChipRoot(_traitSlots[i]?.Root, mouse))
+ {
+ return true;
+ }
+ }
+
+ for (int i = 0; i < _statusSlots.Length; i++)
+ {
+ if (IsPointerOverChipRoot(_statusSlots[i]?.Root, mouse))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsPointerOverChipRoot(GameObject root, Vector2 mouse)
+ {
+ if (root == null || !root.activeInHierarchy)
+ {
+ return false;
+ }
+
+ RectTransform rt = root.GetComponent();
+ return rt != null && RectTransformUtility.RectangleContainsScreenPoint(rt, mouse, null);
+ }
+
///
/// Favorite control (and the panel chrome around it).
/// Used so idle does not treat those clicks as "manual pause".
@@ -402,6 +449,7 @@ public static class WatchCaption
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.gameObject.SetActive(true);
+ WireReasonClick(0);
}
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
@@ -731,7 +779,30 @@ public static class WatchCaption
return;
}
- string next = UnitDossier.OwnedEventReason(actor, _current) ?? "";
+ // Honor harness reason override the same way FromActor/BuildStoryBeat does.
+ string next;
+ if (!string.IsNullOrEmpty(UnitDossier.HarnessReasonOverride))
+ {
+ string subject = _current.Name ?? "";
+ if (string.IsNullOrEmpty(subject))
+ {
+ subject = EventFeedUtil.SafeName(actor);
+ }
+
+ string relatedName = UnitDossier.HarnessReasonRelatedName ?? "";
+ _current.ReasonRelatedId = UnitDossier.HarnessReasonRelatedId != 0
+ ? UnitDossier.HarnessReasonRelatedId
+ : ActivityLog.ResolveLivingUnitIdByName(relatedName, _current.UnitId);
+ next = ActivityProse.ColorizePersonNames(
+ UnitDossier.HarnessReasonOverride.Trim(),
+ subject,
+ relatedName);
+ }
+ else
+ {
+ next = UnitDossier.OwnedEventReason(actor, _current) ?? "";
+ }
+
string prev = _current.ReasonLine ?? "";
if (next == prev)
{
@@ -745,8 +816,7 @@ public static class WatchCaption
bool hasReason = !string.IsNullOrEmpty(next);
if (_reasonText != null)
{
- _reasonText.text = hasReason ? next : "";
- _reasonText.gameObject.SetActive(hasReason);
+ ApplyReasonText(hasReason ? next : "", hasReason ? _current.ReasonRelatedId : 0);
}
bool hasTask = !string.IsNullOrEmpty(_current.TaskText);
@@ -1122,6 +1192,11 @@ public static class WatchCaption
{
_lastHistoryCount = -1;
_lastLiveHistFingerprint = int.MinValue;
+ if (_current == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ SetFromActor(MoveCamera._focus_unit);
+ }
+
if (!_visible || _current == null)
{
return;
@@ -1133,7 +1208,11 @@ public static class WatchCaption
///
/// Harness: force JobLabel onto the nametag identity tag (and optional orange reason beat).
///
- public static bool ForceJobLabelOnFocus(string jobLabel, string reasonOverride = "")
+ public static bool ForceJobLabelOnFocus(
+ string jobLabel,
+ string reasonOverride = "",
+ long reasonRelatedId = 0,
+ string reasonRelatedName = "")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
@@ -1142,6 +1221,8 @@ public static class WatchCaption
UnitDossier.HarnessJobLabelOverride = jobLabel ?? "";
UnitDossier.HarnessReasonOverride = reasonOverride ?? "";
+ UnitDossier.HarnessReasonRelatedId = reasonRelatedId;
+ UnitDossier.HarnessReasonRelatedName = reasonRelatedName ?? "";
SetFromActor(MoveCamera._focus_unit);
return _current != null
&& !string.IsNullOrEmpty(_current.JobLabel)
@@ -1303,17 +1384,82 @@ public static class WatchCaption
bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine);
if (_reasonText != null)
{
- _reasonText.text = hasReason ? dossier.ReasonLine : "";
- _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
- _reasonText.verticalOverflow = VerticalWrapMode.Overflow;
- _reasonText.resizeTextForBestFit = false;
- _reasonText.gameObject.SetActive(hasReason);
+ ApplyReasonText(
+ hasReason ? dossier.ReasonLine : "",
+ hasReason && dossier != null ? dossier.ReasonRelatedId : 0);
}
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
RefreshFavoriteVisual(hasLive ? actor : null);
}
+ /// Orange story beat with rich-text gold names; empty hides the row.
+ private static void ApplyReasonText(string reasonRichOrPlain, long otherId = 0)
+ {
+ if (_reasonText == null)
+ {
+ return;
+ }
+
+ bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain);
+ _reasonText.supportRichText = true;
+ _reasonText.color = ReasonColor;
+ _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
+ _reasonText.verticalOverflow = VerticalWrapMode.Overflow;
+ _reasonText.resizeTextForBestFit = false;
+ _reasonText.text = hasReason ? reasonRichOrPlain : "";
+ _reasonText.gameObject.SetActive(hasReason);
+ WireReasonClick(hasReason ? otherId : 0);
+ }
+
+ private static void WireReasonClick(long otherId)
+ {
+ long subjectId = CurrentUnitId;
+ _reasonOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
+ if (_reasonText == null)
+ {
+ return;
+ }
+
+ var entry = _reasonText.gameObject.GetComponent();
+ if (entry == null)
+ {
+ entry = _reasonText.gameObject.AddComponent();
+ }
+
+ entry.triggers.Clear();
+ bool clickable = _reasonOtherId != 0 && _reasonText.gameObject.activeSelf;
+ _reasonText.raycastTarget = clickable;
+ if (!clickable)
+ {
+ return;
+ }
+
+ long captured = _reasonOtherId;
+ var down = new UnityEngine.EventSystems.EventTrigger.Entry
+ {
+ eventID = UnityEngine.EventSystems.EventTriggerType.PointerDown
+ };
+ down.callback.AddListener(_ => OpenHistoryOtherInLore(captured));
+ entry.triggers.Add(down);
+ }
+
+ /// Harness: orange reason names another living unit.
+ public static bool ReasonOtherClickable => _reasonOtherId != 0;
+
+ /// Harness: click the orange reason to open the related unit in Lore.
+ public static bool ClickReasonOther(out long otherId)
+ {
+ otherId = _reasonOtherId;
+ if (otherId == 0)
+ {
+ return false;
+ }
+
+ OpenHistoryOtherInLore(otherId);
+ return true;
+ }
+
private static int ApplyTraitChips(UnitDossier dossier)
{
int traitCount = 0;
@@ -1356,6 +1502,20 @@ public static class WatchCaption
slot.Label.text = name;
}
+ if (slot.Tip != null)
+ {
+ if (chip != null && (chip.Trait != null || !string.IsNullOrEmpty(chip.Id)))
+ {
+ slot.Tip.BindTrait(chip.Trait, chip.Id);
+ slot.Tip.enabled = true;
+ }
+ else
+ {
+ slot.Tip.Clear();
+ slot.Tip.enabled = false;
+ }
+ }
+
if (traitPreview.Length > 0)
{
traitPreview.Append(", ");
@@ -1366,6 +1526,12 @@ public static class WatchCaption
}
else
{
+ if (slot.Tip != null)
+ {
+ slot.Tip.Clear();
+ slot.Tip.enabled = false;
+ }
+
slot.Root.SetActive(false);
}
}
@@ -1422,6 +1588,20 @@ public static class WatchCaption
slot.Label.color = StatusNameColor;
}
+ if (slot.Tip != null)
+ {
+ if (chip != null && (chip.Status != null || !string.IsNullOrEmpty(chip.Id)))
+ {
+ slot.Tip.BindStatus(chip.Status, chip.Id);
+ slot.Tip.enabled = true;
+ }
+ else
+ {
+ slot.Tip.Clear();
+ slot.Tip.enabled = false;
+ }
+ }
+
if (preview.Length > 0)
{
preview.Append(", ");
@@ -1432,6 +1612,12 @@ public static class WatchCaption
}
else
{
+ if (slot.Tip != null)
+ {
+ slot.Tip.Clear();
+ slot.Tip.enabled = false;
+ }
+
slot.Root.SetActive(false);
}
}
@@ -1559,7 +1745,8 @@ public static class WatchCaption
_historyCol.SetActive(true);
- var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)>(HistoryPeekMax);
+ var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey, long otherId)>(
+ HistoryPeekMax);
if (activity != null)
{
for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
@@ -1577,7 +1764,8 @@ public static class WatchCaption
}
string rich = !string.IsNullOrEmpty(e.DisplayLineRich) ? e.DisplayLineRich : plain;
- lines.Add((rich, plain, true, null, e.TaskId ?? ""));
+ long otherId = ResolveHistoryOtherId(unitId, e.RelatedId, e.TargetLabel);
+ lines.Add((rich, plain, true, null, e.TaskId ?? "", otherId));
}
}
@@ -1598,7 +1786,8 @@ public static class WatchCaption
continue;
}
- lines.Add((rich, plain, false, e.Kind, ""));
+ long otherId = e.OtherId != 0 && e.OtherId != unitId ? e.OtherId : 0;
+ lines.Add((rich, plain, false, e.Kind, "", otherId));
}
}
@@ -1639,7 +1828,7 @@ public static class WatchCaption
}
private static void ApplyCombinedSlotContents(
- List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)> lines,
+ List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey, long otherId)> lines,
float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
@@ -1660,6 +1849,8 @@ public static class WatchCaption
if (i >= lines.Count)
{
slot.Root.SetActive(false);
+ slot.OtherId = 0;
+ WireHistoryRowClick(slot, 0);
_historySlotHeights[i] = 0f;
_historySlotIsActivity[i] = false;
continue;
@@ -1668,6 +1859,7 @@ public static class WatchCaption
var row = lines[i];
_historySlotIsActivity[i] = row.isActivity;
slot.Root.SetActive(true);
+ slot.OtherId = row.otherId;
Sprite icon = row.isActivity
? (HudIcons.ForActivityKey(row.activityKey)
?? HudIcons.FromUiIcon("iconClock")
@@ -1707,6 +1899,8 @@ public static class WatchCaption
_historySlotHeights[i] = HistoryLineMinH;
}
+ WireHistoryRowClick(slot, row.otherId);
+
if (row.isActivity)
{
LastActivityShown++;
@@ -1727,6 +1921,132 @@ public static class WatchCaption
LastHistoryJoined = string.Join(" | ", joined);
}
+ private static void WireHistoryRowClick(HistorySlot slot, long otherId)
+ {
+ if (slot?.Root == null)
+ {
+ return;
+ }
+
+ slot.OtherId = otherId;
+ if (slot.Hit == null)
+ {
+ slot.Hit = slot.Root.GetComponent();
+ if (slot.Hit == null)
+ {
+ slot.Hit = slot.Root.AddComponent();
+ }
+
+ slot.Hit.color = new Color(1f, 1f, 1f, 0.001f);
+ }
+
+ if (slot.Button == null)
+ {
+ slot.Button = slot.Root.GetComponent