Life saga first pass

This commit is contained in:
DazedAnon 2026-07-21 23:46:20 -05:00
parent c23b2b9c1e
commit 798e3dce05
26 changed files with 6095 additions and 3027 deletions

View file

@ -2864,6 +2864,92 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: "story_cleared");
break;
case "interest_saga_clear":
LifeSagaRoster.Clear();
LifeSagaRail.Clear();
_cmdOk++;
Emit(cmd, ok: true, detail: "saga_cleared");
break;
case "interest_story_purge_leftovers":
StoryPlanner.PurgeCloserLeftovers();
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"purged crisisActive={StoryPlanner.CrisisActive} softQuiet={StoryPlanner.SoftFillQuietActive}");
break;
case "story_resume_parked":
{
var board = new System.Collections.Generic.List<StoryArc>(4);
StoryPlanner.CopyBoard(board);
string id = "";
for (int i = 0; i < board.Count; i++)
{
if (board[i] != null && board[i].IsParked)
{
id = board[i].Id ?? "";
break;
}
}
bool ok = !string.IsNullOrEmpty(id) && StoryPlanner.TryResume(id);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok,
detail:
$"resumeOk={ok} id='{id}' watching='{StoryPlanner.Active?.Id}' board={StoryPlanner.BoardCount}");
break;
}
case "saga_prefer_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
LifeSagaRoster.Tick(Time.unscaledTime);
bool ok = id != 0 && LifeSagaRoster.IsMc(id) && LifeSagaRoster.TogglePrefer(id);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok,
detail:
$"preferToggle id={id} onRoster={LifeSagaRoster.IsMc(id)} prefer={LifeSagaRoster.IsPrefer(id)}");
break;
}
case "saga_force_admit_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
bool ok = LifeSagaRoster.HarnessForceAdmit(focus);
if (ok)
{
LifeSagaRoster.StampChapter(id, "Harness chapter", Time.unscaledTime);
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"admit id={id} roster={LifeSagaRoster.Count} on={ok}");
break;
}
case "interest_variety_note":
{
// Stamp the current (or pending needle in value) as the last shown variety arc.
@ -3417,6 +3503,9 @@ public static class AgentHarness
if (Queue.Count == 0)
{
// Free AFK after harness must not inherit a leftover crisis closer tip.
StoryPlanner.PurgeCloserLeftovers();
LifeSagaRoster.Clear();
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}");
LogService.LogInfo(
@ -7758,7 +7847,118 @@ public static class AgentHarness
string have = arc != null ? arc.Phase.ToString() : "None";
pass = !string.IsNullOrEmpty(want)
&& string.Equals(have, want, StringComparison.OrdinalIgnoreCase);
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}";
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold} board={StoryPlanner.BoardCount}";
break;
}
case "story_board_count":
{
int want = ParseInt(cmd.value, 0);
int have = StoryPlanner.BoardCount;
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
if (cmp == "min")
{
pass = have >= want;
}
else if (cmp == "max")
{
pass = have <= want;
}
else
{
pass = have == want;
}
detail = $"boardCount={have} want={want} cmp={cmp}";
break;
}
case "story_parked_count":
{
int want = ParseInt(cmd.value, 0);
var board = new System.Collections.Generic.List<StoryArc>(4);
StoryPlanner.CopyBoard(board);
int parked = 0;
for (int i = 0; i < board.Count; i++)
{
if (board[i] != null && board[i].IsParked)
{
parked++;
}
}
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
pass = cmp == "min" ? parked >= want : parked == want;
detail = $"parked={parked} want={want} board={board.Count}";
break;
}
case "browse_paused":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = SpectatorMode.BrowsePaused;
pass = have == want;
detail = $"browsePaused={have} want={want} idle={SpectatorMode.Active}";
break;
}
case "saga_roster_count":
case "story_rail_count":
{
LifeSagaRoster.Tick(Time.unscaledTime);
LifeSagaRail.Refresh();
int want = ParseInt(cmd.value, 0);
int have = LifeSagaRoster.Count;
int shown = LifeSagaRail.LastShownCount;
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
string expectKey = (cmd.expect ?? "").Trim().ToLowerInvariant();
int probe = expectKey == "story_rail_count" ? shown : have;
pass = cmp == "min" ? probe >= want : probe == want;
detail = $"roster={have} railShown={shown} want={want} cmp={cmp} visible={LifeSagaRail.Visible}";
break;
}
case "saga_overview_tip":
case "story_rail_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 = $"sagaTip='{have}' any='{any}' pass={pass}";
break;
}
case "saga_overview_tip_not":
case "story_rail_tip_not":
{
string have = SampleRailTip();
string any = (cmd.value ?? "").Trim();
pass = true;
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 = false;
break;
}
}
}
detail = $"railTip='{have}' not_any='{any}' pass={pass}";
break;
}
case "crisis_active":
@ -7770,6 +7970,14 @@ public static class AgentHarness
detail = $"crisisActive={have} want={want} kind={crisis?.Kind} phase={crisis?.Phase}";
break;
}
case "soft_fill_quiet":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = StoryPlanner.SoftFillQuietActive;
pass = have == want;
detail = $"softFillQuiet={have} want={want}";
break;
}
case "crisis_kind":
{
string want = (cmd.value ?? "").Trim();
@ -7894,11 +8102,21 @@ public static class AgentHarness
break;
}
case "ledger_has_focus":
case "saga_has_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
pass = id != 0 && CharacterLedger.IsOnLedger(id);
detail = $"ledger focus={SafeName(focus)} id={id} on={pass} heat={CharacterLedger.Heat(id):0.##}";
LifeSagaRoster.Tick(Time.unscaledTime);
pass = id != 0 && LifeSagaRoster.IsMc(id);
detail = $"saga focus={SafeName(focus)} id={id} on={pass} prefer={LifeSagaRoster.IsPrefer(id)}";
break;
}
case "saga_prefer_focus_on":
{
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}";
break;
}
case "tip_matches_unit":
@ -11945,6 +12163,26 @@ public static class AgentHarness
return defaultValue;
}
private static string SampleRailTip()
{
LifeSagaRoster.Tick(Time.unscaledTime);
LifeSagaRail.Refresh();
string have = LifeSagaRail.LastTipSample ?? "";
if (!string.IsNullOrEmpty(have))
{
return have;
}
var slots = new System.Collections.Generic.List<LifeSagaSlot>(10);
LifeSagaRoster.CopySlots(slots);
if (slots.Count > 0 && slots[0] != null)
{
return LifeSagaOverview.Build(slots[0]) ?? "";
}
return "";
}
private static bool ParseBool(string raw, bool defaultValue)
{
if (string.IsNullOrEmpty(raw))

View file

@ -888,7 +888,7 @@ public static class ChronicleHud
if (pauseIdle && SpectatorMode.Active)
{
_pausedIdleForLore = true;
SpectatorMode.SetActive(false, quiet: true);
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
}
else if (pauseIdle)
{
@ -1015,12 +1015,12 @@ public static class ChronicleHud
_followFocus = followFocus;
_detailPane = DetailPane.Life;
// Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
// would instantly drop the camera off the browse subject.
// Pause idle first: browsePause keeps StoryPlanner / registry for Lore close resume.
// SetActive(false) still ClearFollow() so focus the browse subject after.
if (pauseIdle && SpectatorMode.Active)
{
_pausedIdleForLore = true;
SpectatorMode.SetActive(false, quiet: true);
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
}
else if (pauseIdle)
{
@ -2283,7 +2283,7 @@ public static class ChronicleHud
{
if (SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
WatchCaption.PinWhilePaused();
WatchCaption.ShowStatusBanner(
captured.Kind == ChronicleKind.World || captured.IsLegend

View file

@ -85,5 +85,25 @@ public static partial class EventCatalog
IsFallback = true
};
}
/// <summary>
/// True when <paramref name="disasterId"/> is an authored overlay or a live
/// <see cref="AssetManager.disasters"/> asset (not tip-string heuristics).
/// </summary>
public static bool IsLiveOrAuthored(string disasterId)
{
if (string.IsNullOrEmpty(disasterId))
{
return false;
}
string id = disasterId.Trim();
if (HasAuthored(id))
{
return true;
}
return ActivityAssetCatalog.TryGetDisasterAsset(id) != null;
}
}
}

View file

@ -182,9 +182,11 @@ public static partial class EventCatalog
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
float strength = 40f;
string category = "WorldLog";
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
// Live disaster library / authored disaster overlays - not tip-string matching.
if (Disaster.IsLiveOrAuthored(id))
{
strength = 95f;
DisasterInterestEntry d = Disaster.GetOrFallback(id);
strength = d.EventStrength > 0f ? d.EventStrength : 95f;
category = "Disaster";
}
@ -202,6 +204,23 @@ public static partial class EventCatalog
};
}
/// <summary>WorldLog / disaster tips classified as Disaster via catalog or live library.</summary>
public static bool IsDisasterCategory(string assetId)
{
if (string.IsNullOrEmpty(assetId))
{
return false;
}
if (TryGet(assetId, out WorldLogEventEntry entry)
&& string.Equals(entry.Category, "Disaster", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return Disaster.IsLiveOrAuthored(assetId);
}
public static bool TryGetEventStrength(string assetId, out float eventStrength)
{
WorldLogEventEntry entry = GetOrFallback(assetId);

View file

@ -119,6 +119,9 @@ internal static class HarnessScenarios
return StoryDecisionIntentLive();
case "story_aftermath_war":
return StoryAftermathWar();
case "story_crisis_hygiene":
case "crisis_hygiene":
return StoryCrisisHygiene();
case "story_crisis_war":
case "crisis_war":
return StoryCrisisWar();
@ -133,10 +136,27 @@ internal static class HarnessScenarios
return StoryNearTie();
case "story_arc_combat":
return StoryArcCombat();
case "story_lore_pause_keeps":
case "lore_pause_keeps_story":
return StoryLorePauseKeeps();
case "story_board_park_resume":
case "story_park_resume":
return StoryBoardParkResume();
case "story_arc_preempt_resume":
return StoryArcPreemptResume();
case "story_ledger_revisit":
return StoryLedgerRevisit();
// Retired with CharacterLedger - soft MC bias lives in saga_* scenarios.
return SagaCameraPrefersMc();
case "saga_roster_diversity":
return SagaRosterDiversity();
case "saga_camera_prefers_mc":
return SagaCameraPrefersMc();
case "saga_replace_on_death":
return SagaReplaceOnDeath();
case "saga_prefer_soft_bias":
return SagaPreferSoftBias();
case "saga_overview_has_mc":
return SagaOverviewHasMc();
case "story_spine_scoped":
return StorySpineScoped();
case "story_family_variety":
@ -318,9 +338,17 @@ internal static class HarnessScenarios
Nested("reg_combat_wiped_side", "combat_wiped_side"),
Nested("reg_story_near_tie", "story_near_tie"),
Nested("reg_story_arc", "story_arc_combat"),
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
Nested("reg_story_board_park", "story_board_park_resume"),
Nested("reg_story_aftermath_war", "story_aftermath_war"),
Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"),
Nested("reg_story_spine", "story_spine_scoped"),
Nested("reg_story_family", "story_family_variety"),
Nested("reg_story_ledger", "story_ledger_revisit"),
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_diversity", "saga_roster_diversity"),
Nested("reg_saga_death", "saga_replace_on_death"),
Nested("reg_discovery", "discovery"),
Nested("reg_worldlog", "world_log"),
Nested("reg_presentability", "camera_presentability"),
@ -1702,11 +1730,57 @@ internal static class HarnessScenarios
Step("saw24", "director_run", wait: 1.5f),
Step("saw25", "assert", expect: "tip_matches_any", value: "lingers|stands over|war front"),
Step("saw26", "assert", expect: "tip_asset", value: "aftermath_"),
// Large war auto-opens a crisis; short-arc linger must quiet-close it (no dual closer).
Step("saw27", "assert", expect: "crisis_active", value: "false"),
Step("saw28", "assert", expect: "soft_fill_quiet", value: "true"),
Step("saw29", "director_run", wait: 2.0f),
Step("saw30", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"),
Step("saw90", "fast_timing", value: "false"),
Step("saw99", "snapshot"),
};
}
/// <summary>
/// Large WarFront opens crisis; short-arc war linger owns the exit; purge leaves no
/// pending epilogue_crisis for free AFK.
/// </summary>
private static List<HarnessCommand> StoryCrisisHygiene()
{
return new List<HarnessCommand>
{
Step("sch0", "dismiss_windows"),
Step("sch1", "wait_world"),
Step("sch2", "set_setting", expect: "enabled", value: "true"),
Step("sch3", "fast_timing", value: "true"),
Step("sch4", "spawn", asset: "human", count: 2),
Step("sch5", "spectator", value: "off"),
Step("sch6", "spectator", value: "on"),
Step("sch7", "pick_unit", asset: "human"),
Step("sch8", "focus", asset: "human"),
Step("sch9", "interest_end_session"),
Step("sch10", "interest_story_clear"),
Step("sch20", "interest_war_session", asset: "human", expect: "sch_war"),
Step("sch21", "director_run", wait: 0.8f),
Step("sch22", "assert", expect: "tip_matches_any", value: "War -"),
// Live 12v8 sides open crisis without harness force.
Step("sch23", "assert", expect: "crisis_active", value: "true"),
Step("sch24", "assert", expect: "crisis_kind", value: "War"),
Step("sch30", "interest_expire_pending", value: ""),
Step("sch31", "interest_mark_sticky_cold"),
Step("sch32", "director_run", wait: 1.5f),
Step("sch33", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sch34", "assert", expect: "crisis_active", value: "false"),
Step("sch35", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"),
Step("sch36", "assert", expect: "soft_fill_quiet", value: "true"),
Step("sch40", "interest_story_purge_leftovers"),
Step("sch41", "assert", expect: "crisis_active", value: "false"),
Step("sch42", "director_run", wait: 1.0f),
Step("sch43", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"),
Step("sch90", "fast_timing", value: "false"),
Step("sch99", "snapshot"),
};
}
/// <summary>
/// Large WarFront opens a crisis chapter; forced closer is epilogue_crisis.
/// </summary>
@ -2573,6 +2647,95 @@ internal static class HarnessScenarios
}
/// <summary>Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath.</summary>
/// <summary>
/// Lore browse freeze must not wipe StoryPlanner; close resumes the same climax.
/// </summary>
private static List<HarnessCommand> StoryLorePauseKeeps()
{
return new List<HarnessCommand>
{
Step("slp0", "dismiss_windows"),
Step("slp1", "wait_world"),
Step("slp2", "set_setting", expect: "enabled", value: "true"),
Step("slp3", "fast_timing", value: "true"),
Step("slp4", "spawn", asset: "human", count: 2),
Step("slp5", "spectator", value: "off"),
Step("slp6", "spectator", value: "on"),
Step("slp7", "pick_unit", asset: "human"),
Step("slp8", "focus", asset: "human"),
Step("slp9", "interest_end_session"),
Step("slp10", "interest_story_clear"),
Step("slp20", "interest_war_session", asset: "human", expect: "slp_war"),
Step("slp21", "director_run", wait: 0.6f),
Step("slp22", "assert", expect: "tip_matches_any", value: "War -"),
Step("slp23", "assert", expect: "story_phase", value: "Climax"),
Step("slp24", "assert", expect: "story_board_count", value: "1", label: "min"),
Step("slp30", "lore_open_focus"),
Step("slp31", "assert", expect: "idle", value: "false"),
Step("slp32", "assert", expect: "browse_paused", value: "true"),
Step("slp33", "assert", expect: "story_phase", value: "Climax"),
Step("slp34", "assert", expect: "story_board_count", value: "1", label: "min"),
Step("slp40", "lore_close"),
Step("slp41", "wait", wait: 0.25f),
Step("slp42", "assert", expect: "idle", value: "true"),
Step("slp43", "assert", expect: "browse_paused", value: "false"),
Step("slp44", "assert", expect: "story_phase", value: "Climax"),
Step("slp45", "assert", expect: "tip_matches_any", value: "War -"),
Step("slp90", "fast_timing", value: "false"),
Step("slp99", "snapshot"),
};
}
/// <summary>
/// Hard war storyline parks when forced off-story; returning to War tip resumes the board slot.
/// </summary>
private static List<HarnessCommand> StoryBoardParkResume()
{
return new List<HarnessCommand>
{
Step("sbp0", "dismiss_windows"),
Step("sbp1", "wait_world"),
Step("sbp2", "set_setting", expect: "enabled", value: "true"),
Step("sbp3", "fast_timing", value: "true"),
Step("sbp4", "spawn", asset: "human", count: 2),
Step("sbp5", "spectator", value: "off"),
Step("sbp6", "spectator", value: "on"),
Step("sbp7", "pick_unit", asset: "human"),
Step("sbp8", "focus", asset: "human"),
Step("sbp9", "interest_end_session"),
Step("sbp10", "interest_story_clear"),
Step("sbp20", "interest_war_session", asset: "human", expect: "sbp_war"),
Step("sbp21", "director_run", wait: 0.6f),
Step("sbp22", "assert", expect: "tip_matches_any", value: "War -"),
Step("sbp23", "assert", expect: "story_phase", value: "Climax"),
Step("sbp23b", "director_run", wait: 0.3f),
// Force an unrelated tip (war hold blocks ordinary epic inject cut-ins).
Step("sbp30", "interest_force_session", asset: "human",
label: "ParkProbe", tier: "Epic", expect: "sbp_park_probe",
value: "lead=event;evt=150"),
Step("sbp31", "assert", expect: "tip_contains", value: "ParkProbe"),
Step("sbp32", "assert", expect: "story_parked_count", value: "1", label: "min"),
Step("sbp33", "assert", expect: "story_board_count", value: "1", label: "min"),
// Resume parked short arc without rail Commit (rail is life-saga now).
Step("sbp40", "story_resume_parked"),
Step("sbp41", "assert", expect: "story_parked_count", value: "0"),
Step("sbp42", "assert", expect: "story_phase", value: "Climax"),
Step("sbp43", "assert", expect: "story_board_count", value: "1"),
// A second distinct war theater parks the first and watches the new climax.
Step("sbp50", "interest_force_session", asset: "human",
label: "ParkProbe2", tier: "Epic", expect: "sbp_park_probe2",
value: "lead=event;evt=150"),
Step("sbp51", "assert", expect: "story_parked_count", value: "1", label: "min"),
Step("sbp52", "interest_war_session", asset: "human", expect: "sbp_war2"),
Step("sbp53", "director_run", wait: 1.0f),
Step("sbp54", "assert", expect: "tip_matches_any", value: "War -"),
Step("sbp55", "assert", expect: "story_phase", value: "Climax"),
Step("sbp56", "assert", expect: "story_board_count", value: "2", label: "min"),
Step("sbp90", "fast_timing", value: "false"),
Step("sbp99", "snapshot"),
};
}
private static List<HarnessCommand> StoryArcCombat()
{
return new List<HarnessCommand>
@ -2588,6 +2751,9 @@ internal static class HarnessScenarios
Step("sac8", "pick_unit", asset: "human"),
Step("sac9", "focus", asset: "human"),
Step("sac10", "interest_end_session"),
Step("sac10b", "interest_story_clear"),
Step("sac10c", "interest_variety_clear"),
Step("sac10d", "interest_expire_pending", value: ""),
Step("sac20", "interest_combat_session", asset: "human", value: "wolf", expect: "sac_pack"),
Step("sac20b", "combat_isolate_pair", value: "20"),
Step("sac21", "status_apply", asset: "human", value: "invincible"),
@ -2596,7 +2762,10 @@ internal static class HarnessScenarios
Step("sac24", "assert", expect: "story_phase", value: "Climax"),
Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"),
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"),
Step("sac26", "age_current", wait: 10f),
Step("sac26b", "interest_expire_pending", value: ""),
Step("sac27", "interest_mark_combat_cold"),
Step("sac28", "director_run", wait: 1.5f),
Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
@ -2706,39 +2875,154 @@ internal static class HarnessScenarios
};
}
/// <summary>Featured unit wins a near-tie against a stranger milestone.</summary>
private static List<HarnessCommand> StoryLedgerRevisit()
/// <summary>MC on the life-saga roster wins a near-tie against a stranger tip.</summary>
private static List<HarnessCommand> SagaCameraPrefersMc()
{
return new List<HarnessCommand>
{
Step("sl0", "dismiss_windows"),
Step("sl1", "wait_world"),
Step("sl2", "set_setting", expect: "enabled", value: "true"),
Step("sl3", "fast_timing", value: "true"),
Step("sl4", "spawn", asset: "human", count: 2),
Step("sl5", "spectator", value: "off"),
Step("sl6", "spectator", value: "on"),
Step("sl7", "pick_unit", asset: "human"),
Step("sl8", "focus", asset: "human"),
Step("sl9", "interest_end_session"),
// Feature unit A via a watched tip.
Step("sl10", "interest_inject", asset: "human", label: "FeaturedLife", tier: "Action",
expect: "sl_feat", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
Step("sl11", "director_run", wait: 1.2f),
Step("sl12", "assert", expect: "tip_contains", value: "FeaturedLife"),
Step("sl13", "assert", expect: "ledger_has_focus"),
Step("sl14", "interest_end_session"),
Step("sl15", "interest_expire_pending", value: ""),
// Near-tie: same strength on focus vs other human - ledger/causal heat should tip focus.
Step("sl20", "interest_inject", asset: "human", label: "LedgerWin", tier: "Action",
expect: "sl_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
Step("sl21", "interest_inject", asset: "human", label: "LedgerLose", tier: "Action",
expect: "sl_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
Step("sl22", "director_run", wait: 1.2f),
Step("sl23", "assert", expect: "tip_contains", value: "LedgerWin"),
Step("sl24", "assert", expect: "tip_not_contains", value: "LedgerLose"),
Step("sl90", "fast_timing", value: "false"),
Step("sl99", "snapshot"),
Step("scm0", "dismiss_windows"),
Step("scm1", "wait_world"),
Step("scm2", "set_setting", expect: "enabled", value: "true"),
Step("scm3", "fast_timing", value: "true"),
Step("scm4", "spawn", asset: "human", count: 2),
Step("scm5", "spectator", value: "off"),
Step("scm6", "spectator", value: "on"),
Step("scm7", "pick_unit", asset: "human"),
Step("scm8", "focus", asset: "human"),
Step("scm9", "interest_end_session"),
Step("scm10", "interest_saga_clear"),
Step("scm11", "saga_force_admit_focus"),
Step("scm12", "assert", expect: "saga_has_focus"),
Step("scm13", "interest_expire_pending", value: ""),
Step("scm20", "interest_inject", asset: "human", label: "SagaMcWin", tier: "Action",
expect: "scm_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
Step("scm21", "interest_inject", asset: "human", label: "SagaMcLose", tier: "Action",
expect: "scm_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
Step("scm22", "director_run", wait: 1.2f),
Step("scm23", "assert", expect: "tip_contains", value: "SagaMcWin"),
Step("scm24", "assert", expect: "tip_not_contains", value: "SagaMcLose"),
Step("scm90", "fast_timing", value: "false"),
Step("scm99", "snapshot"),
};
}
/// <summary>Prefer toggle soft-biases a near-tie toward the Prefer'd MC.</summary>
private static List<HarnessCommand> SagaPreferSoftBias()
{
return new List<HarnessCommand>
{
Step("spb0", "dismiss_windows"),
Step("spb1", "wait_world"),
Step("spb2", "set_setting", expect: "enabled", value: "true"),
Step("spb3", "fast_timing", value: "true"),
Step("spb4", "spawn", asset: "human", count: 2),
Step("spb5", "spectator", value: "off"),
Step("spb6", "spectator", value: "on"),
Step("spb7", "pick_unit", asset: "human"),
Step("spb8", "focus", asset: "human"),
Step("spb9", "interest_end_session"),
Step("spb10", "interest_saga_clear"),
Step("spb11", "saga_force_admit_focus"),
Step("spb12", "saga_prefer_focus"),
Step("spb13", "assert", expect: "saga_prefer_focus_on"),
Step("spb14", "interest_expire_pending", value: ""),
Step("spb20", "interest_inject", asset: "human", label: "PreferWin", tier: "Action",
expect: "spb_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
Step("spb21", "interest_inject", asset: "human", label: "PreferLose", tier: "Action",
expect: "spb_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
Step("spb22", "director_run", wait: 1.2f),
Step("spb23", "assert", expect: "tip_contains", value: "PreferWin"),
Step("spb24", "assert", expect: "tip_not_contains", value: "PreferLose"),
Step("spb90", "fast_timing", value: "false"),
Step("spb99", "snapshot"),
};
}
/// <summary>Saga overview hover has MC name and no planner jargon / trait descs.</summary>
private static List<HarnessCommand> SagaOverviewHasMc()
{
return new List<HarnessCommand>
{
Step("som0", "dismiss_windows"),
Step("som1", "wait_world"),
Step("som2", "set_setting", expect: "enabled", value: "true"),
Step("som3", "fast_timing", value: "true"),
Step("som4", "spawn", asset: "human", count: 1),
Step("som5", "spectator", value: "off"),
Step("som6", "spectator", value: "on"),
Step("som7", "pick_unit", asset: "human"),
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"),
Step("som14", "assert", expect: "story_rail_count", value: "1", label: "min"),
Step("som90", "fast_timing", value: "false"),
Step("som99", "snapshot"),
};
}
/// <summary>Multi-species notables fill distinct roster slots.</summary>
private static List<HarnessCommand> SagaRosterDiversity()
{
return new List<HarnessCommand>
{
Step("srd0", "dismiss_windows"),
Step("srd1", "wait_world"),
Step("srd2", "set_setting", expect: "enabled", value: "true"),
Step("srd3", "fast_timing", value: "true"),
Step("srd4", "spawn", asset: "human", count: 1),
Step("srd5", "spawn", asset: "elf", count: 1),
Step("srd6", "spawn", asset: "dwarf", count: 1),
Step("srd7", "spectator", value: "off"),
Step("srd8", "spectator", value: "on"),
Step("srd9", "interest_saga_clear"),
Step("srd10", "pick_unit", asset: "human"),
Step("srd11", "focus", asset: "human"),
Step("srd12", "saga_force_admit_focus"),
Step("srd13", "pick_unit", asset: "elf"),
Step("srd14", "focus", asset: "elf"),
Step("srd15", "saga_force_admit_focus"),
Step("srd16", "pick_unit", asset: "dwarf"),
Step("srd17", "focus", asset: "dwarf"),
Step("srd18", "saga_force_admit_focus"),
Step("srd19", "assert", expect: "saga_roster_count", value: "3", label: "min"),
Step("srd90", "fast_timing", value: "false"),
Step("srd99", "snapshot"),
};
}
/// <summary>Dead MC leaves the living roster; a new living unit can refill the slot.</summary>
private static List<HarnessCommand> SagaReplaceOnDeath()
{
return new List<HarnessCommand>
{
Step("srdth0", "dismiss_windows"),
Step("srdth1", "wait_world"),
Step("srdth2", "set_setting", expect: "enabled", value: "true"),
Step("srdth3", "fast_timing", value: "true"),
// Single MC so auto-refill cannot mask the death prune.
Step("srdth4", "spawn", asset: "human", count: 1),
Step("srdth5", "spectator", value: "off"),
Step("srdth6", "spectator", value: "on"),
Step("srdth7", "pick_unit", asset: "human"),
Step("srdth8", "focus", asset: "human"),
Step("srdth9", "interest_saga_clear"),
Step("srdth10", "saga_force_admit_focus"),
Step("srdth11", "assert", expect: "saga_roster_count", value: "1"),
Step("srdth12", "kill_focus"),
Step("srdth13", "wait", wait: 0.3f),
Step("srdth14", "director_run", wait: 0.8f),
Step("srdth15", "assert", expect: "saga_roster_count", value: "0"),
Step("srdth16", "spawn", asset: "human", count: 1),
Step("srdth17", "pick_unit", asset: "human"),
Step("srdth18", "focus", asset: "human"),
Step("srdth19", "saga_force_admit_focus"),
Step("srdth20", "assert", expect: "saga_roster_count", value: "1"),
Step("srdth90", "fast_timing", value: "false"),
Step("srdth99", "snapshot"),
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,659 @@
using System;
using UnityEngine;
namespace IdleSpectator;
public static partial class InterestDirector
{
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
{
if (candidate == null)
{
return false;
}
// Same FixedDwell beat already watched on this subject - never cut back in.
if (InterestVariety.IsBeatCooled(candidate))
{
InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key);
return false;
}
if (_current == null)
{
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
}
float now = Time.unscaledTime;
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
if (IsCombatPartnerSwapNoise(_current, candidate))
{
InterestDropLog.Record(
"combat_partner_hold",
$"cur={_current.Key} next={candidate.Key}");
return false;
}
// Crisis signal tip (war / disaster / outbreak) owns the camera against Mass/Battle scraps.
// Must run before crisis cut-in: a leftover war crisis can MatchCrisis(Mass) while a
// small WarFront tip does not. Drop reason keeps war_theater_hold alias for harness.
if (StoryPlanner.IsCrisisCameraSignal(_current)
&& candidate.Completion == InterestCompletionKind.CombatActive)
{
string holdReason = _current.Completion == InterestCompletionKind.WarFront
? "war_theater_hold"
: "crisis_theater_hold";
InterestDropLog.Record(
holdReason,
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
return false;
}
// Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars
// against aftermath / ambient peers that outscore the closer on raw EventStrength.
if (StoryPlanner.CrisisActive
&& StoryPlanner.MatchesCrisis(candidate)
&& !StoryPlanner.MatchesCrisis(_current)
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
{
return true;
}
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
// Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom
// theater may reclaim the chapter (Mass → War) without waiting for combat cold.
if (CombatFightIsHot(_current, now)
&& !IsSameStoryArc(_current, candidate)
&& candidate.Completion != InterestCompletionKind.CombatActive)
{
bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.SharesKingdomTheater(_current, candidate);
if (warTheaterReclaim)
{
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
}
if (IsCombatUrgentPeer(_current, candidate))
{
InterestDropLog.Record(
"combat_urgent_cut",
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
}
InterestDropLog.Record(
"combat_hold",
$"cur={_current.Key} next={candidate.Key}");
return false;
}
bool inGrace = InQuietGrace;
if (inGrace)
{
// Story aftermath / same-arc continuation may cut freely during quiet grace.
if (IsSameStoryArc(_current, candidate)
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
{
return true;
}
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
// Prevents family-join / love status from stealing a fight between swings.
if (_current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.StatusPhase
|| _current.Completion == InterestCompletionKind.HappinessGrief
|| IsStickyStoryScene(_current)))
{
float graceCur = _current.TotalScore;
float graceNext = candidate.TotalScore;
float onCurrentGrace = now - _currentStartedAt;
ScoringWeights gw = InterestScoringConfig.W;
float graceMargin = IsSoftStickyCluster(_current)
? gw.cutInMargin
: Mathf.Max(
gw.cutInMargin,
gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f);
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
{
float valveMargin = gw.stickyVarietyValveMargin > 0f
? gw.stickyVarietyValveMargin
: 20f;
graceMargin = Mathf.Min(graceMargin, valveMargin);
if (graceNext >= graceCur - gw.rotateSlack
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
{
return true;
}
}
return graceNext >= graceCur + graceMargin
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
{
return false;
}
bool protectedScene = SessionProtected(now, onCurrent);
ScoringWeights w = InterestScoringConfig.W;
float curScore = _current.TotalScore;
float nextScore = candidate.TotalScore;
bool nextFill = InterestScoring.IsFillScore(nextScore);
bool curAmbient = IsAmbientShot(_current);
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
bool sticky = IsStickyStoryScene(_current);
float cutMargin = sticky
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
: w.cutInMargin;
bool varietyValve = VarietyValveOpen(_current, onCurrent)
&& IsVarietyValveCandidate(candidate);
if (varietyValve)
{
float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
cutMargin = Mathf.Min(cutMargin, valveMargin);
}
// Ambient fill: any real event may take the camera immediately (no min dwell).
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
{
return true;
}
// Soft family pack: yield to discrete unit events after a short hold.
if (IsSoftStickyCluster(_current)
&& !IsSoftStickyCluster(candidate)
&& !nextFill
&& !IsAmbientShot(candidate)
&& onCurrent >= SoftClusterYieldSeconds
&& nextScore >= curScore - w.rotateSlack)
{
return true;
}
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
{
bool hotEnough = InterestScoring.IsHotScore(nextScore)
|| nextScore >= curScore - w.rotateSlack;
bool storyEnough = nextScore >= w.noticeScoreMin
|| candidate.EventStrength >= w.noticeScoreMin;
if (hotEnough || storyEnough)
{
InterestDropLog.Record(
"variety_valve",
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
return true;
}
}
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
{
return true;
}
// StoryPlanner hard-arc / crisis hold against unrelated peers.
float storyHold = Mathf.Max(
StoryPlanner.ArcHoldMargin(_current, candidate),
StoryPlanner.CrisisHoldMargin(_current, candidate));
if (storyHold > 0f)
{
cutMargin = Mathf.Max(cutMargin, storyHold);
}
// Instant score-margin cut - no settle grace, no MinDwell block.
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
if (nextScore >= curScore + effectiveMargin)
{
return true;
}
if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
{
InterestDropLog.Record(
"below_margin",
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
+ (varietyValve ? " valve" : "")
+ (storyHold > 0f ? " story" : ""));
}
// Fill never cuts a protected non-fill session without margin (already failed above).
if (nextFill && !curFill && protectedScene)
{
return false;
}
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
if (nextFill && curFill && onCurrent < fillRotate)
{
return false;
}
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
if ((protectedScene || sticky) && !curAmbient)
{
return false;
}
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return true;
}
return onCurrent >= MinDwellFor(_current)
&& sinceSwitch >= _switchCooldown
&& nextScore >= curScore - w.rotateSlack;
}
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
private const float SoftClusterYieldSeconds = 4f;
private static bool IsSoftStickyCluster(InterestCandidate c) =>
c != null && c.Completion == InterestCompletionKind.FamilyPack;
/// <summary>
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
/// </summary>
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
{
return false;
}
ScoringWeights w = InterestScoringConfig.W;
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
if (next.TotalScore >= current.TotalScore + stickyMargin)
{
return true;
}
// King / leadership deaths sit under the epic floor (king_killed=88) but must cut
// live scraps - soak missed a king slaying while parked on a Duel thrash.
if (IsLeadershipDeathPeer(next))
{
return true;
}
float epicMin = w.combatUrgentEventStrengthMin > 0f
? w.combatUrgentEventStrengthMin
: 95f;
float urgentMargin = w.combatUrgentCutInMargin > 0f
? w.combatUrgentCutInMargin
: 20f;
return next.EventStrength >= epicMin
&& next.TotalScore >= current.TotalScore + urgentMargin;
}
/// <summary>
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
/// </summary>
private static bool IsLeadershipDeathPeer(InterestCandidate c)
{
if (c == null)
{
return false;
}
string id = (c.AssetId ?? "").ToLowerInvariant();
if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0
|| id.IndexOf("king_dead", StringComparison.Ordinal) >= 0
|| id.IndexOf("king_died", StringComparison.Ordinal) >= 0
|| id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0
|| id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0)
{
return true;
}
if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return id.IndexOf("kill", StringComparison.Ordinal) >= 0
|| id.IndexOf("dead", StringComparison.Ordinal) >= 0
|| id.IndexOf("died", StringComparison.Ordinal) >= 0
|| id.IndexOf("slain", StringComparison.Ordinal) >= 0;
}
/// <summary>
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
/// </summary>
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
!_varietyValveUsed
&& VarietyValveTimeReady(scene, onCurrent)
&& !CombatFightIsHot(scene, Time.unscaledTime);
private static bool IsStickyStoryScene(InterestCandidate c)
{
if (c == null)
{
return false;
}
// FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster).
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusPhase
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| c.Completion == InterestCompletionKind.HappinessGrief)
{
return true;
}
// Hatch FixedDwell is a story beat - hold against weak peers.
if (!string.IsNullOrEmpty(c.HappinessEffectId)
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
if (!string.IsNullOrEmpty(c.Key)
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
{
return true;
}
return false;
}
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
// Hatch: same subject beat may refresh without full margin.
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
{
return true;
}
// Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
// Different combat:pair keys used to soft-cut and thrash Duel partners.
if (current.Completion == InterestCompletionKind.CombatActive
&& next.Completion == InterestCompletionKind.CombatActive
&& IsSameCombatPairArc(current, next))
{
return true;
}
// Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut).
if (current.Completion == InterestCompletionKind.CombatActive
&& next.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.SharesKingdomTheater(current, next))
{
return true;
}
if (current.Completion == InterestCompletionKind.WarFront
&& next.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.SharesKingdomTheater(current, next))
{
return true;
}
if (current.Completion == InterestCompletionKind.HappinessGrief
&& next.Completion == InterestCompletionKind.HappinessGrief
&& current.SubjectId != 0
&& current.SubjectId == next.SubjectId)
{
return true;
}
// StoryPlanner aftermath / epilogue of the active climax.
if (StoryPlanner.IsContinuationOf(current, next))
{
return true;
}
return false;
}
/// <summary>
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
/// </summary>
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
CollectCombatActorIds(current, out long curA, out long curB);
CollectCombatActorIds(next, out long nextA, out long nextB);
bool samePair = curA != 0
&& curB != 0
&& nextA != 0
&& nextB != 0
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
if (samePair)
{
return true;
}
// Duel → Mass/Battle escalate: shared fighter + larger theater.
bool nextMulti = next.ParticipantCount >= 3
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| HasStickyCollectiveMulti(next);
if (!nextMulti)
{
return false;
}
return SharesCombatFighterId(current, nextA)
|| SharesCombatFighterId(current, nextB)
|| SharesCombatFighterId(next, curA)
|| SharesCombatFighterId(next, curB);
}
/// <summary>
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
/// </summary>
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
{
if (current == null
|| next == null
|| current.Completion != InterestCompletionKind.CombatActive
|| next.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
// Multi escalate / different theater is not partner-swap noise.
bool nextMulti = next.ParticipantCount >= 3
|| HasStickyCollectiveMulti(next)
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
&& next.ParticipantCount > 2);
if (nextMulti && !IsThinPairCombat(next))
{
return false;
}
CollectCombatActorIds(current, out long curA, out long curB);
CollectCombatActorIds(next, out long nextA, out long nextB);
if (curA == 0 && curB == 0
&& current.TheaterLeadId == 0
&& current.PairOwnerId == 0
&& current.SubjectId == 0)
{
return false;
}
// Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise.
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
{
if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB))
{
return true;
}
return false;
}
if (!IsThinPairCombat(next))
{
return false;
}
// Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise.
// Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live.
bool sameUnorderedPair = curA != 0
&& curB != 0
&& nextA != 0
&& nextB != 0
&& ((curA == nextA && curB == nextB)
|| (curA == nextB && curB == nextA));
if (sameUnorderedPair)
{
long curSub = current.SubjectId != 0
? current.SubjectId
: (current.PairOwnerId != 0
? current.PairOwnerId
: EventFeedUtil.SafeId(current.FollowUnit));
long nextSub = next.SubjectId != 0
? next.SubjectId
: EventFeedUtil.SafeId(next.FollowUnit);
if (curSub != 0 && nextSub != 0 && curSub != nextSub)
{
return true;
}
return false;
}
// Shared fighter + different partner = attack_target thrash.
return SharesCombatOwnerOrLead(current, nextA, nextB)
|| SharesCombatFighterId(current, nextA)
|| SharesCombatFighterId(current, nextB);
}
private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB)
{
if (current == null)
{
return false;
}
long lead = current.TheaterLeadId != 0
? current.TheaterLeadId
: (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId);
if (lead == 0)
{
lead = EventFeedUtil.SafeId(current.FollowUnit);
}
return lead != 0 && (lead == nextA || lead == nextB);
}
private static bool IsThinPairCombat(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
{
return true;
}
string tip = c.Label ?? "";
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
string key = c.Key ?? "";
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|| (key.StartsWith("combat:", StringComparison.Ordinal)
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
}
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
{
a = 0;
b = 0;
if (c == null)
{
return;
}
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
if (a == 0)
{
a = EventFeedUtil.SafeId(c.FollowUnit);
}
if (b == 0)
{
b = EventFeedUtil.SafeId(c.RelatedUnit);
}
}
private static bool SharesCombatFighterId(InterestCandidate c, long id)
{
if (c == null || id == 0)
{
return false;
}
CollectCombatActorIds(c, out long a, out long b);
return id == a || id == b;
}
private static bool SameKeyPrefix(string a, string b, string prefix)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
{
return false;
}
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|| !b.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}
return string.Equals(a, b, StringComparison.Ordinal);
}
}

File diff suppressed because it is too large Load diff

View file

@ -269,7 +269,9 @@ public static class InterestRegistry
bool match = string.IsNullOrEmpty(needle)
|| (c.Key != null
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|| (c.AssetId != null
&& c.AssetId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
if (match)
{
c.ExpiresAt = now - 0.01f;

View file

@ -160,7 +160,7 @@ public static class InterestScoring
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
float causalBonus = CausalHeat.ScoreBonus(c);
float ledgerBonus = CharacterLedger.ScoreBonus(c);
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
@ -169,7 +169,7 @@ public static class InterestScoring
- repeatPenalty
+ frequencyAdjust
+ causalBonus
+ ledgerBonus
+ sagaBonus
+ ownershipBonus;
string detail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
@ -190,9 +190,9 @@ public static class InterestScoring
detail += $" causal=+{causalBonus:0.#}";
}
if (ledgerBonus > 0.05f)
if (sagaBonus > 0.05f)
{
detail += $" ledger=+{ledgerBonus:0.#}";
detail += $" saga=+{sagaBonus:0.#}";
}
if (ownershipBonus > 0.05f)

View file

@ -36,10 +36,18 @@ public class StoryWeights
public int ledgerCap = 16;
public float ledgerWeight = 8f;
public float historyDensityWindowSeconds = 600f;
/// <summary>Multiply subject ledger heat after a family life chapter tip is shown.</summary>
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
public float ledgerBleedLifeChapter = 0.4f;
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
public float ledgerBleedVillage = 0.82f;
/// <summary>Life-saga roster soft MC tip bonus.</summary>
public float sagaMcWeight = 6f;
/// <summary>Extra soft bonus when the saga slot is Prefer'd.</summary>
public float sagaPreferWeight = 10f;
/// <summary>Extra soft bonus when the unit is a WorldBox favorite.</summary>
public float sagaGameFavoriteWeight = 8f;
/// <summary>Chapter starvation window before dull non-favorite MCs lose rank.</summary>
public float sagaDullSeconds = 300f;
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
public float familyLifeBeatCooldownSeconds = 90f;
@ -70,6 +78,11 @@ public class StoryWeights
public float crisisEpilogueStrength = 52f;
/// <summary>Forced closer max watch (seconds).</summary>
public float crisisEpilogueMaxWatch = 16f;
/// <summary>
/// After a hard story/crisis closer, suppress soft-life crumbs and character-fill
/// for this many seconds so AFK does not immediately crumb-surf.
/// </summary>
public float softFillQuietSeconds = 18f;
}
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>

View file

@ -164,7 +164,7 @@ public static class InterestVariety
}
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
CharacterLedger.CoolAfterSoftLifeBeat(chosen);
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
}
/// <summary>
@ -727,7 +727,33 @@ public static class InterestVariety
return Ranked[0];
}
// Probabilistic among top-N only when scores are within epsilon.
// Near-tie: Prefer'd MC, then any MC, else random among top-N.
InterestCandidate bestSaga = null;
int bestRank = -1;
for (int i = 0; i < topN; i++)
{
if (Ranked[0].TotalScore - Ranked[i].TotalScore >= epsilon)
{
break;
}
int rank = LifeSagaRoster.TipTouchesPrefer(Ranked[i])
? 2
: LifeSagaRoster.TipTouchesMc(Ranked[i])
? 1
: 0;
if (rank > bestRank)
{
bestRank = rank;
bestSaga = Ranked[i];
}
}
if (bestSaga != null && bestRank > 0)
{
return bestSaga;
}
int pick = Rng.Next(topN);
return Ranked[pick];
}

View file

@ -12,6 +12,12 @@ public static class SpectatorMode
public static bool Active { get; private set; }
/// <summary>
/// True when idle was frozen for Lore/browse without wiping StoryPlanner / registry.
/// Resume via <see cref="SetActive"/>(true) restores that session instead of a full clear enable.
/// </summary>
public static bool BrowsePaused { get; private set; }
private static bool _forceFileChecked;
public static void Toggle()
@ -19,7 +25,11 @@ public static class SpectatorMode
SetActive(!Active);
}
public static void SetActive(bool active, bool quiet = false)
/// <param name="browsePause">
/// When disabling: freeze story/crisis/ledger/registry for Lore browse (do not Clear).
/// Ignored when enabling - resume uses <see cref="BrowsePaused"/>.
/// </param>
public static void SetActive(bool active, bool quiet = false, bool browsePause = false)
{
if (active && !ModSettings.Enabled)
{
@ -36,22 +46,45 @@ public static class SpectatorMode
if (Active)
{
WatchCaption.ClearPausePin();
InterestDirector.OnSpectatorEnabled();
bool fromBrowse = BrowsePaused;
BrowsePaused = false;
if (fromBrowse)
{
InterestDirector.OnSpectatorBrowseResumed();
}
else
{
InterestDirector.OnSpectatorEnabled();
}
SpeciesDiscovery.OnSpectatorEnabled();
Chronicle.OnSpectatorEnabled();
ChronicleHud.OnIdleResumed();
FocusRelationshipArrows.OnSpectatorEnabled();
LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
LogService.LogInfo(
fromBrowse
? "[IdleSpectator] Spectator resumed after Lore/browse pause"
: "[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
}
else
{
InterestDirector.OnSpectatorDisabled();
if (browsePause)
{
BrowsePaused = true;
InterestDirector.OnSpectatorBrowsePaused();
}
else
{
BrowsePaused = false;
InterestDirector.OnSpectatorDisabled();
}
SpeciesDiscovery.OnSpectatorDisabled();
FocusRelationshipArrows.OnSpectatorDisabled();
CameraDirector.ClearFollow();
if (quiet)
{
// Manual-input pause: leave dossier up so ShowStatusBanner can reuse it.
// Manual-input / Lore pause: leave dossier up so ShowStatusBanner can reuse it.
StateProbe.Reset();
}
else

View file

@ -1,295 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
/// </summary>
public static class CharacterLedger
{
private struct LedgerEntry
{
public long UnitId;
public float Heat;
public float TouchedAt;
}
private static readonly List<LedgerEntry> Entries = new List<LedgerEntry>(20);
private static readonly List<int> ScratchIdx = new List<int>(8);
public static void Clear()
{
Entries.Clear();
}
public static int Count => Entries.Count;
public static bool IsOnLedger(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId == unitId)
{
return true;
}
}
return false;
}
public static float Heat(long unitId)
{
if (unitId == 0)
{
return 0f;
}
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId == unitId)
{
return Entries[i].Heat;
}
}
return 0f;
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
float h = Heat(c.SubjectId);
if (h <= 0f && c.RelatedId != 0)
{
h = Heat(c.RelatedId) * 0.5f;
}
return h * s.ledgerWeight;
}
public static void Touch(long unitId, float heatAdd = 1f)
{
if (unitId == 0)
{
return;
}
float now = Time.unscaledTime;
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId != unitId)
{
continue;
}
LedgerEntry e = Entries[i];
e.Heat = Mathf.Min(8f, e.Heat + heatAdd);
e.TouchedAt = now;
Entries[i] = e;
return;
}
EnsureCapacityForNew(unitId);
Entries.Add(new LedgerEntry
{
UnitId = unitId,
Heat = Mathf.Clamp(heatAdd, 0.5f, 8f),
TouchedAt = now
});
}
public static void NoteFeatured(InterestCandidate scene)
{
if (scene == null)
{
return;
}
float add = 1f;
if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit)))
{
add += 1.5f;
}
if (InterestScoring.IsNotable(scene.FollowUnit))
{
add += 0.75f;
}
if (scene.SubjectId != 0)
{
Touch(scene.SubjectId, add);
float density = Chronicle.RecentHistoryCount(
scene.SubjectId,
InterestScoringConfig.Story.historyDensityWindowSeconds);
if (density > 0)
{
Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f));
}
}
if (scene.RelatedId != 0)
{
Touch(scene.RelatedId, add * 0.6f);
}
float causal = CausalHeat.Get(scene.SubjectId);
if (causal > 0.5f)
{
Touch(scene.SubjectId, Mathf.Min(1.5f, causal));
}
}
/// <summary>
/// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough
/// to monopolize the next few picks for the same village cast.
/// </summary>
public static void CoolAfterSoftLifeBeat(InterestCandidate scene)
{
if (scene == null || !InterestVariety.IsSoftLifeChapter(scene))
{
return;
}
StoryWeights s = InterestScoringConfig.Story;
float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f;
float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f;
Bleed(scene.SubjectId, subjectMul);
if (scene.RelatedId != 0)
{
Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f));
}
// Mild cool across the rest of the ledger so harness-featured villagers fade.
for (int i = 0; i < Entries.Count; i++)
{
long id = Entries[i].UnitId;
if (id == 0 || id == scene.SubjectId || id == scene.RelatedId)
{
continue;
}
Bleed(id, villageMul);
}
}
public static void Bleed(long unitId, float multiplyHeat)
{
if (unitId == 0 || multiplyHeat >= 0.999f)
{
return;
}
multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f);
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId != unitId)
{
continue;
}
LedgerEntry e = Entries[i];
e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat);
Entries[i] = e;
return;
}
}
public static void Enumerate(List<long> into)
{
if (into == null)
{
return;
}
into.Clear();
for (int i = 0; i < Entries.Count; i++)
{
into.Add(Entries[i].UnitId);
}
}
public static Actor PreferLedgerUnit(IEnumerable<Actor> candidates)
{
if (candidates == null)
{
return null;
}
Actor best = null;
float bestHeat = -1f;
foreach (Actor a in candidates)
{
if (a == null || !a.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(a);
float h = Heat(id);
if (h > bestHeat)
{
bestHeat = h;
best = a;
}
}
return bestHeat > 0f ? best : null;
}
private static void EnsureCapacityForNew(long incomingId)
{
StoryWeights s = InterestScoringConfig.Story;
int cap = s.ledgerCap > 0 ? s.ledgerCap : 16;
if (Entries.Count < cap)
{
return;
}
// Never evict current arc cast.
ScratchIdx.Clear();
int worst = -1;
float worstHeat = float.MaxValue;
for (int i = 0; i < Entries.Count; i++)
{
long id = Entries[i].UnitId;
if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id))
{
continue;
}
if (id == incomingId)
{
continue;
}
float h = Entries[i].Heat;
if (h < worstHeat)
{
worstHeat = h;
worst = i;
}
}
if (worst >= 0)
{
Entries.RemoveAt(worst);
}
else if (Entries.Count > 0)
{
// All protected - drop oldest non-incoming.
Entries.RemoveAt(0);
}
}
}

