feat(saga): replace tabs with character beat captions

- Compose Identity, Beat, and Context without mutating camera labels
- Move Cast and Legacy details into a hover-only panel
- Centralize relation, title, stake, and legacy prose
- Make rail clicks toggle Prefer without pinning a Saga subject
- Add harness coverage for caption composition and hover behavior
This commit is contained in:
DazedAnon 2026-07-22 17:09:13 -05:00
parent 2592e947aa
commit 58a67a198d
17 changed files with 2827 additions and 1144 deletions

View file

@ -3317,13 +3317,26 @@ public static class AgentHarness
case "saga_show_hover_first":
{
LifeSagaRoster.Tick(Time.unscaledTime);
// Soft-admit focus if the roster was pruned mid-scenario (unit death /
// bind churn) so hover depth stays testable on the watched life.
if (LifeSagaRoster.Count == 0)
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (focus != null && focus.isAlive())
{
LifeSagaRoster.HarnessForceAdmit(focus);
}
}
LifeSagaRail.Refresh();
LifeSagaRail.ShowHover(0);
WatchCaption.RequestRelayout();
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
// Height may be 0 when Cast+Legacy are empty; hover is still valid.
bool ok = LifeSagaViewController.IsHoverPreview
&& model != null
&& model.UnitId != 0
&& LifeSagaRail.LastHoverHeight > 0f;
&& LifeSagaViewController.HoverPreviewId != 0;
if (ok)
{
_cmdOk++;
@ -3337,7 +3350,7 @@ public static class AgentHarness
cmd,
ok,
detail:
$"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive}");
$"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive} rail={LifeSagaRail.LastShownCount} roster={LifeSagaRoster.Count}");
break;
}
@ -3365,11 +3378,22 @@ public static class AgentHarness
case "saga_click_first":
{
LifeSagaRail.Refresh();
long before = LifeSagaViewController.PersistentSagaId;
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
LifeSagaRail.HarnessClick(0);
WatchCaption.RequestRelayout();
long after = LifeSagaViewController.EffectiveSagaId;
bool ok = after != 0;
// Prefer toggle only - does not open hover / pin saga subject.
bool preferAfter = LifeSagaRail.LastPreferPipVisible
|| (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
// After click, Prefer state on slot 0 should be readable.
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(slots);
if (slots.Count > 0 && slots[0] != null)
{
ok = slots[0].Prefer || preferBefore;
}
if (ok)
{
_cmdOk++;
@ -3379,7 +3403,7 @@ public static class AgentHarness
_cmdFail++;
}
Emit(cmd, ok, detail: $"before={before} after={after} prefer={LifeSagaRail.LastPreferPipVisible}");
Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
break;
}
@ -4427,19 +4451,27 @@ public static class AgentHarness
return;
}
WorldTile tile = TileNearCamera();
// Prefer spawning next to an existing living unit so terrain is valid.
Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
if (anchor != null)
WorldTile tile = null;
try
{
WorldTile anchorTile = World.world.GetTile(
Mathf.RoundToInt(anchor.current_position.x),
Mathf.RoundToInt(anchor.current_position.y));
if (anchorTile != null)
tile = TileNearCamera();
// Prefer spawning next to an existing living unit so terrain is valid.
Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
if (anchor != null)
{
tile = anchorTile;
WorldTile anchorTile = World.world.GetTile(
Mathf.RoundToInt(anchor.current_position.x),
Mathf.RoundToInt(anchor.current_position.y));
if (anchorTile != null)
{
tile = anchorTile;
}
}
}
catch (Exception)
{
tile = null;
}
if (tile == null)
{
@ -4456,31 +4488,51 @@ public static class AgentHarness
int count = Math.Max(1, cmd.count);
int spawned = 0;
Actor last = null;
Exception lastEx = null;
for (int i = 0; i < count; i++)
{
Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
if (actor != null && actor.isAlive())
try
{
spawned++;
last = actor;
Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
if (actor != null && actor.isAlive())
{
spawned++;
last = actor;
}
}
catch (Exception ex)
{
lastEx = ex;
}
}
if (spawned <= 0 || last == null || !last.isAlive())
{
if (RetryFront(cmd, "spawn_zero"))
if (RetryFront(cmd, lastEx != null ? "spawn_ex:" + lastEx.GetType().Name : "spawn_zero"))
{
return;
}
_cmdFail++;
Emit(cmd, ok: false, detail: $"spawned=0/{count} asset={assetId}");
Emit(
cmd,
ok: false,
detail: lastEx != null
? $"spawn_exception asset={assetId} msg={lastEx.Message}"
: $"spawned=0/{count} asset={assetId}");
return;
}
_lastSpawned = last;
_lastSpawnedAssetId = last.asset != null ? last.asset.id : assetId;
CameraDirector.FocusUnit(last);
try
{
CameraDirector.FocusUnit(last);
}
catch (Exception)
{
// Spawn succeeded; focus is best-effort.
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"spawned={spawned}/{count} asset={_lastSpawnedAssetId} last={SafeName(last)}");
@ -9205,6 +9257,163 @@ public static class AgentHarness
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
break;
}
case "caption_mc_identity":
{
string idLine = WatchCaption.LastHeadline ?? "";
pass = idLine.IndexOf(" · ", StringComparison.Ordinal) >= 0
&& (idLine.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Leader", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Alpha", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Chief", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Captain", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Founder", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Plotter", StringComparison.OrdinalIgnoreCase) >= 0);
detail = $"identity='{idLine}' pass={pass}";
break;
}
case "caption_mc_identity_or_species":
{
string idLine = WatchCaption.LastHeadline ?? "";
pass = !string.IsNullOrEmpty(idLine)
&& idLine.IndexOf("A life in motion", StringComparison.OrdinalIgnoreCase) < 0;
detail = $"identity='{idLine}' pass={pass}";
break;
}
case "caption_nobody_species":
{
string idLine = WatchCaption.LastHeadline ?? "";
bool hasTitleSep = idLine.IndexOf(" · ", StringComparison.Ordinal) >= 0
&& (idLine.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0
|| idLine.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) >= 0);
pass = !string.IsNullOrEmpty(idLine) && !hasTitleSep;
detail = $"identity='{idLine}' pass={pass}";
break;
}
case "caption_identity_not_in_label":
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
string label = cur != null ? (cur.Label ?? "") : "";
pass = label.IndexOf(" · King", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) < 0
&& !label.StartsWith("King of", StringComparison.OrdinalIgnoreCase);
detail = $"label='{label}' identity='{WatchCaption.LastHeadline}' pass={pass}";
break;
}
case "caption_prose_no_jargon":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
string tip = LifeSagaPresentation.BuildPlainText(id) ?? "";
string beat = WatchCaption.LastBeatLine ?? "";
string ctx = WatchCaption.LastContextLine ?? "";
string all = tip + "\n" + beat + "\n" + ctx + "\n" + (WatchCaption.LastHeadline ?? "");
pass = !SagaProse.ContainsBannedPhrase(all);
string sample = all.Replace('\n', ' ');
if (sample.Length > 120)
{
sample = sample.Substring(0, 120);
}
detail = $"banned={!pass} sample='{sample}'";
break;
}
case "caption_prose_casing_cast":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
LifeSagaViewModel model = LifeSagaPresentation.Build(id);
string any = (cmd.value ?? "Lover").Trim();
pass = false;
if (model != null)
{
string[] parts = any.Split('|');
for (int i = 0; i < model.Cast.Count && !pass; i++)
{
string rel = model.Cast[i]?.Relation ?? "";
for (int p = 0; p < parts.Length; p++)
{
string want = (parts[p] ?? "").Trim();
if (want.Length > 0 && string.Equals(rel, want, StringComparison.Ordinal))
{
pass = true;
break;
}
}
}
}
detail = $"wantExact='{any}' pass={pass}";
break;
}
case "caption_context_stake":
{
string ctx = WatchCaption.LastContextLine ?? "";
pass = !string.IsNullOrEmpty(ctx)
&& (ctx.IndexOf("war", StringComparison.OrdinalIgnoreCase) >= 0
|| ctx.IndexOf("Still", StringComparison.OrdinalIgnoreCase) >= 0
|| ctx.IndexOf("Feud", StringComparison.OrdinalIgnoreCase) >= 0
|| ctx.IndexOf("tangled", StringComparison.OrdinalIgnoreCase) >= 0
|| ctx.IndexOf("Bound", StringComparison.OrdinalIgnoreCase) >= 0
|| ctx.IndexOf("Rules", StringComparison.OrdinalIgnoreCase) >= 0);
bool looksLikeSpine = ctx.IndexOf(" · ", StringComparison.Ordinal) >= 0
&& (ctx.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| ctx.StartsWith("War front", StringComparison.OrdinalIgnoreCase)
|| ctx.StartsWith("Love", StringComparison.OrdinalIgnoreCase)
|| ctx.StartsWith("Grief", StringComparison.OrdinalIgnoreCase)
|| ctx.StartsWith("Plot", StringComparison.OrdinalIgnoreCase)
|| ctx.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|| ctx.StartsWith("Battle", StringComparison.OrdinalIgnoreCase));
if (looksLikeSpine && ctx.IndexOf("Still", StringComparison.OrdinalIgnoreCase) < 0)
{
pass = false;
}
detail = $"context='{ctx}' pass={pass}";
break;
}
case "caption_context_omits":
{
// Soft tip hitch: Context must not show banned biography fragments.
string ctx = WatchCaption.LastContextLine ?? "";
string banned = (cmd.value ?? "Slew|Became|Founded").Trim();
pass = true;
string[] parts = banned.Split('|');
for (int i = 0; i < parts.Length; i++)
{
string want = (parts[i] ?? "").Trim();
if (want.Length > 0
&& ctx.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = false;
break;
}
}
detail = $"context='{ctx}' banned='{banned}' pass={pass}";
break;
}
case "saga_panel_cast_first":
{
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;
detail =
$"panel={panel} ownsAvatar={LifeSagaPanel.OwnsAvatar} dbg='{dbg}' pass={pass}";
break;
}
case "caption_hover_keeps_beat":
{
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);
pass = hover && panel && identityOk && focusId != 0
&& LifeSagaPanel.BoundUnitId == LifeSagaViewController.HoverPreviewId;
detail =
$"hover={hover} panelBound={LifeSagaPanel.BoundUnitId} hoverId={LifeSagaViewController.HoverPreviewId} identity='{identity}' beat='{WatchCaption.LastBeatLine}' pass={pass}";
break;
}
case "saga_memory_has_focus":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);

View file

