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 name: idle-spectator-e2e
description: >- description: >-
Run IdleSpectator WorldBox E2E tests via the in-mod agent harness 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, Use when adding or fixing IdleSpectator features, playtesting the mod,
writing harness scenarios, or verifying spectator/focus/tip/settings behavior. 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). 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. 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 ```bash
./scripts/harness-run.sh --repeat 3 critical_smoke ./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`). 4. After larger changes (director, discovery, settings, input, tips), also run:
5. Iterate until **all repeats PASS**. Do not stop after one flaky pass. ```bash
6. Report: scenario name, PASS/FAIL counts, and any new asserts. ./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 tiny doc-only edits, harness is optional.
For anything that touches camera, spectator, tips, settings, discovery, or input exit, harness is required. 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 critical_smoke
./scripts/harness-run.sh --repeat 3 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 tip_match
./scripts/harness-run.sh smoke ./scripts/harness-run.sh smoke
./scripts/harness-run.sh --no-launch critical_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 - Touches `.harness/reset` + rewinds offset so commands are re-read
- Enables `.harness/auto-dismiss` (closes Welcome Back) - Enables `.harness/auto-dismiss` (closes Welcome Back)
- Waits for `last-result.json` status `PASS` / `FAIL` - Waits for `last-result.json` status `PASS` / `FAIL`
- `regression` runs each named scenario sequentially (stop on first fail)
A loaded world is still required. A loaded world is still required.
`wait_world` + auto-dismiss handle Welcome Back and empty maps. `wait_world` + auto-dismiss handle Welcome Back and empty maps.
@ -72,32 +78,50 @@ A loaded world is still required.
| Name | Purpose | | 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 | | `smoke` | Short enable/focus health check |
| `tip_match` | Tip asset must match focused unit | | `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. Prefer `pick_unit` (living map unit) over miracle `spawn` in critical paths.
Spawned crabs/sheep often die on bad tiles and flake tests. 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 ## Adding coverage for a new fix
1. Add asserts/steps to `critical_smoke` when the bug is core behavior. 1. Add asserts/steps to the most specific scenario (or `critical_smoke` if core).
2. Or add a named scenario and run both it and `critical_smoke`. 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`. 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). 5. `wait_world` must stay front-of-queue on retry (never enqueue behind later steps).
## Command cheat sheet ## Command cheat sheet
JSONL lines in `.harness/commands.jsonl` (also used by scenarios): 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`) - `spectator` (`value=on|off`, optional `expect=blocked`)
- `pick_unit`, `spawn`, `focus`, `watch`, `trigger_interest` - `pick_unit`, `spawn`, `focus`, `watch`, `watch_ghost`, `trigger_interest`
- `simulate_input` (camera-drag manual exit) - `simulate_input` (`expect=grace` to assert grace hold)
- `set_setting` (`expect=enabled`, `value=true|false`) - `set_setting` (`expect=enabled|show_watch_reasons`, `value=true|false`)
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled` - `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain`
- `snapshot`, `reset_counters`, `scenario` - `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: Machine output:
@ -117,6 +141,7 @@ A change is done only when:
- Relevant harness scenario(s) PASS - Relevant harness scenario(s) PASS
- For core spectator/focus/tip/settings work: `critical_smoke` passes **3 times** (`--repeat 3`) - 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 - Failures were fixed by hardening the mod or scenario, not by lowering asserts
## More detail ## More detail

View file

