Working smoke test

This commit is contained in:
DazedAnon 2026-07-14 14:59:26 -05:00
parent 331a1d8583
commit 08d781a67b
8 changed files with 681 additions and 112 deletions

View file

@ -24,6 +24,12 @@ public static class AgentHarness
private static int _cmdFail; private static int _cmdFail;
private static string _lastBatchId = ""; private static string _lastBatchId = "";
private static readonly Queue<HarnessCommand> Queue = new Queue<HarnessCommand>(); private static readonly Queue<HarnessCommand> Queue = new Queue<HarnessCommand>();
private static Actor _lastSpawned;
private static string _lastSpawnedAssetId = "";
private static readonly string[] PreferredSpawnAssets =
{
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
};
[Serializable] [Serializable]
private class LastResult private class LastResult
@ -48,6 +54,7 @@ public static class AgentHarness
public static void Update() public static void Update()
{ {
EnsureDirs(); EnsureDirs();
CheckExternalReset();
IngestNewCommands(); IngestNewCommands();
// Welcome Back / other ScrollWindows block world start until closed. // Welcome Back / other ScrollWindows block world start until closed.
@ -98,9 +105,9 @@ public static class AgentHarness
{ {
case "wait_world": case "wait_world":
TryDismissBlockingWindows(); TryDismissBlockingWindows();
if (!WorldReady() || IsBlockingWindowOpen()) if (!WorldReady() || IsBlockingWindowOpen() || !HasAnyAliveUnit())
{ {
Queue.Enqueue(cmd); RequeueFront(cmd);
_waitUntil = Time.unscaledTime + 0.35f; _waitUntil = Time.unscaledTime + 0.35f;
if (!_readyLogged) if (!_readyLogged)
{ {
@ -136,8 +143,21 @@ public static class AgentHarness
case "spectator": case "spectator":
{ {
bool on = ParseBool(cmd.value, defaultValue: true); bool on = ParseBool(cmd.value, defaultValue: true);
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
SpectatorMode.SetActive(on); SpectatorMode.SetActive(on);
bool ok = SpectatorMode.Active == on; bool ok;
string detail;
if (expect == "blocked")
{
ok = on && !SpectatorMode.Active;
detail = $"blocked idle={SpectatorMode.Active} enabled={ModSettings.Enabled}";
}
else
{
ok = SpectatorMode.Active == on;
detail = $"idle={SpectatorMode.Active}";
}
if (ok) if (ok)
{ {
_cmdOk++; _cmdOk++;
@ -147,7 +167,23 @@ public static class AgentHarness
_cmdFail++; _cmdFail++;
} }
Emit(cmd, ok, detail: $"idle={SpectatorMode.Active}"); Emit(cmd, ok, detail);
break;
}
case "simulate_input":
{
bool exited = InterestDirector.SimulateManualInputExit();
if (exited)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, exited, detail: $"idle={SpectatorMode.Active}");
break; break;
} }
@ -155,6 +191,10 @@ public static class AgentHarness
DoSpawn(cmd); DoSpawn(cmd);
break; break;
case "pick_unit":
DoPickUnit(cmd);
break;
case "focus": case "focus":
DoFocus(cmd); DoFocus(cmd);
break; break;
@ -245,21 +285,74 @@ public static class AgentHarness
LogService.LogInfo($"[IdleSpectator][HARNESS] scenario={name} steps={steps.Count}"); LogService.LogInfo($"[IdleSpectator][HARNESS] scenario={name} steps={steps.Count}");
} }
private static void DoPickUnit(HarnessCommand cmd)
{
if (!WorldReady())
{
if (RetryFront(cmd, "world_not_ready"))
{
return;
}
_cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready");
return;
}
string assetId = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset;
Actor unit = ResolveUnit(assetId);
if (unit == null || !unit.isAlive())
{
// Ignore stale harness spawn handle; scan the live world.
_lastSpawned = null;
unit = string.IsNullOrEmpty(assetId)
? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f)
: WorldActivityScanner.FindAliveByAssetId(assetId, CameraPos(), 5000f);
}
if (unit == null || !unit.isAlive())
{
if (RetryFront(cmd, "no_unit"))
{
return;
}
_cmdFail++;
Emit(cmd, ok: false, detail: "no living unit in world");
return;
}
_lastSpawned = unit;
_lastSpawnedAssetId = unit.asset != null ? unit.asset.id : "";
CameraDirector.FocusUnit(unit);
_cmdOk++;
Emit(cmd, ok: true, detail: $"picked={SafeName(unit)} asset={_lastSpawnedAssetId}");
}
private static void DoSpawn(HarnessCommand cmd) private static void DoSpawn(HarnessCommand cmd)
{ {
if (!WorldReady()) if (!WorldReady())
{ {
if (RetryFront(cmd, "world_not_ready"))
{
return;
}
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready"); Emit(cmd, ok: false, detail: "world_not_ready");
return; return;
} }
string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
if (string.IsNullOrEmpty(assetId)) if (string.IsNullOrEmpty(assetId) || assetId == "auto")
{ {
_cmdFail++; assetId = PickSpawnAssetId();
Emit(cmd, ok: false, detail: "missing asset"); if (string.IsNullOrEmpty(assetId))
return; {
_cmdFail++;
Emit(cmd, ok: false, detail: "no spawnable asset");
return;
}
} }
if (AssetManager.actor_library?.get(assetId) == null) if (AssetManager.actor_library?.get(assetId) == null)
@ -270,8 +363,26 @@ public static class AgentHarness
} }
WorldTile tile = TileNearCamera(); WorldTile tile = TileNearCamera();
// Prefer spawning next to an existing living unit so terrain is valid.
Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
if (anchor != null)
{
WorldTile anchorTile = World.world.GetTile(
Mathf.RoundToInt(anchor.current_position.x),
Mathf.RoundToInt(anchor.current_position.y));
if (anchorTile != null)
{
tile = anchorTile;
}
}
if (tile == null) if (tile == null)
{ {
if (RetryFront(cmd, "no_tile"))
{
return;
}
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: "no tile near camera"); Emit(cmd, ok: false, detail: "no tile near camera");
return; return;
@ -283,54 +394,61 @@ public static class AgentHarness
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true); Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
if (actor != null) if (actor != null && actor.isAlive())
{ {
spawned++; spawned++;
last = actor; last = actor;
} }
} }
bool ok = spawned > 0; if (spawned <= 0 || last == null || !last.isAlive())
if (ok)
{
_cmdOk++;
}
else
{ {
if (RetryFront(cmd, "spawn_zero"))
{
return;
}
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: $"spawned=0/{count} asset={assetId}");
return;
} }
string who = last != null ? SafeName(last) : "-"; _lastSpawned = last;
Emit(cmd, ok, detail: $"spawned={spawned}/{count} asset={assetId} last={who}"); _lastSpawnedAssetId = last.asset != null ? last.asset.id : assetId;
CameraDirector.FocusUnit(last);
_cmdOk++;
Emit(cmd, ok: true, detail: $"spawned={spawned}/{count} asset={_lastSpawnedAssetId} last={SafeName(last)}");
} }
private static void DoFocus(HarnessCommand cmd) private static void DoFocus(HarnessCommand cmd)
{ {
if (!WorldReady()) if (!WorldReady())
{ {
if (RetryFront(cmd, "world_not_ready"))
{
return;
}
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready"); Emit(cmd, ok: false, detail: "world_not_ready");
return; return;
} }
Actor unit = null;
string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
if (!string.IsNullOrEmpty(assetId) && assetId != "nearest") if (assetId == "nearest" || assetId == "auto")
{ {
Vector3 pos = CameraPos(); assetId = assetId == "auto" ? _lastSpawnedAssetId : null;
unit = WorldActivityScanner.FindNearestAliveUnit(pos, 200f, assetId);
}
else if (MoveCamera.hasFocusUnit())
{
unit = MoveCamera._focus_unit;
}
else
{
unit = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
} }
Actor unit = ResolveUnit(assetId);
if (unit == null || !unit.isAlive()) if (unit == null || !unit.isAlive())
{ {
if (RetryFront(cmd, "no_unit"))
{
return;
}
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: "no unit to focus"); Emit(cmd, ok: false, detail: "no unit to focus");
return; return;
@ -345,34 +463,44 @@ public static class AgentHarness
{ {
if (!WorldReady()) if (!WorldReady())
{ {
if (RetryFront(cmd, "world_not_ready"))
{
return;
}
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: "world_not_ready"); Emit(cmd, ok: false, detail: "world_not_ready");
return; return;
} }
string assetId = cmd.asset; string assetId = cmd.asset;
Actor follow = null; if (assetId == "auto")
if (!string.IsNullOrEmpty(assetId))
{ {
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId); assetId = _lastSpawnedAssetId;
} }
Actor follow = ResolveUnit(string.IsNullOrEmpty(assetId) ? null : assetId);
if (follow == null) if (follow == null)
{ {
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); if (RetryFront(cmd, string.IsNullOrEmpty(assetId) ? "no_unit" : "no_asset_unit"))
} {
return;
}
if (follow == null)
{
_cmdFail++; _cmdFail++;
Emit(cmd, ok: false, detail: "no unit for watch"); Emit(cmd, ok: false, detail: string.IsNullOrEmpty(assetId) ? "no unit for watch" : "no unit for asset: " + assetId);
return; return;
} }
InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity); InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity);
string resolvedAsset = follow.asset != null ? follow.asset.id : assetId;
string label = string.IsNullOrEmpty(cmd.label) string label = string.IsNullOrEmpty(cmd.label)
? $"Harness: {(follow.asset != null ? follow.asset.id : SafeName(follow))}" ? $"Harness: {resolvedAsset}"
: cmd.label; : cmd.label.Replace("{asset}", resolvedAsset ?? "");
if (label.Contains("{asset}"))
{
label = label.Replace("{asset}", resolvedAsset ?? "");
}
InterestEvent interest = new InterestEvent InterestEvent interest = new InterestEvent
{ {
@ -522,16 +650,32 @@ public static class AgentHarness
string tipAsset = CameraDirector.LastWatchAssetId ?? ""; string tipAsset = CameraDirector.LastWatchAssetId ?? "";
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string unitAsset = unit?.asset != null ? unit.asset.id : ""; string unitAsset = unit?.asset != null ? unit.asset.id : "";
pass = !string.IsNullOrEmpty(tipAsset) string tip = CameraDirector.LastWatchLabel ?? "";
&& tipAsset == unitAsset bool assetsMatch = !string.IsNullOrEmpty(tipAsset)
&& MoveCamera.hasFocusUnit(); && tipAsset == unitAsset
&& MoveCamera.hasFocusUnit();
// If the tip claims a species, the focused unit must be that species.
bool labelOk = true;
const string prefix = "New species: ";
if (tip.StartsWith(prefix))
{
string claimed = tip.Substring(prefix.Length).Trim();
labelOk = !string.IsNullOrEmpty(claimed) && claimed == unitAsset;
}
pass = assetsMatch && labelOk;
detail = detail =
$"tipAsset={tipAsset} unitAsset={unitAsset} tip={CameraDirector.LastWatchLabel}"; $"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}";
break; break;
} }
case "unit_asset": case "unit_asset":
{ {
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
if (wantAsset == "auto" || string.IsNullOrEmpty(wantAsset))
{
wantAsset = _lastSpawnedAssetId;
}
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string unitAsset = unit?.asset != null ? unit.asset.id : ""; string unitAsset = unit?.asset != null ? unit.asset.id : "";
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset; pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
@ -544,6 +688,29 @@ public static class AgentHarness
detail = $"bad={StateProbe.BadEventCount}"; detail = $"bad={StateProbe.BadEventCount}";
break; break;
} }
case "tip_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
if (needle == "auto" || needle.Contains("{asset}"))
{
needle = (needle == "auto" ? "New species: {asset}" : needle)
.Replace("{asset}", _lastSpawnedAssetId ?? "");
}
string tip = CameraDirector.LastWatchLabel ?? "";
pass = !string.IsNullOrEmpty(needle)
&& tip.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"tip='{tip}' contains='{needle}'";
break;
}
case "enabled":
case "setting_enabled":
{
bool want = ParseBool(cmd.value, defaultValue: true);
pass = ModSettings.Enabled == want;
detail = $"enabled={ModSettings.Enabled} want={want}";
break;
}
default: default:
pass = false; pass = false;
detail = "unknown expect: " + expect; detail = "unknown expect: " + expect;
@ -705,6 +872,11 @@ public static class AgentHarness
return Config.game_loaded && World.world != null && !SmoothLoader.isLoading(); return Config.game_loaded && World.world != null && !SmoothLoader.isLoading();
} }
private static bool HasAnyAliveUnit()
{
return WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f) != null;
}
private static bool ShouldAutoDismissUi() private static bool ShouldAutoDismissUi()
{ {
if (Queue.Count > 0 || _waitUntil > 0f) if (Queue.Count > 0 || _waitUntil > 0f)
@ -915,6 +1087,143 @@ public static class AgentHarness
} }
} }
/// <summary>
/// Script drops <c>.harness/reset</c> (and/or writes offset=0) before a new command batch.
/// Without this, a same-length rewrite of commands.jsonl is skipped forever.
/// </summary>
private static void CheckExternalReset()
{
string dir = HarnessDir();
if (string.IsNullOrEmpty(dir))
{
return;
}
bool resetRequested = false;
string resetPath = Path.Combine(dir, "reset");
if (File.Exists(resetPath))
{
try
{
File.Delete(resetPath);
}
catch
{
// Still reset in memory.
}
resetRequested = true;
}
// Also honor an on-disk offset that was rewound by the runner.
if (!resetRequested && File.Exists(OffsetPath()))
{
try
{
if (long.TryParse(File.ReadAllText(OffsetPath()).Trim(), out long diskOffset)
&& diskOffset < _offset)
{
resetRequested = true;
_offset = diskOffset;
}
}
catch
{
// ignore
}
}
if (!resetRequested)
{
return;
}
Queue.Clear();
_waitUntil = -1f;
_readyLogged = false;
_offset = 0;
_lastSpawned = null;
_lastSpawnedAssetId = "";
try
{
File.WriteAllText(OffsetPath(), "0");
}
catch
{
// ignore
}
LogService.LogInfo("[IdleSpectator][HARNESS] inbox reset - re-reading commands.jsonl");
}
private static void RequeueFront(HarnessCommand cmd)
{
List<HarnessCommand> rest = new List<HarnessCommand>(Queue);
Queue.Clear();
Queue.Enqueue(cmd);
foreach (HarnessCommand step in rest)
{
Queue.Enqueue(step);
}
}
private static string PickSpawnAssetId()
{
foreach (string id in PreferredSpawnAssets)
{
if (AssetManager.actor_library?.get(id) != null)
{
return id;
}
}
return null;
}
private static Actor ResolveUnit(string assetId)
{
if (_lastSpawned != null && _lastSpawned.isAlive())
{
if (string.IsNullOrEmpty(assetId)
|| (_lastSpawned.asset != null && _lastSpawned.asset.id == assetId))
{
return _lastSpawned;
}
}
Vector3 pos = CameraPos();
if (!string.IsNullOrEmpty(assetId))
{
return WorldActivityScanner.FindAliveByAssetId(assetId, pos, 5000f);
}
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null && MoveCamera._focus_unit.isAlive())
{
return MoveCamera._focus_unit;
}
return WorldActivityScanner.FindNearestAliveUnit(pos, 5000f);
}
private static bool RetryFront(HarnessCommand cmd, string reason, int maxRetries = 20)
{
cmd.retries++;
if (cmd.retries > maxRetries)
{
return false;
}
RequeueFront(cmd);
_waitUntil = Time.unscaledTime + 0.35f;
if (cmd.retries == 1 || cmd.retries % 10 == 0)
{
LogService.LogInfo(
$"[IdleSpectator][HARNESS] retry id={cmd.id} action={cmd.action} reason={reason} attempt={cmd.retries}");
}
return true;
}
private static string HarnessDir() private static string HarnessDir()
{ {
if (string.IsNullOrEmpty(ModClass.ModFolder)) if (string.IsNullOrEmpty(ModClass.ModFolder))

View file

@ -17,4 +17,6 @@ internal class HarnessCommand
public string expect = ""; public string expect = "";
public int count = 1; public int count = 1;
public float wait; public float wait;
/// <summary>Transient retry counter (not written by the shell runner).</summary>
public int retries;
} }

