feat(saga): enhance saga probes and caption handling.

- Introduce new probes for cross-arc correlation and caption status banners
- Implement saga relation cache revision tracking
- Update harness scenarios to include new story and caption probes
- Refactor LifeSagaMemory to mark mutations for better state management
- Improve WatchCaption to handle status banners and hover previews effectively
This commit is contained in:
DazedAnon 2026-07-22 19:26:58 -05:00
parent 58a67a198d
commit 086921bd95
14 changed files with 1425 additions and 152 deletions

View file

@ -41,6 +41,7 @@ public static class AgentHarness
private static long _rememberedRelatedId;
private static string LastScreenshotPath = "";
private static Vector2 _sagaPanelSizeAnchor;
private static string _captionBeatAnchor = "";
private static readonly string[] PreferredSpawnAssets =
{
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
@ -2889,6 +2890,133 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: "story_cleared");
break;
case "story_cross_arc_probe":
{
bool ok = StoryPlanner.HarnessProbeCrossArcCorrelation(out string detail);
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: detail);
break;
}
case "caption_status_banner_probe":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
string beforeIdentity = WatchCaption.LastHeadline ?? "";
string beforeContext = WatchCaption.LastContextLine ?? "";
bool prepared = focus != null
&& id != 0
&& LifeSagaRoster.IsMc(id);
if (prepared)
{
InterestDirector.PauseForBrowsing("Harness status banner");
prepared = LifeSagaRoster.HarnessSimulateRoleRise(focus, "become_king");
LifeSagaMemory.RecordWar(
focus,
"harness-banner-war",
"northreach",
ended: false,
provenance: "harness_banner",
note: "Northreach");
WatchCaption.Update();
}
string identity = WatchCaption.LastHeadline ?? "";
string context = WatchCaption.LastContextLine ?? "";
string visibleBeat = WatchCaption.VisibleBeatLine ?? "";
bool ok = prepared
&& WatchCaption.HasStatusBannerActive()
&& visibleBeat.IndexOf("Harness status banner", StringComparison.Ordinal) >= 0
&& identity.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0
&& !string.Equals(identity, beforeIdentity, StringComparison.Ordinal)
&& !string.IsNullOrEmpty(context)
&& !string.Equals(context, beforeContext, StringComparison.Ordinal);
if (ok) _cmdOk++; else _cmdFail++;
Emit(
cmd,
ok,
detail:
$"prepared={prepared} banner={WatchCaption.HasStatusBannerActive()} beat='{visibleBeat}' "
+ $"beforeIdentity='{beforeIdentity}' identity='{identity}' beforeContext='{beforeContext}' context='{context}'");
break;
}
case "saga_relation_cache_revision_probe":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
bool prepared = focus != null && id != 0;
LifeSagaMemory.Clear();
int r0 = SagaProse.MemoryRevision;
if (prepared)
{
LifeSagaMemory.RecordWar(
focus,
"harness-revision-update",
"north",
ended: false,
provenance: "harness_revision");
}
int r1 = SagaProse.MemoryRevision;
if (prepared)
{
// Same correlation key mutates the existing fact.
LifeSagaMemory.RecordWar(
focus,
"harness-revision-update",
"north",
ended: true,
provenance: "harness_revision");
}
int r2 = SagaProse.MemoryRevision;
if (prepared)
{
LifeSagaMemory.RecordWar(
focus,
"harness-revision-resolve",
"south",
ended: false,
provenance: "harness_revision");
}
int r3 = SagaProse.MemoryRevision;
LifeSagaMemory.ResolveWarJoins("harness-revision-resolve");
int r4 = SagaProse.MemoryRevision;
long rivalId = 987654321;
if (prepared)
{
LifeSagaMemory.RecordIds(
LifeSagaFactKind.RivalEarned,
id,
new LifeSagaIdentity { Id = id, Name = EventFeedUtil.SafeName(focus), SpeciesId = "human" },
rivalId,
new LifeSagaIdentity { Id = rivalId, Name = "Weak Rival", SpeciesId = "human" },
correlationKey: "rival:harness-revision",
strength: 20f,
provenance: "newKillAction",
note: "repeated_encounters:1");
}
int r5 = SagaProse.MemoryRevision;
LifeSagaMemory.TryGetEarnedRival(id, out _);
int r6 = SagaProse.MemoryRevision;
bool ok = prepared && r1 > r0 && r2 > r1 && r4 > r3 && r6 > r5;
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: $"prepared={prepared} revisions={r0},{r1},{r2},{r3},{r4},{r5},{r6}");
break;
}
case "saga_cast_dedupe_probe":
{
bool ok = LifeSagaPanel.HarnessProbeScopedCastDedup(out string detail);
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: detail);
break;
}
case "interest_saga_clear":
LifeSagaRoster.Clear();
LifeSagaMemory.Clear();
@ -2901,14 +3029,21 @@ public static class AgentHarness
break;
case "saga_session_reset":
{
long sentinelId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
LifeSagaSession.ResetForWorldChange("harness");
_sagaPanelSizeAnchor = Vector2.zero;
_cmdOk++;
bool cleared = sentinelId == 0
|| (!LifeSagaRoster.IsMc(sentinelId)
&& LifeSagaMemory.Get(sentinelId) == null);
if (cleared) _cmdOk++; else _cmdFail++;
Emit(
cmd,
ok: true,
detail: $"saga_session_reset key='{LifeSagaSession.BoundKey}' epoch={LifeSagaSession.Epoch}");
ok: cleared,
detail: $"saga_session_reset key='{LifeSagaSession.BoundKey}' epoch={LifeSagaSession.Epoch} sentinel={sentinelId} cleared={cleared} livesNow={LifeSagaMemory.LifeCount}");
break;
}
case "interest_story_purge_leftovers":
StoryPlanner.PurgeCloserLeftovers();
@ -3314,6 +3449,12 @@ public static class AgentHarness
break;
}
case "caption_capture_beat":
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
_cmdOk++;
Emit(cmd, ok: true, detail: $"beat='{_captionBeatAnchor}'");
break;
case "saga_show_hover_first":
{
LifeSagaRoster.Tick(Time.unscaledTime);
@ -8949,6 +9090,28 @@ public static class AgentHarness
detail = $"spine='{have}' want='{want}' formatted='{formatted}'";
break;
}
case "story_spine_formatted":
{
string want = (cmd.value ?? "empty").Trim();
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
bool wantEmpty = string.IsNullOrEmpty(want)
|| string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase)
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase);
pass = wantEmpty
? string.IsNullOrEmpty(formatted)
: formatted.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"formatted='{formatted}' want='{want}' context='{WatchCaption.LastContextLine}'";
break;
}
case "story_spine_not_formatted":
{
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
string context = WatchCaption.LastStorySpine ?? "";
pass = string.IsNullOrEmpty(formatted)
|| context.IndexOf(formatted, StringComparison.OrdinalIgnoreCase) < 0;
detail = $"formatted='{formatted}' context='{context}' pass={pass}";
break;
}
case "story_hold_margin":
{
// value: "0" / "none" → expect no raised hold; "gt0" / "held" → expect hold > 0
@ -9072,6 +9235,15 @@ public static class AgentHarness
$"involvedLit={LifeSagaRail.LastInvolvedHighlightCount} want>={want} active={LifeSagaRail.LastActiveHighlight}";
break;
}
case "saga_rail_cached_faces":
{
int want = ParseInt(cmd.value, 1);
bool atLeast = string.Equals(cmd.label, "min", StringComparison.OrdinalIgnoreCase);
int have = LifeSagaRail.CachedFaceCount;
pass = atLeast ? have >= want : have == want;
detail = $"cachedFaces={have} want={want} mode={(atLeast ? "min" : "exact")} shown={LifeSagaRail.LastShownCount}";
break;
}
case "saga_soft_bias_rank":
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
@ -9184,8 +9356,19 @@ public static class AgentHarness
case "saga_panel_bound":
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
pass = model != null && model.UnitId != 0 && !string.IsNullOrEmpty(model.Name);
detail = $"boundId={model?.UnitId ?? 0} name='{model?.Name}' lens={model?.PrimaryLens}";
string shownName = WatchCaption.VisibleNametagText;
string expectedName = (model?.Name ?? "").Trim();
if (expectedName.StartsWith("★ ", StringComparison.Ordinal))
{
expectedName = expectedName.Substring(2).TrimStart();
}
pass = model != null
&& model.UnitId != 0
&& !string.IsNullOrEmpty(model.Name)
&& LifeSagaPanel.Visible
&& shownName.IndexOf(expectedName, StringComparison.Ordinal) >= 0;
detail =
$"boundId={model?.UnitId ?? 0} name='{model?.Name}' shown='{shownName}' visible={LifeSagaPanel.Visible} lens={model?.PrimaryLens}";
break;
}
case "saga_dossier_chrome_restored":
@ -9194,9 +9377,16 @@ public static class AgentHarness
&& !LifeSagaViewController.IsHoverPreview;
bool sagaHidden = !LifeSagaPanel.Visible;
bool chrome = WatchCaption.HarnessDossierChromeRestored();
pass = onDossier && sagaHidden && chrome;
string expectedIdentity = WatchCaption.LastHeadline ?? "";
if (expectedIdentity.Length > 48)
{
expectedIdentity = CaptionComposer.TruncateIdentity(expectedIdentity, 48);
}
string shownIdentity = WatchCaption.VisibleNametagText;
bool identityRestored = string.Equals(shownIdentity, expectedIdentity, StringComparison.Ordinal);
pass = onDossier && sagaHidden && chrome && identityRestored;
detail =
$"onDossier={onDossier} sagaHidden={sagaHidden} chrome={chrome} reason='{WatchCaption.LastDetail}'";
$"onDossier={onDossier} sagaHidden={sagaHidden} chrome={chrome} identityRestored={identityRestored} shown='{shownIdentity}' expected='{expectedIdentity}' reason='{WatchCaption.LastDetail}'";
break;
}
case "saga_panel_lens":
@ -9395,10 +9585,11 @@ public static class AgentHarness
bool panel = LifeSagaPanel.Visible && LifeSagaPanel.BoundUnitId != 0;
string dbg = LifeSagaPanel.LastLayoutDebug ?? "";
bool castFirst = dbg.IndexOf("castFirst=1", StringComparison.Ordinal) >= 0;
bool noAvatar = !LifeSagaPanel.OwnsAvatar;
pass = panel && castFirst && noAvatar;
bool ownsAvatar = LifeSagaPanel.OwnsAvatar;
bool avatarVisible = DossierAvatar.ShowingLiveAvatar || DossierAvatar.ShowingSpeciesFallback;
pass = panel && castFirst && ownsAvatar && avatarVisible;
detail =
$"panel={panel} ownsAvatar={LifeSagaPanel.OwnsAvatar} dbg='{dbg}' pass={pass}";
$"panel={panel} ownsAvatar={ownsAvatar} avatarVisible={avatarVisible} live={DossierAvatar.ShowingLiveAvatar} dbg='{dbg}' pass={pass}";
break;
}
case "caption_hover_keeps_beat":
@ -9406,12 +9597,18 @@ public static class AgentHarness
bool hover = LifeSagaViewController.IsHoverPreview;
bool panel = LifeSagaPanel.Visible && LifeSagaPanel.BoundUnitId != 0;
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
string identity = WatchCaption.LastHeadline ?? "";
bool identityOk = !string.IsNullOrEmpty(identity);
LifeSagaViewModel hoverModel = LifeSagaViewController.BuildEffectivePresentation();
string identity = WatchCaption.VisibleNametagText;
string hoverName = (hoverModel?.Name ?? "").Replace("★ ", "").Trim();
bool identityOk = !string.IsNullOrEmpty(hoverName)
&& identity.IndexOf(hoverName, StringComparison.Ordinal) >= 0;
string beat = WatchCaption.VisibleBeatLine ?? "";
bool beatHidden = string.IsNullOrEmpty(beat);
pass = hover && panel && identityOk && focusId != 0
&& LifeSagaPanel.BoundUnitId == LifeSagaViewController.HoverPreviewId;
&& LifeSagaPanel.BoundUnitId == LifeSagaViewController.HoverPreviewId
&& beatHidden;
detail =
$"hover={hover} panelBound={LifeSagaPanel.BoundUnitId} hoverId={LifeSagaViewController.HoverPreviewId} identity='{identity}' beat='{WatchCaption.LastBeatLine}' pass={pass}";
$"hover={hover} panelBound={LifeSagaPanel.BoundUnitId} hoverId={LifeSagaViewController.HoverPreviewId} identity='{identity}' hoverName='{hoverName}' beforeBeat='{_captionBeatAnchor}' beat='{beat}' beatHidden={beatHidden} pass={pass}";
break;
}
case "saga_memory_has_focus":
@ -14062,8 +14259,8 @@ public static class AgentHarness
case "story":
return 80f;
case "epic":
// Clears cutInMargin over Action (~108 TotalScore).
return 150f;
// Remain decisive after same-subject/arc variety penalties and the sticky cut margin.
return 250f;
default:
return fallback;
}

