From db5f45a00faa562af5e4b7d2f9a475b91ffc4c4b Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 15:13:49 -0500 Subject: [PATCH] Regression Test --- .cursor/skills/idle-spectator-e2e/SKILL.md | 55 ++- .../skills/idle-spectator-e2e/reference.md | 21 +- IdleSpectator/AgentHarness.cs | 378 +++++++++++++++++- IdleSpectator/HarnessScenarios.cs | 273 ++++++++++++- IdleSpectator/InterestCollector.cs | 11 + IdleSpectator/InterestDirector.cs | 107 ++++- IdleSpectator/Main.cs | 2 +- IdleSpectator/ModSettings.cs | 6 + IdleSpectator/SpeciesDiscovery.cs | 86 +++- IdleSpectator/mod.json | 4 +- scripts/harness-run.sh | 72 +++- 11 files changed, 937 insertions(+), 78 deletions(-) diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index d511d24..9e1a984 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -2,7 +2,7 @@ name: idle-spectator-e2e description: >- Run IdleSpectator WorldBox E2E tests via the in-mod agent harness - (scripts/harness-run.sh, critical_smoke, .harness commands). + (scripts/harness-run.sh, critical_smoke, regression suite, .harness commands). Use when adding or fixing IdleSpectator features, playtesting the mod, writing harness scenarios, or verifying spectator/focus/tip/settings behavior. --- @@ -19,13 +19,17 @@ After any feature or bugfix in `IdleSpectator/`: 1. Bump `IdleSpectator/mod.json` version (NML recompiles on game load). 2. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior. -3. Run: +3. Day-to-day / PR gate: ```bash ./scripts/harness-run.sh --repeat 3 critical_smoke ``` -4. If you added a focused scenario, also run it (example: `./scripts/harness-run.sh tip_match`). -5. Iterate until **all repeats PASS**. Do not stop after one flaky pass. -6. Report: scenario name, PASS/FAIL counts, and any new asserts. +4. After larger changes (director, discovery, settings, input, tips), also run: + ```bash + ./scripts/harness-run.sh regression + ``` +5. If you added a focused scenario, run it directly too. +6. Iterate until **all repeats PASS**. Do not stop after one flaky pass. +7. Report: scenario name(s), PASS/FAIL counts, and any new asserts. For tiny doc-only edits, harness is optional. For anything that touches camera, spectator, tips, settings, discovery, or input exit, harness is required. @@ -38,6 +42,7 @@ For anything that touches camera, spectator, tips, settings, discovery, or input ./scripts/harness-run.sh critical_smoke ./scripts/harness-run.sh --repeat 3 critical_smoke +./scripts/harness-run.sh regression # full suite ./scripts/harness-run.sh tip_match ./scripts/harness-run.sh smoke ./scripts/harness-run.sh --no-launch critical_smoke @@ -64,6 +69,7 @@ The runner: - Touches `.harness/reset` + rewinds offset so commands are re-read - Enables `.harness/auto-dismiss` (closes Welcome Back) - Waits for `last-result.json` status `PASS` / `FAIL` +- `regression` runs each named scenario sequentially (stop on first fail) A loaded world is still required. `wait_world` + auto-dismiss handle Welcome Back and empty maps. @@ -72,32 +78,50 @@ A loaded world is still required. | Name | Purpose | |------|---------| -| `critical_smoke` | Default regression: settings gate, idle health, tip match, retarget, manual exit | +| `critical_smoke` | PR gate: settings gate, idle health, tip match, retarget, manual exit | +| `settings_full` | Disable-while-active, show_watch_reasons | +| `ghost_guard` | Fake new-species tip must not steal focus onto wrong unit | +| `retarget_stability` | Many watches without focus/power-bar flicker | +| `input_exit` | Post-enable grace + camera-drag exit | +| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action | +| `discovery` | Present dedupe + force drain + tip match | +| `world_log` | Story/Epic interest interrupt via collector | | `smoke` | Short enable/focus health check | | `tip_match` | Tip asset must match focused unit | +| `regression` | Shell suite of all gate scenarios above | + +Out of gate (too flaky / environmental): real battles, map-gen discovery, OS hotkey `I`. Prefer `pick_unit` (living map unit) over miracle `spawn` in critical paths. Spawned crabs/sheep often die on bad tiles and flake tests. +## Timing note + +Director and discovery use `Time.unscaledTime`. +Game speed 5x does **not** shorten harness waits. +Use `fast_timing` in scenarios (and/or `director_run`) instead. + ## Adding coverage for a new fix -1. Add asserts/steps to `critical_smoke` when the bug is core behavior. -2. Or add a named scenario and run both it and `critical_smoke`. +1. Add asserts/steps to the most specific scenario (or `critical_smoke` if core). +2. Register new scenarios in `HarnessScenarios.Build` and in `REGRESSION_SCENARIOS` inside `scripts/harness-run.sh`. 3. Use `asset=auto` / `label="New species: {asset}"` after `pick_unit`. -4. Keep InterestDirector frozen while `AgentHarness.Busy` so ambient does not steal focus mid-test. +4. Keep InterestDirector frozen while `AgentHarness.FreezeDirector` (Busy unless `director_run`). 5. `wait_world` must stay front-of-queue on retry (never enqueue behind later steps). ## Command cheat sheet JSONL lines in `.harness/commands.jsonl` (also used by scenarios): -- `wait_world`, `dismiss_windows`, `wait` +- `wait_world`, `dismiss_windows`, `wait`, `director_run`, `fast_timing` +- `age_current`, `expire_grace`, `remember_focus` - `spectator` (`value=on|off`, optional `expect=blocked`) -- `pick_unit`, `spawn`, `focus`, `watch`, `trigger_interest` -- `simulate_input` (camera-drag manual exit) -- `set_setting` (`expect=enabled`, `value=true|false`) -- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled` -- `snapshot`, `reset_counters`, `scenario` +- `pick_unit`, `spawn`, `focus`, `watch`, `watch_ghost`, `trigger_interest` +- `simulate_input` (`expect=grace` to assert grace hold) +- `set_setting` (`expect=enabled|show_watch_reasons`, `value=true|false`) +- `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain` +- `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` +- `snapshot`, `reset_counters` (StateProbe only; `value=all` also clears harness counters), `scenario` Machine output: @@ -117,6 +141,7 @@ A change is done only when: - Relevant harness scenario(s) PASS - For core spectator/focus/tip/settings work: `critical_smoke` passes **3 times** (`--repeat 3`) +- For larger director/discovery/input/settings work: `regression` PASS - Failures were fixed by hardening the mod or scenario, not by lowering asserts ## More detail diff --git a/.cursor/skills/idle-spectator-e2e/reference.md b/.cursor/skills/idle-spectator-e2e/reference.md index 719ae6f..83df766 100644 --- a/.cursor/skills/idle-spectator-e2e/reference.md +++ b/.cursor/skills/idle-spectator-e2e/reference.md @@ -7,8 +7,10 @@ scripts/harness-run.sh -> starts WorldBox if needed -> writes IdleSpectator/.harness/commands.jsonl -> touches reset + auto-dismiss + -> regression: loops named scenarios sequentially AgentHarness.Update() (in-game) -> executes commands on Unity main thread + -> FreezeDirector unless director_run is active -> writes results.jsonl + last-result.json -> logs [HARNESS]/[ASSERT]/[SNAP] ``` @@ -18,6 +20,12 @@ AgentHarness.Update() (in-game) Wayland makes `xdotool`/`ydotool` unreliable. Truth lives in engine state (`MoveCamera`, tips, settings), so drive the mod API directly. +## Timing + +InterestDirector and SpeciesDiscovery use `Time.unscaledTime`. +WorldBox 5x game speed does not advance those clocks. +Use harness `fast_timing` + `director_run` / `age_current` for scheduling tests. + ## Hard-won failure modes | Symptom | Cause | Fix | @@ -26,15 +34,19 @@ Truth lives in engine state (`MoveCamera`, tips, settings), so drive the mod API | Steps run before world ready | `wait_world` requeued to back | `RequeueFront` | | Tip says crab, focus is angle | watch fell back to nearest unit | require matching asset; use `pick_unit`/`asset=auto` | | Spawn then "no unit for asset" | miracle spawns die on bad tiles | prefer `pick_unit` for critical paths | -| Flaky health/no_bad mid-scenario | ambient retarget while harness busy | `InterestDirector` returns early when `AgentHarness.Busy` | +| Flaky health/no_bad mid-scenario | ambient retarget while harness busy | `FreezeDirector` unless `director_run` | | Steam hang after tests | `Application.Quit` | leave game running | +| Director tests take forever | production dwell/rotate | `fast_timing` (not game 5x) | ## Useful asserts - `health` - idle + focus + power bar hidden + `bad==0` - `tip_matches_unit` - tip asset equals focus unit asset; if tip starts with `New species:`, label species must match - `no_bad` - StateProbe bad counter is zero -- `enabled` - mod setting `enabled` bool +- `enabled` / `show_watch_reasons` - mod settings +- `current_tier` / `would_accept_curiosity` - director scheduling +- `focus_same` - after `remember_focus` (ghost guard) +- `presented` / `pending_discovery` - species discovery buffer ## Extending scenarios @@ -42,11 +54,12 @@ Edit `IdleSpectator/HarnessScenarios.cs`: 1. Add steps with `Step(id, action, ...)`. 2. Register name in `Build`. -3. Run `./scripts/harness-run.sh ` then `--repeat 3 critical_smoke`. +3. Add the name to `REGRESSION_SCENARIOS` in `scripts/harness-run.sh` when it should gate merges. +4. Run `./scripts/harness-run.sh ` then `--repeat 3 critical_smoke` (and `regression` for suite-level changes). Keep critical_smoke deterministic: - `dismiss_windows` / `wait_world` first - `pick_unit` before tip/focus asserts -- `reset_counters` after spectator+focus before health +- `reset_counters` after spectator+focus before health (StateProbe only; use `value=all` only at batch start) - `simulate_input` last for manual-exit coverage diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 63c78ee..0c032b7 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -26,6 +26,8 @@ public static class AgentHarness private static readonly Queue Queue = new Queue(); private static Actor _lastSpawned; private static string _lastSpawnedAssetId = ""; + private static bool _directorRunActive; + private static string _rememberedFocusKey = ""; private static readonly string[] PreferredSpawnAssets = { "sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab" @@ -51,6 +53,12 @@ public static class AgentHarness public static bool Busy => Queue.Count > 0 || _waitUntil > 0f; + /// + /// When true, InterestDirector / SpeciesDiscovery stay frozen during harness commands. + /// director_run temporarily clears this so dwell/interrupt/discovery can execute. + /// + public static bool FreezeDirector => Busy && !_directorRunActive; + public static void Update() { EnsureDirs(); @@ -71,6 +79,7 @@ public static class AgentHarness } _waitUntil = -1f; + _directorRunActive = false; } if (Queue.Count == 0) @@ -134,12 +143,68 @@ public static class AgentHarness case "wait": { float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 1f); + _directorRunActive = false; _waitUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds); _cmdOk++; Emit(cmd, ok: true, detail: $"wait={seconds:0.##}s"); break; } + case "director_run": + { + float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 1f); + seconds = Mathf.Max(0.1f, seconds); + _directorRunActive = true; + _waitUntil = Time.unscaledTime + seconds; + _cmdOk++; + Emit(cmd, ok: true, detail: $"director_run={seconds:0.##}s"); + break; + } + + case "fast_timing": + { + bool on = ParseBool(cmd.value, defaultValue: true); + InterestDirector.SetHarnessFastTiming(on); + SpeciesDiscovery.SetHarnessFastTiming(on); + _cmdOk++; + Emit(cmd, ok: true, detail: $"fast_timing={on}"); + break; + } + + case "age_current": + { + float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f); + InterestDirector.HarnessAgeCurrent(seconds); + _cmdOk++; + Emit(cmd, ok: true, detail: $"aged={seconds:0.##}s tier={InterestDirector.CurrentTierName}"); + break; + } + + case "expire_grace": + { + InterestDirector.HarnessExpireInputGrace(); + _cmdOk++; + Emit(cmd, ok: true, detail: $"in_grace={InterestDirector.InInputGrace}"); + break; + } + + case "remember_focus": + { + _rememberedFocusKey = FocusKey(); + bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-"; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}"); + break; + } + case "spectator": { bool on = ParseBool(cmd.value, defaultValue: true); @@ -173,8 +238,25 @@ public static class AgentHarness case "simulate_input": { - bool exited = InterestDirector.SimulateManualInputExit(); - if (exited) + string mode = (cmd.value ?? "drag").Trim().ToLowerInvariant(); + string expect = (cmd.expect ?? "").Trim().ToLowerInvariant(); + bool honorGrace = expect == "grace" || expect == "blocked" || mode == "grace"; + // Only camera-drag is reliably injectable without OS input. + bool exited = InterestDirector.SimulateManualInputExit(honorGrace: honorGrace); + bool ok; + string detail; + if (honorGrace) + { + ok = !exited && SpectatorMode.Active; + detail = $"grace_hold idle={SpectatorMode.Active} in_grace={InterestDirector.InInputGrace}"; + } + else + { + ok = exited && !SpectatorMode.Active; + detail = $"exited idle={SpectatorMode.Active}"; + } + + if (ok) { _cmdOk++; } @@ -183,10 +265,53 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, exited, detail: $"idle={SpectatorMode.Active}"); + Emit(cmd, ok, detail); break; } + case "discovery_reset": + { + SpeciesDiscovery.HarnessResetTracking(); + _cmdOk++; + Emit(cmd, ok: true, detail: "discovery_tracking_cleared"); + break; + } + + case "discovery_note": + DoDiscoveryNote(cmd); + break; + + case "discovery_mark": + case "discovery_mark_presented": + { + string assetId = ResolveAssetId(cmd); + SpeciesDiscovery.MarkPresented(assetId); + _cmdOk++; + Emit(cmd, ok: true, detail: $"presented={assetId} pending={SpeciesDiscovery.PendingCount}"); + break; + } + + case "discovery_drain": + { + bool drained = SpeciesDiscovery.HarnessForceDrainOnce(); + if (drained) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, drained, detail: + $"drained={drained} pending={SpeciesDiscovery.PendingCount} session={SpeciesDiscovery.DrainedThisSession}"); + break; + } + + case "watch_ghost": + DoWatchGhost(cmd); + break; + case "spawn": DoSpawn(cmd); break; @@ -224,14 +349,21 @@ public static class AgentHarness return; case "reset_counters": + { StateProbe.ResetCounters(); - _assertPass = 0; - _assertFail = 0; - _cmdOk = 0; - _cmdFail = 0; + string mode = (cmd.value ?? cmd.expect ?? "").Trim().ToLowerInvariant(); + if (mode == "all" || mode == "harness") + { + _assertPass = 0; + _assertFail = 0; + _cmdOk = 0; + _cmdFail = 0; + } + _cmdOk++; - Emit(cmd, ok: true, detail: "counters_reset"); + Emit(cmd, ok: true, detail: mode == "all" || mode == "harness" ? "counters_reset_all" : "stateprobe_reset"); break; + } default: _cmdFail++; @@ -261,11 +393,15 @@ public static class AgentHarness } _lastBatchId = string.IsNullOrEmpty(cmd.id) ? name : cmd.id; - _assertPass = 0; - _assertFail = 0; - _cmdOk = 0; - _cmdFail = 0; - StateProbe.ResetCounters(); + bool keepStats = (cmd.expect ?? "").Trim().Equals("keep", System.StringComparison.OrdinalIgnoreCase); + if (!keepStats) + { + _assertPass = 0; + _assertFail = 0; + _cmdOk = 0; + _cmdFail = 0; + StateProbe.ResetCounters(); + } // Re-queue scenario steps ahead of anything else already queued. List rest = new List(Queue); @@ -552,14 +688,16 @@ public static class AgentHarness Score = 40f, Position = follow.current_position, FollowUnit = follow, - Label = string.IsNullOrEmpty(cmd.label) ? "Harness interest" : cmd.label, + Label = string.IsNullOrEmpty(cmd.label) + ? $"Harness {ParseTier(cmd.tier, InterestTier.Curiosity)}" + : cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? "")), CreatedAt = Time.unscaledTime, AssetId = follow.asset != null ? follow.asset.id : assetId }; InterestCollector.EnqueueDirect(interest); _cmdOk++; - Emit(cmd, ok: true, detail: $"queued tier={interest.Tier} label={interest.Label}"); + Emit(cmd, ok: true, detail: $"queued tier={interest.Tier} label={interest.Label} pending={InterestCollector.PendingCount}"); } private static void DoSetSetting(HarnessCommand cmd) @@ -711,6 +849,74 @@ public static class AgentHarness detail = $"enabled={ModSettings.Enabled} want={want}"; break; } + case "show_watch_reasons": + { + bool want = ParseBool(cmd.value, defaultValue: true); + pass = ModSettings.ShowWatchReasons == want; + detail = $"show_watch_reasons={ModSettings.ShowWatchReasons} want={want}"; + break; + } + case "current_tier": + { + string want = (cmd.value ?? cmd.tier ?? "").Trim(); + string have = InterestDirector.CurrentTierName; + pass = !string.IsNullOrEmpty(want) + && string.Equals(have, want, System.StringComparison.OrdinalIgnoreCase); + detail = $"tier={have} want={want} label={InterestDirector.CurrentLabel}"; + break; + } + case "would_accept_curiosity": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = InterestDirector.WouldAcceptCuriosity(); + pass = have == want; + detail = $"would_accept={have} want={want} tier={InterestDirector.CurrentTierName}"; + break; + } + case "focus_same": + case "focus_unchanged": + { + string nowKey = FocusKey(); + pass = !string.IsNullOrEmpty(_rememberedFocusKey) + && _rememberedFocusKey != "-" + && nowKey == _rememberedFocusKey; + detail = $"remembered={_rememberedFocusKey} now={nowKey}"; + break; + } + case "presented": + { + string assetId = ResolveAssetId(cmd); + bool want = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true); + bool have = SpeciesDiscovery.WasPresented(assetId); + pass = have == want; + detail = $"asset={assetId} presented={have} want={want}"; + break; + } + case "pending_discovery": + case "discovery_pending": + { + int want = ParseCountExpect(cmd, defaultValue: 0); + int have = SpeciesDiscovery.PendingCount; + pass = have == want; + detail = $"pending={have} want={want}"; + break; + } + case "pending_interest": + { + int want = ParseCountExpect(cmd, defaultValue: 0); + int have = InterestCollector.PendingCount; + pass = have == want; + detail = $"interest_pending={have} want={want}"; + break; + } + case "in_grace": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool have = InterestDirector.InInputGrace; + pass = have == want; + detail = $"in_grace={have} want={want}"; + break; + } default: pass = false; detail = "unknown expect: " + expect; @@ -736,12 +942,127 @@ public static class AgentHarness private static void DoSnapshot(HarnessCommand cmd) { string snap = - $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount}"; + $"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount}"; _cmdOk++; Emit(cmd, ok: true, detail: snap); LogService.LogInfo("[IdleSpectator][SNAP] " + snap); } + private static void DoDiscoveryNote(HarnessCommand cmd) + { + string assetId = ResolveAssetId(cmd); + if (string.IsNullOrEmpty(assetId)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "missing asset for discovery_note"); + return; + } + + Vector3 pos = CameraPos(); + Actor unit = ResolveUnit(assetId); + if (unit != null) + { + pos = unit.current_position; + } + + bool ok = SpeciesDiscovery.HarnessNoteAsset(assetId, pos); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"asset={assetId} pending={SpeciesDiscovery.PendingCount}"); + } + + private static void DoWatchGhost(HarnessCommand cmd) + { + if (!WorldReady()) + { + if (RetryFront(cmd, "world_not_ready")) + { + return; + } + + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + Actor near = ResolveUnit(_lastSpawnedAssetId); + if (near == null) + { + near = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f); + } + + if (near == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit near camera for ghost watch"); + return; + } + + // Claim a species that is extremely unlikely to match the focused unit. + string fakeAsset = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" + ? "__ghost_species__" + : cmd.asset; + string label = string.IsNullOrEmpty(cmd.label) + ? $"New species: {fakeAsset}" + : cmd.label.Replace("{asset}", fakeAsset); + + InterestEvent interest = new InterestEvent + { + Tier = ParseTier(cmd.tier, InterestTier.Curiosity), + Score = 50f, + Position = near.current_position, + FollowUnit = null, + Label = label, + CreatedAt = Time.unscaledTime, + AssetId = fakeAsset + }; + + string before = FocusKey(); + CameraDirector.Watch(interest); + string after = FocusKey(); + bool unchanged = before == after && MoveCamera.hasFocusUnit(); + if (unchanged) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, unchanged, detail: $"ghost={fakeAsset} before={before} after={after} tip={CameraDirector.LastWatchLabel}"); + } + + private static string ResolveAssetId(HarnessCommand cmd) + { + string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; + if (assetId == "auto" || string.IsNullOrEmpty(assetId)) + { + assetId = _lastSpawnedAssetId; + } + + return assetId ?? ""; + } + + private static string FocusKey() + { + if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) + { + return "-"; + } + + Actor unit = MoveCamera._focus_unit; + string asset = unit.asset != null ? unit.asset.id : "?"; + return $"{asset}@{unit.GetHashCode()}"; + } + private static void IngestNewCommands() { string path = CommandsPath(); @@ -1024,6 +1345,27 @@ public static class AgentHarness return fallback; } + private static int ParseCountExpect(HarnessCommand cmd, int defaultValue) + { + // Prefer explicit value= so Step()'s default count=1 does not override want=0. + if (!string.IsNullOrEmpty(cmd.value) + && int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int fromValue)) + { + return fromValue; + } + + if (cmd.count != 1 || string.IsNullOrEmpty(cmd.value)) + { + // count explicitly set via Step(..., count: N) when N != default 1, or no value given. + if (string.IsNullOrEmpty(cmd.value)) + { + return cmd.count; + } + } + + return defaultValue; + } + private static bool ParseBool(string raw, bool defaultValue) { if (string.IsNullOrEmpty(raw)) @@ -1140,10 +1482,14 @@ public static class AgentHarness Queue.Clear(); _waitUntil = -1f; + _directorRunActive = false; _readyLogged = false; _offset = 0; _lastSpawned = null; _lastSpawnedAssetId = ""; + _rememberedFocusKey = ""; + InterestDirector.SetHarnessFastTiming(false); + SpeciesDiscovery.SetHarnessFastTiming(false); try { File.WriteAllText(OffsetPath(), "0"); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index e22c6b5..99d8133 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -18,11 +18,49 @@ internal static class HarnessScenarios case "critical": case "critical_smoke": return CriticalSmoke(); + case "settings_full": + return SettingsFull(); + case "ghost_guard": + return GhostGuard(); + case "retarget_stability": + return RetargetStability(); + case "input_exit": + return InputExit(); + case "director_tiers": + return DirectorTiers(); + case "discovery": + return Discovery(); + case "world_log": + return WorldLog(); + case "regression": + case "full": + return Regression(); default: return null; } } + /// + /// Full regression meta-suite (nested scenarios keep assert counters). + /// Battle / map-gen paths stay out of gate. + /// + private static List Regression() + { + return new List + { + Nested("reg_critical", "critical_smoke"), + Nested("reg_settings", "settings_full"), + Nested("reg_ghost", "ghost_guard"), + Nested("reg_retarget", "retarget_stability"), + Nested("reg_input", "input_exit"), + Nested("reg_director", "director_tiers"), + Nested("reg_discovery", "discovery"), + Nested("reg_worldlog", "world_log"), + Step("reg_end", "fast_timing", value: "false"), + Step("reg_snap", "snapshot"), + }; + } + private static List Smoke() { return new List @@ -70,7 +108,6 @@ internal static class HarnessScenarios Step("c01", "wait_world"), Step("c02", "reset_counters"), - // Lock onto a living map unit for the rest of the scenario. Step("c05", "pick_unit", asset: "auto"), Step("c06", "wait", wait: 0.25f), @@ -118,6 +155,240 @@ internal static class HarnessScenarios }; } + private static List SettingsFull() + { + return new List + { + Step("sf0", "dismiss_windows"), + Step("sf1", "wait_world"), + Step("sf2", "set_setting", expect: "enabled", value: "true"), + Step("sf3", "pick_unit", asset: "auto"), + + Step("sf10", "set_setting", expect: "enabled", value: "false"), + Step("sf11", "assert", expect: "enabled", value: "false"), + Step("sf12", "spectator", value: "on", expect: "blocked"), + Step("sf13", "assert", expect: "idle", value: "false"), + + Step("sf20", "set_setting", expect: "enabled", value: "true"), + Step("sf21", "spectator", value: "on"), + Step("sf22", "focus", asset: "auto"), + Step("sf23", "assert", expect: "idle", value: "true"), + + // Disable while active must turn idle off (settings callback). + Step("sf30", "set_setting", expect: "enabled", value: "false"), + Step("sf31", "assert", expect: "idle", value: "false"), + Step("sf32", "assert", expect: "enabled", value: "false"), + + Step("sf40", "set_setting", expect: "enabled", value: "true"), + Step("sf41", "set_setting", expect: "show_watch_reasons", value: "false"), + Step("sf42", "assert", expect: "show_watch_reasons", value: "false"), + Step("sf43", "set_setting", expect: "show_watch_reasons", value: "true"), + Step("sf44", "assert", expect: "show_watch_reasons", value: "true"), + + Step("sf99", "snapshot"), + }; + } + + private static List GhostGuard() + { + return new List + { + Step("g0", "dismiss_windows"), + Step("g1", "wait_world"), + Step("g2", "set_setting", expect: "enabled", value: "true"), + Step("g3", "pick_unit", asset: "auto"), + Step("g4", "spectator", value: "on"), + Step("g5", "focus", asset: "auto"), + Step("g6", "reset_counters"), + Step("g7", "remember_focus"), + Step("g8", "watch_ghost"), + Step("g9", "wait", wait: 0.25f), + Step("g10", "assert", expect: "focus_same"), + Step("g11", "assert", expect: "health"), + Step("g12", "assert", expect: "no_bad"), + Step("g13", "assert", expect: "power_bar", value: "false"), + Step("g99", "snapshot"), + }; + } + + private static List RetargetStability() + { + return new List + { + Step("rs0", "dismiss_windows"), + Step("rs1", "wait_world"), + Step("rs2", "set_setting", expect: "enabled", value: "true"), + Step("rs3", "pick_unit", asset: "auto"), + Step("rs4", "spectator", value: "on"), + Step("rs5", "focus", asset: "auto"), + Step("rs6", "reset_counters"), + + Step("rs10", "watch", asset: "auto", label: "Stab 1: {asset}", tier: "Curiosity"), + Step("rs11", "wait", wait: 0.2f), + Step("rs12", "watch", asset: "auto", label: "Stab 2: {asset}", tier: "Curiosity"), + Step("rs13", "wait", wait: 0.2f), + Step("rs14", "watch", asset: "auto", label: "Stab 3: {asset}", tier: "Curiosity"), + Step("rs15", "wait", wait: 0.2f), + Step("rs16", "watch", asset: "auto", label: "Stab 4: {asset}", tier: "Curiosity"), + Step("rs17", "wait", wait: 0.25f), + + Step("rs20", "assert", expect: "health"), + Step("rs21", "assert", expect: "no_bad"), + Step("rs22", "assert", expect: "power_bar", value: "false"), + Step("rs23", "assert", expect: "unit_asset", asset: "auto"), + Step("rs99", "snapshot"), + }; + } + + private static List InputExit() + { + return new List + { + Step("ie0", "dismiss_windows"), + Step("ie1", "wait_world"), + Step("ie2", "set_setting", expect: "enabled", value: "true"), + Step("ie3", "fast_timing", value: "true"), + Step("ie4", "pick_unit", asset: "auto"), + Step("ie5", "spectator", value: "off"), + Step("ie6", "spectator", value: "on"), + Step("ie7", "focus", asset: "auto"), + + // Still inside post-enable grace: drag must not exit. + Step("ie10", "assert", expect: "in_grace", value: "true"), + Step("ie11", "simulate_input", value: "drag", expect: "grace"), + Step("ie12", "assert", expect: "idle", value: "true"), + + Step("ie20", "expire_grace"), + Step("ie21", "assert", expect: "in_grace", value: "false"), + Step("ie22", "simulate_input", value: "drag"), + Step("ie23", "assert", expect: "idle", value: "false"), + Step("ie24", "assert", expect: "no_bad"), + + Step("ie90", "fast_timing", value: "false"), + Step("ie99", "snapshot"), + }; + } + + private static List DirectorTiers() + { + return new List + { + Step("dt0", "dismiss_windows"), + Step("dt1", "wait_world"), + Step("dt2", "set_setting", expect: "enabled", value: "true"), + Step("dt3", "fast_timing", value: "true"), + Step("dt4", "pick_unit", asset: "auto"), + Step("dt5", "spectator", value: "off"), + Step("dt6", "spectator", value: "on"), + Step("dt7", "focus", asset: "auto"), + Step("dt8", "reset_counters"), + + // Curiosity can replace ambient after rotate window. + Step("dt10", "trigger_interest", asset: "auto", label: "CurioA", tier: "Curiosity"), + Step("dt11", "age_current", wait: 2f), + Step("dt12", "director_run", wait: 1.2f), + Step("dt13", "assert", expect: "current_tier", value: "Curiosity"), + Step("dt14", "assert", expect: "tip_contains", value: "CurioA"), + + // Action interrupts curiosity after high-tier window. + Step("dt20", "trigger_interest", asset: "auto", label: "ActionA", tier: "Action"), + Step("dt21", "age_current", wait: 1.2f), + Step("dt22", "director_run", wait: 1.2f), + Step("dt23", "assert", expect: "current_tier", value: "Action"), + Step("dt24", "assert", expect: "tip_contains", value: "ActionA"), + + // Curiosity must not be accepted while Action holds the camera (no director_run: + // ambient rotate would otherwise steal Action after dwell). + Step("dt30", "trigger_interest", asset: "auto", label: "CurioBlocked", tier: "Curiosity"), + Step("dt31", "assert", expect: "current_tier", value: "Action"), + Step("dt32", "assert", expect: "tip_contains", value: "ActionA"), + Step("dt33", "assert", expect: "would_accept_curiosity", value: "false"), + Step("dt34", "assert", expect: "health"), + Step("dt35", "assert", expect: "no_bad"), + + Step("dt90", "fast_timing", value: "false"), + Step("dt99", "snapshot"), + }; + } + + private static List Discovery() + { + return new List + { + Step("d0", "dismiss_windows"), + Step("d1", "wait_world"), + Step("d2", "set_setting", expect: "enabled", value: "true"), + Step("d3", "fast_timing", value: "true"), + Step("d4", "discovery_reset"), + Step("d5", "pick_unit", asset: "auto"), + Step("d6", "spectator", value: "off"), + Step("d7", "spectator", value: "on"), + Step("d8", "focus", asset: "auto"), + Step("d9", "reset_counters"), + + // Live-path dedupe: mark presented drops buffered copy. + Step("d10", "discovery_note", asset: "auto"), + Step("d11", "assert", expect: "pending_discovery", value: "1"), + Step("d12", "discovery_mark", asset: "auto"), + Step("d13", "assert", expect: "pending_discovery", value: "0"), + Step("d14", "assert", expect: "presented", asset: "auto", value: "true"), + + // Force drain path once curiosity is acceptable. + Step("d20", "discovery_reset"), + Step("d21", "discovery_note", asset: "auto"), + Step("d22", "assert", expect: "pending_discovery", value: "1"), + Step("d23", "age_current", wait: 2f), + Step("d24", "assert", expect: "would_accept_curiosity", value: "true"), + Step("d25", "discovery_drain"), + Step("d26", "assert", expect: "pending_discovery", value: "0"), + Step("d27", "assert", expect: "presented", asset: "auto", value: "true"), + Step("d28", "director_run", wait: 1.2f), + Step("d29", "assert", expect: "tip_matches_unit"), + Step("d30", "assert", expect: "no_bad"), + + Step("d90", "fast_timing", value: "false"), + Step("d99", "snapshot"), + }; + } + + private static List WorldLog() + { + return new List + { + Step("wl0", "dismiss_windows"), + Step("wl1", "wait_world"), + Step("wl2", "set_setting", expect: "enabled", value: "true"), + Step("wl3", "fast_timing", value: "true"), + Step("wl4", "pick_unit", asset: "auto"), + Step("wl5", "spectator", value: "off"), + Step("wl6", "spectator", value: "on"), + Step("wl7", "focus", asset: "auto"), + Step("wl8", "reset_counters"), + + Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"), + Step("wl11", "age_current", wait: 1.2f), + Step("wl12", "director_run", wait: 1.2f), + Step("wl13", "assert", expect: "current_tier", value: "Story"), + Step("wl14", "assert", expect: "tip_contains", value: "kingdom_new"), + + Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"), + Step("wl21", "age_current", wait: 1.2f), + Step("wl22", "director_run", wait: 1.2f), + Step("wl23", "assert", expect: "current_tier", value: "Epic"), + Step("wl24", "assert", expect: "tip_contains", value: "diplomacy_war_started"), + Step("wl25", "assert", expect: "health"), + Step("wl26", "assert", expect: "no_bad"), + + Step("wl90", "fast_timing", value: "false"), + Step("wl99", "snapshot"), + }; + } + + private static HarnessCommand Nested(string id, string scenario) + { + return Step(id, "scenario", value: scenario, expect: "keep"); + } + private static HarnessCommand Step( string id, string action, diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs index c140002..3a8d8d8 100644 --- a/IdleSpectator/InterestCollector.cs +++ b/IdleSpectator/InterestCollector.cs @@ -109,6 +109,17 @@ public static class InterestCollector } } + public static int PendingCount + { + get + { + lock (Gate) + { + return Pending.Count; + } + } + } + public static bool HasPendingAtLeast(InterestTier minTier) { lock (Gate) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 34fea56..d86ce93 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -16,6 +16,18 @@ public static class InterestDirector public const float QuietWorldAmbientAfterSeconds = 8f; public const float AmbientRotateSeconds = 20f; public const float ActionPollSeconds = 0.75f; + public const float InputExitGraceSeconds = 2.5f; + + // Production defaults (copied when leaving harness fast timing). + private static float _minDwell = MinDwellSeconds; + private static float _curiosityDwell = CuriosityDwellSeconds; + private static float _curiosityRotate = CuriosityRotateSeconds; + private static float _switchCooldown = SwitchCooldownSeconds; + private static float _highTierInterrupt = HighTierInterruptAfterSeconds; + private static float _quietAmbientAfter = QuietWorldAmbientAfterSeconds; + private static float _ambientRotate = AmbientRotateSeconds; + private static float _actionPoll = ActionPollSeconds; + private static float _inputExitGrace = InputExitGraceSeconds; private static InterestEvent _current; private static float _currentStartedAt = -999f; @@ -24,7 +36,62 @@ public static class InterestDirector private static float _lastAmbientAt = -999f; private static float _lastActionPollAt = -999f; private static float _enabledAt = -999f; - private const float InputExitGraceSeconds = 2.5f; + + public static string CurrentTierName => + _current == null ? "none" : _current.Tier.ToString(); + + public static string CurrentLabel => + _current == null ? "" : (_current.Label ?? ""); + + public static bool InInputGrace => + SpectatorMode.Active && Time.unscaledTime - _enabledAt < _inputExitGrace; + + /// + /// Harness-only: shorten dwell/rotate so director tests finish in seconds. + /// Game 5x speed does not help - this code uses . + /// + public static void SetHarnessFastTiming(bool enabled) + { + if (enabled) + { + _minDwell = 1.5f; + _curiosityDwell = 1.2f; + _curiosityRotate = 1.5f; + _switchCooldown = 0.75f; + _highTierInterrupt = 0.75f; + _quietAmbientAfter = 1.5f; + _ambientRotate = 2.5f; + _actionPoll = 0.35f; + _inputExitGrace = 0.6f; + } + else + { + _minDwell = MinDwellSeconds; + _curiosityDwell = CuriosityDwellSeconds; + _curiosityRotate = CuriosityRotateSeconds; + _switchCooldown = SwitchCooldownSeconds; + _highTierInterrupt = HighTierInterruptAfterSeconds; + _quietAmbientAfter = QuietWorldAmbientAfterSeconds; + _ambientRotate = AmbientRotateSeconds; + _actionPoll = ActionPollSeconds; + _inputExitGrace = InputExitGraceSeconds; + } + } + + /// Harness: pretend the current interest started ago. + public static void HarnessAgeCurrent(float seconds) + { + float age = Mathf.Max(0f, seconds); + float now = Time.unscaledTime; + _currentStartedAt = now - age; + _lastSwitchAt = now - age; + } + + /// Harness: expire input-exit grace immediately. + public static void HarnessExpireInputGrace() + { + _enabledAt = Time.unscaledTime - _inputExitGrace - 0.05f; + } public static void OnSpectatorEnabled() { @@ -53,14 +120,13 @@ public static class InterestDirector return; } - // During harness batches, do not ambient-steal focus. The harness picks units explicitly. - if (AgentHarness.Busy) + // During harness batches, freeze unless director_run explicitly unfreezes. + if (AgentHarness.FreezeDirector) { return; } - // Harness drives the camera/assert loop; ignore manual-input exit while commands run. - if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds + if (Time.unscaledTime - _enabledAt >= _inputExitGrace && PlayerTookManualControl()) { ExitFromManualInput(); @@ -72,7 +138,7 @@ public static class InterestDirector float sinceSwitch = now - _lastSwitchAt; // Periodically surface live battles into the queue (Action tier). - if (now - _lastActionPollAt >= ActionPollSeconds) + if (now - _lastActionPollAt >= _actionPoll) { _lastActionPollAt = now; InterestEvent battle = WorldActivityScanner.FindHottestBattle(); @@ -112,14 +178,14 @@ public static class InterestDirector return; } - bool quiet = now - _lastInterestingAt >= QuietWorldAmbientAfterSeconds; + bool quiet = now - _lastInterestingAt >= _quietAmbientAfter; bool dwellDone = _current == null || onCurrent >= DwellFor(_current); - bool ambientDue = now - _lastAmbientAt >= AmbientRotateSeconds; + bool ambientDue = now - _lastAmbientAt >= _ambientRotate; if (_current == null && sinceSwitch >= 0.5f) { TryAmbient(force: true); } - else if (quiet && dwellDone && ambientDue && sinceSwitch >= SwitchCooldownSeconds) + else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown) { TryAmbient(force: false); } @@ -144,9 +210,10 @@ public static class InterestDirector } /// - /// Harness: pulse camera-drag input and take the real manual-exit path. + /// Harness: pulse camera-drag (or honorGrace path) and take the real manual-exit path. + /// When is true, input during the post-enable grace does not exit. /// - public static bool SimulateManualInputExit() + public static bool SimulateManualInputExit(bool honorGrace = false) { if (!SpectatorMode.Active) { @@ -161,6 +228,12 @@ public static class InterestDirector return false; } + if (honorGrace && Time.unscaledTime - _enabledAt < _inputExitGrace) + { + MoveCamera.camera_drag_run = false; + return false; + } + ExitFromManualInput(); MoveCamera.camera_drag_run = false; return !SpectatorMode.Active; @@ -218,7 +291,7 @@ public static class InterestDirector return MinDwellSeconds; } - return current.Tier <= InterestTier.Curiosity ? CuriosityDwellSeconds : MinDwellSeconds; + return current.Tier <= InterestTier.Curiosity ? _curiosityDwell : _minDwell; } private static bool CanSwitchTo(InterestEvent candidate, float onCurrent, float sinceSwitch) @@ -237,7 +310,7 @@ public static class InterestDirector return false; } - if (_current.Tier == InterestTier.Curiosity && onCurrent < CuriosityRotateSeconds) + if (_current.Tier == InterestTier.Curiosity && onCurrent < _curiosityRotate) { return false; } @@ -248,12 +321,12 @@ public static class InterestDirector } } - if (candidate.Tier > _current.Tier && onCurrent >= HighTierInterruptAfterSeconds) + if (candidate.Tier > _current.Tier && onCurrent >= _highTierInterrupt) { return true; } - return onCurrent >= DwellFor(_current) && sinceSwitch >= SwitchCooldownSeconds; + return onCurrent >= DwellFor(_current) && sinceSwitch >= _switchCooldown; } private static void SwitchTo(InterestEvent next, float now) @@ -287,12 +360,12 @@ public static class InterestDirector // Don't let ambient yank the camera off a curiosity tip after a few seconds. if (!force && _current != null && _current.Tier == InterestTier.Curiosity - && now - _currentStartedAt < AmbientRotateSeconds) + && now - _currentStartedAt < _ambientRotate) { return; } - if (!force && now - _lastSwitchAt < SwitchCooldownSeconds && _current != null) + if (!force && now - _lastSwitchAt < _switchCooldown && _current != null) { return; } diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index 5a8e132..da55112 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -62,7 +62,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable AgentHarness.Update(); SpectatorMode.PollInput(); InterestDirector.Update(); - if (!AgentHarness.Busy) + if (!AgentHarness.FreezeDirector) { SpeciesDiscovery.Update(); } diff --git a/IdleSpectator/ModSettings.cs b/IdleSpectator/ModSettings.cs index b09ec6d..5c41328 100644 --- a/IdleSpectator/ModSettings.cs +++ b/IdleSpectator/ModSettings.cs @@ -59,6 +59,12 @@ public static class ModSettings { ModConfigItem item = _config[GroupId][id]; item.SetValue(value); + // Ensure settings callbacks apply even if NML does not fire them from SetValue. + if (id == EnabledId) + { + OnEnabledChanged(value); + } + return true; } catch (System.Exception ex) diff --git a/IdleSpectator/SpeciesDiscovery.cs b/IdleSpectator/SpeciesDiscovery.cs index f7ff965..46c4f49 100644 --- a/IdleSpectator/SpeciesDiscovery.cs +++ b/IdleSpectator/SpeciesDiscovery.cs @@ -10,12 +10,15 @@ namespace IdleSpectator; /// public static class SpeciesDiscovery { - private const float DrainIntervalSeconds = 12f; - private const float NotReadyBackoffSeconds = 5f; + private const float DrainIntervalSecondsDefault = 12f; + private const float NotReadyBackoffSecondsDefault = 5f; private const int MaxBuffered = 24; private const int MaxDrainPerSession = 8; private const int MaxNotReadyAttempts = 8; + private static float _drainInterval = DrainIntervalSecondsDefault; + private static float _notReadyBackoff = NotReadyBackoffSecondsDefault; + private static readonly List Pending = new List(); private static readonly HashSet SeenAssets = new HashSet(); private static readonly HashSet PresentedAssets = new HashSet(); @@ -30,10 +33,14 @@ public static class SpeciesDiscovery public int NotReadyAttempts; } + public static int PendingCount => Pending.Count; + + public static int DrainedThisSession => _drainedThisSession; + public static void OnSpectatorEnabled() { _drainedThisSession = 0; - _lastDrainAt = Time.unscaledTime - DrainIntervalSeconds + 2f; + _lastDrainAt = Time.unscaledTime - _drainInterval + 2f; } public static void OnSpectatorDisabled() @@ -42,6 +49,73 @@ public static class SpeciesDiscovery _drainedThisSession = 0; } + /// + /// Harness-only: shorten drain interval. Uses unscaled time (game 5x does not help). + /// + public static void SetHarnessFastTiming(bool enabled) + { + _drainInterval = enabled ? 0.75f : DrainIntervalSecondsDefault; + _notReadyBackoff = enabled ? 0.35f : NotReadyBackoffSecondsDefault; + } + + /// Harness: clear presented/seen so the same asset can be re-tested. + public static void HarnessResetTracking() + { + PresentedAssets.Clear(); + SeenAssets.Clear(); + Pending.Clear(); + _drainedThisSession = 0; + _lastDrainAt = Time.unscaledTime - _drainInterval; + } + + /// Harness: buffer a living asset as if newSpecies fired during load. + public static bool HarnessNoteAsset(string assetId, Vector3 position) + { + if (string.IsNullOrEmpty(assetId)) + { + return false; + } + + ActorAsset asset = AssetManager.actor_library?.get(assetId); + if (asset == null) + { + return false; + } + + SeenAssets.Remove(assetId); + PresentedAssets.Remove(assetId); + for (int i = Pending.Count - 1; i >= 0; i--) + { + if (Pending[i].AssetId == assetId) + { + Pending.RemoveAt(i); + } + } + + NoteCreated(asset, null, mutation: false, subspecies: null); + if (Pending.Count > 0) + { + PendingSpecies last = Pending[Pending.Count - 1]; + if (last.AssetId == assetId && position != Vector3.zero) + { + last.Position = position; + Pending[Pending.Count - 1] = last; + } + } + + return Pending.Exists(p => p.AssetId == assetId); + } + + /// Harness: ignore drain interval and attempt one drain immediately. + public static bool HarnessForceDrainOnce() + { + _lastDrainAt = Time.unscaledTime - _drainInterval - 0.05f; + int before = _drainedThisSession; + int pendingBefore = Pending.Count; + Update(); + return _drainedThisSession > before || Pending.Count < pendingBefore; + } + public static void NoteCreated(ActorAsset asset, WorldTile tile, bool mutation, Subspecies subspecies) { if (mutation || asset == null || string.IsNullOrEmpty(asset.id)) @@ -119,7 +193,7 @@ public static class SpeciesDiscovery } float now = Time.unscaledTime; - if (now - _lastDrainAt < DrainIntervalSeconds) + if (now - _lastDrainAt < _drainInterval) { return; } @@ -134,7 +208,7 @@ public static class SpeciesDiscovery if (index < 0) { // Front items not spawnable yet - retry later without dequeue flap. - _lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds; + _lastDrainAt = now - _drainInterval + _notReadyBackoff; return; } @@ -160,7 +234,7 @@ public static class SpeciesDiscovery Pending[index] = next; } - _lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds; + _lastDrainAt = now - _drainInterval + _notReadyBackoff; return; } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 1737f9b..f36179d 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.5.0", - "description": "AFK Idle Spectator (I) with agent harness. Critical E2E: settings gate, focus health, tip match, retarget, manual exit.", + "version": "0.6.3", + "description": "AFK Idle Spectator (I) with agent harness. Regression suite: settings, ghost guard, retarget, input grace, director tiers, discovery, world-log.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index 7759d9a..122f291 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -5,6 +5,7 @@ # Usage: # ./scripts/harness-run.sh # default: critical_smoke # ./scripts/harness-run.sh critical_smoke +# ./scripts/harness-run.sh regression # full suite (named scenarios) # ./scripts/harness-run.sh --repeat 3 # run scenario 3 times (all must PASS) # ./scripts/harness-run.sh tip_match # ./scripts/harness-run.sh smoke @@ -22,6 +23,18 @@ BOOT_TIMEOUT_SEC="${HARNESS_BOOT_TIMEOUT:-180}" DO_LAUNCH=1 REPEAT=1 +# Full regression gate (battle / map-gen intentionally excluded). +REGRESSION_SCENARIOS=( + critical_smoke + settings_full + ghost_guard + retarget_stability + input_exit + director_tiers + discovery + world_log +) + scenario="" raw_cmd="" while [[ $# -gt 0 ]]; do @@ -118,6 +131,7 @@ ensure_worldbox() { queue_and_wait() { local run_label="$1" + local scenario_name="$2" rm -f "$HARNESS/last-result.json" : > "$HARNESS/results.jsonl" echo 0 > "$HARNESS/offset" @@ -127,16 +141,16 @@ queue_and_wait() { sleep 0.35 { - echo '{"id":"batch","action":"reset_counters"}' + echo '{"id":"batch","action":"reset_counters","value":"all"}' echo '{"id":"dismiss","action":"dismiss_windows"}' if [[ -n "$raw_cmd" ]]; then echo "$raw_cmd" else - echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario\"}" + echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario_name\"}" fi } > "$HARNESS/commands.jsonl" - echo "Harness queued: ${scenario:-raw} ($run_label) (mod=$MOD)" + echo "Harness queued: ${scenario_name:-raw} ($run_label) (mod=$MOD)" echo "Waiting up to ${TIMEOUT_SEC}s for last-result.json (load a world if at the menu) ..." local deadline=$((SECONDS + TIMEOUT_SEC)) @@ -171,6 +185,25 @@ queue_and_wait() { return 1 } +run_one_scenario() { + local name="$1" + local label="$2" + local i pass_count=0 + for ((i=1; i<=REPEAT; i++)); do + echo "======== ${name} RUN $i / $REPEAT ($label) ========" + if queue_and_wait "${name} $i/$REPEAT" "$name"; then + pass_count=$((pass_count + 1)) + echo "RUN $i PASS ($name)" + else + echo "RUN $i FAIL ($name)" >&2 + echo "Summary: $pass_count / $REPEAT passed for $name before failure" >&2 + return 1 + fi + done + echo "Summary: $pass_count / $REPEAT PASS ($name)" + return 0 +} + mkdir -p "$HARNESS" ensure_worldbox @@ -178,18 +211,25 @@ if (( TIMEOUT_SEC < 120 )); then TIMEOUT_SEC=120 fi -pass_count=0 -for ((i=1; i<=REPEAT; i++)); do - echo "======== RUN $i / $REPEAT ========" - if queue_and_wait "run $i/$REPEAT"; then - pass_count=$((pass_count + 1)) - echo "RUN $i PASS" - else - echo "RUN $i FAIL" >&2 - echo "Summary: $pass_count / $REPEAT passed before failure" >&2 - exit 1 +if [[ "$scenario" == "regression" || "$scenario" == "full" ]]; then + # Director/discovery waits need headroom when the suite runs many scenarios. + if (( TIMEOUT_SEC < 180 )); then + TIMEOUT_SEC=180 fi -done + suite_pass=0 + suite_total=${#REGRESSION_SCENARIOS[@]} + echo "======== REGRESSION SUITE ($suite_total scenarios, repeat=$REPEAT) ========" + for name in "${REGRESSION_SCENARIOS[@]}"; do + if run_one_scenario "$name" "regression"; then + suite_pass=$((suite_pass + 1)) + else + echo "REGRESSION FAIL at scenario=$name ($suite_pass / $suite_total completed)" >&2 + exit 1 + fi + done + echo "REGRESSION Summary: $suite_pass / $suite_total scenarios PASS (repeat=$REPEAT each)" + exit 0 +fi -echo "Summary: $pass_count / $REPEAT PASS" -exit 0 +run_one_scenario "${scenario:-raw}" "single" +exit $?