diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md
index 7863956..1bb6053 100644
--- a/.cursor/skills/idle-spectator-e2e/SKILL.md
+++ b/.cursor/skills/idle-spectator-e2e/SKILL.md
@@ -215,6 +215,8 @@ Use `fast_timing` / `director_run` instead.
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `dossier_statuses_ok`, `citizen_job_labels_ok`, `tooltip_library_ok`, `history_other_rows`, `lore_detail_is_other`, `reason_names_colored`, `fallen_killer_banner`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows`
- `dossier_click_history_other`, `dossier_force_named_reason` - dossier row → Lore other; gold names in orange reason
- `snapshot`, `reset_counters`, `scenario`
+- `soak_probe` / `saga_soak_probe` - read-only AFK dump (roster vs focus, Active/Involved, rivals); **preserves** saga roster+memory on batch end
+- `snapshot` with `value=preserve` (or `soak` / `keep`) also skips the batch-end saga wipe
## Process safety
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index b471725..06c919f 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -30,6 +30,11 @@ public static class AgentHarness
private static Actor _happinessPartner;
private static long _activitySubjectId;
private static bool _directorRunActive;
+ ///
+ /// When set, batch-end skips roster/memory wipe so AFK soak probes stay non-destructive.
+ /// Scenario batches never set this; only soak_probe / preserve snapshots do.
+ ///
+ private static bool _preserveSagaAfterBatch;
private static string _rememberedFocusKey = "";
private static string _rememberedTip = "";
private static long _rememberedRelatedId;
@@ -2972,6 +2977,50 @@ public static class AgentHarness
break;
}
+ case "saga_fill_cap":
+ {
+ // Force-admit living units until Cap (skips Prefer partner / current focus when asked).
+ string assetId = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto"
+ ? "human"
+ : cmd.asset;
+ Actor skip = _happinessPartner;
+ int added = 0;
+ int guard = 0;
+ while (LifeSagaRoster.Count < LifeSagaRoster.Cap && guard++ < 40)
+ {
+ Actor unit = FindOtherAliveUnitPreferOffRoster(skip, assetId, preferOffRoster: true);
+ if (unit == null || !unit.isAlive())
+ {
+ break;
+ }
+
+ if (!LifeSagaRoster.HarnessForceAdmit(unit))
+ {
+ break;
+ }
+
+ added++;
+ skip = unit;
+ }
+
+ bool ok = LifeSagaRoster.Count >= LifeSagaRoster.Cap;
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail:
+ $"filled added={added} roster={LifeSagaRoster.Count} cap={LifeSagaRoster.Cap}");
+ break;
+ }
+
case "saga_force_admit_partner":
{
Actor partner = _happinessPartner;
@@ -3133,24 +3182,77 @@ public static class AgentHarness
break;
}
+ case "saga_stiffen_roster":
+ {
+ // Keep Cap competitive via Heat (RefreshSlot rewrites Standing from live BaseInterest).
+ float now = Time.unscaledTime;
+ float heat = LifeSagaRoster.HeatCap;
+ if (!string.IsNullOrEmpty(cmd.value)
+ && float.TryParse(
+ cmd.value.Trim(),
+ System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out float parsed)
+ && parsed > 0f)
+ {
+ heat = parsed;
+ }
+
+ var board = new System.Collections.Generic.List(LifeSagaRoster.Cap);
+ LifeSagaRoster.CopySlots(board);
+ int n = 0;
+ for (int i = 0; i < board.Count; i++)
+ {
+ LifeSagaSlot s = board[i];
+ if (s == null || s.UnitId == 0)
+ {
+ continue;
+ }
+
+ // Standing is overwritten on Tick; heat is the durable harness lever.
+ LifeSagaRoster.HarnessSetScores(s.UnitId, standing: 1f, heat: heat);
+ s.AdmittedAt = now;
+ s.TouchedAt = now;
+ s.LastChapterAt = now;
+ n++;
+ }
+
+ LifeSagaRoster.Dirty = true;
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: $"stiffened={n} heat={heat:0.##} roster={LifeSagaRoster.Count}");
+ break;
+ }
+
case "saga_consider_hard_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
- bool ok = id != 0
- && LifeSagaRoster.ConsiderAdmit(id, heat: 6f, hardArcAdmit: true);
- if (ok)
+ float heat = 6f;
+ if (!string.IsNullOrEmpty(cmd.value)
+ && float.TryParse(
+ cmd.value.Trim(),
+ System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out float parsed)
+ && parsed > 0f)
{
- _cmdOk++;
- }
- else
- {
- _cmdFail++;
+ heat = parsed;
}
- Emit(cmd, ok,
+ if (id == 0)
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no focus");
+ break;
+ }
+
+ bool admitted = LifeSagaRoster.ConsiderAdmit(id, heat: heat, hardArcAdmit: true);
+ _cmdOk++;
+ Emit(
+ cmd,
+ ok: true,
detail:
- $"hardAdmit id={id} on={LifeSagaRoster.IsMc(id)} roster={LifeSagaRoster.Count}");
+ $"hardAdmit id={id} on={admitted} heat={heat:0.##} roster={LifeSagaRoster.Count}");
break;
}
@@ -3847,9 +3949,19 @@ public static class AgentHarness
break;
case "snapshot":
+ if (WantsPreserveSaga(cmd))
+ {
+ _preserveSagaAfterBatch = true;
+ }
+
DoSnapshot(cmd);
break;
+ case "soak_probe":
+ case "saga_soak_probe":
+ DoSoakProbe(cmd);
+ break;
+
case "scenario":
ExpandScenario(cmd);
return;
@@ -3881,12 +3993,19 @@ public static class AgentHarness
{
// Free AFK after harness must not inherit a leftover crisis closer tip.
StoryPlanner.PurgeCloserLeftovers();
- LifeSagaRoster.Clear();
- LifeSagaMemory.Clear();
+ bool preserve = _preserveSagaAfterBatch;
+ _preserveSagaAfterBatch = false;
+ if (!preserve)
+ {
+ LifeSagaRoster.Clear();
+ LifeSagaMemory.Clear();
+ }
+
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
- WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}");
+ string wipe = preserve ? "saga_preserved" : "saga_wiped";
+ WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail} {wipe}");
LogService.LogInfo(
- $"[IdleSpectator][HARNESS] batch done status={status} ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}");
+ $"[IdleSpectator][HARNESS] batch done status={status} ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail} {wipe}");
}
}
@@ -3903,6 +4022,8 @@ public static class AgentHarness
}
_lastBatchId = string.IsNullOrEmpty(cmd.id) ? name : cmd.id;
+ // Scenario batches always wipe at end - never inherit a prior soak preserve flag.
+ _preserveSagaAfterBatch = false;
bool keepStats = (cmd.expect ?? "").Trim().Equals("keep", System.StringComparison.OrdinalIgnoreCase);
if (!keepStats)
{
@@ -8555,8 +8676,11 @@ public static class AgentHarness
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
LifeSagaRoster.Tick(Time.unscaledTime);
- pass = id != 0 && LifeSagaRoster.IsMc(id);
- detail = $"saga focus={SafeName(focus)} id={id} on={pass} prefer={LifeSagaRoster.IsPrefer(id)}";
+ bool want = ParseBool(cmd.value, defaultValue: true);
+ bool have = id != 0 && LifeSagaRoster.IsMc(id);
+ pass = have == want;
+ detail =
+ $"saga focus={SafeName(focus)} id={id} on={have} want={want} prefer={LifeSagaRoster.IsPrefer(id)}";
break;
}
case "saga_prefer_focus_on":
@@ -12329,6 +12453,16 @@ public static class AgentHarness
$"[IdleSpectator][ASSERT] {(pass ? "PASS" : "FAIL")} id={cmd.id} expect={expect} {detail}");
}
+ private static bool WantsPreserveSaga(HarnessCommand cmd)
+ {
+ string v = (cmd?.value ?? cmd?.expect ?? "").Trim().ToLowerInvariant();
+ return v == "preserve"
+ || v == "soak"
+ || v == "keep"
+ || v == "keep_saga"
+ || v == "preserve_saga";
+ }
+
private static void DoSnapshot(HarnessCommand cmd)
{
string drops = InterestDropLog.FormatRecent(6);
@@ -12344,6 +12478,116 @@ public static class AgentHarness
LogService.LogInfo("[IdleSpectator][SNAP] " + snap);
}
+ ///
+ /// Read-only AFK dump: roster vs camera, Active/Involved chrome, rival/crossover counts.
+ /// Preserves saga roster+memory when the batch ends.
+ ///
+ private static void DoSoakProbe(HarnessCommand cmd)
+ {
+ _preserveSagaAfterBatch = true;
+ float now = Time.unscaledTime;
+ LifeSagaRoster.Tick(now);
+ LifeSagaRail.Refresh();
+
+ Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ long focusId = EventFeedUtil.SafeId(focus);
+ InterestCandidate tip = InterestDirector.CurrentCandidate;
+ bool focusMc = focusId != 0 && LifeSagaRoster.IsMc(focusId);
+ bool focusPrefer = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
+ bool focusCast = focusId != 0 && LifeSagaRoster.IsMcCast(focusId);
+ int softRank = tip != null ? LifeSagaRoster.SoftBiasRank(tip) : 0;
+ int touchedMcs = tip != null ? LifeSagaRoster.CountTouchedMcs(tip) : 0;
+
+ bool rival = false;
+ string rivalNote = "";
+ if (LifeSagaMemory.TryGetEarnedRival(focusId, out LifeSagaFact rivalFact) && rivalFact != null)
+ {
+ rival = true;
+ rivalNote = !string.IsNullOrEmpty(rivalFact.Note)
+ ? rivalFact.Note
+ : (rivalFact.Provenance ?? rivalFact.Kind.ToString());
+ }
+
+ int facts = 0;
+ int crossovers = 0;
+ if (focusId != 0)
+ {
+ var list = LifeSagaMemory.FactsFor(focusId);
+ facts = list != null ? list.Count : 0;
+ if (list != null)
+ {
+ for (int i = 0; i < list.Count; i++)
+ {
+ if (list[i] != null && list[i].McCrossover)
+ {
+ crossovers++;
+ }
+ }
+ }
+ }
+
+ var board = new List(LifeSagaRoster.Cap);
+ LifeSagaRoster.CopySlots(board);
+ var names = new StringBuilder(160);
+ for (int i = 0; i < board.Count; i++)
+ {
+ LifeSagaSlot s = board[i];
+ if (s == null || s.UnitId == 0)
+ {
+ continue;
+ }
+
+ if (names.Length > 0)
+ {
+ names.Append(',');
+ }
+
+ string n = string.IsNullOrEmpty(s.DisplayName) ? ("#" + s.UnitId) : s.DisplayName;
+ if (s.UnitId == focusId)
+ {
+ n = "*" + n;
+ }
+
+ if (s.Prefer || s.GameFavorite)
+ {
+ n += "^";
+ }
+
+ names.Append(n);
+ }
+
+ string tipLabel = tip?.Label ?? CameraDirector.LastWatchLabel ?? "";
+ string detail =
+ $"focus={SafeName(focus)} id={focusId} onRoster={focusMc} prefer={focusPrefer} cast={focusCast} "
+ + $"activeLit={LifeSagaRail.LastActiveHighlight} involvedLit={LifeSagaRail.LastInvolvedHighlightCount} "
+ + $"softRank={softRank} touchedMcs={touchedMcs} "
+ + $"rival={rival} crossoverFacts={crossovers} facts={facts} lives={LifeSagaMemory.LifeCount} "
+ + $"roster={LifeSagaRoster.Count} tip={tipLabel} "
+ + $"score={InterestDirector.CurrentScoreLabel} names=[{names}]";
+
+ _cmdOk++;
+ Emit(cmd, ok: true, detail: detail);
+ LogService.LogInfo("[IdleSpectator][SOAK] " + detail);
+
+ try
+ {
+ EnsureDirs();
+ string path = Path.Combine(HarnessDir(), "soak-probe.txt");
+ File.WriteAllText(
+ path,
+ detail
+ + Environment.NewLine
+ + "rivalNote=" + rivalNote
+ + Environment.NewLine
+ + "preserved=true"
+ + Environment.NewLine);
+ }
+ catch
+ {
+ // ignore IO
+ }
+ }
+
private static void DoDiscoveryNote(HarnessCommand cmd)
{
string assetId = ResolveAssetId(cmd);
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index c407a93..3ec0a91 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -171,6 +171,8 @@ internal static class HarnessScenarios
return SagaRivalRematch();
case "saga_prefer_cuts_ambient":
return SagaPreferCutsAmbient();
+ case "saga_hard_arc_escalates":
+ return SagaHardArcEscalates();
case "saga_overview_has_mc":
return SagaOverviewHasMc();
case "saga_snapshot_popover":
@@ -387,6 +389,7 @@ internal static class HarnessScenarios
Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"),
+ Nested("reg_saga_hard_arc_heat", "saga_hard_arc_escalates"),
Nested("reg_saga_overview", "saga_overview_has_mc"),
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
Nested("reg_saga_memory", "saga_memory_survives"),
@@ -3213,6 +3216,40 @@ internal static class HarnessScenarios
};
}
+ ///
+ /// Prolonged hard-arc heat must beat a full Cap of stiff slots; one-shot heat 1.5 must not.
+ /// Mirrors soak: stranger duel stuck off-rail until admit heat escalates.
+ ///
+ private static List SagaHardArcEscalates()
+ {
+ return new List
+ {
+ Step("she0", "dismiss_windows"),
+ Step("she1", "wait_world"),
+ Step("she2", "set_setting", expect: "enabled", value: "true"),
+ Step("she3", "fast_timing", value: "true"),
+ Step("she4", "spawn", asset: "human", count: 14),
+ Step("she5", "spectator", value: "off"),
+ Step("she6", "spectator", value: "on"),
+ Step("she7", "interest_saga_clear"),
+ Step("she8", "pick_unit", asset: "human"),
+ Step("she9", "happiness_remember_partner"),
+ Step("she10", "saga_fill_cap", asset: "human"),
+ Step("she50", "assert", expect: "saga_roster_count", value: "10"),
+ Step("she51", "saga_stiffen_roster", value: "8"),
+ Step("she60", "focus_partner"),
+ Step("she62", "assert", expect: "saga_has_focus", value: "false"),
+ Step("she63", "saga_consider_hard_focus", value: "1.5"),
+ Step("she64", "assert", expect: "saga_has_focus", value: "false"),
+ Step("she65", "saga_consider_hard_focus", value: "12"),
+ Step("she66", "assert", expect: "saga_has_focus"),
+ Step("she67", "saga_rail_refresh"),
+ Step("she68", "assert", expect: "saga_rail_active_highlight", value: "true"),
+ Step("she90", "fast_timing", value: "false"),
+ Step("she99", "snapshot"),
+ };
+ }
+
///
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps.
diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs
index 56076f8..5fe18aa 100644
--- a/IdleSpectator/Story/LifeSagaRoster.cs
+++ b/IdleSpectator/Story/LifeSagaRoster.cs
@@ -683,6 +683,26 @@ public static class LifeSagaRoster
trimmed);
}
+ ///
+ /// Hard-arc admit heat grows with climax age so prolonged theater can challenge a full Cap.
+ /// One-shot heat 1.5 loses to a roster of notables; ~2 minutes reaches .
+ ///
+ public static float HardArcAdmitHeat(StoryArc arc, float now = -1f)
+ {
+ if (now < 0f)
+ {
+ now = Time.unscaledTime;
+ }
+
+ if (arc == null || arc.StartedAt <= 0f)
+ {
+ return 1.5f;
+ }
+
+ float age = Mathf.Max(0f, now - arc.StartedAt);
+ return Mathf.Min(HeatCap, 1.5f + age / 12f);
+ }
+
public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now)
{
if (arc == null || !arc.IsActive)
@@ -697,6 +717,8 @@ public static class LifeSagaRoster
}
bool hard = IsHardStoryKind(arc.Kind);
+ float subjectHeat = hard ? HardArcAdmitHeat(arc, now) : 1.5f;
+ float relatedHeat = hard ? Mathf.Max(1.2f, subjectHeat * 0.85f) : 1.2f;
if (arc.CastIds != null)
{
for (int i = 0; i < arc.CastIds.Count; i++)
@@ -704,7 +726,7 @@ public static class LifeSagaRoster
long id = arc.CastIds[i];
if (hard)
{
- ConsiderAdmit(id, 1.5f, hardArcAdmit: true, now);
+ ConsiderAdmit(id, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(id, arc.Kind, line);
}
@@ -720,13 +742,20 @@ public static class LifeSagaRoster
{
if (hard)
{
- ConsiderAdmit(tip.SubjectId, 1.5f, hardArcAdmit: true, now);
+ ConsiderAdmit(tip.SubjectId, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(tip.SubjectId, arc.Kind, line);
if (tip.RelatedId != 0)
{
- ConsiderAdmit(tip.RelatedId, 1.2f, hardArcAdmit: true, now);
+ ConsiderAdmit(tip.RelatedId, relatedHeat, hardArcAdmit: true, now);
MarkHardArcContext(tip.RelatedId, arc.Kind, line);
}
+
+ long follow = EventFeedUtil.SafeId(tip.FollowUnit);
+ if (follow != 0 && follow != tip.SubjectId && follow != tip.RelatedId)
+ {
+ ConsiderAdmit(follow, subjectHeat, hardArcAdmit: true, now);
+ MarkHardArcContext(follow, arc.Kind, line);
+ }
}
if (IsMc(tip.SubjectId))
@@ -738,6 +767,15 @@ public static class LifeSagaRoster
{
StampChapter(tip.RelatedId, ToChapterKind(arc.Kind), line, now);
}
+
+ long followStamp = EventFeedUtil.SafeId(tip.FollowUnit);
+ if (followStamp != 0
+ && followStamp != tip.SubjectId
+ && followStamp != tip.RelatedId
+ && IsMc(followStamp))
+ {
+ StampChapter(followStamp, ToChapterKind(arc.Kind), line, now);
+ }
}
}
diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs
index 33b6631..22ed1c3 100644
--- a/IdleSpectator/Story/StoryPlanner.cs
+++ b/IdleSpectator/Story/StoryPlanner.cs
@@ -210,6 +210,12 @@ public static class StoryPlanner
_active.HardHold = hardHold || _active.HardHold;
NoteCastFrom(current, _active);
StampHeadline(_active, current);
+ // Re-challenge Cap while climax holds: admit heat escalates with arc age so a
+ // prolonged duel/war/plot on a stranger can still join the rail (Active chrome).
+ if (IsHardStoryKind(_active.Kind))
+ {
+ LifeSagaRoster.StampChapterFromArc(_active, current, Time.unscaledTime);
+ }
}
private static bool IsCombatKind(StoryArcKind kind) =>
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 7b70e39..d3293dd 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.29.57",
+ "version": "0.29.59",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/docs/life-saga.md b/docs/life-saga.md
index e2aa557..4db2cec 100644
--- a/docs/life-saga.md
+++ b/docs/life-saga.md
@@ -38,6 +38,7 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
- Admission-role units always compete on the periodic scan.
- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief).
+- Hard-arc admit heat escalates with climax age (re-stamped while the arc holds) so a prolonged stranger duel can displace dull Cap slots and light Active chrome.
- Soft `NoteFeatured` heats existing MCs only.
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
@@ -106,6 +107,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `StoryPlanner.Clear` does not wipe the roster or saga memory.
- Lore browse / brief idle-off keeps the roster and memory.
- World replacement, harness batch end, and `interest_saga_clear` wipe roster + memory.
+- Read-only soak dumps use `soak_probe` (or `snapshot` with `value=preserve`) and skip the batch-end wipe.
## Harness
@@ -120,6 +122,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `saga_rival_not_single_kill`
- `saga_rival_rematch`
- `saga_prefer_cuts_ambient`
+- `saga_hard_arc_escalates`
- `saga_overview_has_mc`
- `saga_adaptive_dossier`
- `saga_memory_survives`