Refactor interest management in IdleSpectator to enhance reason ownership handling and improve dossier updates. Introduce new methods in InterestDirector for managing owned candidates and update WatchCaption to synchronize reason display with active dwell states. Revise AgentHarness to ensure accurate reason reporting during inactivity. Increment version in mod.json to 0.18.2 to reflect these changes.
This commit is contained in:
parent
020d73fa9c
commit
fd2283222c
6 changed files with 206 additions and 166 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,67 @@ public static class InterestDirector
|
|||
|
||||
public static InterestCandidate CurrentCandidate => _current;
|
||||
|
||||
/// <summary>
|
||||
/// True while the current scene is past completion but still held through quiet_grace
|
||||
/// (camera may remain; orange reason must be empty).
|
||||
/// </summary>
|
||||
public static bool InQuietGrace
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_current == null || CurrentIsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _inactiveSince >= 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
|
||||
/// and the director session is still in its active dwell (not quiet_grace).
|
||||
/// </summary>
|
||||
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 ?? "");
|
||||
|
||||
|
|
|
|||
|
|
@ -212,8 +212,8 @@ public sealed class UnitDossier
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public so caption can refresh reason when the director leaves active dwell
|
||||
/// (quiet_grace) without rebuilding the whole dossier.
|
||||
/// </summary>
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep orange reason in sync with director active dwell (clears in quiet_grace).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue