1690 lines
51 KiB
C#
1690 lines
51 KiB
C#
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>();
|
|
private static Actor _lastSpawned;
|
|
private static string _lastSpawnedAssetId = "";
|
|
private static bool _directorRunActive;
|
|
private static string _rememberedFocusKey = "";
|
|
private static readonly string[] PreferredSpawnAssets =
|
|
{
|
|
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
|
|
};
|
|
|
|
[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;
|
|
|
|
/// <summary>
|
|
/// When true, InterestDirector / SpeciesDiscovery stay frozen during harness commands.
|
|
/// <c>director_run</c> temporarily clears this so dwell/interrupt/discovery can execute.
|
|
/// </summary>
|
|
public static bool FreezeDirector => Busy && !_directorRunActive;
|
|
|
|
public static void Update()
|
|
{
|
|
EnsureDirs();
|
|
CheckExternalReset();
|
|
IngestNewCommands();
|
|
|
|
// Welcome Back / other ScrollWindows block world start until closed.
|
|
if (ShouldAutoDismissUi())
|
|
{
|
|
TryDismissBlockingWindows();
|
|
}
|
|
|
|
if (_waitUntil > 0f)
|
|
{
|
|
if (Time.unscaledTime < _waitUntil)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_waitUntil = -1f;
|
|
_directorRunActive = false;
|
|
}
|
|
|
|
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() || !HasAnyAliveUnit())
|
|
{
|
|
RequeueFront(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);
|
|
_directorRunActive = false;
|
|
_waitUntil = Time.unscaledTime + Mathf.Max(0.05f, seconds);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"wait={seconds:0.##}s");
|
|
break;
|
|
}
|
|
|
|
case "director_run":
|
|
{
|
|
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 1f);
|
|
seconds = Mathf.Max(0.1f, seconds);
|
|
_directorRunActive = true;
|
|
_waitUntil = Time.unscaledTime + seconds;
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"director_run={seconds:0.##}s");
|
|
break;
|
|
}
|
|
|
|
case "fast_timing":
|
|
{
|
|
bool on = ParseBool(cmd.value, defaultValue: true);
|
|
InterestDirector.SetHarnessFastTiming(on);
|
|
SpeciesDiscovery.SetHarnessFastTiming(on);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"fast_timing={on}");
|
|
break;
|
|
}
|
|
|
|
case "age_current":
|
|
{
|
|
float seconds = cmd.wait > 0f ? cmd.wait : ParseFloat(cmd.value, 2f);
|
|
InterestDirector.HarnessAgeCurrent(seconds);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"aged={seconds:0.##}s tier={InterestDirector.CurrentTierName}");
|
|
break;
|
|
}
|
|
|
|
case "expire_grace":
|
|
{
|
|
InterestDirector.HarnessExpireInputGrace();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"in_grace={InterestDirector.InInputGrace}");
|
|
break;
|
|
}
|
|
|
|
case "remember_focus":
|
|
{
|
|
_rememberedFocusKey = FocusKey();
|
|
bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-";
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}");
|
|
break;
|
|
}
|
|
|
|
case "spectator":
|
|
{
|
|
bool on = ParseBool(cmd.value, defaultValue: true);
|
|
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
|
SpectatorMode.SetActive(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++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail);
|
|
break;
|
|
}
|
|
|
|
case "simulate_input":
|
|
{
|
|
string mode = (cmd.value ?? "drag").Trim().ToLowerInvariant();
|
|
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
|
bool honorGrace = expect == "grace" || expect == "blocked" || mode == "grace";
|
|
// Only camera-drag is reliably injectable without OS input.
|
|
bool exited = InterestDirector.SimulateManualInputExit(honorGrace: honorGrace);
|
|
bool ok;
|
|
string detail;
|
|
if (honorGrace)
|
|
{
|
|
ok = !exited && SpectatorMode.Active;
|
|
detail = $"grace_hold idle={SpectatorMode.Active} in_grace={InterestDirector.InInputGrace}";
|
|
}
|
|
else
|
|
{
|
|
ok = exited && !SpectatorMode.Active;
|
|
detail = $"exited idle={SpectatorMode.Active}";
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail);
|
|
break;
|
|
}
|
|
|
|
case "discovery_reset":
|
|
{
|
|
SpeciesDiscovery.HarnessResetTracking();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: "discovery_tracking_cleared");
|
|
break;
|
|
}
|
|
|
|
case "discovery_note":
|
|
DoDiscoveryNote(cmd);
|
|
break;
|
|
|
|
case "discovery_mark":
|
|
case "discovery_mark_presented":
|
|
{
|
|
string assetId = ResolveAssetId(cmd);
|
|
SpeciesDiscovery.MarkPresented(assetId);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"presented={assetId} pending={SpeciesDiscovery.PendingCount}");
|
|
break;
|
|
}
|
|
|
|
case "discovery_drain":
|
|
{
|
|
bool drained = SpeciesDiscovery.HarnessForceDrainOnce();
|
|
if (drained)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, drained, detail:
|
|
$"drained={drained} pending={SpeciesDiscovery.PendingCount} session={SpeciesDiscovery.DrainedThisSession}");
|
|
break;
|
|
}
|
|
|
|
case "watch_ghost":
|
|
DoWatchGhost(cmd);
|
|
break;
|
|
|
|
case "chronicle_force":
|
|
case "chronicle_note":
|
|
{
|
|
bool ok = Chronicle.ForceNoteCurrentFocus(
|
|
string.IsNullOrEmpty(cmd.label) ? "Slew" : cmd.label);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"chronicle_count={Chronicle.Count} caption={WatchCaption.LastHeadline}");
|
|
break;
|
|
}
|
|
|
|
case "chronicle_open":
|
|
{
|
|
Chronicle.ShowHud();
|
|
bool open = Chronicle.HudVisible;
|
|
if (open)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, open, detail: $"hud={open}");
|
|
break;
|
|
}
|
|
|
|
case "chronicle_jump":
|
|
{
|
|
bool ok = Chronicle.JumpToLatest();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"jumped={ok} focus={MoveCamera.hasFocusUnit()}");
|
|
break;
|
|
}
|
|
|
|
case "spawn":
|
|
DoSpawn(cmd);
|
|
break;
|
|
|
|
case "pick_unit":
|
|
DoPickUnit(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();
|
|
string mode = (cmd.value ?? cmd.expect ?? "").Trim().ToLowerInvariant();
|
|
if (mode == "all" || mode == "harness")
|
|
{
|
|
_assertPass = 0;
|
|
_assertFail = 0;
|
|
_cmdOk = 0;
|
|
_cmdFail = 0;
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: mode == "all" || mode == "harness" ? "counters_reset_all" : "stateprobe_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;
|
|
bool keepStats = (cmd.expect ?? "").Trim().Equals("keep", System.StringComparison.OrdinalIgnoreCase);
|
|
if (!keepStats)
|
|
{
|
|
_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 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) || assetId == "auto")
|
|
{
|
|
assetId = PickSpawnAssetId();
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no spawnable asset");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (AssetManager.actor_library?.get(assetId) == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "unknown asset: " + assetId);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 && actor.isAlive())
|
|
{
|
|
spawned++;
|
|
last = actor;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
|
if (assetId == "nearest" || assetId == "auto")
|
|
{
|
|
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;
|
|
}
|
|
|
|
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())
|
|
{
|
|
if (RetryFront(cmd, "world_not_ready"))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
string assetId = cmd.asset;
|
|
if (assetId == "auto")
|
|
{
|
|
assetId = _lastSpawnedAssetId;
|
|
}
|
|
|
|
Actor follow = ResolveUnit(string.IsNullOrEmpty(assetId) ? null : assetId);
|
|
if (follow == null)
|
|
{
|
|
if (RetryFront(cmd, string.IsNullOrEmpty(assetId) ? "no_unit" : "no_asset_unit"))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_cmdFail++;
|
|
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: {resolvedAsset}"
|
|
: cmd.label.Replace("{asset}", resolvedAsset ?? "");
|
|
if (label.Contains("{asset}"))
|
|
{
|
|
label = label.Replace("{asset}", resolvedAsset ?? "");
|
|
}
|
|
|
|
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 {ParseTier(cmd.tier, InterestTier.Curiosity)}"
|
|
: cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? "")),
|
|
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} pending={InterestCollector.PendingCount}");
|
|
}
|
|
|
|
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 : "";
|
|
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={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;
|
|
detail = $"unitAsset={unitAsset} want={wantAsset}";
|
|
break;
|
|
}
|
|
case "no_bad":
|
|
{
|
|
pass = StateProbe.BadEventCount == 0;
|
|
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;
|
|
}
|
|
case "show_watch_reasons":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
pass = ModSettings.ShowWatchReasons == want;
|
|
detail = $"show_watch_reasons={ModSettings.ShowWatchReasons} want={want}";
|
|
break;
|
|
}
|
|
case "current_tier":
|
|
{
|
|
string want = (cmd.value ?? cmd.tier ?? "").Trim();
|
|
string have = InterestDirector.CurrentTierName;
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& string.Equals(have, want, System.StringComparison.OrdinalIgnoreCase);
|
|
detail = $"tier={have} want={want} label={InterestDirector.CurrentLabel}";
|
|
break;
|
|
}
|
|
case "would_accept_curiosity":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = InterestDirector.WouldAcceptCuriosity();
|
|
pass = have == want;
|
|
detail = $"would_accept={have} want={want} tier={InterestDirector.CurrentTierName}";
|
|
break;
|
|
}
|
|
case "focus_same":
|
|
case "focus_unchanged":
|
|
{
|
|
string nowKey = FocusKey();
|
|
pass = !string.IsNullOrEmpty(_rememberedFocusKey)
|
|
&& _rememberedFocusKey != "-"
|
|
&& nowKey == _rememberedFocusKey;
|
|
detail = $"remembered={_rememberedFocusKey} now={nowKey}";
|
|
break;
|
|
}
|
|
case "presented":
|
|
{
|
|
string assetId = ResolveAssetId(cmd);
|
|
bool want = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
|
|
bool have = SpeciesDiscovery.WasPresented(assetId);
|
|
pass = have == want;
|
|
detail = $"asset={assetId} presented={have} want={want}";
|
|
break;
|
|
}
|
|
case "pending_discovery":
|
|
case "discovery_pending":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 0);
|
|
int have = SpeciesDiscovery.PendingCount;
|
|
pass = have == want;
|
|
detail = $"pending={have} want={want}";
|
|
break;
|
|
}
|
|
case "pending_interest":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 0);
|
|
int have = InterestCollector.PendingCount;
|
|
pass = have == want;
|
|
detail = $"interest_pending={have} want={want}";
|
|
break;
|
|
}
|
|
case "in_grace":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = InterestDirector.InInputGrace;
|
|
pass = have == want;
|
|
detail = $"in_grace={have} want={want}";
|
|
break;
|
|
}
|
|
case "dossier_contains":
|
|
case "caption_contains":
|
|
{
|
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
if (needle == "auto" || string.IsNullOrEmpty(needle))
|
|
{
|
|
needle = _lastSpawnedAssetId;
|
|
}
|
|
|
|
string caption = WatchCaption.LastCaptionText ?? "";
|
|
UnitDossier dossier = WatchCaption.Current;
|
|
bool dossierHit = dossier != null && dossier.ContainsIgnoreCase(needle);
|
|
bool captionHit = !string.IsNullOrEmpty(needle)
|
|
&& caption.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
pass = dossierHit || captionHit;
|
|
detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)}";
|
|
break;
|
|
}
|
|
case "chronicle_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int have = Chronicle.Count;
|
|
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
|
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
|
|
{
|
|
pass = have >= want;
|
|
detail = $"count={have} min={want}";
|
|
}
|
|
else
|
|
{
|
|
pass = have == want;
|
|
detail = $"count={have} want={want}";
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "chronicle_latest_contains":
|
|
{
|
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
if (needle == "auto")
|
|
{
|
|
needle = _lastSpawnedAssetId;
|
|
}
|
|
|
|
ChronicleEntry latest = Chronicle.Latest;
|
|
string line = latest != null ? latest.Line : "";
|
|
pass = latest != null
|
|
&& !string.IsNullOrEmpty(needle)
|
|
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
detail = $"latest='{line}' needle='{needle}'";
|
|
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} tier={InterestDirector.CurrentTierName} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count}";
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: snap);
|
|
LogService.LogInfo("[IdleSpectator][SNAP] " + snap);
|
|
}
|
|
|
|
private static void DoDiscoveryNote(HarnessCommand cmd)
|
|
{
|
|
string assetId = ResolveAssetId(cmd);
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "missing asset for discovery_note");
|
|
return;
|
|
}
|
|
|
|
Vector3 pos = CameraPos();
|
|
Actor unit = ResolveUnit(assetId);
|
|
if (unit != null)
|
|
{
|
|
pos = unit.current_position;
|
|
}
|
|
|
|
bool ok = SpeciesDiscovery.HarnessNoteAsset(assetId, pos);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"asset={assetId} pending={SpeciesDiscovery.PendingCount}");
|
|
}
|
|
|
|
private static void DoWatchGhost(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
if (RetryFront(cmd, "world_not_ready"))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor near = ResolveUnit(_lastSpawnedAssetId);
|
|
if (near == null)
|
|
{
|
|
near = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
|
|
}
|
|
|
|
if (near == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit near camera for ghost watch");
|
|
return;
|
|
}
|
|
|
|
// Claim a species that is extremely unlikely to match the focused unit.
|
|
string fakeAsset = string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto"
|
|
? "__ghost_species__"
|
|
: cmd.asset;
|
|
string label = string.IsNullOrEmpty(cmd.label)
|
|
? $"New species: {fakeAsset}"
|
|
: cmd.label.Replace("{asset}", fakeAsset);
|
|
|
|
InterestEvent interest = new InterestEvent
|
|
{
|
|
Tier = ParseTier(cmd.tier, InterestTier.Curiosity),
|
|
Score = 50f,
|
|
Position = near.current_position,
|
|
FollowUnit = null,
|
|
Label = label,
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = fakeAsset
|
|
};
|
|
|
|
string before = FocusKey();
|
|
CameraDirector.Watch(interest);
|
|
string after = FocusKey();
|
|
bool unchanged = before == after && MoveCamera.hasFocusUnit();
|
|
if (unchanged)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, unchanged, detail: $"ghost={fakeAsset} before={before} after={after} tip={CameraDirector.LastWatchLabel}");
|
|
}
|
|
|
|
private static string ResolveAssetId(HarnessCommand cmd)
|
|
{
|
|
string assetId = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
|
if (assetId == "auto" || string.IsNullOrEmpty(assetId))
|
|
{
|
|
assetId = _lastSpawnedAssetId;
|
|
}
|
|
|
|
return assetId ?? "";
|
|
}
|
|
|
|
private static string FocusKey()
|
|
{
|
|
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
|
|
{
|
|
return "-";
|
|
}
|
|
|
|
Actor unit = MoveCamera._focus_unit;
|
|
string asset = unit.asset != null ? unit.asset.id : "?";
|
|
return $"{asset}@{unit.GetHashCode()}";
|
|
}
|
|
|
|
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 HasAnyAliveUnit()
|
|
{
|
|
return WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f) != null;
|
|
}
|
|
|
|
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 int ParseCountExpect(HarnessCommand cmd, int defaultValue)
|
|
{
|
|
// Prefer explicit value= so Step()'s default count=1 does not override want=0.
|
|
if (!string.IsNullOrEmpty(cmd.value)
|
|
&& int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int fromValue))
|
|
{
|
|
return fromValue;
|
|
}
|
|
|
|
if (cmd.count != 1 || string.IsNullOrEmpty(cmd.value))
|
|
{
|
|
// count explicitly set via Step(..., count: N) when N != default 1, or no value given.
|
|
if (string.IsNullOrEmpty(cmd.value))
|
|
{
|
|
return cmd.count;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
_directorRunActive = false;
|
|
_readyLogged = false;
|
|
_offset = 0;
|
|
_lastSpawned = null;
|
|
_lastSpawnedAssetId = "";
|
|
_rememberedFocusKey = "";
|
|
InterestDirector.SetHarnessFastTiming(false);
|
|
SpeciesDiscovery.SetHarnessFastTiming(false);
|
|
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))
|
|
{
|
|
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");
|
|
}
|