View file

@ -0,0 +1,504 @@
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.
/// </summary>
public static class LifeSagaOverview
{
private const int EpithetMinScore = 80;
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();
}
public static float StandoutTraitScore(Actor actor)
{
ActorTrait top = PickTopTrait(actor, out int score);
if (top == null)
{
return 0f;
}
return Mathf.Max(0, score);
}
private static string TryEpithetName(Actor actor)
{
ActorTrait top = PickTopTrait(actor, out int score);
if (top == null || score < EpithetMinScore)
{
return "";
}
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 "";
}
}

View file

@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Dossier rail of life-saga MCs. Click toggles Prefer (soft favorite). Hover shows overview card.
/// </summary>
public static class LifeSagaRail
{
private const int MaxSlots = LifeSagaRoster.Cap;
private const float SlotSize = 14f;
private const float Gap = 2f;
private const float HoverWidth = 300f;
private const float HoverLineHeight = 12f;
private const float HoverMaxHeight = 84f;
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 = "";
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
public static string LastTipSample { get; private set; } = "";
private sealed class Slot
{
public GameObject Root;
public RectTransform Rt;
public Image Bg;
public Text Label;
public Button Button;
public long UnitId;
public string Tip = "";
public LifeSagaRailHover Hover;
}
public static bool Visible =>
_row != null && _row.activeSelf;
public static int LastShownCount { get; private set; }
public static void EnsureBuilt(Transform parent)
{
if (_row != null || parent == null)
{
return;
}
_row = new GameObject("LifeSagaRail", typeof(RectTransform));
_row.transform.SetParent(parent, false);
_rowRt = _row.GetComponent<RectTransform>();
_rowRt.pivot = new Vector2(0f, 1f);
_rowRt.anchorMin = new Vector2(0f, 1f);
_rowRt.anchorMax = new Vector2(0f, 1f);
for (int i = 0; i < MaxSlots; i++)
{
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);
}
public static void Clear()
{
_fingerprint = "";
LastShownCount = 0;
LastTipSample = "";
HideHover();
if (_row != null)
{
_row.SetActive(false);
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Root != null)
{
Slots[i].Root.SetActive(false);
Slots[i].UnitId = 0;
Slots[i].Tip = "";
}
}
}
public static void Refresh()
{
if (_row == null)
{
return;
}
if (!SpectatorMode.Active && !SpectatorMode.BrowsePaused)
{
Clear();
return;
}
if (!ModSettings.ShowDossierCaption || WatchCaption.HasStatusBannerActive())
{
Clear();
return;
}
LifeSagaRoster.Tick(Time.unscaledTime);
SlotScratch.Clear();
LifeSagaRoster.CopySlots(SlotScratch);
string fp = Fingerprint(SlotScratch);
if (string.Equals(fp, _fingerprint, StringComparison.Ordinal)
&& !LifeSagaRoster.Dirty)
{
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++)
{
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 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)
{
LastTipSample = slot.Tip ?? "";
}
slot.Rt.anchoredPosition = new Vector2(x, 0f);
slot.Root.SetActive(true);
x += SlotSize + Gap;
LastShownCount++;
}
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0);
}
/// <summary>Place rail under the spine; returns next y.</summary>
public static float Place(float y, float padX)
{
if (!Visible || _rowRt == null)
{
return y;
}
_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;
}
internal static void ShowHover(int index)
{
if (_hoverText == null || index < 0 || index >= Slots.Length)
{
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);
WatchCaption.RequestRelayout();
}
internal static void HideHover()
{
if (_hoverText != null)
{
_hoverText.gameObject.SetActive(false);
_hoverText.text = "";
}
WatchCaption.RequestRelayout();
}
private static float EstimateHoverHeight(string text)
{
if (string.IsNullOrEmpty(text))
{
return HoverLineHeight;
}
int lines = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
lines++;
}
}
return Mathf.Min(HoverMaxHeight, lines * HoverLineHeight + 2f);
}
private static Slot BuildSlot(Transform parent, int index)
{
GameObject go = new GameObject("SagaRail" + index, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
RectTransform rt = go.GetComponent<RectTransform>();
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(SlotSize, SlotSize);
Image bg = go.GetComponent<Image>();
bg.color = HudTheme.ToolFill;
bg.raycastTarget = true;
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 9);
label.alignment = TextAnchor.MiddleCenter;
label.resizeTextForBestFit = false;
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = Vector2.zero;
labelRt.offsetMax = Vector2.zero;
Button button = go.GetComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
int captured = index;
button.onClick.AddListener(new UnityAction(() => OnClick(captured)));
LifeSagaRailHover hover = go.AddComponent<LifeSagaRailHover>();
hover.Index = index;
go.SetActive(false);
return new Slot
{
Root = go,
Rt = rt,
Bg = bg,
Label = label,
Button = button,
Hover = hover
};
}
private static void OnClick(int index)
{
if (index < 0 || index >= Slots.Length)
{
return;
}
long id = Slots[index].UnitId;
if (id == 0)
{
return;
}
if (!SpectatorMode.Active && SpectatorMode.BrowsePaused)
{
SpectatorMode.SetActive(true, quiet: true);
}
LifeSagaRoster.TogglePrefer(id);
_fingerprint = "";
LifeSagaRoster.Dirty = true;
Refresh();
}
private static string GlyphFor(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 "★";
}
string sp = saga.SpeciesId ?? "";
if (sp.Length > 0)
{
return char.ToUpperInvariant(sp[0]).ToString();
}
return "?";
}
private static string Fingerprint(List<LifeSagaSlot> board)
{
var sb = new StringBuilder();
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
sb.Append(watchId).Append('|');
for (int i = 0; i < board.Count; i++)
{
LifeSagaSlot s = board[i];
if (s == null)
{
continue;
}
sb.Append(s.UnitId).Append(':')
.Append(s.Prefer ? 'P' : '-')
.Append(s.GameFavorite ? 'F' : '-')
.Append(s.IsKing ? 'K' : '-')
.Append(s.Chapters.Count).Append(':')
.Append(s.DisplayName ?? "").Append(';');
}
return sb.ToString();
}
}