@ -218,6 +218,11 @@ internal static class HarnessScenarios
return SagaRailPreferClick();
case "saga_soft_fill_no_pack":
return SagaSoftFillNoPack();
case "caption_character_beat":
case "caption_mc_identity":
return CaptionCharacterBeat();
case "caption_prose_voice":
return CaptionProseVoice();
case "story_spine_scoped":
return StorySpineScoped();
case "story_family_variety":
@ -439,6 +444,8 @@ internal static class HarnessScenarios
Nested("reg_saga_rail_active", "saga_rail_active_highlight"),
Nested("reg_saga_rail_prefer", "saga_rail_prefer_click"),
Nested("reg_saga_soft_fill", "saga_soft_fill_no_pack"),
Nested("reg_caption_beat", "caption_character_beat"),
Nested("reg_caption_prose", "caption_prose_voice"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_presentability", "camera_presentability"),
@ -3571,7 +3578,7 @@ internal static class HarnessScenarios
};
}
/// <summary>Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body.</summary>
/// <summary>Hover depth panel under always-on character beat caption (no Dossier/Saga tabs).</summary>
private static List<HarnessCommand> SagaAdaptiveDossier()
{
return new List<HarnessCommand>
@ -3588,65 +3595,51 @@ internal static class HarnessScenarios
Step("sad9", "focus", asset: "human"),
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("sad12", "saga_select_tab", value: "saga"),
Step("sad13", "assert", expect: "saga_tab_effective", value: "saga"),
Step("sad14", "assert", expect: "saga_panel_bound"),
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
Step("sad15", "screenshot", value: "saga-tab-selected"),
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"),
Step("sad14d", "assert", expect: "saga_panel_cast_first"),
Step("sad15", "screenshot", value: "saga-hover-depth"),
Step("sad15b", "saga_stamp_chapter_focus", asset: "Love", value: "Bound with a lasting partner"),
Step("sad15c", "saga_memory_record_focus", asset: "Founding", value: "kingdom"),
Step("sad15d", "saga_select_tab", value: "saga"),
Step("sad15e", "assert", expect: "saga_panel_fixed_size"),
Step("sad15f", "screenshot", value: "saga-tab-rich"),
Step("sad15e2", "assert", expect: "saga_panel_lens", value: "true"),
Step("sad15e3", "assert", expect: "saga_overview_tip_not",
value: "Evidence|kills ·|remembered clashes|Open Lore|Earned rival|saga hero"),
Step("sad15f", "screenshot", value: "saga-hover-rich"),
Step("sad16", "saga_select_tab", value: "dossier"),
Step("sad16b", "assert", expect: "saga_panel_fixed_size"),
Step("sad16c", "screenshot", value: "saga-dossier-paired"),
Step("sad16c", "screenshot", value: "saga-caption-idle"),
Step("sad17", "saga_show_hover_first"),
Step("sad17b", "assert", expect: "saga_panel_fixed_size"),
Step("sad18", "assert", expect: "saga_tab_selected", value: "dossier"),
Step("sad19", "assert", expect: "saga_tab_effective", value: "saga"),
Step("sad20", "assert", expect: "saga_hover_preview", value: "true"),
Step("sad21", "assert", expect: "saga_read_pause", value: "true"),
// Leaving the glyph restores Dossier immediately (no panel-dwell keep-alive).
Step("sad21b", "saga_hide_hover"),
Step("sad21d", "saga_hover_tick_away"),
Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"),
Step("sad21i", "assert", expect: "saga_tab_effective", value: "dossier"),
Step("sad21j", "assert", expect: "saga_read_pause", value: "false"),
// Dossier chrome must restore immediately (not wait for a tip ownership change).
Step("sad21j2", "assert", expect: "saga_dossier_chrome_restored"),
Step("sad21k", "saga_show_hover_first"),
Step("sad21l", "assert", expect: "saga_hover_preview", value: "true"),
// Tab click during preview cancels preview and applies the selected tab.
Step("sad21m", "saga_select_tab", value: "dossier"),
Step("sad21n", "assert", expect: "saga_hover_preview", value: "false"),
Step("sad21o", "assert", expect: "saga_tab_effective", value: "dossier"),
Step("sad22", "saga_show_hover_first"),
Step("sad22b", "saga_hide_hover"),
Step("sad23b", "saga_hover_tick_away"),
Step("sad24", "assert", expect: "saga_hover_preview", value: "false"),
Step("sad25", "assert", expect: "saga_tab_effective", value: "dossier"),
Step("sad26", "assert", expect: "saga_read_pause", value: "false"),
Step("sad27", "assert", expect: "saga_overview_tip_not", value: "Fighting |Why |Now |Notable "),
Step("sad28", "saga_select_tab", value: "saga"),
Step("sad29", "assert", expect: "saga_panel_lens", value: "true"),
Step("sad29b", "assert", expect: "saga_overview_tip_not",
value: "Evidence|kills ·|remembered clashes|Open Lore"),
// Non-MC watch with no pin → empty pick state; rail click pins subject.
Step("sad30", "interest_saga_clear"),
Step("sad31", "spawn", asset: "human", count: 2),
Step("sad32", "pick_unit", asset: "human"),
Step("sad33", "focus", asset: "human"),
Step("sad34", "saga_force_admit_focus"),
Step("sad35", "spawn", asset: "sheep", count: 1),
Step("sad36", "pick_unit", asset: "sheep"),
Step("sad37", "focus", asset: "sheep"),
Step("sad38", "saga_select_tab", value: "saga"),
Step("sad39", "assert", expect: "saga_panel_empty", value: "true"),
Step("sad40", "saga_click_first"),
Step("sad41", "assert", expect: "saga_panel_empty", value: "false"),
Step("sad42", "assert", expect: "saga_panel_bound"),
Step("sad43", "screenshot", value: "saga-rail-top"),
// Prefer click does not open hover / pin saga subject.
// Re-seed a human - long hover cycles can leave the map without living units.
Step("sad30", "saga_select_tab", value: "dossier"),
Step("sad30b", "spawn", asset: "human", count: 2),
Step("sad30c", "pick_unit", asset: "human"),
Step("sad30d", "focus", asset: "human"),
Step("sad30e", "interest_saga_clear"),
Step("sad30f", "saga_force_admit_focus"),
Step("sad30g", "saga_rail_refresh"),
Step("sad31", "saga_click_first"),
Step("sad32", "assert", expect: "saga_prefer_focus_on", value: "true"),
Step("sad33", "assert", expect: "saga_hover_preview", value: "false"),
Step("sad34", "assert", expect: "caption_mc_identity_or_species"),
Step("sad43", "screenshot", value: "saga-rail-prefer"),
Step("sad90", "fast_timing", value: "false"),
Step("sad99", "snapshot"),
};
@ -3707,7 +3700,7 @@ internal static class HarnessScenarios
expect: "harness_war", label: "North"),
Step("swm15", "assert", expect: "saga_memory_fact_focus", value: "WarJoin"),
Step("swm16", "assert", expect: "saga_memory_fact_other", value: "WarJoin"),
Step("swm17", "assert", expect: "saga_stake_contains", value: "War with|North vs South"),
Step("swm17", "assert", expect: "saga_stake_contains", value: "Still at war|North vs South|War with"),
Step("swm18", "saga_memory_resolve_war", value: "harness_war"),
Step("swm19", "assert", expect: "saga_memory_fact_resolved", value: "harness_war", label: "true"),
// Original partner from swm9 is the other principal - focus them directly.
@ -3740,8 +3733,8 @@ internal static class HarnessScenarios
Step("spc13", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_plot"),
Step("spc14", "assert", expect: "saga_memory_fact_other", value: "PlotJoin"),
Step("spc15", "saga_select_tab", value: "saga"),
Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot ally"),
Step("spc17", "assert", expect: "saga_stake_contains", value: "Plot with|Joined"),
Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot Partner"),
Step("spc17", "assert", expect: "saga_stake_contains", value: "Still tangled|Plotting|Joined|Plotted"),
Step("spc90", "fast_timing", value: "false"),
Step("spc99", "snapshot"),
};
@ -3773,7 +3766,7 @@ internal static class HarnessScenarios
Step("sck18", "assert", expect: "saga_cast_contains", value: "Parent"),
Step("sck19", "saga_memory_record_focus", asset: "War", value: "North vs South",
expect: "harness_war_crown", label: "North"),
Step("sck20", "assert", expect: "saga_cast_contains", value: "War foe|Parent"),
Step("sck20", "assert", expect: "saga_cast_contains", value: "War Enemy|Parent"),
Step("sck90", "fast_timing", value: "false"),
Step("sck99", "snapshot"),
};
@ -3839,7 +3832,7 @@ internal static class HarnessScenarios
};
}
/// <summary>Live roster role rise becomes RoleChange stake; combat is fallback when alone.</summary>
/// <summary>Live roster role rise updates Identity without duplicating its prior combat stake.</summary>
private static List<HarnessCommand> SagaStakeFromRoleLive()
{
return new List<HarnessCommand>
@ -3855,11 +3848,11 @@ internal static class HarnessScenarios
Step("srl8", "pick_unit", asset: "human"),
Step("srl9", "focus", asset: "human"),
Step("srl10", "saga_force_admit_focus"),
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "A fight marked them"),
Step("srl12", "assert", expect: "saga_stake_contains", value: "fight marked|A fight"),
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "Survived a hard fight"),
Step("srl12", "assert", expect: "saga_stake_contains", value: "Survived a hard fight|hard fight"),
Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
Step("srl15", "assert", expect: "saga_stake_contains", value: "Became clan chief"),
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
Step("srl90", "fast_timing", value: "false"),
Step("srl99", "snapshot"),
};
@ -3899,6 +3892,96 @@ internal static class HarnessScenarios
};
}
/// <summary>Character beat caption: MC identity title, prose bans, hover keeps beat.</summary>
private static List<HarnessCommand> CaptionCharacterBeat()
{
return new List<HarnessCommand>
{
Step("ccb0", "dismiss_windows"),
Step("ccb1", "wait_world"),
Step("ccb2", "set_setting", expect: "enabled", value: "true"),
Step("ccb3", "fast_timing", value: "true"),
Step("ccb4", "spawn", asset: "human", count: 2),
Step("ccb5", "spectator", value: "off"),
Step("ccb6", "spectator", value: "on"),
Step("ccb7", "interest_saga_clear"),
Step("ccb8", "pick_unit", asset: "human"),
Step("ccb9", "focus", asset: "human"),
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"),
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"),
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"),
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"),
Step("ccb21h", "wait", value: "0.3"),
Step("ccb21i", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan|Founded"),
Step("ccb22", "saga_show_hover_first"),
Step("ccb23", "assert", expect: "caption_hover_keeps_beat"),
Step("ccb23b", "assert", expect: "saga_panel_cast_first"),
Step("ccb24", "saga_hide_hover"),
Step("ccb25", "saga_hover_tick_away"),
Step("ccb26", "spawn", asset: "sheep", count: 1),
Step("ccb27", "pick_unit", asset: "sheep"),
Step("ccb28", "focus", asset: "sheep"),
Step("ccb29", "interest_force_session", asset: "sheep", label: "{a} grazes", tier: "Action",
expect: "ccb_sheep", value: "lead=event;evt=40;force=true"),
Step("ccb30", "wait", value: "0.3"),
Step("ccb31", "assert", expect: "caption_nobody_species"),
Step("ccb90", "fast_timing", value: "false"),
Step("ccb99", "snapshot"),
};
}
/// <summary>SagaProse cast/stake voice across bond, foe, plot partner classes.</summary>
private static List<HarnessCommand> CaptionProseVoice()
{
return new List<HarnessCommand>
{
Step("cpv0", "dismiss_windows"),
Step("cpv1", "wait_world"),
Step("cpv2", "set_setting", expect: "enabled", value: "true"),
Step("cpv3", "fast_timing", value: "true"),
Step("cpv4", "spawn", asset: "human", count: 2),
Step("cpv5", "spectator", value: "off"),
Step("cpv6", "spectator", value: "on"),
Step("cpv7", "interest_saga_clear"),
Step("cpv8", "pick_unit", asset: "human"),
Step("cpv9", "happiness_remember_partner"),
Step("cpv10", "pick_unit", asset: "human", value: "other"),
Step("cpv11", "focus", asset: "human"),
Step("cpv12", "saga_force_admit_focus"),
Step("cpv13", "happiness_bond_lovers"),
Step("cpv14", "assert", expect: "saga_cast_contains", value: "Lover"),
Step("cpv15", "assert", expect: "caption_prose_casing_cast", value: "Lover|Best Friend"),
Step("cpv16", "saga_note_encounters", value: "3"),
Step("cpv17", "assert", expect: "saga_rival_focus", value: "true"),
Step("cpv18", "assert", expect: "saga_cast_contains", value: "Old Foe|War Enemy|Blood Foe|Lover"),
Step("cpv19", "assert", expect: "caption_prose_no_jargon"),
Step("cpv20", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_cpv_plot"),
Step("cpv21", "assert", expect: "saga_cast_contains", value: "Plot Partner|Lover|Old Foe"),
Step("cpv22", "assert", expect: "saga_stake_contains", value: "Still tangled|Still at war|Feud|Bound|Lover|Plot"),
Step("cpv90", "fast_timing", value: "false"),
Step("cpv99", "snapshot"),
};
}
/// <summary>Hover acquires read-pause; camera owner stays fixed until release.</summary>
private static List<HarnessCommand> SagaHoverReadPause()
{

View file

@ -153,6 +153,15 @@ public static partial class InterestDirector
return false;
}
// Solo status tips: only the live carrier owns the orange reason.
// Stops Prefer/MC soft-follow from showing "Becomes pregnant" on a king who lacks it.
if (scene.Completion == InterestCompletionKind.StatusPhase
&& !string.IsNullOrEmpty(scene.StatusId)
&& !StatusGameApi.HasStatus(unit, scene.StatusId))
{
return false;
}
bool ensemble = scene.Completion == InterestCompletionKind.CombatActive
|| scene.Completion == InterestCompletionKind.WarFront
|| scene.Completion == InterestCompletionKind.PlotActive

View file

@ -0,0 +1,539 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Presentation-layer character beat: Identity + Beat + Context.
/// Does not mutate InterestCandidate.Label or weaken identity_filter.
/// </summary>
public static class CaptionComposer
{
public const float ReentrySeconds = 12f;
private static long _lastCaptionUnitId;
private static float _lastCaptionAt = -999f;
private static float _lastCutawayAt = -999f;
private static long _cutawayFromId;
private static string _lastFingerprint = "";
public static string LastIdentityLine { get; private set; } = "";
public static string LastBeatLine { get; private set; } = "";
public static string LastContextLine { get; private set; } = "";
public static long LastReasonRelatedId { get; private set; }
public static string LastFingerprint => _lastFingerprint;
public static void Clear()
{
_lastCaptionUnitId = 0;
_lastCaptionAt = -999f;
_lastCutawayAt = -999f;
_cutawayFromId = 0;
_lastFingerprint = "";
LastIdentityLine = "";
LastBeatLine = "";
LastContextLine = "";
LastReasonRelatedId = 0;
}
public static CaptionModel Compose(
Actor focus,
InterestCandidate ownedTip,
string presentableBeat,
long reasonRelatedId,
string spineLabel,
bool statusBannerOwnsBeat)
{
var model = new CaptionModel();
long focusId = EventFeedUtil.SafeId(focus);
float now = Time.time;
bool isReentry = false;
if (focusId != 0 && focusId != _lastCaptionUnitId)
{
// Test the returning subject before replacing the cutaway record with
// the subject we are leaving. Updating first made this comparison
// impossible: on A -> B -> A, _cutawayFromId was changed to B before
// A could be recognized as the returning life.
if (_cutawayFromId == focusId
&& _lastCutawayAt > 0f
&& now - _lastCutawayAt >= ReentrySeconds
&& LifeSagaRoster.IsMc(focusId))
{
isReentry = true;
}
if (_lastCaptionUnitId != 0)
{
_cutawayFromId = _lastCaptionUnitId;
_lastCutawayAt = now;
}
}
if (focusId != 0)
{
_lastCaptionUnitId = focusId;
_lastCaptionAt = now;
}
model.IdentityLine = BuildIdentityLine(focus, focusId);
model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? "";
if (statusBannerOwnsBeat)
{
// Banner owns orange row; composer still fills Identity + Context.
model.BeatLine = "";
}
else
{
model.BeatLine = EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
if (!isReentry)
{
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
}
}
model.ContextLine = BuildContext(
focusId,
ownedTip,
model.BeatLine,
model.IdentityLine,
spineLabel,
isReentry && !statusBannerOwnsBeat);
model.Fingerprint =
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
LastIdentityLine = model.IdentityLine;
LastBeatLine = model.BeatLine;
LastContextLine = model.ContextLine;
LastReasonRelatedId = model.ReasonRelatedId;
_lastFingerprint = model.Fingerprint;
return model;
}
public static string TruncateIdentity(string identity, int maxChars)
{
if (string.IsNullOrEmpty(identity) || maxChars <= 0 || identity.Length <= maxChars)
{
return identity ?? "";
}
// Prefer Name · FirstTitleToken…
int sep = identity.IndexOf(" · ", StringComparison.Ordinal);
if (sep > 0)
{
string name = identity.Substring(0, sep);
string rest = identity.Substring(sep + 3).Trim();
string firstToken = rest;
int sp = rest.IndexOf(' ');
if (sp > 0)
{
firstToken = rest.Substring(0, sp);
}
string compact = name + " · " + firstToken;
if (compact.Length <= maxChars)
{
return compact + "…";
}
if (name.Length + 1 < maxChars)
{
return name.Substring(0, Math.Min(name.Length, maxChars - 1)) + "…";
}
}
return identity.Substring(0, Math.Max(1, maxChars - 1)) + "…";
}
private static string BuildIdentityLine(Actor focus, long focusId)
{
if (focus == null || focusId == 0)
{
return "";
}
string name = EventFeedUtil.SafeName(focus);
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
// Strip Prefer star if presentation added it elsewhere.
if (name.StartsWith("★ ", StringComparison.Ordinal))
{
name = name.Substring(2).Trim();
}
string speciesJob = UnitDossier.BuildHeadline(
name,
UnitDossier.BuildIdentityTag(
focus.asset != null ? focus.asset.id : "",
UnitDossier.ReadLiveJobLabel(focus)));
if (!LifeSagaRoster.IsMc(focusId))
{
return speciesJob;
}
LifeSagaSlot slot = LifeSagaRoster.Get(focusId);
LifeSagaLifeMemory memory = LifeSagaMemory.Get(focusId);
string title = SagaProse.Title(slot, focus, memory);
if (string.IsNullOrEmpty(title))
{
return speciesJob;
}
return name + " · " + title;
}
private static string EnrichBeat(
Actor focus,
long focusId,
InterestCandidate tip,
string beat,
ref long relatedId,
bool isReentry)
{
if (string.IsNullOrEmpty(beat))
{
return "";
}
string result = beat;
long otherId = relatedId;
if (otherId == 0 && tip != null)
{
otherId = tip.RelatedId != 0 ? tip.RelatedId : tip.PairPartnerId;
}
if (otherId == 0 && tip != null && tip.PairPartnerId != 0)
{
otherId = tip.PairPartnerId;
}
if (otherId != 0)
{
relatedId = otherId;
}
// Ensemble tips: no cast-name injection; rematch clause OK when pair/related already named.
bool ensemble = SagaProse.IsEnsembleTip(beat);
Actor other = otherId != 0 ? EventFeedUtil.FindAliveById(otherId) : null;
if (other == null && otherId != 0)
{
other = EventFeedUtil.FindUnitById(otherId);
}
if (focusId != 0 && otherId != 0 && other != null)
{
RelationEvidence ev = SagaProse.ResolveRelation(focusId, otherId, focus, other);
string verb = SagaProse.ClassifyTipVerb(beat);
string clause = SagaProse.BeatClause(focus, other, ev, beat, verb);
if (!string.IsNullOrEmpty(clause))
{
// Skip weak clauses on pure ensemble without the other name in tip.
if (ensemble)
{
string otherName = EventFeedUtil.SafeName(other);
if (!string.IsNullOrEmpty(otherName)
&& beat.IndexOf(otherName, StringComparison.OrdinalIgnoreCase) < 0
&& tip != null
&& tip.PairPartnerId == 0
&& tip.RelatedId == 0)
{
clause = "";
}
}
if (!string.IsNullOrEmpty(clause)
&& result.IndexOf(clause, StringComparison.OrdinalIgnoreCase) < 0)
{
result = result.TrimEnd() + " · " + clause;
}
}
}
if (isReentry && LifeSagaRoster.IsMc(focusId))
{
string n = EventFeedUtil.SafeName(focus);
if (!string.IsNullOrEmpty(n)
&& result.StartsWith(n, StringComparison.Ordinal)
&& result.IndexOf(" again", StringComparison.OrdinalIgnoreCase) < 0)
{
// "Name again · rest" or "Name is …" → "Name again is …" is awkward.
// Prefer "Name again · " only when tip doesn't already start with Name + again.
if (result.StartsWith(n + " ", StringComparison.Ordinal))
{
result = n + " again · " + result.Substring(n.Length).TrimStart();
}
}
}
return result;
}
/// <summary>
/// Identity already names the focused life directly above the Beat. Remove only that
/// leading subject (plain or our exact rich-name wrapper), leaving other names intact.
/// </summary>
private static string RemoveIdentitySubjectPrefix(Actor focus, string beat)
{
if (focus == null || string.IsNullOrEmpty(beat))
{
return beat ?? "";
}
string name = EventFeedUtil.SafeName(focus);
if (string.IsNullOrEmpty(name))
{
return beat;
}
string rest = "";
string richName = ActivityProse.ColorName(name);
if (beat.StartsWith(richName + " ", StringComparison.Ordinal))
{
rest = beat.Substring(richName.Length).TrimStart();
}
else if (beat.StartsWith(name + " ", StringComparison.Ordinal))
{
rest = beat.Substring(name.Length).TrimStart();
}
return string.IsNullOrEmpty(rest) ? beat : SagaProse.Sentence(rest);
}
private static string BuildContext(
long focusId,
InterestCandidate tip,
string beatLine,
string identityLine,
string spineLabel,
bool isReentry)
{
string spine = spineLabel ?? "";
if (!string.IsNullOrEmpty(spine))
{
return spine;
}
if (!LifeSagaRoster.IsMc(focusId))
{
return "";
}
string tipMood = TipMood(beatLine, tip);
// Walk strongest → weaker so a soft tip can skip a kill/role hitch and still
// show an ongoing war/plot/rival stake when one exists.
LifeSagaFact stake = PickHitchStake(focusId, beatLine, tipMood, identityLine ?? "");
if (stake == null)
{
return "";
}
return SagaProse.StakeLine(stake) ?? "";
}
private static LifeSagaFact PickHitchStake(
long focusId,
string beatLine,
string tipMood,
string identityLine)
{
LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId);
if (best != null
&& !BeatCoversStake(beatLine, best)
&& !ShouldOmitStakeHitch(best, tipMood, identityLine))
{
return best;
}
var facts = LifeSagaMemory.FactsFor(focusId);
LifeSagaFact next = null;
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact f = facts[i];
if (f == null || f.Resolved || ReferenceEquals(f, best))
{
continue;
}
if (BeatCoversStake(beatLine, f) || ShouldOmitStakeHitch(f, tipMood, identityLine))
{
continue;
}
// Only promote stake-worthy / fallback-worthy unresolved facts.
if (LifeSagaMemory.IsUnresolvedStakeCandidate(f)
&& (next == null
|| f.Strength > next.Strength
|| (Mathf.Approximately(f.Strength, next.Strength) && f.At > next.At)))
{
next = f;
}
}
return next;
}
/// <summary>
/// Soft live tips (love/rest/dream) should not hitch closed biography
/// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may.
/// </summary>
private static bool ShouldOmitStakeHitch(LifeSagaFact stake, string tipMood, string identityLine)
{
if (stake == null)
{
return true;
}
// RoleChange that already sits in Identity ("King of X") is always redundant.
if (stake.Kind == LifeSagaFactKind.RoleChange
&& RoleChangeEchoesIdentity(stake, identityLine))
{
return true;
}
// "Founder" in Identity already carries the founding fact; repeating
// "Founded kingdom" immediately below it adds no new information.
if (stake.Kind == LifeSagaFactKind.Founding
&& identityLine.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
bool soft = tipMood == "love" || tipMood == "rest";
if (!soft)
{
return false;
}
switch (stake.Kind)
{
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.CombatEncounter:
case LifeSagaFactKind.HardArcCombat:
case LifeSagaFactKind.RoleChange:
case LifeSagaFactKind.Founding:
case LifeSagaFactKind.HomeGained:
case LifeSagaFactKind.HomeLost:
return true;
default:
return false;
}
}
private static bool RoleChangeEchoesIdentity(LifeSagaFact stake, string identityLine)
{
if (string.IsNullOrEmpty(identityLine))
{
return false;
}
string body = SagaProse.StakeLine(stake) ?? "";
if (string.IsNullOrEmpty(body))
{
return false;
}
// "Became Pack Alpha" under Identity "Name · Pack Alpha".
string lowerId = identityLine.ToLowerInvariant();
string lowerBody = body.ToLowerInvariant();
if (lowerBody.StartsWith("became ", StringComparison.Ordinal))
{
string role = lowerBody.Substring("became ".Length).Trim();
if (role.Length >= 4 && lowerId.IndexOf(role, StringComparison.Ordinal) >= 0)
{
return true;
}
}
string[] roleTokens =
{
"king", "queen", "leader", "alpha", "chief", "captain", "founder"
};
for (int i = 0; i < roleTokens.Length; i++)
{
string t = roleTokens[i];
if (lowerBody.IndexOf(t, StringComparison.Ordinal) >= 0
&& lowerId.IndexOf(t, StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
private static string TipMood(string beatLine, InterestCandidate tip)
{
string verb = SagaProse.ClassifyTipVerb(beatLine);
if (verb == "love" || verb == "rest" || verb == "grief" || verb == "combat" || verb == "plot")
{
return verb;
}
if (tip != null && !string.IsNullOrEmpty(tip.StatusId))
{
string sid = tip.StatusId.ToLowerInvariant();
if (sid.IndexOf("pregnant", StringComparison.Ordinal) >= 0
|| sid.IndexOf("love", StringComparison.Ordinal) >= 0
|| sid.IndexOf("fell_in_love", StringComparison.Ordinal) >= 0)
{
return "love";
}
if (sid.IndexOf("sleep", StringComparison.Ordinal) >= 0
|| sid.IndexOf("dream", StringComparison.Ordinal) >= 0
|| sid.IndexOf("afterglow", StringComparison.Ordinal) >= 0)
{
return "rest";
}
}
return verb;
}
private static bool BeatCoversStake(string beat, LifeSagaFact stake)
{
if (string.IsNullOrEmpty(beat) || stake == null)
{
return false;
}
if (stake.OtherId != 0 && !string.IsNullOrEmpty(stake.Other.Name)
&& beat.IndexOf(stake.Other.Name, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
string stakeText = SagaProse.StakeLine(stake);
if (!string.IsNullOrEmpty(stakeText)
&& beat.IndexOf(stakeText, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
// Rematch clash clause already teaches the feud.
if (beat.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0
&& stake.Kind == LifeSagaFactKind.RivalEarned)
{
return true;
}
return false;
}
}
public sealed class CaptionModel
{
public string IdentityLine = "";
public string BeatLine = "";
public string ContextLine = "";
public long ReasonRelatedId;
public string Fingerprint = "";
}

View file

@ -56,6 +56,7 @@ public static class LifeSagaMemory
PairEncounterCooldown.Clear();
_boundWorld = null;
_worldEpoch++;
SagaProse.BumpMemoryRevision();
}
public static void Clear() => ClearSession();
@ -245,6 +246,7 @@ public static class LifeSagaMemory
};
life.Facts.Insert(0, fact);
life.TouchedAt = now;
SagaProse.BumpMemoryRevision();
while (life.Facts.Count > MaxFactsPerLife)
{
life.Facts.RemoveAt(life.Facts.Count - 1);
@ -1207,6 +1209,20 @@ public static class LifeSagaMemory
return PickStrongest(life, strongOnly: false);
}
/// <summary>
/// True when an unresolved fact is eligible as a Context hitch candidate
/// (primary stake-worthy or fallback-worthy).
/// </summary>
public static bool IsUnresolvedStakeCandidate(LifeSagaFact fact)
{
if (fact == null || fact.Resolved)
{
return false;
}
return IsStakeWorthy(fact) || IsStakeFallbackWorthy(fact);
}
private static LifeSagaFact PickStrongest(LifeSagaLifeMemory life, bool strongOnly)
{
LifeSagaFact best = null;
@ -1331,21 +1347,7 @@ public static class LifeSagaMemory
}
/// <summary>Legacy display line: optional world date · beat prose.</summary>
public static string FormatLegacyLine(LifeSagaFact fact)
{
string line = FormatFactLine(fact);
if (string.IsNullOrEmpty(line))
{
return "";
}
if (!string.IsNullOrEmpty(fact.DateLabel))
{
return fact.DateLabel + " · " + line;
}
return line;
}
public static string FormatLegacyLine(LifeSagaFact fact) => SagaProse.FormatLegacyLine(fact);
public static string FormatFamilySummary(int childCount)
{
@ -1402,153 +1404,7 @@ public static class LifeSagaMemory
return chapters;
}
public static string FormatFactLine(LifeSagaFact fact)
{
if (fact == null)
{
return "";
}
string other = !string.IsNullOrEmpty(fact.Other.Name) ? fact.Other.Name : "";
switch (fact.Kind)
{
case LifeSagaFactKind.BondFormed:
if (fact.McCrossover)
{
return string.IsNullOrEmpty(other)
? "Bound to a fellow saga hero"
: "Bound to fellow saga hero " + other;
}
return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other;
case LifeSagaFactKind.BondBroken:
return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other;
case LifeSagaFactKind.FriendFormed:
return string.IsNullOrEmpty(other) ? "Made a true friend" : "Befriended " + other;
case LifeSagaFactKind.ParentChild:
if (!IsParentPerspective(fact))
{
return string.IsNullOrEmpty(other) ? "Was born" : "Born to " + other;
}
return string.IsNullOrEmpty(other) ? "Had a child" : "Had child " + other;
case LifeSagaFactKind.Kill:
if (fact.McCrossover)
{
return string.IsNullOrEmpty(other)
? "Slew a fellow saga hero"
: "Slew fellow saga hero " + other;
}
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
case LifeSagaFactKind.Death:
if (fact.McCrossover)
{
return string.IsNullOrEmpty(other)
? "Fallen to a fellow saga hero"
: "Slain by fellow saga hero " + other;
}
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
case LifeSagaFactKind.RivalEarned:
if (!string.IsNullOrEmpty(fact.Note))
{
if (fact.Note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Opposed in war"
: "Opposed " + other + " in war";
}
if (fact.Note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Opposed in a plot"
: "Opposed " + other + " in a plot";
}
if (fact.Note.StartsWith("killed_kin", StringComparison.Ordinal)
|| fact.Note.StartsWith("kin_slain", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Blood feud over kin"
: "Blood feud with " + other;
}
if (fact.Note.StartsWith("mc_rematch:", StringComparison.Ordinal)
|| fact.Note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other)
? "Clashed again and again"
: "Clashed thrice with " + other;
}
}
return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
case LifeSagaFactKind.WarJoin:
{
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm))
{
return "War with " + other + " (" + realm + ")";
}
if (!string.IsNullOrEmpty(other))
{
return "War with " + other;
}
return string.IsNullOrEmpty(realm) ? "Entered a war" : "War: " + realm;
}
case LifeSagaFactKind.WarEnd:
{
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
return string.IsNullOrEmpty(realm) ? "War ended" : "War ended (" + realm + ")";
}
case LifeSagaFactKind.PlotJoin:
{
string plot = fact.Note ?? "";
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot))
{
return "Plot with " + other + " (" + plot + ")";
}
if (!string.IsNullOrEmpty(other))
{
return "Plot with " + other;
}
return string.IsNullOrEmpty(plot) ? "Joined a plot" : "Joined " + plot;
}
case LifeSagaFactKind.PlotEnd:
{
string plot = fact.Note ?? "";
return string.IsNullOrEmpty(plot) ? "Plot resolved" : "Plot resolved (" + plot + ")";
}
case LifeSagaFactKind.Founding:
return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home";
case LifeSagaFactKind.RoleChange:
return RoleNote(fact.Note);
case LifeSagaFactKind.HomeGained:
return "Found a home";
case LifeSagaFactKind.HomeLost:
return "Lost a home";
case LifeSagaFactKind.HardArcLove:
return FirstNonEmpty(fact.Note, "A relationship changed them");
case LifeSagaFactKind.HardArcGrief:
return FirstNonEmpty(fact.Note, "Carrying a loss");
case LifeSagaFactKind.HardArcCombat:
return FirstNonEmpty(fact.Note, "A fight marked them");
case LifeSagaFactKind.HardArcWar:
return FirstNonEmpty(fact.Note, "War closed in");
case LifeSagaFactKind.HardArcPlot:
return FirstNonEmpty(fact.Note, "Entangled in a plot");
case LifeSagaFactKind.CombatEncounter:
return string.IsNullOrEmpty(other) ? "Fought again" : "Clashed with " + other;
default:
return FirstNonEmpty(fact.Note, "A turning point");
}
}
public static string FormatFactLine(LifeSagaFact fact) => SagaProse.FormatFactLine(fact);
private static void TryMarkKinKillRival(Actor killer, Actor victim)
{

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
@ -6,45 +7,39 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Saga story card: identity + stake beside the portrait, Cast and Legacy full-width under it.
/// No Evidence stats dump and no Open Lore button (L still opens Lore).
/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail.
/// 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.
/// </summary>
public static class LifeSagaPanel
{
/// <summary>Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).</summary>
/// <summary>Matches WatchCaption rail chrome inner width (PanelWidthMax - pads).</summary>
public const float BodyWidthFixed = 232f;
/// <summary>Hard cap so Saga never eats the screen.</summary>
public const float BodyHeightMax = 148f;
public const float BodyHeightMax = 172f;
public const float BodyWidthMin = BodyWidthFixed;
public const float BodyWidthMax = BodyWidthFixed;
public const float BodyHeightMin = 56f;
public const float BodyHeightMin = 12f;
public const float BodyHeightFixed = BodyHeightMax;
/// <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 float Pad = 3f;
private const float AvatarSize = 32f;
private const float ColGap = 6f;
private const float CastFace = 16f;
private const float CastSlotW = 54f;
private const float SecLabelH = 10f;
private const float LineH = 10f;
private const int EyebrowFont = 7;
private const int NameFont = 9;
private const int TitleFont = 7;
private const int StakeFont = 8;
private const int BodyFont = 7;
private const int MaxCast = 4;
private const int MaxLegacy = 5;
private const int BuildVersion = 8;
private const int BuildVersion = 10;
private static GameObject _root;
private static RectTransform _rootRt;
private static Text _eyebrowText;
private static Text _nameText;
private static Text _titleText;
private static Text _stakeText;
private static Text _castHead;
private static Text _legacyHead;
private static Text _legacyText;
@ -52,7 +47,6 @@ public static class LifeSagaPanel
private static long _boundId;
private static string _speciesId = "";
private static string _fingerprint = "";
private static bool _ownsAvatar;
private static int _builtVersion;
private static float _laidOutHeight = BodyHeightMin;
@ -70,7 +64,8 @@ public static class LifeSagaPanel
public static float BodyHeight => _laidOutHeight;
public static float BodyWidthPx => BodyWidthFixed;
public static long BoundUnitId => _boundId;
public static bool OwnsAvatar => _ownsAvatar && Visible;
/// <summary>Always false - caption owns identity; panel never steals DossierAvatar.</summary>
public static bool OwnsAvatar => false;
public static string LastLayoutDebug { get; private set; } = "";
public static void EnsureBuilt(Transform parent)
@ -87,13 +82,9 @@ public static class LifeSagaPanel
if (_root != null)
{
Object.Destroy(_root);
UnityEngine.Object.Destroy(_root);
_root = null;
_rootRt = null;
_eyebrowText = null;
_nameText = null;
_titleText = null;
_stakeText = null;
_castHead = null;
_legacyHead = null;
_legacyText = null;
@ -107,12 +98,6 @@ public static class LifeSagaPanel
_rootRt.anchorMax = new Vector2(0f, 1f);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
_eyebrowText = MakeLabel(_root.transform, "SagaEyebrow", EyebrowFont, HudTheme.Muted, FontStyle.Normal);
_nameText = MakeLabel(_root.transform, "SagaName", NameFont, HudTheme.ValueOrange, FontStyle.Bold);
_titleText = MakeLabel(_root.transform, "SagaTitle", TitleFont, HudTheme.Muted, FontStyle.Normal);
_stakeText = MakeLabel(_root.transform, "StakeBody", StakeFont, HudTheme.ValueOrange, FontStyle.Bold);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_castHead.text = "Cast";
_legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
@ -135,7 +120,6 @@ public static class LifeSagaPanel
_boundId = 0;
_speciesId = "";
_fingerprint = "";
_ownsAvatar = false;
_laidOutHeight = BodyHeightMin;
if (_root != null)
{
@ -151,10 +135,6 @@ public static class LifeSagaPanel
}
_root.SetActive(visible);
if (!visible)
{
_ownsAvatar = false;
}
}
public static void Bind(LifeSagaViewModel model)
@ -166,7 +146,7 @@ public static class LifeSagaPanel
if (model == null || model.UnitId == 0)
{
BindEmptyPickState();
Clear();
return;
}
@ -178,27 +158,28 @@ public static class LifeSagaPanel
if (!same)
{
_eyebrowText.text = model.LensEyebrow ?? "";
_nameText.text = StripStar(model.Name);
_titleText.text = model.Title ?? "";
_stakeText.text = model.StakeLine ?? "";
for (int i = 0; i < CastSlots.Length; i++)
int shownCast = 0;
for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++)
{
if (i < model.Cast.Count && model.Cast[i] != null)
LifeSagaCastMember member = model.Cast[i];
if (member == null || CastDuplicatesCurrentBeat(member))
{
BindCast(CastSlots[i], model.Cast[i]);
}
else
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
continue;
}
BindCast(CastSlots[shownCast], member);
shownCast++;
}
for (int i = shownCast; i < CastSlots.Length; i++)
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
var legacyParts = new List<string>(MaxLegacy);
var seen = new HashSet<string>();
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
string stakeKey = (model.StakeLine ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
{
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
@ -225,7 +206,6 @@ public static class LifeSagaPanel
_legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : "";
}
RefreshAvatar(model);
Layout();
_root.SetActive(true);
}
@ -239,89 +219,35 @@ public static class LifeSagaPanel
_rootRt.anchoredPosition = new Vector2(padX, -y);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
if (_ownsAvatar)
{
DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize);
DossierAvatar.DrawBehind(_root.transform);
DossierAvatar.ForceCompactFrame();
}
return y + _laidOutHeight + 2f;
}
private static void RefreshAvatar(LifeSagaViewModel model)
{
if (model == null || model.UnitId == 0)
{
_ownsAvatar = false;
return;
}
DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize);
DossierAvatar.SetHostVisible(true);
Actor live = EventFeedUtil.FindAliveById(model.UnitId);
if (live != null && live.isAlive())
{
DossierAvatar.Show(live);
DossierAvatar.ForceCompactFrame();
}
else
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
_ownsAvatar = true;
}
private static void Layout()
{
bool useAvatarCol = _ownsAvatar;
float colX = useAvatarCol ? Pad + AvatarSize + ColGap : Pad;
float textW = BodyWidthFixed - colX - Pad;
float fullX = Pad;
float fullW = BodyWidthFixed - Pad * 2f;
float y = Pad;
float avatarBottom = useAvatarCol ? Pad + AvatarSize : Pad;
float yMax = BodyHeightMax - Pad;
if (!string.IsNullOrEmpty(_eyebrowText.text))
// 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 ?? "";
for (int i = 0; i < CastSlots.Length; i++)
{
PlaceText(_eyebrowText, colX, y, textW, LineH);
y += LineH;
}
else
{
Hide(_eyebrowText);
}
CastSlot slot = CastSlots[i];
if (slot?.Root == null || !slot.Root.activeSelf || slot.Name == null)
{
continue;
}
PlaceText(_nameText, colX, y, textW, NameFont + 2f);
y += NameFont + 2f;
if (!string.IsNullOrEmpty(_titleText.text))
{
PlaceText(_titleText, colX, y, textW, LineH);
y += LineH;
}
else
{
Hide(_titleText);
}
// Stake + Cast + Legacy wrap under the portrait (identity stays in the header band).
y = Mathf.Max(y + 2f, avatarBottom + 2f);
if (!string.IsNullOrEmpty(_stakeText.text))
{
float stakeH = Mathf.Clamp(
EstimateWrapHeight(_stakeText.text, fullW, StakeFont),
LineH,
LineH * 3f);
PlaceText(_stakeText, fullX, y, fullW, stakeH);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
y += stakeH + 2f;
}
else
{
Hide(_stakeText);
string castName = slot.Name.text ?? "";
if (!string.IsNullOrEmpty(castName)
&& liveBeat.IndexOf(castName, StringComparison.OrdinalIgnoreCase) >= 0)
{
slot.Root.SetActive(false);
slot.UnitId = 0;
}
}
int castN = 0;
@ -333,13 +259,13 @@ public static class LifeSagaPanel
}
}
float yMax = BodyHeightMax - Pad;
if (castN > 0)
{
PlaceText(_castHead, fullX, y, fullW, SecLabelH);
y += SecLabelH;
float castX = fullX;
float castRowH = CastFace + 18f;
float castX = fullX;
int col = 0;
for (int i = 0; i < CastSlots.Length; i++)
{
if (!CastSlots[i].Root.activeSelf)
@ -347,11 +273,19 @@ public static class LifeSagaPanel
continue;
}
if (col >= CastPerRow)
{
y += castRowH + 2f;
castX = fullX;
col = 0;
}
RectTransform rt = CastSlots[i].Root.GetComponent<RectTransform>();
rt.anchoredPosition = new Vector2(castX, -y);
rt.sizeDelta = new Vector2(CastSlotW, castRowH);
CastSlots[i].Root.transform.SetAsLastSibling();
castX += CastSlotW + 2f;
col++;
}
y += castRowH + 2f;
@ -369,7 +303,7 @@ public static class LifeSagaPanel
float legacyH = Mathf.Clamp(
EstimateWrapHeight(_legacyText.text, fullW, BodyFont),
LineH,
LineH * 5f);
LineH * 6f);
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
PlaceText(_legacyText, fullX, y, fullW, legacyH);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
@ -383,7 +317,7 @@ public static class LifeSagaPanel
hasLegacy = false;
}
y = Mathf.Max(y + Pad, useAvatarCol ? AvatarSize + Pad * 2f : BodyHeightMin);
y = Mathf.Max(y + Pad, BodyHeightMin);
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
if (_rootRt != null)
@ -392,7 +326,7 @@ public static class LifeSagaPanel
}
LastLayoutDebug =
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' eyebrow='{Short(_eyebrowText?.text)}' stake='{Short(_stakeText?.text)}' cast={castN} legacy={(hasLegacy ? 1 : 0)}";
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} castFirst=1";
}
private static float EstimateWrapHeight(string text, float width, float fontSize)
@ -449,14 +383,13 @@ public static class LifeSagaPanel
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Normal);
Text relation = MakeLabel(root.transform, "Rel", BodyFont, HudTheme.Muted, FontStyle.Normal);
RectTransform nameRt = name.rectTransform;
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal);
nameRt.anchoredPosition = new Vector2(0f, -(CastFace + 1f));
nameRt.sizeDelta = new Vector2(CastSlotW, 9f);
RectTransform relRt = relation.rectTransform;
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
relRt.anchoredPosition = new Vector2(0f, -(CastFace + 9f));
relRt.sizeDelta = new Vector2(CastSlotW, 9f);
root.SetActive(false);
return new CastSlot
@ -472,8 +405,10 @@ public static class LifeSagaPanel
private static void BindCast(CastSlot slot, LifeSagaCastMember member)
{
slot.UnitId = member.UnitId;
slot.Name.text = FirstNonEmpty(member.Name, "Someone");
slot.Relation.text = FirstNonEmpty(member.Relation, "Linked");
string name = string.IsNullOrEmpty(member.Name) ? "Someone" : member.Name;
string rel = string.IsNullOrEmpty(member.Relation) ? "Linked" : member.Relation;
FitCastText(slot.Name, name);
FitCastText(slot.Relation, rel);
Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null;
Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId)
@ -482,67 +417,44 @@ public static class LifeSagaPanel
slot.Face.sprite = sprite;
slot.Face.enabled = sprite != null;
slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f);
slot.FaceBg.color = member.Alive
? new Color(0.12f, 0.13f, 0.16f, 0.95f)
: new Color(0.18f, 0.12f, 0.12f, 0.95f);
slot.Root.SetActive(true);
}
private static void BindEmptyPickState()
/// <summary>The live Beat already explains the person currently on screen.</summary>
private static bool CastDuplicatesCurrentBeat(LifeSagaCastMember member)
{
_boundId = 0;
_speciesId = "";
_fingerprint = "empty-pick";
_ownsAvatar = false;
if (_eyebrowText != null)
{
_eyebrowText.text = "";
}
if (_nameText != null)
{
_nameText.text = "Pick a life";
}
if (_titleText != null)
{
_titleText.text = "from the rail";
}
if (_stakeText != null)
{
_stakeText.text = "Hover or click a glyph to read their saga.";
}
if (_legacyText != null)
{
_legacyText.text = "";
}
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i]?.Root != null)
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
DossierAvatar.SetHostVisible(false);
Layout();
if (_root != null)
{
_root.SetActive(true);
}
string beat = WatchCaption.VisibleBeatLine ?? "";
return member != null
&& !string.IsNullOrEmpty(member.Name)
&& beat.IndexOf(member.Name, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
private static void FitCastText(Text text, string value)
{
int fitSize = size < 7 ? 7 : size;
if (text == null)
{
return;
}
text.text = value ?? "";
text.resizeTextForBestFit = true;
text.resizeTextMinSize = 6;
text.resizeTextMaxSize = BodyFont;
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Truncate;
}
private static Text MakeLabel(Transform parent, string name, int fontSize, Color color, FontStyle style)
{
int fitSize = fontSize < 7 ? 7 : fontSize;
Text text = HudCanvas.MakeText(parent, name, "", fitSize);
text.color = color;
text.fontStyle = style;
text.fontSize = fitSize;
text.resizeTextForBestFit = false;
text.alignment = TextAnchor.UpperLeft;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
RectTransform rt = text.rectTransform;
@ -585,8 +497,8 @@ public static class LifeSagaPanel
private static string Fingerprint(LifeSagaViewModel model)
{
var sb = new StringBuilder(192);
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title);
sb.Append('|').Append(model.LensEyebrow).Append('|').Append(model.StakeLine);
sb.Append(model.UnitId).Append('|').Append(model.StakeLine)
.Append('|').Append(WatchCaption.VisibleBeatLine);
for (int i = 0; i < model.Cast.Count; i++)
{
var c = model.Cast[i];
@ -609,41 +521,4 @@ public static class LifeSagaPanel
return sb.ToString();
}
private static string StripStar(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return name.StartsWith("★ ") ? name.Substring(2) : name;
}
private static string FirstNonEmpty(params string[] parts)
{
if (parts == null)
{
return "";
}
for (int i = 0; i < parts.Length; i++)
{
if (!string.IsNullOrEmpty(parts[i]))
{
return parts[i];
}
}
return "";
}
private static string Short(string s)
{
if (string.IsNullOrEmpty(s))
{
return "";
}
return s.Length <= 28 ? s : s.Substring(0, 27) + "…";
}
}

View file

@ -49,10 +49,10 @@ public static class LifeSagaPresentation
model.PrimaryLens = primary;
model.SecondaryLens = secondary;
LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId);
LifeSagaFact stake = StrongestDisplayStake(unitId, model.Title);
model.StakeLine = stake != null
? LifeSagaMemory.FormatFactLine(stake)
: BuildFallbackStake(slot, actor, memory);
: (string.IsNullOrEmpty(model.Title) ? BuildFallbackStake(slot, actor, memory) : "");
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
BuildCast(model, slot, actor, memory, primary);
@ -61,6 +61,42 @@ public static class LifeSagaPresentation
return model;
}
/// <summary>
/// Pick the strongest unresolved pressure that adds information beyond Identity.
/// Current-role/founding facts belong in the title, not a duplicate tooltip stake.
/// </summary>
private static LifeSagaFact StrongestDisplayStake(long unitId, string title)
{
LifeSagaFact best = null;
var facts = LifeSagaMemory.FactsFor(unitId);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact fact = facts[i];
if (fact == null || fact.Resolved || !LifeSagaMemory.IsUnresolvedStakeCandidate(fact))
{
continue;
}
bool repeatsIdentity = SagaProse.RoleChangeEchoesTitle(fact, title)
|| (fact.Kind == LifeSagaFactKind.Founding
&& !string.IsNullOrEmpty(title)
&& title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0);
if (repeatsIdentity)
{
continue;
}
if (best == null
|| fact.Strength > best.Strength
|| (Mathf.Approximately(fact.Strength, best.Strength) && fact.At > best.At))
{
best = fact;
}
}
return best;
}
/// <summary>Compatibility plain text for harness diagnostics.</summary>
public static string BuildPlainText(long unitId)
{
@ -359,73 +395,32 @@ public static class LifeSagaPresentation
private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (slot != null)
{
if (slot.IsKing)
{
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
}
if (slot.IsClanChief) return "Clan chief";
if (slot.IsLeader)
{
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
}
if (slot.IsAlpha) return "Pack alpha";
if (slot.IsArmyCaptain) return "Army captain";
if (slot.IsFounder) return "Founder";
if (slot.IsPlotAuthor) return "Plotter";
if (slot.IsSingleton) return "Last of their kind";
}
if (memory != null)
{
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding)
{
return "Founder";
}
}
}
return "A life in motion";
return SagaProse.Title(slot, actor, memory);
}
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
{
return LifeSagaMemory.FormatFactLine(rival);
return SagaProse.StakeLine(rival);
}
if (slot != null)
{
switch (slot.AdmissionReason)
string roleStake = SagaProse.RoleFallbackStake(slot.AdmissionReason, slot, actor);
if (!string.IsNullOrEmpty(roleStake))
{
case LifeSagaAdmissionReason.King:
return "Carries a kingdom's fate";
case LifeSagaAdmissionReason.Alpha:
return "Holds the pack together";
case LifeSagaAdmissionReason.PlotAuthor:
return "A scheme is still unfolding";
case LifeSagaAdmissionReason.Singleton:
return "Their lineage hangs by a thread";
case LifeSagaAdmissionReason.Founder:
return "What they built still needs defending";
return roleStake;
}
}
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
{
return "Succession points toward " + SafeName(heir);
return SagaProse.Sentence("Succession points toward " + SafeName(heir));
}
return memory != null && memory.Facts.Count > 0
? LifeSagaMemory.FormatFactLine(memory.Facts[0])
? SagaProse.FormatFactLine(memory.Facts[0])
: "";
}
@ -442,7 +437,7 @@ public static class LifeSagaPresentation
void AddLive(Actor other, string label, string truth)
{
if (other == null || model.Cast.Count >= 4)
if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCast)
{
return;
}
@ -466,7 +461,7 @@ public static class LifeSagaPresentation
void AddObserved(LifeSagaIdentity identity, string label, string truth)
{
if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id))
if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCast || !seen.Add(identity.Id))
{
return;
}
@ -485,41 +480,47 @@ public static class LifeSagaPresentation
if (actor != null)
{
AddLive(ActorRelation.GetLover(actor), "Lover", "is");
AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is");
AddLive(ActorRelation.GetLover(actor), SagaProse.CastLabelLive("lover"), "is");
AddLive(ActorRelation.GetBestFriend(actor), SagaProse.CastLabelLive("best friend"), "is");
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
{
AddLive(parent, "Parent", "is");
AddLive(parent, SagaProse.CastLabelLive("parent"), "is");
}
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
{
AddLive(child, "Child", "is");
AddLive(child, SagaProse.CastLabelLive("child"), "is");
}
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
{
AddLive(heir, "Likely successor", "likely");
AddLive(heir, SagaProse.CastLabelLive("heir"), "likely");
}
}
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
{
string foeLabel = SagaProse.CastLabel(rivalFact, EventFeedUtil.FindAliveById(rivalFact.OtherId));
if (string.IsNullOrEmpty(foeLabel))
{
foeLabel = SagaProse.TitleLabel("Old Foe");
}
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
if (liveRival != null)
{
AddLive(liveRival, "Earned rival", "observed");
AddLive(liveRival, foeLabel, "observed");
}
else
{
AddObserved(rivalFact.Other, "Earned rival", "observed");
AddObserved(rivalFact.Other, foeLabel, "observed");
}
}
// Former bonds from memory when live cast still has room.
if (memory != null && model.Cast.Count < 4)
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
{
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
@ -529,19 +530,19 @@ public static class LifeSagaPresentation
if (f.Kind == LifeSagaFactKind.BondBroken)
{
AddObserved(f.Other, "Lost lover", "observed");
AddObserved(f.Other, SagaProse.CastLabelLive("once loved"), "observed");
}
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
{
AddObserved(f.Other, "Child", "observed");
AddObserved(f.Other, SagaProse.CastLabelLive("child"), "observed");
}
}
}
// Living Others from unresolved war/plot joins.
if (memory != null && model.Cast.Count < 4)
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
{
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null || f.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId))
@ -557,26 +558,28 @@ public static class LifeSagaPresentation
if (f.Kind == LifeSagaFactKind.WarJoin)
{
AddLive(liveOther, "War foe", "observed");
AddLive(liveOther, SagaProse.TitleLabel("War Enemy"), "observed");
}
else if (f.Kind == LifeSagaFactKind.PlotJoin)
{
AddLive(liveOther, "Plot ally", "observed");
AddLive(liveOther, SagaProse.TitleLabel("Plot Partner"), "observed");
}
}
}
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
if (actor != null
&& model.Cast.Count < 4
&& model.Cast.Count < LifeSagaPanel.MaxCast
&& (primary == LifeSagaLens.PackAlpha
|| primary == LifeSagaLens.LoveGrief
|| primary == LifeSagaLens.CrownClan
|| primary == LifeSagaLens.WarriorKiller
|| primary == LifeSagaLens.Plotter))
{
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))
string peerLabel = primary == LifeSagaLens.PackAlpha
? SagaProse.CastLabelLive("packmate")
: SagaProse.CastLabelLive("kin");
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCast + 2))
{
if (peer == null || peer == actor)
{
@ -584,7 +587,7 @@ public static class LifeSagaPresentation
}
AddLive(peer, peerLabel, "is");
if (model.Cast.Count >= 4)
if (model.Cast.Count >= LifeSagaPanel.MaxCast)
{
break;
}
@ -640,6 +643,9 @@ public static class LifeSagaPresentation
int parentedCount = LifeSagaMemory.CountParentedChildren(unitId);
// Many children: prefer one lineage summary over named birth crumbs.
bool preferFamilySummary = parentedCount >= 3;
// Reserve the final Legacy slot for that summary; otherwise a death/bond/war
// burst can fill all four slots and make the lineage disappear nondeterministically.
int factLimit = parentedCount >= 2 ? 3 : 4;
bool wroteParentChild = false;
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
@ -667,6 +673,21 @@ public static class LifeSagaPresentation
continue;
}
// Current role is already stated in Identity (for example
// "Adisolf · King of Nuanand"). Do not repeat "Became King" below it.
if (f.Kind == LifeSagaFactKind.RoleChange
&& SagaProse.RoleChangeEchoesTitle(f, model.Title))
{
continue;
}
if (f.Kind == LifeSagaFactKind.Founding
&& !string.IsNullOrEmpty(model.Title)
&& model.Title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
{
continue;
}
if (f.Kind == LifeSagaFactKind.ParentChild)
{
if (preferFamilySummary || wroteParentChild)
@ -697,7 +718,7 @@ public static class LifeSagaPresentation
wroteParentChild = true;
}
if (model.Legacy.Count >= 4)
if (model.Legacy.Count >= factLimit)
{
break;
}

View file

@ -8,8 +8,8 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Dossier rail of life-saga MCs as a horizontal strip under the tabs.
/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews.
/// Dossier rail of life-saga MCs as a horizontal strip at the top of caption chrome.
/// Click toggles Prefer only. Hover previews Cast/Legacy depth under the caption.
/// </summary>
public static class LifeSagaRail
{
@ -302,7 +302,7 @@ public static class LifeSagaRail
}
/// <summary>
/// Place the horizontal rail under the tabs (stable top chrome).
/// Place the horizontal rail at the top of caption chrome (stable under hover).
/// Returns the next y below the rail.
/// </summary>
public static float Place(float y, float padX)
@ -318,16 +318,43 @@ public static class LifeSagaRail
internal static void ShowHover(int index)
{
if (index < 0)
{
index = 0;
}
if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
// Rail glyphs can be empty after a Clear while roster still has MCs
// (fingerprint early-out). Force a structural rebuild once.
_fingerprint = "";
Refresh();
}
if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i] != null && Slots[i].UnitId != 0)
{
index = i;
break;
}
}
}
if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
return;
}
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
LastHoverHeight = LifeSagaPanel.BodyHeight;
LastHoverHeight = Mathf.Max(LifeSagaPanel.BodyHeight, BodyHeightMinFallback);
WatchCaption.RequestRelayout();
}
private const float BodyHeightMinFallback = 1f;
internal static void HideHover()
{
LifeSagaViewController.EndHoverPreview();
@ -577,12 +604,6 @@ public static class LifeSagaRail
}
LifeSagaRoster.TogglePrefer(id);
if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|| (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
&& !LifeSagaViewController.IsHoverPreview))
{
LifeSagaViewController.SetPersistentSaga(id);
}
_fingerprint = "";
LifeSagaRoster.Dirty = true;

