diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 289d4c0..63c78ee 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -24,6 +24,12 @@ public static class AgentHarness private static int _cmdFail; private static string _lastBatchId = ""; private static readonly Queue Queue = new Queue(); + private static Actor _lastSpawned; + private static string _lastSpawnedAssetId = ""; + private static readonly string[] PreferredSpawnAssets = + { + "sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab" + }; [Serializable] private class LastResult @@ -48,6 +54,7 @@ public static class AgentHarness public static void Update() { EnsureDirs(); + CheckExternalReset(); IngestNewCommands(); // Welcome Back / other ScrollWindows block world start until closed. @@ -98,9 +105,9 @@ public static class AgentHarness { case "wait_world": TryDismissBlockingWindows(); - if (!WorldReady() || IsBlockingWindowOpen()) + if (!WorldReady() || IsBlockingWindowOpen() || !HasAnyAliveUnit()) { - Queue.Enqueue(cmd); + RequeueFront(cmd); _waitUntil = Time.unscaledTime + 0.35f; if (!_readyLogged) { @@ -136,8 +143,21 @@ public static class AgentHarness case "spectator": { bool on = ParseBool(cmd.value, defaultValue: true); + string expect = (cmd.expect ?? "").Trim().ToLowerInvariant(); 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) { _cmdOk++; @@ -147,7 +167,23 @@ public static class AgentHarness _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; } @@ -155,6 +191,10 @@ public static class AgentHarness DoSpawn(cmd); break; + case "pick_unit": + DoPickUnit(cmd); + break; + case "focus": DoFocus(cmd); break; @@ -245,21 +285,74 @@ public static class AgentHarness 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) { 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.value : cmd.asset; - if (string.IsNullOrEmpty(assetId)) + if (string.IsNullOrEmpty(assetId) || assetId == "auto") { - _cmdFail++; - Emit(cmd, ok: false, detail: "missing asset"); - return; + assetId = PickSpawnAssetId(); + if (string.IsNullOrEmpty(assetId)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no spawnable asset"); + return; + } } if (AssetManager.actor_library?.get(assetId) == null) @@ -270,8 +363,26 @@ public static class AgentHarness } 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 (RetryFront(cmd, "no_tile")) + { + return; + } + _cmdFail++; Emit(cmd, ok: false, detail: "no tile near camera"); return; @@ -283,54 +394,61 @@ public static class AgentHarness for (int i = 0; i < count; i++) { Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true); - if (actor != null) + if (actor != null && actor.isAlive()) { spawned++; last = actor; } } - bool ok = spawned > 0; - if (ok) - { - _cmdOk++; - } - else + if (spawned <= 0 || last == null || !last.isAlive()) { + if (RetryFront(cmd, "spawn_zero")) + { + return; + } + _cmdFail++; + Emit(cmd, ok: false, detail: $"spawned=0/{count} asset={assetId}"); + return; } - string who = last != null ? SafeName(last) : "-"; - Emit(cmd, ok, detail: $"spawned={spawned}/{count} asset={assetId} last={who}"); + _lastSpawned = last; + _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) { if (!WorldReady()) { + if (RetryFront(cmd, "world_not_ready")) + { + return; + } + _cmdFail++; Emit(cmd, ok: false, detail: "world_not_ready"); return; } - Actor unit = null; string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; - if (!string.IsNullOrEmpty(assetId) && assetId != "nearest") + if (assetId == "nearest" || assetId == "auto") { - Vector3 pos = CameraPos(); - unit = WorldActivityScanner.FindNearestAliveUnit(pos, 200f, assetId); - } - else if (MoveCamera.hasFocusUnit()) - { - unit = MoveCamera._focus_unit; - } - else - { - unit = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + assetId = assetId == "auto" ? _lastSpawnedAssetId : null; } + Actor unit = ResolveUnit(assetId); if (unit == null || !unit.isAlive()) { + if (RetryFront(cmd, "no_unit")) + { + return; + } + _cmdFail++; Emit(cmd, ok: false, detail: "no unit to focus"); return; @@ -345,34 +463,44 @@ public static class AgentHarness { if (!WorldReady()) { + if (RetryFront(cmd, "world_not_ready")) + { + return; + } + _cmdFail++; Emit(cmd, ok: false, detail: "world_not_ready"); return; } string assetId = cmd.asset; - Actor follow = null; - if (!string.IsNullOrEmpty(assetId)) + if (assetId == "auto") { - follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId); + assetId = _lastSpawnedAssetId; } + Actor follow = ResolveUnit(string.IsNullOrEmpty(assetId) ? null : assetId); if (follow == null) { - follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); - } + if (RetryFront(cmd, string.IsNullOrEmpty(assetId) ? "no_unit" : "no_asset_unit")) + { + return; + } - if (follow == null) - { _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; } InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity); + string resolvedAsset = follow.asset != null ? follow.asset.id : assetId; string label = string.IsNullOrEmpty(cmd.label) - ? $"Harness: {(follow.asset != null ? follow.asset.id : SafeName(follow))}" - : cmd.label; + ? $"Harness: {resolvedAsset}" + : cmd.label.Replace("{asset}", resolvedAsset ?? ""); + if (label.Contains("{asset}")) + { + label = label.Replace("{asset}", resolvedAsset ?? ""); + } InterestEvent interest = new InterestEvent { @@ -522,16 +650,32 @@ public static class AgentHarness string tipAsset = CameraDirector.LastWatchAssetId ?? ""; Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; string unitAsset = unit?.asset != null ? unit.asset.id : ""; - pass = !string.IsNullOrEmpty(tipAsset) - && tipAsset == unitAsset - && MoveCamera.hasFocusUnit(); + string tip = CameraDirector.LastWatchLabel ?? ""; + bool assetsMatch = !string.IsNullOrEmpty(tipAsset) + && 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 = - $"tipAsset={tipAsset} unitAsset={unitAsset} tip={CameraDirector.LastWatchLabel}"; + $"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}"; break; } case "unit_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; string unitAsset = unit?.asset != null ? unit.asset.id : ""; pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset; @@ -544,6 +688,29 @@ public static class AgentHarness detail = $"bad={StateProbe.BadEventCount}"; 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: pass = false; detail = "unknown expect: " + expect; @@ -705,6 +872,11 @@ public static class AgentHarness return Config.game_loaded && World.world != null && !SmoothLoader.isLoading(); } + private static bool HasAnyAliveUnit() + { + return WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f) != null; + } + private static bool ShouldAutoDismissUi() { if (Queue.Count > 0 || _waitUntil > 0f) @@ -915,6 +1087,143 @@ public static class AgentHarness } } + /// + /// Script drops .harness/reset (and/or writes offset=0) before a new command batch. + /// Without this, a same-length rewrite of commands.jsonl is skipped forever. + /// + 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 rest = new List(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() { if (string.IsNullOrEmpty(ModClass.ModFolder)) diff --git a/IdleSpectator/HarnessCommand.cs b/IdleSpectator/HarnessCommand.cs index 02e0814..4c58074 100644 --- a/IdleSpectator/HarnessCommand.cs +++ b/IdleSpectator/HarnessCommand.cs @@ -17,4 +17,6 @@ internal class HarnessCommand public string expect = ""; public int count = 1; public float wait; + /// Transient retry counter (not written by the shell runner). + public int retries; } diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 0facc44..e22c6b5 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -15,6 +15,9 @@ internal static class HarnessScenarios return Smoke(); case "tip_match": return TipMatch(); + case "critical": + case "critical_smoke": + return CriticalSmoke(); default: return null; } @@ -27,12 +30,14 @@ internal static class HarnessScenarios Step("s0", "dismiss_windows"), Step("s1", "wait_world"), Step("s2", "reset_counters"), - Step("s3", "spectator", value: "off"), - Step("s4", "assert", expect: "idle", value: "false"), - Step("s5", "spectator", value: "on"), - Step("s6", "wait", wait: 0.75f), - Step("s7", "assert", expect: "health"), - Step("s8", "snapshot"), + Step("s3", "pick_unit", asset: "auto"), + Step("s4", "spectator", value: "off"), + Step("s5", "assert", expect: "idle", value: "false"), + Step("s6", "spectator", value: "on"), + Step("s7", "focus", asset: "auto"), + 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("t1", "wait_world"), Step("t2", "reset_counters"), - Step("t3", "spectator", value: "on"), - Step("t4", "spawn", asset: "crab", count: 2), - Step("t5", "wait", wait: 0.5f), - Step("t6", "watch", asset: "crab", label: "New species: crab", tier: "Curiosity"), - Step("t7", "wait", wait: 0.35f), - Step("t8", "assert", expect: "tip_matches_unit"), - Step("t9", "assert", expect: "unit_asset", asset: "crab"), - Step("t10", "assert", expect: "power_bar", value: "false"), - Step("t11", "snapshot"), + Step("t3", "pick_unit", asset: "auto"), + Step("t4", "spectator", value: "on"), + Step("t5", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"), + Step("t6", "wait", wait: 0.3f), + Step("t7", "assert", expect: "tip_matches_unit"), + Step("t8", "assert", expect: "unit_asset", asset: "auto"), + Step("t9", "assert", expect: "power_bar", value: "false"), + Step("t10", "snapshot"), + }; + } + + /// + /// Critical E2E against already-living map units (no fragile miracle spawns). + /// + private static List CriticalSmoke() + { + return new List + { + 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"), }; } diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 4597731..34fea56 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -53,13 +53,17 @@ public static class InterestDirector 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. - if (!AgentHarness.Busy - && Time.unscaledTime - _enabledAt >= InputExitGraceSeconds + if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds && PlayerTookManualControl()) { - SpectatorMode.SetActive(false); - WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false); + ExitFromManualInput(); return; } @@ -139,6 +143,36 @@ public static class InterestDirector sinceSwitch); } + /// + /// Harness: pulse camera-drag input and take the real manual-exit path. + /// + 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() { // Rising-edge style checks only - avoid sticky flags like already_used_power diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index 19866cf..5a8e132 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -62,7 +62,10 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable AgentHarness.Update(); SpectatorMode.PollInput(); InterestDirector.Update(); - SpeciesDiscovery.Update(); + if (!AgentHarness.Busy) + { + SpeciesDiscovery.Update(); + } StateProbe.Update(); AutoTest.Update(); } diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index e84a4db..07dc872 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -406,6 +406,16 @@ public static class WorldActivityScanner /// 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; float bestDist = maxDist * maxDist; foreach (Actor actor in EnumerateAliveUnits()) @@ -429,6 +439,49 @@ public static class WorldActivityScanner return best; } + /// + /// Any living unit of this asset id (asset.units first, then world scan). + /// + 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) { if (asset?.units != null) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index c9849fe..1737f9b 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.4.1", - "description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species. Includes .harness agent playtest bus.", + "version": "0.5.0", + "description": "AFK Idle Spectator (I) with agent harness. Critical E2E: settings gate, focus health, tip match, retarget, manual exit.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index f03a0a0..7759d9a 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -1,9 +1,15 @@ #!/usr/bin/env bash # Run an IdleSpectator agent harness scenario (or raw JSONL commands). +# Starts WorldBox via Steam when it is not already running. +# # 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 smoke # ./scripts/harness-run.sh --cmd '{"id":"1","action":"snapshot"}' +# ./scripts/harness-run.sh --no-launch smoke set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -11,8 +17,10 @@ MOD="${IDLE_SPECTATOR_MOD:-$ROOT/IdleSpectator}" HARNESS="$MOD/.harness" LOG="${WORLDBOX_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}" TIMEOUT_SEC="${HARNESS_TIMEOUT:-90}" - -mkdir -p "$HARNESS" +STEAM_APP_ID="${WORLDBOX_APP_ID:-1206560}" +BOOT_TIMEOUT_SEC="${HARNESS_BOOT_TIMEOUT:-180}" +DO_LAUNCH=1 +REPEAT=1 scenario="" raw_cmd="" @@ -26,6 +34,14 @@ while [[ $# -gt 0 ]]; do TIMEOUT_SEC="$2" shift 2 ;; + --repeat) + REPEAT="$2" + shift 2 + ;; + --no-launch) + DO_LAUNCH=0 + shift + ;; -*) echo "Unknown flag: $1" >&2 exit 2 @@ -38,53 +54,142 @@ while [[ $# -gt 0 ]]; do done if [[ -z "$scenario" && -z "$raw_cmd" ]]; then - scenario="smoke" + scenario="critical_smoke" fi -rm -f "$HARNESS/last-result.json" -: > "$HARNESS/results.jsonl" -# While this file exists, harness auto-closes Welcome Back / blocking UI. -touch "$HARNESS/auto-dismiss" - -{ - 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 +worldbox_running() { + local pid cmd + for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do + 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 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 - sleep 0.5 done -echo "Timed out after ${TIMEOUT_SEC}s" >&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 -exit 1 +echo "Summary: $pass_count / $REPEAT PASS" +exit 0