diff --git a/IdleSpectator/ActorChroniclePatches.cs b/IdleSpectator/ActorChroniclePatches.cs
index 060b091..553b150 100644
--- a/IdleSpectator/ActorChroniclePatches.cs
+++ b/IdleSpectator/ActorChroniclePatches.cs
@@ -12,6 +12,7 @@ public static class ActorChroniclePatches
[HarmonyPostfix]
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
{
+ LifeSagaMemory.RecordKill(__instance, pDeadUnit);
Chronicle.NoteKill(__instance, pDeadUnit);
}
@@ -51,6 +52,7 @@ public static class ActorChroniclePatches
return;
}
+ LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
Chronicle.NoteDeath(__instance, pType, __state);
GraveMarkers.AddDeath(__instance);
}
@@ -59,6 +61,7 @@ public static class ActorChroniclePatches
[HarmonyPostfix]
public static void PostfixLovers(Actor __instance, Actor pTarget)
{
+ LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
Chronicle.NoteLovers(__instance, pTarget);
}
@@ -71,6 +74,7 @@ public static class ActorChroniclePatches
return;
}
+ LifeSagaMemory.RecordFriendFormed(__instance, pActor);
Chronicle.NoteBestFriends(__instance, pActor);
}
}
diff --git a/IdleSpectator/ActorRelation.cs b/IdleSpectator/ActorRelation.cs
index 4301293..22ff821 100644
--- a/IdleSpectator/ActorRelation.cs
+++ b/IdleSpectator/ActorRelation.cs
@@ -191,6 +191,92 @@ public static class ActorRelation
}
}
+ /// Living parents reported by the running game's relation data.
+ public static IEnumerable EnumerateParents(Actor actor, int max = 2)
+ {
+ if (actor == null || max <= 0)
+ {
+ yield break;
+ }
+
+ object parents = ReadMember(actor, "getParents", "get_parents", "parents");
+ if (parents == null)
+ {
+ parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids");
+ }
+
+ int yielded = 0;
+ var seen = new HashSet();
+ if (parents is IEnumerable enumerable)
+ {
+ foreach (object raw in enumerable)
+ {
+ Actor parent = ResolveActor(raw) ?? ResolveActorId(raw);
+ if (!IsDistinctLivingRelation(actor, parent, seen))
+ {
+ continue;
+ }
+
+ yield return parent;
+ yielded++;
+ if (yielded >= max)
+ {
+ yield break;
+ }
+ }
+ }
+
+ if (yielded >= max)
+ {
+ yield break;
+ }
+
+ string[] singular =
+ {
+ "mother", "father", "parent", "mother_id", "father_id", "parent_id",
+ "parent_1", "parent_2", "parent1", "parent2"
+ };
+ for (int i = 0; i < singular.Length && yielded < max; i++)
+ {
+ object raw = ReadMember(actor, singular[i])
+ ?? ReadMember(actor.data, singular[i]);
+ Actor parent = ResolveActor(raw) ?? ResolveActorId(raw);
+ if (!IsDistinctLivingRelation(actor, parent, seen))
+ {
+ continue;
+ }
+
+ yield return parent;
+ yielded++;
+ }
+ }
+
+ /// Current living combat target, when the live attack target is an actor.
+ public static Actor GetCombatOpponent(Actor actor)
+ {
+ if (actor == null)
+ {
+ return null;
+ }
+
+ try
+ {
+ if (!actor.has_attack_target || actor.attack_target == null)
+ {
+ return null;
+ }
+
+ Actor opponent = ResolveActor(actor.attack_target);
+ return opponent != null && opponent != actor && opponent.isAlive()
+ ? opponent
+ : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
///
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
///
@@ -315,6 +401,50 @@ public static class ActorRelation
return null;
}
+ private static Actor ResolveActorId(object raw)
+ {
+ if (raw is long longId)
+ {
+ return FindUnitById(longId);
+ }
+
+ if (raw is int intId)
+ {
+ return FindUnitById(intId);
+ }
+
+ try
+ {
+ if (raw != null && long.TryParse(raw.ToString(), out long parsed))
+ {
+ return FindUnitById(parsed);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return null;
+ }
+
+ private static bool IsDistinctLivingRelation(Actor subject, Actor related, HashSet seen)
+ {
+ if (related == null || related == subject || !related.isAlive())
+ {
+ return false;
+ }
+
+ try
+ {
+ return seen.Add(related.getID());
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
private static Actor ReadActorMember(object target, params string[] names)
{
object raw = ReadMember(target, names);
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 85e5509..79bacd9 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -34,6 +34,7 @@ public static class AgentHarness
private static string _rememberedTip = "";
private static long _rememberedRelatedId;
private static string LastScreenshotPath = "";
+ private static Vector2 _sagaPanelSizeAnchor;
private static readonly string[] PreferredSpawnAssets =
{
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
@@ -2866,7 +2867,11 @@ public static class AgentHarness
case "interest_saga_clear":
LifeSagaRoster.Clear();
+ LifeSagaMemory.Clear();
+ LifeSagaViewController.Clear();
LifeSagaRail.Clear();
+ LifeSagaPanel.Clear();
+ _sagaPanelSizeAnchor = Vector2.zero;
_cmdOk++;
Emit(cmd, ok: true, detail: "saga_cleared");
break;
@@ -2938,7 +2943,6 @@ public static class AgentHarness
bool ok = LifeSagaRoster.HarnessForceAdmit(focus);
if (ok)
{
- LifeSagaRoster.StampChapter(id, "Harness chapter", Time.unscaledTime);
_cmdOk++;
}
else
@@ -2950,6 +2954,303 @@ public static class AgentHarness
break;
}
+ case "saga_stamp_chapter_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long id = EventFeedUtil.SafeId(focus);
+ string line = (cmd.value ?? "").Trim();
+ if (string.IsNullOrEmpty(line))
+ {
+ line = (cmd.label ?? "").Trim();
+ }
+
+ LifeSagaChapterKind kind = LifeSagaChapterKind.Unknown;
+ string kindRaw = (cmd.asset ?? "").Trim();
+ if (!string.IsNullOrEmpty(kindRaw)
+ && System.Enum.TryParse(kindRaw, ignoreCase: true, out LifeSagaChapterKind parsed))
+ {
+ kind = parsed;
+ }
+
+ bool ok = id != 0
+ && LifeSagaRoster.IsMc(id)
+ && !string.IsNullOrEmpty(line);
+ if (ok)
+ {
+ LifeSagaRoster.StampChapter(id, kind, line);
+ LifeSagaRoster.Dirty = true;
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail:
+ $"stamp id={id} kind={kind} line={line} chapters={LifeSagaRoster.Get(id)?.Chapters.Count ?? 0}");
+ break;
+ }
+
+ case "saga_weaken_nonprefer":
+ {
+ // Age out non-Prefer slots so a hotter admit can displace them.
+ float now = Time.unscaledTime;
+ var board = new System.Collections.Generic.List(LifeSagaRoster.Cap);
+ LifeSagaRoster.CopySlots(board);
+ int n = 0;
+ for (int i = 0; i < board.Count; i++)
+ {
+ LifeSagaSlot s = board[i];
+ if (s == null || s.Prefer || s.GameFavorite)
+ {
+ continue;
+ }
+
+ LifeSagaRoster.HarnessSetScores(s.UnitId, standing: 1f, heat: 0f);
+ s.AdmittedAt = now - 900f;
+ s.TouchedAt = now - 900f;
+ s.LastChapterAt = 0f;
+ n++;
+ }
+
+ LifeSagaRoster.Dirty = true;
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"weakened={n} roster={LifeSagaRoster.Count}");
+ break;
+ }
+
+ case "saga_consider_hard_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long id = EventFeedUtil.SafeId(focus);
+ bool ok = id != 0
+ && LifeSagaRoster.ConsiderAdmit(id, heat: 6f, hardArcAdmit: true);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok,
+ detail:
+ $"hardAdmit id={id} on={LifeSagaRoster.IsMc(id)} roster={LifeSagaRoster.Count}");
+ break;
+ }
+
+ case "saga_rail_refresh":
+ {
+ LifeSagaRoster.Tick(Time.unscaledTime);
+ LifeSagaRail.Refresh();
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
+ detail:
+ $"railShown={LifeSagaRail.LastShownCount} activeLit={LifeSagaRail.LastActiveHighlight} preferPip={LifeSagaRail.LastPreferPipVisible}");
+ break;
+ }
+
+ case "saga_show_hover_first":
+ {
+ LifeSagaRoster.Tick(Time.unscaledTime);
+ LifeSagaRail.Refresh();
+ LifeSagaRail.ShowHover(0);
+ WatchCaption.RequestRelayout();
+ LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
+ bool ok = LifeSagaViewController.IsHoverPreview
+ && model.UnitId != 0
+ && LifeSagaRail.LastHoverHeight > 0f;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail:
+ $"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive}");
+ break;
+ }
+
+ case "saga_hide_hover":
+ {
+ LifeSagaRail.HideHover();
+ WatchCaption.RequestRelayout();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"pause={InterestDirector.ReadPauseActive}");
+ break;
+ }
+
+ case "saga_hover_panel_dwell":
+ {
+ LifeSagaViewController.HarnessSimulatePanelDwell();
+ bool ok = LifeSagaViewController.IsHoverPreview;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"hoverId={LifeSagaViewController.HoverPreviewId}");
+ break;
+ }
+
+ case "saga_hover_tick_away":
+ {
+ LifeSagaViewController.HarnessTickExitAwayFromChrome();
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
+ detail:
+ $"hoverPreview={LifeSagaViewController.IsHoverPreview} pause={InterestDirector.ReadPauseActive}");
+ break;
+ }
+
+ case "saga_click_first":
+ {
+ LifeSagaRail.Refresh();
+ long before = LifeSagaViewController.PersistentSagaId;
+ LifeSagaRail.HarnessClick(0);
+ WatchCaption.RequestRelayout();
+ long after = LifeSagaViewController.EffectiveSagaId;
+ bool ok = after != 0;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"before={before} after={after} prefer={LifeSagaRail.LastPreferPipVisible}");
+ break;
+ }
+
+ case "saga_open_lore":
+ {
+ LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Saga);
+ WatchCaption.RequestRelayout();
+ LifeSagaPanel.HarnessOpenLore();
+ bool ok = Chronicle.HudVisible;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(cmd, ok, detail: $"lore={ok} bound={LifeSagaPanel.BoundUnitId}");
+ break;
+ }
+
+ case "saga_select_tab":
+ {
+ string raw = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
+ LifeSagaViewController.Tab tab = raw.StartsWith("s")
+ ? LifeSagaViewController.Tab.Saga
+ : LifeSagaViewController.Tab.Dossier;
+ LifeSagaViewController.SelectTab(tab);
+ WatchCaption.RequestRelayout();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"tab={LifeSagaViewController.SelectedTab}");
+ break;
+ }
+
+ case "saga_memory_record_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ if (focus == null || !focus.isAlive())
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no_focus");
+ break;
+ }
+
+ string kindRaw = (cmd.asset ?? "Kill").Trim();
+ LifeSagaFactKind kind = LifeSagaFactKind.Kill;
+ if (kindRaw.IndexOf("bond", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ kind = LifeSagaFactKind.BondFormed;
+ }
+ else if (kindRaw.IndexOf("war", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ kind = LifeSagaFactKind.WarJoin;
+ }
+ else if (kindRaw.IndexOf("plot", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ kind = LifeSagaFactKind.PlotJoin;
+ }
+ else if (kindRaw.IndexOf("found", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ kind = LifeSagaFactKind.Founding;
+ }
+ else if (kindRaw.IndexOf("role", StringComparison.OrdinalIgnoreCase) >= 0
+ || kindRaw.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ kind = LifeSagaFactKind.RoleChange;
+ }
+
+ LifeSagaFact fact = LifeSagaMemory.Record(
+ kind,
+ focus,
+ null,
+ correlationKey: "harness:" + EventFeedUtil.SafeId(focus) + ":" + kind,
+ strength: 80f,
+ provenance: "harness",
+ note: cmd.value ?? "");
+ bool ok = fact != null;
+ if (ok) _cmdOk++; else _cmdFail++;
+ Emit(cmd, ok, detail: $"kind={kind} facts={LifeSagaMemory.FactsFor(EventFeedUtil.SafeId(focus)).Count}");
+ break;
+ }
+
+ case "saga_set_scores_focus":
+ {
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long id = EventFeedUtil.SafeId(focus);
+ float standing = ParseFloat(cmd.value, 0.1f);
+ float heat = ParseFloat(cmd.label, 0f);
+ bool ok = id != 0 && LifeSagaRoster.HarnessSetScores(id, standing, heat);
+ if (ok) _cmdOk++; else _cmdFail++;
+ Emit(cmd, ok, detail: $"id={id} standing={standing} heat={heat}");
+ break;
+ }
+
+ case "soft_fill_quiet_arm":
+ {
+ StoryPlanner.NoteHardStoryQuiet();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"softQuiet={StoryPlanner.SoftFillQuietActive}");
+ break;
+ }
+
+ case "saga_world_scan":
+ {
+ // Force a live admission scan even while harness Busy suppresses Refill discovery.
+ LifeSagaRoster.HarnessWorldScan();
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"roster={LifeSagaRoster.Count}");
+ break;
+ }
+
case "interest_variety_note":
{
// Stamp the current (or pending needle in value) as the last shown variety arc.
@@ -3506,6 +3807,7 @@ public static class AgentHarness
// Free AFK after harness must not inherit a leftover crisis closer tip.
StoryPlanner.PurgeCloserLeftovers();
LifeSagaRoster.Clear();
+ LifeSagaMemory.Clear();
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}");
LogService.LogInfo(
@@ -3569,14 +3871,27 @@ public static class AgentHarness
}
string assetId = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset;
- Actor unit = ResolveUnit(assetId);
- if (unit == null || !unit.isAlive())
+ string mode = (cmd.value ?? "").Trim().ToLowerInvariant();
+ bool wantOther = mode.IndexOf("other", StringComparison.Ordinal) >= 0;
+ bool preferOffRoster = mode.IndexOf("off_roster", StringComparison.Ordinal) >= 0
+ || mode.IndexOf("other", StringComparison.Ordinal) >= 0;
+ Actor exclude = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
+ Actor unit = null;
+ if (wantOther)
{
- // Ignore stale harness spawn handle; scan the live world.
- _lastSpawned = null;
- unit = string.IsNullOrEmpty(assetId)
- ? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f)
- : WorldActivityScanner.FindAliveByAssetId(assetId, CameraPos(), 5000f);
+ unit = FindOtherAliveUnitPreferOffRoster(exclude, assetId, preferOffRoster);
+ }
+ else
+ {
+ unit = ResolveUnit(assetId);
+ if (unit == null || !unit.isAlive())
+ {
+ // Ignore stale harness spawn handle; scan the live world.
+ _lastSpawned = null;
+ unit = string.IsNullOrEmpty(assetId)
+ ? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f)
+ : WorldActivityScanner.FindAliveByAssetId(assetId, CameraPos(), 5000f);
+ }
}
if (unit == null || !unit.isAlive())
@@ -3595,7 +3910,7 @@ public static class AgentHarness
_lastSpawnedAssetId = unit.asset != null ? unit.asset.id : "";
CameraDirector.FocusUnit(unit);
_cmdOk++;
- Emit(cmd, ok: true, detail: $"picked={SafeName(unit)} asset={_lastSpawnedAssetId}");
+ Emit(cmd, ok: true, detail: $"picked={SafeName(unit)} asset={_lastSpawnedAssetId} other={wantOther}");
}
private static void DoSpawn(HarnessCommand cmd)
@@ -4614,6 +4929,29 @@ public static class AgentHarness
return;
}
+ if (expect.IndexOf("stunned", StringComparison.OrdinalIgnoreCase) >= 0
+ || expect.IndexOf("invincible", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ bool inv = expect.IndexOf("invincible", StringComparison.OrdinalIgnoreCase) >= 0;
+ c.StatusId = inv ? "invincible" : "stunned";
+ c.AssetId = c.StatusId;
+ c.Source = "status";
+ c.Completion = InterestCompletionKind.StatusPhase;
+ c.Category = "Status";
+ return;
+ }
+
+ if (expect.IndexOf("family_pack", StringComparison.OrdinalIgnoreCase) >= 0
+ || expect.IndexOf("pack_soft", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ c.AssetId = "family_pack";
+ c.Source = "family";
+ c.Category = "Family";
+ c.Completion = InterestCompletionKind.FamilyPack;
+ c.Label = string.IsNullOrEmpty(c.Label) ? "Pack - Harness (3) · gathering" : c.Label;
+ return;
+ }
+
if (expect.IndexOf("just_slept", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("rest_life", StringComparison.OrdinalIgnoreCase) >= 0
|| expect.IndexOf("ends a rest", StringComparison.OrdinalIgnoreCase) >= 0)
@@ -4664,6 +5002,48 @@ public static class AgentHarness
}
}
+ private static Actor FindOtherAliveUnitPreferOffRoster(
+ Actor exclude,
+ string preferAsset,
+ bool preferOffRoster)
+ {
+ Actor bestOff = null;
+ Actor bestAny = null;
+ if (World.world?.units == null)
+ {
+ return null;
+ }
+
+ foreach (Actor actor in World.world.units)
+ {
+ if (actor == null || !actor.isAlive() || actor == exclude)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(preferAsset)
+ && (actor.asset == null
+ || !string.Equals(actor.asset.id, preferAsset, StringComparison.OrdinalIgnoreCase)))
+ {
+ continue;
+ }
+
+ long id = EventFeedUtil.SafeId(actor);
+ if (preferOffRoster && id != 0 && !LifeSagaRoster.IsMc(id))
+ {
+ bestOff = actor;
+ break;
+ }
+
+ if (bestAny == null)
+ {
+ bestAny = actor;
+ }
+ }
+
+ return bestOff ?? bestAny ?? FindOtherAliveUnit(exclude, preferAsset);
+ }
+
private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset)
{
if (World.world?.units == null)
@@ -7991,13 +8371,9 @@ public static class AgentHarness
case "story_spine":
{
string want = (cmd.value ?? "").Trim();
+ // HUD display only - do not fall back to FormatSpineLabel (Climax may be suppressed).
string have = WatchCaption.LastStorySpine ?? "";
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
- // Prefer live dossier text; fall back to planner format if HUD not painted yet.
- if (string.IsNullOrEmpty(have))
- {
- have = formatted;
- }
bool wantEmpty = string.IsNullOrEmpty(want)
|| string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase)
@@ -8027,10 +8403,7 @@ public static class AgentHarness
pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
}
- detail =
- $"storySpine='{have}' want='{want}' formatted='{formatted}' "
- + $"belongs={StoryPlanner.BelongsToActiveStoryBeat(InterestDirector.CurrentCandidate)} "
- + $"phase={StoryPlanner.Active?.Phase.ToString() ?? "None"}";
+ detail = $"spine='{have}' want='{want}' formatted='{formatted}'";
break;
}
case "story_hold_margin":
@@ -8115,8 +8488,231 @@ public static class AgentHarness
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
- pass = id != 0 && LifeSagaRoster.IsPrefer(id);
- detail = $"prefer focus={SafeName(focus)} id={id} on={pass}";
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = id != 0 && LifeSagaRoster.IsPrefer(id);
+ pass = have == want;
+ detail = $"prefer focus={SafeName(focus)} id={id} have={have} want={want}";
+ break;
+ }
+ case "saga_rail_active_highlight":
+ {
+ LifeSagaRail.Refresh();
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ pass = LifeSagaRail.LastActiveHighlight == want;
+ detail =
+ $"activeLit={LifeSagaRail.LastActiveHighlight} want={want} preferPip={LifeSagaRail.LastPreferPipVisible}";
+ break;
+ }
+ case "saga_rail_prefer_pip":
+ {
+ LifeSagaRail.Refresh();
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ pass = LifeSagaRail.LastPreferPipVisible == want;
+ detail =
+ $"preferPip={LifeSagaRail.LastPreferPipVisible} want={want} activeLit={LifeSagaRail.LastActiveHighlight}";
+ break;
+ }
+ case "saga_snapshot_layout":
+ {
+ int rows = LifeSagaRail.LastHoverRowCount;
+ float height = LifeSagaRail.LastHoverHeight;
+ pass = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
+ && height >= 100f
+ && height <= 200f;
+ detail = $"snapshot rows={rows} height={height:0.0} tab={LifeSagaViewController.EffectiveTab} compact={pass}";
+ break;
+ }
+ case "saga_tab_selected":
+ {
+ string want = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
+ string have = LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
+ ? "saga"
+ : "dossier";
+ pass = have == want || (want.StartsWith("s") && have == "saga")
+ || (want.StartsWith("d") && have == "dossier");
+ detail = $"selected={have} want={want}";
+ break;
+ }
+ case "saga_tab_effective":
+ {
+ string want = (cmd.value ?? "dossier").Trim().ToLowerInvariant();
+ string have = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
+ ? "saga"
+ : "dossier";
+ pass = have == want || (want.StartsWith("s") && have == "saga")
+ || (want.StartsWith("d") && have == "dossier");
+ detail = $"effective={have} want={want} hover={LifeSagaViewController.HoverPreviewId}";
+ break;
+ }
+ case "saga_hover_preview":
+ {
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = LifeSagaViewController.IsHoverPreview;
+ pass = have == want;
+ detail = $"hoverPreview={have} want={want} id={LifeSagaViewController.HoverPreviewId}";
+ break;
+ }
+ case "saga_read_pause":
+ {
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = InterestDirector.ReadPauseActive;
+ pass = have == want;
+ detail = $"readPause={have} want={want}";
+ break;
+ }
+ case "saga_panel_bound":
+ {
+ LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
+ pass = model != null && model.UnitId != 0 && !string.IsNullOrEmpty(model.Name);
+ detail = $"boundId={model?.UnitId ?? 0} name='{model?.Name}' lens={model?.PrimaryLens}";
+ break;
+ }
+ case "saga_panel_evidence":
+ {
+ LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = model != null && !string.IsNullOrEmpty(model.EvidenceLine);
+ pass = have == want;
+ detail =
+ $"evidence='{model?.EvidenceTitle}:{model?.EvidenceLine}' want={want} lens={model?.PrimaryLens}";
+ break;
+ }
+ case "saga_panel_empty":
+ {
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
+ && LifeSagaPanel.Visible
+ && LifeSagaPanel.BoundUnitId == 0;
+ pass = have == want;
+ detail =
+ $"empty={have} want={want} bound={LifeSagaPanel.BoundUnitId} effective={LifeSagaViewController.EffectiveSagaId}";
+ break;
+ }
+ case "chronicle_visible":
+ {
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = Chronicle.HudVisible;
+ pass = have == want;
+ detail = $"loreVisible={have} want={want}";
+ break;
+ }
+ case "saga_panel_fixed_size":
+ {
+ // Locked tabbed chrome width + content-height Saga body (capped).
+ Vector2 size = WatchCaption.LastPanelSize;
+ float bodyH = LifeSagaPanel.BodyHeight;
+ float bodyW = LifeSagaPanel.BodyWidthPx;
+ bool onSaga = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
+ bool widthOk = Mathf.Abs(bodyW - LifeSagaPanel.BodyWidthFixed) < 0.5f;
+ bool heightOk = bodyH >= LifeSagaPanel.BodyHeightMin - 0.5f
+ && bodyH <= LifeSagaPanel.BodyHeightMax + 0.5f;
+ bool compact = widthOk && heightOk;
+ if (onSaga)
+ {
+ compact = compact
+ && LifeSagaPanel.Visible
+ && size.y <= LifeSagaPanel.BodyHeightMax + 100f
+ && size.x <= 260f
+ && size.x >= bodyW - 0.5f;
+ }
+ else
+ {
+ compact = size.x <= 260f && size.y <= 280f && size.y >= 40f;
+ }
+
+ pass = compact;
+ detail =
+ $"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
+ break;
+ }
+ case "saga_memory_has_focus":
+ {
+ long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = LifeSagaMemory.HasFacts(id);
+ pass = have == want;
+ detail = $"id={id} hasFacts={have} want={want} lives={LifeSagaMemory.LifeCount}";
+ break;
+ }
+ case "saga_memory_fact_focus":
+ {
+ long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
+ string want = (cmd.value ?? "").Trim();
+ pass = false;
+ var facts = LifeSagaMemory.FactsFor(id);
+ for (int i = 0; i < facts.Count; i++)
+ {
+ if (facts[i] != null
+ && facts[i].Kind.ToString().IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ pass = true;
+ break;
+ }
+ }
+
+ detail = $"id={id} wantKind={want} facts={facts.Count} pass={pass}";
+ break;
+ }
+ case "saga_memory_life_count":
+ {
+ int want = ParseInt(cmd.value, 1);
+ int have = LifeSagaMemory.LifeCount;
+ string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
+ pass = cmp == "min" ? have >= want : have == want;
+ detail = $"lives={have} want={want} cmp={cmp}";
+ break;
+ }
+ case "saga_admission_persisted":
+ {
+ var board = new System.Collections.Generic.List(LifeSagaRoster.Cap);
+ LifeSagaRoster.CopySlots(board);
+ LifeSagaSlot slot = board.Count > 0 ? board[0] : null;
+ pass = slot != null && slot.AdmissionReason != LifeSagaAdmissionReason.None;
+ detail =
+ $"admission={slot?.AdmissionReason.ToString() ?? "missing"} id={slot?.UnitId ?? 0}";
+ break;
+ }
+ case "saga_role_tip":
+ {
+ string have = SampleRailTip();
+ string any = (cmd.value ?? "").Trim();
+ pass = false;
+ if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any))
+ {
+ string[] parts = any.Split('|');
+ for (int i = 0; i < parts.Length; i++)
+ {
+ string needle = (parts[i] ?? "").Trim();
+ if (needle.Length > 0
+ && have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ pass = true;
+ break;
+ }
+ }
+ }
+
+ detail = $"roleTip='{have}' any='{any}' pass={pass}";
+ break;
+ }
+ case "saga_prefer_count":
+ {
+ LifeSagaRoster.Tick(Time.unscaledTime);
+ int want = ParseInt(cmd.value, 1);
+ var board = new System.Collections.Generic.List(LifeSagaRoster.Cap);
+ LifeSagaRoster.CopySlots(board);
+ int have = 0;
+ for (int i = 0; i < board.Count; i++)
+ {
+ if (board[i] != null && board[i].Prefer)
+ {
+ have++;
+ }
+ }
+
+ string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
+ pass = cmp == "min" ? have >= want : have == want;
+ detail = $"preferCount={have} want={want} cmp={cmp} roster={board.Count}";
break;
}
case "tip_matches_unit":
@@ -12177,7 +12773,7 @@ public static class AgentHarness
LifeSagaRoster.CopySlots(slots);
if (slots.Count > 0 && slots[0] != null)
{
- return LifeSagaOverview.Build(slots[0]) ?? "";
+ return LifeSagaPresentation.BuildPlainText(slots[0]) ?? "";
}
return "";
diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs
index a229b68..2397b8f 100644
--- a/IdleSpectator/Chronicle.cs
+++ b/IdleSpectator/Chronicle.cs
@@ -176,6 +176,7 @@ public static class Chronicle
{
ClearSession();
ActivityLog.ClearSession();
+ LifeSagaMemory.ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
@@ -695,6 +696,7 @@ public static class Chronicle
_currentAgeId = "";
_currentAgeName = "";
ActivityLog.ClearSession();
+ LifeSagaMemory.ClearSession();
BumpRevision();
// Keep _boundWorld so re-enable on same map does not loop-clear.
}
diff --git a/IdleSpectator/ChronicleHud.cs b/IdleSpectator/ChronicleHud.cs
index a7a25c7..0e233df 100644
--- a/IdleSpectator/ChronicleHud.cs
+++ b/IdleSpectator/ChronicleHud.cs
@@ -800,13 +800,25 @@ public static class ChronicleHud
}
_pausedIdleForLore = false;
+ bool restoreSaga = LifeSagaViewController.TryRestoreAfterLore();
if (!ModSettings.Enabled || SpectatorMode.Active)
{
+ if (restoreSaga)
+ {
+ WatchCaption.RequestRelayout();
+ }
+
return;
}
SpectatorMode.SetActive(true, quiet: true);
- LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle");
+ if (restoreSaga)
+ {
+ WatchCaption.RequestRelayout();
+ }
+
+ LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle"
+ + (restoreSaga ? " (restored Saga)" : ""));
}
///
diff --git a/IdleSpectator/DossierAvatar.cs b/IdleSpectator/DossierAvatar.cs
index 4cfffee..80f1d22 100644
--- a/IdleSpectator/DossierAvatar.cs
+++ b/IdleSpectator/DossierAvatar.cs
@@ -98,6 +98,14 @@ public static class DossierAvatar
}
}
+ public static void SetHostVisible(bool visible)
+ {
+ if (_host != null)
+ {
+ _host.gameObject.SetActive(visible);
+ }
+ }
+
public static void SetActive(bool active)
{
if (_host != null)
@@ -106,6 +114,109 @@ public static class DossierAvatar
}
}
+ ///
+ /// Keep the live portrait under saga/dossier chrome that must paint on top of it.
+ ///
+ public static void DrawBehind(Transform sibling)
+ {
+ if (_host == null || sibling == null)
+ {
+ return;
+ }
+
+ // Insert immediately under the sibling so the opaque stone frame cannot cover copy.
+ int index = sibling.GetSiblingIndex();
+ _host.SetSiblingIndex(index);
+ if (_host.GetSiblingIndex() > sibling.GetSiblingIndex())
+ {
+ _host.SetSiblingIndex(sibling.GetSiblingIndex());
+ }
+ }
+
+ ///
+ /// Keep the compact stone-style dossier frame - strip king/clan vanity overrides.
+ ///
+ public static void ForceCompactFrame()
+ {
+ if (_host == null)
+ {
+ return;
+ }
+
+ if (_usingVanilla && _instance != null && _instance.gameObject.activeSelf)
+ {
+ try
+ {
+ _instance.show_banner_kingdom = false;
+ _instance.show_banner_clan = false;
+ if (_instance.kingdomBanner != null)
+ {
+ _instance.kingdomBanner.gameObject.SetActive(false);
+ }
+
+ if (_instance.clanBanner != null)
+ {
+ _instance.clanBanner.gameObject.SetActive(false);
+ }
+
+ if (_instance.avatarLoader != null)
+ {
+ _instance.avatarLoader.avatarSize = 0.55f;
+ }
+
+ // Clear king/leader ornate frame overrides when the game exposes them.
+ var prop = _instance.GetType().GetProperty("override_avatar_frames");
+ if (prop != null && prop.CanWrite)
+ {
+ prop.SetValue(_instance, false, null);
+ }
+
+ var field = _instance.GetType().GetField("override_avatar_frames");
+ if (field != null)
+ {
+ field.SetValue(_instance, false);
+ }
+
+ StripNamedVanity(_instance.transform);
+ }
+ catch
+ {
+ // keep showing whatever frame we have
+ }
+ }
+ }
+
+ private static void StripNamedVanity(Transform root)
+ {
+ if (root == null)
+ {
+ return;
+ }
+
+ for (int i = 0; i < root.childCount; i++)
+ {
+ Transform child = root.GetChild(i);
+ if (child == null)
+ {
+ continue;
+ }
+
+ string n = child.name ?? "";
+ string lower = n.ToLowerInvariant();
+ if (lower.Contains("banner")
+ || lower.Contains("jewel")
+ || lower.Contains("crown")
+ || lower.Contains("king_frame")
+ || lower.Contains("frame_king")
+ || lower.Contains("ornate"))
+ {
+ child.gameObject.SetActive(false);
+ }
+
+ StripNamedVanity(child);
+ }
+ }
+
public static void Show(Actor actor)
{
if (_host == null)
@@ -167,6 +278,7 @@ public static class DossierAvatar
_shownActorId = id;
_shownFormKey = formKey;
DisableInteractions(_instance.gameObject);
+ ForceCompactFrame();
return;
}
catch (System.Exception ex)
diff --git a/IdleSpectator/Events/Patches/MetaEventPatches.cs b/IdleSpectator/Events/Patches/MetaEventPatches.cs
index 183b885..18e30ea 100644
--- a/IdleSpectator/Events/Patches/MetaEventPatches.cs
+++ b/IdleSpectator/Events/Patches/MetaEventPatches.cs
@@ -55,6 +55,21 @@ public static class MetaEventPatches
public static void PostfixNewClan(Clan __result)
{
EmitNew("new_clan", "Politics", 70f, __result, "clan");
+ Actor clanFounder = TryReadActor(__result);
+ if (clanFounder != null)
+ {
+ string clanKey = "";
+ try
+ {
+ clanKey = __result != null ? __result.getID().ToString() : "";
+ }
+ catch
+ {
+ clanKey = "";
+ }
+
+ LifeSagaMemory.RecordFounding(clanFounder, "clan", clanKey, "new_clan");
+ }
}
[HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))]
@@ -77,6 +92,21 @@ public static class MetaEventPatches
{
// Outcome tip (decision intent like build_civ_city_here is Layer B).
EmitNew("new_city", "Politics", 86f, __result, "city");
+ Actor cityFounder = TryReadActor(__result);
+ if (cityFounder != null)
+ {
+ string cityKey = "";
+ try
+ {
+ cityKey = __result != null ? __result.getID().ToString() : "";
+ }
+ catch
+ {
+ cityKey = "";
+ }
+
+ LifeSagaMemory.RecordFounding(cityFounder, "city", cityKey, "new_city");
+ }
}
[HarmonyPatch(typeof(KingdomManager), nameof(KingdomManager.makeNewCivKingdom))]
@@ -101,6 +131,17 @@ public static class MetaEventPatches
92f,
anchor,
EventReason.MetaNew(anchor, "kingdom"));
+ string kingdomKey = "";
+ try
+ {
+ kingdomKey = __result.getID().ToString();
+ }
+ catch
+ {
+ kingdomKey = "";
+ }
+
+ LifeSagaMemory.RecordFounding(anchor, "kingdom", kingdomKey, "new_kingdom");
}
private static void EmitNew(
diff --git a/IdleSpectator/Events/Patches/PlotEventPatches.cs b/IdleSpectator/Events/Patches/PlotEventPatches.cs
index 5e02199..a36b94a 100644
--- a/IdleSpectator/Events/Patches/PlotEventPatches.cs
+++ b/IdleSpectator/Events/Patches/PlotEventPatches.cs
@@ -16,6 +16,20 @@ public static class PlotEventPatches
}
PlotInterestFeed.EmitFromPlot(__result, "new");
+ try
+ {
+ Actor author = __result.getAuthor();
+ if (author != null && author.isAlive())
+ {
+ string plotKey = __result.getID().ToString();
+ string assetId = ReadPlotAssetId(__result);
+ LifeSagaMemory.RecordPlot(author, plotKey, assetId, finished: false, provenance: "newPlot");
+ }
+ }
+ catch
+ {
+ // ignore
+ }
}
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.cancelPlot))]
@@ -40,6 +54,16 @@ public static class PlotEventPatches
}
PlotInterestFeed.EmitFromPlot(pObject, "join");
+ try
+ {
+ string plotKey = pObject.getID().ToString();
+ string assetId = ReadPlotAssetId(pObject);
+ LifeSagaMemory.RecordPlot(__instance, plotKey, assetId, finished: false, provenance: "setPlot");
+ }
+ catch
+ {
+ // ignore
+ }
}
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
@@ -94,10 +118,38 @@ public static class PlotEventPatches
}
PlotInterestFeed.Emit(assetId, pActor, "complete");
+ try
+ {
+ string plotKey = __instance.getID().ToString();
+ LifeSagaMemory.RecordPlot(pActor, plotKey, assetId, finished: true, provenance: "finishPlot");
+ }
+ catch
+ {
+ // ignore
+ }
+
return;
}
PlotInterestFeed.EmitFromPlot(__instance, "complete");
+ try
+ {
+ Actor author = __instance.getAuthor();
+ if (author != null && author.isAlive())
+ {
+ string plotKey = __instance.getID().ToString();
+ LifeSagaMemory.RecordPlot(
+ author,
+ plotKey,
+ ReadPlotAssetId(__instance),
+ finished: true,
+ provenance: "finishPlot");
+ }
+ }
+ catch
+ {
+ // ignore
+ }
}
private static string ReadPlotAssetId(object plot)
diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs
index 92767bd..eb5169d 100644
--- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs
+++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs
@@ -38,9 +38,29 @@ public static class RelationshipEventPatches
return true;
}
+ [HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
+ [HarmonyPrefix]
+ public static void PrefixSetLover(Actor __instance, Actor pActor, out Actor __state)
+ {
+ __state = null;
+ try
+ {
+ if (__instance != null && EventOutcome.IsLiving(__instance) && __instance.hasLover())
+ {
+ __state = __instance.lover;
+ }
+ }
+ catch
+ {
+ __state = null;
+ }
+
+ _ = pActor;
+ }
+
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
[HarmonyPostfix]
- public static void PostfixSetLover(Actor __instance, Actor pActor)
+ public static void PostfixSetLover(Actor __instance, Actor pActor, Actor __state)
{
if (!EventOutcome.IsLiving(__instance))
{
@@ -50,10 +70,12 @@ public static class RelationshipEventPatches
// setLover is already the confirmed state transition.
if (pActor == null)
{
+ LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
return;
}
+ LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
}
@@ -103,6 +125,7 @@ public static class RelationshipEventPatches
continue;
}
+ LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
RelationshipInterestFeed.Emit("clear_lover", unit, null);
}
catch
@@ -142,7 +165,11 @@ public static class RelationshipEventPatches
increaseChildren
&& EventOutcome.IsLiving(parent)
&& EventOutcome.IsLiving(child),
- () => RelationshipInterestFeed.Emit("add_child", parent, child));
+ () =>
+ {
+ LifeSagaMemory.RecordParentChild(parent, child);
+ RelationshipInterestFeed.Emit("add_child", parent, child);
+ });
}
[HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))]
@@ -150,6 +177,21 @@ public static class RelationshipEventPatches
public static void PostfixNewFamily(Family __result, Actor pActor)
{
Actor anchor = EventOutcome.IsLiving(pActor) ? pActor : null;
+ if (anchor != null)
+ {
+ string familyKey = "";
+ try
+ {
+ familyKey = __result != null ? __result.getID().ToString() : "";
+ }
+ catch
+ {
+ familyKey = "";
+ }
+
+ LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
+ }
+
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
}
@@ -405,6 +447,10 @@ public static class RelationshipEventPatches
"Family.setAlpha",
"become_alpha",
pNew && EventOutcome.IsLiving(pActor),
- () => InterestFeeds.EmitAlpha(pActor, "family_set_alpha"));
+ () =>
+ {
+ LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
+ InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
+ });
}
}
diff --git a/IdleSpectator/Events/Patches/WarEventPatches.cs b/IdleSpectator/Events/Patches/WarEventPatches.cs
index 2acb2ca..81eec00 100644
--- a/IdleSpectator/Events/Patches/WarEventPatches.cs
+++ b/IdleSpectator/Events/Patches/WarEventPatches.cs
@@ -18,6 +18,40 @@ public static class WarEventPatches
try
{
WarInterestFeed.EmitFromWarObject(__result, phase: "new");
+ Actor founder = null;
+ try
+ {
+ founder = __result?.main_attacker?.king;
+ }
+ catch
+ {
+ founder = null;
+ }
+
+ string warKey = "";
+ try
+ {
+ warKey = __result.getID().ToString();
+ }
+ catch
+ {
+ warKey = "";
+ }
+
+ if (founder != null && founder.isAlive() && !string.IsNullOrEmpty(warKey))
+ {
+ string kingdom = "";
+ try
+ {
+ kingdom = founder.kingdom != null ? founder.kingdom.name : "";
+ }
+ catch
+ {
+ kingdom = "";
+ }
+
+ LifeSagaMemory.RecordWar(founder, warKey, kingdom, ended: false, provenance: "newWar");
+ }
}
catch
{
@@ -37,6 +71,38 @@ public static class WarEventPatches
try
{
WarInterestFeed.EmitFromWarObject(__args[0], phase: "end");
+ object war = __args[0];
+ string warKey = "";
+ try
+ {
+ if (war is War typed)
+ {
+ warKey = typed.getID().ToString();
+ }
+ }
+ catch
+ {
+ warKey = "";
+ }
+
+ if (!string.IsNullOrEmpty(warKey) && war is War endedWar)
+ {
+ Actor anchor = null;
+ try
+ {
+ anchor = endedWar.main_attacker?.king
+ ?? endedWar.main_defender?.king;
+ }
+ catch
+ {
+ anchor = null;
+ }
+
+ if (anchor != null && anchor.isAlive())
+ {
+ LifeSagaMemory.RecordWar(anchor, warKey, "", ended: true, provenance: "endWar");
+ }
+ }
}
catch
{
diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs
index 6c85264..465ee20 100644
--- a/IdleSpectator/HappinessEventRouter.cs
+++ b/IdleSpectator/HappinessEventRouter.cs
@@ -284,6 +284,7 @@ public static class HappinessEventRouter
if (entry.Life == HappinessLifePolicy.ProjectToLife
&& entry.Overlap != HappinessOverlapPolicy.CanonicalMilestone)
{
+ NoteSagaFromHappiness(occ, subject, related, effectId);
if (Chronicle.NoteHappinessLife(occ, subject, related))
{
occ.WroteLife = true;
@@ -305,6 +306,64 @@ public static class HappinessEventRouter
return occ;
}
+ private static void NoteSagaFromHappiness(
+ HappinessOccurrence occ,
+ Actor subject,
+ Actor related,
+ string effectId)
+ {
+ if (subject == null || string.IsNullOrEmpty(effectId))
+ {
+ return;
+ }
+
+ string id = effectId;
+ if (id.IndexOf("become_king", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("became_king", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ LifeSagaMemory.RecordRole(subject, "become_king", "happiness:" + id);
+ return;
+ }
+
+ if (id.IndexOf("become_leader", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("became_leader", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ LifeSagaMemory.RecordRole(subject, "become_leader", "happiness:" + id);
+ return;
+ }
+
+ if (id.IndexOf("become_alpha", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ LifeSagaMemory.RecordRole(subject, "become_alpha", "happiness:" + id);
+ return;
+ }
+
+ if (id.IndexOf("just_found_house", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("found_house", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ LifeSagaMemory.RecordHome(subject, gained: true, provenance: "happiness:" + id);
+ return;
+ }
+
+ if (id.IndexOf("just_lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ LifeSagaMemory.RecordHome(subject, gained: false, provenance: "happiness:" + id);
+ return;
+ }
+
+ if (id.IndexOf("death_lover", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || id.IndexOf("grief", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ if (related != null)
+ {
+ LifeSagaMemory.RecordBondBroken(subject, related, "happiness:" + id);
+ }
+ }
+
+ _ = occ;
+ }
+
private static HappinessOccurrence PublishAggregateMember(
Actor subject,
string effectId,
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 69f8d81..33176a9 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -157,6 +157,23 @@ internal static class HarnessScenarios
return SagaPreferSoftBias();
case "saga_overview_has_mc":
return SagaOverviewHasMc();
+ case "saga_snapshot_popover":
+ case "saga_adaptive_dossier":
+ return SagaAdaptiveDossier();
+ case "saga_memory_survives":
+ return SagaMemorySurvives();
+ case "saga_hover_read_pause":
+ return SagaHoverReadPause();
+ case "saga_admit_roles":
+ return SagaAdmitRoles();
+ case "saga_replace_weaker":
+ return SagaReplaceWeaker();
+ case "saga_rail_active_highlight":
+ return SagaRailActiveHighlight();
+ case "saga_rail_prefer_click":
+ return SagaRailPreferClick();
+ case "saga_soft_fill_no_pack":
+ return SagaSoftFillNoPack();
case "story_spine_scoped":
return StorySpineScoped();
case "story_family_variety":
@@ -347,8 +364,16 @@ internal static class HarnessScenarios
Nested("reg_saga_camera", "saga_camera_prefers_mc"),
Nested("reg_saga_prefer", "saga_prefer_soft_bias"),
Nested("reg_saga_overview", "saga_overview_has_mc"),
+ Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
+ Nested("reg_saga_memory", "saga_memory_survives"),
+ Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
Nested("reg_saga_diversity", "saga_roster_diversity"),
Nested("reg_saga_death", "saga_replace_on_death"),
+ Nested("reg_saga_admit", "saga_admit_roles"),
+ Nested("reg_saga_replace", "saga_replace_weaker"),
+ 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_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_presentability", "camera_presentability"),
@@ -2616,7 +2641,8 @@ internal static class HarnessScenarios
Step("ss53", "status_apply", asset: "human", value: "invincible"),
Step("ss54", "status_apply", asset: "wolf", value: "invincible"),
Step("ss55", "assert", expect: "story_phase", value: "Climax"),
- Step("ss56", "assert", expect: "story_spine", value: "Duel · Climax"),
+ // Tip already leads with Duel - hide redundant Kind · Climax spine row.
+ Step("ss56", "assert", expect: "story_spine", value: "empty"),
// In-place escalate must retarget Kind Mass without a director SwitchTo.
Step("ss57", "combat_ensemble_escalate", value: "6"),
Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
@@ -2624,16 +2650,15 @@ internal static class HarnessScenarios
Step("ss57d", "wait", value: "0.35"),
Step("ss57e", "combat_maintain_focus"),
Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
- // Spine Kind follows tip Label (Battle/Mass/Skirmish), not a collapsed Mass bucket.
- Step("ss59", "assert", expect: "story_spine",
- value: "Battle · Climax|Mass · Climax|Skirmish · Climax"),
+ // Climax spine suppressed when tip already names the kind.
+ Step("ss59", "assert", expect: "story_spine", value: "empty"),
// Shrink back to a named duel - spine Kind must follow the Label, not scale-hold.
Step("ss59b", "combat_isolate_pair", value: "20"),
Step("ss59c", "combat_maintain_focus"),
Step("ss59d", "wait", value: "0.35"),
Step("ss59e", "combat_maintain_focus"),
Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"),
- Step("ss59g", "assert", expect: "story_spine", value: "Duel · Climax"),
+ Step("ss59g", "assert", expect: "story_spine", value: "empty"),
Step("ss60", "interest_force_session", asset: "human",
label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel",
value: "lead=event;evt=95"),
@@ -2760,7 +2785,7 @@ internal static class HarnessScenarios
Step("sac22", "status_apply", asset: "wolf", value: "invincible"),
Step("sac23", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("sac24", "assert", expect: "story_phase", value: "Climax"),
- Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"),
+ Step("sac24b", "assert", expect: "story_spine", value: "empty"),
Step("sac25", "interest_expire_pending", value: ""),
// Survivor aftermath requires a fallen theater partner (both-alive must not mint stands-over).
Step("sac25b", "combat_kill_related"),
@@ -2938,7 +2963,10 @@ internal static class HarnessScenarios
};
}
- /// Saga overview hover has MC name and no planner jargon / trait descs.
+ ///
+ /// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
+ /// no planner jargon / trait descs / Chronicle dumps.
+ ///
private static List SagaOverviewHasMc()
{
return new List
@@ -2954,16 +2982,186 @@ internal static class HarnessScenarios
Step("som8", "focus", asset: "human"),
Step("som9", "interest_saga_clear"),
Step("som10", "saga_force_admit_focus"),
- Step("som11", "director_run", wait: 0.4f),
Step("som12", "assert", expect: "saga_roster_count", value: "1", label: "min"),
Step("som13", "assert", expect: "saga_overview_tip_not",
- value: "Climax|Aftermath|Epilogue|parked|watching|Known for:|Appearance brings comfort"),
+ value: "Climax|Aftermath|Epilogue|parked|watching|Appearance brings comfort|Standing|Known for|Harness chapter|Fighting "),
+ Step("som13b", "assert", expect: "saga_overview_tip",
+ value: "At stake|Cast|Legacy|King|Leader|Alpha|Dragon|Human|life"),
Step("som14", "assert", expect: "story_rail_count", value: "1", label: "min"),
+ Step("som15", "assert", expect: "saga_admission_persisted"),
+ Step("som15b", "saga_rail_refresh"),
+ Step("som15c", "assert", expect: "saga_admission_persisted"),
+ Step("som16", "saga_show_hover_first"),
+ Step("som17", "assert", expect: "saga_tab_effective", value: "saga"),
+ Step("som17b", "assert", expect: "saga_snapshot_layout"),
+ Step("som20", "saga_stamp_chapter_focus", asset: "Combat",
+ value: "Survived a bloody duel against a rival"),
+ Step("som21", "saga_rail_refresh"),
+ Step("som22", "saga_show_hover_first"),
+ Step("som23", "assert", expect: "saga_overview_tip", value: "Survived a bloody duel"),
+ Step("som24", "assert", expect: "saga_overview_tip_not",
+ value: "Climax|Aftermath|Epilogue|Appearance brings comfort|Standing age|Fighting "),
+ Step("som25", "assert", expect: "saga_snapshot_layout"),
+ Step("som26", "screenshot", value: "saga-adaptive-hover"),
Step("som90", "fast_timing", value: "false"),
Step("som99", "snapshot"),
};
}
+ /// Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body.
+ private static List SagaAdaptiveDossier()
+ {
+ return new List
+ {
+ Step("sad0", "dismiss_windows"),
+ Step("sad1", "wait_world"),
+ Step("sad2", "set_setting", expect: "enabled", value: "true"),
+ Step("sad3", "fast_timing", value: "true"),
+ Step("sad4", "spawn", asset: "human", count: 2),
+ Step("sad5", "spectator", value: "off"),
+ Step("sad6", "spectator", value: "on"),
+ Step("sad7", "interest_saga_clear"),
+ Step("sad8", "pick_unit", asset: "human"),
+ Step("sad9", "focus", asset: "human"),
+ Step("sad10", "saga_force_admit_focus"),
+ Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
+ 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("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("sad16", "saga_select_tab", value: "dossier"),
+ Step("sad16b", "assert", expect: "saga_panel_fixed_size"),
+ Step("sad16c", "screenshot", value: "saga-dossier-paired"),
+ 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"),
+ // Panel dwell clears the exit timer; leaving chrome must re-arm and restore Dossier.
+ Step("sad21b", "saga_hover_panel_dwell"),
+ Step("sad21c", "saga_hover_tick_away"),
+ Step("sad21d", "wait", wait: 0.1f),
+ Step("sad21e", "saga_hover_tick_away"),
+ Step("sad21f", "wait", wait: 0.4f),
+ Step("sad21g", "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"),
+ 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("sad23", "wait", wait: 0.4f),
+ 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_evidence", value: "true"),
+ // 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"),
+ // Open Lore from Saga and restore Saga on close.
+ Step("sad44", "saga_open_lore"),
+ Step("sad45", "assert", expect: "chronicle_visible", value: "true"),
+ Step("sad46", "lore_close"),
+ Step("sad47", "assert", expect: "saga_tab_effective", value: "saga"),
+ Step("sad48", "assert", expect: "saga_panel_bound"),
+ Step("sad90", "fast_timing", value: "false"),
+ Step("sad99", "snapshot"),
+ };
+ }
+
+ /// Memory survives roster eviction and captures without Chronicle.
+ private static List SagaMemorySurvives()
+ {
+ return new List
+ {
+ Step("sms0", "dismiss_windows"),
+ Step("sms1", "wait_world"),
+ Step("sms2", "set_setting", expect: "enabled", value: "true"),
+ Step("sms3", "set_setting", expect: "chronicle_enabled", value: "false"),
+ Step("sms4", "fast_timing", value: "true"),
+ Step("sms5", "spawn", asset: "human", count: 2),
+ Step("sms6", "spectator", value: "off"),
+ Step("sms7", "spectator", value: "on"),
+ Step("sms8", "interest_saga_clear"),
+ Step("sms9", "pick_unit", asset: "human"),
+ Step("sms10", "focus", asset: "human"),
+ Step("sms11", "saga_force_admit_focus"),
+ Step("sms12", "saga_memory_record_focus", asset: "Kill", value: "Named foe"),
+ Step("sms13", "assert", expect: "saga_memory_has_focus", value: "true"),
+ Step("sms14", "assert", expect: "saga_memory_fact_focus", value: "Kill"),
+ Step("sms15", "saga_set_scores_focus", value: "0.1", label: "0"),
+ Step("sms16", "pick_unit", asset: "human", label: "other"),
+ Step("sms17", "focus", asset: "human"),
+ Step("sms18", "saga_force_admit_focus"),
+ Step("sms19", "saga_world_scan"),
+ Step("sms20", "assert", expect: "saga_memory_life_count", value: "1", label: "min"),
+ Step("sms21", "set_setting", expect: "chronicle_enabled", value: "true"),
+ Step("sms90", "fast_timing", value: "false"),
+ Step("sms99", "snapshot"),
+ };
+ }
+
+ /// Hover acquires read-pause; camera owner stays fixed until release.
+ private static List SagaHoverReadPause()
+ {
+ return new List
+ {
+ Step("shp0", "dismiss_windows"),
+ Step("shp1", "wait_world"),
+ Step("shp2", "set_setting", expect: "enabled", value: "true"),
+ Step("shp3", "fast_timing", value: "true"),
+ Step("shp4", "spawn", asset: "human", count: 2),
+ Step("shp5", "spectator", value: "off"),
+ Step("shp6", "spectator", value: "on"),
+ Step("shp7", "interest_saga_clear"),
+ Step("shp8", "pick_unit", asset: "human"),
+ Step("shp9", "focus", asset: "human"),
+ Step("shp10", "saga_force_admit_focus"),
+ Step("shp11", "interest_force_session", asset: "human", label: "PauseHold",
+ tier: "Action", value: "lead=event;evt=70;force=true;ttl=30"),
+ Step("shp12", "assert", expect: "tip_contains", value: "PauseHold"),
+ Step("shp13", "saga_show_hover_first"),
+ Step("shp14", "assert", expect: "saga_read_pause", value: "true"),
+ Step("shp15", "interest_inject", asset: "human", label: "ShouldNotCut", tier: "Action",
+ expect: "shp_cut", value: "lead=event;evt=95;force=false;ttl=30;pick=other"),
+ Step("shp16", "director_run", wait: 1.0f),
+ Step("shp17", "assert", expect: "tip_contains", value: "PauseHold"),
+ Step("shp18", "assert", expect: "tip_not_contains", value: "ShouldNotCut"),
+ Step("shp19", "saga_hide_hover"),
+ Step("shp20", "wait", wait: 0.35f),
+ Step("shp21", "assert", expect: "saga_read_pause", value: "false"),
+ Step("shp90", "fast_timing", value: "false"),
+ Step("shp99", "snapshot"),
+ };
+ }
+
/// Multi-species notables fill distinct roster slots.
private static List SagaRosterDiversity()
{
@@ -3026,6 +3224,204 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Extended roles: singleton dragon overview + hard-arc nobody admit path.
+ ///
+ private static List SagaAdmitRoles()
+ {
+ return new List
+ {
+ Step("sar0", "dismiss_windows"),
+ Step("sar1", "wait_world"),
+ Step("sar2", "set_setting", expect: "enabled", value: "true"),
+ Step("sar3", "fast_timing", value: "true"),
+ Step("sar4", "spawn", asset: "dragon", count: 1),
+ Step("sar5", "spawn", asset: "human", count: 1),
+ Step("sar6", "spectator", value: "off"),
+ Step("sar7", "spectator", value: "on"),
+ Step("sar8", "interest_saga_clear"),
+ Step("sar9", "pick_unit", asset: "dragon"),
+ Step("sar10", "focus", asset: "dragon"),
+ // Force admit so role flags refresh from live singleton/pop helpers.
+ Step("sar11", "saga_force_admit_focus"),
+ Step("sar12", "assert", expect: "saga_has_focus"),
+ Step("sar13", "saga_rail_refresh"),
+ Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"),
+ // Nobody human may enter only via hard-arc ConsiderAdmit.
+ Step("sar20", "pick_unit", asset: "human"),
+ Step("sar21", "focus", asset: "human"),
+ Step("sar22", "saga_consider_hard_focus"),
+ Step("sar23", "assert", expect: "saga_has_focus"),
+ Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
+ Step("sar90", "fast_timing", value: "false"),
+ Step("sar99", "snapshot"),
+ };
+ }
+
+ /// Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin.
+ private static List SagaReplaceWeaker()
+ {
+ return new List
+ {
+ Step("srw0", "dismiss_windows"),
+ Step("srw1", "wait_world"),
+ Step("srw2", "set_setting", expect: "enabled", value: "true"),
+ Step("srw3", "fast_timing", value: "true"),
+ Step("srw4", "spawn", asset: "human", count: 11),
+ Step("srw5", "spectator", value: "off"),
+ Step("srw6", "spectator", value: "on"),
+ Step("srw7", "interest_saga_clear"),
+ // Fill Cap with force-admits.
+ Step("srw10", "pick_unit", asset: "human"),
+ Step("srw11", "focus", asset: "human"),
+ Step("srw12", "saga_force_admit_focus"),
+ Step("srw13", "saga_prefer_focus"),
+ Step("srw14", "assert", expect: "saga_prefer_focus_on"),
+ Step("srw20", "pick_unit", asset: "human", value: "other"),
+ Step("srw21", "focus", asset: "human"),
+ Step("srw22", "saga_force_admit_focus"),
+ Step("srw23", "pick_unit", asset: "human", value: "other"),
+ Step("srw24", "focus", asset: "human"),
+ Step("srw25", "saga_force_admit_focus"),
+ Step("srw26", "pick_unit", asset: "human", value: "other"),
+ Step("srw27", "focus", asset: "human"),
+ Step("srw28", "saga_force_admit_focus"),
+ Step("srw29", "pick_unit", asset: "human", value: "other"),
+ Step("srw30", "focus", asset: "human"),
+ Step("srw31", "saga_force_admit_focus"),
+ Step("srw32", "pick_unit", asset: "human", value: "other"),
+ Step("srw33", "focus", asset: "human"),
+ Step("srw34", "saga_force_admit_focus"),
+ Step("srw35", "pick_unit", asset: "human", value: "other"),
+ Step("srw36", "focus", asset: "human"),
+ Step("srw37", "saga_force_admit_focus"),
+ Step("srw38", "pick_unit", asset: "human", value: "other"),
+ Step("srw39", "focus", asset: "human"),
+ Step("srw40", "saga_force_admit_focus"),
+ Step("srw41", "pick_unit", asset: "human", value: "other"),
+ Step("srw42", "focus", asset: "human"),
+ Step("srw43", "saga_force_admit_focus"),
+ Step("srw44", "pick_unit", asset: "human", value: "other"),
+ Step("srw45", "focus", asset: "human"),
+ Step("srw46", "saga_force_admit_focus"),
+ Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
+ Step("srw51", "saga_weaken_nonprefer"),
+ // 11th human challenges Cap via hard-arc admit path.
+ Step("srw60", "pick_unit", asset: "human", value: "other"),
+ Step("srw61", "focus", asset: "human"),
+ Step("srw62", "saga_consider_hard_focus"),
+ Step("srw63", "assert", expect: "saga_has_focus"),
+ Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
+ // Prefer pin still present; challenger focus itself is not Prefer.
+ Step("srw70", "assert", expect: "saga_prefer_focus_on", value: "false"),
+ Step("srw71", "assert", expect: "saga_prefer_count", value: "1", label: "min"),
+ Step("srw90", "fast_timing", value: "false"),
+ Step("srw99", "snapshot"),
+ };
+ }
+
+ /// Active rail chrome tracks camera follow, not Prefer alone.
+ private static List SagaRailActiveHighlight()
+ {
+ return new List
+ {
+ Step("sra0", "dismiss_windows"),
+ Step("sra1", "wait_world"),
+ Step("sra2", "set_setting", expect: "enabled", value: "true"),
+ Step("sra3", "fast_timing", value: "true"),
+ Step("sra4", "spawn", asset: "human", count: 2),
+ Step("sra5", "spectator", value: "off"),
+ Step("sra6", "spectator", value: "on"),
+ Step("sra7", "interest_saga_clear"),
+ Step("sra8", "pick_unit", asset: "human"),
+ Step("sra9", "focus", asset: "human"),
+ Step("sra10", "saga_force_admit_focus"),
+ Step("sra11", "saga_prefer_focus"),
+ Step("sra12", "saga_rail_refresh"),
+ Step("sra13", "assert", expect: "saga_rail_active_highlight", value: "true"),
+ Step("sra14", "assert", expect: "saga_rail_prefer_pip", value: "true"),
+ Step("sra20", "pick_unit", asset: "human", value: "other"),
+ Step("sra21", "focus", asset: "human"),
+ Step("sra22", "saga_force_admit_focus"),
+ Step("sra23", "saga_rail_refresh"),
+ // Follow moved off Prefer'd MC - Active highlight should track the new follow.
+ Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"),
+ Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
+ Step("sra90", "fast_timing", value: "false"),
+ Step("sra99", "snapshot"),
+ };
+ }
+
+ /// Prefer toggle flips on then off with immediate pip state.
+ private static List SagaRailPreferClick()
+ {
+ return new List
+ {
+ Step("srp0", "dismiss_windows"),
+ Step("srp1", "wait_world"),
+ Step("srp2", "set_setting", expect: "enabled", value: "true"),
+ Step("srp3", "fast_timing", value: "true"),
+ Step("srp4", "spawn", asset: "human", count: 1),
+ Step("srp5", "spectator", value: "off"),
+ Step("srp6", "spectator", value: "on"),
+ Step("srp7", "interest_saga_clear"),
+ Step("srp8", "pick_unit", asset: "human"),
+ Step("srp9", "focus", asset: "human"),
+ Step("srp10", "saga_force_admit_focus"),
+ Step("srp11", "saga_prefer_focus"),
+ Step("srp12", "saga_rail_refresh"),
+ Step("srp13", "assert", expect: "saga_prefer_focus_on"),
+ Step("srp14", "assert", expect: "saga_rail_prefer_pip", value: "true"),
+ Step("srp20", "saga_prefer_focus"),
+ Step("srp21", "saga_rail_refresh"),
+ Step("srp22", "assert", expect: "saga_prefer_focus_on", value: "false"),
+ Step("srp23", "assert", expect: "saga_rail_prefer_pip", value: "false"),
+ Step("srp90", "fast_timing", value: "false"),
+ Step("srp99", "snapshot"),
+ };
+ }
+
+ ///
+ /// During soft-fill quiet, Pack and stun/invincible crumbs do not win; combat still can.
+ ///
+ private static List SagaSoftFillNoPack()
+ {
+ return new List
+ {
+ Step("ssf0", "dismiss_windows"),
+ Step("ssf1", "wait_world"),
+ Step("ssf2", "set_setting", expect: "enabled", value: "true"),
+ Step("ssf3", "fast_timing", value: "true"),
+ Step("ssf4", "spawn", asset: "human", count: 2),
+ Step("ssf5", "spectator", value: "off"),
+ Step("ssf6", "spectator", value: "on"),
+ Step("ssf7", "pick_unit", asset: "human"),
+ Step("ssf8", "focus", asset: "human"),
+ Step("ssf9", "interest_end_session"),
+ Step("ssf10", "soft_fill_quiet_arm"),
+ Step("ssf11", "assert", expect: "soft_fill_quiet", value: "true"),
+ Step("ssf20", "interest_expire_pending", value: ""),
+ Step("ssf21", "interest_inject", asset: "human", label: "Pack - QuietBlock (3) · gathering",
+ tier: "Action", expect: "pack_soft",
+ value: "lead=event;evt=70;char=5;force=false;ttl=60"),
+ Step("ssf22", "interest_inject", asset: "human", label: "QuietStun",
+ tier: "Action", expect: "stunned",
+ value: "lead=event;evt=68;char=5;force=false;ttl=60;pick=other"),
+ Step("ssf23", "interest_inject", asset: "human", label: "QuietInvincible",
+ tier: "Action", expect: "invincible",
+ value: "lead=event;evt=66;char=5;force=false;ttl=60"),
+ Step("ssf24", "director_run", wait: 1.2f),
+ Step("ssf25", "assert", expect: "tip_not_contains", value: "Pack -"),
+ Step("ssf26", "assert", expect: "tip_not_contains", value: "QuietStun"),
+ Step("ssf27", "assert", expect: "tip_not_contains", value: "QuietInvincible"),
+ Step("ssf30", "interest_combat_session", asset: "human", expect: "ssf_combat"),
+ Step("ssf31", "director_run", wait: 0.8f),
+ Step("ssf32", "assert", expect: "tip_matches_any", value: "Duel -|fighting|Skirmish -"),
+ Step("ssf90", "fast_timing", value: "false"),
+ Step("ssf99", "snapshot"),
+ };
+ }
+
///
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
///
diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs
index da34dbd..d71bf73 100644
--- a/IdleSpectator/HudIcons.cs
+++ b/IdleSpectator/HudIcons.cs
@@ -1,3 +1,4 @@
+using System;
using UnityEngine;
namespace IdleSpectator;
@@ -140,6 +141,97 @@ public static class HudIcons
}
}
+ ///
+ /// Readable saga-rail face: live inspect sprite when usable, else species icon.
+ /// Skips egg forms and near-empty tiny crops that read as a single pixel.
+ ///
+ public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null)
+ {
+ if (actor != null && actor.isAlive())
+ {
+ bool egg = false;
+ try
+ {
+ egg = actor.isEgg();
+ }
+ catch
+ {
+ egg = false;
+ }
+
+ if (!egg)
+ {
+ Sprite live = FromActorLive(actor);
+ if (IsUsableRailFace(live))
+ {
+ return live;
+ }
+
+ Sprite asset = FromActor(actor);
+ if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset))
+ {
+ return asset;
+ }
+ }
+ }
+
+ string species = speciesFallbackId;
+ if (string.IsNullOrEmpty(species) && actor?.asset != null)
+ {
+ species = actor.asset.id;
+ }
+
+ return FromSpeciesId(species);
+ }
+
+ public static bool IsUsableRailFace(Sprite sprite)
+ {
+ if (sprite == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ // Reject 1-4px crops and blank atlas slices that look like dust on the rail.
+ float w = sprite.rect.width;
+ float h = sprite.rect.height;
+ if (w < 8f || h < 8f)
+ {
+ return false;
+ }
+
+ if (sprite.texture == null)
+ {
+ return false;
+ }
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool LooksLikeEggSprite(Sprite sprite)
+ {
+ if (sprite == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ string n = sprite.name ?? "";
+ return n.IndexOf("egg", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
public static Sprite ForChronicleKind(ChronicleKind kind)
{
switch (kind)
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index d00b8a7..1537ada 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -460,9 +460,51 @@ public static partial class InterestDirector
private static float _browseFrozenLastSwitchAt = -999f;
private static float _browseFrozenLastInterestingAt = -999f;
+ /// Camera read-pause lease owners (hover preview). Feeds/story continue; switching deferred.
+ private static readonly HashSet ReadPauseOwners = new HashSet();
+ private static bool _readPauseNeedsReconcile;
+
+ public static bool ReadPauseActive => ReadPauseOwners.Count > 0;
+
+ public static bool AcquireReadPause(string owner)
+ {
+ if (string.IsNullOrEmpty(owner))
+ {
+ return false;
+ }
+
+ bool added = ReadPauseOwners.Add(owner);
+ return added || ReadPauseOwners.Contains(owner);
+ }
+
+ public static void ReleaseReadPause(string owner)
+ {
+ if (string.IsNullOrEmpty(owner))
+ {
+ return;
+ }
+
+ if (ReadPauseOwners.Remove(owner) && ReadPauseOwners.Count == 0)
+ {
+ _readPauseNeedsReconcile = true;
+ }
+ }
+
+ public static void ClearReadPause()
+ {
+ if (ReadPauseOwners.Count == 0)
+ {
+ return;
+ }
+
+ ReadPauseOwners.Clear();
+ _readPauseNeedsReconcile = true;
+ }
+
public static void OnSpectatorEnabled()
{
ClearBrowseFreeze();
+ ClearReadPause();
InterestRegistry.Clear();
InterestVariety.Clear();
InterestScoring.ClearCache();
@@ -488,6 +530,7 @@ public static partial class InterestDirector
public static void OnSpectatorDisabled()
{
ClearBrowseFreeze();
+ ClearReadPause();
_current = null;
_interrupted = null;
InterestRegistry.Clear();
@@ -598,6 +641,20 @@ public static partial class InterestDirector
StoryPlanner.Tick(now);
LifeSagaRoster.Tick(now);
+ LifeSagaMemory.EnsureWorldBound();
+
+ if (ReadPauseActive)
+ {
+ // Keep feeds/story/roster alive; freeze every camera-mutating path.
+ return;
+ }
+
+ if (_readPauseNeedsReconcile)
+ {
+ _readPauseNeedsReconcile = false;
+ MaintainCameraFocus(now);
+ }
+
UpdateSessionLiveness(now, onCurrent);
MaintainCombatFocus(now, force: false);
MaintainWarFront(now, force: false);
@@ -1829,6 +1886,12 @@ public static partial class InterestDirector
LogService.LogInfo("[IdleSpectator] Scene end (" + reason + "): " + _current.Label);
}
+ // Soft crumbs ending during/after hard quiet must not reopen the floodgates.
+ if (ended != null && InterestVariety.IsSoftFillQuietCrumb(ended))
+ {
+ StoryPlanner.NoteSoftCrumbQuietRefresh(now);
+ }
+
StoryPlanner.OnEndCurrent(ended, now);
// Resume interrupted Epic-preempted scene if still valid.
@@ -1925,10 +1988,10 @@ public static partial class InterestDirector
continue;
}
- // After hard story/crisis closers, skip soft-life crumbs for a beat so AFK
- // does not immediately hatch/reproduce/sleep surf. Leave them pending.
+ // After hard story/crisis closers, skip soft-life / short interrupt crumbs
+ // so AFK does not immediately hatch/reproduce/sleep/stun surf. Leave them pending.
if (softQuiet
- && InterestVariety.IsSoftLifeChapter(c)
+ && InterestVariety.IsSoftFillQuietCrumb(c)
&& !BreaksSoftFillQuiet(c))
{
InterestDropLog.Record("soft_fill_quiet", c.AssetId ?? c.HappinessEffectId ?? c.Key);
@@ -1973,12 +2036,12 @@ public static partial class InterestDirector
return false;
}
+ // FamilyPack is ambient cluster fill - must not break soft-fill quiet.
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
- || c.Completion == InterestCompletionKind.EarthquakeActive
- || c.Completion == InterestCompletionKind.FamilyPack)
+ || c.Completion == InterestCompletionKind.EarthquakeActive)
{
return true;
}
diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs
index 0fcea53..060294e 100644
--- a/IdleSpectator/InterestVariety.cs
+++ b/IdleSpectator/InterestVariety.cs
@@ -1207,6 +1207,57 @@ public static class InterestVariety
|| s.IndexOf("favorite", StringComparison.Ordinal) >= 0;
}
+ ///
+ /// Soft-fill quiet crumbs: soft life chapters, short interrupt FX, and ambient Pack fill.
+ /// Pack must not own the camera during quiet (and no longer breaks quiet).
+ ///
+ public static bool IsSoftFillQuietCrumb(InterestCandidate c) =>
+ c != null
+ && (c.Completion == InterestCompletionKind.FamilyPack
+ || IsSoftLifeChapter(c)
+ || IsShortInterruptFxChapter(c));
+
+ ///
+ /// Brief interrupt FX that should not own AFK during soft-fill quiet.
+ /// Kept non-outbreak; real danger statuses stay hot.
+ ///
+ public static bool IsShortInterruptFxChapter(InterestCandidate c)
+ {
+ if (c == null)
+ {
+ return false;
+ }
+
+ string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId;
+ if (string.IsNullOrEmpty(status))
+ {
+ return false;
+ }
+
+ if (c.Completion != InterestCompletionKind.StatusPhase
+ && c.Completion != InterestCompletionKind.FixedDwell
+ && string.IsNullOrEmpty(c.StatusId))
+ {
+ return false;
+ }
+
+ return IsShortInterruptFxId(status);
+ }
+
+ public static bool IsShortInterruptFxId(string statusId)
+ {
+ if (string.IsNullOrEmpty(statusId))
+ {
+ return false;
+ }
+
+ string s = statusId.Trim().ToLowerInvariant();
+ return s.IndexOf("stun", StringComparison.Ordinal) >= 0
+ || s.IndexOf("invincible", StringComparison.Ordinal) >= 0
+ || s == "surprised"
+ || s == "confused";
+ }
+
///
/// Soft personal status FX ids from the live status inventory shape:
/// mood / life-cycle / brief FX. Danger and combat statuses stay hotter.
@@ -1218,11 +1269,16 @@ public static class InterestVariety
return false;
}
+ // Short interrupt FX count as soft crumbs for quiet + variety cooling.
+ if (IsShortInterruptFxId(statusId))
+ {
+ return true;
+ }
+
string s = statusId.Trim().ToLowerInvariant();
// Danger / combat / possession stay camera-sticky for repeats.
if (s.IndexOf("burn", StringComparison.Ordinal) >= 0
|| s.IndexOf("poison", StringComparison.Ordinal) >= 0
- || s.IndexOf("stun", StringComparison.Ordinal) >= 0
|| s.IndexOf("drown", StringComparison.Ordinal) >= 0
|| s.IndexOf("starv", StringComparison.Ordinal) >= 0
|| s.IndexOf("possess", StringComparison.Ordinal) >= 0
@@ -1231,7 +1287,6 @@ public static class InterestVariety
|| s.IndexOf("rage", StringComparison.Ordinal) >= 0
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|| s.IndexOf("shield", StringComparison.Ordinal) >= 0
- || s.IndexOf("invincible", StringComparison.Ordinal) >= 0
|| s.IndexOf("combat", StringComparison.Ordinal) >= 0
|| s == "on_guard"
|| s == "angry"
diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs
new file mode 100644
index 0000000..aa623ec
--- /dev/null
+++ b/IdleSpectator/Story/LifeSagaMemory.cs
@@ -0,0 +1,1332 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+///
+/// World-session structured saga facts keyed by unit id.
+/// Survives roster eviction; cleared only on world replacement / harness clear / teardown.
+/// Independent of ChronicleEnabled and camera feed gates.
+///
+public static class LifeSagaMemory
+{
+ public const int MaxFactsPerLife = 48;
+ public const int MaxLives = 120;
+ public const int RivalEncounterThreshold = 2;
+
+ private static readonly Dictionary Lives =
+ new Dictionary(64);
+ private static readonly Dictionary PairEncounters =
+ new Dictionary(64);
+ private static object _boundWorld;
+ private static int _worldEpoch;
+
+ public static int WorldEpoch => _worldEpoch;
+ public static int LifeCount => Lives.Count;
+
+ public static void EnsureWorldBound()
+ {
+ object world = null;
+ try
+ {
+ world = World.world;
+ }
+ catch
+ {
+ world = null;
+ }
+
+ if (ReferenceEquals(world, _boundWorld))
+ {
+ return;
+ }
+
+ ClearSession();
+ _boundWorld = world;
+ _worldEpoch++;
+ }
+
+ public static void ClearSession()
+ {
+ Lives.Clear();
+ PairEncounters.Clear();
+ _boundWorld = null;
+ }
+
+ public static void Clear() => ClearSession();
+
+ public static LifeSagaLifeMemory Get(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return null;
+ }
+
+ EnsureWorldBound();
+ return Lives.TryGetValue(unitId, out LifeSagaLifeMemory life) ? life : null;
+ }
+
+ public static LifeSagaLifeMemory GetOrCreate(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return null;
+ }
+
+ EnsureWorldBound();
+ if (Lives.TryGetValue(unitId, out LifeSagaLifeMemory existing))
+ {
+ return existing;
+ }
+
+ PruneIfNeeded();
+ var life = new LifeSagaLifeMemory { UnitId = unitId };
+ Lives[unitId] = life;
+ return life;
+ }
+
+ public static bool HasFacts(long unitId)
+ {
+ LifeSagaLifeMemory life = Get(unitId);
+ return life != null && life.Facts.Count > 0;
+ }
+
+ public static IReadOnlyList FactsFor(long unitId)
+ {
+ LifeSagaLifeMemory life = Get(unitId);
+ return life != null ? life.Facts : Array.Empty();
+ }
+
+ public static LifeSagaFact Record(
+ LifeSagaFactKind kind,
+ Actor subject,
+ Actor other = null,
+ string correlationKey = null,
+ float strength = 50f,
+ string provenance = "",
+ string kingdomKey = "",
+ string cityKey = "",
+ string clanKey = "",
+ string familyKey = "",
+ string warKey = "",
+ string plotKey = "",
+ bool resolved = false,
+ string note = "")
+ {
+ if (subject == null)
+ {
+ return null;
+ }
+
+ long subjectId = EventFeedUtil.SafeId(subject);
+ if (subjectId == 0)
+ {
+ return null;
+ }
+
+ LifeSagaIdentity subjectSnap = Snapshot(subject);
+ LifeSagaIdentity otherSnap = Snapshot(other);
+ return RecordIds(
+ kind,
+ subjectId,
+ subjectSnap,
+ otherSnap.Id,
+ otherSnap,
+ correlationKey,
+ strength,
+ provenance,
+ kingdomKey,
+ cityKey,
+ clanKey,
+ familyKey,
+ warKey,
+ plotKey,
+ resolved,
+ note);
+ }
+
+ public static LifeSagaFact RecordIds(
+ LifeSagaFactKind kind,
+ long subjectId,
+ LifeSagaIdentity subject,
+ long otherId,
+ LifeSagaIdentity other,
+ string correlationKey = null,
+ float strength = 50f,
+ string provenance = "",
+ string kingdomKey = "",
+ string cityKey = "",
+ string clanKey = "",
+ string familyKey = "",
+ string warKey = "",
+ string plotKey = "",
+ bool resolved = false,
+ string note = "")
+ {
+ if (subjectId == 0)
+ {
+ return null;
+ }
+
+ EnsureWorldBound();
+ LifeSagaLifeMemory life = GetOrCreate(subjectId);
+ if (life == null)
+ {
+ return null;
+ }
+
+ if (subject.Id != 0)
+ {
+ life.Identity = subject;
+ }
+
+ string key = string.IsNullOrEmpty(correlationKey)
+ ? DefaultKey(kind, subjectId, otherId, warKey, plotKey)
+ : correlationKey;
+
+ LifeSagaFact existing = FindByKey(life, key);
+ float now = Time.unscaledTime;
+ StampWorldDate(out double worldTime, out string dateLabel);
+ if (existing != null)
+ {
+ existing.Strength = Mathf.Max(existing.Strength, strength);
+ existing.Resolved = resolved;
+ existing.At = now;
+ existing.WorldTime = worldTime;
+ existing.DateLabel = dateLabel;
+ if (!string.IsNullOrEmpty(note))
+ {
+ existing.Note = note;
+ }
+
+ if (other.Id != 0 && existing.Other.Id == 0)
+ {
+ existing.Other = other;
+ }
+
+ life.TouchedAt = now;
+ return existing;
+ }
+
+ var fact = new LifeSagaFact
+ {
+ Key = key,
+ Kind = kind,
+ SubjectId = subjectId,
+ OtherId = otherId,
+ Subject = subject.Id != 0 ? subject : SnapshotId(subjectId),
+ Other = other.Id != 0 ? other : (otherId != 0 ? SnapshotId(otherId) : default),
+ At = now,
+ WorldTime = worldTime,
+ DateLabel = dateLabel,
+ Strength = strength,
+ Provenance = provenance ?? "",
+ KingdomKey = kingdomKey ?? "",
+ CityKey = cityKey ?? "",
+ ClanKey = clanKey ?? "",
+ FamilyKey = familyKey ?? "",
+ WarKey = warKey ?? "",
+ PlotKey = plotKey ?? "",
+ Resolved = resolved,
+ Note = note ?? ""
+ };
+ life.Facts.Insert(0, fact);
+ life.TouchedAt = now;
+ while (life.Facts.Count > MaxFactsPerLife)
+ {
+ life.Facts.RemoveAt(life.Facts.Count - 1);
+ }
+
+ // Mirror onto the other life for shared events (bonds, kills, kin).
+ if (otherId != 0 && otherId != subjectId
+ && (kind == LifeSagaFactKind.BondFormed
+ || kind == LifeSagaFactKind.BondBroken
+ || kind == LifeSagaFactKind.FriendFormed
+ || kind == LifeSagaFactKind.ParentChild
+ || kind == LifeSagaFactKind.Kill
+ || kind == LifeSagaFactKind.Death
+ || kind == LifeSagaFactKind.CombatEncounter
+ || kind == LifeSagaFactKind.RivalEarned
+ || kind == LifeSagaFactKind.WarJoin
+ || kind == LifeSagaFactKind.PlotJoin))
+ {
+ MirrorToOther(fact, otherId);
+ }
+
+ return fact;
+ }
+
+ public static void RecordBondFormed(Actor a, Actor b, string provenance = "set_lover")
+ {
+ if (a == null || b == null)
+ {
+ return;
+ }
+
+ Record(
+ LifeSagaFactKind.BondFormed,
+ a,
+ b,
+ correlationKey: "bond:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)) + ":formed:"
+ + Time.unscaledTime.ToString("0.###"),
+ strength: 70f,
+ provenance: provenance);
+ }
+
+ public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
+ {
+ if (survivor == null)
+ {
+ return;
+ }
+
+ long survivorId = EventFeedUtil.SafeId(survivor);
+ LifeSagaIdentity formerSnap = Snapshot(former);
+ if (formerSnap.Id == 0)
+ {
+ // Fall back to last known lover fact on this life.
+ LifeSagaLifeMemory life = Get(survivorId);
+ if (life != null)
+ {
+ for (int i = 0; i < life.Facts.Count; i++)
+ {
+ LifeSagaFact f = life.Facts[i];
+ if (f != null && f.Kind == LifeSagaFactKind.BondFormed && f.OtherId != 0)
+ {
+ formerSnap = f.Other;
+ break;
+ }
+ }
+ }
+ }
+
+ if (formerSnap.Id == 0)
+ {
+ return;
+ }
+
+ RecordIds(
+ LifeSagaFactKind.BondBroken,
+ survivorId,
+ Snapshot(survivor),
+ formerSnap.Id,
+ formerSnap,
+ correlationKey: "bond:" + PairKey(survivorId, formerSnap.Id) + ":broken:"
+ + Time.unscaledTime.ToString("0.###"),
+ strength: 75f,
+ provenance: provenance,
+ resolved: true);
+ }
+
+ public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
+ {
+ if (parent == null || child == null)
+ {
+ return;
+ }
+
+ long parentId = EventFeedUtil.SafeId(parent);
+ long childId = EventFeedUtil.SafeId(child);
+ Record(
+ LifeSagaFactKind.ParentChild,
+ parent,
+ child,
+ correlationKey: "parent:" + parentId + ":" + childId,
+ strength: 65f,
+ provenance: provenance);
+ }
+
+ public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend")
+ {
+ if (a == null || b == null)
+ {
+ return;
+ }
+
+ Record(
+ LifeSagaFactKind.FriendFormed,
+ a,
+ b,
+ correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
+ strength: 55f,
+ provenance: provenance);
+ }
+
+ public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
+ {
+ if (killer == null || victim == null)
+ {
+ return;
+ }
+
+ long killerId = EventFeedUtil.SafeId(killer);
+ long victimId = EventFeedUtil.SafeId(victim);
+ Record(
+ LifeSagaFactKind.Kill,
+ killer,
+ victim,
+ correlationKey: "kill:" + killerId + ":" + victimId + ":"
+ + Time.unscaledTime.ToString("0.###"),
+ strength: 80f,
+ provenance: provenance);
+
+ NoteCombatEncounter(killer, victim, provenance);
+ TryMarkKinKillRival(killer, victim);
+ }
+
+ public static void RecordDeath(Actor victim, Actor killer, string attackType, string provenance = "die")
+ {
+ if (victim == null)
+ {
+ return;
+ }
+
+ long victimId = EventFeedUtil.SafeId(victim);
+ Record(
+ LifeSagaFactKind.Death,
+ victim,
+ killer,
+ correlationKey: "death:" + victimId,
+ strength: 90f,
+ provenance: provenance,
+ resolved: true,
+ note: attackType ?? "");
+
+ if (killer != null)
+ {
+ NoteCombatEncounter(killer, victim, provenance);
+ }
+ }
+
+ public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat")
+ {
+ if (a == null || b == null)
+ {
+ return;
+ }
+
+ long idA = EventFeedUtil.SafeId(a);
+ long idB = EventFeedUtil.SafeId(b);
+ if (idA == 0 || idB == 0 || idA == idB)
+ {
+ return;
+ }
+
+ string pair = PairKey(idA, idB);
+ if (!PairEncounters.TryGetValue(pair, out int count))
+ {
+ count = 0;
+ }
+
+ count++;
+ PairEncounters[pair] = count;
+
+ Record(
+ LifeSagaFactKind.CombatEncounter,
+ a,
+ b,
+ correlationKey: "encounter:" + pair,
+ strength: 40f + Mathf.Min(20f, count * 5f),
+ provenance: provenance,
+ note: "x" + count);
+
+ if (count >= RivalEncounterThreshold)
+ {
+ EarnRival(a, b, "repeated_encounters:" + count, provenance);
+ }
+ }
+
+ public static void EarnRival(Actor subject, Actor rival, string evidence, string provenance)
+ {
+ if (subject == null || rival == null)
+ {
+ return;
+ }
+
+ long subjectId = EventFeedUtil.SafeId(subject);
+ long rivalId = EventFeedUtil.SafeId(rival);
+ Record(
+ LifeSagaFactKind.RivalEarned,
+ subject,
+ rival,
+ correlationKey: "rival:" + PairKey(subjectId, rivalId),
+ strength: 85f,
+ provenance: provenance,
+ note: evidence ?? "");
+ }
+
+ public static void RecordWar(
+ Actor subject,
+ string warKey,
+ string kingdomKey,
+ bool ended,
+ string provenance = "war")
+ {
+ if (subject == null || string.IsNullOrEmpty(warKey))
+ {
+ return;
+ }
+
+ Record(
+ ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin,
+ subject,
+ null,
+ correlationKey: "war:" + warKey + ":" + EventFeedUtil.SafeId(subject),
+ strength: ended ? 70f : 78f,
+ provenance: provenance,
+ kingdomKey: kingdomKey,
+ warKey: warKey,
+ resolved: ended);
+ }
+
+ public static void RecordPlot(
+ Actor subject,
+ string plotKey,
+ string plotAsset,
+ bool finished,
+ string provenance = "plot")
+ {
+ if (subject == null || string.IsNullOrEmpty(plotKey))
+ {
+ return;
+ }
+
+ Record(
+ finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin,
+ subject,
+ null,
+ correlationKey: "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject),
+ strength: finished ? 72f : 76f,
+ provenance: provenance,
+ plotKey: plotKey,
+ note: plotAsset ?? "",
+ resolved: finished);
+ }
+
+ public static void RecordFounding(Actor subject, string metaKind, string metaKey, string provenance)
+ {
+ if (subject == null)
+ {
+ return;
+ }
+
+ Record(
+ LifeSagaFactKind.Founding,
+ subject,
+ null,
+ correlationKey: "found:" + (metaKind ?? "meta") + ":" + (metaKey ?? EventFeedUtil.SafeId(subject).ToString()),
+ strength: 74f,
+ provenance: provenance,
+ kingdomKey: metaKind == "kingdom" ? metaKey : "",
+ cityKey: metaKind == "city" ? metaKey : "",
+ familyKey: metaKind == "family" ? metaKey : "",
+ clanKey: metaKind == "clan" ? metaKey : "",
+ note: metaKind ?? "");
+ }
+
+ public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
+ {
+ if (subject == null || string.IsNullOrEmpty(roleId))
+ {
+ return;
+ }
+
+ Record(
+ LifeSagaFactKind.RoleChange,
+ subject,
+ null,
+ correlationKey: "role:" + EventFeedUtil.SafeId(subject) + ":" + roleId,
+ strength: 68f,
+ provenance: provenance,
+ note: roleId);
+ }
+
+ public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
+ {
+ if (subject == null)
+ {
+ return;
+ }
+
+ Record(
+ gained ? LifeSagaFactKind.HomeGained : LifeSagaFactKind.HomeLost,
+ subject,
+ null,
+ correlationKey: "home:" + EventFeedUtil.SafeId(subject) + ":" + (gained ? "gain" : "loss") + ":"
+ + Time.unscaledTime.ToString("0.###"),
+ strength: 50f,
+ provenance: provenance);
+ }
+
+ public static void RecordHardArc(
+ long unitId,
+ StoryArcKind arcKind,
+ string note,
+ long relatedId = 0,
+ string provenance = "story_planner")
+ {
+ if (unitId == 0)
+ {
+ return;
+ }
+
+ LifeSagaIdentity subject = SnapshotId(unitId);
+ LifeSagaIdentity other = relatedId != 0 ? SnapshotId(relatedId) : default;
+ LifeSagaFactKind kind = HardArcKind(arcKind);
+ RecordIds(
+ kind,
+ unitId,
+ subject,
+ relatedId,
+ other,
+ correlationKey: "arc:" + unitId + ":" + arcKind + ":" + (note ?? "").GetHashCode(),
+ strength: 60f,
+ provenance: provenance,
+ note: note ?? "");
+ }
+
+ public static LifeSagaIdentity Snapshot(Actor actor)
+ {
+ if (actor == null)
+ {
+ return default;
+ }
+
+ long id = EventFeedUtil.SafeId(actor);
+ if (id == 0)
+ {
+ return default;
+ }
+
+ string name = "";
+ string species = "";
+ try
+ {
+ name = actor.getName() ?? "";
+ }
+ catch
+ {
+ name = "";
+ }
+
+ try
+ {
+ species = actor.asset != null ? actor.asset.id : "";
+ }
+ catch
+ {
+ species = "";
+ }
+
+ return new LifeSagaIdentity
+ {
+ Id = id,
+ Name = name,
+ SpeciesId = species
+ };
+ }
+
+ public static LifeSagaIdentity SnapshotId(long unitId)
+ {
+ if (unitId == 0)
+ {
+ return default;
+ }
+
+ Actor live = EventFeedUtil.FindAliveById(unitId);
+ if (live != null)
+ {
+ return Snapshot(live);
+ }
+
+ LifeSagaLifeMemory life = Get(unitId);
+ if (life != null && life.Identity.Id != 0)
+ {
+ return life.Identity;
+ }
+
+ return new LifeSagaIdentity { Id = unitId };
+ }
+
+ public static Actor ResolveAlive(long unitId) => EventFeedUtil.FindAliveById(unitId);
+
+ public static bool TryGetEarnedRival(long unitId, out LifeSagaFact rivalFact)
+ {
+ rivalFact = null;
+ LifeSagaLifeMemory life = Get(unitId);
+ if (life == null)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < life.Facts.Count; i++)
+ {
+ LifeSagaFact f = life.Facts[i];
+ if (f != null && f.Kind == LifeSagaFactKind.RivalEarned && f.OtherId != 0)
+ {
+ rivalFact = f;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static LifeSagaFact StrongestUnresolved(long unitId)
+ {
+ LifeSagaLifeMemory life = Get(unitId);
+ if (life == null)
+ {
+ return null;
+ }
+
+ LifeSagaFact best = null;
+ for (int i = 0; i < life.Facts.Count; i++)
+ {
+ LifeSagaFact f = life.Facts[i];
+ if (f == null || f.Resolved)
+ {
+ continue;
+ }
+
+ if (!IsStakeWorthy(f.Kind))
+ {
+ continue;
+ }
+
+ if (best == null || f.Strength > best.Strength
+ || (Mathf.Approximately(f.Strength, best.Strength) && f.At > best.At))
+ {
+ best = f;
+ }
+ }
+
+ return best;
+ }
+
+ public static List SelectLegacy(long unitId, int max = 5)
+ {
+ var result = new List(max);
+ LifeSagaLifeMemory life = Get(unitId);
+ if (life == null || max <= 0)
+ {
+ return result;
+ }
+
+ var usedBuckets = new HashSet();
+ // Prefer diversity across turning-point buckets.
+ for (int pass = 0; pass < 2 && result.Count < max; pass++)
+ {
+ for (int i = 0; i < life.Facts.Count && result.Count < max; i++)
+ {
+ LifeSagaFact f = life.Facts[i];
+ if (f == null || !IsLegacyWorthy(f.Kind))
+ {
+ continue;
+ }
+
+ int bucket = LegacyBucket(f.Kind);
+ if (pass == 0 && usedBuckets.Contains(bucket))
+ {
+ continue;
+ }
+
+ if (ContainsFact(result, f))
+ {
+ continue;
+ }
+
+ result.Add(f);
+ usedBuckets.Add(bucket);
+ }
+ }
+
+ return result;
+ }
+
+ public static List ChaptersFor(long unitId, int max = 3)
+ {
+ var chapters = new List(max);
+ List legacy = SelectLegacy(unitId, max);
+ for (int i = 0; i < legacy.Count; i++)
+ {
+ LifeSagaFact f = legacy[i];
+ chapters.Add(new LifeSagaChapter
+ {
+ Kind = ToChapterKind(f.Kind),
+ Line = FormatFactLine(f),
+ At = f.At
+ });
+ }
+
+ 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:
+ 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:
+ return string.IsNullOrEmpty(other) ? "A child was born" : "Child " + other;
+ case LifeSagaFactKind.Kill:
+ return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
+ case LifeSagaFactKind.Death:
+ return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
+ case LifeSagaFactKind.RivalEarned:
+ return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
+ case LifeSagaFactKind.WarJoin:
+ return "Entered a war";
+ case LifeSagaFactKind.WarEnd:
+ return "War ended";
+ case LifeSagaFactKind.PlotJoin:
+ return "Joined a plot";
+ case LifeSagaFactKind.PlotEnd:
+ return "Plot resolved";
+ 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");
+ }
+ }
+
+ private static void TryMarkKinKillRival(Actor killer, Actor victim)
+ {
+ if (killer == null || victim == null)
+ {
+ return;
+ }
+
+ long killerId = EventFeedUtil.SafeId(killer);
+ // If victim is close kin of someone on roster/memory, or killer killed kin of a remembered bond.
+ LifeSagaLifeMemory killerLife = Get(killerId);
+ if (killerLife == null)
+ {
+ return;
+ }
+
+ long victimId = EventFeedUtil.SafeId(victim);
+ for (int i = 0; i < killerLife.Facts.Count; i++)
+ {
+ LifeSagaFact f = killerLife.Facts[i];
+ if (f == null || f.OtherId == 0)
+ {
+ continue;
+ }
+
+ if (f.Kind == LifeSagaFactKind.BondFormed
+ || f.Kind == LifeSagaFactKind.FriendFormed
+ || f.Kind == LifeSagaFactKind.ParentChild)
+ {
+ // Victim killed someone close to killer? Already handled as Kill.
+ // Killer slew kin of a bonded other: check victim parents/children against bonded id.
+ if (IsCloseKin(victim, f.OtherId))
+ {
+ Actor rival = EventFeedUtil.FindAliveById(f.OtherId);
+ if (rival != null)
+ {
+ EarnRival(killer, rival, "killed_kin", "kin_kill");
+ EarnRival(rival, killer, "kin_slain", "kin_kill");
+ }
+ }
+ }
+ }
+
+ _ = victimId;
+ }
+
+ private static bool IsCloseKin(Actor actor, long relativeId)
+ {
+ if (actor == null || relativeId == 0)
+ {
+ return false;
+ }
+
+ try
+ {
+ foreach (Actor p in ActorRelation.EnumerateParents(actor, 2))
+ {
+ if (EventFeedUtil.SafeId(p) == relativeId)
+ {
+ return true;
+ }
+ }
+
+ foreach (Actor c in ActorRelation.EnumerateChildren(actor, 8))
+ {
+ if (EventFeedUtil.SafeId(c) == relativeId)
+ {
+ return true;
+ }
+ }
+
+ Actor lover = ActorRelation.GetLover(actor);
+ if (lover != null && EventFeedUtil.SafeId(lover) == relativeId)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return false;
+ }
+
+ private static void MirrorToOther(LifeSagaFact fact, long otherId)
+ {
+ if (fact == null || otherId == 0)
+ {
+ return;
+ }
+
+ LifeSagaLifeMemory life = GetOrCreate(otherId);
+ if (life == null)
+ {
+ return;
+ }
+
+ string mirrorKey = fact.Key + ":mirror";
+ if (FindByKey(life, mirrorKey) != null)
+ {
+ return;
+ }
+
+ LifeSagaFactKind mirrorKind = fact.Kind;
+ if (fact.Kind == LifeSagaFactKind.Kill)
+ {
+ mirrorKind = LifeSagaFactKind.Death;
+ }
+ else if (fact.Kind == LifeSagaFactKind.Death)
+ {
+ mirrorKind = LifeSagaFactKind.Kill;
+ }
+
+ var mirror = new LifeSagaFact
+ {
+ Key = mirrorKey,
+ Kind = mirrorKind,
+ SubjectId = otherId,
+ OtherId = fact.SubjectId,
+ Subject = fact.Other.Id != 0 ? fact.Other : SnapshotId(otherId),
+ Other = fact.Subject,
+ At = fact.At,
+ WorldTime = fact.WorldTime,
+ DateLabel = fact.DateLabel,
+ Strength = fact.Strength,
+ Provenance = fact.Provenance,
+ KingdomKey = fact.KingdomKey,
+ CityKey = fact.CityKey,
+ ClanKey = fact.ClanKey,
+ FamilyKey = fact.FamilyKey,
+ WarKey = fact.WarKey,
+ PlotKey = fact.PlotKey,
+ Resolved = fact.Resolved,
+ Note = fact.Note
+ };
+ life.Facts.Insert(0, mirror);
+ life.TouchedAt = fact.At;
+ while (life.Facts.Count > MaxFactsPerLife)
+ {
+ life.Facts.RemoveAt(life.Facts.Count - 1);
+ }
+ }
+
+ private static LifeSagaFact FindByKey(LifeSagaLifeMemory life, string key)
+ {
+ if (life == null || string.IsNullOrEmpty(key))
+ {
+ return null;
+ }
+
+ for (int i = 0; i < life.Facts.Count; i++)
+ {
+ if (life.Facts[i] != null
+ && string.Equals(life.Facts[i].Key, key, StringComparison.Ordinal))
+ {
+ return life.Facts[i];
+ }
+ }
+
+ return null;
+ }
+
+ private static void PruneIfNeeded()
+ {
+ if (Lives.Count < MaxLives)
+ {
+ return;
+ }
+
+ var ranked = new List(Lives.Count);
+ foreach (KeyValuePair kv in Lives)
+ {
+ ranked.Add(kv.Value);
+ }
+
+ ranked.Sort((a, b) =>
+ {
+ int ap = ProtectScore(a);
+ int bp = ProtectScore(b);
+ int cmp = ap.CompareTo(bp);
+ if (cmp != 0)
+ {
+ return cmp;
+ }
+
+ return a.TouchedAt.CompareTo(b.TouchedAt);
+ });
+
+ int drop = Mathf.Max(1, Lives.Count - MaxLives + 8);
+ for (int i = 0; i < ranked.Count && drop > 0; i++)
+ {
+ LifeSagaLifeMemory life = ranked[i];
+ if (ProtectScore(life) >= 2)
+ {
+ continue;
+ }
+
+ Lives.Remove(life.UnitId);
+ drop--;
+ }
+ }
+
+ private static int ProtectScore(LifeSagaLifeMemory life)
+ {
+ if (life == null)
+ {
+ return 0;
+ }
+
+ if (LifeSagaRoster.IsMc(life.UnitId))
+ {
+ return 3;
+ }
+
+ LifeSagaSlot slot = LifeSagaRoster.Get(life.UnitId);
+ if (slot != null && (slot.Prefer || slot.GameFavorite))
+ {
+ return 3;
+ }
+
+ for (int i = 0; i < life.Facts.Count; i++)
+ {
+ if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i].Kind))
+ {
+ return 2;
+ }
+ }
+
+ return 1;
+ }
+
+ private static string DefaultKey(
+ LifeSagaFactKind kind,
+ long subjectId,
+ long otherId,
+ string warKey,
+ string plotKey)
+ {
+ return kind + ":" + subjectId + ":" + otherId + ":" + (warKey ?? "") + ":" + (plotKey ?? "");
+ }
+
+ private static string PairKey(long a, long b)
+ {
+ return a <= b ? a + ":" + b : b + ":" + a;
+ }
+
+ private static bool IsStakeWorthy(LifeSagaFactKind kind)
+ {
+ switch (kind)
+ {
+ case LifeSagaFactKind.WarJoin:
+ case LifeSagaFactKind.PlotJoin:
+ case LifeSagaFactKind.RivalEarned:
+ case LifeSagaFactKind.BondBroken:
+ case LifeSagaFactKind.HardArcWar:
+ case LifeSagaFactKind.HardArcPlot:
+ case LifeSagaFactKind.HardArcGrief:
+ case LifeSagaFactKind.RoleChange:
+ case LifeSagaFactKind.Founding:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsLegacyWorthy(LifeSagaFactKind kind)
+ {
+ switch (kind)
+ {
+ case LifeSagaFactKind.CombatEncounter:
+ return false;
+ default:
+ return kind != LifeSagaFactKind.Unknown;
+ }
+ }
+
+ private static int LegacyBucket(LifeSagaFactKind kind)
+ {
+ switch (kind)
+ {
+ case LifeSagaFactKind.Founding:
+ case LifeSagaFactKind.HomeGained:
+ return 0;
+ case LifeSagaFactKind.BondFormed:
+ case LifeSagaFactKind.FriendFormed:
+ case LifeSagaFactKind.ParentChild:
+ case LifeSagaFactKind.HardArcLove:
+ return 1;
+ case LifeSagaFactKind.RoleChange:
+ return 2;
+ case LifeSagaFactKind.Kill:
+ case LifeSagaFactKind.CombatEncounter:
+ case LifeSagaFactKind.RivalEarned:
+ case LifeSagaFactKind.HardArcCombat:
+ case LifeSagaFactKind.WarJoin:
+ case LifeSagaFactKind.WarEnd:
+ case LifeSagaFactKind.HardArcWar:
+ case LifeSagaFactKind.PlotJoin:
+ case LifeSagaFactKind.PlotEnd:
+ case LifeSagaFactKind.HardArcPlot:
+ return 3;
+ case LifeSagaFactKind.BondBroken:
+ case LifeSagaFactKind.HomeLost:
+ case LifeSagaFactKind.Death:
+ case LifeSagaFactKind.HardArcGrief:
+ return 4;
+ default:
+ return 5;
+ }
+ }
+
+ private static bool ContainsFact(List list, LifeSagaFact fact)
+ {
+ for (int i = 0; i < list.Count; i++)
+ {
+ if (list[i] != null && list[i].Key == fact.Key)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static LifeSagaFactKind HardArcKind(StoryArcKind arcKind)
+ {
+ switch (arcKind)
+ {
+ case StoryArcKind.Love:
+ return LifeSagaFactKind.HardArcLove;
+ case StoryArcKind.Grief:
+ return LifeSagaFactKind.HardArcGrief;
+ case StoryArcKind.CombatDuel:
+ case StoryArcKind.CombatMass:
+ return LifeSagaFactKind.HardArcCombat;
+ case StoryArcKind.WarFront:
+ return LifeSagaFactKind.HardArcWar;
+ case StoryArcKind.Plot:
+ return LifeSagaFactKind.HardArcPlot;
+ default:
+ return LifeSagaFactKind.Unknown;
+ }
+ }
+
+ private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
+ {
+ switch (kind)
+ {
+ case LifeSagaFactKind.Kill:
+ case LifeSagaFactKind.CombatEncounter:
+ case LifeSagaFactKind.RivalEarned:
+ case LifeSagaFactKind.HardArcCombat:
+ return LifeSagaChapterKind.Combat;
+ case LifeSagaFactKind.WarJoin:
+ case LifeSagaFactKind.WarEnd:
+ case LifeSagaFactKind.HardArcWar:
+ return LifeSagaChapterKind.War;
+ case LifeSagaFactKind.PlotJoin:
+ case LifeSagaFactKind.PlotEnd:
+ case LifeSagaFactKind.HardArcPlot:
+ return LifeSagaChapterKind.Plot;
+ case LifeSagaFactKind.BondFormed:
+ case LifeSagaFactKind.HardArcLove:
+ return LifeSagaChapterKind.Love;
+ case LifeSagaFactKind.BondBroken:
+ case LifeSagaFactKind.Death:
+ case LifeSagaFactKind.HardArcGrief:
+ return LifeSagaChapterKind.Grief;
+ default:
+ return LifeSagaChapterKind.Unknown;
+ }
+ }
+
+ private static string RoleNote(string roleId)
+ {
+ if (string.IsNullOrEmpty(roleId))
+ {
+ return "Rose in standing";
+ }
+
+ if (roleId.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return "Became king";
+ }
+
+ if (roleId.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return "Became a leader";
+ }
+
+ if (roleId.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return "Became alpha";
+ }
+
+ return "Role: " + roleId;
+ }
+
+ private static string FirstNonEmpty(params string[] values)
+ {
+ if (values == null)
+ {
+ return "";
+ }
+
+ for (int i = 0; i < values.Length; i++)
+ {
+ if (!string.IsNullOrEmpty(values[i]))
+ {
+ return values[i];
+ }
+ }
+
+ return "";
+ }
+
+ private static void StampWorldDate(out double worldTime, out string dateLabel)
+ {
+ worldTime = 0;
+ dateLabel = "";
+ try
+ {
+ if (World.world == null)
+ {
+ return;
+ }
+
+ worldTime = World.world.getCurWorldTime();
+ int[] raw = Date.getRawDate(worldTime);
+ if (raw != null && raw.Length >= 3)
+ {
+ string monthName = Date.formatMonth(raw[1]);
+ if (string.IsNullOrEmpty(monthName))
+ {
+ monthName = "m" + raw[1];
+ }
+
+ dateLabel = monthName + " " + raw[2];
+ }
+ }
+ catch
+ {
+ // keep empty
+ }
+ }
+}
+
+public enum LifeSagaFactKind
+{
+ Unknown,
+ BondFormed,
+ BondBroken,
+ FriendFormed,
+ ParentChild,
+ Kill,
+ Death,
+ CombatEncounter,
+ RivalEarned,
+ WarJoin,
+ WarEnd,
+ PlotJoin,
+ PlotEnd,
+ Founding,
+ RoleChange,
+ HomeGained,
+ HomeLost,
+ HardArcLove,
+ HardArcGrief,
+ HardArcCombat,
+ HardArcWar,
+ HardArcPlot
+}
+
+public struct LifeSagaIdentity
+{
+ public long Id;
+ public string Name;
+ public string SpeciesId;
+}
+
+public sealed class LifeSagaFact
+{
+ public string Key = "";
+ public LifeSagaFactKind Kind;
+ public long SubjectId;
+ public long OtherId;
+ public LifeSagaIdentity Subject;
+ public LifeSagaIdentity Other;
+ public float At;
+ public double WorldTime;
+ public string DateLabel = "";
+ public float Strength;
+ public string Provenance = "";
+ public string KingdomKey = "";
+ public string CityKey = "";
+ public string ClanKey = "";
+ public string FamilyKey = "";
+ public string WarKey = "";
+ public string PlotKey = "";
+ public bool Resolved;
+ public string Note = "";
+}
+
+public sealed class LifeSagaLifeMemory
+{
+ public long UnitId;
+ public LifeSagaIdentity Identity;
+ public float TouchedAt;
+ public readonly List Facts = new List(16);
+}
diff --git a/IdleSpectator/Story/LifeSagaOverview.cs b/IdleSpectator/Story/LifeSagaOverview.cs
index 09aff77..1d65ef5 100644
--- a/IdleSpectator/Story/LifeSagaOverview.cs
+++ b/IdleSpectator/Story/LifeSagaOverview.cs
@@ -1,504 +1,135 @@
-using System;
using System.Collections.Generic;
-using System.Text;
-using UnityEngine;
namespace IdleSpectator;
///
-/// Multi-line saga hover card: Title / Why / Circle / Chapters.
-/// No trait descs, no Chronicle dump, no planner jargon.
+/// Compatibility façade over .
+/// Prefer for new UI.
///
public static class LifeSagaOverview
{
- private const int EpithetMinScore = 80;
+ public static LifeSagaSnapshot BuildSnapshot(LifeSagaSlot slot)
+ {
+ var snapshot = new LifeSagaSnapshot();
+ if (slot == null || slot.UnitId == 0)
+ {
+ return snapshot;
+ }
+
+ LifeSagaViewModel model = LifeSagaPresentation.Build(slot.UnitId);
+ snapshot.UnitId = model.UnitId;
+ snapshot.Header = model.Name;
+ snapshot.Subtitle = model.Title;
+ if (!string.IsNullOrEmpty(model.StakeLine))
+ {
+ snapshot.Rows.Add(new LifeSagaSnapshotRow
+ {
+ Kind = LifeSagaSnapshotRowKind.Headline,
+ Text = model.StakeLine
+ });
+ }
+
+ if (model.Cast.Count > 0)
+ {
+ var parts = new List(model.Cast.Count);
+ for (int i = 0; i < model.Cast.Count; i++)
+ {
+ parts.Add(model.Cast[i].Relation + " " + model.Cast[i].Name);
+ }
+
+ snapshot.Rows.Add(new LifeSagaSnapshotRow
+ {
+ Kind = LifeSagaSnapshotRowKind.Circle,
+ Text = string.Join(" · ", parts)
+ });
+ }
+
+ if (!string.IsNullOrEmpty(model.EvidenceLine))
+ {
+ snapshot.Rows.Add(new LifeSagaSnapshotRow
+ {
+ Kind = LifeSagaSnapshotRowKind.Current,
+ Text = model.EvidenceTitle + ": " + model.EvidenceLine
+ });
+ }
+
+ for (int i = 0; i < model.Legacy.Count; i++)
+ {
+ snapshot.Rows.Add(new LifeSagaSnapshotRow
+ {
+ Kind = LifeSagaSnapshotRowKind.Chapter,
+ ChapterKind = ToChapterKind(model.Legacy[i].Kind),
+ Text = model.Legacy[i].Line
+ });
+ }
+
+ return snapshot;
+ }
public static string Build(LifeSagaSlot slot)
{
- if (slot == null || slot.UnitId == 0)
- {
- return "";
- }
-
- Actor actor = slot.Resolve();
- var sb = new StringBuilder(256);
- string title = BuildTitle(slot, actor);
- if (!string.IsNullOrEmpty(title))
- {
- sb.Append(title);
- }
-
- string why = BuildWhy(slot, actor);
- AppendLine(sb, why);
-
- string circle = BuildCircle(actor);
- AppendLine(sb, circle);
-
- string chapters = BuildChapters(slot);
- AppendLine(sb, chapters);
-
- return sb.ToString().TrimEnd();
- }
-
- private static void AppendLine(StringBuilder sb, string line)
- {
- if (string.IsNullOrEmpty(line))
- {
- return;
- }
-
- if (sb.Length > 0)
- {
- sb.Append('\n');
- }
-
- sb.Append(line);
- }
-
- private static string BuildTitle(LifeSagaSlot slot, Actor actor)
- {
- string name = !string.IsNullOrEmpty(slot.DisplayName)
- ? slot.DisplayName
- : "Someone";
- string epithet = TryEpithetName(actor);
- string role = BuildRole(slot, actor);
- bool starred = slot.Prefer || slot.GameFavorite;
-
- var sb = new StringBuilder(64);
- if (starred)
- {
- sb.Append("★ ");
- }
-
- sb.Append(name);
- if (!string.IsNullOrEmpty(epithet))
- {
- sb.Append(" the ").Append(epithet);
- }
-
- if (!string.IsNullOrEmpty(role))
- {
- sb.Append(" · ").Append(role);
- }
-
- return sb.ToString();
- }
-
- private static string BuildRole(LifeSagaSlot slot, Actor actor)
- {
- if (slot.IsKing)
- {
- string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
- return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
- }
-
- if (slot.IsAlpha)
- {
- return "Alpha of the pack";
- }
-
- if (slot.IsLeader)
- {
- string city = FirstNonEmpty(slot.CityKey, CityName(actor));
- return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
- }
-
- string species = SpeciesLabel(slot, actor);
- string kingdomFallback = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
- if (!string.IsNullOrEmpty(species) && !string.IsNullOrEmpty(kingdomFallback))
- {
- return species + " of " + kingdomFallback;
- }
-
- return FirstNonEmpty(species, kingdomFallback);
- }
-
- private static string BuildWhy(LifeSagaSlot slot, Actor actor)
- {
- // Title already carries role / favorite star - Why adds standing/event only.
- var parts = new List(3);
- if (slot.Kills >= 10)
- {
- parts.Add(slot.Kills + " kills");
- }
-
- if (slot.Renown >= 80f)
- {
- parts.Add("renown " + Mathf.RoundToInt(slot.Renown));
- }
-
- if (IsAtWar(actor))
- {
- string side = FirstNonEmpty(slot.KingdomKey, KingdomName(actor), SpeciesLabel(slot, actor));
- if (!string.IsNullOrEmpty(side))
- {
- parts.Add("leading " + side + " in war");
- }
- else
- {
- parts.Add("at war");
- }
- }
- else if (slot.IsKing && parts.Count == 0)
- {
- // Newly crowned with no kill/war signal - omit rather than reprint King.
- }
-
- if (parts.Count == 0)
- {
- return "";
- }
-
- return string.Join(" · ", parts);
- }
-
- private static string BuildCircle(Actor actor)
- {
- if (actor == null)
- {
- return "";
- }
-
- var parts = new List(3);
- Actor lover = ActorRelation.GetLover(actor);
- if (lover != null)
- {
- parts.Add("Lover " + SafeName(lover));
- }
-
- Actor friend = ActorRelation.GetBestFriend(actor);
- if (friend != null && friend != lover)
- {
- parts.Add("Friend " + SafeName(friend));
- }
-
- foreach (Actor child in ActorRelation.EnumerateChildren(actor, max: 1))
- {
- if (child != null && child != lover && child != friend)
- {
- parts.Add("Child " + SafeName(child));
- break;
- }
- }
-
- if (parts.Count == 0)
- {
- return "";
- }
-
- return string.Join(" · ", parts);
- }
-
- private static string BuildChapters(LifeSagaSlot slot)
- {
- if (slot.Chapters == null || slot.Chapters.Count == 0)
- {
- return "";
- }
-
- var sb = new StringBuilder(128);
- int n = Mathf.Min(LifeSagaRoster.MaxChapters, slot.Chapters.Count);
- for (int i = 0; i < n; i++)
- {
- string line = slot.Chapters[i];
- if (string.IsNullOrEmpty(line))
- {
- continue;
- }
-
- if (sb.Length > 0)
- {
- sb.Append('\n');
- }
-
- sb.Append("· ").Append(line);
- }
-
- return sb.ToString();
+ return LifeSagaPresentation.BuildPlainText(slot);
}
public static float StandoutTraitScore(Actor actor)
{
- ActorTrait top = PickTopTrait(actor, out int score);
- if (top == null)
- {
- return 0f;
- }
-
- return Mathf.Max(0, score);
+ return UnitDossier.ReadStandoutTraitNames(actor, 1).Count > 0 ? 100f : 0f;
}
- private static string TryEpithetName(Actor actor)
+ private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
{
- ActorTrait top = PickTopTrait(actor, out int score);
- if (top == null || score < EpithetMinScore)
+ switch (kind)
{
- return "";
+ case LifeSagaFactKind.Kill:
+ case LifeSagaFactKind.RivalEarned:
+ case LifeSagaFactKind.HardArcCombat:
+ return LifeSagaChapterKind.Combat;
+ case LifeSagaFactKind.WarJoin:
+ case LifeSagaFactKind.WarEnd:
+ case LifeSagaFactKind.HardArcWar:
+ return LifeSagaChapterKind.War;
+ case LifeSagaFactKind.PlotJoin:
+ case LifeSagaFactKind.PlotEnd:
+ case LifeSagaFactKind.HardArcPlot:
+ return LifeSagaChapterKind.Plot;
+ case LifeSagaFactKind.BondFormed:
+ case LifeSagaFactKind.HardArcLove:
+ return LifeSagaChapterKind.Love;
+ case LifeSagaFactKind.BondBroken:
+ case LifeSagaFactKind.Death:
+ case LifeSagaFactKind.HardArcGrief:
+ return LifeSagaChapterKind.Grief;
+ default:
+ return LifeSagaChapterKind.Unknown;
}
-
- return TraitDisplayName(top);
- }
-
- private static ActorTrait PickTopTrait(Actor actor, out int bestScore)
- {
- bestScore = int.MinValue;
- if (actor == null)
- {
- return null;
- }
-
- try
- {
- if (!actor.hasTraits())
- {
- return null;
- }
-
- ActorTrait best = null;
- foreach (ActorTrait trait in actor.getTraits())
- {
- if (trait == null || string.IsNullOrEmpty(trait.id))
- {
- continue;
- }
-
- if (IsDefaultTraitForActor(trait, actor))
- {
- continue;
- }
-
- int score = TraitInterestScore(trait, actor);
- if (score > bestScore)
- {
- bestScore = score;
- best = trait;
- }
- }
-
- return best;
- }
- catch
- {
- return null;
- }
- }
-
- private static int TraitInterestScore(ActorTrait trait, Actor actor)
- {
- if (trait == null)
- {
- return int.MinValue;
- }
-
- int score = 0;
- try
- {
- score += (int)trait.rarity * 100;
- }
- catch
- {
- // ignore
- }
-
- try
- {
- if (trait.type == TraitType.Negative)
- {
- score += 25;
- }
- else if (trait.type == TraitType.Positive)
- {
- score += 15;
- }
- }
- catch
- {
- // ignore
- }
-
- if (!IsDefaultTraitForActor(trait, actor))
- {
- score += 50;
- }
- else
- {
- score -= 40;
- }
-
- try
- {
- if (trait.rate_birth > 0 && trait.rate_birth <= 5)
- {
- score += 20;
- }
- else if (trait.rate_birth >= 40)
- {
- score -= 15;
- }
- }
- catch
- {
- // ignore
- }
-
- string id = trait.id ?? "";
- if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0
- || id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0)
- {
- score += 35;
- }
-
- return score;
- }
-
- private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor)
- {
- try
- {
- if (trait == null || actor == null || actor.asset == null
- || trait.default_for_actor_assets == null)
- {
- return false;
- }
-
- return trait.default_for_actor_assets.Contains(actor.asset);
- }
- catch
- {
- return false;
- }
- }
-
- private static string TraitDisplayName(ActorTrait trait)
- {
- try
- {
- string locale = trait.getLocaleID();
- if (!string.IsNullOrEmpty(locale))
- {
- string localized = LocalizedTextManager.getText(locale);
- if (!string.IsNullOrEmpty(localized) && localized != locale)
- {
- return localized;
- }
- }
- }
- catch
- {
- // fall through
- }
-
- string id = trait.id ?? "";
- if (id.Length == 0)
- {
- return "";
- }
-
- return char.ToUpperInvariant(id[0]) + id.Substring(1).Replace('_', ' ');
- }
-
- private static bool IsAtWar(Actor actor)
- {
- if (actor == null)
- {
- return false;
- }
-
- try
- {
- Kingdom k = actor.kingdom;
- if (k == null)
- {
- return false;
- }
-
- return k.hasEnemies();
- }
- catch
- {
- return false;
- }
- }
-
- private static string SpeciesLabel(LifeSagaSlot slot, Actor actor)
- {
- string id = FirstNonEmpty(slot?.SpeciesId, actor?.asset != null ? actor.asset.id : "");
- if (string.IsNullOrEmpty(id))
- {
- return "";
- }
-
- if (id.Length == 1)
- {
- return id.ToUpperInvariant();
- }
-
- return char.ToUpperInvariant(id[0]) + id.Substring(1);
- }
-
- private static string KingdomName(Actor actor)
- {
- try
- {
- return actor?.kingdom != null ? (actor.kingdom.name ?? "") : "";
- }
- catch
- {
- return "";
- }
- }
-
- private static string CityName(Actor actor)
- {
- try
- {
- return actor?.city != null ? (actor.city.name ?? "") : "";
- }
- catch
- {
- return "";
- }
- }
-
- private static string SafeName(Actor actor)
- {
- try
- {
- string n = actor.getName();
- if (!string.IsNullOrEmpty(n))
- {
- return n;
- }
- }
- catch
- {
- // ignore
- }
-
- return actor?.asset != null ? actor.asset.id : "Someone";
- }
-
- private static string FirstNonEmpty(params string[] values)
- {
- if (values == null)
- {
- return "";
- }
-
- for (int i = 0; i < values.Length; i++)
- {
- if (!string.IsNullOrEmpty(values[i]))
- {
- return values[i];
- }
- }
-
- return "";
+ }
+}
+
+public enum LifeSagaSnapshotRowKind
+{
+ Headline,
+ Current,
+ Circle,
+ Qualities,
+ Chapter
+}
+
+public sealed class LifeSagaSnapshotRow
+{
+ public LifeSagaSnapshotRowKind Kind;
+ public LifeSagaChapterKind ChapterKind;
+ public string Text = "";
+}
+
+public sealed class LifeSagaSnapshot
+{
+ public long UnitId;
+ public string Header = "";
+ public string Subtitle = "";
+ public readonly List Rows = new List();
+
+ public string ToPlainText()
+ {
+ return LifeSagaPresentation.BuildPlainText(UnitId);
}
}
diff --git a/IdleSpectator/Story/LifeSagaPanel.cs b/IdleSpectator/Story/LifeSagaPanel.cs
new file mode 100644
index 0000000..cae53f9
--- /dev/null
+++ b/IdleSpectator/Story/LifeSagaPanel.cs
@@ -0,0 +1,757 @@
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.Events;
+using UnityEngine.UI;
+
+namespace IdleSpectator;
+
+///
+/// Saga body matching Design A (cast + stakes): identity, stake, cast, evidence, legacy, Open Lore.
+/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail.
+/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
+///
+public static class LifeSagaPanel
+{
+ /// Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).
+ public const float BodyWidthFixed = 232f;
+ /// Hard cap so Saga never eats the screen.
+ public const float BodyHeightMax = 148f;
+
+ public const float BodyWidthMin = BodyWidthFixed;
+ public const float BodyWidthMax = BodyWidthFixed;
+ public const float BodyHeightMin = 56f;
+ public const float BodyHeightFixed = BodyHeightMax;
+
+ 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 LoreH = 11f;
+ private const float SecLabelH = 10f;
+ private const float LineH = 10f;
+ 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 = 7;
+
+ private static GameObject _root;
+ private static RectTransform _rootRt;
+ private static Text _nameText;
+ private static Text _titleText;
+ private static Text _stakeText;
+ private static Text _castHead;
+ private static Text _evidenceHead;
+ private static Text _evidenceText;
+ private static Text _legacyHead;
+ private static Text _legacyText;
+ private static Button _loreBtn;
+ private static Text _loreBtnLabel;
+ private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
+ 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;
+
+ private sealed class CastSlot
+ {
+ public GameObject Root;
+ public Image FaceBg;
+ public Image Face;
+ public Text Name;
+ public Text Relation;
+ public long UnitId;
+ }
+
+ public static bool Visible => _root != null && _root.activeSelf;
+ public static float BodyHeight => _laidOutHeight;
+ public static float BodyWidthPx => BodyWidthFixed;
+ public static long BoundUnitId => _boundId;
+ public static bool OwnsAvatar => _ownsAvatar && Visible;
+ public static string LastLayoutDebug { get; private set; } = "";
+
+ public static void EnsureBuilt(Transform parent)
+ {
+ if (parent == null)
+ {
+ return;
+ }
+
+ if (_root != null && _builtVersion == BuildVersion)
+ {
+ return;
+ }
+
+ if (_root != null)
+ {
+ Object.Destroy(_root);
+ _root = null;
+ _rootRt = null;
+ _nameText = null;
+ _titleText = null;
+ _stakeText = null;
+ _castHead = null;
+ _evidenceHead = null;
+ _evidenceText = null;
+ _legacyHead = null;
+ _legacyText = null;
+ _loreBtn = null;
+ _loreBtnLabel = null;
+ }
+
+ _root = new GameObject("LifeSagaPanel", typeof(RectTransform));
+ _root.transform.SetParent(parent, false);
+ _rootRt = _root.GetComponent();
+ _rootRt.pivot = new Vector2(0f, 1f);
+ _rootRt.anchorMin = new Vector2(0f, 1f);
+ _rootRt.anchorMax = new Vector2(0f, 1f);
+ _rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
+
+ _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";
+ _evidenceHead = MakeLabel(_root.transform, "EvidenceHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
+ _evidenceHead.text = "Evidence";
+ _evidenceText = MakeLabel(_root.transform, "EvidenceBody", BodyFont, HudTheme.Body, FontStyle.Normal);
+ _evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
+ _evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
+ _legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
+ _legacyHead.text = "Legacy";
+ _legacyText = MakeLabel(_root.transform, "LegacyBody", BodyFont, HudTheme.Body, FontStyle.Normal);
+ _legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
+ _legacyText.verticalOverflow = VerticalWrapMode.Overflow;
+
+ for (int i = 0; i < MaxCast; i++)
+ {
+ CastSlots[i] = BuildCast(i);
+ }
+
+ GameObject loreGo = new GameObject("SagaLoreBtn", typeof(RectTransform), typeof(Image), typeof(Button));
+ loreGo.transform.SetParent(_root.transform, false);
+ Image loreBg = loreGo.GetComponent();
+ loreBg.color = new Color(0f, 0f, 0f, 0f);
+ loreBg.raycastTarget = true;
+ _loreBtn = loreGo.GetComponent
public static class WatchCaption
{
- private const float PanelWidthMax = 420f;
- private const float SpeciesSize = 16f;
- private const float SexSize = 14f;
- private const float LiveMax = 44f;
- private const float ChipIcon = 12f;
- /// Fixed nametag task label slot so text swaps do not autofit-tick the header.
- private const float TaskLabelW = 78f;
- private const float TraitIcon = 12f;
- private const float HistoryIcon = 10f;
+ ///
+ /// Locked outer width whenever tabs/rail are shown so Dossier↔Saga never slides
+ /// right-anchored glyphs out from under the hover cursor.
+ ///
+ private const float PanelWidthMax = 240f;
+ private const float SpeciesSize = 14f;
+ private const float SexSize = 12f;
+ private const float LiveMax = 40f;
+ private const float ChipIcon = 11f;
+ /// Max nametag task label slot; PlaceHeader may shrink further to clear sex/favorite.
+ private const float TaskLabelW = 72f;
+ private const float TraitIcon = 11f;
+ private const float HistoryIcon = 9f;
private const float HistoryColMinW = 118f;
- private const float NameMinW = 28f;
+ private const float NameMinW = 24f;
/// Hard cap so long "Name (Species/Job)" lines cannot paint under the level chip.
- private const float NameMaxW = 230f;
+ private const float NameMaxW = 160f;
private const float PadX = 4f;
/// Inset from the canvas top-right corner (grows left via pivot).
private const float ScreenInset = 12f;
- private const float PadTop = 4f;
- private const float HeaderH = 18f;
- private const float BodyH = 44f;
- private const float TraitsH = 14f;
- private const float StatusesH = 14f;
- private const float ReasonH = 14f;
- private const float StorySpineH = 12f;
- private const float HistoryLineMinH = 13f;
+ private const float PadTop = 3f;
+ private const float HeaderH = 15f;
+ private const float BodyH = 40f;
+ private const float TraitsH = 13f;
+ private const float StatusesH = 13f;
+ private const float ReasonH = 13f;
+ private const float StorySpineH = 11f;
+ private const float TabH = 14f;
+ private const float HistoryLineMinH = 12f;
private const float HistoryLineMaxH = 100f;
- private const float Gap = 2f;
+ private const float Gap = 1f;
private const int HistoryPeekMax = 3;
- private const float BtnSize = 16f;
+ private const float BtnSize = 14f;
private const float StatusBannerSeconds = 5f;
/// History column grows with content until the dossier hits .
private static float HistoryColMaxW =>
Mathf.Max(HistoryColMinW, PanelWidthMax - PadX * 2f - LiveMax - 4f);
+ /// Inner width available to tabs/dossier/saga body.
+ private static float BodyColumnInnerWidth() =>
+ Mathf.Max(120f, PanelWidthMax - PadX * 2f);
+
private static float _panelWidth = LiveMax + PadX * 2f;
private static readonly Color NameColor = HudTheme.Name;
@@ -66,6 +75,13 @@ public static class WatchCaption
private static Text _reasonText;
private static Text _storySpineText;
private static long _reasonOtherId;
+ private static Button _tabDossierBtn;
+ private static Button _tabSagaBtn;
+ private static Text _tabDossierLabel;
+ private static Text _tabSagaLabel;
+ private static Image _tabDossierBg;
+ private static Image _tabSagaBg;
+ private static GameObject _tabsRow;
private static Image _levelIcon;
private static Text _levelValue;
@@ -414,6 +430,8 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = "";
LastStorySpine = "";
+ LifeSagaViewController.Clear();
+ LifeSagaPanel.Clear();
LifeSagaRail.Clear();
LastHistoryPreview = "";
LastHistoryJoined = "";
@@ -666,14 +684,23 @@ public static class WatchCaption
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
// must not leave the nametag on the previous person for a frame.
- ReconcileDossierToFocus();
- RefreshLivePortrait();
- RefreshLiveTask();
- RefreshLiveIdentity();
- RefreshLiveStatuses();
- RefreshOwnedReason();
- RefreshStorySpine();
- RefreshHistoryIfChanged();
+ LifeSagaViewController.Tick();
+ if (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Dossier)
+ {
+ ReconcileDossierToFocus();
+ RefreshLivePortrait();
+ RefreshLiveTask();
+ RefreshLiveIdentity();
+ RefreshLiveStatuses();
+ RefreshOwnedReason();
+ RefreshStorySpine();
+ RefreshHistoryIfChanged();
+ }
+ else
+ {
+ RefreshStorySpine();
+ RefreshSagaBody();
+ }
}
///
@@ -732,13 +759,8 @@ public static class WatchCaption
return;
}
- Relayout(
- _boundActor != null || CountActiveHistorySlots() > 0 || _current != null,
- CountActiveTraitSlots(),
- CountActiveStatusSlots(),
- _taskText != null && _taskText.gameObject.activeSelf,
- _reasonText != null && _reasonText.gameObject.activeSelf,
- CountActiveHistorySlots());
+ // Route through spine/tab refresh so Saga body and rail stay consistent.
+ RefreshStorySpine();
}
private static bool HasStatusBanner()
@@ -914,34 +936,238 @@ public static class WatchCaption
///
/// Quiet Kind · Phase line under the orange reason while StoryPlanner owns an arc.
+ /// Suppressed when the orange reason already names the same kind and phase is Climax.
///
private static void RefreshStorySpine()
{
if (!_visible || HasStatusBanner())
{
+ LifeSagaViewController.CancelPreviewAndPause();
+ LifeSagaPanel.Clear();
LifeSagaRail.Clear();
+ SetTabsVisible(false);
return;
}
string next = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption
? StoryPlanner.FormatSpineLabel()
: "";
+ next = FilterRedundantStorySpine(next, CurrentReasonText());
if (!string.Equals(next, LastStorySpine, StringComparison.Ordinal))
{
ApplyStorySpine(next);
}
LifeSagaRail.Refresh();
- bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
- bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
- bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null;
+ RefreshTabChrome();
+ bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
+ if (sagaMode)
+ {
+ RefreshSagaBody();
+ }
+ else
+ {
+ LifeSagaPanel.SetVisible(false);
+ }
+
+ bool hasReason = !sagaMode && _reasonText != null && _reasonText.gameObject.activeSelf;
+ bool hasTask = !sagaMode && _taskText != null && _taskText.gameObject.activeSelf;
+ bool hasBody = sagaMode
+ || _boundActor != null
+ || CountActiveHistorySlots() > 0
+ || _current != null;
Relayout(
hasBody,
- CountActiveTraitSlots(),
- CountActiveStatusSlots(),
+ sagaMode ? 0 : CountActiveTraitSlots(),
+ sagaMode ? 0 : CountActiveStatusSlots(),
hasTask,
hasReason,
- CountActiveHistorySlots());
+ sagaMode ? 0 : CountActiveHistorySlots());
+ }
+
+ /// Orange reason line currently shown (or bound), for spine dedupe.
+ private static string CurrentReasonText()
+ {
+ if (_reasonText != null && _reasonText.gameObject.activeSelf
+ && !string.IsNullOrEmpty(_reasonText.text))
+ {
+ return _reasonText.text;
+ }
+
+ return _current != null ? (_current.ReasonLine ?? "") : "";
+ }
+
+ ///
+ /// Drop Kind · Climax when the reason already leads with that kind (e.g. "Duel - A vs B").
+ /// Keep Aftermath / Epilogue and unmatched kinds. Combat Climax is also dropped when the
+ /// tip already leads with any combat scale word (covers brief Kind sync lag).
+ ///
+ private static string FilterRedundantStorySpine(string spine, string reason)
+ {
+ if (string.IsNullOrEmpty(spine))
+ {
+ return "";
+ }
+
+ int sep = spine.IndexOf('·');
+ if (sep < 0)
+ {
+ return spine;
+ }
+
+ string kind = spine.Substring(0, sep).Trim();
+ string phase = spine.Substring(sep + 1).Trim();
+ if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase))
+ {
+ return spine;
+ }
+
+ if (!string.Equals(phase, "Climax", StringComparison.OrdinalIgnoreCase))
+ {
+ return spine;
+ }
+
+ if (string.IsNullOrEmpty(reason))
+ {
+ return spine;
+ }
+
+ if (ReasonLeadsWithToken(reason, kind))
+ {
+ return "";
+ }
+
+ // Tip already carries combat scale; hide Kind · Climax even if spine Kind lags SyncActiveClimax.
+ if (IsCombatSpineKind(kind) && ReasonLeadsWithCombatScale(reason))
+ {
+ return "";
+ }
+
+ return spine;
+ }
+
+ private static bool IsCombatSpineKind(string kind)
+ {
+ return string.Equals(kind, "Duel", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(kind, "Skirmish", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(kind, "Battle", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(kind, "Mass", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool ReasonLeadsWithCombatScale(string reason)
+ {
+ return ReasonLeadsWithToken(reason, "Duel")
+ || ReasonLeadsWithToken(reason, "Skirmish")
+ || ReasonLeadsWithToken(reason, "Battle")
+ || ReasonLeadsWithToken(reason, "Mass");
+ }
+
+ /// True when reason starts with the token before " - " / " · " / whitespace.
+ private static bool ReasonLeadsWithToken(string reason, string token)
+ {
+ if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(token))
+ {
+ return false;
+ }
+
+ string lead = reason.TrimStart();
+ int cut = lead.Length;
+ for (int i = 0; i < lead.Length; i++)
+ {
+ char c = lead[i];
+ if (c == '-' || c == '·' || char.IsWhiteSpace(c))
+ {
+ cut = i;
+ break;
+ }
+ }
+
+ string head = lead.Substring(0, cut).Trim();
+ return string.Equals(head, token, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void RefreshSagaBody()
+ {
+ EnsureBuilt();
+ LifeSagaPanel.EnsureBuilt(_root.transform);
+ LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
+ bool lore = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
+ && !LifeSagaViewController.IsHoverPreview;
+ LifeSagaPanel.Bind(model, lore);
+ }
+
+ private static void RefreshTabChrome()
+ {
+ if (_tabsRow == null)
+ {
+ return;
+ }
+
+ bool show = ModSettings.ShowDossierCaption && !HasStatusBanner()
+ && (SpectatorMode.Active || SpectatorMode.BrowsePaused);
+ SetTabsVisible(show);
+ if (!show)
+ {
+ return;
+ }
+
+ bool saga = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga;
+ ApplyTabVisual(_tabDossierBtn, _tabDossierBg, _tabDossierLabel, !saga);
+ ApplyTabVisual(_tabSagaBtn, _tabSagaBg, _tabSagaLabel, saga);
+ // Keep the tab label stable - never "Saga · Name" (that made chrome feel jumpy).
+ if (_tabSagaLabel != null)
+ {
+ _tabSagaLabel.text = "Saga";
+ }
+ }
+
+ private static string TruncateTabName(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return "";
+ }
+
+ string trimmed = name.StartsWith("★ ") ? name.Substring(2) : name;
+ return trimmed.Length <= 12 ? trimmed : trimmed.Substring(0, 11) + "…";
+ }
+
+ private static void SetTabsVisible(bool visible)
+ {
+ if (_tabsRow != null)
+ {
+ _tabsRow.SetActive(visible);
+ }
+ }
+
+ private static void ApplyTabVisual(Button btn, Image bg, Text label, bool selected)
+ {
+ if (bg != null)
+ {
+ bg.color = selected
+ ? new Color(0.28f, 0.3f, 0.38f, 0.95f)
+ : new Color(0.14f, 0.15f, 0.18f, 0.85f);
+ }
+
+ if (label != null)
+ {
+ label.color = selected ? HudTheme.ValueOrange : HudTheme.Muted;
+ label.fontStyle = selected ? FontStyle.Bold : FontStyle.Normal;
+ }
+
+ _ = btn;
+ }
+
+ private static void OnTabDossier()
+ {
+ LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Dossier);
+ RefreshStorySpine();
+ }
+
+ private static void OnTabSaga()
+ {
+ LifeSagaViewController.SelectTab(LifeSagaViewController.Tab.Saga);
+ RefreshStorySpine();
}
private static void ApplyStorySpine(string spine)
@@ -2347,16 +2573,34 @@ public static class WatchCaption
}
}
- // Chip rows must widen the panel - PlaceTraits/PlaceStatuses lay out natural widths.
- _panelWidth = Mathf.Clamp(
- PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, statusesRowW, LiveMax),
- LiveMax + PadX * 2f,
- PanelWidthMax);
+ bool sagaMode = LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
+ && !HasStatusBanner();
+ bool tabbedChrome = (_tabsRow != null && _tabsRow.activeSelf) || LifeSagaRail.Visible;
+
+ // Tabbed chrome keeps a constant width so hover preview cannot reflow the rail.
+ if (tabbedChrome)
+ {
+ _panelWidth = PanelWidthMax;
+ }
+ else if (sagaMode)
+ {
+ float sagaNeed = Mathf.Max(
+ LifeSagaPanel.BodyWidthPx,
+ LifeSagaRail.Visible ? LifeSagaRail.RowWidthPx : 0f);
+ _panelWidth = Mathf.Clamp(sagaNeed + PadX * 2f, 120f, PanelWidthMax);
+ }
+ else
+ {
+ _panelWidth = Mathf.Clamp(
+ PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, statusesRowW, LiveMax),
+ LiveMax + PadX * 2f,
+ PanelWidthMax);
+ }
LastHistoryFillsBody = historyCount <= 0;
- if (historyCount > 0)
+ if (!sagaMode && historyCount > 0)
{
- float fillW = _panelWidth - PadX * 2f - LiveMax - 4f;
+ float fillW = BodyColumnInnerWidth() - LiveMax - 4f;
if (fillW > _historyColW + 0.5f)
{
RemeasureHistoryForWidth(fillW);
@@ -2370,6 +2614,65 @@ public static class WatchCaption
}
float y = PadTop;
+ if (_tabsRow != null && _tabsRow.activeSelf)
+ {
+ PlaceTabs(y);
+ y += TabH + Gap;
+ }
+
+ // Stable top chrome: rail under tabs so Dossier↔Saga body swaps cannot move glyphs.
+ if (LifeSagaRail.Visible)
+ {
+ y = LifeSagaRail.Place(y, PadX);
+ }
+
+ SetDossierBodyVisible(!sagaMode);
+ if (sagaMode)
+ {
+ LifeSagaPanel.EnsureBuilt(_root.transform);
+ y = LifeSagaPanel.Place(y, PadX);
+ if (tabbedChrome)
+ {
+ _panelWidth = PanelWidthMax;
+ }
+ else
+ {
+ float sagaNeed = Mathf.Max(
+ LifeSagaPanel.BodyWidthPx,
+ LifeSagaRail.Visible ? LifeSagaRail.RowWidthPx : 0f);
+ _panelWidth = Mathf.Clamp(sagaNeed + PadX * 2f, 120f, PanelWidthMax);
+ }
+
+ float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
+ if (_rootRt != null)
+ {
+ ApplyRootPlacement();
+ _rootRt.sizeDelta = new Vector2(_panelWidth, height);
+ LastPanelSize = _rootRt.sizeDelta;
+ }
+
+ BringHeaderFront();
+ LastLayoutOk = true;
+ return;
+ }
+
+ // Row visibility is owned by Place*/fill - never leave empty rows active at stale
+ // positions (SetDossierBodyVisible only hides on saga; it must not resurrect them).
+ if (historyCount <= 0 && _historyCol != null)
+ {
+ _historyCol.SetActive(false);
+ }
+
+ if (statusCount <= 0 && _statusesRow != null)
+ {
+ _statusesRow.SetActive(false);
+ }
+
+ if (traitCount <= 0 && _traitsRow != null)
+ {
+ _traitsRow.SetActive(false);
+ }
+
PlaceHeader(y, hasTask);
y += HeaderH + Gap;
@@ -2422,22 +2725,17 @@ public static class WatchCaption
y += StorySpineH + Gap;
}
- if (LifeSagaRail.Visible)
- {
- y = LifeSagaRail.Place(y, PadX);
- }
-
- float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
+ float heightDossier = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
if (_rootRt != null)
{
ApplyRootPlacement();
- _rootRt.sizeDelta = new Vector2(_panelWidth, height);
+ _rootRt.sizeDelta = new Vector2(_panelWidth, heightDossier);
LastPanelSize = _rootRt.sizeDelta;
}
BringHeaderFront();
- LastLayoutOk = ProbeLayoutInside(height)
+ LastLayoutOk = ProbeLayoutInside(heightDossier)
&& headerW > SpeciesSize
&& (historyCount <= 0 || LastHistoryFillsBody);
if (!LastLayoutOk)
@@ -2447,34 +2745,120 @@ public static class WatchCaption
}
}
+ private static void PlaceTabs(float yFromTop)
+ {
+ if (_tabsRow == null)
+ {
+ return;
+ }
+
+ RectTransform rowRt = _tabsRow.GetComponent();
+ rowRt.anchoredPosition = new Vector2(PadX, -yFromTop);
+ rowRt.sizeDelta = new Vector2(Mathf.Max(100f, BodyColumnInnerWidth()), TabH);
+ if (_tabDossierBtn != null)
+ {
+ RectTransform rt = _tabDossierBtn.GetComponent();
+ rt.anchoredPosition = Vector2.zero;
+ rt.sizeDelta = new Vector2(56f, TabH);
+ }
+
+ if (_tabSagaBtn != null)
+ {
+ RectTransform rt = _tabSagaBtn.GetComponent();
+ rt.anchoredPosition = new Vector2(58f, 0f);
+ rt.sizeDelta = new Vector2(56f, TabH);
+ }
+ }
+
+ private static void SetDossierBodyVisible(bool visible)
+ {
+ if (_nameText != null) _nameText.gameObject.SetActive(visible);
+ if (_speciesIcon != null) _speciesIcon.gameObject.SetActive(visible);
+ if (_sexIcon != null) _sexIcon.gameObject.SetActive(visible);
+ if (_levelIcon != null) _levelIcon.gameObject.SetActive(visible);
+ if (_levelValue != null) _levelValue.gameObject.SetActive(visible);
+ if (_taskIcon != null) _taskIcon.gameObject.SetActive(visible && _taskText != null && !string.IsNullOrEmpty(_taskText.text));
+ if (_taskText != null) _taskText.gameObject.SetActive(visible && !string.IsNullOrEmpty(_taskText.text));
+ if (_favoriteBtn != null) _favoriteBtn.gameObject.SetActive(visible);
+ if (_reasonText != null) _reasonText.gameObject.SetActive(visible && !string.IsNullOrEmpty(_reasonText.text));
+ if (_storySpineText != null)
+ {
+ _storySpineText.gameObject.SetActive(
+ visible && !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner());
+ }
+
+ // History / traits / statuses stay under Fill*/Place* ownership while dossier is
+ // visible. Only force-hide them when switching to Saga so empty rows cannot remain
+ // active at stale coordinates and fail ProbeLayoutInside.
+ if (!visible)
+ {
+ if (_historyCol != null) _historyCol.SetActive(false);
+ if (_traitsRow != null) _traitsRow.SetActive(false);
+ if (_statusesRow != null) _statusesRow.SetActive(false);
+ }
+
+ // Saga reuses the same live portrait host for the MC - leave it visible there.
+ if (!LifeSagaPanel.OwnsAvatar)
+ {
+ DossierAvatar.SetHostVisible(visible);
+ }
+ }
+
///
/// Nametag: left-pack [species] Name [lv]n [task]; right-pin [sex] [★] to the panel edge.
+ /// Middle chips shrink/ellipsis so they never paint under the right cluster.
///
private static float PlaceHeader(float yFromTop, bool hasTask)
{
- float x = PadX;
+ float panelW = Mathf.Max(_panelWidth, LiveMax + PadX * 2f);
+ // Sex + favorite hug the dossier's top-right (right-justified).
+ float right = panelW - PadX;
+ PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop);
+ right -= BtnSize;
+ if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
+ {
+ right -= 2f;
+ PlaceLeftChip(_sexIcon.rectTransform, right - SexSize, yFromTop + 2f, SexSize, SexSize);
+ right -= SexSize;
+ }
+ const float minGap = 6f;
+ float leftBudget = Mathf.Max(NameMinW, right - PadX - minGap);
+
+ float x = PadX;
if (_speciesIcon != null)
{
PlaceLeftChip(_speciesIcon.rectTransform, x, yFromTop + 1f, SpeciesSize, SpeciesSize);
x += SpeciesSize + 3f;
}
- if (_nameText != null)
+ bool hasLevel = _levelIcon != null && _levelIcon.gameObject.activeSelf
+ && _levelValue != null && _levelValue.gameObject.activeSelf;
+ float levelW = 0f;
+ if (hasLevel)
{
- float nameW = FitNameChipWidth();
- PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH);
- x += nameW + 5f;
+ levelW = ChipIcon + 1f
+ + Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f)
+ + 4f;
}
- if (_levelIcon != null && _levelIcon.gameObject.activeSelf)
+ float taskReserve = hasTask ? ChipIcon + 1f + 28f + 4f : 0f;
+ float nameBudget = Mathf.Clamp(
+ leftBudget - (x - PadX) - levelW - taskReserve,
+ NameMinW,
+ NameMaxW);
+
+ if (_nameText != null)
+ {
+ float nameW = FitNameChipWidth(nameBudget);
+ PlaceLeftChip(_nameText.rectTransform, x, yFromTop, nameW, HeaderH);
+ x += nameW + 4f;
+ }
+
+ if (hasLevel)
{
PlaceLeftChip(_levelIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
x += ChipIcon + 1f;
- }
-
- if (_levelValue != null && _levelValue.gameObject.activeSelf)
- {
float lvW = Mathf.Clamp(MeasureTextWidth(_levelValue, 10f), 8f, 24f);
PlaceLeftChip(_levelValue.rectTransform, x, yFromTop, lvW, HeaderH);
x += lvW + 4f;
@@ -2484,10 +2868,15 @@ public static class WatchCaption
{
_taskIcon.gameObject.SetActive(true);
_taskText.gameObject.SetActive(true);
+ float taskBudget = Mathf.Clamp(
+ leftBudget - (x - PadX) - ChipIcon - 1f,
+ 24f,
+ TaskLabelW);
+ float taskW = FitTaskChipWidth(taskBudget);
PlaceLeftChip(_taskIcon.rectTransform, x, yFromTop + 3f, ChipIcon, ChipIcon);
x += ChipIcon + 1f;
- PlaceLeftChip(_taskText.rectTransform, x, yFromTop, TaskLabelW, HeaderH);
- x += TaskLabelW + 4f;
+ PlaceLeftChip(_taskText.rectTransform, x, yFromTop, taskW, HeaderH);
+ x += taskW + 4f;
}
else
{
@@ -2502,17 +2891,6 @@ public static class WatchCaption
}
}
- // Sex + favorite always hug the dossier's top-right (right-justified).
- float right = Mathf.Max(_panelWidth, LiveMax + PadX * 2f) - PadX;
- PlaceHeaderButton(_favoriteBtn, right - BtnSize, yFromTop);
- right -= BtnSize;
- if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
- {
- right -= 2f;
- PlaceLeftChip(_sexIcon.rectTransform, right - SexSize, yFromTop + 2f, SexSize, SexSize);
- right -= SexSize;
- }
-
RefreshFavoriteVisual(_boundActor);
BringHeaderFront();
@@ -2524,7 +2902,7 @@ public static class WatchCaption
float x = PadX + SpeciesSize + 3f;
if (_nameText != null)
{
- x += FitNameChipWidth() + 5f;
+ x += Mathf.Min(FitNameChipWidth(NameMaxW), NameMaxW) + 4f;
}
if (_levelIcon != null && _levelIcon.gameObject.activeSelf)
@@ -2544,7 +2922,7 @@ public static class WatchCaption
}
// Reserve a gap + right cluster so sex/favorite can pin to the panel edge.
- const float minGap = 8f;
+ const float minGap = 6f;
float rightCluster = BtnSize;
if (_sexIcon != null && _sexIcon.gameObject.activeSelf)
{
@@ -2625,16 +3003,17 @@ public static class WatchCaption
}
///
- /// Size the name chip to the visible text and ellipsis-truncate when over .
- /// Prevents Overflow paint from landing under the level badge (long "Name (species)" lines).
+ /// Size the name chip to the visible text and ellipsis-truncate when over .
+ /// Prevents Overflow paint from landing under level / task / sex chips.
///
- private static float FitNameChipWidth()
+ private static float FitNameChipWidth(float maxW = NameMaxW)
{
if (_nameText == null)
{
return NameMinW;
}
+ maxW = Mathf.Max(NameMinW, maxW);
_nameText.resizeTextForBestFit = false;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "");
@@ -2646,9 +3025,9 @@ public static class WatchCaption
_nameText.text = full;
float natural = MeasureTextWidth(_nameText, 72f);
- if (natural <= NameMaxW)
+ if (natural <= maxW)
{
- return Mathf.Clamp(natural, NameMinW, NameMaxW);
+ return Mathf.Clamp(natural, NameMinW, maxW);
}
const string ellipsis = "...";
@@ -2662,8 +3041,8 @@ public static class WatchCaption
? ellipsis
: (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis);
_nameText.text = candidate;
- float w = MeasureTextWidth(_nameText, NameMaxW);
- if (w <= NameMaxW)
+ float w = MeasureTextWidth(_nameText, maxW);
+ if (w <= maxW)
{
best = candidate;
lo = mid + 1;
@@ -2675,7 +3054,104 @@ public static class WatchCaption
}
_nameText.text = best;
- return Mathf.Clamp(MeasureTextWidth(_nameText, NameMaxW), NameMinW, NameMaxW);
+ return Mathf.Clamp(MeasureTextWidth(_nameText, maxW), NameMinW, maxW);
+ }
+
+ /// Ellipsis-fit the task chip into the remaining header budget.
+ private static float FitTaskChipWidth(float maxW)
+ {
+ if (_taskText == null)
+ {
+ return Mathf.Clamp(maxW, 24f, TaskLabelW);
+ }
+
+ maxW = Mathf.Clamp(maxW, 24f, TaskLabelW);
+ _taskText.resizeTextForBestFit = false;
+ _taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
+ string full = TruncateTaskLabel(_taskText.text ?? "");
+ if (string.IsNullOrEmpty(full))
+ {
+ _taskText.text = "";
+ return maxW;
+ }
+
+ _taskText.text = full;
+ float natural = MeasureTextWidth(_taskText, maxW);
+ if (natural <= maxW)
+ {
+ return Mathf.Clamp(natural, 24f, maxW);
+ }
+
+ const string ellipsis = "...";
+ int lo = 0;
+ int hi = full.Length;
+ string best = ellipsis;
+ while (lo <= hi)
+ {
+ int mid = (lo + hi) / 2;
+ string candidate = mid <= 0
+ ? ellipsis
+ : (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis);
+ _taskText.text = candidate;
+ float w = MeasureTextWidth(_taskText, maxW);
+ if (w <= maxW)
+ {
+ best = candidate;
+ lo = mid + 1;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+
+ _taskText.text = best;
+ return Mathf.Clamp(MeasureTextWidth(_taskText, maxW), 24f, maxW);
+ }
+
+ private static void EllipsisFitLabel(Text label, float maxW)
+ {
+ if (label == null || maxW < 8f)
+ {
+ return;
+ }
+
+ string full = label.text ?? "";
+ if (string.IsNullOrEmpty(full))
+ {
+ return;
+ }
+
+ label.resizeTextForBestFit = false;
+ label.horizontalOverflow = HorizontalWrapMode.Overflow;
+ if (MeasureTextWidth(label, maxW) <= maxW)
+ {
+ return;
+ }
+
+ const string ellipsis = "...";
+ int lo = 0;
+ int hi = full.Length;
+ string best = ellipsis;
+ while (lo <= hi)
+ {
+ int mid = (lo + hi) / 2;
+ string candidate = mid <= 0
+ ? ellipsis
+ : (mid >= full.Length ? full : full.Substring(0, mid).TrimEnd() + ellipsis);
+ label.text = candidate;
+ if (MeasureTextWidth(label, maxW) <= maxW)
+ {
+ best = candidate;
+ lo = mid + 1;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+
+ label.text = best;
}
private static float MeasureTextWidth(Text text, float fallback)
@@ -2931,6 +3407,7 @@ public static class WatchCaption
return 0f;
}
+ _statusesRow.SetActive(true);
RectTransform row = _statusesRow.GetComponent();
row.anchorMin = new Vector2(0f, 1f);
row.anchorMax = new Vector2(1f, 1f);
@@ -2938,6 +3415,7 @@ public static class WatchCaption
row.offsetMin = new Vector2(PadX, -(yFromTop + StatusesH));
row.offsetMax = new Vector2(-PadX, -yFromTop);
+ float maxRow = Mathf.Max(40f, BodyColumnInnerWidth());
float x = 0f;
for (int i = 0; i < _statusSlots.Length; i++)
{
@@ -2951,6 +3429,19 @@ public static class WatchCaption
? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f)
: 28f;
float slotW = TraitIcon + 2f + labelW;
+ if (x + slotW > maxRow && slot.Label != null)
+ {
+ labelW = Mathf.Max(12f, maxRow - x - TraitIcon - 2f);
+ EllipsisFitLabel(slot.Label, labelW);
+ slotW = TraitIcon + 2f + labelW;
+ }
+
+ if (x + slotW > maxRow + 0.5f)
+ {
+ slot.Root.SetActive(false);
+ continue;
+ }
+
PlaceTraitSlot(slot, x, 0f, slotW, StatusesH);
x += slotW + 3f;
}
@@ -2965,6 +3456,7 @@ public static class WatchCaption
return 0f;
}
+ _traitsRow.SetActive(true);
RectTransform row = _traitsRow.GetComponent();
row.anchorMin = new Vector2(0f, 1f);
row.anchorMax = new Vector2(1f, 1f);
@@ -2972,6 +3464,7 @@ public static class WatchCaption
row.offsetMin = new Vector2(PadX, -(yFromTop + TraitsH));
row.offsetMax = new Vector2(-PadX, -yFromTop);
+ float maxRow = Mathf.Max(40f, BodyColumnInnerWidth());
float x = 0f;
for (int i = 0; i < _traitSlots.Length; i++)
{
@@ -2985,6 +3478,19 @@ public static class WatchCaption
? Mathf.Max(MeasureTextWidth(slot.Label, 28f), 18f)
: 28f;
float slotW = TraitIcon + 2f + labelW;
+ if (x + slotW > maxRow && slot.Label != null)
+ {
+ labelW = Mathf.Max(12f, maxRow - x - TraitIcon - 2f);
+ EllipsisFitLabel(slot.Label, labelW);
+ slotW = TraitIcon + 2f + labelW;
+ }
+
+ if (x + slotW > maxRow + 0.5f)
+ {
+ slot.Root.SetActive(false);
+ continue;
+ }
+
PlaceTraitSlot(slot, x, 0f, slotW, TraitsH);
x += slotW + 3f;
}
@@ -3000,6 +3506,7 @@ public static class WatchCaption
return 0f;
}
+ _traitsRow.SetActive(true);
float colW = MeasureTraitsBesideWidth();
RectTransform row = _traitsRow.GetComponent();
@@ -3142,6 +3649,13 @@ public static class WatchCaption
_storySpineText = null;
_reasonOtherId = 0;
LastStorySpine = "";
+ _tabsRow = null;
+ _tabDossierBtn = null;
+ _tabSagaBtn = null;
+ _tabDossierLabel = null;
+ _tabSagaLabel = null;
+ _tabDossierBg = null;
+ _tabSagaBg = null;
_levelIcon = null;
_levelValue = null;
_taskIcon = null;
@@ -3216,13 +3730,13 @@ public static class WatchCaption
button.targetGraphic = hit;
button.interactable = false;
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon);
- Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
+ Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7);
label.color = HistoryTextColor;
label.alignment = TextAnchor.MiddleLeft;
- label.horizontalOverflow = HorizontalWrapMode.Overflow;
+ label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.supportRichText = true;
label.resizeTextMinSize = 6;
- label.resizeTextMaxSize = 8;
+ label.resizeTextMaxSize = 7;
_historySlots[i] = new HistorySlot
{
Root = slotGo,
@@ -3245,12 +3759,12 @@ public static class WatchCaption
hit.color = new Color(1f, 1f, 1f, 0.001f);
hit.raycastTarget = true;
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
- Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
+ Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7);
label.color = StatusNameColor;
label.alignment = TextAnchor.MiddleLeft;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.resizeTextMinSize = 6;
- label.resizeTextMaxSize = 8;
+ label.resizeTextMaxSize = 7;
DossierChipTip tip = slotGo.AddComponent();
tip.enabled = false;
_statusSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip };
@@ -3267,19 +3781,19 @@ public static class WatchCaption
hit.color = new Color(1f, 1f, 1f, 0.001f);
hit.raycastTarget = true;
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
- Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
+ Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 7);
label.color = TraitNameColor;
label.alignment = TextAnchor.MiddleLeft;
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.resizeTextMinSize = 6;
- label.resizeTextMaxSize = 8;
+ label.resizeTextMaxSize = 7;
DossierChipTip tip = slotGo.AddComponent();
tip.enabled = false;
_traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip };
slotGo.SetActive(false);
}
- _reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 8);
+ _reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 7);
_reasonText.color = ReasonColor;
_reasonText.supportRichText = true;
_reasonText.alignment = TextAnchor.UpperLeft;
@@ -3298,25 +3812,27 @@ public static class WatchCaption
_storySpineText.raycastTarget = false;
LifeSagaRail.EnsureBuilt(_root.transform);
+ LifeSagaPanel.EnsureBuilt(_root.transform);
+ BuildTabsRow();
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
- _nameText = HudCanvas.MakeText(_root.transform, "Name", "", 11);
+ _nameText = HudCanvas.MakeText(_root.transform, "Name", "", 10);
_nameText.fontStyle = FontStyle.Bold;
_nameText.color = NameColor;
_nameText.alignment = TextAnchor.MiddleLeft;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
_levelIcon = HudCanvas.MakeIcon(_root.transform, "LevelIcon", ChipIcon);
- _levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 9);
+ _levelValue = HudCanvas.MakeText(_root.transform, "LevelValue", "", 8);
_levelValue.color = StatValueColor;
_levelValue.fontStyle = FontStyle.Bold;
_levelValue.alignment = TextAnchor.MiddleLeft;
_levelValue.horizontalOverflow = HorizontalWrapMode.Overflow;
_taskIcon = HudCanvas.MakeIcon(_root.transform, "TaskIcon", ChipIcon);
- _taskText = HudCanvas.MakeText(_root.transform, "Task", "", 8);
+ _taskText = HudCanvas.MakeText(_root.transform, "Task", "", 7);
_taskText.color = TaskColor;
_taskText.alignment = TextAnchor.MiddleLeft;
_taskText.horizontalOverflow = HorizontalWrapMode.Overflow;
@@ -3345,6 +3861,58 @@ public static class WatchCaption
_favoriteIcon = _favoriteBtn.transform.Find("Icon")?.GetComponent();
}
+ private static void BuildTabsRow()
+ {
+ if (_tabsRow != null || _root == null)
+ {
+ return;
+ }
+
+ _tabsRow = new GameObject("SagaTabs", typeof(RectTransform));
+ _tabsRow.transform.SetParent(_root.transform, false);
+ RectTransform rowRt = _tabsRow.GetComponent();
+ rowRt.pivot = new Vector2(0f, 1f);
+ rowRt.anchorMin = new Vector2(0f, 1f);
+ rowRt.anchorMax = new Vector2(0f, 1f);
+
+ _tabDossierBtn = BuildTabButton(_tabsRow.transform, "TabDossier", "Dossier", OnTabDossier, out _tabDossierBg, out _tabDossierLabel);
+ _tabSagaBtn = BuildTabButton(_tabsRow.transform, "TabSaga", "Saga", OnTabSaga, out _tabSagaBg, out _tabSagaLabel);
+ _tabsRow.SetActive(false);
+ }
+
+ private static Button BuildTabButton(
+ Transform parent,
+ string name,
+ string label,
+ UnityAction onClick,
+ out Image bg,
+ out Text text)
+ {
+ GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
+ go.transform.SetParent(parent, false);
+ RectTransform rt = go.GetComponent();
+ rt.pivot = new Vector2(0f, 1f);
+ rt.anchorMin = new Vector2(0f, 1f);
+ rt.anchorMax = new Vector2(0f, 1f);
+ rt.sizeDelta = new Vector2(64f, TabH);
+ bg = go.GetComponent();
+ bg.color = new Color(0.14f, 0.15f, 0.18f, 0.85f);
+ bg.raycastTarget = true;
+ Button button = go.GetComponent();
+ button.targetGraphic = bg;
+ button.onClick.AddListener(onClick);
+ text = HudCanvas.MakeText(go.transform, "Label", label, 8);
+ text.alignment = TextAnchor.MiddleCenter;
+ text.color = HudTheme.Muted;
+ text.raycastTarget = false;
+ RectTransform textRt = text.rectTransform;
+ textRt.anchorMin = Vector2.zero;
+ textRt.anchorMax = Vector2.one;
+ textRt.offsetMin = Vector2.zero;
+ textRt.offsetMax = Vector2.zero;
+ return button;
+ }
+
private static Button BuildIconButton(Transform parent, string name, Sprite icon, UnityAction onClick)
{
GameObject go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index 94c515b..3b52160 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -1092,15 +1092,15 @@ public static class WorldActivityScanner
SplitActorScore(actor, speciesCounts, out float action, out float character);
// Action-primary total used for ranking; character is a light tie-break.
float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
- // Life-saga bias for ambient fill - prefer roster MCs (Prefer first) over strangers.
+ // Life-saga bias for ambient fill - Prefer/MC must beat anonymous villagers post-quiet.
long id = EventFeedUtil.SafeId(actor);
if (LifeSagaRoster.IsPrefer(id))
{
- score += Mathf.Max(4f, InterestScoringConfig.Story.sagaPreferWeight * 0.45f);
+ score += Mathf.Max(8f, InterestScoringConfig.Story.sagaPreferWeight * 0.75f);
}
else if (LifeSagaRoster.IsMc(id))
{
- score += Mathf.Max(2f, InterestScoringConfig.Story.sagaMcWeight * 0.45f);
+ score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
}
float causal = CausalHeat.Get(id);
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 37ae2fd..1264791 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.29.7",
+ "version": "0.29.48",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/docs/life-saga.md b/docs/life-saga.md
index 364a579..f92a4c0 100644
--- a/docs/life-saga.md
+++ b/docs/life-saga.md
@@ -1,40 +1,90 @@
# Life-saga roster
-Long-lived cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
+Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
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.
## Ownership
| Concern | Owner |
|---------|--------|
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
-| Hover card | `LifeSagaOverview` |
-| Rail glyphs / Prefer click | `LifeSagaRail` |
+| Durable observed facts | `LifeSagaMemory` |
+| Adaptive presentation | `LifeSagaPresentation` |
+| Dossier / Saga tabs + hover preview | `LifeSagaViewController` |
+| Saga body UI | `LifeSagaPanel` |
+| Rail icons / Prefer click | `LifeSagaRail` |
+| Camera read-pause lease | `InterestDirector` |
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
| Full history | Chronicle / Lore |
+## Admission
+
+Live discovery (no tip-string allowlists):
+
+- Game favorite, king, city leader, kills/renown floors (`InterestScoring.IsNotable`)
+- Pack alpha, clan chief, army captain, active plot author, founder, species singleton
+
+Standing is recomputed from live roles each refill.
+Heat comes from chapter stamps / featured tips and decays with idle time.
+Each slot keeps its original admission reason even when its live role later changes.
+Hard-arc admissions also retain the arc kind and chapter context that brought the life onto the roster.
+
+## Dynamic top-10
+
+- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
+- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
+- Admission-role units always compete on the periodic scan.
+- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief).
+- Soft `NoteFeatured` heats existing MCs only.
+- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
+
## Camera (soft)
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > CausalHeat.
- Near-ties prefer Prefer'd MCs, then any MC.
- Ambient fill prefers roster MCs over random villagers.
+- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
## Rail UX
-- Up to 10 glyphs (role/species letter).
+- 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 only; Prefer uses a star pip / distinct border.
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
-- Hover: Title (name + optional epithet + role) / Why / Circle / Chapters.
-- Chapters are stamped saga beats only - not Chronicle peeks.
-- Trait names may appear once as a title epithet; trait descs never appear on the hover.
+- While on the **Saga** tab, click also pins that life as the persistent Saga subject.
+- 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 rail (or panel after a dwell) re-arms a short grace, then restores the prior tab and releases the pause lease.
+- 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 matches Design A: identity, stake (accent wrap), Cast (face + name + relation), Evidence (lens module), Legacy, Open Lore.
+- Open Lore from Saga stashes return-to-Saga; Lore close restores the Saga tab and subject when still an MC.
+- Body height sizes to content up to a cap; lines wrap fully (no ellipsis truncation).
+- Glyph hover keeps the preview while the pointer stays on the rail or dossier panel.
+- Dossier story spine (`Kind · Phase`) is hidden when the orange reason 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 require memory evidence (repeated encounters, kin kills, war/plot opposition).
+- Likely succession is omitted unless a game-authored heir API probe succeeds.
+- Pack / kin lenses fill remaining Cast slots with live family peers (`Packmate` / `Kin`) after lover/friend/parents/children/heir/rival.
+
+## Soft-fill quiet (AFK hygiene)
+
+After hard story/crisis closers, soft-fill quiet suppresses soft-life crumbs and short interrupt FX (`stunned` / `invincible`).
+`FamilyPack` does not break quiet (ambient cluster fill).
+Combat / war / plot / outbreak / earthquake / story beats / epic strength still cut in.
+Quiet can refresh briefly when a soft crumb ends (bounded).
## Session
-- `StoryPlanner.Clear` does not wipe the roster.
-- Lore browse / brief idle-off keeps the roster.
-- Harness batch end and `interest_saga_clear` wipe the roster.
+- `StoryPlanner.Clear` does not wipe the roster or saga memory.
+- Lore browse / brief idle-off keeps the roster and memory.
+- World replacement, harness batch end, and `interest_saga_clear` wipe roster + memory.
## Harness
@@ -42,6 +92,14 @@ Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/Story
- `saga_camera_prefers_mc`
- `saga_prefer_soft_bias`
- `saga_overview_has_mc`
+- `saga_adaptive_dossier`
+- `saga_memory_survives`
+- `saga_hover_read_pause`
- `saga_replace_on_death`
+- `saga_admit_roles`
+- `saga_replace_weaker`
+- `saga_rail_active_highlight`
+- `saga_rail_prefer_click`
+- `saga_soft_fill_no_pack`
See also: [story-planner.md](story-planner.md).
diff --git a/docs/story-planner.md b/docs/story-planner.md
index 37051a4..f902e31 100644
--- a/docs/story-planner.md
+++ b/docs/story-planner.md
@@ -17,7 +17,10 @@ There is **no** rail Commit / hard pin anymore.
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
-Click toggles Prefer (soft favorite). Hover is a multi-line saga overview.
+Click toggles Prefer (soft favorite).
+Dossier/Saga tabs and hover preview are owned by `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).
Crisis camera hold: WarFront always blocks Mass/Battle cut-ins (`war_theater_hold`).
@@ -164,14 +167,15 @@ Short-arc related epilogue is skipped when a crisis closer owns the exit.
When a short-arc war linger injects while a war crisis is live, the crisis quiet-closes
(no second `epilogue_crisis`). Harness batch end and `interest_story_purge_leftovers`
drop leftover crisis closers so free AFK does not inherit a fake ending.
-After hard story/crisis ends, `softFillQuietSeconds` suppresses soft-life crumbs and
-character-fill ambient briefly.
+After hard story/crisis ends, `softFillQuietSeconds` suppresses soft-life crumbs,
+short interrupt FX (`stunned` / `invincible`), and character-fill ambient briefly.
+`FamilyPack` does not break that quiet window (see [life-saga.md](life-saga.md)).
## Selection discipline
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
- Near-ties still roll among top-3.
-- Causal heat + Character Ledger heat add into `RecalcTotal`.
+- Causal heat + life-saga Prefer/MC soft bias add into `RecalcTotal`.
## Knobs