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