diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 5553ad3..eee9aa4 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1885,11 +1885,17 @@ public static class AgentHarness { float ago = ParseFloat(cmd.value, 1f); InterestDirector.HarnessMarkInactive(ago); + // Rebuild dossier so orange reason clears under quiet_grace / inactive dwell. + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + WatchCaption.SetFromActor(MoveCamera._focus_unit); + } + _cmdOk++; Emit( cmd, ok: true, - detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey}"); + detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey} reason='{WatchCaption.Current?.ReasonLine}'"); break; } @@ -3665,43 +3671,44 @@ public static class AgentHarness } case "reason_owns_focus": { - // Reason must not name a different live unit than the scene subject/related. + // Reason must not name a different live unit than the owned active candidate. UnitDossier dossier = WatchCaption.Current; string reason = dossier?.ReasonLine ?? ""; - InterestCandidate scene = InterestDirector.CurrentCandidate; Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + InterestCandidate owned = focus != null + ? InterestDirector.TryGetOwnedReasonCandidate(focus) + : null; if (string.IsNullOrEmpty(reason)) { pass = true; - detail = "empty_reason_ok"; + detail = "empty_reason_ok active=" + InterestDirector.CurrentIsActive; break; } - if (scene == null || focus == null) - { - pass = string.IsNullOrEmpty(reason); - detail = $"scene={(scene != null)} focus={(focus != null)} reason='{reason}'"; - break; - } - - bool sceneOwnsFocus = scene.FollowUnit == focus - || scene.RelatedUnit == focus - || (scene.SubjectId != 0 && scene.SubjectId == EventFeedUtil.SafeId(focus)) - || (scene.RelatedId != 0 && scene.RelatedId == EventFeedUtil.SafeId(focus)); - if (!sceneOwnsFocus) + if (owned == null || focus == null) { pass = false; - detail = "reason_present_but_scene_does_not_own_focus reason='" + reason + "'"; + detail = $"reason_without_owned_active focus={(focus != null)} active={InterestDirector.CurrentIsActive} reason='{reason}'"; break; } - string stranger = FindStrangerNamedInReason(reason, scene); + string stranger = FindStrangerNamedInReason(reason, owned); pass = string.IsNullOrEmpty(stranger); detail = pass ? $"owned reason='{reason}'" : $"stranger_in_reason='{stranger}' reason='{reason}'"; break; } + case "reason_empty": + { + UnitDossier dossier = WatchCaption.Current; + string reason = dossier?.ReasonLine ?? ""; + bool wantEmpty = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true); + bool isEmpty = string.IsNullOrEmpty(reason); + pass = isEmpty == wantEmpty; + detail = $"reason='{reason}' empty={isEmpty} wantEmpty={wantEmpty} active={InterestDirector.CurrentIsActive} quiet={InterestDirector.InQuietGrace}"; + break; + } case "dossier_task_refresh": { // Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI. diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index e4312f9..68e77fd 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -938,9 +938,9 @@ internal static class HarnessScenarios Step("wr65", "assert", expect: "reason_owns_focus"), Step("wr66", "wait", wait: 0.35f), - // Mass fight inject → Clash / Fighting scale beat. + // Label-sourced clash (not inventing Clash from fighter counts). Step("wr50", "interest_expire_pending", value: ""), - Step("wr51", "interest_inject", asset: "human", label: "MassClash", tier: "Action", expect: "mass_clash", + Step("wr51", "interest_inject", asset: "human", label: "Clash · 8 fighting", tier: "Action", expect: "mass_clash", value: "lead=event;evt=90;char=5;fighters=8;notables=0;force=true"), Step("wr52", "age_current", wait: 0.8f), Step("wr53", "director_run", wait: 1.2f), @@ -950,6 +950,14 @@ internal static class HarnessScenarios Step("wr57", "screenshot", value: "hud-watch-reason-clash.png"), Step("wr58", "wait", wait: 0.55f), + // After active dwell ends, orange reason clears even if focus remains. + Step("wr70", "interest_force_session", asset: "human", + label: "War: Essionan vs North", tier: "Epic", expect: "dwell_end_war"), + Step("wr71", "assert", expect: "dossier_contains", value: "War"), + Step("wr72", "interest_mark_inactive", value: "1.0"), + Step("wr73", "assert", expect: "reason_empty", value: "true"), + Step("wr74", "assert", expect: "reason_owns_focus"), + // Cold-boot / force-session focus gaps are unrelated to caption clarity. Step("wr89", "reset_counters"), Step("wr90", "assert", expect: "no_bad"), diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 42d7045..8b54b98 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -74,6 +74,67 @@ public static class InterestDirector public static InterestCandidate CurrentCandidate => _current; + /// + /// True while the current scene is past completion but still held through quiet_grace + /// (camera may remain; orange reason must be empty). + /// + public static bool InQuietGrace + { + get + { + if (_current == null || CurrentIsActive) + { + return false; + } + + return _inactiveSince >= 0f; + } + } + + /// + /// Candidate that may drive the orange dossier reason: owns + /// and the director session is still in its active dwell (not quiet_grace). + /// + public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit) + { + if (unit == null || _current == null || !CurrentIsActive || InQuietGrace) + { + return null; + } + + long unitId = 0; + try + { + unitId = unit.getID(); + } + catch + { + unitId = 0; + } + + if (_current.FollowUnit == unit) + { + return _current; + } + + if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId) + { + return _current; + } + + if (_current.RelatedUnit == unit) + { + return _current; + } + + if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId) + { + return _current; + } + + return null; + } + public static string InterruptedKey => _interrupted == null ? "" : (_interrupted.Key ?? ""); diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index 1fe858e..ce61bba 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -212,8 +212,8 @@ public sealed class UnitDossier } /// - /// Story beat for the dossier reason row: owned only by the matched current candidate. - /// Empty reason beats a wrong line - no watchLabel / spectacle / activity fallbacks. + /// Orange dossier reason: tracked event that earned this shot. + /// Empty when no owned active candidate (never invent from live fighting/job). /// private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor) { @@ -222,143 +222,51 @@ public sealed class UnitDossier return HarnessReasonOverride.Trim(); } - InterestCandidate scene = MatchingScene(actor, d); + return OwnedEventReason(actor, d); + } + + /// + /// Public so caption can refresh reason when the director leaves active dwell + /// (quiet_grace) without rebuilding the whole dossier. + /// + public static string OwnedEventReason(Actor actor, UnitDossier dossier = null) + { + if (actor == null || !actor.isAlive()) + { + return ""; + } + + InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor); if (scene == null) { return ""; } - string combat = CombatBeat(d, scene); - if (!string.IsNullOrEmpty(combat)) - { - return combat; - } - - string griefOrLife = HappinessBeat(scene, d); - if (!string.IsNullOrEmpty(griefOrLife)) - { - return griefOrLife; - } - - // Ambient filler: no story beat - hide the reason row. + // Character-fill ambient: hide reason (task chip already shows live activity). if (InterestScoring.IsFillScore(scene.TotalScore) && !InterestScoring.IsCombatAction(scene)) { return ""; } + UnitDossier d = dossier; + if (d == null) + { + d = new UnitDossier(); + try + { + d.UnitId = actor.getID(); + d.Name = SafeName(actor); + d.SpeciesId = actor.asset != null ? actor.asset.id : ""; + } + catch + { + // ignore + } + } + return SceneLabelBeat(scene, d) ?? ""; } - private static InterestCandidate MatchingScene(Actor actor, UnitDossier d) - { - InterestCandidate scene = InterestDirector.CurrentCandidate; - if (scene == null || actor == null) - { - return null; - } - - if (scene.FollowUnit == actor) - { - return scene; - } - - if (scene.SubjectId != 0 && scene.SubjectId == d.UnitId) - { - return scene; - } - - if (scene.RelatedUnit == actor) - { - return scene; - } - - if (scene.RelatedId != 0 && scene.RelatedId == d.UnitId) - { - return scene; - } - - return null; - } - - private static string CombatBeat(UnitDossier d, InterestCandidate scene) - { - bool fighting = d.IsFighting - || (scene != null && InterestScoring.IsCombatAction(scene)); - if (!fighting) - { - return ""; - } - - int fighters = scene != null ? scene.ParticipantCount : 0; - int notables = scene != null ? scene.NotableParticipantCount : 0; - if (fighters <= 0 && d.IsFighting) - { - fighters = 1; - notables = (d.IsKing || d.IsFavorite || d.IsCityLeader) ? 1 : 0; - } - - if (fighters <= 2 && notables >= 2) - { - return "King duel"; - } - - if (fighters >= 4) - { - return "Clash · " + fighters + " fighting"; - } - - if (fighters >= 2) - { - return "Fighting · " + fighters; - } - - return "Fighting"; - } - - private static string HappinessBeat(InterestCandidate scene, UnitDossier d) - { - if (scene == null) - { - return ""; - } - - string label = scene.Label ?? ""; - if (label.StartsWith("Slain here", System.StringComparison.OrdinalIgnoreCase)) - { - return CleanBeat(label); - } - - if (string.IsNullOrEmpty(scene.HappinessEffectId)) - { - return ""; - } - - string id = scene.HappinessEffectId; - if (id.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase)) - { - string grief = EventLabelBeat(label, d); - if (!string.IsNullOrEmpty(grief)) - { - return grief; - } - - if (id.IndexOf("child", System.StringComparison.OrdinalIgnoreCase) >= 0) - { - return "Mourning a child"; - } - - if (id.IndexOf("lover", System.StringComparison.OrdinalIgnoreCase) >= 0) - { - return "Mourning a lover"; - } - - return "Grief"; - } - - string happy = EventLabelBeat(label, d); - return happy ?? ""; - } - private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d) { if (scene == null || string.IsNullOrEmpty(scene.Label)) @@ -593,23 +501,6 @@ public sealed class UnitDossier return shortened; } - private static string ActivityBeat(Actor actor) - { - if (actor == null) - { - return ""; - } - - // Task/job already live on the nametag; only surface hot action here. - ActivityBand band = ActivityInterestTable.Classify(actor); - if (band == ActivityBand.Hot) - { - return "In action"; - } - - return ""; - } - private static bool IsWeakFlavorBeat(string text, UnitDossier d) { if (string.IsNullOrEmpty(text)) diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 88d9019..7ceca8c 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -435,8 +435,8 @@ public static class WatchCaption return; } - // Reason ownership comes from InterestDirector.CurrentCandidate via MatchingScene, - // not from a sticky InterestEvent.Label / LastWatchLabel. + // Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate + // (active dwell + owned unit), not from a sticky InterestEvent.Label. SetFromActor(unit, label: null); } @@ -511,6 +511,7 @@ public static class WatchCaption RefreshLivePortrait(); RefreshLiveTask(); + RefreshOwnedReason(); RefreshHistoryIfChanged(); return; } @@ -561,6 +562,7 @@ public static class WatchCaption RefreshLivePortrait(); RefreshLiveTask(); + RefreshOwnedReason(); RefreshHistoryIfChanged(); } @@ -629,6 +631,77 @@ public static class WatchCaption BringHeaderFront(); } + /// + /// Keep orange reason in sync with director active dwell (clears in quiet_grace). + /// + private static void RefreshOwnedReason() + { + if (!_visible || _current == null || HasStatusBanner()) + { + return; + } + + Actor actor = ResolveBoundLiveActor(); + if (actor == null) + { + return; + } + + string next = UnitDossier.OwnedEventReason(actor, _current); + string prev = _current.ReasonLine ?? ""; + if (next == prev) + { + return; + } + + _current.ReasonLine = next; + LastDetail = _current.DetailLine ?? ""; + LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine); + + bool hasReason = !string.IsNullOrEmpty(next); + if (_reasonText != null) + { + _reasonText.text = hasReason ? next : ""; + _reasonText.gameObject.SetActive(hasReason); + } + + bool hasTask = !string.IsNullOrEmpty(_current.TaskText); + int traits = _current.TopTraits != null ? _current.TopTraits.Count : 0; + int hist = CountActiveHistorySlots(); + Relayout(_current.UnitId != 0, traits, hasTask, hasReason, hist); + } + + private static string JoinCaptionLines(string headline, string reason, string detail) + { + var sb = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(headline)) + { + sb.Append(headline); + } + + if (!string.IsNullOrEmpty(reason)) + { + if (sb.Length > 0) + { + sb.Append('\n'); + } + + sb.Append(reason); + } + + if (!string.IsNullOrEmpty(detail)) + { + if (sb.Length > 0) + { + sb.Append('\n'); + } + + sb.Append(detail); + } + + return sb.ToString(); + } + /// /// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps). /// Never clears the chip on empty AI gaps - only replaces text when a new task is present. diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 0beeba2..aab4cf8 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.18.1", + "version": "0.18.2", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" }