View file

@ -15,6 +15,9 @@ internal static class HarnessScenarios
return Smoke(); return Smoke();
case "tip_match": case "tip_match":
return TipMatch(); return TipMatch();
case "critical":
case "critical_smoke":
return CriticalSmoke();
default: default:
return null; return null;
} }
@ -27,12 +30,14 @@ internal static class HarnessScenarios
Step("s0", "dismiss_windows"), Step("s0", "dismiss_windows"),
Step("s1", "wait_world"), Step("s1", "wait_world"),
Step("s2", "reset_counters"), Step("s2", "reset_counters"),
Step("s3", "spectator", value: "off"), Step("s3", "pick_unit", asset: "auto"),
Step("s4", "assert", expect: "idle", value: "false"), Step("s4", "spectator", value: "off"),
Step("s5", "spectator", value: "on"), Step("s5", "assert", expect: "idle", value: "false"),
Step("s6", "wait", wait: 0.75f), Step("s6", "spectator", value: "on"),
Step("s7", "assert", expect: "health"), Step("s7", "focus", asset: "auto"),
Step("s8", "snapshot"), Step("s8", "wait", wait: 0.35f),
Step("s9", "assert", expect: "health"),
Step("s10", "snapshot"),
}; };
} }
@ -43,15 +48,73 @@ internal static class HarnessScenarios
Step("t0", "dismiss_windows"), Step("t0", "dismiss_windows"),
Step("t1", "wait_world"), Step("t1", "wait_world"),
Step("t2", "reset_counters"), Step("t2", "reset_counters"),
Step("t3", "spectator", value: "on"), Step("t3", "pick_unit", asset: "auto"),
Step("t4", "spawn", asset: "crab", count: 2), Step("t4", "spectator", value: "on"),
Step("t5", "wait", wait: 0.5f), Step("t5", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"),
Step("t6", "watch", asset: "crab", label: "New species: crab", tier: "Curiosity"), Step("t6", "wait", wait: 0.3f),
Step("t7", "wait", wait: 0.35f), Step("t7", "assert", expect: "tip_matches_unit"),
Step("t8", "assert", expect: "tip_matches_unit"), Step("t8", "assert", expect: "unit_asset", asset: "auto"),
Step("t9", "assert", expect: "unit_asset", asset: "crab"), Step("t9", "assert", expect: "power_bar", value: "false"),
Step("t10", "assert", expect: "power_bar", value: "false"), Step("t10", "snapshot"),
Step("t11", "snapshot"), };
}
/// <summary>
/// Critical E2E against already-living map units (no fragile miracle spawns).
/// </summary>
private static List<HarnessCommand> CriticalSmoke()
{
return new List<HarnessCommand>
{
Step("c00", "dismiss_windows"),
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),
Step("c10", "set_setting", expect: "enabled", value: "false"),
Step("c11", "assert", expect: "enabled", value: "false"),
Step("c12", "spectator", value: "on", expect: "blocked"),
Step("c13", "assert", expect: "idle", value: "false"),
Step("c14", "set_setting", expect: "enabled", value: "true"),
Step("c15", "assert", expect: "enabled", value: "true"),
Step("c20", "spectator", value: "off"),
Step("c21", "assert", expect: "idle", value: "false"),
Step("c22", "spectator", value: "on"),
Step("c23", "focus", asset: "auto"),
Step("c24", "reset_counters"),
Step("c25", "wait", wait: 0.35f),
Step("c26", "assert", expect: "health"),
Step("c27", "assert", expect: "power_bar", value: "false"),
Step("c28", "assert", expect: "no_bad"),
Step("c30", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"),
Step("c31", "wait", wait: 0.3f),
Step("c32", "assert", expect: "tip_matches_unit"),
Step("c33", "assert", expect: "unit_asset", asset: "auto"),
Step("c34", "assert", expect: "tip_contains", value: "auto"),
Step("c35", "assert", expect: "power_bar", value: "false"),
Step("c36", "assert", expect: "no_bad"),
Step("c40", "watch", asset: "auto", label: "Retarget A: {asset}", tier: "Curiosity"),
Step("c41", "wait", wait: 0.25f),
Step("c42", "focus", asset: "auto"),
Step("c43", "wait", wait: 0.25f),
Step("c44", "watch", asset: "auto", label: "Retarget B: {asset}", tier: "Curiosity"),
Step("c45", "wait", wait: 0.35f),
Step("c46", "assert", expect: "health"),
Step("c47", "assert", expect: "no_bad"),
Step("c48", "assert", expect: "power_bar", value: "false"),
Step("c50", "simulate_input"),
Step("c51", "wait", wait: 0.25f),
Step("c52", "assert", expect: "idle", value: "false"),
Step("c53", "assert", expect: "no_bad"),
Step("c99", "snapshot"),
}; };
} }