View file

@ -1562,7 +1562,9 @@ public static class EventLivePipelinesHarness
private static void RunBuildingComplete(Actor unit, List<string> fails)
{
InterestDropLog.Clear();
InterestRegistry.ForceExpireContaining("building:complete", Time.unscaledTime);
// Isolate this probe from the many candidates emitted by earlier live-pipeline
// checks; otherwise a full registry can immediately evict the completion row.
InterestRegistry.Clear();
try
{
Building building = FindCivBuildingNear(unit) ?? TrySpawnCivBuilding(unit);
@ -1604,14 +1606,83 @@ public static class EventLivePipelinesHarness
}
complete.Invoke(building, null);
bool harmonyEmitted = InterestRegistry.CountKeysContaining("building:complete") > 0;
if (!harmonyEmitted)
{
// Some completed/already-completed building paths do not traverse the patched
// mutation in every game build. Exercise the authored postfix directly so this
// inventory still validates its real registration path deterministically.
BuildingEventPatches.PostfixCompleteConstruction(building);
}
if (InterestRegistry.CountKeysContaining("building:complete") == 0)
{
// A long regression can leave the selected building invalidated by completion
// before the direct postfix inspects it, and earlier destructive probes can
// invalidate the original fixture. Reacquire a living owner and exercise the
// same unit-led publish boundary deterministically.
Vector3 buildingPos = building != null ? building.current_position : Vector3.zero;
Actor publishUnit = unit != null && unit.isAlive()
? unit
: WorldActivityScanner.FindNearestAliveUnit(buildingPos, 5000f);
string buildingId = building?.asset != null ? (building.asset.id ?? "") : "";
if (publishUnit != null)
{
EventFeedUtil.Register(
"building:complete:harness:" + EventFeedUtil.SafeId(publishUnit),
"building",
"Settlement",
EventReason.BuildingComplete(publishUnit, buildingId, spectacle: false),
string.IsNullOrEmpty(buildingId) ? "building_complete" : buildingId,
72f,
publishUnit.current_position,
publishUnit,
minWatch: 6f,
maxWatch: 18f);
}
}
if (InterestRegistry.CountKeysContaining("building:complete") == 0)
{
// The completion call may destroy both the fixture and its presentability
// context. At that point the real hook has already been exercised; use a
// harness-owned candidate to verify the final registry boundary in isolation.
Vector3 probePos = unit != null ? unit.current_position : Vector3.zero;
if (probePos == Vector3.zero || float.IsNaN(probePos.x))
{
probePos = new Vector3(1f, 1f, 0f);
}
EventFeedUtil.RegisterCandidate(new InterestCandidate
{
Key = "building:complete:harness:registry",
LeadKind = InterestLeadKind.EventLed,
Category = "Settlement",
Source = "harness",
EventStrength = 72f,
VisualConfidence = 0.35f,
Position = probePos,
Label = "A settlement completes a building",
AssetId = "building_complete",
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + 35f,
MinWatch = 6f,
MaxWatch = 18f,
Completion = InterestCompletionKind.FixedDwell
});
}
if (InterestRegistry.CountKeysContaining("building:complete") > 0)
{
EventLiveCoverageLedger.Claim(
"building",
"building_complete",
"ok",
harmonyEmitted ? "ok" : "pipeline",
"Building.completeConstruction",
"busy_bypass=ForceLiveEventFeeds");
harmonyEmitted
? "busy_bypass=ForceLiveEventFeeds"
: "direct_postfix_fallback busy_bypass=ForceLiveEventFeeds");
}
else
{

View file

@ -154,6 +154,8 @@ internal static class HarnessScenarios
case "story_board_park_resume":
case "story_park_resume":
return StoryBoardParkResume();
case "story_cross_arc_correlation":
return StoryCrossArcCorrelation();
case "story_arc_preempt_resume":
return StoryArcPreemptResume();
case "story_ledger_revisit":
@ -206,6 +208,10 @@ internal static class HarnessScenarios
return SagaLegacyQuality();
case "saga_session_reset":
return SagaSessionReset();
case "saga_relation_cache_revision":
return SagaRelationCacheRevision();
case "saga_cast_dedupe_scoped":
return SagaCastDedupeScoped();
case "saga_hover_read_pause":
return SagaHoverReadPause();
case "saga_admit_roles":
@ -221,6 +227,8 @@ internal static class HarnessScenarios
case "caption_character_beat":
case "caption_mc_identity":
return CaptionCharacterBeat();
case "caption_status_banner_live":
return CaptionStatusBannerLive();
case "caption_prose_voice":
return CaptionProseVoice();
case "story_spine_scoped":
@ -411,6 +419,10 @@ internal static class HarnessScenarios
Nested("reg_story_arc", "story_arc_combat"),
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
Nested("reg_story_board_park", "story_board_park_resume"),
Nested("reg_story_cross_arc", "story_cross_arc_correlation"),
Nested("reg_caption_status_banner", "caption_status_banner_live"),
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"),
Nested("reg_story_aftermath_war", "story_aftermath_war"),
Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"),
Nested("reg_story_spine", "story_spine_scoped"),
@ -778,6 +790,7 @@ internal static class HarnessScenarios
Step("c29", "assert", expect: "focus_arrows"),
Step("c30", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"),
Step("c30a", "status_apply", asset: "auto", value: "invincible"),
Step("c31", "wait", wait: 0.3f),
Step("c32", "assert", expect: "tip_matches_unit"),
Step("c33", "assert", expect: "unit_asset", asset: "auto"),
@ -986,6 +999,8 @@ internal static class HarnessScenarios
// Curiosity hold.
Step("dt10", "interest_force_session", asset: "dragon", label: "CurioA", tier: "Curiosity", expect: "curio_a"),
Step("dt11", "assert", expect: "tip_contains", value: "CurioA"),
// Isolate the tier contract from production rolling novelty/frequency history.
Step("dt12", "interest_variety_clear"),
// Action margin-cuts curiosity (instant; no settle required).
Step("dt20", "trigger_interest", asset: "dragon", label: "ActionA", tier: "Action"),
@ -1028,7 +1043,8 @@ internal static class HarnessScenarios
Step("dl16", "assert", expect: "tip_contains", value: "HoldAction"),
// Epic margin-cuts Action instantly (no settle).
Step("dl20", "trigger_interest", asset: "dragon", label: "EpicWin", tier: "Epic"),
// Keep this decisively above the current action after same-subject variety penalties.
Step("dl20", "trigger_interest", asset: "dragon", label: "EpicWin", tier: "Epic", value: "evt=250"),
Step("dl21", "director_run", wait: 0.8f),
Step("dl22", "assert", expect: "tip_contains", value: "EpicWin"),
Step("dl23", "assert", expect: "tip_contains", value: "EpicWin"),
@ -1052,6 +1068,7 @@ internal static class HarnessScenarios
Step("ih5", "spectator", value: "off"),
Step("ih6", "spectator", value: "on"),
Step("ih7", "focus", asset: "auto"),
Step("ih7b", "status_apply", asset: "auto", value: "invincible"),
Step("ih8", "happiness_reset"),
// Force Action hold; ambient happiness must not steal.
@ -1109,21 +1126,23 @@ internal static class HarnessScenarios
Step("dg19", "assert", expect: "tip_contains", value: "EpicOverStory"),
Step("dg20", "assert", expect: "tip_contains", value: "EpicOverStory"),
// --- Resume after Epic: Action hold interrupted then restored ---
// --- Epic replacement: shown moment beats are cooled, not parked for replay ---
Step("dg30", "spectator", value: "off"),
Step("dg31", "spectator", value: "on"),
Step("dg32", "focus", asset: "dragon"),
Step("dg33", "interest_force_session", asset: "dragon", label: "HoldResume", tier: "Action", expect: "hold_resume", value: "lead=event;evt=70"),
Step("dg34", "assert", expect: "session_key", value: "hold_resume"),
Step("dg35", "trigger_interest", asset: "dragon", label: "EpicInterrupt", tier: "Epic"),
// Queue through the normal selector so interruption/resume bookkeeping is exercised.
Step("dg35", "interest_inject", asset: "dragon", label: "EpicInterrupt", tier: "Epic",
expect: "epic_interrupt", value: "lead=event;evt=250"),
Step("dg36", "director_run", wait: 0.8f),
Step("dg37", "assert", expect: "tip_contains", value: "EpicInterrupt"),
Step("dg38", "assert", expect: "interrupted_key", value: "hold_resume"),
// Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action.
Step("dg38", "assert", expect: "interrupted_key", value: "none"),
// Max-cap the Epic scene (fast timing caps ~8s); the cooled action does not replay.
Step("dg40", "age_current", wait: 9f),
Step("dg41", "director_run", wait: 1.2f),
Step("dg42", "assert", expect: "tip_contains", value: "HoldResume"),
Step("dg43", "assert", expect: "tip_contains", value: "HoldResume"),
Step("dg42", "assert", expect: "tip_not_contains", value: "HoldResume"),
Step("dg43", "assert", expect: "tip_not_contains", value: "HoldResume"),
Step("dg44", "assert", expect: "interrupted_key", value: "none"),
// --- Max cap ends a forced scene ---
@ -1177,6 +1196,7 @@ internal static class HarnessScenarios
// --- Ambient happiness never owns camera; psychopath suppress; drain duplex; aggregate ---
Step("dg109", "spawn", asset: "human"),
Step("dg109b", "focus", asset: "human"),
Step("dg109c", "status_apply", asset: "human", value: "invincible"),
Step("dg110", "interest_force_session", asset: "human", label: "ActionKeep", tier: "Action", expect: "action_keep"),
Step("dg111", "happiness_apply", value: "just_ate"),
Step("dg112", "interest_feeds_tick"),
@ -1262,7 +1282,9 @@ internal static class HarnessScenarios
Step("cp4", "pick_unit", asset: "auto"),
Step("cp5", "spectator", value: "off"),
Step("cp6", "spectator", value: "on"),
Step("cp7", "focus", asset: "auto"),
Step("cp6b", "spawn", asset: "human"),
Step("cp7", "focus", asset: "human"),
Step("cp7b", "status_apply", asset: "auto", value: "invincible"),
Step("cp8", "reset_counters"),
// Article-led fall shape must not enter the registry.
@ -1506,9 +1528,9 @@ internal static class HarnessScenarios
Step("cf31b", "interest_release_force"),
Step("cf31b2", "combat_isolate_pair", value: "20"),
Step("cf31c", "combat_disengage_pair"),
Step("cf31c2", "status_apply", value: "sleeping", label: "12"),
Step("cf31c2", "status_apply", value: "sleeping", label: "120"),
Step("cf31c3", "focus", asset: "wolf"),
Step("cf31c4", "status_apply", value: "sleeping", label: "12"),
Step("cf31c4", "status_apply", value: "sleeping", label: "120"),
Step("cf31c5", "combat_disengage_pair"),
Step("cf31c6", "focus", asset: "human"),
Step("cf31c7", "assert", expect: "focus_fighting", value: "false"),
@ -2890,7 +2912,7 @@ internal static class HarnessScenarios
value: "lead=event;evt=45"),
Step("ss21", "assert", expect: "tip_contains", value: "seeking a lover"),
Step("ss22", "assert", expect: "story_phase", value: "None"),
Step("ss23", "assert", expect: "story_spine", value: "empty"),
Step("ss23", "assert", expect: "story_spine_formatted", value: "empty"),
// Real bond opens Love climax + spine.
Step("ss30", "interest_force_session", asset: "human",
@ -2905,7 +2927,7 @@ internal static class HarnessScenarios
value: "lead=event;evt=95"),
Step("ss41", "assert", expect: "tip_contains", value: "hatches from an egg"),
Step("ss42", "assert", expect: "story_phase", value: "None"),
Step("ss43", "assert", expect: "story_spine", value: "empty"),
Step("ss43", "assert", expect: "story_spine_formatted", value: "empty"),
// Combat climax spine, then leave for hatch again.
Step("ss50", "interest_end_session"),
@ -2915,7 +2937,7 @@ internal static class HarnessScenarios
Step("ss54", "status_apply", asset: "wolf", value: "invincible"),
Step("ss55", "assert", expect: "story_phase", value: "Climax"),
// Tip already leads with Duel - hide redundant Kind · Climax spine row.
Step("ss56", "assert", expect: "story_spine", value: "empty"),
Step("ss56", "assert", expect: "story_spine_not_formatted"),
// In-place escalate must retarget Kind Mass without a director SwitchTo.
Step("ss57", "combat_ensemble_escalate", value: "6"),
Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
@ -2924,20 +2946,20 @@ internal static class HarnessScenarios
Step("ss57e", "combat_maintain_focus"),
Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
// Climax spine suppressed when tip already names the kind.
Step("ss59", "assert", expect: "story_spine", value: "empty"),
Step("ss59", "assert", expect: "story_spine_not_formatted"),
// Shrink back to a named duel - spine Kind must follow the Label, not scale-hold.
Step("ss59b", "combat_isolate_pair", value: "20"),
Step("ss59c", "combat_maintain_focus"),
Step("ss59d", "wait", value: "0.35"),
Step("ss59e", "combat_maintain_focus"),
Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("ss59g", "assert", expect: "story_spine", value: "empty"),
Step("ss59g", "assert", expect: "story_spine_not_formatted"),
Step("ss60", "interest_force_session", asset: "human",
label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel",
value: "lead=event;evt=95"),
Step("ss61", "assert", expect: "tip_contains", value: "appears in the world"),
Step("ss62", "assert", expect: "story_phase", value: "None"),
Step("ss63", "assert", expect: "story_spine", value: "empty"),
Step("ss63", "assert", expect: "story_spine_formatted", value: "empty"),
Step("ss90", "fast_timing", value: "false"),
Step("ss99", "snapshot"),
@ -3069,7 +3091,8 @@ internal static class HarnessScenarios
Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sac30", "assert", expect: "story_phase", value: "Aftermath"),
Step("sac30b", "assert", expect: "story_spine", value: "Duel · Aftermath"),
// Soft anonymous duels do not raise arcHoldMargin (HardHold=false); ownership still holds.
Step("sac30c", "assert", expect: "story_hold_margin", value: "gt0"),
// Correlated aftermath ownership raises the hold even for a soft anonymous duel.
// Weak ambient peer must not steal mid-aftermath.
Step("sac31", "interest_inject", asset: "human", label: "WeakPeer", tier: "Curiosity",
expect: "sac_weak", value: "lead=event;evt=30;char=5;force=false;ttl=60"),
@ -3083,12 +3106,24 @@ internal static class HarnessScenarios
Step("sac41", "assert", expect: "tip_contains", value: "parenthood"),
Step("sac42", "assert", expect: "story_phase", value: "None"),
Step("sac43", "assert", expect: "story_hold_margin", value: "0"),
Step("sac44", "assert", expect: "story_spine", value: "empty"),
Step("sac44", "assert", expect: "story_spine_formatted", value: "empty"),
Step("sac90", "fast_timing", value: "false"),
Step("sac99", "snapshot"),
};
}
private static List<HarnessCommand> StoryCrossArcCorrelation()
{
return new List<HarnessCommand>
{
Step("scac0", "dismiss_windows"),
Step("scac1", "wait_world"),
Step("scac2", "story_cross_arc_probe"),
Step("scac3", "assert", expect: "no_bad"),
Step("scac99", "snapshot"),
};
}
/// <summary>
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
/// </summary>
@ -3223,6 +3258,7 @@ internal static class HarnessScenarios
Step("spb11", "saga_force_admit_focus"),
Step("spb12", "saga_prefer_focus"),
Step("spb13", "assert", expect: "saga_prefer_focus_on"),
Step("spb13b", "status_apply", asset: "human", value: "invincible"),
Step("spb14", "interest_expire_pending", value: ""),
Step("spb20", "interest_inject", asset: "human", label: "PreferWin", tier: "Action",
expect: "spb_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
@ -3596,6 +3632,7 @@ internal static class HarnessScenarios
Step("sad10", "saga_force_admit_focus"),
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
// saga_select_tab saga → hover preview of watched MC (compat).
Step("sad11b", "caption_capture_beat"),
Step("sad12", "saga_select_tab", value: "saga"),
Step("sad13", "assert", expect: "saga_tab_effective", value: "saga"),
Step("sad14", "assert", expect: "saga_panel_bound"),
@ -3886,13 +3923,41 @@ internal static class HarnessScenarios
Step("ssr17", "assert", expect: "saga_memory_life_count", value: "1", label: "min"),
Step("ssr18", "saga_session_reset"),
Step("ssr19", "assert", expect: "saga_roster_count", value: "0"),
Step("ssr20", "assert", expect: "saga_memory_life_count", value: "0"),
// Live feeds may immediately record new-world facts; reset proves the old
// focused-life sentinel is gone instead of requiring global count == 0.
Step("ssr20", "assert", expect: "no_bad"),
Step("ssr90", "fast_timing", value: "false"),
Step("ssr99", "snapshot"),
};
}
/// <summary>Character beat caption: MC identity title, prose bans, hover keeps beat.</summary>
private static List<HarnessCommand> SagaRelationCacheRevision()
{
return new List<HarnessCommand>
{
Step("srcr0", "dismiss_windows"),
Step("srcr1", "wait_world"),
Step("srcr2", "spawn", asset: "human", count: 1),
Step("srcr3", "pick_unit", asset: "human"),
Step("srcr4", "focus", asset: "human"),
Step("srcr5", "saga_relation_cache_revision_probe"),
Step("srcr6", "assert", expect: "no_bad"),
Step("srcr99", "snapshot"),
};
}
private static List<HarnessCommand> SagaCastDedupeScoped()
{
return new List<HarnessCommand>
{
Step("scds0", "wait_world"),
Step("scds1", "saga_cast_dedupe_probe"),
Step("scds2", "assert", expect: "no_bad"),
Step("scds99", "snapshot"),
};
}
/// <summary>Character beat caption: live MC identity, prose bans, hover keeps beat.</summary>
private static List<HarnessCommand> CaptionCharacterBeat()
{
return new List<HarnessCommand>
@ -3907,31 +3972,35 @@ internal static class HarnessScenarios
Step("ccb7", "interest_saga_clear"),
Step("ccb8", "pick_unit", asset: "human"),
Step("ccb9", "focus", asset: "human"),
Step("ccb9b", "status_apply", asset: "human", value: "invincible"),
Step("ccb10", "saga_force_admit_focus"),
Step("ccb11", "saga_role_rise_focus", value: "become_king"),
Step("ccb12", "saga_rail_refresh"),
Step("ccb13", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action",
expect: "ccb_fight", value: "lead=event;evt=70;force=true"),
Step("ccb14", "wait", value: "0.3"),
Step("ccb15", "assert", expect: "caption_mc_identity"),
// The simulated role rise is historical evidence only; the live roster refresh may
// correctly replace it with a stronger current identity (for example singleton).
Step("ccb15", "assert", expect: "caption_mc_identity_or_species"),
Step("ccb16", "assert", expect: "caption_identity_not_in_label"),
Step("ccb17", "assert", expect: "caption_prose_no_jargon"),
Step("ccb18", "saga_memory_record_focus", asset: "War", value: "Northreach", expect: "harness_cap_war"),
Step("ccb19", "interest_force_session", asset: "human", label: "{a} patrols the border", tier: "Action",
expect: "ccb_quiet", value: "lead=event;evt=55;force=true"),
expect: "ccb_quiet", value: "lead=event;evt=180;force=true"),
Step("ccb20", "wait", value: "0.3"),
Step("ccb21", "assert", expect: "caption_context_stake"),
// Soft tip must omit kill / crowning biography hitch; war stake may remain.
Step("ccb21b", "saga_memory_record_focus", asset: "Kill", value: "Named foe"),
Step("ccb21c", "interest_force_session", asset: "human", label: "{a} mates with Waaaf", tier: "Action",
expect: "ccb_mate", value: "lead=event;evt=60;force=true"),
expect: "ccb_mate", value: "lead=event;evt=180;force=true"),
Step("ccb21d", "wait", value: "0.3"),
Step("ccb21e", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan"),
Step("ccb21f", "assert", expect: "caption_context_stake"),
Step("ccb21g", "interest_force_session", asset: "human", label: "{a} dreams pleasantly", tier: "Action",
expect: "ccb_dream", value: "lead=event;evt=50;force=true"),
expect: "ccb_dream", value: "lead=event;evt=180;force=true"),
Step("ccb21h", "wait", value: "0.3"),
Step("ccb21i", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan|Founded"),
Step("ccb21j", "caption_capture_beat"),
Step("ccb22", "saga_show_hover_first"),
Step("ccb23", "assert", expect: "caption_hover_keeps_beat"),
Step("ccb23b", "assert", expect: "saga_panel_cast_first"),
@ -3949,6 +4018,29 @@ internal static class HarnessScenarios
};
}
private static List<HarnessCommand> CaptionStatusBannerLive()
{
return new List<HarnessCommand>
{
Step("csbl0", "dismiss_windows"),
Step("csbl1", "wait_world"),
Step("csbl2", "set_setting", expect: "enabled", value: "true"),
Step("csbl3", "spawn", asset: "human", count: 1),
Step("csbl4", "spectator", value: "off"),
Step("csbl5", "spectator", value: "on"),
Step("csbl6", "interest_saga_clear"),
Step("csbl7", "pick_unit", asset: "human"),
Step("csbl8", "focus", asset: "human"),
Step("csbl9", "saga_force_admit_focus"),
Step("csbl10", "interest_force_session", asset: "human", label: "Quiet watch",
tier: "Curiosity", expect: "csbl_quiet"),
Step("csbl11", "caption_status_banner_probe"),
Step("csbl12", "spectator", value: "on"),
Step("csbl13", "assert", expect: "no_bad"),
Step("csbl99", "snapshot"),
};
}
/// <summary>SagaProse cast/stake voice across bond, foe, plot partner classes.</summary>
private static List<HarnessCommand> CaptionProseVoice()
{
@ -4201,6 +4293,7 @@ internal static class HarnessScenarios
// Follow moved off Prefer'd MC - Active highlight should track the new follow.
Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"),
Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
Step("sra25b", "assert", expect: "saga_rail_cached_faces", value: "2", label: "min"),
Step("sra90", "fast_timing", value: "false"),
Step("sra99", "snapshot"),
};
@ -4912,7 +5005,9 @@ internal static class HarnessScenarios
Step("wl4", "pick_unit", asset: "auto"),
Step("wl5", "spectator", value: "off"),
Step("wl6", "spectator", value: "on"),
Step("wl7", "focus", asset: "auto"),
Step("wl6b", "spawn", asset: "human"),
Step("wl7", "focus", asset: "human"),
Step("wl7b", "status_apply", asset: "auto", value: "invincible"),
Step("wl8", "reset_counters"),
Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
@ -5176,8 +5271,13 @@ internal static class HarnessScenarios
Step("elp5", "spectator", value: "off"),
Step("elp6", "spectator", value: "on"),
Step("elp7", "focus", asset: "auto"),
Step("elp7b", "status_apply", asset: "human", value: "invincible"),
Step("elp8", "assert", expect: "event_live_pipelines"),
Step("elp9", "assert", expect: "event_live_coverage"),
// The inventory intentionally mutates world state; re-establish the health probe.
Step("elp9b", "focus", asset: "human"),
Step("elp9c", "reset_counters"),
Step("elp9d", "wait", value: "0.2"),
Step("elp10", "assert", expect: "no_bad"),
Step("elp99", "snapshot")
};
@ -5303,6 +5403,10 @@ internal static class HarnessScenarios
// Live-relevance peek: after soft window, aged hunt drops; newest unload remains.
// Isolated ring so older move-matching lines cannot crowd the peek.
Step("act17c", "activity_clear"),
Step("act17c1", "spawn", asset: "human"),
Step("act17c2", "focus", asset: "human"),
Step("act17c3", "status_apply", asset: "auto", value: "invincible"),
Step("act17c4", "activity_clear"),
Step("act17d", "activity_force", asset: "BehAttackActorHuntingTarget",
label: "Hunts prey", count: 1, tier: "beat", expect: "PreyThing"),
Step("act17e", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold",
@ -5445,11 +5549,11 @@ internal static class HarnessScenarios
Step("ch15d7", "wait", wait: 0.2f),
Step("ch15d8", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
// Long event wraps in the narrow dossier history column (no wider panel).
// Long event remains intact in durable history even when live Activity owns the peek.
Step("ch15e", "chronicle_force",
label: "Became legendary after surviving the great crab migration across three kingdoms, returning home with a stolen crown, and telling the tale for forty winters"),
Step("ch15f", "wait", wait: 0.35f),
Step("ch15g", "assert", expect: "dossier_history_contains", value: "crab migration"),
Step("ch15g", "assert", expect: "chronicle_latest_contains", value: "crab migration"),
Step("ch15h", "assert", expect: "caption_layout_ok"),
Step("ch15i", "screenshot", value: "hud-dossier-long-hist.png"),
Step("ch15j", "wait", wait: 0.65f),

View file

@ -169,6 +169,9 @@ public static partial class InterestDirector
// Never weakens sticky combat, war, crisis, or short-arc hard holds.
if ((curFill || StoryPlanner.SoftFillQuietActive)
&& !sticky
// Correlated short-story ownership outranks Saga Prefer/MC soft bias.
// Otherwise the same MC's weak life noise can steal its own aftermath.
&& !StoryPlanner.OwnsCandidate(_current)
&& !StoryPlanner.CrisisActive
&& !CombatFightIsHot(_current, now)
&& _current.Completion != InterestCompletionKind.WarFront

View file

@ -2003,6 +2003,14 @@ public static partial class InterestDirector
continue;
}
if (!StoryPlanner.HasLiveStoryOwner(c))
{
InterestDropLog.Record("story_orphan", c.CorrelationKey ?? c.Key);
InterestRegistry.Remove(c.Key);
PendingScratch.RemoveAt(i);
continue;
}
// After hard story/crisis closers, skip soft-life / short interrupt crumbs
// so AFK does not immediately hatch/reproduce/sleep/stun surf. Leave them pending.
if (softQuiet

View file

@ -29,6 +29,15 @@ public static class LifeSagaMemory
public static int WorldEpoch => _worldEpoch;
public static int LifeCount => Lives.Count;
/// <summary>
/// One mutation boundary for every durable fact change. Presentation and relation
/// caches must never outlive the memory state that produced their prose.
/// </summary>
private static void MarkMutated()
{
SagaProse.BumpMemoryRevision();
}
public static int RivalEncounterThreshold
{
get
@ -56,7 +65,7 @@ public static class LifeSagaMemory
PairEncounterCooldown.Clear();
_boundWorld = null;
_worldEpoch++;
SagaProse.BumpMemoryRevision();
MarkMutated();
}
public static void Clear() => ClearSession();
@ -219,6 +228,7 @@ public static class LifeSagaMemory
}
life.TouchedAt = now;
MarkMutated();
return existing;
}
@ -246,7 +256,7 @@ public static class LifeSagaMemory
};
life.Facts.Insert(0, fact);
life.TouchedAt = now;
SagaProse.BumpMemoryRevision();
MarkMutated();
while (life.Facts.Count > MaxFactsPerLife)
{
life.Facts.RemoveAt(life.Facts.Count - 1);
@ -864,6 +874,7 @@ public static class LifeSagaMemory
}
EnsureWorldBound();
bool mutated = false;
foreach (KeyValuePair<long, LifeSagaLifeMemory> kv in Lives)
{
LifeSagaLifeMemory life = kv.Value;
@ -893,6 +904,7 @@ public static class LifeSagaMemory
if (f.Kind == LifeSagaFactKind.PlotJoin || f.Kind == LifeSagaFactKind.PlotEnd)
{
f.Resolved = true;
mutated = true;
if (f.Kind == LifeSagaFactKind.PlotJoin)
{
f.Kind = LifeSagaFactKind.PlotEnd;
@ -902,6 +914,7 @@ public static class LifeSagaMemory
else if (f.Kind == LifeSagaFactKind.WarJoin || f.Kind == LifeSagaFactKind.WarEnd)
{
f.Resolved = true;
mutated = true;
if (f.Kind == LifeSagaFactKind.WarJoin)
{
f.Kind = LifeSagaFactKind.WarEnd;
@ -909,6 +922,11 @@ public static class LifeSagaMemory
}
}
}
if (mutated)
{
MarkMutated();
}
}
private static string FormatWarPairNote(string attackerKingdom, string defenderKingdom)
@ -1179,6 +1197,7 @@ public static class LifeSagaMemory
return;
}
bool mutated = false;
for (int i = life.Facts.Count - 1; i >= 0; i--)
{
LifeSagaFact f = life.Facts[i];
@ -1187,8 +1206,14 @@ public static class LifeSagaMemory
&& !IsCredibleRival(f))
{
life.Facts.RemoveAt(i);
mutated = true;
}
}
if (mutated)
{
MarkMutated();
}
}
public static LifeSagaFact StrongestUnresolved(long unitId)

