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"); }