Regression Test
This commit is contained in:
parent
7fc95e8a70
commit
db5f45a00f
11 changed files with 937 additions and 78 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 $?
|
||||
|
|
|
|||
Loading…
Reference in a new issue