feat(saga): escalate hard-arc admit and add soak probes

- Grow climax admit heat with arc age and re-stamp while theater holds
- Let prolonged stranger fights challenge Cap so Active chrome can light
- Add soak_probe that dumps roster vs focus without wiping saga state
- Gate with saga_hard_arc_escalates; bump mod to 0.29.59
This commit is contained in:
DazedAnon 2026-07-22 12:25:11 -05:00
parent 3941dc33e5
commit dafe79dfa2
7 changed files with 350 additions and 20 deletions

View file

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

View file

@ -30,6 +30,11 @@ public static class AgentHarness
private static Actor _happinessPartner;
private static long _activitySubjectId;
private static bool _directorRunActive;
/// <summary>
/// When set, batch-end skips roster/memory wipe so AFK soak probes stay non-destructive.
/// Scenario batches never set this; only <c>soak_probe</c> / preserve snapshots do.
/// </summary>
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<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(board);
int n = 0;
for (int i = 0; i < board.Count; i++)
{
LifeSagaSlot s = board[i];
if (s == null || s.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);
}
/// <summary>
/// Read-only AFK dump: roster vs camera, Active/Involved chrome, rival/crossover counts.
/// Preserves saga roster+memory when the batch ends.
/// </summary>
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<LifeSagaSlot>(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);

View file

@ -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
};
}
/// <summary>
/// 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.
/// </summary>
private static List<HarnessCommand> SagaHardArcEscalates()
{
return new List<HarnessCommand>
{
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"),
};
}
/// <summary>
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
/// no planner jargon / trait descs / Chronicle dumps.

View file

@ -683,6 +683,26 @@ public static class LifeSagaRoster
trimmed);
}
/// <summary>
/// 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 <see cref="HeatCap"/>.
/// </summary>
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);
}
}
}

View file

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

View file

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

View file

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