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`