feat(saga): ship adaptive dossier tabs with durable memory

- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause
- Persist observed saga facts across roster churn; pin subject without stale fallback
- Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips
- Densify dossier header/chips and keep the cast rail as stable top chrome
- Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
This commit is contained in:
DazedAnon 2026-07-22 03:27:46 -05:00
parent 798e3dce05
commit 4b6c829bd0
30 changed files with 7495 additions and 904 deletions

View file

@ -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);
}
}

View file

@ -191,6 +191,92 @@ public static class ActorRelation
}
}
/// <summary>Living parents reported by the running game's relation data.</summary>
public static IEnumerable<Actor> 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<long>();
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++;
}
}
/// <summary>Current living combat target, when the live attack target is an actor.</summary>
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;
}
}
/// <summary>
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
/// </summary>
@ -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<long> 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);

View file

@ -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<LifeSagaSlot>(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<LifeSagaSlot>(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<LifeSagaSlot>(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 "";

View file

@ -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.
}

View file

@ -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)" : ""));
}
/// <summary>

View file

@ -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
}
}
/// <summary>
/// Keep the live portrait under saga/dossier chrome that must paint on top of it.
/// </summary>
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());
}
}
/// <summary>
/// Keep the compact stone-style dossier frame - strip king/clan vanity overrides.
/// </summary>
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)

View file

@ -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(

View file

@ -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)

View file

@ -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");
});
}
}

View file

@ -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
{

View file

@ -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,

View file

@ -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
};
}
/// <summary>Saga overview hover has MC name and no planner jargon / trait descs.</summary>
/// <summary>
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps.
/// </summary>
private static List<HarnessCommand> SagaOverviewHasMc()
{
return new List<HarnessCommand>
@ -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"),
};
}
/// <summary>Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body.</summary>
private static List<HarnessCommand> SagaAdaptiveDossier()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Memory survives roster eviction and captures without Chronicle.</summary>
private static List<HarnessCommand> SagaMemorySurvives()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Hover acquires read-pause; camera owner stays fixed until release.</summary>
private static List<HarnessCommand> SagaHoverReadPause()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Multi-species notables fill distinct roster slots.</summary>
private static List<HarnessCommand> SagaRosterDiversity()
{
@ -3026,6 +3224,204 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Extended roles: singleton dragon overview + hard-arc nobody admit path.
/// </summary>
private static List<HarnessCommand> SagaAdmitRoles()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin.</summary>
private static List<HarnessCommand> SagaReplaceWeaker()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Active rail chrome tracks camera follow, not Prefer alone.</summary>
private static List<HarnessCommand> SagaRailActiveHighlight()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>Prefer toggle flips on then off with immediate pip state.</summary>
private static List<HarnessCommand> SagaRailPreferClick()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// During soft-fill quiet, Pack and stun/invincible crumbs do not win; combat still can.
/// </summary>
private static List<HarnessCommand> SagaSoftFillNoPack()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Sticky WarFront tips stay kingdom-framed across count dips and follow loss.
/// </summary>

View file

@ -1,3 +1,4 @@
using System;
using UnityEngine;
namespace IdleSpectator;
@ -140,6 +141,97 @@ public static class HudIcons
}
}
/// <summary>
/// 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.
/// </summary>
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)

View file

@ -460,9 +460,51 @@ public static partial class InterestDirector
private static float _browseFrozenLastSwitchAt = -999f;
private static float _browseFrozenLastInterestingAt = -999f;
/// <summary>Camera read-pause lease owners (hover preview). Feeds/story continue; switching deferred.</summary>
private static readonly HashSet<string> ReadPauseOwners = new HashSet<string>();
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;
}

View file

@ -1207,6 +1207,57 @@ public static class InterestVariety
|| s.IndexOf("favorite", StringComparison.Ordinal) >= 0;
}
/// <summary>
/// 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).
/// </summary>
public static bool IsSoftFillQuietCrumb(InterestCandidate c) =>
c != null
&& (c.Completion == InterestCompletionKind.FamilyPack
|| IsSoftLifeChapter(c)
|| IsShortInterruptFxChapter(c));
/// <summary>
/// Brief interrupt FX that should not own AFK during soft-fill quiet.
/// Kept non-outbreak; real danger statuses stay hot.
/// </summary>
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";
}
/// <summary>
/// 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"

File diff suppressed because it is too large Load diff

View file