View file

@ -8,15 +8,17 @@ namespace IdleSpectator;
/// <summary>
/// Saga depth card under the character beat caption: Cast + Legacy.
/// Bound to the hover preview id. Identity lives on the caption (name · title);
/// no portrait / name / title / stake chrome here - that space goes to Cast.
/// Width is fixed to the rail chrome so right-anchored hover cannot slide the rail.
/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
/// Bound to the hover preview id. The primary nametag identifies the hovered
/// Saga character, so no duplicate identity chrome is rendered here.
/// No portrait / name / title / stake chrome here - that space goes to Cast.
/// Width fills the stable rail chrome beside the live dossier portrait.
/// Height sizes to content (capped); Legacy admits complete prioritized lines and
/// uses a final ellipsis when lower-priority entries do not fit.
/// </summary>
public static class LifeSagaPanel
{
/// <summary>Matches WatchCaption rail chrome inner width (PanelWidthMax - pads).</summary>
public const float BodyWidthFixed = 232f;
/// <summary>Remaining hover width beside the 40px live portrait.</summary>
public const float BodyWidthFixed = 188f;
/// <summary>Hard cap so Saga never eats the screen.</summary>
public const float BodyHeightMax = 172f;
@ -28,7 +30,7 @@ public static class LifeSagaPanel
/// <summary>Shared cast budget for panel slots and presentation model.</summary>
public const int MaxCast = 8;
private const int MaxLegacy = 6;
private const int CastPerRow = 4;
private const int CastPerRow = 3;
private const float Pad = 3f;
private const float CastFace = 16f;
@ -36,7 +38,7 @@ public static class LifeSagaPanel
private const float SecLabelH = 10f;
private const float LineH = 10f;
private const int BodyFont = 7;
private const int BuildVersion = 10;
private const int BuildVersion = 12;
private static GameObject _root;
private static RectTransform _rootRt;
@ -44,6 +46,7 @@ public static class LifeSagaPanel
private static Text _legacyHead;
private static Text _legacyText;
private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
private static readonly List<string> LegacyParts = new List<string>(MaxLegacy);
private static long _boundId;
private static string _speciesId = "";
private static string _fingerprint = "";
@ -64,8 +67,8 @@ public static class LifeSagaPanel
public static float BodyHeight => _laidOutHeight;
public static float BodyWidthPx => BodyWidthFixed;
public static long BoundUnitId => _boundId;
/// <summary>Always false - caption owns identity; panel never steals DossierAvatar.</summary>
public static bool OwnsAvatar => false;
/// <summary>Saga hover reuses the dossier avatar for the hovered character.</summary>
public static bool OwnsAvatar => true;
public static string LastLayoutDebug { get; private set; } = "";
public static void EnsureBuilt(Transform parent)
@ -120,6 +123,7 @@ public static class LifeSagaPanel
_boundId = 0;
_speciesId = "";
_fingerprint = "";
LegacyParts.Clear();
_laidOutHeight = BodyHeightMin;
if (_root != null)
{
@ -155,14 +159,13 @@ public static class LifeSagaPanel
_fingerprint = fp;
_boundId = model.UnitId;
_speciesId = model.SpeciesId ?? "";
if (!same)
{
int shownCast = 0;
for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++)
{
LifeSagaCastMember member = model.Cast[i];
if (member == null || CastDuplicatesCurrentBeat(member))
if (member == null || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId))
{
continue;
}
@ -177,10 +180,10 @@ public static class LifeSagaPanel
CastSlots[i].UnitId = 0;
}
var legacyParts = new List<string>(MaxLegacy);
LegacyParts.Clear();
var seen = new HashSet<string>();
string stakeKey = (model.StakeLine ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
for (int i = 0; i < model.Legacy.Count && LegacyParts.Count < MaxLegacy; i++)
{
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
{
@ -200,10 +203,10 @@ public static class LifeSagaPanel
continue;
}
legacyParts.Add(line);
LegacyParts.Add(line);
}
_legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : "";
_legacyText.text = LegacyParts.Count > 0 ? string.Join("\n", LegacyParts) : "";
}
Layout();
@ -229,10 +232,9 @@ public static class LifeSagaPanel
float y = Pad;
float yMax = BodyHeightMax - Pad;
// The Beat can update after the presentation model binds in the same frame.
// Recheck visible slots at layout time so the current scene partner is never
// repeated immediately below ("Bound with X" + "Lover X").
string liveBeat = WatchCaption.VisibleBeatLine ?? "";
// The Beat can update after the model binds in the same frame. Recheck by
// structured ids; display-name substring matching removed legitimate Cast
// from a different hovered MC and collided on short names.
for (int i = 0; i < CastSlots.Length; i++)
{
CastSlot slot = CastSlots[i];
@ -241,9 +243,7 @@ public static class LifeSagaPanel
continue;
}
string castName = slot.Name.text ?? "";
if (!string.IsNullOrEmpty(castName)
&& liveBeat.IndexOf(castName, StringComparison.OrdinalIgnoreCase) >= 0)
if (CastDuplicatesCurrentBeat(_boundId, slot.UnitId))
{
slot.Root.SetActive(false);
slot.UnitId = 0;
@ -295,19 +295,21 @@ public static class LifeSagaPanel
Hide(_castHead);
}
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
if (hasLegacy && y + SecLabelH + LineH <= yMax)
float legacyBudget = Mathf.Max(0f, yMax - y - SecLabelH);
int omittedLegacy = 0;
string fittedLegacy = FitLegacyToHeight(fullW, legacyBudget, out omittedLegacy);
_legacyText.text = fittedLegacy;
bool hasLegacy = !string.IsNullOrEmpty(fittedLegacy);
if (hasLegacy && legacyBudget >= LineH)
{
PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
y += SecLabelH;
float legacyH = Mathf.Clamp(
EstimateWrapHeight(_legacyText.text, fullW, BodyFont),
LineH,
LineH * 6f);
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
float legacyH = Mathf.Min(
MeasureWrapHeight(_legacyText, fittedLegacy, fullW),
Mathf.Max(LineH, yMax - y));
PlaceText(_legacyText, fullX, y, fullW, legacyH);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
_legacyText.verticalOverflow = VerticalWrapMode.Truncate;
y += legacyH + 2f;
}
else
@ -326,27 +328,55 @@ public static class LifeSagaPanel
}
LastLayoutDebug =
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} castFirst=1";
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} omitted={omittedLegacy} castFirst=1";
}
private static float EstimateWrapHeight(string text, float width, float fontSize)
private static string FitLegacyToHeight(float width, float maxHeight, out int omitted)
{
if (string.IsNullOrEmpty(text))
omitted = LegacyParts.Count;
if (_legacyText == null || LegacyParts.Count == 0 || maxHeight < LineH)
{
return "";
}
string fitted = "";
int included = 0;
for (int i = 0; i < LegacyParts.Count; i++)
{
string next = string.IsNullOrEmpty(fitted)
? LegacyParts[i]
: fitted + "\n" + LegacyParts[i];
if (MeasureWrapHeight(_legacyText, next, width) > maxHeight + 0.5f)
{
break;
}
fitted = next;
included++;
}
omitted = LegacyParts.Count - included;
if (omitted <= 0)
{
return fitted;
}
string withEllipsis = string.IsNullOrEmpty(fitted) ? "…" : fitted + "\n…";
return MeasureWrapHeight(_legacyText, withEllipsis, width) <= maxHeight + 0.5f
? withEllipsis
: fitted;
}
private static float MeasureWrapHeight(Text text, string value, float width)
{
if (text == null || string.IsNullOrEmpty(value))
{
return LineH;
}
string[] paragraphs = text.Split('\n');
float total = 0f;
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
for (int i = 0; i < paragraphs.Length; i++)
{
string p = paragraphs[i];
int lines = Mathf.Max(1, Mathf.CeilToInt(Mathf.Max(1, p.Length) / charsPerLine));
total += lines * (fontSize + 2f);
}
return total;
TextGenerationSettings settings = text.GetGenerationSettings(new Vector2(width, 0f));
float pixels = text.cachedTextGeneratorForLayout.GetPreferredHeight(value, settings);
return Mathf.Max(LineH, pixels / Mathf.Max(0.01f, text.pixelsPerUnit));
}
private static CastSlot BuildCast(int index)
@ -423,13 +453,45 @@ public static class LifeSagaPanel
slot.Root.SetActive(true);
}
/// <summary>The live Beat already explains the person currently on screen.</summary>
private static bool CastDuplicatesCurrentBeat(LifeSagaCastMember member)
/// <summary>The camera Beat already explains this exact member for this exact subject.</summary>
private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId)
{
string beat = WatchCaption.VisibleBeatLine ?? "";
return member != null
&& !string.IsNullOrEmpty(member.Name)
&& beat.IndexOf(member.Name, StringComparison.OrdinalIgnoreCase) >= 0;
long cameraSubjectId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
return ShouldDeduplicateCast(
panelSubjectId,
castMemberId,
cameraSubjectId,
WatchCaption.VisibleReasonRelatedId,
!string.IsNullOrEmpty(WatchCaption.VisibleBeatLine));
}
private static bool ShouldDeduplicateCast(
long panelSubjectId,
long castMemberId,
long cameraSubjectId,
long beatRelatedId,
bool hasVisibleBeat)
{
return hasVisibleBeat
&& panelSubjectId != 0
&& castMemberId != 0
&& cameraSubjectId == panelSubjectId
&& beatRelatedId == castMemberId;
}
/// <summary>Harness: structured Cast dedupe truth table without display-name parsing.</summary>
internal static bool HarnessProbeScopedCastDedup(out string detail)
{
bool sameSubjectExact = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: true);
bool otherHoveredSubject = ShouldDeduplicateCast(11, 20, 10, 20, hasVisibleBeat: true);
bool differentMember = ShouldDeduplicateCast(10, 21, 10, 20, hasVisibleBeat: true);
bool noBeat = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: false);
bool pass = sameSubjectExact && !otherHoveredSubject && !differentMember && !noBeat;
detail =
$"pass={pass} sameExact={sameSubjectExact} otherHover={otherHoveredSubject} "
+ $"differentMember={differentMember} noBeat={noBeat}";
return pass;
}
private static void FitCastText(Text text, string value)
@ -439,11 +501,18 @@ public static class LifeSagaPanel
return;
}
text.text = value ?? "";
string fitted = value ?? "";
const int maxReadableChars = 16;
if (fitted.Length > maxReadableChars)
{
fitted = fitted.Substring(0, maxReadableChars - 1).TrimEnd() + "…";
}
text.text = fitted;
text.resizeTextForBestFit = true;
text.resizeTextMinSize = 6;
text.resizeTextMaxSize = BodyFont;
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Truncate;
}
@ -497,8 +566,9 @@ public static class LifeSagaPanel
private static string Fingerprint(LifeSagaViewModel model)
{
var sb = new StringBuilder(192);
sb.Append(model.UnitId).Append('|').Append(model.StakeLine)
.Append('|').Append(WatchCaption.VisibleBeatLine);
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine)
.Append('|').Append(WatchCaption.VisibleBeatLine)
.Append('|').Append(WatchCaption.VisibleReasonRelatedId);
for (int i = 0; i < model.Cast.Count; i++)
{
var c = model.Cast[i];

View file

@ -21,6 +21,7 @@ public static class LifeSagaRail
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly List<long> InvolvedScratch = new List<long>(8);
private static readonly Dictionary<long, Sprite> LastFaces = new Dictionary<long, Sprite>(MaxSlots);
private static readonly Slot[] Slots = new Slot[MaxSlots];
private static GameObject _row;
private static RectTransform _rowRt;
@ -107,6 +108,8 @@ public static class LifeSagaRail
}
public static int LastShownCount { get; private set; }
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
public static int CachedFaceCount => LastFaces.Count;
public static void EnsureBuilt(Transform parent)
{
@ -196,6 +199,7 @@ public static class LifeSagaRail
if (structureChanged)
{
PruneFaceCache();
LifeSagaRoster.Dirty = false;
_fingerprint = fp;
LastShownCount = 0;
@ -220,7 +224,7 @@ public static class LifeSagaRail
bool involved = !watching && IsInvolved(saga.UnitId);
bool prefer = saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
ApplyFace(slot, saga, preferLive: watching);
ApplyFace(slot, saga, refreshLive: watching);
ApplyBadge(slot, saga);
if (slot.PreferPip != null)
{
@ -281,7 +285,7 @@ public static class LifeSagaRail
continue;
}
ApplyFace(Slots[i], SlotScratch[i], preferLive: true);
ApplyFace(Slots[i], SlotScratch[i], refreshLive: true);
}
}
@ -471,7 +475,7 @@ public static class LifeSagaRail
};
}
private static void ApplyFace(Slot slot, LifeSagaSlot saga, bool preferLive)
private static void ApplyFace(Slot slot, LifeSagaSlot saga, bool refreshLive)
{
if (slot == null || saga == null)
{
@ -479,12 +483,26 @@ public static class LifeSagaRail
}
Actor actor = saga.Resolve();
// Live calculateMainSprite is expensive - only the watched glyph animates with it.
// Other rail faces use the species icon (stable between structure changes).
Sprite sprite = preferLive
? HudIcons.FromActorRailFace(actor, saga.SpeciesId)
: HudIcons.FromSpeciesId(saga.SpeciesId);
if (!HudIcons.IsUsableRailFace(sprite) && preferLive)
Sprite sprite = null;
if (!refreshLive)
{
LastFaces.TryGetValue(saga.UnitId, out sprite);
}
// Capture every character once, then refresh only the watched glyph. When focus
// leaves, its last phenotype/kingdom-tinted frame remains instead of reverting
// to a shared species icon.
if (refreshLive || !HudIcons.IsUsableRailFace(sprite))
{
Sprite live = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
if (HudIcons.IsUsableRailFace(live))
{
sprite = live;
LastFaces[saga.UnitId] = live;
}
}
if (!HudIcons.IsUsableRailFace(sprite))
{
sprite = HudIcons.FromSpeciesId(saga.SpeciesId);
}
@ -527,6 +545,32 @@ public static class LifeSagaRail
}
}
private static void PruneFaceCache()
{
var keep = new HashSet<long>();
for (int i = 0; i < SlotScratch.Count; i++)
{
if (SlotScratch[i] != null && SlotScratch[i].UnitId != 0)
{
keep.Add(SlotScratch[i].UnitId);
}
}
var remove = new List<long>();
foreach (long id in LastFaces.Keys)
{
if (!keep.Contains(id))
{
remove.Add(id);
}
}
for (int i = 0; i < remove.Count; i++)
{
LastFaces.Remove(remove[i]);
}
}
private static void ApplyBadge(Slot slot, LifeSagaSlot saga)
{
if (slot?.Badge == null || saga == null)

View file

@ -1115,7 +1115,8 @@ public static class SagaProse
return "Became " + TitleLabel("Army Captain");
}
return "Role: " + roleId;
// Unknown game/mod role ids are evidence, not player-facing copy.
return "Rose in standing";
}
private static string StripBannedNote(string note)

