From 331a1d85835ce41ecb1c4e4a88f5d4d6592f4b71 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 14 Jul 2026 14:37:34 -0500 Subject: [PATCH] Test Harness --- .gitignore | 1 + IdleSpectator/AgentHarness.cs | 932 ++++++++++++++++++++++++++++++ IdleSpectator/CameraDirector.cs | 13 + IdleSpectator/HarnessCommand.cs | 20 + IdleSpectator/HarnessScenarios.cs | 82 +++ IdleSpectator/InterestDirector.cs | 5 +- IdleSpectator/Main.cs | 1 + IdleSpectator/ModSettings.cs | 20 + IdleSpectator/mod.json | 4 +- scripts/harness-run.sh | 90 +++ 10 files changed, 1165 insertions(+), 3 deletions(-) create mode 100644 IdleSpectator/AgentHarness.cs create mode 100644 IdleSpectator/HarnessCommand.cs create mode 100644 IdleSpectator/HarnessScenarios.cs create mode 100755 scripts/harness-run.sh diff --git a/.gitignore b/.gitignore index 6cffc46..a55625b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ desktop.ini # local test arm files IdleSpectator/.auto-test IdleSpectator/.force-on +IdleSpectator/.harness/ diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs new file mode 100644 index 0000000..289d4c0 --- /dev/null +++ b/IdleSpectator/AgentHarness.cs @@ -0,0 +1,932 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Agent-driven in-game command bus. Drop JSON lines into .harness/commands.jsonl +/// (or a scenario via {"action":"scenario","value":"smoke"}). Results go to +/// .harness/results.jsonl and .harness/last-result.json. +/// +public static class AgentHarness +{ + private static long _offset; + private static float _waitUntil = -1f; + private static bool _readyLogged; + private static int _assertPass; + private static int _assertFail; + private static int _cmdOk; + private static int _cmdFail; + private static string _lastBatchId = ""; + private static readonly Queue Queue = new Queue(); + + [Serializable] + private class LastResult + { + public string batch_id = ""; + public string status = ""; + public int ok; + public int fail; + public int assert_pass; + public int assert_fail; + public string idle = ""; + public string focus = ""; + public string power_bar = ""; + public string unit = ""; + public string tip = ""; + public string detail = ""; + public string updated_at = ""; + } + + public static bool Busy => Queue.Count > 0 || _waitUntil > 0f; + + public static void Update() + { + EnsureDirs(); + IngestNewCommands(); + + // Welcome Back / other ScrollWindows block world start until closed. + if (ShouldAutoDismissUi()) + { + TryDismissBlockingWindows(); + } + + if (_waitUntil > 0f) + { + if (Time.unscaledTime < _waitUntil) + { + return; + } + + _waitUntil = -1f; + } + + if (Queue.Count == 0) + { + return; + } + + HarnessCommand cmd = Queue.Dequeue(); + try + { + Execute(cmd); + } + catch (Exception ex) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "exception: " + ex.Message); + WriteLastResult("error", "exception: " + ex.Message); + } + } + + private static void Execute(HarnessCommand cmd) + { + string action = (cmd.action ?? "").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "missing action"); + return; + } + + switch (action) + { + case "wait_world": + TryDismissBlockingWindows(); + if (!WorldReady() || IsBlockingWindowOpen()) + { + Queue.Enqueue(cmd); + _waitUntil = Time.unscaledTime + 0.35f; + if (!_readyLogged) + { + _readyLogged = true; + LogService.LogInfo("[IdleSpectator][HARNESS] waiting for world load (will auto-close Welcome Back)"); + } + + return; + } + + _cmdOk++; + Emit(cmd, ok: true, detail: "world_ready"); + break; + + case "dismiss_windows": + case "dismiss_ui": + { + bool closed = TryDismissBlockingWindows(forceAll: ParseBool(cmd.value, defaultValue: false)); + _cmdOk++; + Emit(cmd, ok: true, detail: closed ? "dismissed" : "none_open"); + break; + } + + case "wait": + { + float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 1f); + _waitUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds); + _cmdOk++; + Emit(cmd, ok: true, detail: $"wait={seconds:0.##}s"); + break; + } + + case "spectator": + { + bool on = ParseBool(cmd.value, defaultValue: true); + SpectatorMode.SetActive(on); + bool ok = SpectatorMode.Active == on; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"idle={SpectatorMode.Active}"); + break; + } + + case "spawn": + DoSpawn(cmd); + break; + + case "focus": + DoFocus(cmd); + break; + + case "watch": + DoWatch(cmd); + break; + + case "trigger_interest": + DoTriggerInterest(cmd); + break; + + case "set_setting": + DoSetSetting(cmd); + break; + + case "assert": + DoAssert(cmd); + break; + + case "snapshot": + DoSnapshot(cmd); + break; + + case "scenario": + ExpandScenario(cmd); + return; + + case "reset_counters": + StateProbe.ResetCounters(); + _assertPass = 0; + _assertFail = 0; + _cmdOk = 0; + _cmdFail = 0; + _cmdOk++; + Emit(cmd, ok: true, detail: "counters_reset"); + break; + + default: + _cmdFail++; + Emit(cmd, ok: false, detail: "unknown action: " + action); + break; + } + + if (Queue.Count == 0) + { + string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL"; + WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}"); + LogService.LogInfo( + $"[IdleSpectator][HARNESS] batch done status={status} ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}"); + } + } + + private static void ExpandScenario(HarnessCommand cmd) + { + string name = (cmd.value ?? "").Trim().ToLowerInvariant(); + List steps = HarnessScenarios.Build(name); + if (steps == null || steps.Count == 0) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "unknown scenario: " + name); + WriteLastResult("FAIL", "unknown scenario: " + name); + return; + } + + _lastBatchId = string.IsNullOrEmpty(cmd.id) ? name : cmd.id; + _assertPass = 0; + _assertFail = 0; + _cmdOk = 0; + _cmdFail = 0; + StateProbe.ResetCounters(); + + // Re-queue scenario steps ahead of anything else already queued. + List rest = new List(Queue); + Queue.Clear(); + foreach (HarnessCommand step in steps) + { + Queue.Enqueue(step); + } + + foreach (HarnessCommand step in rest) + { + Queue.Enqueue(step); + } + + _cmdOk++; + Emit(cmd, ok: true, detail: $"expanded={name} steps={steps.Count}"); + LogService.LogInfo($"[IdleSpectator][HARNESS] scenario={name} steps={steps.Count}"); + } + + private static void DoSpawn(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; + if (string.IsNullOrEmpty(assetId)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "missing asset"); + return; + } + + if (AssetManager.actor_library?.get(assetId) == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "unknown asset: " + assetId); + return; + } + + WorldTile tile = TileNearCamera(); + if (tile == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no tile near camera"); + return; + } + + int count = Math.Max(1, cmd.count); + int spawned = 0; + Actor last = null; + for (int i = 0; i < count; i++) + { + Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true); + if (actor != null) + { + spawned++; + last = actor; + } + } + + bool ok = spawned > 0; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + string who = last != null ? SafeName(last) : "-"; + Emit(cmd, ok, detail: $"spawned={spawned}/{count} asset={assetId} last={who}"); + } + + private static void DoFocus(HarnessCommand cmd) + { + if (!WorldReady()) + { + _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") + { + Vector3 pos = CameraPos(); + unit = WorldActivityScanner.FindNearestAliveUnit(pos, 200f, assetId); + } + else if (MoveCamera.hasFocusUnit()) + { + unit = MoveCamera._focus_unit; + } + else + { + unit = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + if (unit == null || !unit.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit to focus"); + return; + } + + CameraDirector.FocusUnit(unit); + _cmdOk++; + Emit(cmd, ok: true, detail: $"unit={SafeName(unit)} asset={(unit.asset != null ? unit.asset.id : "?")}"); + } + + private static void DoWatch(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + string assetId = cmd.asset; + Actor follow = null; + if (!string.IsNullOrEmpty(assetId)) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId); + } + + if (follow == null) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + if (follow == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit for watch"); + return; + } + + InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity); + string label = string.IsNullOrEmpty(cmd.label) + ? $"Harness: {(follow.asset != null ? follow.asset.id : SafeName(follow))}" + : cmd.label; + + InterestEvent interest = new InterestEvent + { + Tier = tier, + Score = 50f, + Position = follow.current_position, + FollowUnit = follow, + Label = label, + CreatedAt = Time.unscaledTime, + AssetId = follow.asset != null ? follow.asset.id : assetId + }; + + CameraDirector.Watch(interest); + _cmdOk++; + Emit(cmd, ok: true, detail: $"tip={label} unit={SafeName(follow)}"); + } + + private static void DoTriggerInterest(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + string assetId = cmd.asset; + Actor follow = null; + if (!string.IsNullOrEmpty(assetId)) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId); + } + + if (follow == null) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + if (follow == null) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no unit for interest"); + return; + } + + InterestEvent interest = new InterestEvent + { + Tier = ParseTier(cmd.tier, InterestTier.Curiosity), + Score = 40f, + Position = follow.current_position, + FollowUnit = follow, + Label = string.IsNullOrEmpty(cmd.label) ? "Harness interest" : cmd.label, + 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}"); + } + + private static void DoSetSetting(HarnessCommand cmd) + { + string key = cmd.expect; + if (string.IsNullOrEmpty(key)) + { + key = cmd.asset; + } + + if (string.IsNullOrEmpty(key)) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "missing setting key (expect/asset)"); + return; + } + + bool val = ParseBool(cmd.value, defaultValue: true); + bool ok = ModSettings.TrySetBool(key, val); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"{key}={val}"); + } + + private static void DoAssert(HarnessCommand cmd) + { + string expect = (cmd.expect ?? "").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(expect)) + { + expect = (cmd.value ?? "").Trim().ToLowerInvariant(); + } + + bool pass; + string detail; + + switch (expect) + { + case "idle": + case "spectator": + { + bool want = ParseBool(cmd.value, defaultValue: true); + // When expect is the whole assert name and value holds bool: + if (expect == "idle" || expect == "spectator") + { + want = ParseBool(string.IsNullOrEmpty(cmd.value) ? "true" : cmd.value, true); + } + + pass = SpectatorMode.Active == want; + detail = $"idle={SpectatorMode.Active} want={want}"; + break; + } + case "focus": + { + bool want = ParseBool(cmd.value, defaultValue: true); + bool has = MoveCamera.hasFocusUnit(); + pass = has == want; + detail = $"focus={has} want={want}"; + break; + } + case "power_bar": + case "bar": + { + bool want = ParseBool(cmd.value, defaultValue: false); + bool bar = CanvasMain.isBottomBarShowing(); + pass = bar == want; + detail = $"powerBar={bar} want={want}"; + break; + } + case "health": + { + bool idle = SpectatorMode.Active; + bool focus = MoveCamera.hasFocusUnit(); + bool bar = CanvasMain.isBottomBarShowing(); + pass = idle && focus && !bar && StateProbe.BadEventCount == 0; + detail = + $"idle={idle} focus={focus} powerBar={bar} bad={StateProbe.BadEventCount}"; + break; + } + case "tip_matches_unit": + { + 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(); + detail = + $"tipAsset={tipAsset} unitAsset={unitAsset} tip={CameraDirector.LastWatchLabel}"; + break; + } + case "unit_asset": + { + string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; + Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string unitAsset = unit?.asset != null ? unit.asset.id : ""; + pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset; + detail = $"unitAsset={unitAsset} want={wantAsset}"; + break; + } + case "no_bad": + { + pass = StateProbe.BadEventCount == 0; + detail = $"bad={StateProbe.BadEventCount}"; + break; + } + default: + pass = false; + detail = "unknown expect: " + expect; + break; + } + + if (pass) + { + _assertPass++; + _cmdOk++; + } + else + { + _assertFail++; + _cmdFail++; + } + + Emit(cmd, pass, detail); + LogService.LogInfo( + $"[IdleSpectator][ASSERT] {(pass ? "PASS" : "FAIL")} id={cmd.id} expect={expect} {detail}"); + } + + 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}"; + _cmdOk++; + Emit(cmd, ok: true, detail: snap); + LogService.LogInfo("[IdleSpectator][SNAP] " + snap); + } + + private static void IngestNewCommands() + { + string path = CommandsPath(); + if (!File.Exists(path)) + { + return; + } + + long length; + try + { + length = new FileInfo(path).Length; + } + catch + { + return; + } + + if (length < _offset) + { + _offset = 0; + } + + if (length == _offset) + { + return; + } + + try + { + using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + fs.Seek(_offset, SeekOrigin.Begin); + using (StreamReader reader = new StreamReader(fs, Encoding.UTF8)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + _offset = fs.Position; + line = line.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith("#")) + { + continue; + } + + HarnessCommand cmd = null; + try + { + cmd = JsonUtility.FromJson(line); + } + catch + { + LogService.LogInfo("[IdleSpectator][HARNESS] bad json: " + line); + continue; + } + + if (cmd == null || string.IsNullOrEmpty(cmd.action)) + { + continue; + } + + if (string.IsNullOrEmpty(cmd.id)) + { + cmd.id = Guid.NewGuid().ToString("N").Substring(0, 8); + } + + Queue.Enqueue(cmd); + } + } + } + + File.WriteAllText(OffsetPath(), _offset.ToString(CultureInfo.InvariantCulture)); + } + catch (Exception ex) + { + LogService.LogInfo("[IdleSpectator][HARNESS] ingest error: " + ex.Message); + } + } + + private static void Emit(HarnessCommand cmd, bool ok, string detail) + { + string line = + $"{{\"id\":\"{Escape(cmd.id)}\",\"action\":\"{Escape(cmd.action)}\",\"ok\":{(ok ? "true" : "false")},\"detail\":\"{Escape(detail)}\"}}"; + try + { + File.AppendAllText(ResultsPath(), line + "\n"); + } + catch + { + // ignore + } + + LogService.LogInfo( + $"[IdleSpectator][HARNESS] {(ok ? "ok" : "FAIL")} id={cmd.id} action={cmd.action} detail={detail}"); + } + + private static void WriteLastResult(string status, string detail) + { + LastResult result = new LastResult + { + batch_id = _lastBatchId, + status = status, + ok = _cmdOk, + fail = _cmdFail, + assert_pass = _assertPass, + assert_fail = _assertFail, + idle = SpectatorMode.Active.ToString(), + focus = MoveCamera.hasFocusUnit().ToString(), + power_bar = CanvasMain.isBottomBarShowing().ToString(), + unit = FocusLabel(), + tip = CameraDirector.LastWatchLabel ?? "", + detail = detail ?? "", + updated_at = DateTime.UtcNow.ToString("o") + }; + + try + { + File.WriteAllText(LastResultPath(), JsonUtility.ToJson(result, true)); + } + catch + { + // ignore + } + } + + private static bool WorldReady() + { + return Config.game_loaded && World.world != null && !SmoothLoader.isLoading(); + } + + private static bool ShouldAutoDismissUi() + { + if (Queue.Count > 0 || _waitUntil > 0f) + { + return true; + } + + string dir = HarnessDir(); + if (string.IsNullOrEmpty(dir)) + { + return false; + } + + if (File.Exists(Path.Combine(dir, "auto-dismiss"))) + { + return true; + } + + string commands = CommandsPath(); + if (!File.Exists(commands)) + { + return false; + } + + try + { + return new FileInfo(commands).Length > _offset; + } + catch + { + return false; + } + } + + /// + /// Closes Welcome Back (and optionally any ScrollWindow) so map gen / play can proceed. + /// + public static bool TryDismissBlockingWindows(bool forceAll = false) + { + if (!ScrollWindow.isWindowActive()) + { + return false; + } + + ScrollWindow current = ScrollWindow.getCurrentWindow(); + string id = current != null ? current.screen_id : ""; + bool welcome = id == "welcome" + || (current != null && current.GetComponent() != null); + + if (!welcome && !forceAll) + { + return false; + } + + ScrollWindow.hideAllEvent(pWithAnimation: false); + LogService.LogInfo($"[IdleSpectator][HARNESS] dismissed window id={id}"); + return true; + } + + private static bool IsBlockingWindowOpen() + { + if (!ScrollWindow.isWindowActive()) + { + return false; + } + + ScrollWindow current = ScrollWindow.getCurrentWindow(); + if (current == null) + { + return false; + } + + if (current.screen_id == "welcome") + { + return true; + } + + return current.GetComponent() != null; + } + + private static WorldTile TileNearCamera() + { + Vector3 pos = CameraPos(); + int x = Mathf.RoundToInt(pos.x); + int y = Mathf.RoundToInt(pos.y); + WorldTile tile = World.world.GetTile(x, y); + if (tile != null) + { + return tile; + } + + return World.world.GetTileSimple(x, y); + } + + private static Vector3 CameraPos() + { + if (MoveCamera.instance != null) + { + return MoveCamera.instance.transform.position; + } + + return Vector3.zero; + } + + private static string FocusLabel() + { + if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) + { + return "-"; + } + + return SafeName(MoveCamera._focus_unit); + } + + private static string SafeName(Actor actor) + { + try + { + string name = actor.getName(); + if (!string.IsNullOrEmpty(name)) + { + return name; + } + } + catch + { + // ignore + } + + return actor.asset != null ? actor.asset.id : "unit"; + } + + private static InterestTier ParseTier(string raw, InterestTier fallback) + { + if (string.IsNullOrEmpty(raw)) + { + return fallback; + } + + if (Enum.TryParse(raw, ignoreCase: true, out InterestTier tier)) + { + return tier; + } + + return fallback; + } + + private static bool ParseBool(string raw, bool defaultValue) + { + if (string.IsNullOrEmpty(raw)) + { + return defaultValue; + } + + raw = raw.Trim().ToLowerInvariant(); + if (raw == "1" || raw == "true" || raw == "on" || raw == "yes") + { + return true; + } + + if (raw == "0" || raw == "false" || raw == "off" || raw == "no") + { + return false; + } + + return defaultValue; + } + + private static float ParseFloat(string raw, float defaultValue) + { + if (float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float v)) + { + return v; + } + + return defaultValue; + } + + private static string Escape(string s) + { + if (string.IsNullOrEmpty(s)) + { + return ""; + } + + return s.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static void EnsureDirs() + { + string dir = HarnessDir(); + if (string.IsNullOrEmpty(dir)) + { + return; + } + + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + if (_offset == 0 && File.Exists(OffsetPath())) + { + if (long.TryParse(File.ReadAllText(OffsetPath()).Trim(), out long stored)) + { + _offset = stored; + } + } + } + + private static string HarnessDir() + { + if (string.IsNullOrEmpty(ModClass.ModFolder)) + { + return null; + } + + return Path.Combine(ModClass.ModFolder, ".harness"); + } + + private static string CommandsPath() => Path.Combine(HarnessDir(), "commands.jsonl"); + private static string ResultsPath() => Path.Combine(HarnessDir(), "results.jsonl"); + private static string LastResultPath() => Path.Combine(HarnessDir(), "last-result.json"); + private static string OffsetPath() => Path.Combine(HarnessDir(), "offset"); +} diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index 09f5898..51da7ef 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -5,6 +5,17 @@ namespace IdleSpectator; public static class CameraDirector { + public static string LastWatchLabel { get; private set; } = ""; + public static string LastWatchAssetId { get; private set; } = ""; + + public static void FocusUnit(Actor actor) + { + if (actor != null && actor.isAlive()) + { + RetargetFollow(actor); + } + } + public static void Watch(InterestEvent interest) { if (interest == null || !interest.HasValidPosition) @@ -18,6 +29,8 @@ public static class CameraDirector } string tip = FormatWatchTip(interest); + LastWatchLabel = interest.Label ?? ""; + LastWatchAssetId = interest.AssetId ?? ""; if (ModSettings.ShowWatchReasons) { WorldTip.showNowTop(tip, pTranslate: false); diff --git a/IdleSpectator/HarnessCommand.cs b/IdleSpectator/HarnessCommand.cs new file mode 100644 index 0000000..02e0814 --- /dev/null +++ b/IdleSpectator/HarnessCommand.cs @@ -0,0 +1,20 @@ +using System; + +namespace IdleSpectator; + +/// +/// One harness command line (JSON via Unity ). +/// +[Serializable] +internal class HarnessCommand +{ + public string id = ""; + public string action = ""; + public string value = ""; + public string asset = ""; + public string label = ""; + public string tier = ""; + public string expect = ""; + public int count = 1; + public float wait; +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs new file mode 100644 index 0000000..0facc44 --- /dev/null +++ b/IdleSpectator/HarnessScenarios.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; + +namespace IdleSpectator; + +/// +/// Built-in agent playtest scenarios expanded by action=scenario. +/// +internal static class HarnessScenarios +{ + public static List Build(string name) + { + switch ((name ?? "").Trim().ToLowerInvariant()) + { + case "smoke": + return Smoke(); + case "tip_match": + return TipMatch(); + default: + return null; + } + } + + private static List Smoke() + { + return new List + { + 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"), + }; + } + + private static List TipMatch() + { + return new List + { + 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"), + }; + } + + private static HarnessCommand Step( + string id, + string action, + string value = "", + string asset = "", + string label = "", + string tier = "", + string expect = "", + int count = 1, + float wait = 0f) + { + return new HarnessCommand + { + id = id, + action = action, + value = value, + asset = asset, + label = label, + tier = tier, + expect = expect, + count = count, + wait = wait + }; + } +} diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 3e8093f..4597731 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -53,7 +53,10 @@ public static class InterestDirector return; } - if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds && PlayerTookManualControl()) + // Harness drives the camera/assert loop; ignore manual-input exit while commands run. + if (!AgentHarness.Busy + && Time.unscaledTime - _enabledAt >= InputExitGraceSeconds + && PlayerTookManualControl()) { SpectatorMode.SetActive(false); WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false); diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index 031c634..19866cf 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -59,6 +59,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable private void Update() { + AgentHarness.Update(); SpectatorMode.PollInput(); InterestDirector.Update(); SpeciesDiscovery.Update(); diff --git a/IdleSpectator/ModSettings.cs b/IdleSpectator/ModSettings.cs index f9dbc75..b09ec6d 100644 --- a/IdleSpectator/ModSettings.cs +++ b/IdleSpectator/ModSettings.cs @@ -48,6 +48,26 @@ public static class ModSettings } } + public static bool TrySetBool(string id, bool value) + { + if (_config == null || string.IsNullOrEmpty(id)) + { + return false; + } + + try + { + ModConfigItem item = _config[GroupId][id]; + item.SetValue(value); + return true; + } + catch (System.Exception ex) + { + LogService.LogInfo($"[IdleSpectator] TrySetBool failed for {id}: {ex.Message}"); + return false; + } + } + private static bool GetBool(string id, bool defaultValue) { if (_config == null) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 5401efa..c9849fe 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.3.10", - "description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species from fresh worlds to empires.", + "version": "0.4.1", + "description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species. Includes .harness agent playtest bus.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh new file mode 100755 index 0000000..f03a0a0 --- /dev/null +++ b/scripts/harness-run.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Run an IdleSpectator agent harness scenario (or raw JSONL commands). +# Usage: +# ./scripts/harness-run.sh smoke +# ./scripts/harness-run.sh tip_match +# ./scripts/harness-run.sh --cmd '{"id":"1","action":"snapshot"}' +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +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" + +scenario="" +raw_cmd="" +while [[ $# -gt 0 ]]; do + case "$1" in + --cmd) + raw_cmd="$2" + shift 2 + ;; + --timeout) + TIMEOUT_SEC="$2" + shift 2 + ;; + -*) + echo "Unknown flag: $1" >&2 + exit 2 + ;; + *) + scenario="$1" + shift + ;; + esac +done + +if [[ -z "$scenario" && -z "$raw_cmd" ]]; then + scenario="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 + fi + 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