@ -1,504 +1,135 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Multi-line saga hover card: Title / Why / Circle / Chapters.
/// No trait descs, no Chronicle dump, no planner jargon.
/// Compatibility façade over <see cref="LifeSagaPresentation"/>.
/// Prefer <see cref="LifeSagaPresentation.Build"/> for new UI.
/// </summary>
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<string>(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<string>(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<string>(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<LifeSagaSnapshotRow> Rows = new List<LifeSagaSnapshotRow>();
public string ToPlainText()
{
return LifeSagaPresentation.BuildPlainText(UnitId);
}
}

View file

@ -0,0 +1,757 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public static class LifeSagaPanel
{
/// <summary>Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).</summary>
public const float BodyWidthFixed = 232f;
/// <summary>Hard cap so Saga never eats the screen.</summary>
public const float BodyHeightMax = 148f;
public const float 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<RectTransform>();
_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<Image>();
loreBg.color = new Color(0f, 0f, 0f, 0f);
loreBg.raycastTarget = true;
_loreBtn = loreGo.GetComponent<Button>();
_loreBtn.targetGraphic = loreBg;
_loreBtn.onClick.AddListener(new UnityAction(OpenLore));
_loreBtnLabel = MakeLabel(loreGo.transform, "Label", BodyFont, HudTheme.ValueOrange, FontStyle.Normal);
_loreBtnLabel.text = "Open Lore";
_loreBtnLabel.alignment = TextAnchor.MiddleLeft;
RectTransform loreRt = loreGo.GetComponent<RectTransform>();
loreRt.pivot = new Vector2(0f, 1f);
loreRt.anchorMin = new Vector2(0f, 1f);
loreRt.anchorMax = new Vector2(0f, 1f);
loreRt.sizeDelta = new Vector2(72f, LoreH);
_builtVersion = BuildVersion;
_root.SetActive(false);
}
public static void Clear()
{
_boundId = 0;
_speciesId = "";
_fingerprint = "";
_ownsAvatar = false;
_laidOutHeight = BodyHeightMin;
if (_root != null)
{
_root.SetActive(false);
}
}
public static void SetVisible(bool visible)
{
if (_root == null)
{
return;
}
_root.SetActive(visible);
if (!visible)
{
_ownsAvatar = false;
}
}
public static void Bind(LifeSagaViewModel model, bool loreInteractive)
{
if (_root == null)
{
return;
}
if (model == null || model.UnitId == 0)
{
BindEmptyPickState();
return;
}
string fp = Fingerprint(model, loreInteractive);
bool same = string.Equals(fp, _fingerprint, System.StringComparison.Ordinal) && _boundId == model.UnitId;
_fingerprint = fp;
_boundId = model.UnitId;
_speciesId = model.SpeciesId ?? "";
if (!same)
{
_nameText.text = StripStar(model.Name);
_titleText.text = model.Title ?? "";
_stakeText.text = model.StakeLine ?? "";
for (int i = 0; i < CastSlots.Length; i++)
{
if (i < model.Cast.Count && model.Cast[i] != null)
{
BindCast(CastSlots[i], model.Cast[i]);
}
else
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
_evidenceText.text = FormatEvidence(model);
var legacyParts = new List<string>(MaxLegacy);
var seen = new HashSet<string>();
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
{
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
{
continue;
}
string line = model.Legacy[i].Line.Trim();
string key = line.ToLowerInvariant();
if (!string.IsNullOrEmpty(stakeKey)
&& string.Equals(key, stakeKey, System.StringComparison.Ordinal))
{
continue;
}
if (!seen.Add(key))
{
continue;
}
legacyParts.Add(line);
}
_legacyText.text = legacyParts.Count > 0 ? string.Join(" · ", legacyParts) : "";
}
if (_loreBtn != null)
{
_loreBtn.gameObject.SetActive(loreInteractive);
_loreBtn.interactable = loreInteractive;
}
RefreshAvatar(model);
Layout();
_root.SetActive(true);
}
public static float Place(float y, float padX)
{
if (!Visible || _rootRt == null)
{
return y;
}
_rootRt.anchoredPosition = new Vector2(padX, -y);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
if (_ownsAvatar)
{
DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize);
DossierAvatar.DrawBehind(_root.transform);
DossierAvatar.ForceCompactFrame();
}
return y + _laidOutHeight + 2f;
}
private static string FormatEvidence(LifeSagaViewModel model)
{
if (model == null || string.IsNullOrEmpty(model.EvidenceLine))
{
return "";
}
var sb = new StringBuilder(96);
if (!string.IsNullOrEmpty(model.EvidenceTitle))
{
sb.Append(model.EvidenceTitle).Append(" · ");
}
sb.Append(model.EvidenceLine);
if (!string.IsNullOrEmpty(model.SecondaryEvidenceLine))
{
sb.Append(" · ");
if (!string.IsNullOrEmpty(model.SecondaryEvidenceTitle))
{
sb.Append(model.SecondaryEvidenceTitle).Append(" · ");
}
sb.Append(model.SecondaryEvidenceLine);
}
return sb.ToString();
}
private static void RefreshAvatar(LifeSagaViewModel model)
{
if (model == null || model.UnitId == 0)
{
_ownsAvatar = false;
return;
}
DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize);
DossierAvatar.SetHostVisible(true);
Actor live = EventFeedUtil.FindAliveById(model.UnitId);
if (live != null && live.isAlive())
{
DossierAvatar.Show(live);
DossierAvatar.ForceCompactFrame();
}
else
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
_ownsAvatar = true;
}
private static void Layout()
{
float colX = Pad + AvatarSize + ColGap;
float textW = BodyWidthFixed - colX - Pad;
float y = Pad;
PlaceText(_nameText, colX, y, textW, NameFont + 2f);
y += NameFont + 2f;
if (!string.IsNullOrEmpty(_titleText.text))
{
PlaceText(_titleText, colX, y, textW, LineH);
y += LineH;
}
else
{
Hide(_titleText);
}
y += 2f;
bool hasStake = !string.IsNullOrEmpty(_stakeText.text);
if (hasStake)
{
float stakeH = Mathf.Clamp(
EstimateWrapHeight(_stakeText.text, textW, StakeFont),
LineH,
LineH * 3f);
PlaceText(_stakeText, colX, y, textW, stakeH);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
y += stakeH + 2f;
}
else
{
Hide(_stakeText);
}
int castN = 0;
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i].Root.activeSelf)
{
castN++;
}
}
if (castN > 0)
{
PlaceText(_castHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float castX = colX;
float castRowH = CastFace + 18f;
for (int i = 0; i < CastSlots.Length; i++)
{
if (!CastSlots[i].Root.activeSelf)
{
continue;
}
RectTransform rt = CastSlots[i].Root.GetComponent<RectTransform>();
rt.anchoredPosition = new Vector2(castX, -y);
rt.sizeDelta = new Vector2(CastSlotW, castRowH);
CastSlots[i].Root.transform.SetAsLastSibling();
castX += CastSlotW + 2f;
}
y += castRowH + 2f;
}
else
{
Hide(_castHead);
}
bool hasEvidence = !string.IsNullOrEmpty(_evidenceText.text);
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
bool loreActive = _loreBtn != null && _loreBtn.gameObject.activeSelf;
float loreReserve = loreActive ? LoreH + 2f : 0f;
float yMax = BodyHeightMax - Pad - loreReserve;
if (hasEvidence && y + SecLabelH + LineH <= yMax)
{
_evidenceHead.text = "Evidence";
PlaceText(_evidenceHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float evidenceH = Mathf.Clamp(
EstimateWrapHeight(_evidenceText.text, textW, BodyFont),
LineH,
LineH * 3f);
evidenceH = Mathf.Min(evidenceH, Mathf.Max(LineH, yMax - y - (hasLegacy ? SecLabelH + LineH : 0f)));
PlaceText(_evidenceText, colX, y, textW, evidenceH);
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
y += evidenceH + 2f;
}
else
{
Hide(_evidenceHead);
Hide(_evidenceText);
}
if (hasLegacy && y + SecLabelH + LineH <= yMax)
{
PlaceText(_legacyHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float legacyH = Mathf.Clamp(
EstimateWrapHeight(_legacyText.text, textW, BodyFont),
LineH,
LineH * 3f);
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
PlaceText(_legacyText, colX, y, textW, legacyH);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
y += legacyH + 2f;
}
else
{
Hide(_legacyHead);
Hide(_legacyText);
}
if (loreActive)
{
y = Mathf.Max(y, AvatarSize + Pad + 2f);
RectTransform loreRt = _loreBtn.GetComponent<RectTransform>();
loreRt.anchoredPosition = new Vector2(colX, -y);
loreRt.sizeDelta = new Vector2(72f, LoreH);
_loreBtn.transform.SetAsLastSibling();
if (_loreBtnLabel != null)
{
RectTransform labelRt = _loreBtnLabel.rectTransform;
labelRt.anchoredPosition = Vector2.zero;
labelRt.sizeDelta = new Vector2(72f, LoreH);
}
y += LoreH;
}
y = Mathf.Max(y + Pad, AvatarSize + Pad * 2f);
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
if (_rootRt != null)
{
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
}
LastLayoutDebug =
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' stake='{Short(_stakeText?.text)}' cast={castN} evidence={(hasEvidence ? 1 : 0)} legacy={(hasLegacy ? 1 : 0)}";
}
private static float EstimateWrapHeight(string text, float width, float fontSize)
{
if (string.IsNullOrEmpty(text))
{
return LineH;
}
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
int lines = Mathf.Max(1, Mathf.CeilToInt(text.Length / charsPerLine));
return lines * (fontSize + 2f);
}
private static CastSlot BuildCast(int index)
{
GameObject root = new GameObject("Cast" + index, typeof(RectTransform));
root.transform.SetParent(_root.transform, false);
RectTransform rt = root.GetComponent<RectTransform>();
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
GameObject bgGo = new GameObject("FaceBg", typeof(RectTransform), typeof(Image));
bgGo.transform.SetParent(root.transform, false);
RectTransform bgRt = bgGo.GetComponent<RectTransform>();
bgRt.pivot = new Vector2(0f, 1f);
bgRt.anchorMin = new Vector2(0f, 1f);
bgRt.anchorMax = new Vector2(0f, 1f);
bgRt.anchoredPosition = Vector2.zero;
bgRt.sizeDelta = new Vector2(CastFace, CastFace);
Image bg = bgGo.GetComponent<Image>();
bg.color = HudTheme.InsetFill;
bg.raycastTarget = false;
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(root.transform, false);
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchorMin = new Vector2(0f, 1f);
faceRt.anchorMax = new Vector2(0f, 1f);
faceRt.anchoredPosition = new Vector2(CastFace * 0.5f, -CastFace * 0.5f);
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
Image face = faceGo.GetComponent<Image>();
face.color = Color.white;
face.preserveAspect = true;
face.raycastTarget = false;
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Bold);
name.alignment = TextAnchor.UpperLeft;
RectTransform nameRt = name.rectTransform;
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal);
relation.alignment = TextAnchor.UpperLeft;
RectTransform relRt = relation.rectTransform;
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
root.SetActive(false);
return new CastSlot
{
Root = root,
FaceBg = bg,
Face = face,
Name = name,
Relation = relation
};
}
private static void BindCast(CastSlot slot, LifeSagaCastMember member)
{
slot.UnitId = member.UnitId;
slot.Name.text = FirstNonEmpty(member.Name, "Someone");
slot.Relation.text = FirstNonEmpty(member.Relation, "Linked");
Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null;
Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId)
?? HudIcons.FromSpeciesId(member.SpeciesId)
?? HudIcons.FromUiIcon("iconCitizen");
slot.Face.sprite = sprite;
slot.Face.enabled = sprite != null;
slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f);
slot.Root.SetActive(true);
}
private static void BindEmptyPickState()
{
_boundId = 0;
_speciesId = "";
_fingerprint = "empty-pick";
_ownsAvatar = false;
if (_nameText != null)
{
_nameText.text = "Pick a life";
}
if (_titleText != null)
{
_titleText.text = "from the rail";
}
if (_stakeText != null)
{
_stakeText.text = "Hover or click a glyph to read their saga.";
}
if (_evidenceText != null)
{
_evidenceText.text = "";
}
if (_legacyText != null)
{
_legacyText.text = "";
}
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i]?.Root != null)
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
if (_loreBtn != null)
{
_loreBtn.gameObject.SetActive(false);
_loreBtn.interactable = false;
}
DossierAvatar.SetHostVisible(false);
Layout();
if (_root != null)
{
_root.SetActive(true);
}
}
/// <summary>Harness: invoke Open Lore for the bound saga life.</summary>
public static void HarnessOpenLore()
{
OpenLore();
}
private static void OpenLore()
{
if (_boundId == 0 || LifeSagaViewController.IsHoverPreview)
{
return;
}
LifeSagaViewController.StashLoreReturn(_boundId);
ChronicleHud.OpenUnitHistory(_boundId, pauseIdle: true, followFocus: false);
}
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
{
int fitSize = size < 7 ? 7 : size;
Text text = HudCanvas.MakeText(parent, name, "", fitSize);
text.color = color;
text.fontStyle = style;
text.fontSize = fitSize;
text.resizeTextForBestFit = false;
text.alignment = TextAnchor.UpperLeft;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
RectTransform rt = text.rectTransform;
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
return text;
}
private static void PlaceText(Text text, float x, float y, float w, float h)
{
if (text == null)
{
return;
}
text.resizeTextForBestFit = false;
text.enabled = true;
Color c = text.color;
if (c.a < 0.99f)
{
c.a = 1f;
text.color = c;
}
RectTransform rt = text.rectTransform;
rt.anchoredPosition = new Vector2(x, -y);
rt.sizeDelta = new Vector2(w, h);
text.gameObject.SetActive(!string.IsNullOrEmpty(text.text));
}
private static void Hide(Text text)
{
if (text != null)
{
text.gameObject.SetActive(false);
}
}
private static string Fingerprint(LifeSagaViewModel model, bool loreInteractive)
{
var sb = new StringBuilder(192);
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title);
sb.Append('|').Append(model.StakeLine).Append('|').Append(model.EvidenceLine);
sb.Append('|').Append(model.SecondaryEvidenceLine);
sb.Append('|').Append(loreInteractive ? '1' : '0');
for (int i = 0; i < model.Cast.Count; i++)
{
var c = model.Cast[i];
if (c == null)
{
continue;
}
sb.Append('|').Append(c.UnitId).Append(':').Append(c.Relation).Append(':').Append(c.Name);
}
for (int i = 0; i < model.Legacy.Count; i++)
{
if (model.Legacy[i] != null)
{
sb.Append('#').Append(model.Legacy[i].Line);
}
}
return sb.ToString();
}
private static string StripStar(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return name.StartsWith("★ ") ? name.Substring(2) : name;
}
private static string FirstNonEmpty(params string[] parts)
{
if (parts == null)
{
return "";
}
for (int i = 0; i < parts.Length; i++)
{
if (!string.IsNullOrEmpty(parts[i]))
{
return parts[i];
}
}
return "";
}
private static string Short(string s)
{
if (string.IsNullOrEmpty(s))
{
return "";
}
return s.Length <= 28 ? s : s.Substring(0, 27) + "…";
}
}