View file

@ -4,7 +4,9 @@ using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease.
/// Owns hover preview and camera read-pause lease for the life-saga rail.
/// AFK chrome is always character beat caption (Identity + Beat + Context);
/// the saga panel is hover depth only (no Dossier/Saga tabs).
/// </summary>
public static class LifeSagaViewController
{
@ -12,129 +14,71 @@ public static class LifeSagaViewController
/// <summary>Only used if an exit was deferred; glyph leave finishes immediately.</summary>
public const float HoverExitGraceSeconds = 0.05f;
/// <summary>
/// Compatibility enum for harness asserts. AFK chrome never pins Saga;
/// <see cref="EffectiveTab"/> is Saga only while a hover preview is active.
/// </summary>
public enum Tab
{
Dossier = 0,
Saga = 1
}
private static Tab _selectedTab = Tab.Dossier;
private static long _persistentSagaId;
private static bool _persistentPinned;
private static long _hoverPreviewId;
private static Tab _tabBeforeHover = Tab.Dossier;
private static float _hoverExitAt = -1f;
private static bool _pauseHeld;
private static bool _loreReturnToSaga;
private static long _loreReturnSagaId;
public static Tab SelectedTab => _selectedTab;
public static long PersistentSagaId => _persistentSagaId;
public static Tab SelectedTab => Tab.Dossier;
public static long PersistentSagaId => 0;
public static long HoverPreviewId => _hoverPreviewId;
public static bool IsHoverPreview => _hoverPreviewId != 0;
/// <summary>Saga while hovering a rail glyph; otherwise Dossier (caption body).</summary>
public static Tab EffectiveTab =>
_hoverPreviewId != 0 ? Tab.Saga : _selectedTab;
_hoverPreviewId != 0 ? Tab.Saga : Tab.Dossier;
public static long EffectiveSagaId
{
get
{
if (_hoverPreviewId != 0)
{
return _hoverPreviewId;
}
return ResolvePersistentSagaId();
}
}
public static long EffectiveSagaId => _hoverPreviewId;
/// <summary>
/// Harness/compat: Dossier cancels hover. Saga begins hover preview of the watched MC
/// when on the roster (no persistent pin / empty pick chrome).
/// </summary>
public static void SelectTab(Tab tab)
{
// Explicit tab click wins over hover preview restore.
if (_hoverPreviewId != 0 || _pauseHeld)
if (tab == Tab.Dossier)
{
CancelPreviewAndPause();
_tabBeforeHover = tab;
}
_selectedTab = tab;
if (tab == Tab.Saga)
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else if (!IsValidMc(_persistentSagaId) || !_persistentPinned)
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
}
public static void SetPersistentSaga(long unitId)
{
if (unitId == 0 || !LifeSagaRoster.IsMc(unitId))
{
WatchCaption.RequestRelayout();
return;
}
_persistentSagaId = unitId;
_persistentPinned = true;
}
/// <summary>Stash Saga return target before opening Lore from the Saga panel.</summary>
public static void StashLoreReturn(long unitId)
{
_loreReturnToSaga = true;
_loreReturnSagaId = unitId;
}
/// <summary>
/// After Lore closes, restore Saga tab + subject when a stash is present.
/// Returns true when a restore was applied.
/// </summary>
public static bool TryRestoreAfterLore()
{
if (!_loreReturnToSaga)
long watch = WatchedMcId();
if (watch != 0)
{
return false;
BeginHoverPreview(watch);
return;
}
long id = _loreReturnSagaId;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
CancelPreviewAndPause();
_selectedTab = Tab.Saga;
_tabBeforeHover = Tab.Saga;
if (IsValidMc(id))
{
_persistentSagaId = id;
_persistentPinned = true;
}
else
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
return true;
}
/// <summary>No-op: Prefer click no longer pins a persistent Saga subject.</summary>
public static void SetPersistentSaga(long unitId)
{
_ = unitId;
}
/// <summary>No-op: Lore return no longer restores a Saga tab.</summary>
public static void StashLoreReturn(long unitId)
{
_ = unitId;
}
/// <summary>No-op: Lore close leaves caption chrome as-is.</summary>
public static bool TryRestoreAfterLore()
{
return false;
}
public static void BeginHoverPreview(long unitId)
@ -145,11 +89,6 @@ public static class LifeSagaViewController
}
_hoverExitAt = -1f;
if (_hoverPreviewId == 0)
{
_tabBeforeHover = _selectedTab;
}
_hoverPreviewId = unitId;
if (!_pauseHeld)
{
@ -168,7 +107,7 @@ public static class LifeSagaViewController
}
// Relayout / glyph→glyph can fire Exit while the pointer is still on a glyph.
// Keep the preview in that case; otherwise restore Dossier immediately.
// Keep the preview in that case; otherwise restore caption body immediately.
if (LifeSagaRail.IsPointerOverGlyph())
{
_hoverExitAt = -1f;
@ -220,12 +159,6 @@ public static class LifeSagaViewController
public static void Clear()
{
CancelPreviewAndPause();
_selectedTab = Tab.Dossier;
_persistentSagaId = 0;
_persistentPinned = false;
_tabBeforeHover = Tab.Dossier;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
}
/// <summary>
@ -251,7 +184,6 @@ public static class LifeSagaViewController
{
_hoverExitAt = -1f;
_hoverPreviewId = 0;
_selectedTab = _tabBeforeHover;
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
@ -261,37 +193,6 @@ public static class LifeSagaViewController
WatchCaption.RequestRelayout();
}
/// <summary>
/// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC.
/// Never falls back to first-roster / stale last-active filler.
/// </summary>
private static long ResolvePersistentSagaId()
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
return watch;
}
if (_persistentPinned && IsValidMc(_persistentSagaId))
{
return _persistentSagaId;
}
_persistentSagaId = 0;
_persistentPinned = false;
return 0;
}
private static bool IsValidMc(long unitId)
{
return unitId != 0
&& LifeSagaRoster.IsMc(unitId)
&& EventFeedUtil.FindAliveById(unitId) != null;
}
private static long WatchedMcId()
{
long watchId = EventFeedUtil.SafeId(

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.29.76",
"version": "0.29.89",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -14,8 +14,9 @@ The camera follows **ranked events** from live feeds (wars, fights, relationship
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
While watching, a **dossier caption** shows who they are and an orange **event sentence** (why the camera is here), e.g. `Kaszus is fighting Waaaf`.
See [`docs/event-reason.md`](docs/event-reason.md).
While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake).
Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause.
See [`docs/life-saga.md`](docs/life-saga.md) and [`docs/event-reason.md`](docs/event-reason.md).
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.

View file

@ -28,6 +28,9 @@ Idle *quality* fails on ranking / ownership mismatch, not missing catalogs.
Authored dials set strength / CreatesInterest / watch windows.
They do not invent which buildings exist.
4. **Layer B stays honest.**
5. **Display compose is after WouldShow.**
`CaptionComposer` Identity/Beat clauses never change whether a Label is presentable.
Titles stay out of `InterestCandidate.Label`.
Falls may still hit Activity / Life when useful.
Demotion means "not camera A," not "erase from the world."
5. **One invariant over many patches.**

View file

@ -16,6 +16,8 @@ Discrete / status / WorldLog: `CreatesInterest` = A gate.
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
Orange dossier reason = the owning A events `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`).
Identity titles and stake slogans never enter `InterestCandidate.Label` - `identity_filter` / `IsIdentityWhy` stay event-first.
The focused unit must be a tip principal:
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
- Pair-locked duels: PairOwner/PairPartner only.

View file

@ -5,19 +5,24 @@ Chronicle stays a history book and never drives the camera.
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
AFK caption is a **character beat**: Identity (who) + Beat (live tip) + Context (spine or stake).
[`CaptionComposer`](../IdleSpectator/Story/CaptionComposer.cs) merges saga identity into the live caption without baking titles into `InterestCandidate.Label`.
[`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns all player-facing relation/stake English.
## Ownership
| Concern | Owner |
|---------|--------|
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
| Durable observed facts | `LifeSagaMemory` |
| Adaptive presentation | `LifeSagaPresentation` |
| Dossier / Saga tabs + hover preview | `LifeSagaViewController` |
| Saga body UI | `LifeSagaPanel` |
| Adaptive presentation model | `LifeSagaPresentation` |
| Player-facing saga/caption English | `SagaProse` |
| Identity + Beat + Context caption | `CaptionComposer``WatchCaption` |
| Hover Cast/Legacy depth | `LifeSagaViewController` + `LifeSagaPanel` |
| Rail icons / Prefer click | `LifeSagaRail` |
| Camera read-pause lease | `InterestDirector` |
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
| Context spine (`Kind · Phase`) | `StoryPlanner.FormatSpineLabel` (via ContextLine) |
| Full history | Chronicle / Lore |
## Admission
@ -55,35 +60,58 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
- Soft-fill quiet still suppresses anonymous crumbs; Prefer/MC tips may break quiet.
- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
## Character beat caption
Always-on AFK chrome (no Dossier/Saga tabs):
| Row | Source | MC | Nobody |
|-----|--------|----|--------|
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
| Beat | owned tip + `SagaProse.BeatClause` | tip enriched with one relation clause | tip as today |
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |
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.
## Rail UX
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
- 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.
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
- While on the **Saga** tab, click also pins that life as the persistent Saga subject (panel only; director bias stays Prefer/MC/cast).
- Tabs sit above the cast rail; the rail is a **horizontal strip under the tabs** so Dossier↔Saga body swaps cannot move the glyphs.
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
- Hovering a glyph temporarily previews that life in the Saga body and pauses camera switching.
- Leaving the glyph restores the prior tab (usually Dossier) immediately and releases the pause lease.
- Hover-exit re-applies dossier reason/traits/status/history so the orange tip does not wait for a tip change.
- Moving onto the panel or empty rail chrome does not keep the preview - only glyph hover does.
- Relayout / glyph-to-glyph exits are ignored while the pointer is still over a glyph.
- Clicking a tab during hover cancels the preview immediately and applies that tab.
- Tabbed chrome locks a constant outer width so Dossier↔Saga hover cannot slide glyphs.
- Saga body is a story card: lens eyebrow + name + title beside the portrait; stake, Cast, and Legacy wrap full-width under it.
- No Evidence stats dump (kills/realm live on Dossier). No Open Lore button (press L).
- Body height sizes to content up to a cap; Legacy beats stack as separate lines (no · soup).
- Legacy prefers turning points (war, bond, role, kill, founding) over Cast-echo kin crumbs.
- Click toggles saga Prefer only (does not mutate WorldBox unit favorite; does not pin a Saga subject).
- 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).
- 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).
- Living Cast lover/friend/kin are not repeated as Legacy relationship lines.
- Legacy lines may prefix the world date (`Month Year · beat`).
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
- Context spine (`Kind · Phase`) is hidden when the orange beat already names the same kind and phase is Climax.
- Never show live task/status/trait rows or call a live attack target a rival.
## Earned rivals
## Saga prose
`SagaProse` is the single voice owner. Evidence drives copy; enum names never appear in UI.
Relation classes: Bond, Friend, Kin (Parent/Child/Heir/Packmate/Kin), Foe (Old Foe/War Enemy/Plot Enemy/Blood Foe), Plot Partner, Grief.
Ban list (player-facing): `earned rival`, `Earned a rival`, `Rivalry with`, `saga hero`, `fellow saga hero`, `War foe`, `Plot ally`, `Likely successor`, `A life in motion` on Identity, mood wallpaper stakes.
Capitalization:
- Identity titles / Cast labels: Title Case (`Pack Alpha`, `Old Foe`, `Best Friend`)
- Beat clauses after ` · `: lowercase start
- Stake / Legacy: Sentence case
- Unit names and owned tip body: leave as-is
## Opposition facts (memory)
Notable only:
@ -91,21 +119,24 @@ Notable only:
- Kin blood (`killed_kin` / `kin_slain`).
- War / plot opposition (opposing principals on the same war/plot tip).
- MC↔MC living rematches earn rivalry one encounter sooner; a single MC kill is crossover Legacy, not automatic RivalEarned.
- Cast shows living credible rivals only; weak kill-cycle `repeated_encounters` facts are pruned.
- Likely succession is omitted unless a game-authored heir API probe succeeds.
- Unresolved `WarJoin` / `PlotJoin` Others fill Cast as `War foe` / `Plot ally` when still living.
- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers (`Packmate` for Pack, `Kin` otherwise) after lover/friend/parents/children/heir/rival.
- Cast shows living credible foes only; weak kill-cycle `repeated_encounters` facts are pruned.
- Heir cast label only when a game-authored heir API probe succeeds (`Heir`, not `Likely successor`).
- Unresolved `WarJoin` / `PlotJoin` Others fill Cast as `War Enemy` / `Plot Partner` when still living.
- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers (`Packmate` for Pack, `Kin` otherwise) after lover/friend/parents/children/heir/foe.
## Stake
- Primary stake: strongest unresolved war/plot/rival/role/founding/grief (and McCrossover beats of those kinds).
- Fallback stake when nothing stronger exists: unresolved `HardArcCombat`, open `BondFormed`, or McCrossover `Kill`.
- War/plot prose names the opposing principal and kingdom/plot display when known.
- War/plot prose names the opposing principal and kingdom/plot display when known (`Still at war with…`).
- Role rises (king/leader/alpha/clan chief/army captain) emit `RoleChange` from live roster deltas.
- Current role/founding facts live in Identity and are omitted from Context, Legacy, and rail tooltip stakes when they would only repeat the title.
- Titleless MCs (hard-arc, no crown) keep species/job Identity; Context stake hitch is required when memory has unresolved facts.
## Cross-MC moments
When subject and other are both roster MCs, facts are flagged `McCrossover` (named stake/legacy prose + heat on both).
Prose stays plain (`Bound with X`, `Slew X`) - no “saga hero” wording.
Both rail glyphs light (Active + Involved) when the current tip touches multiple MCs.
## Soft-fill quiet (AFK hygiene)
@ -120,17 +151,18 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- Roster role refresh runs on the ~3.5s amortized scan (not every frame). Soft heat does not force a world Refill.
- Dirty Prefer / membership changes re-rank existing slots only; full world admission stays on the timed cadence.
- World admission walks ~260 units/frame, then BuildSlots only the best ~2× Cap challengers (skips when pins fill Cap).
- Dossier `Relayout` runs only when chrome geometry changes; leaving Saga/hover restores dossier rows once.
- Dossier `Relayout` runs only when chrome geometry changes; leaving hover restores dossier rows once.
- Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons).
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
- Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel.
- RelationEvidence caches invalidate on memory revision + live lover/friend change.
- Hitch probe: harness `hitch_probe` / load save via `load_save_slot` (slot 2 = Box of Magic). Optional `.harness/autoload_slot`.
## Session
- `StoryPlanner.Clear` does not wipe the roster or saga memory.
- Lore browse / brief idle-off keeps the roster and memory.
- World replacement, map load-complete (`MapBox.finishingUpLoading`), `clearWorld`, harness batch end, and `interest_saga_clear` / `saga_session_reset` wipe roster + memory.
- World replacement, map load-complete (`MapBox.finishingUpLoading`), `clearWorld`, harness batch end, and `interest_saga_clear` / `saga_session_reset` wipe roster + memory (+ `CaptionComposer.Clear`).
- Bind key uses live `map_stats.name`, `current_world_seed_id`, `map_stats` identity, `_load_counter`, and map size - not world reference alone.
- Same-map spectator re-enable does not clear when the bind key is unchanged.
- Read-only soak dumps use `soak_probe` (or `snapshot` with `value=preserve`) and skip the batch-end wipe.
@ -151,7 +183,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `saga_prefer_cuts_ambient`
- `saga_hard_arc_escalates`
- `saga_overview_has_mc`
- `saga_adaptive_dossier`
- `saga_adaptive_dossier` (hover depth; no tabs / empty-pick)
- `saga_memory_survives`
- `saga_war_memory_both_sides`
- `saga_plot_cast_members`
@ -166,5 +198,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `saga_rail_active_highlight`
- `saga_rail_prefer_click`
- `saga_soft_fill_no_pack`
- `caption_character_beat`
- `caption_prose_voice`
See also: [story-planner.md](story-planner.md).

View file

@ -18,7 +18,8 @@ There is **no** rail Commit / hard pin anymore.
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
Click toggles Prefer (soft favorite).
Dossier/Saga tabs and hover preview are owned by `LifeSagaViewController` + `LifeSagaPanel`.
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).
Durable observed facts live in `LifeSagaMemory` (independent of ChronicleEnabled).
Hover temporarily pauses camera switching without disabling Idle Spectator.
See [life-saga.md](life-saga.md).
@ -68,11 +69,13 @@ Phases: `Climax → Aftermath → Epilogue → Done`.
## Dossier spine
While the **current watch tip** is the active arc's climax or story aftermath/epilogue beat,
the dossier shows a muted line under the orange reason: `Kind · Phase`
(e.g. `Duel · Aftermath`, `War front · Climax`).
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).
Also hidden when the orange beat already leads with the same Kind at Climax
(`WatchCaption.FilterRedundantStorySpine` filters against the presentable beat before clause enrichment).
Love climax is outcome-only (`set_lover` / fallen-in-love). `find_lover` seeking stays camera-worthy
but does not open a Love arc. Leaving a climax without aftermath ends the arc (`Done`).
@ -253,8 +256,10 @@ Catalog policy ids live under `event-catalog.json` → `story`
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - saga hover + rail
- `IdleSpectator/Story/SagaProse.cs` - player-facing relation/stake/title English
- `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine