From 086921bd95b5c24fcd974333631f660d0c357bbf Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 22 Jul 2026 19:26:58 -0500 Subject: [PATCH] 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 --- IdleSpectator/AgentHarness.cs | 229 +++++++++++- IdleSpectator/EventLivePipelinesHarness.cs | 77 +++- IdleSpectator/HarnessScenarios.cs | 158 ++++++-- .../InterestDirector.SwitchPolicy.cs | 3 + IdleSpectator/InterestDirector.cs | 8 + IdleSpectator/Story/LifeSagaMemory.cs | 29 +- IdleSpectator/Story/LifeSagaPanel.cs | 180 ++++++--- IdleSpectator/Story/LifeSagaRail.cs | 62 +++- IdleSpectator/Story/SagaProse.cs | 3 +- IdleSpectator/Story/StoryPlanner.cs | 242 +++++++++++- IdleSpectator/WatchCaption.cs | 222 ++++++++++- docs/life-saga.md | 15 +- docs/story-planner.md | 5 +- docs/story-system-coherence-plan.md | 344 ++++++++++++++++++ 14 files changed, 1425 insertions(+), 152 deletions(-) create mode 100644 docs/story-system-coherence-plan.md diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 071611c..ae5042a 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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; } diff --git a/IdleSpectator/EventLivePipelinesHarness.cs b/IdleSpectator/EventLivePipelinesHarness.cs index 6d5b158..8a0ba16 100644 --- a/IdleSpectator/EventLivePipelinesHarness.cs +++ b/IdleSpectator/EventLivePipelinesHarness.cs @@ -1562,7 +1562,9 @@ public static class EventLivePipelinesHarness private static void RunBuildingComplete(Actor unit, List 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 { diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index d026580..3d7ac43 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 StoryCrossArcCorrelation() + { + return new List + { + Step("scac0", "dismiss_windows"), + Step("scac1", "wait_world"), + Step("scac2", "story_cross_arc_probe"), + Step("scac3", "assert", expect: "no_bad"), + Step("scac99", "snapshot"), + }; + } + /// /// Parenthood cools world-wide for a while; a second villager cannot chain the same tip. /// @@ -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"), }; } - /// Character beat caption: MC identity title, prose bans, hover keeps beat. + private static List SagaRelationCacheRevision() + { + return new List + { + 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 SagaCastDedupeScoped() + { + return new List + { + Step("scds0", "wait_world"), + Step("scds1", "saga_cast_dedupe_probe"), + Step("scds2", "assert", expect: "no_bad"), + Step("scds99", "snapshot"), + }; + } + + /// Character beat caption: live MC identity, prose bans, hover keeps beat. private static List CaptionCharacterBeat() { return new List @@ -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 CaptionStatusBannerLive() + { + return new List + { + 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"), + }; + } + /// SagaProse cast/stake voice across bond, foe, plot partner classes. private static List 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), diff --git a/IdleSpectator/InterestDirector.SwitchPolicy.cs b/IdleSpectator/InterestDirector.SwitchPolicy.cs index a96eefc..fa619cb 100644 --- a/IdleSpectator/InterestDirector.SwitchPolicy.cs +++ b/IdleSpectator/InterestDirector.SwitchPolicy.cs @@ -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 diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index cc4fba7..f87bf0e 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -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 diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs index c13ca4e..8e2d174 100644 --- a/IdleSpectator/Story/LifeSagaMemory.cs +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -29,6 +29,15 @@ public static class LifeSagaMemory public static int WorldEpoch => _worldEpoch; public static int LifeCount => Lives.Count; + /// + /// One mutation boundary for every durable fact change. Presentation and relation + /// caches must never outlive the memory state that produced their prose. + /// + 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 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) diff --git a/IdleSpectator/Story/LifeSagaPanel.cs b/IdleSpectator/Story/LifeSagaPanel.cs index 08ba292..38e26e8 100644 --- a/IdleSpectator/Story/LifeSagaPanel.cs +++ b/IdleSpectator/Story/LifeSagaPanel.cs @@ -8,15 +8,17 @@ namespace IdleSpectator; /// /// 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. /// public static class LifeSagaPanel { - /// Matches WatchCaption rail chrome inner width (PanelWidthMax - pads). - public const float BodyWidthFixed = 232f; + /// Remaining hover width beside the 40px live portrait. + public const float BodyWidthFixed = 188f; /// Hard cap so Saga never eats the screen. public const float BodyHeightMax = 172f; @@ -28,7 +30,7 @@ public static class LifeSagaPanel /// Shared cast budget for panel slots and presentation model. 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 LegacyParts = new List(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; - /// Always false - caption owns identity; panel never steals DossierAvatar. - public static bool OwnsAvatar => false; + /// Saga hover reuses the dossier avatar for the hovered character. + 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(MaxLegacy); + LegacyParts.Clear(); var seen = new HashSet(); 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); } - /// The live Beat already explains the person currently on screen. - private static bool CastDuplicatesCurrentBeat(LifeSagaCastMember member) + /// The camera Beat already explains this exact member for this exact subject. + 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; + } + + /// Harness: structured Cast dedupe truth table without display-name parsing. + 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]; diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs index c5903b4..820f167 100644 --- a/IdleSpectator/Story/LifeSagaRail.cs +++ b/IdleSpectator/Story/LifeSagaRail.cs @@ -21,6 +21,7 @@ public static class LifeSagaRail private static readonly List SlotScratch = new List(MaxSlots); private static readonly List InvolvedScratch = new List(8); + private static readonly Dictionary LastFaces = new Dictionary(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; } + /// Harness: unit-keyed last-face snapshots retained for the current roster. + 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(); + 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(); + 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) diff --git a/IdleSpectator/Story/SagaProse.cs b/IdleSpectator/Story/SagaProse.cs index add44cb..6f1efcb 100644 --- a/IdleSpectator/Story/SagaProse.cs +++ b/IdleSpectator/Story/SagaProse.cs @@ -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) diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs index 9cd40b4..afbb577 100644 --- a/IdleSpectator/Story/StoryPlanner.cs +++ b/IdleSpectator/Story/StoryPlanner.cs @@ -499,6 +499,108 @@ public static class StoryPlanner return null; } + /// + /// 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. + /// + 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); + } + + /// + /// Selection defense-in-depth for expired/orphaned synthetic story beats. + /// Crisis closers use their crisis chapter instead of the short-arc board. + /// + 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))); + /// Harness: prove a parked closer resumes only its correlated arc. + 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; + } + /// /// 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; } } - diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index d4e4bdb..f6b75cc 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -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; + : ""; + + /// Structured unit id named by the currently composed camera Beat. + internal static long VisibleReasonRelatedId => + _reasonText != null && _reasonText.gameObject.activeSelf && _current != null + ? _current.ReasonRelatedId + : 0; /// Harness: composed context line (spine or stake). 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 /// 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()); } /// Harness: dossier nametag/reason visible after leaving Saga hover. @@ -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 /// Keep nametag Identity in sync via CaptionComposer (MC title or species/job). 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) diff --git a/docs/life-saga.md b/docs/life-saga.md index 8e08f92..9da6af8 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -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). diff --git a/docs/story-planner.md b/docs/story-planner.md index 62e5e01..e266557 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -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). diff --git a/docs/story-system-coherence-plan.md b/docs/story-system-coherence-plan.md new file mode 100644 index 0000000..5df1381 --- /dev/null +++ b/docs/story-system-coherence-plan.md @@ -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 B2–B3: 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.