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