View file

@ -0,0 +1,871 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Adaptive saga presentation model. Consumes live actor state + LifeSagaMemory.
/// No task/status/trait/Chronicle dumps.
/// </summary>
public static class LifeSagaPresentation
{
private static bool _heirProbeDone;
private static bool _heirApiAvailable;
private static MethodInfo _heirMethod;
private static object _heirTargetSample;
public static LifeSagaViewModel Build(long unitId)
{
var model = new LifeSagaViewModel { UnitId = unitId };
if (unitId == 0)
{
return model;
}
LifeSagaSlot slot = LifeSagaRoster.Get(unitId);
Actor actor = EventFeedUtil.FindAliveById(unitId);
LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId);
string name = FirstNonEmpty(
slot?.DisplayName,
memory?.Identity.Name,
SafeName(actor),
"Someone");
model.Name = name;
if (slot != null && (slot.Prefer || slot.GameFavorite))
{
model.Name = "★ " + name;
}
model.Title = BuildTitle(slot, actor, memory);
model.SpeciesId = FirstNonEmpty(
slot?.SpeciesId,
memory?.Identity.SpeciesId,
actor != null && actor.asset != null ? actor.asset.id : "");
ResolveLenses(slot, actor, memory, out LifeSagaLens primary, out LifeSagaLens secondary);
model.PrimaryLens = primary;
model.SecondaryLens = secondary;
LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId);
model.StakeLine = stake != null
? LifeSagaMemory.FormatFactLine(stake)
: BuildFallbackStake(slot, actor, memory);
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
BuildCast(model, slot, actor, memory, primary);
BuildEvidence(model, primary, secondary, slot, actor, memory);
BuildLegacy(model, unitId, stake);
return model;
}
/// <summary>Compatibility plain text for harness diagnostics.</summary>
public static string BuildPlainText(long unitId)
{
return Build(unitId).ToPlainText();
}
public static string BuildPlainText(LifeSagaSlot slot)
{
return Build(slot?.UnitId ?? 0).ToPlainText();
}
/// <summary>
/// Live probe: only report an heir when a game-authored succession API is discoverable.
/// </summary>
public static bool TryGetLikelySuccessor(Actor actor, out Actor heir, out string evidence)
{
heir = null;
evidence = "";
EnsureHeirProbe();
if (!_heirApiAvailable || actor == null)
{
return false;
}
try
{
object clan = actor.GetType().GetProperty("clan")?.GetValue(actor, null)
?? actor.GetType().GetField("clan")?.GetValue(actor);
if (clan == null || _heirMethod == null)
{
return false;
}
object result = null;
if (_heirMethod.GetParameters().Length == 0)
{
result = _heirMethod.Invoke(clan, null);
}
else if (_heirMethod.GetParameters().Length == 1)
{
result = _heirMethod.Invoke(clan, new object[] { actor });
}
heir = result as Actor;
if (heir != null && heir.isAlive() && heir != actor)
{
evidence = "clan_succession";
return true;
}
}
catch
{
// omit rather than guess
}
heir = null;
return false;
}
public static bool HeirApiAvailable
{
get
{
EnsureHeirProbe();
return _heirApiAvailable;
}
}
private static void EnsureHeirProbe()
{
if (_heirProbeDone)
{
return;
}
_heirProbeDone = true;
try
{
Type clanType = typeof(Actor).Assembly.GetType("Clan")
?? Type.GetType("Clan, Assembly-CSharp");
if (clanType == null)
{
return;
}
string[] names =
{
"findNextHeir", "getNextHeir", "getHeir", "findHeir", "calculateHeir", "getSuccessor"
};
for (int i = 0; i < names.Length; i++)
{
MethodInfo m = clanType.GetMethod(
names[i],
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (m != null && (typeof(Actor).IsAssignableFrom(m.ReturnType) || m.ReturnType == typeof(object)))
{
_heirMethod = m;
_heirApiAvailable = true;
return;
}
}
// Culture succession helpers.
Type cultureType = typeof(Actor).Assembly.GetType("Culture");
if (cultureType != null)
{
MethodInfo m = cultureType.GetMethod(
"findNextHeir",
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
if (m != null)
{
_heirMethod = m;
_heirApiAvailable = true;
}
}
}
catch
{
_heirApiAvailable = false;
}
_ = _heirTargetSample;
}
private static void ResolveLenses(
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory,
out LifeSagaLens primary,
out LifeSagaLens secondary)
{
var scores = new Dictionary<LifeSagaLens, float>();
void Add(LifeSagaLens lens, float amount)
{
if (lens == LifeSagaLens.None || amount <= 0f)
{
return;
}
if (!scores.TryGetValue(lens, out float cur))
{
cur = 0f;
}
scores[lens] = cur + amount;
}
if (slot != null)
{
if (slot.IsKing || slot.IsClanChief || slot.IsLeader)
{
Add(LifeSagaLens.CrownClan, 40f);
}
if (slot.IsAlpha)
{
Add(LifeSagaLens.PackAlpha, 38f);
}
if (slot.IsPlotAuthor)
{
Add(LifeSagaLens.Plotter, 36f);
}
if (slot.IsFounder)
{
Add(LifeSagaLens.FounderWanderer, 30f);
}
if (slot.IsSingleton)
{
Add(LifeSagaLens.LastOfKind, 34f);
}
if (slot.Kills > 0 || slot.IsArmyCaptain)
{
Add(LifeSagaLens.WarriorKiller, Mathf.Min(35f, 12f + slot.Kills * 0.4f));
}
switch (slot.AdmissionReason)
{
case LifeSagaAdmissionReason.King:
case LifeSagaAdmissionReason.ClanChief:
case LifeSagaAdmissionReason.CityLeader:
Add(LifeSagaLens.CrownClan, 8f);
break;
case LifeSagaAdmissionReason.Alpha:
Add(LifeSagaLens.PackAlpha, 8f);
break;
case LifeSagaAdmissionReason.PlotAuthor:
Add(LifeSagaLens.Plotter, 8f);
break;
case LifeSagaAdmissionReason.Founder:
Add(LifeSagaLens.FounderWanderer, 8f);
break;
case LifeSagaAdmissionReason.Singleton:
Add(LifeSagaLens.LastOfKind, 8f);
break;
case LifeSagaAdmissionReason.NotableKills:
Add(LifeSagaLens.WarriorKiller, 8f);
break;
}
}
if (memory != null)
{
for (int i = 0; i < memory.Facts.Count; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null)
{
continue;
}
switch (f.Kind)
{
case LifeSagaFactKind.BondFormed:
case LifeSagaFactKind.BondBroken:
case LifeSagaFactKind.HardArcLove:
case LifeSagaFactKind.HardArcGrief:
Add(LifeSagaLens.LoveGrief, 12f);
break;
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.RivalEarned:
case LifeSagaFactKind.HardArcCombat:
Add(LifeSagaLens.WarriorKiller, 14f);
break;
case LifeSagaFactKind.WarJoin:
case LifeSagaFactKind.HardArcWar:
Add(LifeSagaLens.CrownClan, 10f);
Add(LifeSagaLens.WarriorKiller, 8f);
break;
case LifeSagaFactKind.PlotJoin:
case LifeSagaFactKind.HardArcPlot:
Add(LifeSagaLens.Plotter, 16f);
break;
case LifeSagaFactKind.Founding:
case LifeSagaFactKind.HomeGained:
case LifeSagaFactKind.HomeLost:
Add(LifeSagaLens.FounderWanderer, 12f);
break;
case LifeSagaFactKind.RoleChange:
if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
Add(LifeSagaLens.PackAlpha, 14f);
}
else
{
Add(LifeSagaLens.CrownClan, 12f);
}
break;
case LifeSagaFactKind.ParentChild:
Add(LifeSagaLens.PackAlpha, 6f);
Add(LifeSagaLens.LoveGrief, 4f);
break;
}
}
}
if (actor != null && LifeSagaRoster.IsPackAlpha(actor))
{
Add(LifeSagaLens.PackAlpha, 10f);
}
primary = LifeSagaLens.FounderWanderer;
secondary = LifeSagaLens.None;
float best = -1f;
float second = -1f;
foreach (KeyValuePair<LifeSagaLens, float> kv in scores)
{
if (kv.Value > best)
{
second = best;
secondary = primary;
best = kv.Value;
primary = kv.Key;
}
else if (kv.Value > second)
{
second = kv.Value;
secondary = kv.Key;
}
}
if (secondary == primary || second < best * 0.45f)
{
secondary = LifeSagaLens.None;
}
}
private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (slot != null)
{
if (slot.IsKing)
{
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
}
if (slot.IsClanChief) return "Clan chief";
if (slot.IsLeader)
{
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
}
if (slot.IsAlpha) return "Pack alpha";
if (slot.IsArmyCaptain) return "Army captain";
if (slot.IsFounder) return "Founder";
if (slot.IsPlotAuthor) return "Plotter";
if (slot.IsSingleton) return "Last of their kind";
}
if (memory != null)
{
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding)
{
return "Founder";
}
}
}
return "A life in motion";
}
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
{
return LifeSagaMemory.FormatFactLine(rival);
}
if (slot != null)
{
switch (slot.AdmissionReason)
{
case LifeSagaAdmissionReason.King:
return "Carries a kingdom's fate";
case LifeSagaAdmissionReason.Alpha:
return "Holds the pack together";
case LifeSagaAdmissionReason.PlotAuthor:
return "A scheme is still unfolding";
case LifeSagaAdmissionReason.Singleton:
return "Their lineage hangs by a thread";
case LifeSagaAdmissionReason.Founder:
return "What they built still needs defending";
}
}
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
{
return "Succession points toward " + SafeName(heir);
}
return memory != null && memory.Facts.Count > 0
? LifeSagaMemory.FormatFactLine(memory.Facts[0])
: "";
}
private static void BuildCast(
LifeSagaViewModel model,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory,
LifeSagaLens primary)
{
var seen = new HashSet<long>();
long selfId = model.UnitId;
seen.Add(selfId);
void AddLive(Actor other, string label, string truth)
{
if (other == null || model.Cast.Count >= 4)
{
return;
}
long id = EventFeedUtil.SafeId(other);
if (id == 0 || !seen.Add(id))
{
return;
}
model.Cast.Add(new LifeSagaCastMember
{
UnitId = id,
Name = SafeName(other),
SpeciesId = other.asset != null ? other.asset.id : "",
Relation = label,
Truth = truth,
Alive = true
});
}
void AddObserved(LifeSagaIdentity identity, string label, string truth)
{
if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id))
{
return;
}
bool alive = EventFeedUtil.FindAliveById(identity.Id) != null;
model.Cast.Add(new LifeSagaCastMember
{
UnitId = identity.Id,
Name = FirstNonEmpty(identity.Name, "Someone"),
SpeciesId = identity.SpeciesId ?? "",
Relation = label,
Truth = truth,
Alive = alive
});
}
if (actor != null)
{
AddLive(ActorRelation.GetLover(actor), "Lover", "is");
AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is");
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
{
AddLive(parent, "Parent", "is");
}
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
{
AddLive(child, "Child", "is");
}
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
{
AddLive(heir, "Likely successor", "likely");
}
}
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
{
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
if (liveRival != null)
{
AddLive(liveRival, "Earned rival", "observed");
}
else
{
AddObserved(rivalFact.Other, "Earned rival", "observed");
}
}
// Former bonds from memory when live cast still has room.
if (memory != null && model.Cast.Count < 4)
{
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
{
continue;
}
if (f.Kind == LifeSagaFactKind.BondBroken)
{
AddObserved(f.Other, "Lost lover", "observed");
}
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
{
AddObserved(f.Other, "Child", "observed");
}
}
}
// Pack / kin peers fill remaining slots for pack-shaped and family-shaped lenses.
if (actor != null
&& model.Cast.Count < 4
&& (primary == LifeSagaLens.PackAlpha || primary == LifeSagaLens.LoveGrief))
{
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))
{
if (peer == null || peer == actor)
{
continue;
}
AddLive(peer, peerLabel, "is");
if (model.Cast.Count >= 4)
{
break;
}
}
}
_ = slot;
}
private static void BuildEvidence(
LifeSagaViewModel model,
LifeSagaLens primary,
LifeSagaLens secondary,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
model.EvidenceTitle = EvidenceTitle(primary);
model.EvidenceLine = EvidenceLine(primary, slot, actor, memory);
if (secondary != LifeSagaLens.None)
{
model.SecondaryEvidenceTitle = EvidenceTitle(secondary);
model.SecondaryEvidenceLine = EvidenceLine(secondary, slot, actor, memory);
}
}
private static string EvidenceTitle(LifeSagaLens lens)
{
switch (lens)
{
case LifeSagaLens.CrownClan: return "Realm";
case LifeSagaLens.PackAlpha: return "Pack";
case LifeSagaLens.WarriorKiller: return "Combat";
case LifeSagaLens.Plotter: return "Intrigue";
case LifeSagaLens.FounderWanderer: return "Journey";
case LifeSagaLens.LoveGrief: return "Bond";
case LifeSagaLens.LastOfKind: return "Survival";
default: return "Evidence";
}
}
private static string EvidenceLine(
LifeSagaLens lens,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
switch (lens)
{
case LifeSagaLens.CrownClan:
{
string realm = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
int wars = CountFacts(memory, LifeSagaFactKind.WarJoin);
if (!string.IsNullOrEmpty(realm) && wars > 0)
{
return realm + " · " + wars + " war" + (wars == 1 ? "" : "s") + " observed";
}
return FirstNonEmpty(realm, "Holding power");
}
case LifeSagaLens.PackAlpha:
{
int children = CountFacts(memory, LifeSagaFactKind.ParentChild);
return children > 0
? "Pack line with " + children + " recorded offspring"
: "Leads the family pack";
}
case LifeSagaLens.WarriorKiller:
{
int kills = slot?.Kills ?? 0;
int encounters = CountFacts(memory, LifeSagaFactKind.CombatEncounter)
+ CountFacts(memory, LifeSagaFactKind.Kill);
if (kills > 0)
{
return kills + " kills · " + encounters + " remembered clashes";
}
return encounters > 0 ? encounters + " remembered clashes" : "A fighter's reputation";
}
case LifeSagaLens.Plotter:
{
int plots = CountFacts(memory, LifeSagaFactKind.PlotJoin);
return plots > 0 ? plots + " plot thread" + (plots == 1 ? "" : "s") : "Scheming still";
}
case LifeSagaLens.FounderWanderer:
{
int founded = CountFacts(memory, LifeSagaFactKind.Founding);
return founded > 0 ? "Founded " + founded + " lasting place" + (founded == 1 ? "" : "s")
: FirstNonEmpty(CityName(actor), "Building a place in the world");
}
case LifeSagaLens.LoveGrief:
{
int bonds = CountFacts(memory, LifeSagaFactKind.BondFormed);
int losses = CountFacts(memory, LifeSagaFactKind.BondBroken);
if (losses > 0)
{
return "Carrying " + losses + " observed loss" + (losses == 1 ? "" : "es");
}
return bonds > 0 ? "Bound " + bonds + " time" + (bonds == 1 ? "" : "s") : "A life shaped by attachment";
}
case LifeSagaLens.LastOfKind:
return "The last living thread of their kind";
default:
return "";
}
}
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
{
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 5);
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact f = facts[i];
if (f == null)
{
continue;
}
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
if (stake != null
&& ((!string.IsNullOrEmpty(stake.Key)
&& string.Equals(stake.Key, f.Key, StringComparison.Ordinal))
|| (stake.Kind == f.Kind && stake.Other.Id != 0 && stake.Other.Id == f.Other.Id)))
{
continue;
}
string line = LifeSagaMemory.FormatFactLine(f);
if (string.IsNullOrEmpty(line)
|| string.Equals(line, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|| !seenLines.Add(line))
{
continue;
}
model.Legacy.Add(new LifeSagaLegacyBeat
{
Kind = f.Kind,
Line = line,
Truth = f.Resolved ? "observed" : "observed",
At = f.At,
DateLabel = f.DateLabel ?? ""
});
if (model.Legacy.Count >= 4)
{
break;
}
}
}
private static int CountFacts(LifeSagaLifeMemory memory, LifeSagaFactKind kind)
{
if (memory == null)
{
return 0;
}
int n = 0;
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i] != null && memory.Facts[i].Kind == kind)
{
n++;
}
}
return n;
}
private static string KingdomName(Actor actor)
{
try { return actor?.kingdom?.name ?? ""; }
catch { return ""; }
}
private static string CityName(Actor actor)
{
try { return actor?.city?.name ?? ""; }
catch { return ""; }
}
private static string SafeName(Actor actor)
{
try
{
string name = actor?.getName();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch
{
// ignore
}
return actor?.asset?.id ?? "";
}
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 LifeSagaLens
{
None,
CrownClan,
PackAlpha,
WarriorKiller,
Plotter,
FounderWanderer,
LoveGrief,
LastOfKind
}
public sealed class LifeSagaCastMember
{
public long UnitId;
public string Name = "";
public string SpeciesId = "";
public string Relation = "";
public string Truth = "is";
public bool Alive;
}
public sealed class LifeSagaLegacyBeat
{
public LifeSagaFactKind Kind;
public string Line = "";
public string Truth = "observed";
public float At;
public string DateLabel = "";
}
public sealed class LifeSagaViewModel
{
public long UnitId;
public string Name = "";
public string Title = "";
public string SpeciesId = "";
public string StakeLine = "";
public string StakeProvenance = "";
public LifeSagaLens PrimaryLens;
public LifeSagaLens SecondaryLens;
public string EvidenceTitle = "";
public string EvidenceLine = "";
public string SecondaryEvidenceTitle = "";
public string SecondaryEvidenceLine = "";
public readonly List<LifeSagaCastMember> Cast = new List<LifeSagaCastMember>(4);
public readonly List<LifeSagaLegacyBeat> Legacy = new List<LifeSagaLegacyBeat>(5);
public string ToPlainText()
{
var sb = new StringBuilder(320);
if (!string.IsNullOrEmpty(Name))
{
sb.Append(Name);
}
if (!string.IsNullOrEmpty(Title))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(Title);
}
if (!string.IsNullOrEmpty(StakeLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append("At stake ").Append(StakeLine);
}
if (Cast.Count > 0)
{
if (sb.Length > 0) sb.Append('\n');
sb.Append("Cast ");
for (int i = 0; i < Cast.Count; i++)
{
if (i > 0) sb.Append(" · ");
sb.Append(Cast[i].Relation).Append(' ').Append(Cast[i].Name);
}
}
if (!string.IsNullOrEmpty(EvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(EvidenceTitle).Append(" ").Append(EvidenceLine);
}
if (!string.IsNullOrEmpty(SecondaryEvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(SecondaryEvidenceTitle).Append(" ").Append(SecondaryEvidenceLine);
}
for (int i = 0; i < Legacy.Count; i++)
{
if (sb.Length > 0) sb.Append('\n');
sb.Append("Legacy ").Append(Legacy[i].Line);
}
return sb.ToString();
}
}

View file

@ -8,33 +8,48 @@ using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Dossier rail of life-saga MCs. Click toggles Prefer (soft favorite). Hover shows overview card.
/// Dossier rail of life-saga MCs as a horizontal strip under the tabs.
/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews.
/// </summary>
public static class LifeSagaRail
{
private const int MaxSlots = LifeSagaRoster.Cap;
private const float SlotSize = 14f;
public const float SlotSize = 18f;
private const float Gap = 2f;
private const float HoverWidth = 300f;
private const float HoverLineHeight = 12f;
private const float HoverMaxHeight = 84f;
private const float BadgeSize = 7f;
private const float FaceRefreshSeconds = 0.28f;
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly Slot[] Slots = new Slot[MaxSlots];
private static GameObject _row;
private static RectTransform _rowRt;
private static Text _hoverText;
private static string _fingerprint = "";
private static float _nextFaceRefreshAt;
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
public static string LastTipSample { get; private set; } = "";
/// <summary>Harness: true when the watched MC slot used Active (not Prefer) chrome.</summary>
public static bool LastActiveHighlight { get; private set; }
/// <summary>Harness: Prefer pip visible on first Prefer'd slot.</summary>
public static bool LastPreferPipVisible { get; private set; }
/// <summary>Harness: structured presentation row count for open preview (compat).</summary>
public static int LastHoverRowCount { get; private set; }
/// <summary>Harness: fixed saga body height while previewing.</summary>
public static float LastHoverHeight { get; private set; }
private sealed class Slot
{
public GameObject Root;
public RectTransform Rt;
public Image Bg;
public Image Face;
public Text Label;
public Image Badge;
public Image PreferPip;
public Button Button;
public long UnitId;
public string Tip = "";
@ -44,6 +59,43 @@ public static class LifeSagaRail
public static bool Visible =>
_row != null && _row.activeSelf;
/// <summary>Natural rail row width after last Refresh (glyph strip only).</summary>
public static float RowWidthPx =>
_rowRt != null && _row != null && _row.activeSelf ? _rowRt.sizeDelta.x : 0f;
/// <summary>Natural rail row height after last Refresh.</summary>
public static float RowHeightPx =>
Visible ? SlotSize : 0f;
/// <summary>True when the pointer is over any visible rail glyph.</summary>
public static bool IsPointerOverRail()
{
if (_row == null || !_row.activeInHierarchy || _rowRt == null)
{
return false;
}
if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null))
{
return true;
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Rt == null || !Slots[i].Root.activeInHierarchy)
{
continue;
}
if (RectTransformUtility.RectangleContainsScreenPoint(Slots[i].Rt, Input.mousePosition, null))
{
return true;
}
}
return false;
}
public static int LastShownCount { get; private set; }
public static void EnsureBuilt(Transform parent)
@ -65,14 +117,6 @@ public static class LifeSagaRail
Slots[i] = BuildSlot(_row.transform, i);
}
_hoverText = HudCanvas.MakeText(_row.transform, "Hover", "", 7);
_hoverText.color = HudTheme.Muted;
_hoverText.alignment = TextAnchor.UpperLeft;
_hoverText.horizontalOverflow = HorizontalWrapMode.Wrap;
_hoverText.verticalOverflow = VerticalWrapMode.Overflow;
_hoverText.raycastTarget = false;
_hoverText.gameObject.SetActive(false);
_row.SetActive(false);
}
@ -81,7 +125,11 @@ public static class LifeSagaRail
_fingerprint = "";
LastShownCount = 0;
LastTipSample = "";
HideHover();
LastActiveHighlight = false;
LastPreferPipVisible = false;
LastHoverRowCount = 0;
LastHoverHeight = 0f;
LifeSagaViewController.CancelPreviewAndPause();
if (_row != null)
{
_row.SetActive(false);
@ -117,60 +165,118 @@ public static class LifeSagaRail
return;
}
LifeSagaRoster.Tick(Time.unscaledTime);
float now = Time.unscaledTime;
LifeSagaRoster.Tick(now);
SlotScratch.Clear();
LifeSagaRoster.CopySlots(SlotScratch);
string fp = Fingerprint(SlotScratch);
if (string.Equals(fp, _fingerprint, StringComparison.Ordinal)
&& !LifeSagaRoster.Dirty)
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|| LifeSagaRoster.Dirty;
bool facesDue = now >= _nextFaceRefreshAt;
if (!structureChanged && !facesDue)
{
return;
}
LifeSagaRoster.Dirty = false;
_fingerprint = fp;
LastShownCount = 0;
LastTipSample = "";
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
float x = 0f;
for (int i = 0; i < Slots.Length; i++)
if (structureChanged)
{
Slot slot = Slots[i];
if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0)
LifeSagaRoster.Dirty = false;
_fingerprint = fp;
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
LastPreferPipVisible = false;
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
float x = 0f;
for (int i = 0; i < Slots.Length; i++)
{
slot.Root.SetActive(false);
slot.UnitId = 0;
slot.Tip = "";
continue;
Slot slot = Slots[i];
if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0)
{
slot.Root.SetActive(false);
slot.UnitId = 0;
slot.Tip = "";
continue;
}
LifeSagaSlot saga = SlotScratch[i];
bool watching = watchId != 0 && watchId == saga.UnitId;
bool prefer = saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
ApplyFace(slot, saga);
ApplyBadge(slot, saga);
if (slot.PreferPip != null)
{
slot.PreferPip.gameObject.SetActive(prefer);
if (prefer)
{
LastPreferPipVisible = true;
}
}
if (watching)
{
slot.Bg.color = HudTheme.ValueOrange;
LastActiveHighlight = true;
}
else if (prefer)
{
slot.Bg.color = new Color(0.45f, 0.38f, 0.22f, 0.95f);
}
else
{
slot.Bg.color = new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.45f);
}
slot.Tip = LifeSagaPresentation.BuildPlainText(saga);
if (LastShownCount == 0)
{
LastTipSample = slot.Tip ?? "";
}
slot.Rt.anchoredPosition = new Vector2(x, 0f);
slot.Root.SetActive(true);
x += SlotSize + Gap;
LastShownCount++;
}
LifeSagaSlot saga = SlotScratch[i];
bool watching = watchId != 0 && watchId == saga.UnitId;
bool lit = watching || saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
slot.Label.text = GlyphFor(saga);
slot.Bg.color = lit
? HudTheme.ValueOrange
: new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.55f);
slot.Label.color = lit ? Color.black : HudTheme.Body;
slot.Tip = LifeSagaOverview.Build(saga);
if (LastShownCount == 0)
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0);
}
else
{
for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++)
{
LastTipSample = slot.Tip ?? "";
}
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
{
continue;
}
slot.Rt.anchoredPosition = new Vector2(x, 0f);
slot.Root.SetActive(true);
x += SlotSize + Gap;
LastShownCount++;
ApplyFace(Slots[i], SlotScratch[i]);
}
}
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0);
if (LifeSagaViewController.IsHoverPreview)
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
LastHoverRowCount = model.Cast.Count + model.Legacy.Count
+ (string.IsNullOrEmpty(model.StakeLine) ? 0 : 1);
LastHoverHeight = LifeSagaPanel.BodyHeight;
}
else
{
LastHoverRowCount = 0;
LastHoverHeight = 0f;
}
_nextFaceRefreshAt = now + FaceRefreshSeconds;
}
/// <summary>Place rail under the spine; returns next y.</summary>
/// <summary>
/// Place the horizontal rail under the tabs (stable top chrome).
/// Returns the next y below the rail.
/// </summary>
public static float Place(float y, float padX)
{
if (!Visible || _rowRt == null)
@ -179,76 +285,35 @@ public static class LifeSagaRail
}
_rowRt.anchoredPosition = new Vector2(padX, -y);
float next = y + SlotSize + 2f;
if (_hoverText != null && _hoverText.gameObject.activeSelf)
{
RectTransform ht = _hoverText.GetComponent<RectTransform>();
ht.pivot = new Vector2(0f, 1f);
ht.anchorMin = new Vector2(0f, 1f);
ht.anchorMax = new Vector2(0f, 1f);
ht.anchoredPosition = new Vector2(0f, -(SlotSize + 1f));
float hoverH = EstimateHoverHeight(_hoverText.text);
ht.sizeDelta = new Vector2(HoverWidth, hoverH);
next += hoverH + 1f;
}
return next;
return y + SlotSize + 2f;
}
internal static void ShowHover(int index)
{
if (_hoverText == null || index < 0 || index >= Slots.Length)
if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
return;
}
string tip = Slots[index].Tip;
if (string.IsNullOrEmpty(tip) && Slots[index].UnitId != 0)
{
LifeSagaSlot s = LifeSagaRoster.Get(Slots[index].UnitId);
tip = LifeSagaOverview.Build(s);
Slots[index].Tip = tip;
}
if (string.IsNullOrEmpty(tip))
{
HideHover();
return;
}
_hoverText.text = tip;
_hoverText.gameObject.SetActive(true);
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
LastHoverHeight = LifeSagaPanel.BodyHeight;
WatchCaption.RequestRelayout();
}
internal static void HideHover()
{
if (_hoverText != null)
{
_hoverText.gameObject.SetActive(false);
_hoverText.text = "";
}
LifeSagaViewController.EndHoverPreview();
WatchCaption.RequestRelayout();
}
private static float EstimateHoverHeight(string text)
public static long UnitIdAt(int index)
{
if (string.IsNullOrEmpty(text))
if (index < 0 || index >= Slots.Length || Slots[index] == null)
{
return HoverLineHeight;
return 0;
}
int lines = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
lines++;
}
}
return Mathf.Min(HoverMaxHeight, lines * HoverLineHeight + 2f);
return Slots[index].UnitId;
}
private static Slot BuildSlot(Transform parent, int index)
@ -265,16 +330,66 @@ public static class LifeSagaRail
bg.color = HudTheme.ToolFill;
bg.raycastTarget = true;
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 9);
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(go.transform, false);
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.anchorMin = Vector2.zero;
faceRt.anchorMax = Vector2.one;
faceRt.offsetMin = new Vector2(1f, 1f);
faceRt.offsetMax = new Vector2(-1f, -1f);
Image face = faceGo.GetComponent<Image>();
face.color = Color.white;
face.preserveAspect = true;
face.raycastTarget = false;
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 8);
label.alignment = TextAnchor.MiddleCenter;
label.resizeTextForBestFit = false;
label.raycastTarget = false;
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = Vector2.zero;
labelRt.offsetMax = Vector2.zero;
label.gameObject.SetActive(false);
GameObject badgeGo = new GameObject("Badge", typeof(RectTransform), typeof(Image));
badgeGo.transform.SetParent(go.transform, false);
RectTransform badgeRt = badgeGo.GetComponent<RectTransform>();
badgeRt.pivot = new Vector2(1f, 1f);
badgeRt.anchorMin = new Vector2(1f, 1f);
badgeRt.anchorMax = new Vector2(1f, 1f);
badgeRt.anchoredPosition = new Vector2(1f, 1f);
badgeRt.sizeDelta = new Vector2(BadgeSize, BadgeSize);
Image badge = badgeGo.GetComponent<Image>();
badge.color = Color.white;
badge.preserveAspect = true;
badge.raycastTarget = false;
badgeGo.SetActive(false);
GameObject pipGo = new GameObject("PreferPip", typeof(RectTransform), typeof(Image));
pipGo.transform.SetParent(go.transform, false);
RectTransform pipRt = pipGo.GetComponent<RectTransform>();
pipRt.pivot = new Vector2(0f, 0f);
pipRt.anchorMin = new Vector2(0f, 0f);
pipRt.anchorMax = new Vector2(0f, 0f);
pipRt.anchoredPosition = new Vector2(1f, 1f);
pipRt.sizeDelta = new Vector2(5f, 5f);
Image pip = pipGo.GetComponent<Image>();
pip.color = new Color(1f, 0.85f, 0.25f, 1f);
pip.raycastTarget = false;
Sprite star = HudIcons.FromUiIcon("iconFavorite")
?? HudIcons.FromUiIcon("iconStar")
?? HudIcons.FromUiIcon("iconImportant");
if (star != null)
{
pip.sprite = star;
}
pipGo.SetActive(false);
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
@ -292,12 +407,120 @@ public static class LifeSagaRail
Root = go,
Rt = rt,
Bg = bg,
Face = face,
Label = label,
Badge = badge,
PreferPip = pip,
Button = button,
Hover = hover
};
}
private static void ApplyFace(Slot slot, LifeSagaSlot saga)
{
if (slot == null || saga == null)
{
return;
}
Actor actor = saga.Resolve();
Sprite sprite = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
{
slot.Face.sprite = sprite;
slot.Face.color = Color.white;
slot.Face.preserveAspect = true;
slot.Face.gameObject.SetActive(true);
RectTransform faceRt = slot.Face.rectTransform;
faceRt.anchorMin = new Vector2(0.5f, 0.5f);
faceRt.anchorMax = new Vector2(0.5f, 0.5f);
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchoredPosition = Vector2.zero;
float w = Mathf.Max(1f, sprite.rect.width);
float h = Mathf.Max(1f, sprite.rect.height);
float max = SlotSize - 2f;
float scale = max / Mathf.Max(w, h);
faceRt.sizeDelta = new Vector2(w * scale, h * scale);
if (slot.Label != null)
{
slot.Label.gameObject.SetActive(false);
}
return;
}
if (slot.Face != null)
{
slot.Face.sprite = null;
slot.Face.gameObject.SetActive(false);
}
if (slot.Label != null)
{
slot.Label.text = GlyphFallback(saga);
slot.Label.color = HudTheme.Body;
slot.Label.gameObject.SetActive(true);
}
}
private static void ApplyBadge(Slot slot, LifeSagaSlot saga)
{
if (slot?.Badge == null || saga == null)
{
return;
}
string icon = null;
if (saga.IsKing)
{
icon = "iconKings";
}
else if (saga.IsClanChief)
{
icon = "iconClan";
}
else if (saga.IsLeader)
{
icon = "iconLeaders";
}
else if (saga.IsAlpha)
{
icon = "iconFamilies";
}
else if (saga.IsArmyCaptain)
{
icon = "iconArmy";
}
else if (saga.IsFounder)
{
icon = "iconCity";
}
else if (saga.IsPlotAuthor)
{
icon = "iconPlot";
}
Sprite sprite = !string.IsNullOrEmpty(icon) ? HudIcons.FromUiIcon(icon) : null;
if (sprite == null && saga.IsKing)
{
sprite = HudIcons.FromUiIcon("iconKing");
}
if (sprite == null)
{
slot.Badge.gameObject.SetActive(false);
return;
}
slot.Badge.sprite = sprite;
slot.Badge.gameObject.SetActive(true);
}
internal static void HarnessClick(int index)
{
OnClick(index);
}
private static void OnClick(int index)
{
if (index < 0 || index >= Slots.Length)
@ -317,38 +540,32 @@ public static class LifeSagaRail
}
LifeSagaRoster.TogglePrefer(id);
if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|| (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
&& !LifeSagaViewController.IsHoverPreview))
{
LifeSagaViewController.SetPersistentSaga(id);
}
_fingerprint = "";
LifeSagaRoster.Dirty = true;
Refresh();
WatchCaption.RequestRelayout();
}
private static string GlyphFor(LifeSagaSlot saga)
private static string GlyphFallback(LifeSagaSlot saga)
{
if (saga == null)
{
return "?";
}
if (saga.IsKing)
{
return "K";
}
if (saga.IsAlpha)
{
return "A";
}
if (saga.IsLeader)
{
return "L";
}
if (saga.Prefer || saga.GameFavorite)
{
return "★";
}
if (saga.IsKing) return "K";
if (saga.IsClanChief) return "C";
if (saga.IsAlpha) return "A";
if (saga.IsArmyCaptain) return "P";
if (saga.IsLeader) return "L";
if (saga.Prefer || saga.GameFavorite) return "★";
string sp = saga.SpeciesId ?? "";
if (sp.Length > 0)
{
@ -376,7 +593,10 @@ public static class LifeSagaRail
.Append(s.Prefer ? 'P' : '-')
.Append(s.GameFavorite ? 'F' : '-')
.Append(s.IsKing ? 'K' : '-')
.Append(s.IsClanChief ? 'C' : '-')
.Append(s.IsArmyCaptain ? 'A' : '-')
.Append(s.Chapters.Count).Append(':')
.Append(Mathf.RoundToInt(LifeSagaRoster.EffectiveInterest(s))).Append(':')
.Append(s.DisplayName ?? "").Append(';');
}

View file

@ -11,4 +11,7 @@ public sealed class LifeSagaRailHover : MonoBehaviour, IPointerEnterHandler, IPo
public void OnPointerEnter(PointerEventData eventData) => LifeSagaRail.ShowHover(Index);
public void OnPointerExit(PointerEventData eventData) => LifeSagaRail.HideHover();
// Relayout may briefly deactivate slots; do not cancel sticky hover here.
// PointerExit + ViewController grace own the leave path; Clear() owns teardown.
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,317 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease.
/// </summary>
public static class LifeSagaViewController
{
public const string ReadPauseOwner = "saga_hover";
public const float HoverExitGraceSeconds = 0.35f;
public enum Tab
{
Dossier = 0,
Saga = 1
}
private static Tab _selectedTab = Tab.Dossier;
private static long _persistentSagaId;
private static bool _persistentPinned;
private static long _hoverPreviewId;
private static Tab _tabBeforeHover = Tab.Dossier;
private static float _hoverExitAt = -1f;
private static bool _pauseHeld;
private static bool _loreReturnToSaga;
private static long _loreReturnSagaId;
public static Tab SelectedTab => _selectedTab;
public static long PersistentSagaId => _persistentSagaId;
public static long HoverPreviewId => _hoverPreviewId;
public static bool IsHoverPreview => _hoverPreviewId != 0;
public static Tab EffectiveTab =>
_hoverPreviewId != 0 ? Tab.Saga : _selectedTab;
public static long EffectiveSagaId
{
get
{
if (_hoverPreviewId != 0)
{
return _hoverPreviewId;
}
return ResolvePersistentSagaId();
}
}
public static void SelectTab(Tab tab)
{
// Explicit tab click wins over hover preview restore.
if (_hoverPreviewId != 0 || _pauseHeld)
{
CancelPreviewAndPause();
_tabBeforeHover = tab;
}
_selectedTab = tab;
if (tab == Tab.Saga)
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else if (!IsValidMc(_persistentSagaId) || !_persistentPinned)
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
}
public static void SetPersistentSaga(long unitId)
{
if (unitId == 0 || !LifeSagaRoster.IsMc(unitId))
{
return;
}
_persistentSagaId = unitId;
_persistentPinned = true;
}
/// <summary>Stash Saga return target before opening Lore from the Saga panel.</summary>
public static void StashLoreReturn(long unitId)
{
_loreReturnToSaga = true;
_loreReturnSagaId = unitId;
}
/// <summary>
/// After Lore closes, restore Saga tab + subject when a stash is present.
/// Returns true when a restore was applied.
/// </summary>
public static bool TryRestoreAfterLore()
{
if (!_loreReturnToSaga)
{
return false;
}
long id = _loreReturnSagaId;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
CancelPreviewAndPause();
_selectedTab = Tab.Saga;
_tabBeforeHover = Tab.Saga;
if (IsValidMc(id))
{
_persistentSagaId = id;
_persistentPinned = true;
}
else
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
return true;
}
public static void BeginHoverPreview(long unitId)
{
if (unitId == 0)
{
return;
}
_hoverExitAt = -1f;
if (_hoverPreviewId == 0)
{
_tabBeforeHover = _selectedTab;
}
_hoverPreviewId = unitId;
if (!_pauseHeld)
{
InterestDirector.AcquireReadPause(ReadPauseOwner);
_pauseHeld = true;
}
WatchCaption.RequestRelayout();
}
public static void EndHoverPreview()
{
if (_hoverPreviewId == 0 && !_pauseHeld)
{
return;
}
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
}
public static void Tick()
{
if (_hoverPreviewId == 0)
{
return;
}
// Keep preview alive while the pointer stays on the dossier/rail chrome.
if (WatchCaption.IsPointerOverPanel() || LifeSagaRail.IsPointerOverRail())
{
_hoverExitAt = -1f;
return;
}
// Pointer left chrome after a panel dwell cleared the timer - re-arm grace.
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
return;
}
if (Time.unscaledTime < _hoverExitAt)
{
return;
}
FinishHoverExit();
}
public static void CancelPreviewAndPause()
{
_hoverExitAt = -1f;
_hoverPreviewId = 0;
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
_pauseHeld = false;
}
}
public static void Clear()
{
CancelPreviewAndPause();
_selectedTab = Tab.Dossier;
_persistentSagaId = 0;
_persistentPinned = false;
_tabBeforeHover = Tab.Dossier;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
}
/// <summary>
/// Harness: simulate Tick clearing the exit timer while the pointer dwells on the panel.
/// </summary>
public static void HarnessSimulatePanelDwell()
{
if (_hoverPreviewId == 0)
{
return;
}
_hoverExitAt = -1f;
}
/// <summary>
/// Harness: run one hover Tick as if the pointer is off rail and panel chrome.
/// </summary>
public static void HarnessTickExitAwayFromChrome()
{
if (_hoverPreviewId == 0)
{
return;
}
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
return;
}
if (Time.unscaledTime < _hoverExitAt)
{
return;
}
FinishHoverExit();
}
public static LifeSagaViewModel BuildEffectivePresentation()
{
long id = EffectiveSagaId;
return id != 0 ? LifeSagaPresentation.Build(id) : new LifeSagaViewModel();
}
private static void FinishHoverExit()
{
_hoverExitAt = -1f;
_hoverPreviewId = 0;
_selectedTab = _tabBeforeHover;
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
_pauseHeld = false;
}
WatchCaption.RequestRelayout();
}
/// <summary>
/// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC.
/// Never falls back to first-roster / stale last-active filler.
/// </summary>
private static long ResolvePersistentSagaId()
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
return watch;
}
if (_persistentPinned && IsValidMc(_persistentSagaId))
{
return _persistentSagaId;
}
_persistentSagaId = 0;
_persistentPinned = false;
return 0;
}
private static bool IsValidMc(long unitId)
{
return unitId != 0
&& LifeSagaRoster.IsMc(unitId)
&& EventFeedUtil.FindAliveById(unitId) != null;
}
private static long WatchedMcId()
{
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
return watchId != 0 && LifeSagaRoster.IsMc(watchId) ? watchId : 0;
}
}