View file

@ -0,0 +1,14 @@
using UnityEngine;
using UnityEngine.EventSystems;
namespace IdleSpectator;
/// <summary>Pointer hover bridge for <see cref="LifeSagaRail"/> slots.</summary>
public sealed class LifeSagaRailHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
public int Index;
public void OnPointerEnter(PointerEventData eventData) => LifeSagaRail.ShowHover(Index);
public void OnPointerExit(PointerEventData eventData) => LifeSagaRail.HideHover();
}

View file

@ -0,0 +1,885 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Durable top-10 life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
/// </summary>
public static class LifeSagaRoster
{
public const int Cap = 10;
public const int MaxChapters = 3;
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap);
private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(32);
private static float _nextScanAt;
private static bool _dirty = true;
public static int Count => Slots.Count;
public static bool Dirty
{
get => _dirty;
set => _dirty = value;
}
public static void Clear()
{
for (int i = 0; i < Slots.Count; i++)
{
Slots[i]?.Chapters.Clear();
}
Slots.Clear();
_nextScanAt = 0f;
_dirty = true;
}
public static void CopySlots(List<LifeSagaSlot> into)
{
if (into == null)
{
return;
}
into.Clear();
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] != null && Slots[i].UnitId != 0)
{
into.Add(Slots[i]);
}
}
}
public static LifeSagaSlot Get(long unitId)
{
if (unitId == 0)
{
return null;
}
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] != null && Slots[i].UnitId == unitId)
{
return Slots[i];
}
}
return null;
}
public static bool IsMc(long unitId) => Get(unitId) != null;
public static bool IsPrefer(long unitId)
{
LifeSagaSlot s = Get(unitId);
return s != null && s.Prefer;
}
public static bool TogglePrefer(long unitId)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return false;
}
s.Prefer = !s.Prefer;
_dirty = true;
return true;
}
/// <summary>Harness: force a living unit onto the roster (bypasses admission for tests).</summary>
public static bool HarnessForceAdmit(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return false;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0)
{
return false;
}
float now = Time.unscaledTime;
LifeSagaSlot existing = Get(id);
if (existing != null)
{
RefreshSlot(existing, actor, now);
existing.Interest = Mathf.Max(existing.Interest, 8f);
_dirty = true;
return true;
}
LifeSagaSlot neu = BuildSlot(actor, now);
neu.Interest = Mathf.Max(neu.Interest, 8f);
if (Slots.Count >= Cap)
{
// Evict lowest non-favorite.
int worst = -1;
float worstScore = float.MaxValue;
for (int i = 0; i < Slots.Count; i++)
{
if (Slots[i] == null || Slots[i].GameFavorite)
{
continue;
}
if (Slots[i].Interest < worstScore)
{
worstScore = Slots[i].Interest;
worst = i;
}
}
if (worst >= 0)
{
Slots.RemoveAt(worst);
}
else if (Slots.Count > 0)
{
Slots.RemoveAt(Slots.Count - 1);
}
}
Slots.Add(neu);
_dirty = true;
return true;
}
public static bool SetPrefer(long unitId, bool prefer)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return false;
}
if (s.Prefer == prefer)
{
return true;
}
s.Prefer = prefer;
_dirty = true;
return true;
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights sw = InterestScoringConfig.Story;
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 6f;
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 10f;
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
float best = BonusForUnit(c.SubjectId, mc, prefer, fav);
if (c.RelatedId != 0)
{
best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav) * 0.85f);
}
long followId = EventFeedUtil.SafeId(c.FollowUnit);
if (followId != 0 && followId != c.SubjectId && followId != c.RelatedId)
{
best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav));
}
// Soft bias only - never approach short-arc / crisis ownership magnitudes.
float cap = Mathf.Max(prefer, fav) + mc;
return Mathf.Min(best, cap);
}
private static float BonusForUnit(long unitId, float mc, float prefer, float fav)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return 0f;
}
float bonus = mc;
if (s.Prefer)
{
bonus += prefer;
}
if (s.GameFavorite)
{
bonus += fav;
}
return bonus;
}
/// <summary>Among candidates, pick Prefer'd MC, else any MC, else null.</summary>
public static Actor PreferRosterUnit(IEnumerable<Actor> candidates)
{
if (candidates == null)
{
return null;
}
Actor bestPrefer = null;
Actor bestMc = null;
float bestPreferScore = float.MinValue;
float bestMcScore = float.MinValue;
foreach (Actor a in candidates)
{
if (a == null || !a.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(a);
LifeSagaSlot s = Get(id);
if (s == null)
{
continue;
}
float score = s.Interest;
if (s.Prefer && score >= bestPreferScore)
{
bestPreferScore = score;
bestPrefer = a;
}
if (score >= bestMcScore)
{
bestMcScore = score;
bestMc = a;
}
}
return bestPrefer ?? bestMc;
}
public static bool TipTouchesMc(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (IsMc(c.SubjectId) || IsMc(c.RelatedId))
{
return true;
}
return IsMc(EventFeedUtil.SafeId(c.FollowUnit));
}
public static bool TipTouchesPrefer(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (IsPrefer(c.SubjectId) || IsPrefer(c.RelatedId))
{
return true;
}
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
}
/// <summary>Near-tie breaker: Prefer > MC > neither.</summary>
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
int ap = TipTouchesPrefer(a) ? 2 : TipTouchesMc(a) ? 1 : 0;
int bp = TipTouchesPrefer(b) ? 2 : TipTouchesMc(b) ? 1 : 0;
return bp.CompareTo(ap);
}
public static void StampChapter(long unitId, string line, float now = -1f)
{
if (unitId == 0 || string.IsNullOrEmpty(line))
{
return;
}
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return;
}
if (now < 0f)
{
now = Time.unscaledTime;
}
string trimmed = line.Trim();
if (trimmed.Length > 72)
{
trimmed = trimmed.Substring(0, 72).TrimEnd() + "…";
}
if (s.Chapters.Count > 0
&& string.Equals(s.Chapters[0], trimmed, StringComparison.Ordinal))
{
s.LastChapterAt = now;
return;
}
s.Chapters.Insert(0, trimmed);
while (s.Chapters.Count > MaxChapters)
{
s.Chapters.RemoveAt(s.Chapters.Count - 1);
}
s.LastChapterAt = now;
s.Interest = Mathf.Min(20f, s.Interest + 1.5f);
_dirty = true;
}
public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now)
{
if (arc == null || !arc.IsActive)
{
return;
}
string line = FormatChapterLine(arc, tip);
if (string.IsNullOrEmpty(line))
{
return;
}
if (arc.CastIds != null)
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
if (IsMc(arc.CastIds[i]))
{
StampChapter(arc.CastIds[i], line, now);
}
}
}
if (tip != null)
{
if (IsMc(tip.SubjectId))
{
StampChapter(tip.SubjectId, line, now);
}
if (tip.RelatedId != 0 && IsMc(tip.RelatedId))
{
StampChapter(tip.RelatedId, line, now);
}
}
}
public static string FormatChapterLine(StoryArc arc, InterestCandidate tip)
{
if (arc == null)
{
return "";
}
if (!string.IsNullOrEmpty(arc.Headline))
{
return arc.Headline.Trim();
}
if (tip != null && !string.IsNullOrEmpty(tip.Label))
{
string label = tip.Label.Trim();
if (label.Length > 0 && label.IndexOf("·", StringComparison.Ordinal) < 0)
{
return label.Length > 72 ? label.Substring(0, 72) : label;
}
}
switch (arc.Kind)
{
case StoryArcKind.WarFront:
return "War front";
case StoryArcKind.CombatDuel:
return "Duel";
case StoryArcKind.CombatMass:
return "Battle";
case StoryArcKind.Love:
return "Love";
case StoryArcKind.Grief:
return "Grief";
case StoryArcKind.Plot:
return "Plot";
default:
return "";
}
}
public static void Tick(float now)
{
if (now < _nextScanAt && !_dirty)
{
PruneDead(now);
return;
}
_nextScanAt = now + 2.5f;
Refill(now);
}
public static void NoteFeatured(InterestCandidate scene)
{
if (scene == null)
{
return;
}
float now = Time.unscaledTime;
NoteUnit(scene.SubjectId, 0.8f, now);
if (scene.RelatedId != 0)
{
NoteUnit(scene.RelatedId, 0.5f, now);
}
long follow = EventFeedUtil.SafeId(scene.FollowUnit);
if (follow != 0)
{
NoteUnit(follow, 1f, now);
}
}
private static void NoteUnit(long unitId, float heat, float now)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return;
}
s.Interest = Mathf.Min(20f, s.Interest + heat);
s.TouchedAt = now;
_dirty = true;
}
private static void Refill(float now)
{
Scratch.Clear();
HashSet<long> seen = new HashSet<long>();
// Keep existing Prefer / game-favorite slots first.
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot s = Slots[i];
if (s == null || s.UnitId == 0)
{
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
continue;
}
RefreshSlot(s, a, now);
Scratch.Add(s);
seen.Add(s.UnitId);
}
try
{
if (World.world?.units != null)
{
foreach (Actor actor in World.world.units)
{
if (actor == null || !actor.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0 || seen.Contains(id))
{
continue;
}
if (!IsAdmissionCandidate(actor))
{
continue;
}
LifeSagaSlot neu = BuildSlot(actor, now);
Scratch.Add(neu);
seen.Add(id);
}
}
}
catch
{
// ignore scan failures
}
RankAndCap(Scratch, now);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = true;
}
private static void PruneDead(float now)
{
bool changed = false;
for (int i = Slots.Count - 1; i >= 0; i--)
{
LifeSagaSlot s = Slots[i];
if (s == null)
{
Slots.RemoveAt(i);
changed = true;
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
Slots.RemoveAt(i);
changed = true;
continue;
}
RefreshSlot(s, a, now);
}
if (changed)
{
_dirty = true;
_nextScanAt = now;
}
}
private static void RankAndCap(List<LifeSagaSlot> list, float now)
{
StoryWeights sw = InterestScoringConfig.Story;
float dull = sw.sagaDullSeconds > 0f ? sw.sagaDullSeconds : 300f;
list.Sort((a, b) =>
{
if (a == null && b == null)
{
return 0;
}
if (a == null)
{
return 1;
}
if (b == null)
{
return -1;
}
// Game favorites always rank first.
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
if (fav != 0)
{
return fav;
}
float ai = EffectiveInterest(a, now, dull);
float bi = EffectiveInterest(b, now, dull);
int cmp = bi.CompareTo(ai);
if (cmp != 0)
{
return cmp;
}
return b.TouchedAt.CompareTo(a.TouchedAt);
});
// Diversity pass: when over Cap, drop overcrowded kingdom/species before rare buckets.
if (list.Count <= Cap)
{
return;
}
Dictionary<string, int> kingdomCounts = new Dictionary<string, int>();
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
List<LifeSagaSlot> kept = new List<LifeSagaSlot>(Cap);
for (int pass = 0; pass < 2; pass++)
{
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
{
LifeSagaSlot s = list[i];
if (s == null || kept.Contains(s))
{
continue;
}
if (s.GameFavorite)
{
kept.Add(s);
Bump(kingdomCounts, s.KingdomKey);
Bump(speciesCounts, s.SpeciesId);
continue;
}
bool kingdomCrowded = CountOf(kingdomCounts, s.KingdomKey) >= 2 && pass == 0;
bool speciesCrowded = CountOf(speciesCounts, s.SpeciesId) >= 2 && pass == 0;
if (pass == 0 && (kingdomCrowded || speciesCrowded))
{
continue;
}
kept.Add(s);
Bump(kingdomCounts, s.KingdomKey);
Bump(speciesCounts, s.SpeciesId);
}
}
// Fill remaining by rank if diversity pass left holes.
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
{
if (!kept.Contains(list[i]))
{
kept.Add(list[i]);
}
}
list.Clear();
list.AddRange(kept);
}
private static float EffectiveInterest(LifeSagaSlot s, float now, float dull)
{
float score = s.Interest;
if (s.GameFavorite)
{
score += 50f;
}
if (s.Prefer)
{
score += 8f;
}
if (s.LastChapterAt > 0f && now - s.LastChapterAt > dull && !s.GameFavorite)
{
score *= 0.35f;
}
return score;
}
private static void Bump(Dictionary<string, int> map, string key)
{
if (string.IsNullOrEmpty(key))
{
key = "_";
}
if (!map.TryGetValue(key, out int n))
{
n = 0;
}
map[key] = n + 1;
}
private static int CountOf(Dictionary<string, int> map, string key)
{
if (string.IsNullOrEmpty(key))
{
key = "_";
}
return map.TryGetValue(key, out int n) ? n : 0;
}
private static bool IsAdmissionCandidate(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
if (actor.isFavorite())
{
return true;
}
}
catch
{
// ignore
}
if (InterestScoring.IsNotable(actor))
{
return true;
}
return IsPackAlpha(actor);
}
public static bool IsPackAlpha(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
Family family = actor.family;
if (family == null)
{
return false;
}
Actor alpha = family.getAlpha();
return alpha != null && alpha == actor;
}
catch
{
return false;
}
}
private static LifeSagaSlot BuildSlot(Actor actor, float now)
{
LifeSagaSlot s = new LifeSagaSlot();
RefreshSlot(s, actor, now);
s.Interest = BaseInterest(actor);
s.TouchedAt = now;
return s;
}
private static void RefreshSlot(LifeSagaSlot s, Actor actor, float now)
{
s.UnitId = EventFeedUtil.SafeId(actor);
s.DisplayName = SafeName(actor);
bool gameFav = false;
try
{
gameFav = actor.isFavorite();
}
catch
{
gameFav = false;
}
s.GameFavorite = gameFav;
InterestMetadataSnapshot meta = InterestScoring.GetOrBuildMeta(actor, now);
s.SpeciesId = meta?.SpeciesId ?? (actor.asset != null ? actor.asset.id : "");
s.KingdomKey = meta?.KingdomKey ?? "";
s.CityKey = meta?.CityKey ?? "";
s.IsKing = meta != null && meta.King;
s.IsLeader = meta != null && meta.Leader;
s.IsAlpha = IsPackAlpha(actor);
s.Kills = meta?.Kills ?? 0;
s.Renown = meta?.Renown ?? 0f;
if (s.Interest < 0.5f)
{
s.Interest = BaseInterest(actor);
}
}
private static float BaseInterest(Actor actor)
{
float score = 1f;
try
{
if (actor.isFavorite())
{
score += 12f;
}
if (actor.isKing())
{
score += 10f;
}
else if (actor.isCityLeader())
{
score += 6f;
}
}
catch
{
// ignore
}
if (IsPackAlpha(actor))
{
score += 7f;
}
try
{
int kills = actor.data != null ? actor.data.kills : 0;
score += Mathf.Min(8f, kills * 0.05f);
score += Mathf.Min(4f, actor.renown / 100f);
}
catch
{
// ignore
}
score += LifeSagaOverview.StandoutTraitScore(actor) * 0.02f;
return score;
}
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 : "Unit";
}
}
/// <summary>One life on the saga roster.</summary>
public sealed class LifeSagaSlot
{
public long UnitId;
public string DisplayName = "";
public string SpeciesId = "";
public string KingdomKey = "";
public string CityKey = "";
public bool GameFavorite;
public bool Prefer;
public bool IsKing;
public bool IsLeader;
public bool IsAlpha;
public int Kills;
public float Renown;
public float Interest;
public float TouchedAt;
public float LastChapterAt;
public readonly List<string> Chapters = new List<string>(LifeSagaRoster.MaxChapters);
public Actor Resolve() => EventFeedUtil.FindAliveById(UnitId);
}