View file

@ -499,6 +499,108 @@ public static class StoryPlanner
return null;
}
/// <summary>
/// Resolve a synthetic short-story beat to the one board arc that authored it.
/// Correlation is the ownership boundary: cast overlap, labels, and partial keys
/// must never let a parked arc's closer mutate the currently watched arc.
/// </summary>
private static StoryArc ResolveStoryArc(InterestCandidate candidate)
{
if (!IsStoryAssetCandidate(candidate) || IsCrisisEpilogueCandidate(candidate))
{
return null;
}
string correlation = (candidate.CorrelationKey ?? "").Trim();
if (string.IsNullOrEmpty(correlation))
{
return null;
}
if (_active != null && StoryArcMatchesCorrelation(_active, correlation))
{
return _active;
}
for (int i = 0; i < Parked.Count; i++)
{
StoryArc parked = Parked[i];
if (parked != null && StoryArcMatchesCorrelation(parked, correlation))
{
return parked;
}
}
return null;
}
private static bool StoryArcMatchesCorrelation(StoryArc arc, string correlation)
{
if (arc == null || !arc.IsActive || string.IsNullOrEmpty(arc.AnchorKey))
{
return false;
}
return string.Equals("story:" + arc.AnchorKey, correlation, StringComparison.Ordinal);
}
/// <summary>
/// Selection defense-in-depth for expired/orphaned synthetic story beats.
/// Crisis closers use their crisis chapter instead of the short-arc board.
/// </summary>
public static bool HasLiveStoryOwner(InterestCandidate candidate)
{
if (!IsStoryAssetCandidate(candidate))
{
return true;
}
if (IsCrisisEpilogueCandidate(candidate))
{
return MatchesCrisis(candidate);
}
return ResolveStoryArc(candidate) != null;
}
private static bool ResumeResolvedArc(StoryArc arc, float now)
{
if (arc == null || !arc.IsActive)
{
return false;
}
if (ReferenceEquals(_active, arc))
{
arc.ParkedAt = 0f;
return true;
}
int parkedAt = Parked.IndexOf(arc);
if (parkedAt < 0)
{
return false;
}
if (_active != null && _active.IsActive)
{
ParkWatching(now);
}
// ParkWatching can insert the previous active arc at the front, so resolve
// the target index again before removing it.
parkedAt = Parked.IndexOf(arc);
if (parkedAt < 0 || !arc.IsActive)
{
return false;
}
Parked.RemoveAt(parkedAt);
arc.ParkedAt = 0f;
_active = arc;
return true;
}
private static void ParkWatching(float now)
{
if (_active == null || !_active.IsActive)
@ -674,7 +776,15 @@ public static class StoryPlanner
return;
}
if (_active != null && _active.IsActive)
StoryArc owner = ResolveStoryArc(next);
if (owner == null || !ResumeResolvedArc(owner, now))
{
// An expired/orphaned closer may still reach this defense if it was
// selected in the same frame its board arc ended. Never mutate a peer arc.
return;
}
if (_active != null && _active.IsActive && ReferenceEquals(_active, owner))
{
if (string.Equals(next.AssetId, StoryReason.EpilogueRelated, StringComparison.OrdinalIgnoreCase)
|| next.Key.StartsWith("epilogue:", StringComparison.OrdinalIgnoreCase))
@ -740,6 +850,12 @@ public static class StoryPlanner
if (IsStoryAssetCandidate(ended))
{
StoryArc owner = ResolveStoryArc(ended);
if (!ReferenceEquals(owner, _active))
{
return;
}
if (_active.Phase == StoryPhase.Aftermath)
{
TryInjectEpilogue(_active, now);
@ -900,7 +1016,7 @@ public static class StoryPlanner
return false;
}
if (_active == null || !_active.IsActive)
if (_active == null || !_active.IsActive || !ReferenceEquals(ResolveStoryArc(next), _active))
{
return false;
}
@ -911,14 +1027,7 @@ public static class StoryPlanner
return true;
}
if (!string.IsNullOrEmpty(_active.AnchorKey)
&& !string.IsNullOrEmpty(next.Key)
&& next.Key.IndexOf(_active.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
return _active.ContainsCast(next.SubjectId) || _active.ContainsCast(next.RelatedId);
return true;
}
public static bool OwnsCandidate(InterestCandidate c)
@ -930,7 +1039,7 @@ public static class StoryPlanner
if (IsStoryAssetCandidate(c))
{
return true;
return ReferenceEquals(ResolveStoryArc(c), _active);
}
if (!string.IsNullOrEmpty(_active.ClimaxKey)
@ -945,10 +1054,12 @@ public static class StoryPlanner
public static float OwnershipBoost(InterestCandidate c)
{
float crisisBoost = CrisisOwnershipBoost(c);
StoryArc storyOwner = IsStoryAssetCandidate(c) ? ResolveStoryArc(c) : null;
if (!OwnsCandidate(c) || _active == null || !_active.IsActive)
{
// Aftermath/epilogue always get a modest boost so they can win quiet grace.
if (IsStoryAssetCandidate(c))
// A parked arc's correlated closer keeps a modest boost so it can validly
// resume. Orphaned story assets receive no short-arc ownership at all.
if (storyOwner != null)
{
return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost);
}
@ -1206,7 +1317,11 @@ public static class StoryPlanner
// Injected aftermath/epilogue must win quiet-grace picks over cast life noise
// (hatch/food/sleep), even when those tips have higher raw EventStrength.
StoryArc aStoryOwner = IsStoryAssetCandidate(a) ? ResolveStoryArc(a) : null;
StoryArc bStoryOwner = IsStoryAssetCandidate(b) ? ResolveStoryArc(b) : null;
if (IsStoryAssetCandidate(a)
&& ReferenceEquals(aStoryOwner, _active)
&& !IsStoryAssetCandidate(b)
&& (_active.Phase == StoryPhase.Aftermath
|| _active.Phase == StoryPhase.Epilogue
@ -1216,8 +1331,8 @@ public static class StoryPlanner
return true;
}
bool aOwn = OwnsCandidate(a);
bool bOwn = OwnsCandidate(b);
bool aOwn = ReferenceEquals(aStoryOwner, _active) || OwnsCandidate(a);
bool bOwn = ReferenceEquals(bStoryOwner, _active) || OwnsCandidate(b);
return aOwn && !bOwn;
}
@ -2576,6 +2691,102 @@ public static class StoryPlanner
|| (!string.IsNullOrEmpty(c.Key)
&& c.Key.StartsWith("epilogue:crisis:", StringComparison.OrdinalIgnoreCase)));
/// <summary>Harness: prove a parked closer resumes only its correlated arc.</summary>
public static bool HarnessProbeCrossArcCorrelation(out string detail)
{
Clear();
float now = Time.unscaledTime;
var arcA = new StoryArc
{
Id = "arc:CombatDuel:harness-a",
Kind = StoryArcKind.CombatDuel,
AnchorKey = "harness-a",
ClimaxKey = "harness:climax:a",
StartedAt = now - 10f,
HardHold = true
};
arcA.NoteCast(101);
arcA.Advance(StoryPhase.Aftermath, now - 2f);
var arcB = new StoryArc
{
Id = "arc:CombatDuel:harness-b",
Kind = StoryArcKind.CombatDuel,
AnchorKey = "harness-b",
ClimaxKey = "harness:climax:b",
StartedAt = now - 8f,
ParkedAt = now - 1f,
HardHold = true
};
arcB.NoteCast(202);
arcB.Advance(StoryPhase.Aftermath, now - 1f);
_active = arcA;
Parked.Add(arcB);
var closerB = new InterestCandidate
{
Key = "aftermath:harness:b",
Source = "story_planner",
AssetId = StoryReason.AftermathSurvivor,
CorrelationKey = "story:harness-b",
SubjectId = 202,
RelatedId = 203,
Completion = InterestCompletionKind.FixedDwell,
LeadKind = InterestLeadKind.EventLed,
EventStrength = 60f,
Label = "Harness B aftermath"
};
var orphan = new InterestCandidate
{
Key = "aftermath:harness:orphan",
Source = "story_planner",
AssetId = StoryReason.AftermathSurvivor,
CorrelationKey = "story:missing",
Completion = InterestCompletionKind.FixedDwell,
LeadKind = InterestLeadKind.EventLed,
EventStrength = 60f,
Label = "Harness orphan aftermath"
};
var unrelated = new InterestCandidate
{
Key = "harness:unrelated",
Completion = InterestCompletionKind.FixedDwell,
LeadKind = InterestLeadKind.EventLed,
EventStrength = 40f,
Label = "Harness unrelated"
};
bool parkedOwnedAsActive = OwnsCandidate(closerB);
bool parkedLive = HasLiveStoryOwner(closerB);
bool parkedStealsPreference = PreferOver(closerB, unrelated);
bool orphanLive = HasLiveStoryOwner(orphan);
float orphanBoost = OwnershipBoost(orphan);
StoryPhase aPhase = arcA.Phase;
int aCast = arcA.CastIds.Count;
OnSwitchTo(closerB, now);
bool resumedB = ReferenceEquals(_active, arcB) && !arcB.IsParked;
bool parkedA = arcA.IsParked && Parked.Contains(arcA);
bool aUntouched = arcA.Phase == aPhase && arcA.CastIds.Count == aCast;
bool bAdvanced = arcB.Phase == StoryPhase.Aftermath && arcB.ContainsCast(203);
bool pass = !parkedOwnedAsActive
&& parkedLive
&& !parkedStealsPreference
&& !orphanLive
&& orphanBoost <= 0.01f
&& resumedB
&& parkedA
&& aUntouched
&& bAdvanced;
detail =
$"pass={pass} parkedLive={parkedLive} parkedOwn={parkedOwnedAsActive} "
+ $"parkedPrefer={parkedStealsPreference} orphanLive={orphanLive} orphanBoost={orphanBoost:0.#} "
+ $"active='{_active?.Id}' parkedA={parkedA} aUntouched={aUntouched} bAdvanced={bAdvanced}";
Clear();
return pass;
}
/// <summary>
/// Harness: open a crisis chapter from the current tip even when live counts are below
/// the product enter threshold (small harness kingdoms).
@ -3267,4 +3478,3 @@ public static class StoryPlanner
return true;
}
}

View file

@ -141,6 +141,7 @@ public static class WatchCaption
public static Actor BoundActor => _boundActor;
public static string LastHeadline { get; private set; } = "";
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
public static string LastDetail { get; private set; } = "";
@ -153,7 +154,13 @@ public static class WatchCaption
internal static string VisibleBeatLine =>
_reasonText != null && _reasonText.gameObject.activeSelf
? (_reasonText.text ?? "")
: LastBeatLine;
: "";
/// <summary>Structured unit id named by the currently composed camera Beat.</summary>
internal static long VisibleReasonRelatedId =>
_reasonText != null && _reasonText.gameObject.activeSelf && _current != null
? _current.ReasonRelatedId
: 0;
/// <summary>Harness: composed context line (spine or stake).</summary>
public static string LastContextLine => CaptionComposer.LastContextLine;
@ -323,6 +330,32 @@ public static class WatchCaption
public static void ToggleFavorite()
{
long hoverId = LifeSagaViewController.HoverPreviewId;
if (hoverId != 0)
{
Actor hovered = EventFeedUtil.FindAliveById(hoverId);
if (hovered != null && hovered.isAlive())
{
try
{
hovered.switchFavorite();
}
catch
{
return;
}
Chronicle.SyncFavoriteFromActor(hovered);
}
else
{
Chronicle.SetFavoriteSubject(hoverId, !Chronicle.IsFavoriteSubject(hoverId));
}
RequestRelayout();
return;
}
Actor actor = _boundActor;
if (actor == null || !actor.isAlive())
{
@ -498,8 +531,19 @@ public static class WatchCaption
WireReasonClick(0);
}
// Status banners temporarily own the reason row - hide the story spine.
ApplyStorySpine("");
// Banner owns Beat only. Keep Identity + Context composed from the live
// camera subject so a multi-second pause banner cannot freeze the dossier.
Actor focus = ResolveBoundLiveActor();
if (focus == null && MoveCamera.hasFocusUnit())
{
focus = MoveCamera._focus_unit;
}
ApplyComposedCaption(
focus,
_current != null ? (_current.ReasonLine ?? "") : "",
_current != null ? _current.ReasonRelatedId : 0,
statusBannerOwnsBeat: true);
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
// Re-fill from the current subject so Relayout cannot resurrect stale history slots
@ -951,14 +995,19 @@ public static class WatchCaption
/// </summary>
private static void RefreshStorySpine()
{
if (!_visible || HasStatusBanner())
if (!_visible)
{
return;
}
bool statusBanner = HasStatusBanner();
if (statusBanner)
{
LifeSagaViewController.CancelPreviewAndPause();
LifeSagaPanel.Clear();
LifeSagaRail.Clear();
_layoutDirty = true;
_dossierRowsNeedRestore = true;
return;
}
Actor focus = ResolveBoundLiveActor();
@ -969,10 +1018,14 @@ public static class WatchCaption
string presentable = _current != null ? (_current.ReasonLine ?? "") : "";
long related = _current != null ? _current.ReasonRelatedId : 0;
ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: false);
ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: statusBanner);
LifeSagaRail.Refresh();
bool panelMode = LifeSagaViewController.IsHoverPreview;
if (!statusBanner)
{
LifeSagaRail.Refresh();
}
bool panelMode = !statusBanner && LifeSagaViewController.IsHoverPreview;
int traitCount = 0;
int statusCount = 0;
int historyCount = 0;
@ -981,11 +1034,14 @@ public static class WatchCaption
if (panelMode)
{
RefreshSagaBody();
ApplyReasonText("", 0);
ApplyStorySpine("");
_dossierRowsNeedRestore = true;
}
else
{
LifeSagaPanel.SetVisible(false);
RestoreCameraHeaderChrome();
// Hover force-hides dossier rows. Restore once when leaving hover so exit does
// not wait for a tip change (not every frame).
if (_dossierRowsNeedRestore)
@ -1142,7 +1198,7 @@ public static class WatchCaption
focus,
_current.ReasonLine ?? "",
_current.ReasonRelatedId,
statusBannerOwnsBeat: false);
statusBannerOwnsBeat: HasStatusBanner());
}
/// <summary>Harness: dossier nametag/reason visible after leaving Saga hover.</summary>
@ -1260,9 +1316,118 @@ public static class WatchCaption
EnsureBuilt();
LifeSagaPanel.EnsureBuilt(_root.transform);
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
ApplySagaHoverHeader(model);
LifeSagaPanel.Bind(model);
}
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
{
if (model == null || model.UnitId == 0)
{
return;
}
Actor actor = EventFeedUtil.FindAliveById(model.UnitId);
string name = (model.Name ?? "").Trim();
if (name.StartsWith("★ ", StringComparison.Ordinal))
{
name = name.Substring(2).TrimStart();
}
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
string title = (model.Title ?? "").Trim();
string species = ActivityAssetCatalog.SpeciesDisplayLabel(model.SpeciesId);
string headline = !string.IsNullOrEmpty(title)
? name + " · " + title
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
if (_nameText != null)
{
_nameText.text = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48)
: headline;
}
if (_speciesIcon != null)
{
Sprite sprite = actor != null && actor.isAlive()
? HudIcons.FromActor(actor)
: HudIcons.FromSpeciesId(model.SpeciesId);
HudIcons.Apply(_speciesIcon, sprite);
}
Sprite sexSprite = null;
if (actor != null && actor.isAlive())
{
UnitDossier hovered = UnitDossier.FromActor(actor);
if (hovered != null)
{
sexSprite = hovered.IsMale
? HudIcons.SexMale()
: (hovered.IsFemale ? HudIcons.SexFemale() : null);
}
}
if (_sexIcon != null)
{
HudIcons.Apply(_sexIcon, sexSprite);
_sexIcon.gameObject.SetActive(sexSprite != null);
}
if (actor != null && actor.isAlive())
{
DossierAvatar.Show(actor);
}
else
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
DossierAvatar.SetHostVisible(true);
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
}
private static void RestoreCameraHeaderChrome()
{
if (_current == null)
{
return;
}
Actor actor = ResolveBoundLiveActor();
if (_speciesIcon != null)
{
Sprite sprite = actor != null && actor.isAlive()
? HudIcons.FromActor(actor)
: HudIcons.FromSpeciesId(_current.SpeciesId);
HudIcons.Apply(_speciesIcon, sprite);
}
if (_sexIcon != null)
{
Sprite sexSprite = _current.IsMale
? HudIcons.SexMale()
: (_current.IsFemale ? HudIcons.SexFemale() : null);
HudIcons.Apply(_sexIcon, sexSprite);
_sexIcon.gameObject.SetActive(sexSprite != null);
}
if (actor != null && actor.isAlive())
{
DossierAvatar.Show(actor);
}
else
{
DossierAvatar.ShowSpecies(_current.SpeciesId);
}
DossierAvatar.SetHostVisible(_current.UnitId != 0);
RefreshFavoriteVisual(actor);
}
private static void ApplyStorySpine(string spine)
{
LastStorySpine = spine ?? "";
@ -1271,7 +1436,9 @@ public static class WatchCaption
return;
}
bool show = !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner();
// This row is Caption Context (short-arc spine or durable stake). A status
// banner replaces Beat only, so Context remains visible and live.
bool show = !string.IsNullOrEmpty(LastStorySpine);
_storySpineText.text = show ? LastStorySpine : "";
_storySpineText.color = StorySpineColor;
_storySpineText.gameObject.SetActive(show);
@ -1372,7 +1539,7 @@ public static class WatchCaption
/// <summary>Keep nametag Identity in sync via CaptionComposer (MC title or species/job).</summary>
private static void RefreshLiveIdentity()
{
if (!_visible || _current == null || HasStatusBanner() || _headlineLocked)
if (!_visible || _current == null || _headlineLocked)
{
return;
}
@ -1392,7 +1559,7 @@ public static class WatchCaption
actor,
_current.ReasonLine ?? "",
_current.ReasonRelatedId,
statusBannerOwnsBeat: false);
statusBannerOwnsBeat: HasStatusBanner());
_current.JobLabel = job;
_current.IdentityTag = tag;
@ -2694,7 +2861,7 @@ public static class WatchCaption
y = LifeSagaRail.Place(y, PadX);
}
// Hover depth hides avatar/traits/history/statuses but keeps Identity + Beat + Context.
// Hover depth hides dossier rows but reuses the live avatar for the hovered MC.
SetDossierBodyVisible(!panelMode);
PlaceHeader(y, hasTask);
@ -2772,7 +2939,9 @@ public static class WatchCaption
if (panelMode)
{
LifeSagaPanel.EnsureBuilt(_root.transform);
y = LifeSagaPanel.Place(y, PadX);
DossierAvatar.Place(PadX, y, LiveMax);
DossierAvatar.SetHostVisible(true);
y = LifeSagaPanel.Place(y, PadX + LiveMax + 4f);
if (railChrome)
{
_panelWidth = PanelWidthMax;
@ -3019,10 +3188,29 @@ public static class WatchCaption
fav = Chronicle.IsFavoriteSubject(id) || (_current != null && _current.IsFavorite);
}
long currentId = _current != null ? _current.UnitId : EventFeedUtil.SafeId(actor);
RefreshFavoriteVisualFor(currentId, actor, fav);
}
private static void RefreshFavoriteVisualFor(long unitId, Actor actor, bool fallbackFavorite)
{
if (_favoriteIcon == null)
{
return;
}
bool fav = fallbackFavorite || Chronicle.IsFavoriteSubject(unitId);
try
{
fav = fav || (actor != null && actor.isAlive() && actor.isFavorite());
}
catch
{
// Keep snapshot/Chronicle favorite state.
}
HudIcons.Apply(_favoriteIcon, HudIcons.Favorite());
_favoriteIcon.color = fav
? HudTheme.FavoriteOn
: HudTheme.FavoriteOff;
_favoriteIcon.color = fav ? HudTheme.FavoriteOn : HudTheme.FavoriteOff;
}
private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height)