View file

@ -388,6 +388,25 @@ public static class StoryPlanner
_softFillQuietUntil = Mathf.Max(_softFillQuietUntil, now + quiet);
}
/// <summary>
/// Bounded quiet refresh after a soft crumb ends so AFK does not immediately re-surf.
/// Caps total quiet at 1.5x the base window from now.
/// </summary>
public static void NoteSoftCrumbQuietRefresh(float now = -1f)
{
if (now < 0f)
{
now = Time.unscaledTime;
}
StoryWeights s = InterestScoringConfig.Story;
float quiet = s.softFillQuietSeconds > 0f ? s.softFillQuietSeconds : 18f;
float refresh = quiet * 0.35f;
float capUntil = now + quiet * 1.5f;
float next = Mathf.Max(_softFillQuietUntil, now + refresh);
_softFillQuietUntil = Mathf.Min(next, capUntil);
}
public static void Tick(float now)
{
TickCrisis(now);

View file

@ -229,6 +229,68 @@ public sealed class UnitDossier
FillTopStatuses(dossier, actor);
}
/// <summary>
/// Highest-value live status using the same scalable ranking as dossier chips.
/// </summary>
public static string ReadTopStatus(Actor actor)
{
var scratch = new UnitDossier();
FillTopStatuses(scratch, actor);
return scratch.TopStatuses.Count > 0
? scratch.TopStatuses[0]?.Name ?? ""
: "";
}
/// <summary>
/// Interesting, non-species-default traits using the dossier's live trait ranking.
/// </summary>
public static List<string> ReadStandoutTraitNames(Actor actor, int max, int minScore = 80)
{
var names = new List<string>(Math.Max(0, max));
if (actor == null || max <= 0)
{
return names;
}
try
{
var ranked = new List<ActorTrait>();
if (!actor.hasTraits())
{
return names;
}
foreach (ActorTrait trait in actor.getTraits())
{
if (trait == null
|| string.IsNullOrEmpty(trait.id)
|| IsDefaultTraitForActor(trait, actor)
|| TraitInterestScore(trait, actor) < minScore)
{
continue;
}
ranked.Add(trait);
}
ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor)));
for (int i = 0; i < ranked.Count && names.Count < max; i++)
{
string name = TraitDisplayName(ranked[i]);
if (!string.IsNullOrEmpty(name) && !names.Contains(name))
{
names.Add(name);
}
}
}
catch
{
// Exotic actors may not expose a normal trait collection.
}
return names;
}
public bool ContainsIgnoreCase(string needle)
{
if (string.IsNullOrEmpty(needle))

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -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"
}

View file

@ -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).

View file

@ -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