Working smoke test
This commit is contained in:
parent
331a1d8583
commit
08d781a67b
8 changed files with 681 additions and 112 deletions
|
|
@ -24,6 +24,12 @@ public static class AgentHarness
|
|||
private static int _cmdFail;
|
||||
private static string _lastBatchId = "";
|
||||
private static readonly Queue<HarnessCommand> Queue = new Queue<HarnessCommand>();
|
||||
private static Actor _lastSpawned;
|
||||
private static string _lastSpawnedAssetId = "";
|
||||
private static readonly string[] PreferredSpawnAssets =
|
||||
{
|
||||
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
|
||||
};
|
||||
|
||||
[Serializable]
|
||||
private class LastResult
|
||||
|
|
@ -48,6 +54,7 @@ public static class AgentHarness
|
|||
public static void Update()
|
||||
{
|
||||
EnsureDirs();
|
||||
CheckExternalReset();
|
||||
IngestNewCommands();
|
||||
|
||||
// Welcome Back / other ScrollWindows block world start until closed.
|
||||
|
|
@ -98,9 +105,9 @@ public static class AgentHarness
|
|||
{
|
||||
case "wait_world":
|
||||
TryDismissBlockingWindows();
|
||||
if (!WorldReady() || IsBlockingWindowOpen())
|
||||
if (!WorldReady() || IsBlockingWindowOpen() || !HasAnyAliveUnit())
|
||||
{
|
||||
Queue.Enqueue(cmd);
|
||||
RequeueFront(cmd);
|
||||
_waitUntil = Time.unscaledTime + 0.35f;
|
||||
if (!_readyLogged)
|
||||
{
|
||||
|
|
@ -136,8 +143,21 @@ public static class AgentHarness
|
|||
case "spectator":
|
||||
{
|
||||
bool on = ParseBool(cmd.value, defaultValue: true);
|
||||
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
||||
SpectatorMode.SetActive(on);
|
||||
bool ok = SpectatorMode.Active == on;
|
||||
bool ok;
|
||||
string detail;
|
||||
if (expect == "blocked")
|
||||
{
|
||||
ok = on && !SpectatorMode.Active;
|
||||
detail = $"blocked idle={SpectatorMode.Active} enabled={ModSettings.Enabled}";
|
||||
}
|
||||
else
|
||||
{
|
||||
ok = SpectatorMode.Active == on;
|
||||
detail = $"idle={SpectatorMode.Active}";
|
||||
}
|
||||
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
|
|
@ -147,7 +167,23 @@ public static class AgentHarness
|
|||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"idle={SpectatorMode.Active}");
|
||||
Emit(cmd, ok, detail);
|
||||
break;
|
||||
}
|
||||
|
||||
case "simulate_input":
|
||||
{
|
||||
bool exited = InterestDirector.SimulateManualInputExit();
|
||||
if (exited)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, exited, detail: $"idle={SpectatorMode.Active}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +191,10 @@ public static class AgentHarness
|
|||
DoSpawn(cmd);
|
||||
break;
|
||||
|
||||
case "pick_unit":
|
||||
DoPickUnit(cmd);
|
||||
break;
|
||||
|
||||
case "focus":
|
||||
DoFocus(cmd);
|
||||
break;
|
||||
|
|
@ -245,21 +285,74 @@ public static class AgentHarness
|
|||
LogService.LogInfo($"[IdleSpectator][HARNESS] scenario={name} steps={steps.Count}");
|
||||
}
|
||||
|
||||
private static void DoPickUnit(HarnessCommand cmd)
|
||||
{
|
||||
if (!WorldReady())
|
||||
{
|
||||
if (RetryFront(cmd, "world_not_ready"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "world_not_ready");
|
||||
return;
|
||||
}
|
||||
|
||||
string assetId = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset;
|
||||
Actor unit = ResolveUnit(assetId);
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
// Ignore stale harness spawn handle; scan the live world.
|
||||
_lastSpawned = null;
|
||||
unit = string.IsNullOrEmpty(assetId)
|
||||
? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f)
|
||||
: WorldActivityScanner.FindAliveByAssetId(assetId, CameraPos(), 5000f);
|
||||
}
|
||||
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
if (RetryFront(cmd, "no_unit"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no living unit in world");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSpawned = unit;
|
||||
_lastSpawnedAssetId = unit.asset != null ? unit.asset.id : "";
|
||||
CameraDirector.FocusUnit(unit);
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"picked={SafeName(unit)} asset={_lastSpawnedAssetId}");
|
||||
}
|
||||
|
||||
private static void DoSpawn(HarnessCommand cmd)
|
||||
{
|
||||
if (!WorldReady())
|
||||
{
|
||||
if (RetryFront(cmd, "world_not_ready"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "world_not_ready");
|
||||
return;
|
||||
}
|
||||
|
||||
string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
if (string.IsNullOrEmpty(assetId) || assetId == "auto")
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "missing asset");
|
||||
return;
|
||||
assetId = PickSpawnAssetId();
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no spawnable asset");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (AssetManager.actor_library?.get(assetId) == null)
|
||||
|
|
@ -270,8 +363,26 @@ public static class AgentHarness
|
|||
}
|
||||
|
||||
WorldTile tile = TileNearCamera();
|
||||
// Prefer spawning next to an existing living unit so terrain is valid.
|
||||
Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
|
||||
if (anchor != null)
|
||||
{
|
||||
WorldTile anchorTile = World.world.GetTile(
|
||||
Mathf.RoundToInt(anchor.current_position.x),
|
||||
Mathf.RoundToInt(anchor.current_position.y));
|
||||
if (anchorTile != null)
|
||||
{
|
||||
tile = anchorTile;
|
||||
}
|
||||
}
|
||||
|
||||
if (tile == null)
|
||||
{
|
||||
if (RetryFront(cmd, "no_tile"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no tile near camera");
|
||||
return;
|
||||
|
|
@ -283,54 +394,61 @@ public static class AgentHarness
|
|||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
|
||||
if (actor != null)
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
spawned++;
|
||||
last = actor;
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = spawned > 0;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
if (spawned <= 0 || last == null || !last.isAlive())
|
||||
{
|
||||
if (RetryFront(cmd, "spawn_zero"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: $"spawned=0/{count} asset={assetId}");
|
||||
return;
|
||||
}
|
||||
|
||||
string who = last != null ? SafeName(last) : "-";
|
||||
Emit(cmd, ok, detail: $"spawned={spawned}/{count} asset={assetId} last={who}");
|
||||
_lastSpawned = last;
|
||||
_lastSpawnedAssetId = last.asset != null ? last.asset.id : assetId;
|
||||
CameraDirector.FocusUnit(last);
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"spawned={spawned}/{count} asset={_lastSpawnedAssetId} last={SafeName(last)}");
|
||||
}
|
||||
|
||||
private static void DoFocus(HarnessCommand cmd)
|
||||
{
|
||||
if (!WorldReady())
|
||||
{
|
||||
if (RetryFront(cmd, "world_not_ready"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "world_not_ready");
|
||||
return;
|
||||
}
|
||||
|
||||
Actor unit = null;
|
||||
string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
||||
if (!string.IsNullOrEmpty(assetId) && assetId != "nearest")
|
||||
if (assetId == "nearest" || assetId == "auto")
|
||||
{
|
||||
Vector3 pos = CameraPos();
|
||||
unit = WorldActivityScanner.FindNearestAliveUnit(pos, 200f, assetId);
|
||||
}
|
||||
else if (MoveCamera.hasFocusUnit())
|
||||
{
|
||||
unit = MoveCamera._focus_unit;
|
||||
}
|
||||
else
|
||||
{
|
||||
unit = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
||||
assetId = assetId == "auto" ? _lastSpawnedAssetId : null;
|
||||
}
|
||||
|
||||
Actor unit = ResolveUnit(assetId);
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
if (RetryFront(cmd, "no_unit"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no unit to focus");
|
||||
return;
|
||||
|
|
@ -345,34 +463,44 @@ public static class AgentHarness
|
|||
{
|
||||
if (!WorldReady())
|
||||
{
|
||||
if (RetryFront(cmd, "world_not_ready"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "world_not_ready");
|
||||
return;
|
||||
}
|
||||
|
||||
string assetId = cmd.asset;
|
||||
Actor follow = null;
|
||||
if (!string.IsNullOrEmpty(assetId))
|
||||
if (assetId == "auto")
|
||||
{
|
||||
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId);
|
||||
assetId = _lastSpawnedAssetId;
|
||||
}
|
||||
|
||||
Actor follow = ResolveUnit(string.IsNullOrEmpty(assetId) ? null : assetId);
|
||||
if (follow == null)
|
||||
{
|
||||
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
||||
}
|
||||
if (RetryFront(cmd, string.IsNullOrEmpty(assetId) ? "no_unit" : "no_asset_unit"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (follow == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no unit for watch");
|
||||
Emit(cmd, ok: false, detail: string.IsNullOrEmpty(assetId) ? "no unit for watch" : "no unit for asset: " + assetId);
|
||||
return;
|
||||
}
|
||||
|
||||
InterestTier tier = ParseTier(cmd.tier, InterestTier.Curiosity);
|
||||
string resolvedAsset = follow.asset != null ? follow.asset.id : assetId;
|
||||
string label = string.IsNullOrEmpty(cmd.label)
|
||||
? $"Harness: {(follow.asset != null ? follow.asset.id : SafeName(follow))}"
|
||||
: cmd.label;
|
||||
? $"Harness: {resolvedAsset}"
|
||||
: cmd.label.Replace("{asset}", resolvedAsset ?? "");
|
||||
if (label.Contains("{asset}"))
|
||||
{
|
||||
label = label.Replace("{asset}", resolvedAsset ?? "");
|
||||
}
|
||||
|
||||
InterestEvent interest = new InterestEvent
|
||||
{
|
||||
|
|
@ -522,16 +650,32 @@ public static class AgentHarness
|
|||
string tipAsset = CameraDirector.LastWatchAssetId ?? "";
|
||||
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
string unitAsset = unit?.asset != null ? unit.asset.id : "";
|
||||
pass = !string.IsNullOrEmpty(tipAsset)
|
||||
&& tipAsset == unitAsset
|
||||
&& MoveCamera.hasFocusUnit();
|
||||
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||
bool assetsMatch = !string.IsNullOrEmpty(tipAsset)
|
||||
&& tipAsset == unitAsset
|
||||
&& MoveCamera.hasFocusUnit();
|
||||
// If the tip claims a species, the focused unit must be that species.
|
||||
bool labelOk = true;
|
||||
const string prefix = "New species: ";
|
||||
if (tip.StartsWith(prefix))
|
||||
{
|
||||
string claimed = tip.Substring(prefix.Length).Trim();
|
||||
labelOk = !string.IsNullOrEmpty(claimed) && claimed == unitAsset;
|
||||
}
|
||||
|
||||
pass = assetsMatch && labelOk;
|
||||
detail =
|
||||
$"tipAsset={tipAsset} unitAsset={unitAsset} tip={CameraDirector.LastWatchLabel}";
|
||||
$"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}";
|
||||
break;
|
||||
}
|
||||
case "unit_asset":
|
||||
{
|
||||
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
||||
if (wantAsset == "auto" || string.IsNullOrEmpty(wantAsset))
|
||||
{
|
||||
wantAsset = _lastSpawnedAssetId;
|
||||
}
|
||||
|
||||
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
string unitAsset = unit?.asset != null ? unit.asset.id : "";
|
||||
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
|
||||
|
|
@ -544,6 +688,29 @@ public static class AgentHarness
|
|||
detail = $"bad={StateProbe.BadEventCount}";
|
||||
break;
|
||||
}
|
||||
case "tip_contains":
|
||||
{
|
||||
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
||||
if (needle == "auto" || needle.Contains("{asset}"))
|
||||
{
|
||||
needle = (needle == "auto" ? "New species: {asset}" : needle)
|
||||
.Replace("{asset}", _lastSpawnedAssetId ?? "");
|
||||
}
|
||||
|
||||
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||
pass = !string.IsNullOrEmpty(needle)
|
||||
&& tip.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
detail = $"tip='{tip}' contains='{needle}'";
|
||||
break;
|
||||
}
|
||||
case "enabled":
|
||||
case "setting_enabled":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
pass = ModSettings.Enabled == want;
|
||||
detail = $"enabled={ModSettings.Enabled} want={want}";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
pass = false;
|
||||
detail = "unknown expect: " + expect;
|
||||
|
|
@ -705,6 +872,11 @@ public static class AgentHarness
|
|||
return Config.game_loaded && World.world != null && !SmoothLoader.isLoading();
|
||||
}
|
||||
|
||||
private static bool HasAnyAliveUnit()
|
||||
{
|
||||
return WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f) != null;
|
||||
}
|
||||
|
||||
private static bool ShouldAutoDismissUi()
|
||||
{
|
||||
if (Queue.Count > 0 || _waitUntil > 0f)
|
||||
|
|
@ -915,6 +1087,143 @@ public static class AgentHarness
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Script drops <c>.harness/reset</c> (and/or writes offset=0) before a new command batch.
|
||||
/// Without this, a same-length rewrite of commands.jsonl is skipped forever.
|
||||
/// </summary>
|
||||
private static void CheckExternalReset()
|
||||
{
|
||||
string dir = HarnessDir();
|
||||
if (string.IsNullOrEmpty(dir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool resetRequested = false;
|
||||
string resetPath = Path.Combine(dir, "reset");
|
||||
if (File.Exists(resetPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(resetPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Still reset in memory.
|
||||
}
|
||||
|
||||
resetRequested = true;
|
||||
}
|
||||
|
||||
// Also honor an on-disk offset that was rewound by the runner.
|
||||
if (!resetRequested && File.Exists(OffsetPath()))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (long.TryParse(File.ReadAllText(OffsetPath()).Trim(), out long diskOffset)
|
||||
&& diskOffset < _offset)
|
||||
{
|
||||
resetRequested = true;
|
||||
_offset = diskOffset;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!resetRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Queue.Clear();
|
||||
_waitUntil = -1f;
|
||||
_readyLogged = false;
|
||||
_offset = 0;
|
||||
_lastSpawned = null;
|
||||
_lastSpawnedAssetId = "";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(OffsetPath(), "0");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
LogService.LogInfo("[IdleSpectator][HARNESS] inbox reset - re-reading commands.jsonl");
|
||||
}
|
||||
|
||||
private static void RequeueFront(HarnessCommand cmd)
|
||||
{
|
||||
List<HarnessCommand> rest = new List<HarnessCommand>(Queue);
|
||||
Queue.Clear();
|
||||
Queue.Enqueue(cmd);
|
||||
foreach (HarnessCommand step in rest)
|
||||
{
|
||||
Queue.Enqueue(step);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PickSpawnAssetId()
|
||||
{
|
||||
foreach (string id in PreferredSpawnAssets)
|
||||
{
|
||||
if (AssetManager.actor_library?.get(id) != null)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Actor ResolveUnit(string assetId)
|
||||
{
|
||||
if (_lastSpawned != null && _lastSpawned.isAlive())
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId)
|
||||
|| (_lastSpawned.asset != null && _lastSpawned.asset.id == assetId))
|
||||
{
|
||||
return _lastSpawned;
|
||||
}
|
||||
}
|
||||
|
||||
Vector3 pos = CameraPos();
|
||||
if (!string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return WorldActivityScanner.FindAliveByAssetId(assetId, pos, 5000f);
|
||||
}
|
||||
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null && MoveCamera._focus_unit.isAlive())
|
||||
{
|
||||
return MoveCamera._focus_unit;
|
||||
}
|
||||
|
||||
return WorldActivityScanner.FindNearestAliveUnit(pos, 5000f);
|
||||
}
|
||||
|
||||
private static bool RetryFront(HarnessCommand cmd, string reason, int maxRetries = 20)
|
||||
{
|
||||
cmd.retries++;
|
||||
if (cmd.retries > maxRetries)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RequeueFront(cmd);
|
||||
_waitUntil = Time.unscaledTime + 0.35f;
|
||||
if (cmd.retries == 1 || cmd.retries % 10 == 0)
|
||||
{
|
||||
LogService.LogInfo(
|
||||
$"[IdleSpectator][HARNESS] retry id={cmd.id} action={cmd.action} reason={reason} attempt={cmd.retries}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string HarnessDir()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ModClass.ModFolder))
|
||||
|
|
|
|||
|
|
@ -17,4 +17,6 @@ internal class HarnessCommand
|
|||
public string expect = "";
|
||||
public int count = 1;
|
||||
public float wait;
|
||||
/// <summary>Transient retry counter (not written by the shell runner).</summary>
|
||||
public int retries;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ internal static class HarnessScenarios
|
|||
return Smoke();
|
||||
case "tip_match":
|
||||
return TipMatch();
|
||||
case "critical":
|
||||
case "critical_smoke":
|
||||
return CriticalSmoke();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -27,12 +30,14 @@ internal static class HarnessScenarios
|
|||
Step("s0", "dismiss_windows"),
|
||||
Step("s1", "wait_world"),
|
||||
Step("s2", "reset_counters"),
|
||||
Step("s3", "spectator", value: "off"),
|
||||
Step("s4", "assert", expect: "idle", value: "false"),
|
||||
Step("s5", "spectator", value: "on"),
|
||||
Step("s6", "wait", wait: 0.75f),
|
||||
Step("s7", "assert", expect: "health"),
|
||||
Step("s8", "snapshot"),
|
||||
Step("s3", "pick_unit", asset: "auto"),
|
||||
Step("s4", "spectator", value: "off"),
|
||||
Step("s5", "assert", expect: "idle", value: "false"),
|
||||
Step("s6", "spectator", value: "on"),
|
||||
Step("s7", "focus", asset: "auto"),
|
||||
Step("s8", "wait", wait: 0.35f),
|
||||
Step("s9", "assert", expect: "health"),
|
||||
Step("s10", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -43,15 +48,73 @@ internal static class HarnessScenarios
|
|||
Step("t0", "dismiss_windows"),
|
||||
Step("t1", "wait_world"),
|
||||
Step("t2", "reset_counters"),
|
||||
Step("t3", "spectator", value: "on"),
|
||||
Step("t4", "spawn", asset: "crab", count: 2),
|
||||
Step("t5", "wait", wait: 0.5f),
|
||||
Step("t6", "watch", asset: "crab", label: "New species: crab", tier: "Curiosity"),
|
||||
Step("t7", "wait", wait: 0.35f),
|
||||
Step("t8", "assert", expect: "tip_matches_unit"),
|
||||
Step("t9", "assert", expect: "unit_asset", asset: "crab"),
|
||||
Step("t10", "assert", expect: "power_bar", value: "false"),
|
||||
Step("t11", "snapshot"),
|
||||
Step("t3", "pick_unit", asset: "auto"),
|
||||
Step("t4", "spectator", value: "on"),
|
||||
Step("t5", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"),
|
||||
Step("t6", "wait", wait: 0.3f),
|
||||
Step("t7", "assert", expect: "tip_matches_unit"),
|
||||
Step("t8", "assert", expect: "unit_asset", asset: "auto"),
|
||||
Step("t9", "assert", expect: "power_bar", value: "false"),
|
||||
Step("t10", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Critical E2E against already-living map units (no fragile miracle spawns).
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> CriticalSmoke()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("c00", "dismiss_windows"),
|
||||
Step("c01", "wait_world"),
|
||||
Step("c02", "reset_counters"),
|
||||
|
||||
// Lock onto a living map unit for the rest of the scenario.
|
||||
Step("c05", "pick_unit", asset: "auto"),
|
||||
Step("c06", "wait", wait: 0.25f),
|
||||
|
||||
Step("c10", "set_setting", expect: "enabled", value: "false"),
|
||||
Step("c11", "assert", expect: "enabled", value: "false"),
|
||||
Step("c12", "spectator", value: "on", expect: "blocked"),
|
||||
Step("c13", "assert", expect: "idle", value: "false"),
|
||||
Step("c14", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("c15", "assert", expect: "enabled", value: "true"),
|
||||
|
||||
Step("c20", "spectator", value: "off"),
|
||||
Step("c21", "assert", expect: "idle", value: "false"),
|
||||
Step("c22", "spectator", value: "on"),
|
||||
Step("c23", "focus", asset: "auto"),
|
||||
Step("c24", "reset_counters"),
|
||||
Step("c25", "wait", wait: 0.35f),
|
||||
Step("c26", "assert", expect: "health"),
|
||||
Step("c27", "assert", expect: "power_bar", value: "false"),
|
||||
Step("c28", "assert", expect: "no_bad"),
|
||||
|
||||
Step("c30", "watch", asset: "auto", label: "New species: {asset}", tier: "Curiosity"),
|
||||
Step("c31", "wait", wait: 0.3f),
|
||||
Step("c32", "assert", expect: "tip_matches_unit"),
|
||||
Step("c33", "assert", expect: "unit_asset", asset: "auto"),
|
||||
Step("c34", "assert", expect: "tip_contains", value: "auto"),
|
||||
Step("c35", "assert", expect: "power_bar", value: "false"),
|
||||
Step("c36", "assert", expect: "no_bad"),
|
||||
|
||||
Step("c40", "watch", asset: "auto", label: "Retarget A: {asset}", tier: "Curiosity"),
|
||||
Step("c41", "wait", wait: 0.25f),
|
||||
Step("c42", "focus", asset: "auto"),
|
||||
Step("c43", "wait", wait: 0.25f),
|
||||
Step("c44", "watch", asset: "auto", label: "Retarget B: {asset}", tier: "Curiosity"),
|
||||
Step("c45", "wait", wait: 0.35f),
|
||||
Step("c46", "assert", expect: "health"),
|
||||
Step("c47", "assert", expect: "no_bad"),
|
||||
Step("c48", "assert", expect: "power_bar", value: "false"),
|
||||
|
||||
Step("c50", "simulate_input"),
|
||||
Step("c51", "wait", wait: 0.25f),
|
||||
Step("c52", "assert", expect: "idle", value: "false"),
|
||||
Step("c53", "assert", expect: "no_bad"),
|
||||
|
||||
Step("c99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,13 +53,17 @@ public static class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
// During harness batches, do not ambient-steal focus. The harness picks units explicitly.
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Harness drives the camera/assert loop; ignore manual-input exit while commands run.
|
||||
if (!AgentHarness.Busy
|
||||
&& Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
|
||||
if (Time.unscaledTime - _enabledAt >= InputExitGraceSeconds
|
||||
&& PlayerTookManualControl())
|
||||
{
|
||||
SpectatorMode.SetActive(false);
|
||||
WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
|
||||
ExitFromManualInput();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +143,36 @@ public static class InterestDirector
|
|||
sinceSwitch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: pulse camera-drag input and take the real manual-exit path.
|
||||
/// </summary>
|
||||
public static bool SimulateManualInputExit()
|
||||
{
|
||||
if (!SpectatorMode.Active)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
MoveCamera.camera_drag_run = true;
|
||||
bool detected = PlayerTookManualControl();
|
||||
if (!detected)
|
||||
{
|
||||
MoveCamera.camera_drag_run = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
ExitFromManualInput();
|
||||
MoveCamera.camera_drag_run = false;
|
||||
return !SpectatorMode.Active;
|
||||
}
|
||||
|
||||
private static void ExitFromManualInput()
|
||||
{
|
||||
SpectatorMode.SetActive(false);
|
||||
WorldTip.showNowTop("Idle Spectator paused (manual input)", pTranslate: false);
|
||||
LogService.LogInfo("[IdleSpectator] Spectator paused (manual input)");
|
||||
}
|
||||
|
||||
private static bool PlayerTookManualControl()
|
||||
{
|
||||
// Rising-edge style checks only - avoid sticky flags like already_used_power
|
||||
|
|
|
|||
|
|
@ -62,7 +62,10 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
|||
AgentHarness.Update();
|
||||
SpectatorMode.PollInput();
|
||||
InterestDirector.Update();
|
||||
SpeciesDiscovery.Update();
|
||||
if (!AgentHarness.Busy)
|
||||
{
|
||||
SpeciesDiscovery.Update();
|
||||
}
|
||||
StateProbe.Update();
|
||||
AutoTest.Update();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -406,6 +406,16 @@ public static class WorldActivityScanner
|
|||
/// </summary>
|
||||
public static Actor FindNearestAliveUnit(Vector3 pos, float maxDist, string requireAssetId = null)
|
||||
{
|
||||
// Freshly spawned units are often on asset.units before units_only_alive updates.
|
||||
if (!string.IsNullOrEmpty(requireAssetId))
|
||||
{
|
||||
Actor fromAsset = FindAliveOnAssetList(requireAssetId, pos, maxDist);
|
||||
if (fromAsset != null)
|
||||
{
|
||||
return fromAsset;
|
||||
}
|
||||
}
|
||||
|
||||
Actor best = null;
|
||||
float bestDist = maxDist * maxDist;
|
||||
foreach (Actor actor in EnumerateAliveUnits())
|
||||
|
|
@ -429,6 +439,49 @@ public static class WorldActivityScanner
|
|||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Any living unit of this asset id (asset.units first, then world scan).
|
||||
/// </summary>
|
||||
public static Actor FindAliveByAssetId(string assetId, Vector3 nearPos, float maxDist = 5000f)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindNearestAliveUnit(nearPos, maxDist, assetId);
|
||||
}
|
||||
|
||||
private static Actor FindAliveOnAssetList(string assetId, Vector3 nearPos, float maxDist)
|
||||
{
|
||||
ActorAsset asset = AssetManager.actor_library?.get(assetId);
|
||||
if (asset?.units == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor best = null;
|
||||
float bestDist = maxDist * maxDist;
|
||||
foreach (Actor unit in asset.units)
|
||||
{
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float dx = unit.current_position.x - nearPos.x;
|
||||
float dy = unit.current_position.y - nearPos.y;
|
||||
float d2 = dx * dx + dy * dy;
|
||||
if (d2 < bestDist)
|
||||
{
|
||||
bestDist = d2;
|
||||
best = unit;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Actor FindMatchingSpeciesUnit(ActorAsset asset, Subspecies subspecies, Vector3 nearPos, float maxDist)
|
||||
{
|
||||
if (asset?.units != null)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.4.1",
|
||||
"description": "AFK Idle Spectator (I): follows wars, disasters, battles, creatures, and new species. Includes .harness agent playtest bus.",
|
||||
"version": "0.5.0",
|
||||
"description": "AFK Idle Spectator (I) with agent harness. Critical E2E: settings gate, focus health, tip match, retarget, manual exit.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run an IdleSpectator agent harness scenario (or raw JSONL commands).
|
||||
# Starts WorldBox via Steam when it is not already running.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/harness-run.sh smoke
|
||||
# ./scripts/harness-run.sh # default: critical_smoke
|
||||
# ./scripts/harness-run.sh critical_smoke
|
||||
# ./scripts/harness-run.sh --repeat 3 # run scenario 3 times (all must PASS)
|
||||
# ./scripts/harness-run.sh tip_match
|
||||
# ./scripts/harness-run.sh smoke
|
||||
# ./scripts/harness-run.sh --cmd '{"id":"1","action":"snapshot"}'
|
||||
# ./scripts/harness-run.sh --no-launch smoke
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
|
@ -11,8 +17,10 @@ MOD="${IDLE_SPECTATOR_MOD:-$ROOT/IdleSpectator}"
|
|||
HARNESS="$MOD/.harness"
|
||||
LOG="${WORLDBOX_LOG:-$HOME/.config/unity3d/mkarpenko/WorldBox/Player.log}"
|
||||
TIMEOUT_SEC="${HARNESS_TIMEOUT:-90}"
|
||||
|
||||
mkdir -p "$HARNESS"
|
||||
STEAM_APP_ID="${WORLDBOX_APP_ID:-1206560}"
|
||||
BOOT_TIMEOUT_SEC="${HARNESS_BOOT_TIMEOUT:-180}"
|
||||
DO_LAUNCH=1
|
||||
REPEAT=1
|
||||
|
||||
scenario=""
|
||||
raw_cmd=""
|
||||
|
|
@ -26,6 +34,14 @@ while [[ $# -gt 0 ]]; do
|
|||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
;;
|
||||
--repeat)
|
||||
REPEAT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-launch)
|
||||
DO_LAUNCH=0
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown flag: $1" >&2
|
||||
exit 2
|
||||
|
|
@ -38,53 +54,142 @@ while [[ $# -gt 0 ]]; do
|
|||
done
|
||||
|
||||
if [[ -z "$scenario" && -z "$raw_cmd" ]]; then
|
||||
scenario="smoke"
|
||||
scenario="critical_smoke"
|
||||
fi
|
||||
|
||||
rm -f "$HARNESS/last-result.json"
|
||||
: > "$HARNESS/results.jsonl"
|
||||
# While this file exists, harness auto-closes Welcome Back / blocking UI.
|
||||
touch "$HARNESS/auto-dismiss"
|
||||
|
||||
{
|
||||
echo '{"id":"batch","action":"reset_counters"}'
|
||||
echo '{"id":"dismiss","action":"dismiss_windows"}'
|
||||
if [[ -n "$raw_cmd" ]]; then
|
||||
echo "$raw_cmd"
|
||||
else
|
||||
echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario\"}"
|
||||
fi
|
||||
} > "$HARNESS/commands.jsonl"
|
||||
|
||||
echo "Harness queued: ${scenario:-raw} (mod=$MOD)"
|
||||
echo "Waiting up to ${TIMEOUT_SEC}s for last-result.json ..."
|
||||
|
||||
deadline=$((SECONDS + TIMEOUT_SEC))
|
||||
while (( SECONDS < deadline )); do
|
||||
if [[ -f "$HARNESS/last-result.json" ]]; then
|
||||
# NOTE: do not name this `status` - zsh treats that as read-only.
|
||||
result_status="$(python3 -c "import json;print(json.load(open('$HARNESS/last-result.json')).get('status',''))" 2>/dev/null || true)"
|
||||
if [[ "$result_status" == "PASS" || "$result_status" == "FAIL" || "$result_status" == "error" ]]; then
|
||||
echo "===== last-result.json ====="
|
||||
cat "$HARNESS/last-result.json"
|
||||
echo "===== recent harness log ====="
|
||||
if [[ -f "$LOG" ]]; then
|
||||
rg -n "\[IdleSpectator\]\[(HARNESS|ASSERT|SNAP)\]|dismissed window" "$LOG" | tail -n 40 || true
|
||||
fi
|
||||
if [[ "$result_status" == "PASS" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
worldbox_running() {
|
||||
local pid cmd
|
||||
for pid in $(pgrep -a worldbox 2>/dev/null | awk '{print $1}'); do
|
||||
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)
|
||||
if [[ "$cmd" == *"/common/worldbox/worldbox"* && "$cmd" != *zsh* && "$cmd" != *snap=* && "$cmd" != *harness-run* ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
mod_ready() {
|
||||
[[ -f "$LOG" ]] || return 1
|
||||
if tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
|
||||
return 2
|
||||
fi
|
||||
tail -n 200 "$LOG" 2>/dev/null | rg -q "\[IdleSpectator\]: Harmony patches applied"
|
||||
}
|
||||
|
||||
ensure_worldbox() {
|
||||
if worldbox_running; then
|
||||
echo "WorldBox already running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if (( DO_LAUNCH == 0 )); then
|
||||
echo "WorldBox is not running (pass without --no-launch to auto-start)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Starting WorldBox (steam app $STEAM_APP_ID) ..."
|
||||
echo "===== HARNESS LAUNCH $(date -Iseconds) =====" >> "$LOG" 2>/dev/null || true
|
||||
nohup steam -applaunch "$STEAM_APP_ID" >/tmp/idle-spectator-harness-launch.log 2>&1 &
|
||||
disown || true
|
||||
|
||||
local deadline=$((SECONDS + BOOT_TIMEOUT_SEC))
|
||||
while (( SECONDS < deadline )); do
|
||||
if mod_ready; then
|
||||
echo "WorldBox + IdleSpectator ready"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$LOG" ]] && tail -n 200 "$LOG" 2>/dev/null | rg -q "error CS"; then
|
||||
echo "IdleSpectator compile failed:" >&2
|
||||
rg -n "error CS" "$LOG" | tail -n 40 >&2 || true
|
||||
return 1
|
||||
fi
|
||||
if worldbox_running; then
|
||||
printf '.'
|
||||
else
|
||||
printf 'o'
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo >&2
|
||||
echo "Timed out waiting for WorldBox/IdleSpectator boot (${BOOT_TIMEOUT_SEC}s)" >&2
|
||||
tail -n 40 "$LOG" 2>/dev/null >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
queue_and_wait() {
|
||||
local run_label="$1"
|
||||
rm -f "$HARNESS/last-result.json"
|
||||
: > "$HARNESS/results.jsonl"
|
||||
echo 0 > "$HARNESS/offset"
|
||||
: > "$HARNESS/commands.jsonl"
|
||||
touch "$HARNESS/reset"
|
||||
touch "$HARNESS/auto-dismiss"
|
||||
sleep 0.35
|
||||
|
||||
{
|
||||
echo '{"id":"batch","action":"reset_counters"}'
|
||||
echo '{"id":"dismiss","action":"dismiss_windows"}'
|
||||
if [[ -n "$raw_cmd" ]]; then
|
||||
echo "$raw_cmd"
|
||||
else
|
||||
echo "{\"id\":\"run\",\"action\":\"scenario\",\"value\":\"$scenario\"}"
|
||||
fi
|
||||
} > "$HARNESS/commands.jsonl"
|
||||
|
||||
echo "Harness queued: ${scenario:-raw} ($run_label) (mod=$MOD)"
|
||||
echo "Waiting up to ${TIMEOUT_SEC}s for last-result.json (load a world if at the menu) ..."
|
||||
|
||||
local deadline=$((SECONDS + TIMEOUT_SEC))
|
||||
while (( SECONDS < deadline )); do
|
||||
if [[ -f "$HARNESS/last-result.json" ]]; then
|
||||
# NOTE: do not name this `status` - zsh treats that as read-only.
|
||||
local result_status
|
||||
result_status="$(python3 -c "import json;print(json.load(open('$HARNESS/last-result.json')).get('status',''))" 2>/dev/null || true)"
|
||||
if [[ "$result_status" == "PASS" || "$result_status" == "FAIL" || "$result_status" == "error" ]]; then
|
||||
echo "===== last-result.json ($run_label) ====="
|
||||
cat "$HARNESS/last-result.json"
|
||||
echo "===== recent harness log ====="
|
||||
if [[ -f "$LOG" ]]; then
|
||||
rg -n "\[IdleSpectator\]\[(HARNESS|ASSERT|SNAP)\]|dismissed window|retry id=" "$LOG" | tail -n 50 || true
|
||||
fi
|
||||
if [[ "$result_status" == "PASS" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "Timed out after ${TIMEOUT_SEC}s ($run_label)" >&2
|
||||
if [[ -f "$HARNESS/last-result.json" ]]; then
|
||||
cat "$HARNESS/last-result.json" >&2 || true
|
||||
fi
|
||||
if [[ -f "$LOG" ]]; then
|
||||
rg -n "IdleSpectator|error CS|Exception" "$LOG" | tail -n 50 >&2 || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
mkdir -p "$HARNESS"
|
||||
ensure_worldbox
|
||||
|
||||
if (( TIMEOUT_SEC < 120 )); then
|
||||
TIMEOUT_SEC=120
|
||||
fi
|
||||
|
||||
pass_count=0
|
||||
for ((i=1; i<=REPEAT; i++)); do
|
||||
echo "======== RUN $i / $REPEAT ========"
|
||||
if queue_and_wait "run $i/$REPEAT"; then
|
||||
pass_count=$((pass_count + 1))
|
||||
echo "RUN $i PASS"
|
||||
else
|
||||
echo "RUN $i FAIL" >&2
|
||||
echo "Summary: $pass_count / $REPEAT passed before failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "Timed out after ${TIMEOUT_SEC}s" >&2
|
||||
if [[ -f "$HARNESS/last-result.json" ]]; then
|
||||
cat "$HARNESS/last-result.json" >&2 || true
|
||||
fi
|
||||
if [[ -f "$LOG" ]]; then
|
||||
rg -n "IdleSpectator|error CS|Exception" "$LOG" | tail -n 50 >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
echo "Summary: $pass_count / $REPEAT PASS"
|
||||
exit 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue