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"
}