@ -7,8 +7,10 @@ scripts/harness-run.sh
-> starts WorldBox if needed -> starts WorldBox if needed
-> writes IdleSpectator/.harness/commands.jsonl -> writes IdleSpectator/.harness/commands.jsonl
-> touches reset + auto-dismiss -> touches reset + auto-dismiss
-> regression: loops named scenarios sequentially
AgentHarness.Update() (in-game) AgentHarness.Update() (in-game)
-> executes commands on Unity main thread -> executes commands on Unity main thread
-> FreezeDirector unless director_run is active
-> writes results.jsonl + last-result.json -> writes results.jsonl + last-result.json
-> logs [HARNESS]/[ASSERT]/[SNAP] -> logs [HARNESS]/[ASSERT]/[SNAP]
``` ```
@ -18,6 +20,12 @@ AgentHarness.Update() (in-game)
Wayland makes `xdotool`/`ydotool` unreliable. Wayland makes `xdotool`/`ydotool` unreliable.
Truth lives in engine state (`MoveCamera`, tips, settings), so drive the mod API directly. 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 ## Hard-won failure modes
| Symptom | Cause | Fix | | 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` | | 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` | | 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 | | 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 | | Steam hang after tests | `Application.Quit` | leave game running |
| Director tests take forever | production dwell/rotate | `fast_timing` (not game 5x) |
## Useful asserts ## Useful asserts
- `health` - idle + focus + power bar hidden + `bad==0` - `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 - `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 - `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 ## Extending scenarios
@ -42,11 +54,12 @@ Edit `IdleSpectator/HarnessScenarios.cs`:
1. Add steps with `Step(id, action, ...)`. 1. Add steps with `Step(id, action, ...)`.
2. Register name in `Build`. 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: Keep critical_smoke deterministic:
- `dismiss_windows` / `wait_world` first - `dismiss_windows` / `wait_world` first
- `pick_unit` before tip/focus asserts - `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 - `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 readonly Queue<HarnessCommand> Queue = new Queue<HarnessCommand>();
private static Actor _lastSpawned; private static Actor _lastSpawned;
private static string _lastSpawnedAssetId = ""; private static string _lastSpawnedAssetId = "";
private static bool _directorRunActive;
private static string _rememberedFocusKey = "";
private static readonly string[] PreferredSpawnAssets = private static readonly string[] PreferredSpawnAssets =
{ {
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab" "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; 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() public static void Update()
{ {
EnsureDirs(); EnsureDirs();
@ -71,6 +79,7 @@ public static class AgentHarness
} }
_waitUntil = -1f; _waitUntil = -1f;
_directorRunActive = false;
} }
if (Queue.Count == 0) if (Queue.Count == 0)
@ -134,12 +143,68 @@ public static class AgentHarness
case "wait": case "wait":
{ {
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 1f); float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 1f);
_directorRunActive = false;
_waitUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds); _waitUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds);
_cmdOk++; _cmdOk++;
Emit(cmd, ok: true, detail: $"wait={seconds:0.##}s"); Emit(cmd, ok: true, detail: $"wait={seconds:0.##}s");
break; 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": case "spectator":
{ {
bool on = ParseBool(cmd.value, defaultValue: true); bool on = ParseBool(cmd.value, defaultValue: true);
@ -173,8 +238,25 @@ public static class AgentHarness
case "simulate_input": case "simulate_input":
{ {
bool exited = InterestDirector.SimulateManualInputExit(); string mode = (cmd.value ?? "drag").Trim().ToLowerInvariant();
if (exited) 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++; _cmdOk++;
} }
@ -183,10 +265,53 @@ public static class AgentHarness
_cmdFail++; _cmdFail++;
} }
Emit(cmd, exited, detail: $"idle={SpectatorMode.Active}"); Emit(cmd, ok, detail);
break; 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": case "spawn":
DoSpawn(cmd); DoSpawn(cmd);
break; break;
@ -224,14 +349,21 @@ public static class AgentHarness
return; return;
case "reset_counters": case "reset_counters":
{
StateProbe.ResetCounters(); StateProbe.ResetCounters();
_assertPass = 0; string mode = (cmd.value ?? cmd.expect ?? "").Trim().ToLowerInvariant();
_assertFail = 0; if (mode == "all" || mode == "harness")
_cmdOk = 0; {
_cmdFail = 0; _assertPass = 0;
_assertFail = 0;
_cmdOk = 0;
_cmdFail = 0;
}
_cmdOk++; _cmdOk++;
Emit(cmd, ok: true, detail: "counters_reset"); Emit(cmd, ok: true, detail: mode == "all" || mode == "harness" ? "counters_reset_all" : "stateprobe_reset");
break; break;
}
default: default:
_cmdFail++; _cmdFail++;
@ -261,11 +393,15 @@ public static class AgentHarness
} }
_lastBatchId = string.IsNullOrEmpty(cmd.id) ? name : cmd.id; _lastBatchId = string.IsNullOrEmpty(cmd.id) ? name : cmd.id;
_assertPass = 0; bool keepStats = (cmd.expect ?? "").Trim().Equals("keep", System.StringComparison.OrdinalIgnoreCase);
_assertFail = 0; if (!keepStats)
_cmdOk = 0; {
_cmdFail = 0; _assertPass = 0;
StateProbe.ResetCounters(); _assertFail = 0;
_cmdOk = 0;
_cmdFail = 0;
StateProbe.ResetCounters();
}
// Re-queue scenario steps ahead of anything else already queued. // Re-queue scenario steps ahead of anything else already queued.
List<HarnessCommand> rest = new List<HarnessCommand>(Queue); List<HarnessCommand> rest = new List<HarnessCommand>(Queue);
@ -552,14 +688,16 @@ public static class AgentHarness
Score = 40f, Score = 40f,
Position = follow.current_position, Position = follow.current_position,
FollowUnit = follow, 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, CreatedAt = Time.unscaledTime,
AssetId = follow.asset != null ? follow.asset.id : assetId AssetId = follow.asset != null ? follow.asset.id : assetId
}; };
InterestCollector.EnqueueDirect(interest); InterestCollector.EnqueueDirect(interest);
_cmdOk++; _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) private static void DoSetSetting(HarnessCommand cmd)
@ -711,6 +849,74 @@ public static class AgentHarness
detail = $"enabled={ModSettings.Enabled} want={want}"; detail = $"enabled={ModSettings.Enabled} want={want}";
break; 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: default:
pass = false; pass = false;
detail = "unknown expect: " + expect; detail = "unknown expect: " + expect;
@ -736,12 +942,127 @@ public static class AgentHarness
private static void DoSnapshot(HarnessCommand cmd) private static void DoSnapshot(HarnessCommand cmd)
{ {
string snap = 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++; _cmdOk++;
Emit(cmd, ok: true, detail: snap); Emit(cmd, ok: true, detail: snap);
LogService.LogInfo("[IdleSpectator][SNAP] " + 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() private static void IngestNewCommands()
{ {
string path = CommandsPath(); string path = CommandsPath();
@ -1024,6 +1345,27 @@ public static class AgentHarness
return fallback; 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) private static bool ParseBool(string raw, bool defaultValue)
{ {
if (string.IsNullOrEmpty(raw)) if (string.IsNullOrEmpty(raw))
@ -1140,10 +1482,14 @@ public static class AgentHarness
Queue.Clear(); Queue.Clear();
_waitUntil = -1f; _waitUntil = -1f;
_directorRunActive = false;
_readyLogged = false; _readyLogged = false;
_offset = 0; _offset = 0;
_lastSpawned = null; _lastSpawned = null;
_lastSpawnedAssetId = ""; _lastSpawnedAssetId = "";
_rememberedFocusKey = "";
InterestDirector.SetHarnessFastTiming(false);
SpeciesDiscovery.SetHarnessFastTiming(false);
try try
{ {
File.WriteAllText(OffsetPath(), "0"); File.WriteAllText(OffsetPath(), "0");

View file

@ -18,11 +18,49 @@ internal static class HarnessScenarios
case "critical": case "critical":
case "critical_smoke": case "critical_smoke":
return CriticalSmoke(); 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: default:
return null; 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() private static List<HarnessCommand> Smoke()
{ {
return new List<HarnessCommand> return new List<HarnessCommand>
@ -70,7 +108,6 @@ internal static class HarnessScenarios
Step("c01", "wait_world"), Step("c01", "wait_world"),
Step("c02", "reset_counters"), Step("c02", "reset_counters"),
// Lock onto a living map unit for the rest of the scenario.
Step("c05", "pick_unit", asset: "auto"), Step("c05", "pick_unit", asset: "auto"),
Step("c06", "wait", wait: 0.25f), 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( private static HarnessCommand Step(
string id, string id,
string action, 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) public static bool HasPendingAtLeast(InterestTier minTier)
{ {
lock (Gate) lock (Gate)

View file

@ -16,6 +16,18 @@ public static class InterestDirector
public const float QuietWorldAmbientAfterSeconds = 8f; public const float QuietWorldAmbientAfterSeconds = 8f;
public const float AmbientRotateSeconds = 20f; public const float AmbientRotateSeconds = 20f;
public const float ActionPollSeconds = 0.75f; 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 InterestEvent _current;
private static float _currentStartedAt = -999f; private static float _currentStartedAt = -999f;
@ -24,7 +36,62 @@ public static class InterestDirector
private static float _lastAmbientAt = -999f; private static float _lastAmbientAt = -999f;
private static float _lastActionPollAt = -999f; private static float _lastActionPollAt = -999f;
private static float _enabledAt = -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() public static void OnSpectatorEnabled()
{ {
@ -53,14 +120,13 @@ public static class InterestDirector
return; return;
} }
// During harness batches, do not ambient-steal focus. The harness picks units explicitly. // During harness batches, freeze unless director_run explicitly unfreezes.
if (AgentHarness.Busy) if (AgentHarness.FreezeDirector)
{ {
return; return;
} }
// Harness drives the camera/assert loop; ignore manual-input exit while commands run. if (Time.unscaledTime - _enabledAt >= _inputExitGrace
if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
&& PlayerTookManualControl()) && PlayerTookManualControl())
{ {
ExitFromManualInput(); ExitFromManualInput();
@ -72,7 +138,7 @@ public static class InterestDirector
float sinceSwitch = now - _lastSwitchAt; float sinceSwitch = now - _lastSwitchAt;
// Periodically surface live battles into the queue (Action tier). // Periodically surface live battles into the queue (Action tier).
if (now - _lastActionPollAt >= ActionPollSeconds) if (now - _lastActionPollAt >= _actionPoll)
{ {
_lastActionPollAt = now; _lastActionPollAt = now;
InterestEvent battle = WorldActivityScanner.FindHottestBattle(); InterestEvent battle = WorldActivityScanner.FindHottestBattle();
@ -112,14 +178,14 @@ public static class InterestDirector
return; return;
} }
bool quiet = now - _lastInterestingAt >= QuietWorldAmbientAfterSeconds; bool quiet = now - _lastInterestingAt >= _quietAmbientAfter;
bool dwellDone = _current == null || onCurrent >= DwellFor(_current); bool dwellDone = _current == null || onCurrent >= DwellFor(_current);
bool ambientDue = now - _lastAmbientAt >= AmbientRotateSeconds; bool ambientDue = now - _lastAmbientAt >= _ambientRotate;
if (_current == null && sinceSwitch >= 0.5f) if (_current == null && sinceSwitch >= 0.5f)
{ {
TryAmbient(force: true); TryAmbient(force: true);
} }
else if (quiet && dwellDone && ambientDue && sinceSwitch >= SwitchCooldownSeconds) else if (quiet && dwellDone && ambientDue && sinceSwitch >= _switchCooldown)
{ {
TryAmbient(force: false); TryAmbient(force: false);
} }
@ -144,9 +210,10 @@ public static class InterestDirector
} }
/// <summary> /// <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> /// </summary>
public static bool SimulateManualInputExit() public static bool SimulateManualInputExit(bool honorGrace = false)
{ {
if (!SpectatorMode.Active) if (!SpectatorMode.Active)
{ {
@ -161,6 +228,12 @@ public static class InterestDirector
return false; return false;
} }
if (honorGrace && Time.unscaledTime - _enabledAt < _inputExitGrace)
{
MoveCamera.camera_drag_run = false;
return false;
}
ExitFromManualInput(); ExitFromManualInput();
MoveCamera.camera_drag_run = false; MoveCamera.camera_drag_run = false;
return !SpectatorMode.Active; return !SpectatorMode.Active;
@ -218,7 +291,7 @@ public static class InterestDirector
return MinDwellSeconds; 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) private static bool CanSwitchTo(InterestEvent candidate, float onCurrent, float sinceSwitch)
@ -237,7 +310,7 @@ public static class InterestDirector
return false; return false;
} }
if (_current.Tier == InterestTier.Curiosity && onCurrent < CuriosityRotateSeconds) if (_current.Tier == InterestTier.Curiosity && onCurrent < _curiosityRotate)
{ {
return false; 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 true;
} }
return onCurrent >= DwellFor(_current) && sinceSwitch >= SwitchCooldownSeconds; return onCurrent >= DwellFor(_current) && sinceSwitch >= _switchCooldown;
} }
private static void SwitchTo(InterestEvent next, float now) 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. // Don't let ambient yank the camera off a curiosity tip after a few seconds.
if (!force && _current != null && _current.Tier == InterestTier.Curiosity if (!force && _current != null && _current.Tier == InterestTier.Curiosity
&& now - _currentStartedAt < AmbientRotateSeconds) && now - _currentStartedAt < _ambientRotate)
{ {
return; return;
} }
if (!force && now - _lastSwitchAt < SwitchCooldownSeconds && _current != null) if (!force && now - _lastSwitchAt < _switchCooldown && _current != null)
{ {
return; return;
} }

View file

@ -62,7 +62,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
AgentHarness.Update(); AgentHarness.Update();
SpectatorMode.PollInput(); SpectatorMode.PollInput();
InterestDirector.Update(); InterestDirector.Update();
if (!AgentHarness.Busy) if (!AgentHarness.FreezeDirector)
{ {
SpeciesDiscovery.Update(); SpeciesDiscovery.Update();
} }

View file

@ -59,6 +59,12 @@ public static class ModSettings
{ {
ModConfigItem item = _config[GroupId][id]; ModConfigItem item = _config[GroupId][id];
item.SetValue(value); item.SetValue(value);
// Ensure settings callbacks apply even if NML does not fire them from SetValue.
if (id == EnabledId)
{
OnEnabledChanged(value);
}
return true; return true;
} }
catch (System.Exception ex) catch (System.Exception ex)

View file

@ -10,12 +10,15 @@ namespace IdleSpectator;
/// </summary> /// </summary>
public static class SpeciesDiscovery public static class SpeciesDiscovery
{ {
private const float DrainIntervalSeconds = 12f; private const float DrainIntervalSecondsDefault = 12f;
private const float NotReadyBackoffSeconds = 5f; private const float NotReadyBackoffSecondsDefault = 5f;
private const int MaxBuffered = 24; private const int MaxBuffered = 24;
private const int MaxDrainPerSession = 8; private const int MaxDrainPerSession = 8;
private const int MaxNotReadyAttempts = 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 List<PendingSpecies> Pending = new List<PendingSpecies>();
private static readonly HashSet<string> SeenAssets = new HashSet<string>(); private static readonly HashSet<string> SeenAssets = new HashSet<string>();
private static readonly HashSet<string> PresentedAssets = new HashSet<string>(); private static readonly HashSet<string> PresentedAssets = new HashSet<string>();
@ -30,10 +33,14 @@ public static class SpeciesDiscovery
public int NotReadyAttempts; public int NotReadyAttempts;
} }
public static int PendingCount => Pending.Count;
public static int DrainedThisSession => _drainedThisSession;
public static void OnSpectatorEnabled() public static void OnSpectatorEnabled()
{ {
_drainedThisSession = 0; _drainedThisSession = 0;
_lastDrainAt = Time.unscaledTime - DrainIntervalSeconds + 2f; _lastDrainAt = Time.unscaledTime - _drainInterval + 2f;
} }
public static void OnSpectatorDisabled() public static void OnSpectatorDisabled()
@ -42,6 +49,73 @@ public static class SpeciesDiscovery
_drainedThisSession = 0; _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) public static void NoteCreated(ActorAsset asset, WorldTile tile, bool mutation, Subspecies subspecies)
{ {
if (mutation || asset == null || string.IsNullOrEmpty(asset.id)) if (mutation || asset == null || string.IsNullOrEmpty(asset.id))
@ -119,7 +193,7 @@ public static class SpeciesDiscovery
} }
float now = Time.unscaledTime; float now = Time.unscaledTime;
if (now - _lastDrainAt < DrainIntervalSeconds) if (now - _lastDrainAt < _drainInterval)
{ {
return; return;
} }
@ -134,7 +208,7 @@ public static class SpeciesDiscovery
if (index < 0) if (index < 0)
{ {
// Front items not spawnable yet - retry later without dequeue flap. // Front items not spawnable yet - retry later without dequeue flap.
_lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds; _lastDrainAt = now - _drainInterval + _notReadyBackoff;
return; return;
} }
@ -160,7 +234,7 @@ public static class SpeciesDiscovery
Pending[index] = next; Pending[index] = next;
} }
_lastDrainAt = now - DrainIntervalSeconds + NotReadyBackoffSeconds; _lastDrainAt = now - _drainInterval + _notReadyBackoff;
return; return;
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.5.0", "version": "0.6.3",
"description": "AFK Idle Spectator (I) with agent harness. Critical E2E: settings gate, focus health, tip match, retarget, manual exit.", "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" "GUID": "com.dazed.idlespectator"
} }

View file

@ -5,6 +5,7 @@
# Usage: # Usage:
# ./scripts/harness-run.sh # default: critical_smoke # ./scripts/harness-run.sh # default: critical_smoke
# ./scripts/harness-run.sh 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 --repeat 3 # run scenario 3 times (all must PASS)
# ./scripts/harness-run.sh tip_match # ./scripts/harness-run.sh tip_match
# ./scripts/harness-run.sh smoke # ./scripts/harness-run.sh smoke
@ -22,6 +23,18 @@ BOOT_TIMEOUT_SEC="${HARNESS_BOOT_TIMEOUT:-180}"
DO_LAUNCH=1 DO_LAUNCH=1
REPEAT=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="" scenario=""
raw_cmd="" raw_cmd=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -118,6 +131,7 @@ ensure_worldbox() {
queue_and_wait() { queue_and_wait() {
local run_label="$1" local run_label="$1"
local scenario_name="$2"
rm -f "$HARNESS/last-result.json" rm -f "$HARNESS/last-result.json"
: > "$HARNESS/results.jsonl" : > "$HARNESS/results.jsonl"
echo 0 > "$HARNESS/offset" echo 0 > "$HARNESS/offset"
@ -127,16 +141,16 @@ queue_and_wait() {
sleep 0.35 sleep 0.35
{ {
echo '{"id":"batch","action":"reset_counters"}' echo '{"id":"batch","action":"reset_counters","value":"all"}'
echo '{"id":"dismiss","action":"dismiss_windows"}' echo '{"id":"dismiss","action":"dismiss_windows"}'
if [[ -n "$raw_cmd" ]]; then if [[ -n "$raw_cmd" ]]; then
echo "$raw_cmd" echo "$raw_cmd"
else else
echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario\"}" echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario_name\"}"
fi fi
} > "$HARNESS/commands.jsonl" } > "$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) ..." echo "Waiting up to ${TIMEOUT_SEC}s for last-result.json (load a world if at the menu) ..."
local deadline=$((SECONDS + TIMEOUT_SEC)) local deadline=$((SECONDS + TIMEOUT_SEC))
@ -171,6 +185,25 @@ queue_and_wait() {
return 1 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" mkdir -p "$HARNESS"
ensure_worldbox ensure_worldbox
@ -178,18 +211,25 @@ if (( TIMEOUT_SEC < 120 )); then
TIMEOUT_SEC=120 TIMEOUT_SEC=120
fi fi
pass_count=0 if [[ "$scenario" == "regression" || "$scenario" == "full" ]]; then
for ((i=1; i<=REPEAT; i++)); do # Director/discovery waits need headroom when the suite runs many scenarios.
echo "======== RUN $i / $REPEAT ========" if (( TIMEOUT_SEC < 180 )); then
if queue_and_wait "run $i/$REPEAT"; then TIMEOUT_SEC=180
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
fi 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" run_one_scenario "${scenario:-raw}" "single"
exit 0 exit $?