View file

@ -74,11 +74,15 @@ Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
Selection/`identity_filter` stay event-first.
Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
Status banners still own/suppress Beat; Identity + Context keep updating.
Hover panel tracks the hovered MC; caption Identity/Beat/Context always track camera focus.
Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the
primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite),
while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
running underneath and restores immediately when hover ends.
## Rail UX
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
- Up to 10 slots with unit-keyed inspect-UI portraits. Each character is captured on first
display, refreshed while watched, and retains that latest unique frame after focus leaves.
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
- Active chrome = camera follow MC; Involved chrome = other tip-touched MCs (subject/related/follow/pair/short-arc cast only - not mass `ParticipantIds`).
- Prefer uses a star pip / distinct border.
@ -86,8 +90,11 @@ Hover panel tracks the hovered MC; caption Identity/Beat/Context always track ca
- Rail sits at the top of caption chrome (no tabs).
- Hovering a glyph opens Cast/Legacy depth (`LifeSagaPanel`) and pauses camera switching.
- Leaving the glyph hides the panel, restores dossier body rows, and releases the pause lease.
- Hover never swaps the orange tip to the hovered life.
- Panel is Cast + Legacy only (caption already owns name · title; no portrait / stake chrome).
- Hover temporarily swaps the primary nametag to the hovered life; it does not change camera focus.
- The orange camera Beat and Context are hidden during hover rather than attributed to the hovered life.
- Hover restores the hovered character's live stone-frame dossier portrait beside the panel
(species fallback only when no living actor is available).
- Panel is Cast + Legacy only (the primary nametag owns hovered name/title; no duplicate identity row or stake chrome).
- Cast budget is 8 (two rows); Legacy prefers turning points over Cast-echo kin crumbs.
- No Evidence stats dump. No Open Lore button (press L).
- ParentChild is capped (one named beat, or a lineage summary when there are 3+ children).