View file

@ -32,8 +32,15 @@ public sealed class StoryArc
public StoryPhase Phase = StoryPhase.None;
public string AnchorKey = "";
public string ClimaxKey = "";
/// <summary>
/// Short cast/theater identity for the dossier rail (e.g. "Evil Mage vs Lona").
/// Not Kind/Phase taxonomy - who the story is about.
/// </summary>
public string Headline = "";
public float StartedAt;
public float PhaseStartedAt;
/// <summary>When parked off-camera (0 = currently watching / never parked).</summary>
public float ParkedAt;
/// <summary>True when this arc uses hard hold margins (not soft anonymous duels).</summary>
public bool HardHold;
public readonly List<long> CastIds = new List<long>(4);
@ -42,6 +49,8 @@ public sealed class StoryArc
public bool IsActive =>
Phase != StoryPhase.None && Phase != StoryPhase.Done;
public bool IsParked => ParkedAt > 0f && IsActive;
public bool ContainsCast(long unitId)
{
if (unitId == 0)

File diff suppressed because it is too large Load diff

View file

@ -414,6 +414,7 @@ public static class WatchCaption
LastDetail = "";
LastCaptionText = "";
LastStorySpine = "";
LifeSagaRail.Clear();
LastHistoryPreview = "";
LastHistoryJoined = "";
LastHistoryShown = 0;
@ -720,6 +721,26 @@ public static class WatchCaption
SetFromActor(focus);
}
/// <summary>True while a temporary status banner owns the reason row.</summary>
public static bool HasStatusBannerActive() => HasStatusBanner();
/// <summary>Storyline rail hover/commit asks for a soft relayout without a full rebuild.</summary>
public static void RequestRelayout()
{
if (!_visible)
{
return;
}
Relayout(
_boundActor != null || CountActiveHistorySlots() > 0 || _current != null,
CountActiveTraitSlots(),
CountActiveStatusSlots(),
_taskText != null && _taskText.gameObject.activeSelf,
_reasonText != null && _reasonText.gameObject.activeSelf,
CountActiveHistorySlots());
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
@ -845,6 +866,20 @@ public static class WatchCaption
return;
}
// Transient ownership miss mid-handoff: keep the last orange reason while a
// labeled event tip is still active (avoids a blank frame during Love Follow swaps).
// Do not keep across character-fill / empty-Label vignettes.
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (string.IsNullOrEmpty(next)
&& !string.IsNullOrEmpty(prev)
&& scene != null
&& !InterestDirector.InQuietGrace
&& scene.LeadKind != InterestLeadKind.CharacterLed
&& !string.IsNullOrEmpty(scene.Label))
{
return;
}
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
_current.ReasonLine = next;
LastDetail = _current.DetailLine ?? "";
@ -884,18 +919,19 @@ public static class WatchCaption
{
if (!_visible || HasStatusBanner())
{
LifeSagaRail.Clear();
return;
}
string next = SpectatorMode.Active && ModSettings.ShowDossierCaption
string next = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption
? StoryPlanner.FormatSpineLabel()
: "";
if (string.Equals(next, LastStorySpine, StringComparison.Ordinal))
if (!string.Equals(next, LastStorySpine, StringComparison.Ordinal))
{
return;
ApplyStorySpine(next);
}
ApplyStorySpine(next);
LifeSagaRail.Refresh();
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null;
@ -2386,6 +2422,11 @@ public static class WatchCaption
y += StorySpineH + Gap;
}
if (LifeSagaRail.Visible)
{
y = LifeSagaRail.Place(y, PadX);
}
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
if (_rootRt != null)
{
@ -3256,6 +3297,8 @@ public static class WatchCaption
_storySpineText.resizeTextForBestFit = false;
_storySpineText.raycastTarget = false;
LifeSagaRail.EnsureBuilt(_root.transform);
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);

