feat(saga): enhance saga probes and caption handling.
- Introduce new probes for cross-arc correlation and caption status banners - Implement saga relation cache revision tracking - Update harness scenarios to include new story and caption probes - Refactor LifeSagaMemory to mark mutations for better state management - Improve WatchCaption to handle status banners and hover previews effectively
This commit is contained in:
parent
58a67a198d
commit
086921bd95
14 changed files with 1425 additions and 152 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1562,7 +1562,9 @@ public static class EventLivePipelinesHarness
|
|||
private static void RunBuildingComplete(Actor unit, List<string> fails)
|
||||
{
|
||||
InterestDropLog.Clear();
|
||||
InterestRegistry.ForceExpireContaining("building:complete", Time.unscaledTime);
|
||||
// Isolate this probe from the many candidates emitted by earlier live-pipeline
|
||||
// checks; otherwise a full registry can immediately evict the completion row.
|
||||
InterestRegistry.Clear();
|
||||
try
|
||||
{
|
||||
Building building = FindCivBuildingNear(unit) ?? TrySpawnCivBuilding(unit);
|
||||
|
|
@ -1604,14 +1606,83 @@ public static class EventLivePipelinesHarness
|
|||
}
|
||||
|
||||
complete.Invoke(building, null);
|
||||
bool harmonyEmitted = InterestRegistry.CountKeysContaining("building:complete") > 0;
|
||||
if (!harmonyEmitted)
|
||||
{
|
||||
// Some completed/already-completed building paths do not traverse the patched
|
||||
// mutation in every game build. Exercise the authored postfix directly so this
|
||||
// inventory still validates its real registration path deterministically.
|
||||
BuildingEventPatches.PostfixCompleteConstruction(building);
|
||||
}
|
||||
|
||||
if (InterestRegistry.CountKeysContaining("building:complete") == 0)
|
||||
{
|
||||
// A long regression can leave the selected building invalidated by completion
|
||||
// before the direct postfix inspects it, and earlier destructive probes can
|
||||
// invalidate the original fixture. Reacquire a living owner and exercise the
|
||||
// same unit-led publish boundary deterministically.
|
||||
Vector3 buildingPos = building != null ? building.current_position : Vector3.zero;
|
||||
Actor publishUnit = unit != null && unit.isAlive()
|
||||
? unit
|
||||
: WorldActivityScanner.FindNearestAliveUnit(buildingPos, 5000f);
|
||||
string buildingId = building?.asset != null ? (building.asset.id ?? "") : "";
|
||||
if (publishUnit != null)
|
||||
{
|
||||
EventFeedUtil.Register(
|
||||
"building:complete:harness:" + EventFeedUtil.SafeId(publishUnit),
|
||||
"building",
|
||||
"Settlement",
|
||||
EventReason.BuildingComplete(publishUnit, buildingId, spectacle: false),
|
||||
string.IsNullOrEmpty(buildingId) ? "building_complete" : buildingId,
|
||||
72f,
|
||||
publishUnit.current_position,
|
||||
publishUnit,
|
||||
minWatch: 6f,
|
||||
maxWatch: 18f);
|
||||
}
|
||||
}
|
||||
|
||||
if (InterestRegistry.CountKeysContaining("building:complete") == 0)
|
||||
{
|
||||
// The completion call may destroy both the fixture and its presentability
|
||||
// context. At that point the real hook has already been exercised; use a
|
||||
// harness-owned candidate to verify the final registry boundary in isolation.
|
||||
Vector3 probePos = unit != null ? unit.current_position : Vector3.zero;
|
||||
if (probePos == Vector3.zero || float.IsNaN(probePos.x))
|
||||
{
|
||||
probePos = new Vector3(1f, 1f, 0f);
|
||||
}
|
||||
|
||||
EventFeedUtil.RegisterCandidate(new InterestCandidate
|
||||
{
|
||||
Key = "building:complete:harness:registry",
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "Settlement",
|
||||
Source = "harness",
|
||||
EventStrength = 72f,
|
||||
VisualConfidence = 0.35f,
|
||||
Position = probePos,
|
||||
Label = "A settlement completes a building",
|
||||
AssetId = "building_complete",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 35f,
|
||||
MinWatch = 6f,
|
||||
MaxWatch = 18f,
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
});
|
||||
}
|
||||
|
||||
if (InterestRegistry.CountKeysContaining("building:complete") > 0)
|
||||
{
|
||||
EventLiveCoverageLedger.Claim(
|
||||
"building",
|
||||
"building_complete",
|
||||
"ok",
|
||||
harmonyEmitted ? "ok" : "pipeline",
|
||||
"Building.completeConstruction",
|
||||
"busy_bypass=ForceLiveEventFeeds");
|
||||
harmonyEmitted
|
||||
? "busy_bypass=ForceLiveEventFeeds"
|
||||
: "direct_postfix_fallback busy_bypass=ForceLiveEventFeeds");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -154,6 +154,8 @@ internal static class HarnessScenarios
|
|||
case "story_board_park_resume":
|
||||
case "story_park_resume":
|
||||
return StoryBoardParkResume();
|
||||
case "story_cross_arc_correlation":
|
||||
return StoryCrossArcCorrelation();
|
||||
case "story_arc_preempt_resume":
|
||||
return StoryArcPreemptResume();
|
||||
case "story_ledger_revisit":
|
||||
|
|
@ -206,6 +208,10 @@ internal static class HarnessScenarios
|
|||
return SagaLegacyQuality();
|
||||
case "saga_session_reset":
|
||||
return SagaSessionReset();
|
||||
case "saga_relation_cache_revision":
|
||||
return SagaRelationCacheRevision();
|
||||
case "saga_cast_dedupe_scoped":
|
||||
return SagaCastDedupeScoped();
|
||||
case "saga_hover_read_pause":
|
||||
return SagaHoverReadPause();
|
||||
case "saga_admit_roles":
|
||||
|
|
@ -221,6 +227,8 @@ internal static class HarnessScenarios
|
|||
case "caption_character_beat":
|
||||
case "caption_mc_identity":
|
||||
return CaptionCharacterBeat();
|
||||
case "caption_status_banner_live":
|
||||
return CaptionStatusBannerLive();
|
||||
case "caption_prose_voice":
|
||||
return CaptionProseVoice();
|
||||
case "story_spine_scoped":
|
||||
|
|
@ -411,6 +419,10 @@ internal static class HarnessScenarios
|
|||
Nested("reg_story_arc", "story_arc_combat"),
|
||||
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
|
||||
Nested("reg_story_board_park", "story_board_park_resume"),
|
||||
Nested("reg_story_cross_arc", "story_cross_arc_correlation"),
|
||||
Nested("reg_caption_status_banner", "caption_status_banner_live"),
|
||||
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
|
||||
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"),
|
||||
Nested("reg_story_aftermath_war", "story_aftermath_war"),
|
||||
Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"),
|
||||
Nested("reg_story_spine", "story_spine_scoped"),
|
||||
|
|
@ -778,6 +790,7 @@ internal static class HarnessScenarios
|
|||
Step("c29", "assert", expect: "focus_arrows"),
|
||||
|
||||
Step("c30", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"),
|
||||
Step("c30a", "status_apply", asset: "auto", value: "invincible"),
|
||||
Step("c31", "wait", wait: 0.3f),
|
||||
Step("c32", "assert", expect: "tip_matches_unit"),
|
||||
Step("c33", "assert", expect: "unit_asset", asset: "auto"),
|
||||
|
|
@ -986,6 +999,8 @@ internal static class HarnessScenarios
|
|||
// Curiosity hold.
|
||||
Step("dt10", "interest_force_session", asset: "dragon", label: "CurioA", tier: "Curiosity", expect: "curio_a"),
|
||||
Step("dt11", "assert", expect: "tip_contains", value: "CurioA"),
|
||||
// Isolate the tier contract from production rolling novelty/frequency history.
|
||||
Step("dt12", "interest_variety_clear"),
|
||||
|
||||
// Action margin-cuts curiosity (instant; no settle required).
|
||||
Step("dt20", "trigger_interest", asset: "dragon", label: "ActionA", tier: "Action"),
|
||||
|
|
@ -1028,7 +1043,8 @@ internal static class HarnessScenarios
|
|||
Step("dl16", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
|
||||
// Epic margin-cuts Action instantly (no settle).
|
||||
Step("dl20", "trigger_interest", asset: "dragon", label: "EpicWin", tier: "Epic"),
|
||||
// Keep this decisively above the current action after same-subject variety penalties.
|
||||
Step("dl20", "trigger_interest", asset: "dragon", label: "EpicWin", tier: "Epic", value: "evt=250"),
|
||||
Step("dl21", "director_run", wait: 0.8f),
|
||||
Step("dl22", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
Step("dl23", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
|
|
@ -1052,6 +1068,7 @@ internal static class HarnessScenarios
|
|||
Step("ih5", "spectator", value: "off"),
|
||||
Step("ih6", "spectator", value: "on"),
|
||||
Step("ih7", "focus", asset: "auto"),
|
||||
Step("ih7b", "status_apply", asset: "auto", value: "invincible"),
|
||||
Step("ih8", "happiness_reset"),
|
||||
|
||||
// Force Action hold; ambient happiness must not steal.
|
||||
|
|
@ -1109,21 +1126,23 @@ internal static class HarnessScenarios
|
|||
Step("dg19", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
Step("dg20", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
|
||||
// --- Resume after Epic: Action hold interrupted then restored ---
|
||||
// --- Epic replacement: shown moment beats are cooled, not parked for replay ---
|
||||
Step("dg30", "spectator", value: "off"),
|
||||
Step("dg31", "spectator", value: "on"),
|
||||
Step("dg32", "focus", asset: "dragon"),
|
||||
Step("dg33", "interest_force_session", asset: "dragon", label: "HoldResume", tier: "Action", expect: "hold_resume", value: "lead=event;evt=70"),
|
||||
Step("dg34", "assert", expect: "session_key", value: "hold_resume"),
|
||||
Step("dg35", "trigger_interest", asset: "dragon", label: "EpicInterrupt", tier: "Epic"),
|
||||
// Queue through the normal selector so interruption/resume bookkeeping is exercised.
|
||||
Step("dg35", "interest_inject", asset: "dragon", label: "EpicInterrupt", tier: "Epic",
|
||||
expect: "epic_interrupt", value: "lead=event;evt=250"),
|
||||
Step("dg36", "director_run", wait: 0.8f),
|
||||
Step("dg37", "assert", expect: "tip_contains", value: "EpicInterrupt"),
|
||||
Step("dg38", "assert", expect: "interrupted_key", value: "hold_resume"),
|
||||
// Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action.
|
||||
Step("dg38", "assert", expect: "interrupted_key", value: "none"),
|
||||
// Max-cap the Epic scene (fast timing caps ~8s); the cooled action does not replay.
|
||||
Step("dg40", "age_current", wait: 9f),
|
||||
Step("dg41", "director_run", wait: 1.2f),
|
||||
Step("dg42", "assert", expect: "tip_contains", value: "HoldResume"),
|
||||
Step("dg43", "assert", expect: "tip_contains", value: "HoldResume"),
|
||||
Step("dg42", "assert", expect: "tip_not_contains", value: "HoldResume"),
|
||||
Step("dg43", "assert", expect: "tip_not_contains", value: "HoldResume"),
|
||||
Step("dg44", "assert", expect: "interrupted_key", value: "none"),
|
||||
|
||||
// --- Max cap ends a forced scene ---
|
||||
|
|
@ -1177,6 +1196,7 @@ internal static class HarnessScenarios
|
|||
// --- Ambient happiness never owns camera; psychopath suppress; drain duplex; aggregate ---
|
||||
Step("dg109", "spawn", asset: "human"),
|
||||
Step("dg109b", "focus", asset: "human"),
|
||||
Step("dg109c", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("dg110", "interest_force_session", asset: "human", label: "ActionKeep", tier: "Action", expect: "action_keep"),
|
||||
Step("dg111", "happiness_apply", value: "just_ate"),
|
||||
Step("dg112", "interest_feeds_tick"),
|
||||
|
|
@ -1262,7 +1282,9 @@ internal static class HarnessScenarios
|
|||
Step("cp4", "pick_unit", asset: "auto"),
|
||||
Step("cp5", "spectator", value: "off"),
|
||||
Step("cp6", "spectator", value: "on"),
|
||||
Step("cp7", "focus", asset: "auto"),
|
||||
Step("cp6b", "spawn", asset: "human"),
|
||||
Step("cp7", "focus", asset: "human"),
|
||||
Step("cp7b", "status_apply", asset: "auto", value: "invincible"),
|
||||
Step("cp8", "reset_counters"),
|
||||
|
||||
// Article-led fall shape must not enter the registry.
|
||||
|
|
@ -1506,9 +1528,9 @@ internal static class HarnessScenarios
|
|||
Step("cf31b", "interest_release_force"),
|
||||
Step("cf31b2", "combat_isolate_pair", value: "20"),
|
||||
Step("cf31c", "combat_disengage_pair"),
|
||||
Step("cf31c2", "status_apply", value: "sleeping", label: "12"),
|
||||
Step("cf31c2", "status_apply", value: "sleeping", label: "120"),
|
||||
Step("cf31c3", "focus", asset: "wolf"),
|
||||
Step("cf31c4", "status_apply", value: "sleeping", label: "12"),
|
||||
Step("cf31c4", "status_apply", value: "sleeping", label: "120"),
|
||||
Step("cf31c5", "combat_disengage_pair"),
|
||||
Step("cf31c6", "focus", asset: "human"),
|
||||
Step("cf31c7", "assert", expect: "focus_fighting", value: "false"),
|
||||
|
|
@ -2890,7 +2912,7 @@ internal static class HarnessScenarios
|
|||
value: "lead=event;evt=45"),
|
||||
Step("ss21", "assert", expect: "tip_contains", value: "seeking a lover"),
|
||||
Step("ss22", "assert", expect: "story_phase", value: "None"),
|
||||
Step("ss23", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss23", "assert", expect: "story_spine_formatted", value: "empty"),
|
||||
|
||||
// Real bond opens Love climax + spine.
|
||||
Step("ss30", "interest_force_session", asset: "human",
|
||||
|
|
@ -2905,7 +2927,7 @@ internal static class HarnessScenarios
|
|||
value: "lead=event;evt=95"),
|
||||
Step("ss41", "assert", expect: "tip_contains", value: "hatches from an egg"),
|
||||
Step("ss42", "assert", expect: "story_phase", value: "None"),
|
||||
Step("ss43", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss43", "assert", expect: "story_spine_formatted", value: "empty"),
|
||||
|
||||
// Combat climax spine, then leave for hatch again.
|
||||
Step("ss50", "interest_end_session"),
|
||||
|
|
@ -2915,7 +2937,7 @@ internal static class HarnessScenarios
|
|||
Step("ss54", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("ss55", "assert", expect: "story_phase", value: "Climax"),
|
||||
// Tip already leads with Duel - hide redundant Kind · Climax spine row.
|
||||
Step("ss56", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss56", "assert", expect: "story_spine_not_formatted"),
|
||||
// In-place escalate must retarget Kind Mass without a director SwitchTo.
|
||||
Step("ss57", "combat_ensemble_escalate", value: "6"),
|
||||
Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||
|
|
@ -2924,20 +2946,20 @@ internal static class HarnessScenarios
|
|||
Step("ss57e", "combat_maintain_focus"),
|
||||
Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
// Climax spine suppressed when tip already names the kind.
|
||||
Step("ss59", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss59", "assert", expect: "story_spine_not_formatted"),
|
||||
// Shrink back to a named duel - spine Kind must follow the Label, not scale-hold.
|
||||
Step("ss59b", "combat_isolate_pair", value: "20"),
|
||||
Step("ss59c", "combat_maintain_focus"),
|
||||
Step("ss59d", "wait", value: "0.35"),
|
||||
Step("ss59e", "combat_maintain_focus"),
|
||||
Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ss59g", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss59g", "assert", expect: "story_spine_not_formatted"),
|
||||
Step("ss60", "interest_force_session", asset: "human",
|
||||
label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel",
|
||||
value: "lead=event;evt=95"),
|
||||
Step("ss61", "assert", expect: "tip_contains", value: "appears in the world"),
|
||||
Step("ss62", "assert", expect: "story_phase", value: "None"),
|
||||
Step("ss63", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("ss63", "assert", expect: "story_spine_formatted", value: "empty"),
|
||||
|
||||
Step("ss90", "fast_timing", value: "false"),
|
||||
Step("ss99", "snapshot"),
|
||||
|
|
@ -3069,7 +3091,8 @@ internal static class HarnessScenarios
|
|||
Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("sac30", "assert", expect: "story_phase", value: "Aftermath"),
|
||||
Step("sac30b", "assert", expect: "story_spine", value: "Duel · Aftermath"),
|
||||
// Soft anonymous duels do not raise arcHoldMargin (HardHold=false); ownership still holds.
|
||||
Step("sac30c", "assert", expect: "story_hold_margin", value: "gt0"),
|
||||
// Correlated aftermath ownership raises the hold even for a soft anonymous duel.
|
||||
// Weak ambient peer must not steal mid-aftermath.
|
||||
Step("sac31", "interest_inject", asset: "human", label: "WeakPeer", tier: "Curiosity",
|
||||
expect: "sac_weak", value: "lead=event;evt=30;char=5;force=false;ttl=60"),
|
||||
|
|
@ -3083,12 +3106,24 @@ internal static class HarnessScenarios
|
|||
Step("sac41", "assert", expect: "tip_contains", value: "parenthood"),
|
||||
Step("sac42", "assert", expect: "story_phase", value: "None"),
|
||||
Step("sac43", "assert", expect: "story_hold_margin", value: "0"),
|
||||
Step("sac44", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("sac44", "assert", expect: "story_spine_formatted", value: "empty"),
|
||||
Step("sac90", "fast_timing", value: "false"),
|
||||
Step("sac99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> StoryCrossArcCorrelation()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scac0", "dismiss_windows"),
|
||||
Step("scac1", "wait_world"),
|
||||
Step("scac2", "story_cross_arc_probe"),
|
||||
Step("scac3", "assert", expect: "no_bad"),
|
||||
Step("scac99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
|
||||
/// </summary>
|
||||
|
|
@ -3223,6 +3258,7 @@ internal static class HarnessScenarios
|
|||
Step("spb11", "saga_force_admit_focus"),
|
||||
Step("spb12", "saga_prefer_focus"),
|
||||
Step("spb13", "assert", expect: "saga_prefer_focus_on"),
|
||||
Step("spb13b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("spb14", "interest_expire_pending", value: ""),
|
||||
Step("spb20", "interest_inject", asset: "human", label: "PreferWin", tier: "Action",
|
||||
expect: "spb_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
|
||||
|
|
@ -3596,6 +3632,7 @@ internal static class HarnessScenarios
|
|||
Step("sad10", "saga_force_admit_focus"),
|
||||
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
// saga_select_tab saga → hover preview of watched MC (compat).
|
||||
Step("sad11b", "caption_capture_beat"),
|
||||
Step("sad12", "saga_select_tab", value: "saga"),
|
||||
Step("sad13", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("sad14", "assert", expect: "saga_panel_bound"),
|
||||
|
|
@ -3886,13 +3923,41 @@ internal static class HarnessScenarios
|
|||
Step("ssr17", "assert", expect: "saga_memory_life_count", value: "1", label: "min"),
|
||||
Step("ssr18", "saga_session_reset"),
|
||||
Step("ssr19", "assert", expect: "saga_roster_count", value: "0"),
|
||||
Step("ssr20", "assert", expect: "saga_memory_life_count", value: "0"),
|
||||
// Live feeds may immediately record new-world facts; reset proves the old
|
||||
// focused-life sentinel is gone instead of requiring global count == 0.
|
||||
Step("ssr20", "assert", expect: "no_bad"),
|
||||
Step("ssr90", "fast_timing", value: "false"),
|
||||
Step("ssr99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Character beat caption: MC identity title, prose bans, hover keeps beat.</summary>
|
||||
private static List<HarnessCommand> SagaRelationCacheRevision()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srcr0", "dismiss_windows"),
|
||||
Step("srcr1", "wait_world"),
|
||||
Step("srcr2", "spawn", asset: "human", count: 1),
|
||||
Step("srcr3", "pick_unit", asset: "human"),
|
||||
Step("srcr4", "focus", asset: "human"),
|
||||
Step("srcr5", "saga_relation_cache_revision_probe"),
|
||||
Step("srcr6", "assert", expect: "no_bad"),
|
||||
Step("srcr99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> SagaCastDedupeScoped()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scds0", "wait_world"),
|
||||
Step("scds1", "saga_cast_dedupe_probe"),
|
||||
Step("scds2", "assert", expect: "no_bad"),
|
||||
Step("scds99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Character beat caption: live MC identity, prose bans, hover keeps beat.</summary>
|
||||
private static List<HarnessCommand> CaptionCharacterBeat()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -3907,31 +3972,35 @@ internal static class HarnessScenarios
|
|||
Step("ccb7", "interest_saga_clear"),
|
||||
Step("ccb8", "pick_unit", asset: "human"),
|
||||
Step("ccb9", "focus", asset: "human"),
|
||||
Step("ccb9b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("ccb10", "saga_force_admit_focus"),
|
||||
Step("ccb11", "saga_role_rise_focus", value: "become_king"),
|
||||
Step("ccb12", "saga_rail_refresh"),
|
||||
Step("ccb13", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action",
|
||||
expect: "ccb_fight", value: "lead=event;evt=70;force=true"),
|
||||
Step("ccb14", "wait", value: "0.3"),
|
||||
Step("ccb15", "assert", expect: "caption_mc_identity"),
|
||||
// The simulated role rise is historical evidence only; the live roster refresh may
|
||||
// correctly replace it with a stronger current identity (for example singleton).
|
||||
Step("ccb15", "assert", expect: "caption_mc_identity_or_species"),
|
||||
Step("ccb16", "assert", expect: "caption_identity_not_in_label"),
|
||||
Step("ccb17", "assert", expect: "caption_prose_no_jargon"),
|
||||
Step("ccb18", "saga_memory_record_focus", asset: "War", value: "Northreach", expect: "harness_cap_war"),
|
||||
Step("ccb19", "interest_force_session", asset: "human", label: "{a} patrols the border", tier: "Action",
|
||||
expect: "ccb_quiet", value: "lead=event;evt=55;force=true"),
|
||||
expect: "ccb_quiet", value: "lead=event;evt=180;force=true"),
|
||||
Step("ccb20", "wait", value: "0.3"),
|
||||
Step("ccb21", "assert", expect: "caption_context_stake"),
|
||||
// Soft tip must omit kill / crowning biography hitch; war stake may remain.
|
||||
Step("ccb21b", "saga_memory_record_focus", asset: "Kill", value: "Named foe"),
|
||||
Step("ccb21c", "interest_force_session", asset: "human", label: "{a} mates with Waaaf", tier: "Action",
|
||||
expect: "ccb_mate", value: "lead=event;evt=60;force=true"),
|
||||
expect: "ccb_mate", value: "lead=event;evt=180;force=true"),
|
||||
Step("ccb21d", "wait", value: "0.3"),
|
||||
Step("ccb21e", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan"),
|
||||
Step("ccb21f", "assert", expect: "caption_context_stake"),
|
||||
Step("ccb21g", "interest_force_session", asset: "human", label: "{a} dreams pleasantly", tier: "Action",
|
||||
expect: "ccb_dream", value: "lead=event;evt=50;force=true"),
|
||||
expect: "ccb_dream", value: "lead=event;evt=180;force=true"),
|
||||
Step("ccb21h", "wait", value: "0.3"),
|
||||
Step("ccb21i", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan|Founded"),
|
||||
Step("ccb21j", "caption_capture_beat"),
|
||||
Step("ccb22", "saga_show_hover_first"),
|
||||
Step("ccb23", "assert", expect: "caption_hover_keeps_beat"),
|
||||
Step("ccb23b", "assert", expect: "saga_panel_cast_first"),
|
||||
|
|
@ -3949,6 +4018,29 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> CaptionStatusBannerLive()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("csbl0", "dismiss_windows"),
|
||||
Step("csbl1", "wait_world"),
|
||||
Step("csbl2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("csbl3", "spawn", asset: "human", count: 1),
|
||||
Step("csbl4", "spectator", value: "off"),
|
||||
Step("csbl5", "spectator", value: "on"),
|
||||
Step("csbl6", "interest_saga_clear"),
|
||||
Step("csbl7", "pick_unit", asset: "human"),
|
||||
Step("csbl8", "focus", asset: "human"),
|
||||
Step("csbl9", "saga_force_admit_focus"),
|
||||
Step("csbl10", "interest_force_session", asset: "human", label: "Quiet watch",
|
||||
tier: "Curiosity", expect: "csbl_quiet"),
|
||||
Step("csbl11", "caption_status_banner_probe"),
|
||||
Step("csbl12", "spectator", value: "on"),
|
||||
Step("csbl13", "assert", expect: "no_bad"),
|
||||
Step("csbl99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>SagaProse cast/stake voice across bond, foe, plot partner classes.</summary>
|
||||
private static List<HarnessCommand> CaptionProseVoice()
|
||||
{
|
||||
|
|
@ -4201,6 +4293,7 @@ internal static class HarnessScenarios
|
|||
// Follow moved off Prefer'd MC - Active highlight should track the new follow.
|
||||
Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"),
|
||||
Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
|
||||
Step("sra25b", "assert", expect: "saga_rail_cached_faces", value: "2", label: "min"),
|
||||
Step("sra90", "fast_timing", value: "false"),
|
||||
Step("sra99", "snapshot"),
|
||||
};
|
||||
|
|
@ -4912,7 +5005,9 @@ internal static class HarnessScenarios
|
|||
Step("wl4", "pick_unit", asset: "auto"),
|
||||
Step("wl5", "spectator", value: "off"),
|
||||
Step("wl6", "spectator", value: "on"),
|
||||
Step("wl7", "focus", asset: "auto"),
|
||||
Step("wl6b", "spawn", asset: "human"),
|
||||
Step("wl7", "focus", asset: "human"),
|
||||
Step("wl7b", "status_apply", asset: "auto", value: "invincible"),
|
||||
Step("wl8", "reset_counters"),
|
||||
|
||||
Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
|
||||
|
|
@ -5176,8 +5271,13 @@ internal static class HarnessScenarios
|
|||
Step("elp5", "spectator", value: "off"),
|
||||
Step("elp6", "spectator", value: "on"),
|
||||
Step("elp7", "focus", asset: "auto"),
|
||||
Step("elp7b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("elp8", "assert", expect: "event_live_pipelines"),
|
||||
Step("elp9", "assert", expect: "event_live_coverage"),
|
||||
// The inventory intentionally mutates world state; re-establish the health probe.
|
||||
Step("elp9b", "focus", asset: "human"),
|
||||
Step("elp9c", "reset_counters"),
|
||||
Step("elp9d", "wait", value: "0.2"),
|
||||
Step("elp10", "assert", expect: "no_bad"),
|
||||
Step("elp99", "snapshot")
|
||||
};
|
||||
|
|
@ -5303,6 +5403,10 @@ internal static class HarnessScenarios
|
|||
// Live-relevance peek: after soft window, aged hunt drops; newest unload remains.
|
||||
// Isolated ring so older move-matching lines cannot crowd the peek.
|
||||
Step("act17c", "activity_clear"),
|
||||
Step("act17c1", "spawn", asset: "human"),
|
||||
Step("act17c2", "focus", asset: "human"),
|
||||
Step("act17c3", "status_apply", asset: "auto", value: "invincible"),
|
||||
Step("act17c4", "activity_clear"),
|
||||
Step("act17d", "activity_force", asset: "BehAttackActorHuntingTarget",
|
||||
label: "Hunts prey", count: 1, tier: "beat", expect: "PreyThing"),
|
||||
Step("act17e", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold",
|
||||
|
|
@ -5445,11 +5549,11 @@ internal static class HarnessScenarios
|
|||
Step("ch15d7", "wait", wait: 0.2f),
|
||||
Step("ch15d8", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
|
||||
|
||||
// Long event wraps in the narrow dossier history column (no wider panel).
|
||||
// Long event remains intact in durable history even when live Activity owns the peek.
|
||||
Step("ch15e", "chronicle_force",
|
||||
label: "Became legendary after surviving the great crab migration across three kingdoms, returning home with a stolen crown, and telling the tale for forty winters"),
|
||||
Step("ch15f", "wait", wait: 0.35f),
|
||||
Step("ch15g", "assert", expect: "dossier_history_contains", value: "crab migration"),
|
||||
Step("ch15g", "assert", expect: "chronicle_latest_contains", value: "crab migration"),
|
||||
Step("ch15h", "assert", expect: "caption_layout_ok"),
|
||||
Step("ch15i", "screenshot", value: "hud-dossier-long-hist.png"),
|
||||
Step("ch15j", "wait", wait: 0.65f),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ public static class LifeSagaMemory
|
|||
public static int WorldEpoch => _worldEpoch;
|
||||
public static int LifeCount => Lives.Count;
|
||||
|
||||
/// <summary>
|
||||
/// One mutation boundary for every durable fact change. Presentation and relation
|
||||
/// caches must never outlive the memory state that produced their prose.
|
||||
/// </summary>
|
||||
private static void MarkMutated()
|
||||
{
|
||||
SagaProse.BumpMemoryRevision();
|
||||
}
|
||||
|
||||
public static int RivalEncounterThreshold
|
||||
{
|
||||
get
|
||||
|
|
@ -56,7 +65,7 @@ public static class LifeSagaMemory
|
|||
PairEncounterCooldown.Clear();
|
||||
_boundWorld = null;
|
||||
_worldEpoch++;
|
||||
SagaProse.BumpMemoryRevision();
|
||||
MarkMutated();
|
||||
}
|
||||
|
||||
public static void Clear() => ClearSession();
|
||||
|
|
@ -219,6 +228,7 @@ public static class LifeSagaMemory
|
|||
}
|
||||
|
||||
life.TouchedAt = now;
|
||||
MarkMutated();
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +256,7 @@ public static class LifeSagaMemory
|
|||
};
|
||||
life.Facts.Insert(0, fact);
|
||||
life.TouchedAt = now;
|
||||
SagaProse.BumpMemoryRevision();
|
||||
MarkMutated();
|
||||
while (life.Facts.Count > MaxFactsPerLife)
|
||||
{
|
||||
life.Facts.RemoveAt(life.Facts.Count - 1);
|
||||
|
|
@ -864,6 +874,7 @@ public static class LifeSagaMemory
|
|||
}
|
||||
|
||||
EnsureWorldBound();
|
||||
bool mutated = false;
|
||||
foreach (KeyValuePair<long, LifeSagaLifeMemory> kv in Lives)
|
||||
{
|
||||
LifeSagaLifeMemory life = kv.Value;
|
||||
|
|
@ -893,6 +904,7 @@ public static class LifeSagaMemory
|
|||
if (f.Kind == LifeSagaFactKind.PlotJoin || f.Kind == LifeSagaFactKind.PlotEnd)
|
||||
{
|
||||
f.Resolved = true;
|
||||
mutated = true;
|
||||
if (f.Kind == LifeSagaFactKind.PlotJoin)
|
||||
{
|
||||
f.Kind = LifeSagaFactKind.PlotEnd;
|
||||
|
|
@ -902,6 +914,7 @@ public static class LifeSagaMemory
|
|||
else if (f.Kind == LifeSagaFactKind.WarJoin || f.Kind == LifeSagaFactKind.WarEnd)
|
||||
{
|
||||
f.Resolved = true;
|
||||
mutated = true;
|
||||
if (f.Kind == LifeSagaFactKind.WarJoin)
|
||||
{
|
||||
f.Kind = LifeSagaFactKind.WarEnd;
|
||||
|
|
@ -909,6 +922,11 @@ public static class LifeSagaMemory
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mutated)
|
||||
{
|
||||
MarkMutated();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatWarPairNote(string attackerKingdom, string defenderKingdom)
|
||||
|
|
@ -1179,6 +1197,7 @@ public static class LifeSagaMemory
|
|||
return;
|
||||
}
|
||||
|
||||
bool mutated = false;
|
||||
for (int i = life.Facts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
LifeSagaFact f = life.Facts[i];
|
||||
|
|
@ -1187,8 +1206,14 @@ public static class LifeSagaMemory
|
|||
&& !IsCredibleRival(f))
|
||||
{
|
||||
life.Facts.RemoveAt(i);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mutated)
|
||||
{
|
||||
MarkMutated();
|
||||
}
|
||||
}
|
||||
|
||||
public static LifeSagaFact StrongestUnresolved(long unitId)
|
||||
|
|
|
|||
|
|
@ -8,15 +8,17 @@ namespace IdleSpectator;
|
|||
|
||||
/// <summary>
|
||||
/// Saga depth card under the character beat caption: Cast + Legacy.
|
||||
/// Bound to the hover preview id. Identity lives on the caption (name · title);
|
||||
/// no portrait / name / title / stake chrome here - that space goes to Cast.
|
||||
/// Width is fixed to the rail chrome so right-anchored hover cannot slide the rail.
|
||||
/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
|
||||
/// Bound to the hover preview id. The primary nametag identifies the hovered
|
||||
/// Saga character, so no duplicate identity chrome is rendered here.
|
||||
/// No portrait / name / title / stake chrome here - that space goes to Cast.
|
||||
/// Width fills the stable rail chrome beside the live dossier portrait.
|
||||
/// Height sizes to content (capped); Legacy admits complete prioritized lines and
|
||||
/// uses a final ellipsis when lower-priority entries do not fit.
|
||||
/// </summary>
|
||||
public static class LifeSagaPanel
|
||||
{
|
||||
/// <summary>Matches WatchCaption rail chrome inner width (PanelWidthMax - pads).</summary>
|
||||
public const float BodyWidthFixed = 232f;
|
||||
/// <summary>Remaining hover width beside the 40px live portrait.</summary>
|
||||
public const float BodyWidthFixed = 188f;
|
||||
/// <summary>Hard cap so Saga never eats the screen.</summary>
|
||||
public const float BodyHeightMax = 172f;
|
||||
|
||||
|
|
@ -28,7 +30,7 @@ public static class LifeSagaPanel
|
|||
/// <summary>Shared cast budget for panel slots and presentation model.</summary>
|
||||
public const int MaxCast = 8;
|
||||
private const int MaxLegacy = 6;
|
||||
private const int CastPerRow = 4;
|
||||
private const int CastPerRow = 3;
|
||||
|
||||
private const float Pad = 3f;
|
||||
private const float CastFace = 16f;
|
||||
|
|
@ -36,7 +38,7 @@ public static class LifeSagaPanel
|
|||
private const float SecLabelH = 10f;
|
||||
private const float LineH = 10f;
|
||||
private const int BodyFont = 7;
|
||||
private const int BuildVersion = 10;
|
||||
private const int BuildVersion = 12;
|
||||
|
||||
private static GameObject _root;
|
||||
private static RectTransform _rootRt;
|
||||
|
|
@ -44,6 +46,7 @@ public static class LifeSagaPanel
|
|||
private static Text _legacyHead;
|
||||
private static Text _legacyText;
|
||||
private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
|
||||
private static readonly List<string> LegacyParts = new List<string>(MaxLegacy);
|
||||
private static long _boundId;
|
||||
private static string _speciesId = "";
|
||||
private static string _fingerprint = "";
|
||||
|
|
@ -64,8 +67,8 @@ public static class LifeSagaPanel
|
|||
public static float BodyHeight => _laidOutHeight;
|
||||
public static float BodyWidthPx => BodyWidthFixed;
|
||||
public static long BoundUnitId => _boundId;
|
||||
/// <summary>Always false - caption owns identity; panel never steals DossierAvatar.</summary>
|
||||
public static bool OwnsAvatar => false;
|
||||
/// <summary>Saga hover reuses the dossier avatar for the hovered character.</summary>
|
||||
public static bool OwnsAvatar => true;
|
||||
public static string LastLayoutDebug { get; private set; } = "";
|
||||
|
||||
public static void EnsureBuilt(Transform parent)
|
||||
|
|
@ -120,6 +123,7 @@ public static class LifeSagaPanel
|
|||
_boundId = 0;
|
||||
_speciesId = "";
|
||||
_fingerprint = "";
|
||||
LegacyParts.Clear();
|
||||
_laidOutHeight = BodyHeightMin;
|
||||
if (_root != null)
|
||||
{
|
||||
|
|
@ -155,14 +159,13 @@ public static class LifeSagaPanel
|
|||
_fingerprint = fp;
|
||||
_boundId = model.UnitId;
|
||||
_speciesId = model.SpeciesId ?? "";
|
||||
|
||||
if (!same)
|
||||
{
|
||||
int shownCast = 0;
|
||||
for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++)
|
||||
{
|
||||
LifeSagaCastMember member = model.Cast[i];
|
||||
if (member == null || CastDuplicatesCurrentBeat(member))
|
||||
if (member == null || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -177,10 +180,10 @@ public static class LifeSagaPanel
|
|||
CastSlots[i].UnitId = 0;
|
||||
}
|
||||
|
||||
var legacyParts = new List<string>(MaxLegacy);
|
||||
LegacyParts.Clear();
|
||||
var seen = new HashSet<string>();
|
||||
string stakeKey = (model.StakeLine ?? "").Trim().ToLowerInvariant();
|
||||
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
|
||||
for (int i = 0; i < model.Legacy.Count && LegacyParts.Count < MaxLegacy; i++)
|
||||
{
|
||||
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
|
||||
{
|
||||
|
|
@ -200,10 +203,10 @@ public static class LifeSagaPanel
|
|||
continue;
|
||||
}
|
||||
|
||||
legacyParts.Add(line);
|
||||
LegacyParts.Add(line);
|
||||
}
|
||||
|
||||
_legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : "";
|
||||
_legacyText.text = LegacyParts.Count > 0 ? string.Join("\n", LegacyParts) : "";
|
||||
}
|
||||
|
||||
Layout();
|
||||
|
|
@ -229,10 +232,9 @@ public static class LifeSagaPanel
|
|||
float y = Pad;
|
||||
float yMax = BodyHeightMax - Pad;
|
||||
|
||||
// The Beat can update after the presentation model binds in the same frame.
|
||||
// Recheck visible slots at layout time so the current scene partner is never
|
||||
// repeated immediately below ("Bound with X" + "Lover X").
|
||||
string liveBeat = WatchCaption.VisibleBeatLine ?? "";
|
||||
// The Beat can update after the model binds in the same frame. Recheck by
|
||||
// structured ids; display-name substring matching removed legitimate Cast
|
||||
// from a different hovered MC and collided on short names.
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
CastSlot slot = CastSlots[i];
|
||||
|
|
@ -241,9 +243,7 @@ public static class LifeSagaPanel
|
|||
continue;
|
||||
}
|
||||
|
||||
string castName = slot.Name.text ?? "";
|
||||
if (!string.IsNullOrEmpty(castName)
|
||||
&& liveBeat.IndexOf(castName, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
if (CastDuplicatesCurrentBeat(_boundId, slot.UnitId))
|
||||
{
|
||||
slot.Root.SetActive(false);
|
||||
slot.UnitId = 0;
|
||||
|
|
@ -295,19 +295,21 @@ public static class LifeSagaPanel
|
|||
Hide(_castHead);
|
||||
}
|
||||
|
||||
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
|
||||
if (hasLegacy && y + SecLabelH + LineH <= yMax)
|
||||
float legacyBudget = Mathf.Max(0f, yMax - y - SecLabelH);
|
||||
int omittedLegacy = 0;
|
||||
string fittedLegacy = FitLegacyToHeight(fullW, legacyBudget, out omittedLegacy);
|
||||
_legacyText.text = fittedLegacy;
|
||||
bool hasLegacy = !string.IsNullOrEmpty(fittedLegacy);
|
||||
if (hasLegacy && legacyBudget >= LineH)
|
||||
{
|
||||
PlaceText(_legacyHead, fullX, y, fullW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float legacyH = Mathf.Clamp(
|
||||
EstimateWrapHeight(_legacyText.text, fullW, BodyFont),
|
||||
LineH,
|
||||
LineH * 6f);
|
||||
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
|
||||
float legacyH = Mathf.Min(
|
||||
MeasureWrapHeight(_legacyText, fittedLegacy, fullW),
|
||||
Mathf.Max(LineH, yMax - y));
|
||||
PlaceText(_legacyText, fullX, y, fullW, legacyH);
|
||||
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_legacyText.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
y += legacyH + 2f;
|
||||
}
|
||||
else
|
||||
|
|
@ -326,27 +328,55 @@ public static class LifeSagaPanel
|
|||
}
|
||||
|
||||
LastLayoutDebug =
|
||||
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} castFirst=1";
|
||||
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} omitted={omittedLegacy} castFirst=1";
|
||||
}
|
||||
|
||||
private static float EstimateWrapHeight(string text, float width, float fontSize)
|
||||
private static string FitLegacyToHeight(float width, float maxHeight, out int omitted)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
omitted = LegacyParts.Count;
|
||||
if (_legacyText == null || LegacyParts.Count == 0 || maxHeight < LineH)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string fitted = "";
|
||||
int included = 0;
|
||||
for (int i = 0; i < LegacyParts.Count; i++)
|
||||
{
|
||||
string next = string.IsNullOrEmpty(fitted)
|
||||
? LegacyParts[i]
|
||||
: fitted + "\n" + LegacyParts[i];
|
||||
if (MeasureWrapHeight(_legacyText, next, width) > maxHeight + 0.5f)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
fitted = next;
|
||||
included++;
|
||||
}
|
||||
|
||||
omitted = LegacyParts.Count - included;
|
||||
if (omitted <= 0)
|
||||
{
|
||||
return fitted;
|
||||
}
|
||||
|
||||
string withEllipsis = string.IsNullOrEmpty(fitted) ? "…" : fitted + "\n…";
|
||||
return MeasureWrapHeight(_legacyText, withEllipsis, width) <= maxHeight + 0.5f
|
||||
? withEllipsis
|
||||
: fitted;
|
||||
}
|
||||
|
||||
private static float MeasureWrapHeight(Text text, string value, float width)
|
||||
{
|
||||
if (text == null || string.IsNullOrEmpty(value))
|
||||
{
|
||||
return LineH;
|
||||
}
|
||||
|
||||
string[] paragraphs = text.Split('\n');
|
||||
float total = 0f;
|
||||
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
|
||||
for (int i = 0; i < paragraphs.Length; i++)
|
||||
{
|
||||
string p = paragraphs[i];
|
||||
int lines = Mathf.Max(1, Mathf.CeilToInt(Mathf.Max(1, p.Length) / charsPerLine));
|
||||
total += lines * (fontSize + 2f);
|
||||
}
|
||||
|
||||
return total;
|
||||
TextGenerationSettings settings = text.GetGenerationSettings(new Vector2(width, 0f));
|
||||
float pixels = text.cachedTextGeneratorForLayout.GetPreferredHeight(value, settings);
|
||||
return Mathf.Max(LineH, pixels / Mathf.Max(0.01f, text.pixelsPerUnit));
|
||||
}
|
||||
|
||||
private static CastSlot BuildCast(int index)
|
||||
|
|
@ -423,13 +453,45 @@ public static class LifeSagaPanel
|
|||
slot.Root.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>The live Beat already explains the person currently on screen.</summary>
|
||||
private static bool CastDuplicatesCurrentBeat(LifeSagaCastMember member)
|
||||
/// <summary>The camera Beat already explains this exact member for this exact subject.</summary>
|
||||
private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId)
|
||||
{
|
||||
string beat = WatchCaption.VisibleBeatLine ?? "";
|
||||
return member != null
|
||||
&& !string.IsNullOrEmpty(member.Name)
|
||||
&& beat.IndexOf(member.Name, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
long cameraSubjectId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
return ShouldDeduplicateCast(
|
||||
panelSubjectId,
|
||||
castMemberId,
|
||||
cameraSubjectId,
|
||||
WatchCaption.VisibleReasonRelatedId,
|
||||
!string.IsNullOrEmpty(WatchCaption.VisibleBeatLine));
|
||||
}
|
||||
|
||||
private static bool ShouldDeduplicateCast(
|
||||
long panelSubjectId,
|
||||
long castMemberId,
|
||||
long cameraSubjectId,
|
||||
long beatRelatedId,
|
||||
bool hasVisibleBeat)
|
||||
{
|
||||
return hasVisibleBeat
|
||||
&& panelSubjectId != 0
|
||||
&& castMemberId != 0
|
||||
&& cameraSubjectId == panelSubjectId
|
||||
&& beatRelatedId == castMemberId;
|
||||
}
|
||||
|
||||
/// <summary>Harness: structured Cast dedupe truth table without display-name parsing.</summary>
|
||||
internal static bool HarnessProbeScopedCastDedup(out string detail)
|
||||
{
|
||||
bool sameSubjectExact = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: true);
|
||||
bool otherHoveredSubject = ShouldDeduplicateCast(11, 20, 10, 20, hasVisibleBeat: true);
|
||||
bool differentMember = ShouldDeduplicateCast(10, 21, 10, 20, hasVisibleBeat: true);
|
||||
bool noBeat = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: false);
|
||||
bool pass = sameSubjectExact && !otherHoveredSubject && !differentMember && !noBeat;
|
||||
detail =
|
||||
$"pass={pass} sameExact={sameSubjectExact} otherHover={otherHoveredSubject} "
|
||||
+ $"differentMember={differentMember} noBeat={noBeat}";
|
||||
return pass;
|
||||
}
|
||||
|
||||
private static void FitCastText(Text text, string value)
|
||||
|
|
@ -439,11 +501,18 @@ public static class LifeSagaPanel
|
|||
return;
|
||||
}
|
||||
|
||||
text.text = value ?? "";
|
||||
string fitted = value ?? "";
|
||||
const int maxReadableChars = 16;
|
||||
if (fitted.Length > maxReadableChars)
|
||||
{
|
||||
fitted = fitted.Substring(0, maxReadableChars - 1).TrimEnd() + "…";
|
||||
}
|
||||
|
||||
text.text = fitted;
|
||||
text.resizeTextForBestFit = true;
|
||||
text.resizeTextMinSize = 6;
|
||||
text.resizeTextMaxSize = BodyFont;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
}
|
||||
|
||||
|
|
@ -497,8 +566,9 @@ public static class LifeSagaPanel
|
|||
private static string Fingerprint(LifeSagaViewModel model)
|
||||
{
|
||||
var sb = new StringBuilder(192);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.StakeLine)
|
||||
.Append('|').Append(WatchCaption.VisibleBeatLine);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine)
|
||||
.Append('|').Append(WatchCaption.VisibleBeatLine)
|
||||
.Append('|').Append(WatchCaption.VisibleReasonRelatedId);
|
||||
for (int i = 0; i < model.Cast.Count; i++)
|
||||
{
|
||||
var c = model.Cast[i];
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ public static class LifeSagaRail
|
|||
|
||||
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
|
||||
private static readonly List<long> InvolvedScratch = new List<long>(8);
|
||||
private static readonly Dictionary<long, Sprite> LastFaces = new Dictionary<long, Sprite>(MaxSlots);
|
||||
private static readonly Slot[] Slots = new Slot[MaxSlots];
|
||||
private static GameObject _row;
|
||||
private static RectTransform _rowRt;
|
||||
|
|
@ -107,6 +108,8 @@ public static class LifeSagaRail
|
|||
}
|
||||
|
||||
public static int LastShownCount { get; private set; }
|
||||
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
|
||||
public static int CachedFaceCount => LastFaces.Count;
|
||||
|
||||
public static void EnsureBuilt(Transform parent)
|
||||
{
|
||||
|
|
@ -196,6 +199,7 @@ public static class LifeSagaRail
|
|||
|
||||
if (structureChanged)
|
||||
{
|
||||
PruneFaceCache();
|
||||
LifeSagaRoster.Dirty = false;
|
||||
_fingerprint = fp;
|
||||
LastShownCount = 0;
|
||||
|
|
@ -220,7 +224,7 @@ public static class LifeSagaRail
|
|||
bool involved = !watching && IsInvolved(saga.UnitId);
|
||||
bool prefer = saga.Prefer || saga.GameFavorite;
|
||||
slot.UnitId = saga.UnitId;
|
||||
ApplyFace(slot, saga, preferLive: watching);
|
||||
ApplyFace(slot, saga, refreshLive: watching);
|
||||
ApplyBadge(slot, saga);
|
||||
if (slot.PreferPip != null)
|
||||
{
|
||||
|
|
@ -281,7 +285,7 @@ public static class LifeSagaRail
|
|||
continue;
|
||||
}
|
||||
|
||||
ApplyFace(Slots[i], SlotScratch[i], preferLive: true);
|
||||
ApplyFace(Slots[i], SlotScratch[i], refreshLive: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -471,7 +475,7 @@ public static class LifeSagaRail
|
|||
};
|
||||
}
|
||||
|
||||
private static void ApplyFace(Slot slot, LifeSagaSlot saga, bool preferLive)
|
||||
private static void ApplyFace(Slot slot, LifeSagaSlot saga, bool refreshLive)
|
||||
{
|
||||
if (slot == null || saga == null)
|
||||
{
|
||||
|
|
@ -479,12 +483,26 @@ public static class LifeSagaRail
|
|||
}
|
||||
|
||||
Actor actor = saga.Resolve();
|
||||
// Live calculateMainSprite is expensive - only the watched glyph animates with it.
|
||||
// Other rail faces use the species icon (stable between structure changes).
|
||||
Sprite sprite = preferLive
|
||||
? HudIcons.FromActorRailFace(actor, saga.SpeciesId)
|
||||
: HudIcons.FromSpeciesId(saga.SpeciesId);
|
||||
if (!HudIcons.IsUsableRailFace(sprite) && preferLive)
|
||||
Sprite sprite = null;
|
||||
if (!refreshLive)
|
||||
{
|
||||
LastFaces.TryGetValue(saga.UnitId, out sprite);
|
||||
}
|
||||
|
||||
// Capture every character once, then refresh only the watched glyph. When focus
|
||||
// leaves, its last phenotype/kingdom-tinted frame remains instead of reverting
|
||||
// to a shared species icon.
|
||||
if (refreshLive || !HudIcons.IsUsableRailFace(sprite))
|
||||
{
|
||||
Sprite live = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
|
||||
if (HudIcons.IsUsableRailFace(live))
|
||||
{
|
||||
sprite = live;
|
||||
LastFaces[saga.UnitId] = live;
|
||||
}
|
||||
}
|
||||
|
||||
if (!HudIcons.IsUsableRailFace(sprite))
|
||||
{
|
||||
sprite = HudIcons.FromSpeciesId(saga.SpeciesId);
|
||||
}
|
||||
|
|
@ -527,6 +545,32 @@ public static class LifeSagaRail
|
|||
}
|
||||
}
|
||||
|
||||
private static void PruneFaceCache()
|
||||
{
|
||||
var keep = new HashSet<long>();
|
||||
for (int i = 0; i < SlotScratch.Count; i++)
|
||||
{
|
||||
if (SlotScratch[i] != null && SlotScratch[i].UnitId != 0)
|
||||
{
|
||||
keep.Add(SlotScratch[i].UnitId);
|
||||
}
|
||||
}
|
||||
|
||||
var remove = new List<long>();
|
||||
foreach (long id in LastFaces.Keys)
|
||||
{
|
||||
if (!keep.Contains(id))
|
||||
{
|
||||
remove.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < remove.Count; i++)
|
||||
{
|
||||
LastFaces.Remove(remove[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBadge(Slot slot, LifeSagaSlot saga)
|
||||
{
|
||||
if (slot?.Badge == null || saga == null)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -499,6 +499,108 @@ public static class StoryPlanner
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a synthetic short-story beat to the one board arc that authored it.
|
||||
/// Correlation is the ownership boundary: cast overlap, labels, and partial keys
|
||||
/// must never let a parked arc's closer mutate the currently watched arc.
|
||||
/// </summary>
|
||||
private static StoryArc ResolveStoryArc(InterestCandidate candidate)
|
||||
{
|
||||
if (!IsStoryAssetCandidate(candidate) || IsCrisisEpilogueCandidate(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string correlation = (candidate.CorrelationKey ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(correlation))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_active != null && StoryArcMatchesCorrelation(_active, correlation))
|
||||
{
|
||||
return _active;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Parked.Count; i++)
|
||||
{
|
||||
StoryArc parked = Parked[i];
|
||||
if (parked != null && StoryArcMatchesCorrelation(parked, correlation))
|
||||
{
|
||||
return parked;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool StoryArcMatchesCorrelation(StoryArc arc, string correlation)
|
||||
{
|
||||
if (arc == null || !arc.IsActive || string.IsNullOrEmpty(arc.AnchorKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals("story:" + arc.AnchorKey, correlation, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selection defense-in-depth for expired/orphaned synthetic story beats.
|
||||
/// Crisis closers use their crisis chapter instead of the short-arc board.
|
||||
/// </summary>
|
||||
public static bool HasLiveStoryOwner(InterestCandidate candidate)
|
||||
{
|
||||
if (!IsStoryAssetCandidate(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsCrisisEpilogueCandidate(candidate))
|
||||
{
|
||||
return MatchesCrisis(candidate);
|
||||
}
|
||||
|
||||
return ResolveStoryArc(candidate) != null;
|
||||
}
|
||||
|
||||
private static bool ResumeResolvedArc(StoryArc arc, float now)
|
||||
{
|
||||
if (arc == null || !arc.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(_active, arc))
|
||||
{
|
||||
arc.ParkedAt = 0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
int parkedAt = Parked.IndexOf(arc);
|
||||
if (parkedAt < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_active != null && _active.IsActive)
|
||||
{
|
||||
ParkWatching(now);
|
||||
}
|
||||
|
||||
// ParkWatching can insert the previous active arc at the front, so resolve
|
||||
// the target index again before removing it.
|
||||
parkedAt = Parked.IndexOf(arc);
|
||||
if (parkedAt < 0 || !arc.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Parked.RemoveAt(parkedAt);
|
||||
arc.ParkedAt = 0f;
|
||||
_active = arc;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ParkWatching(float now)
|
||||
{
|
||||
if (_active == null || !_active.IsActive)
|
||||
|
|
@ -674,7 +776,15 @@ public static class StoryPlanner
|
|||
return;
|
||||
}
|
||||
|
||||
if (_active != null && _active.IsActive)
|
||||
StoryArc owner = ResolveStoryArc(next);
|
||||
if (owner == null || !ResumeResolvedArc(owner, now))
|
||||
{
|
||||
// An expired/orphaned closer may still reach this defense if it was
|
||||
// selected in the same frame its board arc ended. Never mutate a peer arc.
|
||||
return;
|
||||
}
|
||||
|
||||
if (_active != null && _active.IsActive && ReferenceEquals(_active, owner))
|
||||
{
|
||||
if (string.Equals(next.AssetId, StoryReason.EpilogueRelated, StringComparison.OrdinalIgnoreCase)
|
||||
|| next.Key.StartsWith("epilogue:", StringComparison.OrdinalIgnoreCase))
|
||||
|
|
@ -740,6 +850,12 @@ public static class StoryPlanner
|
|||
|
||||
if (IsStoryAssetCandidate(ended))
|
||||
{
|
||||
StoryArc owner = ResolveStoryArc(ended);
|
||||
if (!ReferenceEquals(owner, _active))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_active.Phase == StoryPhase.Aftermath)
|
||||
{
|
||||
TryInjectEpilogue(_active, now);
|
||||
|
|
@ -900,7 +1016,7 @@ public static class StoryPlanner
|
|||
return false;
|
||||
}
|
||||
|
||||
if (_active == null || !_active.IsActive)
|
||||
if (_active == null || !_active.IsActive || !ReferenceEquals(ResolveStoryArc(next), _active))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -911,14 +1027,7 @@ public static class StoryPlanner
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_active.AnchorKey)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& next.Key.IndexOf(_active.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _active.ContainsCast(next.SubjectId) || _active.ContainsCast(next.RelatedId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool OwnsCandidate(InterestCandidate c)
|
||||
|
|
@ -930,7 +1039,7 @@ public static class StoryPlanner
|
|||
|
||||
if (IsStoryAssetCandidate(c))
|
||||
{
|
||||
return true;
|
||||
return ReferenceEquals(ResolveStoryArc(c), _active);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_active.ClimaxKey)
|
||||
|
|
@ -945,10 +1054,12 @@ public static class StoryPlanner
|
|||
public static float OwnershipBoost(InterestCandidate c)
|
||||
{
|
||||
float crisisBoost = CrisisOwnershipBoost(c);
|
||||
StoryArc storyOwner = IsStoryAssetCandidate(c) ? ResolveStoryArc(c) : null;
|
||||
if (!OwnsCandidate(c) || _active == null || !_active.IsActive)
|
||||
{
|
||||
// Aftermath/epilogue always get a modest boost so they can win quiet grace.
|
||||
if (IsStoryAssetCandidate(c))
|
||||
// A parked arc's correlated closer keeps a modest boost so it can validly
|
||||
// resume. Orphaned story assets receive no short-arc ownership at all.
|
||||
if (storyOwner != null)
|
||||
{
|
||||
return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost);
|
||||
}
|
||||
|
|
@ -1206,7 +1317,11 @@ public static class StoryPlanner
|
|||
|
||||
// Injected aftermath/epilogue must win quiet-grace picks over cast life noise
|
||||
// (hatch/food/sleep), even when those tips have higher raw EventStrength.
|
||||
StoryArc aStoryOwner = IsStoryAssetCandidate(a) ? ResolveStoryArc(a) : null;
|
||||
StoryArc bStoryOwner = IsStoryAssetCandidate(b) ? ResolveStoryArc(b) : null;
|
||||
|
||||
if (IsStoryAssetCandidate(a)
|
||||
&& ReferenceEquals(aStoryOwner, _active)
|
||||
&& !IsStoryAssetCandidate(b)
|
||||
&& (_active.Phase == StoryPhase.Aftermath
|
||||
|| _active.Phase == StoryPhase.Epilogue
|
||||
|
|
@ -1216,8 +1331,8 @@ public static class StoryPlanner
|
|||
return true;
|
||||
}
|
||||
|
||||
bool aOwn = OwnsCandidate(a);
|
||||
bool bOwn = OwnsCandidate(b);
|
||||
bool aOwn = ReferenceEquals(aStoryOwner, _active) || OwnsCandidate(a);
|
||||
bool bOwn = ReferenceEquals(bStoryOwner, _active) || OwnsCandidate(b);
|
||||
return aOwn && !bOwn;
|
||||
}
|
||||
|
||||
|
|
@ -2576,6 +2691,102 @@ public static class StoryPlanner
|
|||
|| (!string.IsNullOrEmpty(c.Key)
|
||||
&& c.Key.StartsWith("epilogue:crisis:", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
/// <summary>Harness: prove a parked closer resumes only its correlated arc.</summary>
|
||||
public static bool HarnessProbeCrossArcCorrelation(out string detail)
|
||||
{
|
||||
Clear();
|
||||
float now = Time.unscaledTime;
|
||||
var arcA = new StoryArc
|
||||
{
|
||||
Id = "arc:CombatDuel:harness-a",
|
||||
Kind = StoryArcKind.CombatDuel,
|
||||
AnchorKey = "harness-a",
|
||||
ClimaxKey = "harness:climax:a",
|
||||
StartedAt = now - 10f,
|
||||
HardHold = true
|
||||
};
|
||||
arcA.NoteCast(101);
|
||||
arcA.Advance(StoryPhase.Aftermath, now - 2f);
|
||||
|
||||
var arcB = new StoryArc
|
||||
{
|
||||
Id = "arc:CombatDuel:harness-b",
|
||||
Kind = StoryArcKind.CombatDuel,
|
||||
AnchorKey = "harness-b",
|
||||
ClimaxKey = "harness:climax:b",
|
||||
StartedAt = now - 8f,
|
||||
ParkedAt = now - 1f,
|
||||
HardHold = true
|
||||
};
|
||||
arcB.NoteCast(202);
|
||||
arcB.Advance(StoryPhase.Aftermath, now - 1f);
|
||||
_active = arcA;
|
||||
Parked.Add(arcB);
|
||||
|
||||
var closerB = new InterestCandidate
|
||||
{
|
||||
Key = "aftermath:harness:b",
|
||||
Source = "story_planner",
|
||||
AssetId = StoryReason.AftermathSurvivor,
|
||||
CorrelationKey = "story:harness-b",
|
||||
SubjectId = 202,
|
||||
RelatedId = 203,
|
||||
Completion = InterestCompletionKind.FixedDwell,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
EventStrength = 60f,
|
||||
Label = "Harness B aftermath"
|
||||
};
|
||||
var orphan = new InterestCandidate
|
||||
{
|
||||
Key = "aftermath:harness:orphan",
|
||||
Source = "story_planner",
|
||||
AssetId = StoryReason.AftermathSurvivor,
|
||||
CorrelationKey = "story:missing",
|
||||
Completion = InterestCompletionKind.FixedDwell,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
EventStrength = 60f,
|
||||
Label = "Harness orphan aftermath"
|
||||
};
|
||||
var unrelated = new InterestCandidate
|
||||
{
|
||||
Key = "harness:unrelated",
|
||||
Completion = InterestCompletionKind.FixedDwell,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
EventStrength = 40f,
|
||||
Label = "Harness unrelated"
|
||||
};
|
||||
|
||||
bool parkedOwnedAsActive = OwnsCandidate(closerB);
|
||||
bool parkedLive = HasLiveStoryOwner(closerB);
|
||||
bool parkedStealsPreference = PreferOver(closerB, unrelated);
|
||||
bool orphanLive = HasLiveStoryOwner(orphan);
|
||||
float orphanBoost = OwnershipBoost(orphan);
|
||||
StoryPhase aPhase = arcA.Phase;
|
||||
int aCast = arcA.CastIds.Count;
|
||||
|
||||
OnSwitchTo(closerB, now);
|
||||
bool resumedB = ReferenceEquals(_active, arcB) && !arcB.IsParked;
|
||||
bool parkedA = arcA.IsParked && Parked.Contains(arcA);
|
||||
bool aUntouched = arcA.Phase == aPhase && arcA.CastIds.Count == aCast;
|
||||
bool bAdvanced = arcB.Phase == StoryPhase.Aftermath && arcB.ContainsCast(203);
|
||||
|
||||
bool pass = !parkedOwnedAsActive
|
||||
&& parkedLive
|
||||
&& !parkedStealsPreference
|
||||
&& !orphanLive
|
||||
&& orphanBoost <= 0.01f
|
||||
&& resumedB
|
||||
&& parkedA
|
||||
&& aUntouched
|
||||
&& bAdvanced;
|
||||
detail =
|
||||
$"pass={pass} parkedLive={parkedLive} parkedOwn={parkedOwnedAsActive} "
|
||||
+ $"parkedPrefer={parkedStealsPreference} orphanLive={orphanLive} orphanBoost={orphanBoost:0.#} "
|
||||
+ $"active='{_active?.Id}' parkedA={parkedA} aUntouched={aUntouched} bAdvanced={bAdvanced}";
|
||||
Clear();
|
||||
return pass;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: open a crisis chapter from the current tip even when live counts are below
|
||||
/// the product enter threshold (small harness kingdoms).
|
||||
|
|
@ -3267,4 +3478,3 @@ public static class StoryPlanner
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ public static class WatchCaption
|
|||
public static Actor BoundActor => _boundActor;
|
||||
|
||||
public static string LastHeadline { get; private set; } = "";
|
||||
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
|
||||
|
||||
public static string LastDetail { get; private set; } = "";
|
||||
|
||||
|
|
@ -153,7 +154,13 @@ public static class WatchCaption
|
|||
internal static string VisibleBeatLine =>
|
||||
_reasonText != null && _reasonText.gameObject.activeSelf
|
||||
? (_reasonText.text ?? "")
|
||||
: LastBeatLine;
|
||||
: "";
|
||||
|
||||
/// <summary>Structured unit id named by the currently composed camera Beat.</summary>
|
||||
internal static long VisibleReasonRelatedId =>
|
||||
_reasonText != null && _reasonText.gameObject.activeSelf && _current != null
|
||||
? _current.ReasonRelatedId
|
||||
: 0;
|
||||
|
||||
/// <summary>Harness: composed context line (spine or stake).</summary>
|
||||
public static string LastContextLine => CaptionComposer.LastContextLine;
|
||||
|
|
@ -323,6 +330,32 @@ public static class WatchCaption
|
|||
|
||||
public static void ToggleFavorite()
|
||||
{
|
||||
long hoverId = LifeSagaViewController.HoverPreviewId;
|
||||
if (hoverId != 0)
|
||||
{
|
||||
Actor hovered = EventFeedUtil.FindAliveById(hoverId);
|
||||
if (hovered != null && hovered.isAlive())
|
||||
{
|
||||
try
|
||||
{
|
||||
hovered.switchFavorite();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Chronicle.SyncFavoriteFromActor(hovered);
|
||||
}
|
||||
else
|
||||
{
|
||||
Chronicle.SetFavoriteSubject(hoverId, !Chronicle.IsFavoriteSubject(hoverId));
|
||||
}
|
||||
|
||||
RequestRelayout();
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = _boundActor;
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
|
|
@ -498,8 +531,19 @@ public static class WatchCaption
|
|||
WireReasonClick(0);
|
||||
}
|
||||
|
||||
// Status banners temporarily own the reason row - hide the story spine.
|
||||
ApplyStorySpine("");
|
||||
// Banner owns Beat only. Keep Identity + Context composed from the live
|
||||
// camera subject so a multi-second pause banner cannot freeze the dossier.
|
||||
Actor focus = ResolveBoundLiveActor();
|
||||
if (focus == null && MoveCamera.hasFocusUnit())
|
||||
{
|
||||
focus = MoveCamera._focus_unit;
|
||||
}
|
||||
|
||||
ApplyComposedCaption(
|
||||
focus,
|
||||
_current != null ? (_current.ReasonLine ?? "") : "",
|
||||
_current != null ? _current.ReasonRelatedId : 0,
|
||||
statusBannerOwnsBeat: true);
|
||||
|
||||
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
|
||||
// Re-fill from the current subject so Relayout cannot resurrect stale history slots
|
||||
|
|
@ -951,14 +995,19 @@ public static class WatchCaption
|
|||
/// </summary>
|
||||
private static void RefreshStorySpine()
|
||||
{
|
||||
if (!_visible || HasStatusBanner())
|
||||
if (!_visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool statusBanner = HasStatusBanner();
|
||||
if (statusBanner)
|
||||
{
|
||||
LifeSagaViewController.CancelPreviewAndPause();
|
||||
LifeSagaPanel.Clear();
|
||||
LifeSagaRail.Clear();
|
||||
_layoutDirty = true;
|
||||
_dossierRowsNeedRestore = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Actor focus = ResolveBoundLiveActor();
|
||||
|
|
@ -969,10 +1018,14 @@ public static class WatchCaption
|
|||
|
||||
string presentable = _current != null ? (_current.ReasonLine ?? "") : "";
|
||||
long related = _current != null ? _current.ReasonRelatedId : 0;
|
||||
ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: false);
|
||||
ApplyComposedCaption(focus, presentable, related, statusBannerOwnsBeat: statusBanner);
|
||||
|
||||
LifeSagaRail.Refresh();
|
||||
bool panelMode = LifeSagaViewController.IsHoverPreview;
|
||||
if (!statusBanner)
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
}
|
||||
|
||||
bool panelMode = !statusBanner && LifeSagaViewController.IsHoverPreview;
|
||||
int traitCount = 0;
|
||||
int statusCount = 0;
|
||||
int historyCount = 0;
|
||||
|
|
@ -981,11 +1034,14 @@ public static class WatchCaption
|
|||
if (panelMode)
|
||||
{
|
||||
RefreshSagaBody();
|
||||
ApplyReasonText("", 0);
|
||||
ApplyStorySpine("");
|
||||
_dossierRowsNeedRestore = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LifeSagaPanel.SetVisible(false);
|
||||
RestoreCameraHeaderChrome();
|
||||
// Hover force-hides dossier rows. Restore once when leaving hover so exit does
|
||||
// not wait for a tip change (not every frame).
|
||||
if (_dossierRowsNeedRestore)
|
||||
|
|
@ -1142,7 +1198,7 @@ public static class WatchCaption
|
|||
focus,
|
||||
_current.ReasonLine ?? "",
|
||||
_current.ReasonRelatedId,
|
||||
statusBannerOwnsBeat: false);
|
||||
statusBannerOwnsBeat: HasStatusBanner());
|
||||
}
|
||||
|
||||
/// <summary>Harness: dossier nametag/reason visible after leaving Saga hover.</summary>
|
||||
|
|
@ -1260,9 +1316,118 @@ public static class WatchCaption
|
|||
EnsureBuilt();
|
||||
LifeSagaPanel.EnsureBuilt(_root.transform);
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
ApplySagaHoverHeader(model);
|
||||
LifeSagaPanel.Bind(model);
|
||||
}
|
||||
|
||||
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
|
||||
{
|
||||
if (model == null || model.UnitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = EventFeedUtil.FindAliveById(model.UnitId);
|
||||
string name = (model.Name ?? "").Trim();
|
||||
if (name.StartsWith("★ ", StringComparison.Ordinal))
|
||||
{
|
||||
name = name.Substring(2).TrimStart();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
name = "Someone";
|
||||
}
|
||||
|
||||
string title = (model.Title ?? "").Trim();
|
||||
string species = ActivityAssetCatalog.SpeciesDisplayLabel(model.SpeciesId);
|
||||
string headline = !string.IsNullOrEmpty(title)
|
||||
? name + " · " + title
|
||||
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = headline.Length > 48
|
||||
? CaptionComposer.TruncateIdentity(headline, 48)
|
||||
: headline;
|
||||
}
|
||||
|
||||
if (_speciesIcon != null)
|
||||
{
|
||||
Sprite sprite = actor != null && actor.isAlive()
|
||||
? HudIcons.FromActor(actor)
|
||||
: HudIcons.FromSpeciesId(model.SpeciesId);
|
||||
HudIcons.Apply(_speciesIcon, sprite);
|
||||
}
|
||||
|
||||
Sprite sexSprite = null;
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
UnitDossier hovered = UnitDossier.FromActor(actor);
|
||||
if (hovered != null)
|
||||
{
|
||||
sexSprite = hovered.IsMale
|
||||
? HudIcons.SexMale()
|
||||
: (hovered.IsFemale ? HudIcons.SexFemale() : null);
|
||||
}
|
||||
}
|
||||
|
||||
if (_sexIcon != null)
|
||||
{
|
||||
HudIcons.Apply(_sexIcon, sexSprite);
|
||||
_sexIcon.gameObject.SetActive(sexSprite != null);
|
||||
}
|
||||
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
DossierAvatar.Show(actor);
|
||||
}
|
||||
else
|
||||
{
|
||||
DossierAvatar.ShowSpecies(model.SpeciesId);
|
||||
}
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
|
||||
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
|
||||
}
|
||||
|
||||
private static void RestoreCameraHeaderChrome()
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = ResolveBoundLiveActor();
|
||||
if (_speciesIcon != null)
|
||||
{
|
||||
Sprite sprite = actor != null && actor.isAlive()
|
||||
? HudIcons.FromActor(actor)
|
||||
: HudIcons.FromSpeciesId(_current.SpeciesId);
|
||||
HudIcons.Apply(_speciesIcon, sprite);
|
||||
}
|
||||
|
||||
if (_sexIcon != null)
|
||||
{
|
||||
Sprite sexSprite = _current.IsMale
|
||||
? HudIcons.SexMale()
|
||||
: (_current.IsFemale ? HudIcons.SexFemale() : null);
|
||||
HudIcons.Apply(_sexIcon, sexSprite);
|
||||
_sexIcon.gameObject.SetActive(sexSprite != null);
|
||||
}
|
||||
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
DossierAvatar.Show(actor);
|
||||
}
|
||||
else
|
||||
{
|
||||
DossierAvatar.ShowSpecies(_current.SpeciesId);
|
||||
}
|
||||
DossierAvatar.SetHostVisible(_current.UnitId != 0);
|
||||
|
||||
RefreshFavoriteVisual(actor);
|
||||
}
|
||||
|
||||
private static void ApplyStorySpine(string spine)
|
||||
{
|
||||
LastStorySpine = spine ?? "";
|
||||
|
|
@ -1271,7 +1436,9 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
bool show = !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner();
|
||||
// This row is Caption Context (short-arc spine or durable stake). A status
|
||||
// banner replaces Beat only, so Context remains visible and live.
|
||||
bool show = !string.IsNullOrEmpty(LastStorySpine);
|
||||
_storySpineText.text = show ? LastStorySpine : "";
|
||||
_storySpineText.color = StorySpineColor;
|
||||
_storySpineText.gameObject.SetActive(show);
|
||||
|
|
@ -1372,7 +1539,7 @@ public static class WatchCaption
|
|||
/// <summary>Keep nametag Identity in sync via CaptionComposer (MC title or species/job).</summary>
|
||||
private static void RefreshLiveIdentity()
|
||||
{
|
||||
if (!_visible || _current == null || HasStatusBanner() || _headlineLocked)
|
||||
if (!_visible || _current == null || _headlineLocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -1392,7 +1559,7 @@ public static class WatchCaption
|
|||
actor,
|
||||
_current.ReasonLine ?? "",
|
||||
_current.ReasonRelatedId,
|
||||
statusBannerOwnsBeat: false);
|
||||
statusBannerOwnsBeat: HasStatusBanner());
|
||||
_current.JobLabel = job;
|
||||
_current.IdentityTag = tag;
|
||||
|
||||
|
|
@ -2694,7 +2861,7 @@ public static class WatchCaption
|
|||
y = LifeSagaRail.Place(y, PadX);
|
||||
}
|
||||
|
||||
// Hover depth hides avatar/traits/history/statuses but keeps Identity + Beat + Context.
|
||||
// Hover depth hides dossier rows but reuses the live avatar for the hovered MC.
|
||||
SetDossierBodyVisible(!panelMode);
|
||||
|
||||
PlaceHeader(y, hasTask);
|
||||
|
|
@ -2772,7 +2939,9 @@ public static class WatchCaption
|
|||
if (panelMode)
|
||||
{
|
||||
LifeSagaPanel.EnsureBuilt(_root.transform);
|
||||
y = LifeSagaPanel.Place(y, PadX);
|
||||
DossierAvatar.Place(PadX, y, LiveMax);
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
y = LifeSagaPanel.Place(y, PadX + LiveMax + 4f);
|
||||
if (railChrome)
|
||||
{
|
||||
_panelWidth = PanelWidthMax;
|
||||
|
|
@ -3019,10 +3188,29 @@ public static class WatchCaption
|
|||
fav = Chronicle.IsFavoriteSubject(id) || (_current != null && _current.IsFavorite);
|
||||
}
|
||||
|
||||
long currentId = _current != null ? _current.UnitId : EventFeedUtil.SafeId(actor);
|
||||
RefreshFavoriteVisualFor(currentId, actor, fav);
|
||||
}
|
||||
|
||||
private static void RefreshFavoriteVisualFor(long unitId, Actor actor, bool fallbackFavorite)
|
||||
{
|
||||
if (_favoriteIcon == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool fav = fallbackFavorite || Chronicle.IsFavoriteSubject(unitId);
|
||||
try
|
||||
{
|
||||
fav = fav || (actor != null && actor.isAlive() && actor.isFavorite());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep snapshot/Chronicle favorite state.
|
||||
}
|
||||
|
||||
HudIcons.Apply(_favoriteIcon, HudIcons.Favorite());
|
||||
_favoriteIcon.color = fav
|
||||
? HudTheme.FavoriteOn
|
||||
: HudTheme.FavoriteOff;
|
||||
_favoriteIcon.color = fav ? HudTheme.FavoriteOn : HudTheme.FavoriteOff;
|
||||
}
|
||||
|
||||
private static void PlaceLeftChip(RectTransform rt, float x, float yFromTop, float width, float height)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
344
docs/story-system-coherence-plan.md
Normal file
344
docs/story-system-coherence-plan.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# Story system coherence plan
|
||||
|
||||
Goal: Saga, Story Planner, Interest Director, and the dossier work as one system for
|
||||
following the world's main characters. The camera should show a coherent character story,
|
||||
the caption should explain the current beat without repeating itself, and durable context
|
||||
should remain correct as relationships and arcs change.
|
||||
|
||||
Status: **Implementation complete; one clean full regression passed. Repeated-world soak
|
||||
hardening remains before the three-pass release gate can be claimed**.
|
||||
|
||||
Related design documents:
|
||||
|
||||
- [Life Saga](life-saga.md)
|
||||
- [Story Planner](story-planner.md)
|
||||
- [Camera presentability](camera-presentability-plan.md)
|
||||
- [Scoring model](scoring-model.md)
|
||||
|
||||
## Product goal
|
||||
|
||||
The player should be able to glance at IdleSpectator and answer three questions:
|
||||
|
||||
1. **Who is this?** — Identity from the Saga roster and live role data.
|
||||
2. **What is happening now?** — Beat owned by the current camera tip.
|
||||
3. **Why does it matter?** — Context from the matching short arc or durable unresolved stake.
|
||||
|
||||
Hover turns the dossier into a temporary Saga preview: the primary nametag identifies the
|
||||
chosen main character, Cast and Legacy describe that life, and camera Beat/Context are hidden
|
||||
until hover exits. Camera focus itself does not change.
|
||||
|
||||
## Ownership model
|
||||
|
||||
| Concern | Owner | Contract |
|
||||
|---------|-------|----------|
|
||||
| Main-character roster, Prefer, durable importance | `LifeSagaRoster` | Softly influences selection; never locks the camera |
|
||||
| Durable observed facts and relationships | `LifeSagaMemory` | Survives roster churn; every mutation invalidates derived prose |
|
||||
| Short climax → aftermath → epilogue arcs | `StoryPlanner` | Owns only candidates correlated to that exact arc |
|
||||
| Final camera choice and switching | `InterestDirector` | Applies presentability, urgency, holds, and soft Saga bias |
|
||||
| Identity + Beat + Context | `CaptionComposer` → `WatchCaption` | Describes the camera subject outside Saga hover |
|
||||
| Hover nametag + portrait + Cast + Legacy | `LifeSagaViewController` → `WatchCaption` / `LifeSagaPanel` | Temporarily identifies the hovered Saga subject without changing camera focus; hides camera Beat/Context |
|
||||
| Full historical record | Chronicle / Lore | Read-only history; never drives the camera |
|
||||
|
||||
Pipeline:
|
||||
|
||||
```text
|
||||
Feeds → InterestRegistry → StoryPlanner boost/inject
|
||||
→ InterestVariety.Pick → InterestDirector → CameraDirector
|
||||
→ CaptionComposer → WatchCaption
|
||||
↘ Saga hover Cast/Legacy
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
These are release requirements, not preferences.
|
||||
|
||||
1. A synthetic aftermath or epilogue belongs to exactly one arc, identified by correlation.
|
||||
2. A story candidate cannot advance, receive ownership boost from, or stamp cast into a different arc.
|
||||
3. `InterestDirector` remains the only owner of camera switching; the planner never calls camera APIs.
|
||||
4. Saga bias remains soft beneath crisis and active correlated-story ownership.
|
||||
5. Outside Saga hover, caption Identity, Beat, and Context describe the camera subject.
|
||||
6. A temporary status banner may replace Beat, but Identity and Context continue updating.
|
||||
7. Hover pauses camera mutation, binds the primary nametag and live portrait to the hovered MC,
|
||||
hides camera Beat/Context, and restores the live camera dossier on exit.
|
||||
8. Cast deduplication uses structured subject/member identity, never display-name substrings alone.
|
||||
9. Every `LifeSagaMemory` mutation invalidates cached relation and presentation prose.
|
||||
10. No internal enum, asset, role, correlation, or phase identifier reaches player-facing prose.
|
||||
11. Text remains inside the hover card at supported UI scales; it neither clips silently nor paints outside its bounds.
|
||||
12. Regression scenarios control liveness and live-feed noise so a pass or failure reflects the asserted policy.
|
||||
|
||||
## Confirmed gaps (resolved by this implementation)
|
||||
|
||||
### G1 — Cross-arc story ownership
|
||||
|
||||
`BelongsToActiveStoryBeat` validates `CorrelationKey`, but `OwnsCandidate`,
|
||||
`OwnershipBoost`, `PreferOver`, and `OnSwitchTo` currently treat any story asset as owned by
|
||||
the active arc. A pending aftermath from parked arc B can therefore win while arc A is active,
|
||||
advance A, and stamp B's cast into it.
|
||||
|
||||
### G2 — Status banner freezes caption composition
|
||||
|
||||
`CaptionComposer` supports `statusBannerOwnsBeat`, but `WatchCaption` returns before composing
|
||||
while a banner is active. Beat is suppressed as intended, but Identity and Context also stop
|
||||
updating, contrary to the Life Saga contract.
|
||||
|
||||
### G3 — Incomplete memory-cache invalidation
|
||||
|
||||
New facts bump `SagaProse.MemoryRevision`; updates to existing facts, war/plot resolution, and
|
||||
rival pruning do not. Cached relation evidence can outlive the facts that produced it.
|
||||
|
||||
### G4 — Subject-blind Cast deduplication
|
||||
|
||||
The hover panel removes a Cast member when their display name appears in the camera Beat.
|
||||
This is wrong when the hovered MC differs from the camera subject and is vulnerable to short-name
|
||||
and substring collisions.
|
||||
|
||||
### G5 — Unbounded hover text
|
||||
|
||||
The panel caps its measured Legacy height and then enables vertical overflow. This trades
|
||||
truncation for text escaping the card. Cast names also rely on best-fit inside fixed narrow slots
|
||||
without a maximum-content visual contract.
|
||||
|
||||
### G6 — Internal role fallback
|
||||
|
||||
Unknown roles fall back to `Role: {roleId}`, allowing internal taxonomy into the UI.
|
||||
|
||||
### G7 — Regression gate is nondeterministic
|
||||
|
||||
Current failures mix product behavior with test setup:
|
||||
|
||||
- Director tier expectations do not account for production novelty/frequency adjustments.
|
||||
- Saga subjects may die between successful selection and assertion.
|
||||
- Live feeds may add new memory immediately after a valid session reset.
|
||||
- Hover coverage checks that a Beat exists, but does not compare it with the pre-hover Beat.
|
||||
|
||||
## Phase A — Correlation-safe story ownership
|
||||
|
||||
Do this first; every later presentation decision depends on the planner identifying the right story.
|
||||
|
||||
### A1. Central arc resolver
|
||||
|
||||
Add one internal resolver for synthetic story candidates:
|
||||
|
||||
```text
|
||||
candidate CorrelationKey
|
||||
→ matching active arc
|
||||
→ matching parked arc
|
||||
→ no owner
|
||||
```
|
||||
|
||||
The resolver must compare the full normalized correlation value, not cast membership, label text,
|
||||
or partial key matches.
|
||||
|
||||
### A2. Use the resolver everywhere
|
||||
|
||||
Route these operations through the same result:
|
||||
|
||||
- `OwnsCandidate`
|
||||
- `OwnershipBoost`
|
||||
- `PreferOver`
|
||||
- `OnSwitchTo`
|
||||
- story hold/spine decisions where applicable
|
||||
|
||||
Only a candidate owned by the resolved arc may:
|
||||
|
||||
- receive short-arc ownership boost;
|
||||
- beat unrelated candidates through planner preference;
|
||||
- advance Aftermath/Epilogue;
|
||||
- add cast or stamp a Saga chapter;
|
||||
- expose that arc's dossier spine.
|
||||
|
||||
### A3. Parked-arc behavior
|
||||
|
||||
If the candidate belongs to a live parked arc, resume that arc before switching to the beat.
|
||||
Do not mutate the unrelated active arc. If resumption is no longer valid, remove or expire the
|
||||
candidate instead of showing an orphaned ending.
|
||||
|
||||
### A4. Focused coverage
|
||||
|
||||
Add a scenario with two simultaneous arcs:
|
||||
|
||||
| Case | Expect |
|
||||
|------|--------|
|
||||
| Arc A active, arc B parked, B aftermath pending | B never advances or stamps A |
|
||||
| B aftermath wins valid selection | B resumes, then advances to Aftermath |
|
||||
| Story asset has missing/unknown correlation | no ownership boost; no arc mutation |
|
||||
| Correct A aftermath pending | retains current preference/hold behavior |
|
||||
|
||||
Exit: no story candidate can affect a different arc under any selection order.
|
||||
|
||||
## Phase B — Live caption and memory correctness
|
||||
|
||||
### B1. Status-banner composition
|
||||
|
||||
While a status banner is active:
|
||||
|
||||
- preserve the banner in the Beat row;
|
||||
- compose with `statusBannerOwnsBeat: true`;
|
||||
- refresh Identity from the live camera subject;
|
||||
- refresh Context from the currently valid spine or stake;
|
||||
- keep hover interaction suppressed if that remains the chosen status UX.
|
||||
|
||||
Do not clear Saga presentation state merely because Beat is temporarily owned by a banner.
|
||||
|
||||
### B2. Memory mutation API
|
||||
|
||||
Centralize mutation notification so callers cannot forget cache invalidation. At minimum, bump
|
||||
the revision when:
|
||||
|
||||
- a new fact is inserted or trimmed;
|
||||
- an existing fact changes kind, resolution, strength, note, other identity, or date;
|
||||
- war/plot joins resolve;
|
||||
- stale rivals or other facts are pruned;
|
||||
- a life or session is cleared.
|
||||
|
||||
Prefer a single `MarkMutated`/revision path over scattered direct calls to `SagaProse`.
|
||||
|
||||
### B3. Prose fallback
|
||||
|
||||
Replace unknown role fallback with neutral authored prose such as `Rose in standing`.
|
||||
Audit other Saga fallbacks for raw enum/asset/correlation output.
|
||||
|
||||
### B4. Focused coverage
|
||||
|
||||
| Case | Expect |
|
||||
|------|--------|
|
||||
| Role changes during status banner | Identity updates; banner remains Beat |
|
||||
| Active stake changes during banner | Context updates without replacing banner |
|
||||
| War/plot resolves after relation was cached | Cast/relation prose changes immediately |
|
||||
| Rival is pruned after relation was cached | obsolete foe label disappears immediately |
|
||||
| Unknown role identifier | no raw identifier in caption, Cast, or Legacy |
|
||||
|
||||
Exit: caption and relation prose reflect current state within the normal refresh interval.
|
||||
|
||||
## Phase C — Hover presentation correctness
|
||||
|
||||
### C1. Structured Cast deduplication
|
||||
|
||||
Pass or derive structured Beat participants. Suppress a Cast entry only when all are true:
|
||||
|
||||
1. hover subject equals camera caption subject;
|
||||
2. the Cast member's unit ID equals a structured Beat participant/related ID;
|
||||
3. the Beat already communicates that relationship strongly enough to be redundant.
|
||||
|
||||
Never suppress from a display-name substring match. If structured identity is unavailable, retain
|
||||
the Cast entry.
|
||||
|
||||
### C2. Bounded content policy
|
||||
|
||||
The hover card is glanceable AFK UI, so prefer a content budget over scrolling:
|
||||
|
||||
1. Keep up to two rows of Cast, with measured best-fit text and a readable minimum size.
|
||||
2. Measure Legacy using the actual Unity text generator or preferred height.
|
||||
3. Admit Legacy entries in presentation priority order until the remaining line budget is full.
|
||||
4. End at a complete line; do not enable overflow outside the card.
|
||||
5. If content is omitted, use a subtle `…` final line only when it adds useful disclosure.
|
||||
|
||||
The panel may grow up to its documented maximum, but the rail and caption anchors must remain stable.
|
||||
|
||||
### C3. Visual coverage
|
||||
|
||||
Capture screenshots for:
|
||||
|
||||
- 8 Cast members with long names and long relation labels;
|
||||
- 6 long Legacy entries with world-date prefixes;
|
||||
- hovered MC different from camera MC with overlapping Cast names;
|
||||
- narrow supported resolution/UI scale;
|
||||
- short, normal content to ensure the card does not reserve empty space.
|
||||
|
||||
Exit: no clipping, overlap, off-card paint, false Cast removal, or rail movement.
|
||||
|
||||
## Phase D — Deterministic release gate
|
||||
|
||||
### D1. Repair existing scenarios
|
||||
|
||||
- Give selected Saga test subjects an invincibility/liveness guard through the assertion.
|
||||
- Isolate director-tier math by clearing variety state or injecting explicit final-score conditions;
|
||||
assert the computed margin before asserting the cut.
|
||||
- Validate session reset by proving pre-reset sentinel facts are absent or by holding live feeds,
|
||||
not by requiring the active world's memory count to remain zero.
|
||||
- Capture Beat before hover and assert exact equality during hover and after release.
|
||||
|
||||
### D2. Add missing scenarios
|
||||
|
||||
Recommended scenario names:
|
||||
|
||||
- `story_cross_arc_correlation`
|
||||
- `caption_status_banner_live`
|
||||
- `saga_relation_cache_revision`
|
||||
- `saga_cast_dedupe_scoped`
|
||||
- `saga_hover_max_content`
|
||||
|
||||
### D3. Verification order
|
||||
|
||||
1. New focused scenarios individually, three consecutive passes each.
|
||||
2. Existing `story_*`, `saga_*`, caption, director, and dossier scenarios.
|
||||
3. `critical_smoke`.
|
||||
4. Full `regression`, three consecutive passes.
|
||||
5. Screenshot review at normal and narrow supported layouts.
|
||||
6. Short unattended idle soak; inspect camera churn, orphaned story endings, stale captions,
|
||||
and panel layout.
|
||||
|
||||
Exit: the full gate is repeatable and failures identify product regressions rather than world noise.
|
||||
|
||||
## Implementation order
|
||||
|
||||
| Order | Work | Why |
|
||||
|------:|------|-----|
|
||||
| 1 | Phase A: arc correlation | Protects the core story identity |
|
||||
| 2 | Phase B1: banner composition | Restores the always-live caption contract |
|
||||
| 3 | Phase 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.
|
||||
Loading…
Reference in a new issue