Test Harness
This commit is contained in:
parent
26081f8e6a
commit
331a1d8583
10 changed files with 1165 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -52,3 +52,4 @@ desktop.ini
|
|||
# local test arm files
|
||||
IdleSpectator/.auto-test
|
||||
IdleSpectator/.force-on
|
||||
IdleSpectator/.harness/
|
||||
|
|
|
|||
932
IdleSpectator/AgentHarness.cs
Normal file
932
IdleSpectator/AgentHarness.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Agent-driven in-game command bus. Drop JSON lines into <c>.harness/commands.jsonl</c>
|
||||
/// (or a scenario via <c>{"action":"scenario","value":"smoke"}</c>). Results go to
|
||||
/// <c>.harness/results.jsonl</c> and <c>.harness/last-result.json</c>.
|
||||
/// </summary>
|
||||
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<HarnessCommand> Queue = new Queue<HarnessCommand>();
|
||||
|
||||
[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<HarnessCommand> 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<HarnessCommand> rest = new List<HarnessCommand>(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<HarnessCommand>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes Welcome Back (and optionally any ScrollWindow) so map gen / play can proceed.
|
||||
/// </summary>
|
||||
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<WindowWelcome>() != 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<WindowWelcome>() != 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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
20
IdleSpectator/HarnessCommand.cs
Normal file
20
IdleSpectator/HarnessCommand.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using System;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// One harness command line (JSON via Unity <see cref="UnityEngine.JsonUtility"/>).
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
82
IdleSpectator/HarnessScenarios.cs
Normal file
82
IdleSpectator/HarnessScenarios.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in agent playtest scenarios expanded by <c>action=scenario</c>.
|
||||
/// </summary>
|
||||
internal static class HarnessScenarios
|
||||
{
|
||||
public static List<HarnessCommand> Build(string name)
|
||||
{
|
||||
switch ((name ?? "").Trim().ToLowerInvariant())
|
||||
{
|
||||
case "smoke":
|
||||
return Smoke();
|
||||
case "tip_match":
|
||||
return TipMatch();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> Smoke()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
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<HarnessCommand> TipMatch()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
|||
|
||||
private void Update()
|
||||
{
|
||||
AgentHarness.Update();
|
||||
SpectatorMode.PollInput();
|
||||
InterestDirector.Update();
|
||||
SpeciesDiscovery.Update();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
90
scripts/harness-run.sh
Executable file
90
scripts/harness-run.sh
Executable file
|
|
@ -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
|
||||
Loading…
Reference in a new issue