View file

@ -1092,12 +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;
// Character Ledger bias for ambient fill - prefer watched lives over strangers.
// Life-saga bias for ambient fill - prefer roster MCs (Prefer first) over strangers.
long id = EventFeedUtil.SafeId(actor);
float ledger = CharacterLedger.Heat(id);
if (ledger > 0f)
if (LifeSagaRoster.IsPrefer(id))
{
score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
score += Mathf.Max(4f, InterestScoringConfig.Story.sagaPreferWeight * 0.45f);
}
else if (LifeSagaRoster.IsMc(id))
{
score += Mathf.Max(2f, InterestScoringConfig.Story.sagaMcWeight * 0.45f);
}
float causal = CausalHeat.Get(id);

View file

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

View file

@ -131,6 +131,10 @@
"historyDensityWindowSeconds": 600,
"ledgerBleedLifeChapter": 0.4,
"ledgerBleedVillage": 0.82,
"sagaMcWeight": 6,
"sagaPreferWeight": 10,
"sagaGameFavoriteWeight": 8,
"sagaDullSeconds": 300,
"familyLifeBeatCooldownSeconds": 90,
"crisisWarParticipantMin": 8,
"crisisDisasterStrengthMin": 95,
@ -142,7 +146,8 @@
"crisisCooldownSeconds": 45,
"crisisDisasterCooldownSeconds": 120,
"crisisEpilogueStrength": 52,
"crisisEpilogueMaxWatch": 16
"crisisEpilogueMaxWatch": 16,
"softFillQuietSeconds": 18
},
"sources": [

47
docs/life-saga.md Normal file
View file

@ -0,0 +1,47 @@
# Life-saga roster
Long-lived 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.
## Ownership
| Concern | Owner |
|---------|--------|
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
| Hover card | `LifeSagaOverview` |
| Rail glyphs / Prefer click | `LifeSagaRail` |
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
| Full history | Chronicle / Lore |
## 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.
## Rail UX
- Up to 10 glyphs (role/species letter).
- 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.
## 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.
## Harness
- `saga_roster_diversity`
- `saga_camera_prefers_mc`
- `saga_prefer_soft_bias`
- `saga_overview_has_mc`
- `saga_replace_on_death`
See also: [story-planner.md](story-planner.md).

View file

@ -5,7 +5,33 @@ IdleSpectator commits to short multi-beat stories instead of channel-surfing ran
Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
`StoryPlanner` sits above the director and owns *which story* the next 3090s belongs to.
See also: [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
## Short-arc board (not the dossier rail)
Hard storylines (notable duel / mass / war / plot / love / grief) still form a small
watching + parked board (cap 4) for short-chapter resume.
Cutting away **parks** a hard arc; soft anonymous scraps still end.
There is **no** rail Commit / hard pin anymore.
- Lore browse uses `browsePause` - freezes idle without `StoryPlanner.Clear()`.
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
- 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.
See [life-saga.md](life-saga.md).
Crisis camera hold: WarFront always blocks Mass/Battle cut-ins (`war_theater_hold`).
Disaster / outbreak signals use the same class rule while their chapter is live
(`crisis_theater_hold`), classified via live `AssetManager.disasters` + catalog categories
(not tip-string `IndexOf`).
`InterestDirector` is a partial class: session orchestrator + `StickyMaintain` + `SwitchPolicy`.
Harness: `story_lore_pause_keeps`, `story_board_park_resume` (short-arc park/resume via
`story_resume_parked`), `story_crisis_war`, `story_crisis_disaster`, `war_front_sticky`,
plus `saga_*` scenarios.
See also: [life-saga.md](life-saga.md), [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
## Pipeline
@ -135,6 +161,11 @@ warm and cannot steal the War tip (`war_theater_hold`). Crisis closers clear sho
and suppress combat task detail crumbs under the orange reason.
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.
## Selection discipline
@ -172,7 +203,8 @@ Catalog policy ids live under `event-catalog.json` → `story`
| `story_resume_cooled` | cooled soft tip does not resume after cut-in |
| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick |
| `combat_wiped_side` | wiped camp never prints vs (0) |
| `story_aftermath_war` | war sticky cold → aftermath |
| `story_aftermath_war` | war sticky cold → aftermath; no dual crisis closer |
| `story_crisis_hygiene` | auto-opened war crisis + short-arc linger; purge leaves no epilogue leak |
| `story_near_tie` | large gap always picks #1 |
| `story_arc_combat` | climax → aftermath ownership |
| `story_arc_preempt_resume` | epic cut then fresh scrap aftermath |
@ -204,9 +236,10 @@ Catalog policy ids live under `event-catalog.json` → `story`
## Files
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels, crisis overlay
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - saga hover + rail
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row