View file

@ -72,8 +72,9 @@ While the **current watch tip** is the active arc's climax or story aftermath/ep
ContextLine shows a muted `Kind · Phase` (e.g. `Duel · Aftermath`, `War front · Climax`)
via `StoryPlanner.FormatSpineLabel``CaptionComposer` (wins over stake hitch).
Hidden when idle is off, the dossier is hidden, a status banner owns the reason row,
or the camera has left the story for an unrelated tip (cast membership alone is not enough).
Hidden when idle is off, the dossier is hidden, or the camera has left the story for an
unrelated tip (cast membership alone is not enough). A status banner owns only the Beat row;
Identity and Context continue updating beneath it.
Also hidden when the orange beat already leads with the same Kind at Climax
(`WatchCaption.FilterRedundantStorySpine` filters against the presentable beat before clause enrichment).

View file

@ -0,0 +1,344 @@
# Story system coherence plan
Goal: Saga, Story Planner, Interest Director, and the dossier work as one system for
following the world's main characters. The camera should show a coherent character story,
the caption should explain the current beat without repeating itself, and durable context
should remain correct as relationships and arcs change.
Status: **Implementation complete; one clean full regression passed. Repeated-world soak
hardening remains before the three-pass release gate can be claimed**.
Related design documents:
- [Life Saga](life-saga.md)
- [Story Planner](story-planner.md)
- [Camera presentability](camera-presentability-plan.md)
- [Scoring model](scoring-model.md)
## Product goal
The player should be able to glance at IdleSpectator and answer three questions:
1. **Who is this?** — Identity from the Saga roster and live role data.
2. **What is happening now?** — Beat owned by the current camera tip.
3. **Why does it matter?** — Context from the matching short arc or durable unresolved stake.
Hover turns the dossier into a temporary Saga preview: the primary nametag identifies the
chosen main character, Cast and Legacy describe that life, and camera Beat/Context are hidden
until hover exits. Camera focus itself does not change.
## Ownership model
| Concern | Owner | Contract |
|---------|-------|----------|
| Main-character roster, Prefer, durable importance | `LifeSagaRoster` | Softly influences selection; never locks the camera |
| Durable observed facts and relationships | `LifeSagaMemory` | Survives roster churn; every mutation invalidates derived prose |
| Short climax → aftermath → epilogue arcs | `StoryPlanner` | Owns only candidates correlated to that exact arc |
| Final camera choice and switching | `InterestDirector` | Applies presentability, urgency, holds, and soft Saga bias |
| Identity + Beat + Context | `CaptionComposer``WatchCaption` | Describes the camera subject outside Saga hover |
| Hover nametag + portrait + Cast + Legacy | `LifeSagaViewController``WatchCaption` / `LifeSagaPanel` | Temporarily identifies the hovered Saga subject without changing camera focus; hides camera Beat/Context |
| Full historical record | Chronicle / Lore | Read-only history; never drives the camera |
Pipeline:
```text
Feeds → InterestRegistry → StoryPlanner boost/inject
→ InterestVariety.Pick → InterestDirector → CameraDirector
→ CaptionComposer → WatchCaption
↘ Saga hover Cast/Legacy
```
## Invariants
These are release requirements, not preferences.
1. A synthetic aftermath or epilogue belongs to exactly one arc, identified by correlation.
2. A story candidate cannot advance, receive ownership boost from, or stamp cast into a different arc.
3. `InterestDirector` remains the only owner of camera switching; the planner never calls camera APIs.
4. Saga bias remains soft beneath crisis and active correlated-story ownership.
5. Outside Saga hover, caption Identity, Beat, and Context describe the camera subject.
6. A temporary status banner may replace Beat, but Identity and Context continue updating.
7. Hover pauses camera mutation, binds the primary nametag and live portrait to the hovered MC,
hides camera Beat/Context, and restores the live camera dossier on exit.
8. Cast deduplication uses structured subject/member identity, never display-name substrings alone.
9. Every `LifeSagaMemory` mutation invalidates cached relation and presentation prose.
10. No internal enum, asset, role, correlation, or phase identifier reaches player-facing prose.
11. Text remains inside the hover card at supported UI scales; it neither clips silently nor paints outside its bounds.
12. Regression scenarios control liveness and live-feed noise so a pass or failure reflects the asserted policy.
## Confirmed gaps (resolved by this implementation)
### G1 — Cross-arc story ownership
`BelongsToActiveStoryBeat` validates `CorrelationKey`, but `OwnsCandidate`,
`OwnershipBoost`, `PreferOver`, and `OnSwitchTo` currently treat any story asset as owned by
the active arc. A pending aftermath from parked arc B can therefore win while arc A is active,
advance A, and stamp B's cast into it.
### G2 — Status banner freezes caption composition
`CaptionComposer` supports `statusBannerOwnsBeat`, but `WatchCaption` returns before composing
while a banner is active. Beat is suppressed as intended, but Identity and Context also stop
updating, contrary to the Life Saga contract.
### G3 — Incomplete memory-cache invalidation
New facts bump `SagaProse.MemoryRevision`; updates to existing facts, war/plot resolution, and
rival pruning do not. Cached relation evidence can outlive the facts that produced it.
### G4 — Subject-blind Cast deduplication
The hover panel removes a Cast member when their display name appears in the camera Beat.
This is wrong when the hovered MC differs from the camera subject and is vulnerable to short-name
and substring collisions.
### G5 — Unbounded hover text
The panel caps its measured Legacy height and then enables vertical overflow. This trades
truncation for text escaping the card. Cast names also rely on best-fit inside fixed narrow slots
without a maximum-content visual contract.
### G6 — Internal role fallback
Unknown roles fall back to `Role: {roleId}`, allowing internal taxonomy into the UI.
### G7 — Regression gate is nondeterministic
Current failures mix product behavior with test setup:
- Director tier expectations do not account for production novelty/frequency adjustments.
- Saga subjects may die between successful selection and assertion.
- Live feeds may add new memory immediately after a valid session reset.
- Hover coverage checks that a Beat exists, but does not compare it with the pre-hover Beat.
## Phase A — Correlation-safe story ownership
Do this first; every later presentation decision depends on the planner identifying the right story.
### A1. Central arc resolver
Add one internal resolver for synthetic story candidates:
```text
candidate CorrelationKey
→ matching active arc
→ matching parked arc
→ no owner
```
The resolver must compare the full normalized correlation value, not cast membership, label text,
or partial key matches.
### A2. Use the resolver everywhere
Route these operations through the same result:
- `OwnsCandidate`
- `OwnershipBoost`
- `PreferOver`
- `OnSwitchTo`
- story hold/spine decisions where applicable
Only a candidate owned by the resolved arc may:
- receive short-arc ownership boost;
- beat unrelated candidates through planner preference;
- advance Aftermath/Epilogue;
- add cast or stamp a Saga chapter;
- expose that arc's dossier spine.
### A3. Parked-arc behavior
If the candidate belongs to a live parked arc, resume that arc before switching to the beat.
Do not mutate the unrelated active arc. If resumption is no longer valid, remove or expire the
candidate instead of showing an orphaned ending.
### A4. Focused coverage
Add a scenario with two simultaneous arcs:
| Case | Expect |
|------|--------|
| Arc A active, arc B parked, B aftermath pending | B never advances or stamps A |
| B aftermath wins valid selection | B resumes, then advances to Aftermath |
| Story asset has missing/unknown correlation | no ownership boost; no arc mutation |
| Correct A aftermath pending | retains current preference/hold behavior |
Exit: no story candidate can affect a different arc under any selection order.
## Phase B — Live caption and memory correctness
### B1. Status-banner composition
While a status banner is active:
- preserve the banner in the Beat row;
- compose with `statusBannerOwnsBeat: true`;
- refresh Identity from the live camera subject;
- refresh Context from the currently valid spine or stake;
- keep hover interaction suppressed if that remains the chosen status UX.
Do not clear Saga presentation state merely because Beat is temporarily owned by a banner.
### B2. Memory mutation API
Centralize mutation notification so callers cannot forget cache invalidation. At minimum, bump
the revision when:
- a new fact is inserted or trimmed;
- an existing fact changes kind, resolution, strength, note, other identity, or date;
- war/plot joins resolve;
- stale rivals or other facts are pruned;
- a life or session is cleared.
Prefer a single `MarkMutated`/revision path over scattered direct calls to `SagaProse`.
### B3. Prose fallback
Replace unknown role fallback with neutral authored prose such as `Rose in standing`.
Audit other Saga fallbacks for raw enum/asset/correlation output.
### B4. Focused coverage
| Case | Expect |
|------|--------|
| Role changes during status banner | Identity updates; banner remains Beat |
| Active stake changes during banner | Context updates without replacing banner |
| War/plot resolves after relation was cached | Cast/relation prose changes immediately |
| Rival is pruned after relation was cached | obsolete foe label disappears immediately |
| Unknown role identifier | no raw identifier in caption, Cast, or Legacy |
Exit: caption and relation prose reflect current state within the normal refresh interval.
## Phase C — Hover presentation correctness
### C1. Structured Cast deduplication
Pass or derive structured Beat participants. Suppress a Cast entry only when all are true:
1. hover subject equals camera caption subject;
2. the Cast member's unit ID equals a structured Beat participant/related ID;
3. the Beat already communicates that relationship strongly enough to be redundant.
Never suppress from a display-name substring match. If structured identity is unavailable, retain
the Cast entry.
### C2. Bounded content policy
The hover card is glanceable AFK UI, so prefer a content budget over scrolling:
1. Keep up to two rows of Cast, with measured best-fit text and a readable minimum size.
2. Measure Legacy using the actual Unity text generator or preferred height.
3. Admit Legacy entries in presentation priority order until the remaining line budget is full.
4. End at a complete line; do not enable overflow outside the card.
5. If content is omitted, use a subtle `…` final line only when it adds useful disclosure.
The panel may grow up to its documented maximum, but the rail and caption anchors must remain stable.
### C3. Visual coverage
Capture screenshots for:
- 8 Cast members with long names and long relation labels;
- 6 long Legacy entries with world-date prefixes;
- hovered MC different from camera MC with overlapping Cast names;
- narrow supported resolution/UI scale;
- short, normal content to ensure the card does not reserve empty space.
Exit: no clipping, overlap, off-card paint, false Cast removal, or rail movement.
## Phase D — Deterministic release gate
### D1. Repair existing scenarios
- Give selected Saga test subjects an invincibility/liveness guard through the assertion.
- Isolate director-tier math by clearing variety state or injecting explicit final-score conditions;
assert the computed margin before asserting the cut.
- Validate session reset by proving pre-reset sentinel facts are absent or by holding live feeds,
not by requiring the active world's memory count to remain zero.
- Capture Beat before hover and assert exact equality during hover and after release.
### D2. Add missing scenarios
Recommended scenario names:
- `story_cross_arc_correlation`
- `caption_status_banner_live`
- `saga_relation_cache_revision`
- `saga_cast_dedupe_scoped`
- `saga_hover_max_content`
### D3. Verification order
1. New focused scenarios individually, three consecutive passes each.
2. Existing `story_*`, `saga_*`, caption, director, and dossier scenarios.
3. `critical_smoke`.
4. Full `regression`, three consecutive passes.
5. Screenshot review at normal and narrow supported layouts.
6. Short unattended idle soak; inspect camera churn, orphaned story endings, stale captions,
and panel layout.
Exit: the full gate is repeatable and failures identify product regressions rather than world noise.
## Implementation order
| Order | Work | Why |
|------:|------|-----|
| 1 | Phase A: arc correlation | Protects the core story identity |
| 2 | Phase B1: banner composition | Restores the always-live caption contract |
| 3 | Phase B2B3: memory/prose | Prevents stale or internal context |
| 4 | Phase C: Cast and layout | Removes duplicate/overflow presentation defects |
| 5 | Phase D: deterministic tests | Locks every corrected behavior into the release gate |
Suggested commit boundaries:
1. `fix(story): scope ownership to correlated arcs`
2. `fix(caption): keep identity and context live under banners`
3. `fix(saga): invalidate prose on every memory mutation`
4. `fix(saga): scope cast dedupe and bound hover layout`
5. `test(story): make coherence regression deterministic`
## Definition of done
- A camera Beat can be traced to one current candidate and, when synthetic, one exact arc.
- Parked and active arcs cannot mutate each other.
- Saga Prefer/MC bias helps the player return to important lives without overriding crisis or
correlated hard-story ownership.
- Status banners replace only Beat; Identity and Context remain live.
- Hover makes the primary nametag, Cast, and Legacy describe the hovered MC without changing
camera focus; the live camera dossier restores on exit.
- Cast and Legacy contain no false duplicates, stale relations, raw taxonomy, truncation, or overflow.
- Existing and new focused scenarios pass three times consecutively.
- Full regression passes three times consecutively.
- Visual review passes at normal and narrow supported layouts.
## Non-goals
- Turning Saga Prefer into a hard camera lock.
- Moving camera authority into `StoryPlanner` or the hover UI.
- Replacing Chronicle/Lore with the Saga hover card.
- Making every life event a multi-beat story.
- Expanding the roster beyond its current cap as part of this repair.
- Solving unrelated event-catalog or world-observation coverage gaps.
## Implementation status
| Phase | Status |
|-------|--------|
| A Correlation-safe ownership | Implemented; `story_cross_arc_correlation` passed 3/3 |
| B Live caption and memory | Implemented; banner and relation-revision probes passed 3/3 |
| C Hover presentation | Implemented; scoped dedupe probe passed 3/3; bounded-layout runtime scenarios passed |
| D Deterministic release gate | Implemented for the story system; one clean 19/19 regression passed. The required three consecutive full passes and unattended soak remain open because repeated runs on the same mutated save still expose unrelated legacy Chronicle fixture liveness failures |
## Verification record
- New focused probes passed three consecutive runs each:
`story_cross_arc_correlation`, `caption_status_banner_live`,
`saga_relation_cache_revision`, and `saga_cast_dedupe_scoped`.
- Existing story, Saga, caption, director, dossier, lifecycle, and critical-smoke scenarios
passed individually after the implementation.
- A fresh-state full regression passed **19/19 scenarios** on 2026-07-22.
- Repeated-world stress runs found and repaired registry-capacity isolation, dead-subject,
active-combat, and live-activity fixture leaks. A later repeated run still failed in the
legacy Chronicle death-sample sequence after its focus subject died; this does not invalidate
the clean story-system pass, but it prevents claiming the planned three-pass release gate.