View file

@ -53,13 +53,17 @@ public static class InterestDirector
return; return;
} }
// During harness batches, do not ambient-steal focus. The harness picks units explicitly.
if (AgentHarness.Busy)
{
return;
}
// Harness drives the camera/assert loop; ignore manual-input exit while commands run. // Harness drives the camera/assert loop; ignore manual-input exit while commands run.
if (!AgentHarness.Busy if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
&& Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
&& PlayerTookManualControl()) && PlayerTookManualControl())
{ {
SpectatorMode.SetActive(false); ExitFromManualInput();
WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
return; return;
} }
@ -139,6 +143,36 @@ public static class InterestDirector
sinceSwitch); sinceSwitch);
} }
/// <summary>
/// Harness: pulse camera-drag input and take the real manual-exit path.
/// </summary>
public static bool SimulateManualInputExit()
{
if (!SpectatorMode.Active)
{
return false;
}
MoveCamera.camera_drag_run = true;
bool detected = PlayerTookManualControl();
if (!detected)
{
MoveCamera.camera_drag_run = false;
return false;
}
ExitFromManualInput();
MoveCamera.camera_drag_run = false;
return !SpectatorMode.Active;
}
private static void ExitFromManualInput()
{
SpectatorMode.SetActive(false);
WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)");
}
private static bool PlayerTookManualControl() private static bool PlayerTookManualControl()
{ {
// Rising-edge style checks only - avoid sticky flags like already_used_power // Rising-edge style checks only - avoid sticky flags like already_used_power

View file

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

View file

@ -406,6 +406,16 @@ public static class WorldActivityScanner
/// </summary> /// </summary>
public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null) public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null)
{ {
// Freshly spawned units are often on asset.units before units_only_alive updates.
if (!string.IsNullOrEmpty(requireAssetId))
{
Actor fromAsset = FindAliveOnAssetList(requireAssetId, pos, maxDist);
if (fromAsset != null)
{
return fromAsset;
}
}
Actor best = null; Actor best = null;
float bestDist = maxDist * maxDist; float bestDist = maxDist * maxDist;
foreach (Actor actor in EnumerateAliveUnits()) foreach (Actor actor in EnumerateAliveUnits())
@ -429,6 +439,49 @@ public static class WorldActivityScanner
return best; return best;
} }
/// <summary>
/// Any living unit of this asset id (asset.units first, then world scan).
/// </summary>
public static Actor FindAliveByAssetId(string assetId, Vector3 nearPos, float maxDist = 5000f)
{
if (string.IsNullOrEmpty(assetId))
{
return null;
}
return FindNearestAliveUnit(nearPos, maxDist, assetId);
}
private static Actor FindAliveOnAssetList(string assetId, Vector3 nearPos, float maxDist)
{
ActorAsset asset = AssetManager.actor_library?.get(assetId);
if (asset?.units == null)
{
return null;
}
Actor best = null;
float bestDist = maxDist * maxDist;
foreach (Actor unit in asset.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
float dx = unit.current_position.x - nearPos.x;
float dy = unit.current_position.y - nearPos.y;
float d2 = dx * dx + dy * dy;
if (d2 < bestDist)
{
bestDist = d2;
best = unit;
}
}
return best;
}
private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist) private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist)
{ {
if (asset?.units != null) if (asset?.units != null)

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.4.1", "version": "0.5.0",
"description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species. Includes .harness agent playtest bus.", "description": "AFK Idle Spectator (I) with agent harness. Critical E2E: settings gate, focus health, tip match, retarget, manual exit.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }

View file

@ -1,9 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run an IdleSpectator agent harness scenario (or raw JSONL commands). # Run an IdleSpectator agent harness scenario (or raw JSONL commands).
# Starts WorldBox via Steam when it is not already running.
#
# Usage: # Usage:
# ./scripts/harness-run.sh smoke # ./scripts/harness-run.sh # default: critical_smoke
# ./scripts/harness-run.sh critical_smoke
# ./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 --cmd '{"id":"1","action":"snapshot"}' # ./scripts/harness-run.sh --cmd '{"id":"1","action":"snapshot"}'
# ./scripts/harness-run.sh --no-launch smoke
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@ -11,8 +17,10 @@ MOD="${IDLE_SPECTATOR_MOD:-$ROOT/IdleSpectator}"
HARNESS="$MOD/.harness" HARNESS="$MOD/.harness"
LOG="${WORLDBOX_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}" LOG="${WORLDBOX_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}"
TIMEOUT_SEC="${HARNESS_TIMEOUT:-90}" TIMEOUT_SEC="${HARNESS_TIMEOUT:-90}"
STEAM_APP_ID="${WORLDBOX_APP_ID:-1206560}"
mkdir -p "$HARNESS" BOOT_TIMEOUT_SEC="${HARNESS_BOOT_TIMEOUT:-180}"
DO_LAUNCH=1
REPEAT=1
scenario="" scenario=""
raw_cmd="" raw_cmd=""
@ -26,6 +34,14 @@ while [[ $# -gt 0 ]]; do
TIMEOUT_SEC="$2" TIMEOUT_SEC="$2"
shift 2 shift 2
;; ;;
--repeat)
REPEAT="$2"
shift 2
;;
--no-launch)
DO_LAUNCH=0
shift
;;
-*) -*)
echo "Unknown flag: $1" >&2 echo "Unknown flag: $1" >&2
exit 2 exit 2
@ -38,53 +54,142 @@ while [[ $# -gt 0 ]]; do
done done
if [[ -z "$scenario" && -z "$raw_cmd" ]]; then if [[ -z "$scenario" && -z "$raw_cmd" ]]; then
scenario="smoke" scenario="critical_smoke"
fi fi
rm -f "$HARNESS/last-result.json" worldbox_running() {
: > "$HARNESS/results.jsonl" local pid cmd
# While this file exists, harness auto-closes Welcome Back / blocking UI. for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do
touch "$HARNESS/auto-dismiss" cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)
if [[ "$cmd" == *"/common/worldbox/worldbox"* && "$cmd" != *zsh* && "$cmd" != *snap=* && "$cmd" != *harness-run* ]]; then
{ return 0
echo '{"id":"batch","action":"reset_counters"}'
echo '{"id":"dismiss","action":"dismiss_windows"}'
if [[ -n "$raw_cmd" ]]; then
echo "$raw_cmd"
else
echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario\"}"
fi
} > "$HARNESS/commands.jsonl"
echo "Harness queued: ${scenario:-raw} (mod=$MOD)"
echo "Waiting up to ${TIMEOUT_SEC}s for last-result.json ..."
deadline=$((SECONDS + TIMEOUT_SEC))
while (( SECONDS < deadline )); do
if [[ -f "$HARNESS/last-result.json" ]]; then
# NOTE: do not name this `status` - zsh treats that as read-only.
result_status="$(python3 -c "import json;print(json.load(open('$HARNESS/last-result.json')).get('status',''))" 2>/dev/null || true)"
if [[ "$result_status" == "PASS" || "$result_status" == "FAIL" || "$result_status" == "error" ]]; then
echo "===== last-result.json ====="
cat "$HARNESS/last-result.json"
echo "===== recent harness log ====="
if [[ -f "$LOG" ]]; then
rg -n "\[IdleSpectator\]\[(HARNESS|ASSERT|SNAP)\]|dismissed window" "$LOG" | tail -n 40 || true
fi
if [[ "$result_status" == "PASS" ]]; then
exit 0
fi
exit 1
fi fi
done
return 1
}
mod_ready() {
[[ -f "$LOG" ]] || return 1
if tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
return 2
fi
tail -n 200 "$LOG" 2>/dev/null | rg -q "\[IdleSpectator\]: Harmony patches applied"
}
ensure_worldbox() {
if worldbox_running; then
echo "WorldBox already running"
return 0
fi
if (( DO_LAUNCH == 0 )); then
echo "WorldBox is not running (pass without --no-launch to auto-start)" >&2
return 1
fi
echo "Starting WorldBox (steam app $STEAM_APP_ID) ..."
echo "===== HARNESS LAUNCH $(date -Iseconds) =====" >> "$LOG" 2>/dev/null || true
nohup steam -applaunch "$STEAM_APP_ID" >/tmp/idle-spectator-harness-launch.log 2>&1 &
disown || true
local deadline=$((SECONDS + BOOT_TIMEOUT_SEC))
while (( SECONDS < deadline )); do
if mod_ready; then
echo "WorldBox + IdleSpectator ready"
return 0
fi
if [[ -f "$LOG" ]] && tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
echo "IdleSpectator compile failed:" >&2
rg -n "error CS" "$LOG" | tail -n 40 >&2 || true
return 1
fi
if worldbox_running; then
printf '.'
else
printf 'o'
fi
sleep 2
done
echo >&2
echo "Timed out waiting for WorldBox/IdleSpectator boot (${BOOT_TIMEOUT_SEC}s)" >&2
tail -n 40 "$LOG" 2>/dev/null >&2 || true
return 1
}
queue_and_wait() {
local run_label="$1"
rm -f "$HARNESS/last-result.json"
: > "$HARNESS/results.jsonl"
echo 0 > "$HARNESS/offset"
: > "$HARNESS/commands.jsonl"
touch "$HARNESS/reset"
touch "$HARNESS/auto-dismiss"
sleep 0.35
{
echo '{"id":"batch","action":"reset_counters"}'
echo '{"id":"dismiss","action":"dismiss_windows"}'
if [[ -n "$raw_cmd" ]]; then
echo "$raw_cmd"
else
echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario\"}"
fi
} > "$HARNESS/commands.jsonl"
echo "Harness queued: ${scenario:-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))
while (( SECONDS < deadline )); do
if [[ -f "$HARNESS/last-result.json" ]]; then
# NOTE: do not name this `status` - zsh treats that as read-only.
local result_status
result_status="$(python3 -c "import json;print(json.load(open('$HARNESS/last-result.json')).get('status',''))" 2>/dev/null || true)"
if [[ "$result_status" == "PASS" || "$result_status" == "FAIL" || "$result_status" == "error" ]]; then
echo "===== last-result.json ($run_label) ====="
cat "$HARNESS/last-result.json"
echo "===== recent harness log ====="
if [[ -f "$LOG" ]]; then
rg -n "\[IdleSpectator\]\[(HARNESS|ASSERT|SNAP)\]|dismissed window|retry id=" "$LOG" | tail -n 50 || true
fi
if [[ "$result_status" == "PASS" ]]; then
return 0
fi
return 1
fi
fi
sleep 0.5
done
echo "Timed out after ${TIMEOUT_SEC}s ($run_label)" >&2
if [[ -f "$HARNESS/last-result.json" ]]; then
cat "$HARNESS/last-result.json" >&2 || true
fi
if [[ -f "$LOG" ]]; then
rg -n "IdleSpectator|error CS|Exception" "$LOG" | tail -n 50 >&2 || true
fi
return 1
}
mkdir -p "$HARNESS"
ensure_worldbox
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
fi fi
sleep 0.5
done done
echo "Timed out after ${TIMEOUT_SEC}s" >&2 echo "Summary: $pass_count / $REPEAT PASS"
if [[ -f "$HARNESS/last-result.json" ]]; then exit 0
cat "$HARNESS/last-result.json" >&2 || true
fi
if [[ -f "$LOG" ]]; then
rg -n "IdleSpectator|error CS|Exception" "$LOG" | tail -n 50 >&2 || true
fi
exit 1