Regression Test

This commit is contained in:
DazedAnon 2026-07-14 15:13:49 -05:00
parent 7fc95e8a70
commit db5f45a00f
11 changed files with 937 additions and 78 deletions

View file

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

View file

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

View file

@ -26,6 +26,8 @@ public static class AgentHarness
private static readonly Queue<HarnessCommand> Queue = new Queue<HarnessCommand>();
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;
/// <summary>
/// When true, InterestDirector / SpeciesDiscovery stay frozen during harness commands.
/// <c>director_run</c> temporarily clears this so dwell/interrupt/discovery can execute.
/// </summary>
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<HarnessCommand> rest = new List<HarnessCommand>(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");

View file

@ -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;
}
}
/// <summary>
/// Full regression meta-suite (nested scenarios keep assert counters).
/// Battle / map-gen paths stay out of gate.
/// </summary>
private static List<HarnessCommand> Regression()
{
return new List<HarnessCommand>
{
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<HarnessCommand> Smoke()
{
return new List<HarnessCommand>
@ -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<HarnessCommand> SettingsFull()
{
return new List<HarnessCommand>
{
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<HarnessCommand> GhostGuard()
{
return new List<HarnessCommand>
{
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<HarnessCommand> RetargetStability()
{
return new List<HarnessCommand>
{
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<HarnessCommand> InputExit()
{
return new List<HarnessCommand>
{
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<HarnessCommand> DirectorTiers()
{
return new List<HarnessCommand>
{
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<HarnessCommand> Discovery()
{
return new List<HarnessCommand>
{
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<HarnessCommand> WorldLog()
{
return new List<HarnessCommand>
{
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,

View file

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

View file

@ -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;
/// <summary>
/// Harness-only: shorten dwell/rotate so director tests finish in seconds.
/// Game 5x speed does not help - this code uses <see cref="Time.unscaledTime"/>.
/// </summary>
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;
}
}
/// <summary>Harness: pretend the current interest started <paramref name="seconds"/> ago.</summary>
public static void HarnessAgeCurrent(float seconds)
{
float age = Mathf.Max(0f, seconds);
float now = Time.unscaledTime;
_currentStartedAt = now - age;
_lastSwitchAt = now - age;
}
/// <summary>Harness: expire input-exit grace immediately.</summary>
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
}
/// <summary>
/// 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 <paramref name="honorGrace"/> is true, input during the post-enable grace does not exit.
/// </summary>
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;
}

View file

@ -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();
}

View file

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

View file

@ -10,12 +10,15 @@ namespace IdleSpectator;
/// </summary>
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<PendingSpecies> Pending = new List<PendingSpecies>();
private static readonly HashSet<string> SeenAssets = new HashSet<string>();
private static readonly HashSet<string> PresentedAssets = new HashSet<string>();
@ -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;
}
/// <summary>
/// Harness-only: shorten drain interval. Uses unscaled time (game 5x does not help).
/// </summary>
public static void SetHarnessFastTiming(bool enabled)
{
_drainInterval = enabled ? 0.75f : DrainIntervalSecondsDefault;
_notReadyBackoff = enabled ? 0.35f : NotReadyBackoffSecondsDefault;
}
/// <summary>Harness: clear presented/seen so the same asset can be re-tested.</summary>
public static void HarnessResetTracking()
{
PresentedAssets.Clear();
SeenAssets.Clear();
Pending.Clear();
_drainedThisSession = 0;
_lastDrainAt = Time.unscaledTime - _drainInterval;
}
/// <summary>Harness: buffer a living asset as if newSpecies fired during load.</summary>
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);
}
/// <summary>Harness: ignore drain interval and attempt one drain immediately.</summary>
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;
}

View file

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

View file

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