worldbox-observer-mod/IdleSpectator/AgentHarness.cs
DazedAnon 0dfe3ab832 Add detailed activity prose for various actions in ActivityProse class
Expanded the ActivityProse class with new templates for actions such as laughing, singing, and playing. Enhanced the formatting logic to ensure proper sentence structure for activities, including mood and task labels. Updated AgentHarness to utilize the new prose formatting for activity sentences and adjusted test scenarios in HarnessScenarios to validate these changes. Incremented version in mod.json to reflect the update.
2026-07-14 23:48:53 -05:00

3224 lines
112 KiB
C#

using System;
using System.Collections;
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 string LastScreenshotPath = "";
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":
{
int n = Math.Max(1, cmd.count);
string baseLabel = string.IsNullOrEmpty(cmd.label) ? "Killed" : cmd.label;
int okN = 0;
for (int i = 0; i < n; i++)
{
string label = n > 1 ? $"{baseLabel} #{i + 1}" : baseLabel;
if (Chronicle.ForceNoteCurrentFocus(label))
{
okN++;
}
}
bool ok = okN == n;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail:
$"forced={okN}/{n} history={Chronicle.HistoryCountFor(Chronicle.CurrentHistorySubjectId())} caption={WatchCaption.LastHeadline}");
break;
}
case "chronicle_clear":
{
Chronicle.ClearSession();
_cmdOk++;
Emit(cmd, ok: true, detail: "chronicle_cleared");
break;
}
case "activity_probe":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string probe = ActivityInterestTable.DumpFocusProbe(focus);
long id = 0;
try
{
if (focus != null)
{
id = focus.getID();
}
}
catch
{
id = 0;
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"{probe} activity_count={ActivityLog.CountFor(id)}");
break;
}
case "activity_force":
{
long id = WatchCaption.CurrentUnitId;
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
id = MoveCamera._focus_unit.getID();
}
catch
{
id = 0;
}
}
string key = !string.IsNullOrEmpty(cmd.asset)
? cmd.asset
: (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "farm");
string line = !string.IsNullOrEmpty(cmd.label) ? cmd.label : key;
bool asBeat = (cmd.tier ?? "").IndexOf("beat", System.StringComparison.OrdinalIgnoreCase) >= 0;
string actorName = "";
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
actorName = MoveCamera._focus_unit.getName() ?? "";
}
}
catch
{
actorName = "";
}
string targetName = cmd.expect ?? "";
int n = Math.Max(1, cmd.count);
int okN = 0;
for (int i = 0; i < n; i++)
{
string one = n > 1 ? $"{line} #{i + 1}" : line;
if (ActivityLog.ForceNote(id, key, one, asBeat, actorName, targetName))
{
okN++;
}
}
WatchCaption.ForceRefreshHistory();
bool ok = okN == n && id != 0;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail:
$"forced={okN}/{n} id={id} count={ActivityLog.CountFor(id)} shown={WatchCaption.LastActivityShown}");
break;
}
case "activity_clear":
{
ActivityLog.ClearSession();
WatchCaption.ForceRefreshHistory();
_cmdOk++;
Emit(cmd, ok: true, detail: "activity_cleared");
break;
}
case "activity_sample_reset":
{
ActivityLog.ResetSampleCounters();
_cmdOk++;
Emit(cmd, ok: true, detail: ActivityLog.FormatSampleStats());
break;
}
case "activity_stats":
{
string stats = ActivityLog.FormatSampleStats();
_cmdOk++;
Emit(cmd, ok: true, detail: stats);
break;
}
case "lore_detail_pane":
{
string raw = (cmd.value ?? cmd.label ?? "life").Trim().ToLowerInvariant();
ChronicleHud.DetailPane pane = raw.StartsWith("act")
? ChronicleHud.DetailPane.Activity
: ChronicleHud.DetailPane.Life;
ChronicleHud.SetDetailPane(pane);
bool ok = ChronicleHud.DetailUnitId != 0
&& ChronicleHud.ActiveDetailPane == pane;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail:
$"pane={ChronicleHud.ActiveDetailPane} detailId={ChronicleHud.DetailUnitId} "
+ $"actRows={ChronicleHud.LastDetailActivityRows} lifeRows={ChronicleHud.LastDetailHistoryRows}");
break;
}
case "chronicle_world_force":
{
string label = !string.IsNullOrEmpty(cmd.label)
? cmd.label
: (!string.IsNullOrEmpty(cmd.value) ? cmd.value : "War: harness vs test");
bool ok = Chronicle.ForceWorldNote(label);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"world_count={Chronicle.WorldCount} label={label}");
break;
}
case "chronicle_death_sample":
{
string typeName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Age";
bool ok = Chronicle.ForceDeathSample(typeName);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
ChronicleEntry latest = Chronicle.Latest;
Emit(cmd, ok, detail: $"type={typeName} latest={(latest != null ? latest.Line : "")}");
break;
}
case "chronicle_tab":
case "lore_tab":
case "world_memory_open":
{
Chronicle.ShowHud();
string tabRaw = (cmd.value ?? cmd.label ?? "world").Trim().ToLowerInvariant();
if (tabRaw == "fallen" || tabRaw == "dead")
{
ChronicleHud.SetTab(ChronicleHud.LoreTab.Fallen);
}
else if (tabRaw == "living"
|| tabRaw == "characters"
|| tabRaw == "character"
|| tabRaw == "chars"
|| tabRaw == "history")
{
ChronicleHud.SetTab(ChronicleHud.LoreTab.Living);
}
else
{
ChronicleHud.SetTab(ChronicleHud.LoreTab.World);
}
bool open = Chronicle.HudVisible;
if (open)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, open,
detail: $"hud={open} tab={ChronicleHud.ActiveTab} memory={Chronicle.MemoryCount} recent={Chronicle.RecentFocusCount}");
break;
}
case "lore_refresh":
case "chronicle_refresh":
{
ChronicleHud.RefreshCastList();
_cmdOk++;
Emit(
cmd,
ok: true,
detail:
$"stale={ChronicleHud.CastListStale} top='{ChronicleHud.LastListTopTitle}' tab={ChronicleHud.ActiveTab}");
break;
}
case "lore_search":
{
Chronicle.ShowHud();
ChronicleHud.SetTab(ChronicleHud.LoreTab.Living);
ChronicleHud.SetSearch(cmd.value ?? cmd.label ?? "");
_cmdOk++;
Emit(cmd, ok: true, detail: $"search='{ChronicleHud.SearchText}'");
break;
}
case "lore_favorites":
{
Chronicle.ShowHud();
string v = (cmd.value ?? "true").Trim().ToLowerInvariant();
bool on = v != "false" && v != "0" && v != "off";
ChronicleHud.SetFavoritesOnly(on);
// Filter is a list view - leave detail if we were in one.
if (ChronicleHud.DetailUnitId != 0)
{
ChronicleHud.ClearUnitDetail();
}
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"favoritesOnly={ChronicleHud.FavoritesOnly} tab={ChronicleHud.ActiveTab}");
break;
}
case "lore_pick":
{
Chronicle.ShowHud();
ChronicleHud.SetTab(ChronicleHud.LoreTab.Living);
string needle = cmd.value ?? cmd.label ?? "";
if (needle == "auto")
{
needle = "";
}
bool ok = ChronicleHud.PickFirstCharacter(needle);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok,
detail: $"picked={ok} focus={MoveCamera.hasFocusUnit()} dossier={WatchCaption.LastHeadline} recent={Chronicle.RecentFocusCount}");
break;
}
case "lore_open_focus":
case "chronicle_open_focus":
{
ChronicleHud.OpenSyncedFromFocus();
bool open = Chronicle.HudVisible;
if (open)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, open,
detail:
$"lore={Chronicle.HudVisible} detail={ChronicleHud.DetailUnitId} dossierId={WatchCaption.CurrentUnitId} idle={SpectatorMode.Active} follow={ChronicleHud.FollowFocus}");
break;
}
case "lore_pick_fallen":
{
Chronicle.ShowHud();
ChronicleHud.SetTab(ChronicleHud.LoreTab.Fallen);
string needle = cmd.value ?? cmd.label ?? "";
if (needle == "auto")
{
needle = "";
}
bool ok = ChronicleHud.PickFirstFallen(needle);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok,
detail:
$"picked={ok} detail={ChronicleHud.DetailUnitId} follow={ChronicleHud.FollowFocus} hist={Chronicle.HistoryCountFor(ChronicleHud.DetailUnitId)} fallenFocus={Chronicle.LastFallenFocusDetail} manner={Chronicle.LatestDeathMannerFor(ChronicleHud.DetailUnitId)}");
break;
}
case "chronicle_orphan":
{
int n = Math.Max(1, cmd.count);
string name = !string.IsNullOrEmpty(cmd.label) ? cmd.label : "Orphan";
string species = !string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "creature";
string prefix = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "Fallen";
AttackType attackType = AttackType.Age;
if (!string.IsNullOrEmpty(cmd.expect)
&& System.Enum.TryParse(cmd.expect, ignoreCase: true, out AttackType parsedAttack))
{
attackType = parsedAttack;
}
long killerId = 0;
Vector3 deathPos = Vector3.zero;
string mode = (cmd.tier ?? "").Trim().ToLowerInvariant();
bool favorite = mode.IndexOf("fav", System.StringComparison.Ordinal) >= 0;
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (mode.IndexOf("killer", System.StringComparison.Ordinal) >= 0
&& focus != null
&& focus.isAlive())
{
killerId = focus.getID();
deathPos = focus.current_position;
}
else if (mode.IndexOf("place", System.StringComparison.Ordinal) >= 0
|| favorite
|| string.IsNullOrEmpty(mode))
{
if (mode.IndexOf("place", System.StringComparison.Ordinal) >= 0 || favorite)
{
if (focus != null)
{
deathPos = focus.current_position;
}
else if (MoveCamera.instance != null)
{
deathPos = MoveCamera.instance.transform.position;
deathPos.z = 0f;
}
if (deathPos == Vector3.zero)
{
deathPos = new Vector3(48f, 48f, 0f);
}
}
}
long id = Chronicle.ForceOrphanHistory(
name, species, n, prefix, attackType, killerId, deathPos, favorite);
DeathManner manner = Chronicle.LatestDeathMannerFor(id);
bool ok = id != 0
&& Chronicle.HistoryCountFor(id) >= n
&& (!favorite || Chronicle.IsFavoriteSubject(id));
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail:
$"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode} fav={Chronicle.IsFavoriteSubject(id)}");
break;
}
case "chronicle_orphan_flood":
{
// Stress subject-cap pruning (does not need living units).
int n = Math.Max(1, cmd.count);
int made = 0;
for (int i = 0; i < n; i++)
{
long id = Chronicle.ForceOrphanHistory(
"Flood" + i,
"human",
1,
"Flood",
AttackType.Age);
if (id != 0)
{
made++;
}
}
bool ok = made == n
&& Chronicle.HistorySubjectCount <= Chronicle.MaxHistorySubjects;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail:
$"made={made}/{n} subjects={Chronicle.HistorySubjectCount} max={Chronicle.MaxHistorySubjects} dossiers={Chronicle.FinalDossierCount}");
break;
}
case "chronicle_set_subject_cap":
{
int cap = Math.Max(40, cmd.count);
if (!string.IsNullOrEmpty(cmd.value)
&& int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
{
cap = parsed;
}
Chronicle.SetMaxHistorySubjects(cap);
bool ok = Chronicle.MaxHistorySubjects == Mathf.Clamp(cap, 40, Chronicle.AbsoluteMaxHistorySubjects)
&& Chronicle.HistorySubjectCount <= Chronicle.MaxHistorySubjects;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail:
$"cap={Chronicle.MaxHistorySubjects} subjects={Chronicle.HistorySubjectCount}");
break;
}
case "chronicle_subject_bench":
{
// value: comma-separated sizes, e.g. "320,640,1280,2560"
// count: events per subject (default 3). expect: max avg fallen ms budget (default 2).
string sizesRaw = string.IsNullOrEmpty(cmd.value)
? "320,640,1280,2560,4096,8192"
: cmd.value;
int eventsPer = Math.Max(1, cmd.count);
if (eventsPer == 1 && string.IsNullOrEmpty(cmd.label))
{
eventsPer = 3;
}
float budgetMs = 2f;
if (!string.IsNullOrEmpty(cmd.expect)
&& float.TryParse(cmd.expect, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedBudget))
{
budgetMs = parsedBudget;
}
string[] parts = sizesRaw.Split(',');
var reports = new List<string>();
int best = 0;
float bestAvg = -1f;
bool any = false;
for (int i = 0; i < parts.Length; i++)
{
string p = parts[i].Trim();
if (!int.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out int size)
|| size < 40)
{
continue;
}
string report = Chronicle.BenchSubjectLoad(size, eventsPer, iters: 25);
reports.Add(report);
any = true;
float avg = float.MaxValue;
const string needle = "fallenAvgMs=";
int idx = report.IndexOf(needle, StringComparison.Ordinal);
if (idx >= 0)
{
int start = idx + needle.Length;
int end = start;
while (end < report.Length
&& (char.IsDigit(report[end]) || report[end] == '.'))
{
end++;
}
float.TryParse(
report.Substring(start, end - start),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out avg);
}
// Highest size that stays within the ms budget.
if (avg <= budgetMs && size > best)
{
best = size;
bestAvg = avg;
}
}
if (best <= 0)
{
best = Chronicle.DefaultMaxHistorySubjects;
bestAvg = budgetMs;
}
Chronicle.SetMaxHistorySubjects(best);
Chronicle.ClearSession();
bool ok = any && reports.Count > 0;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
string joined = string.Join(" || ", reports);
Emit(
cmd,
ok,
detail:
$"budgetMs={budgetMs:0.00} recommend={best} recommendAvgMs={bestAvg:0.00} capNow={Chronicle.MaxHistorySubjects} | {joined}");
break;
}
case "lore_close":
case "chronicle_close":
{
Chronicle.HideHud();
_cmdOk++;
Emit(cmd, ok: true, detail: $"lore={Chronicle.HudVisible}");
break;
}
case "chronicle_open":
{
Chronicle.ShowHud();
ChronicleHud.SetTab(ChronicleHud.LoreTab.World);
bool open = Chronicle.HudVisible;
if (open)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, open, detail: $"hud={open} memory={Chronicle.MemoryCount} age={Chronicle.CurrentAgeName}");
break;
}
case "dossier_favorite":
{
Actor target = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string v = (cmd.value ?? "").Trim().ToLowerInvariant();
if (v == "true" || v == "1" || v == "on" || v == "false" || v == "0" || v == "off")
{
bool want = v == "true" || v == "1" || v == "on";
bool ok = false;
try
{
if (target != null && target.isAlive())
{
if (target.isFavorite() != want)
{
target.switchFavorite();
}
ok = target.isFavorite() == want;
WatchCaption.SetFromActor(target);
}
}
catch
{
ok = false;
}
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"favorite={want} set");
break;
}
WatchCaption.ToggleFavorite();
bool fav = false;
try
{
fav = target != null && target.isFavorite();
}
catch
{
// ignore
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"favorite={fav}");
break;
}
case "dossier_expand":
case "dossier_history_open":
{
WatchCaption.OpenFullHistoryInLore();
bool open = Chronicle.HudVisible && ChronicleHud.DetailUnitId != 0;
if (open)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, open,
detail: $"lore={Chronicle.HudVisible} detail={ChronicleHud.DetailUnitId} idle={SpectatorMode.Active} shown={WatchCaption.LastHistoryShown}");
break;
}
case "lore_back":
{
ChronicleHud.ClearUnitDetail();
_cmdOk++;
Emit(cmd, ok: true, detail: $"detail={ChronicleHud.DetailUnitId} tab={ChronicleHud.ActiveTab}");
break;
}
case "chronicle_jump":
{
bool ok = Chronicle.JumpToLatest();
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail:
$"jumped={ok} detail={Chronicle.LastJumpDetail} focus={MoveCamera.hasFocusUnit()} pos={Chronicle.LastJumpPosition}");
break;
}
case "lore_jump_world":
{
Chronicle.ShowHud();
ChronicleHud.SetTab(ChronicleHud.LoreTab.World);
ChronicleEntry target = null;
IReadOnlyList<ChronicleEntry> snap = Chronicle.SnapshotMemory();
for (int i = snap.Count - 1; i >= 0; i--)
{
ChronicleEntry e = snap[i];
if (e == null || e.Kind == ChronicleKind.AgeChapter)
{
continue;
}
if (e.Kind == ChronicleKind.World || e.IsLegend)
{
target = e;
break;
}
}
bool ok = target != null && Chronicle.JumpTo(target);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail:
$"jumped={ok} detail={Chronicle.LastJumpDetail} focus={MoveCamera.hasFocusUnit()} line={(target != null ? target.Line : "")}");
break;
}
case "screenshot":
{
bool ok = CaptureHarnessScreenshot(cmd.value);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"path={LastScreenshotPath} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}");
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 "focus_arrows":
case "relationship_arrows":
{
bool pinned = FocusRelationshipArrows.IsPinnedToFocus();
pass = SpectatorMode.Active && MoveCamera.hasFocusUnit() && pinned;
detail =
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} pinned={pinned} lastActor={(UnitSelectionEffect.last_actor != null ? SafeName(UnitSelectionEffect.last_actor) : "null")} dest={PlayerConfig.optionBoolEnabled("cursor_arrow_destination")} lover={PlayerConfig.optionBoolEnabled("cursor_arrow_lover")} attack={PlayerConfig.optionBoolEnabled("cursor_arrow_attack_target")}";
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.HistoryCountFor(Chronicle.CurrentHistorySubjectId());
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
{
pass = have >= want;
detail = $"history_count={have} min={want}";
}
else if (cmp == "world")
{
have = Chronicle.WorldCount;
pass = have >= want;
detail = $"world_count={have} min={want}";
}
else
{
pass = have == want;
detail = $"history_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 : "";
string display = latest != null ? latest.DisplayLine : "";
pass = latest != null
&& !string.IsNullOrEmpty(needle)
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"latest='{display}' needle='{needle}' date='{(latest != null ? latest.DateLabel : "")}'";
break;
}
case "chronicle_latest_dated":
{
ChronicleEntry latest = Chronicle.Latest;
pass = latest != null
&& !string.IsNullOrEmpty(latest.DateLabel)
&& latest.WorldTime > 0;
detail =
$"date='{(latest != null ? latest.DateLabel : "")}' worldTime={(latest != null ? latest.WorldTime.ToString("0.###") : "0")} line='{(latest != null ? latest.Line : "")}'";
break;
}
case "caption_layout_ok":
{
float maxH = 220f;
pass = WatchCaption.LastLayoutOk
&& WatchCaption.LastPanelSize.y > 20f
&& WatchCaption.LastPanelSize.y < maxH
&& WatchCaption.LastPanelSize.x <= 440f
&& (WatchCaption.LastHistoryShown <= 0 || WatchCaption.LastHistoryFillsBody)
&& WatchCaption.LastHistoryShown <= 3;
detail =
$"layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize} histFill={WatchCaption.LastHistoryFillsBody} shown={WatchCaption.LastHistoryShown} caption='{(WatchCaption.LastCaptionText ?? "").Replace("\n", " | ")}' hist='{(WatchCaption.LastHistoryPreview ?? "").Replace("\n", " ")}'";
break;
}
case "dossier_traits_ok":
{
UnitDossier dossier = WatchCaption.Current;
string preview = WatchCaption.LastTraitsPreview ?? "";
int count = dossier != null ? dossier.TopTraits.Count : 0;
bool noEllipsis = preview.IndexOf("...", System.StringComparison.Ordinal) < 0;
bool bounded = count >= 0 && count <= 3;
bool labelsMatch = true;
if (dossier != null)
{
for (int i = 0; i < dossier.TopTraits.Count; i++)
{
string name = dossier.TopTraits[i].Name ?? "";
if (name.IndexOf("...", System.StringComparison.Ordinal) >= 0)
{
labelsMatch = false;
break;
}
}
}
pass = bounded && noEllipsis && labelsMatch;
detail = $"traits={count} preview='{preview}' layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}";
break;
}
case "dossier_history_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
string hist = WatchCaption.LastHistoryJoined ?? "";
if (string.IsNullOrEmpty(hist))
{
hist = WatchCaption.LastHistoryPreview ?? "";
}
pass = !string.IsNullOrEmpty(needle)
&& hist.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"hist='{hist}' needle='{needle}' act={WatchCaption.LastActivityShown} life={WatchCaption.LastLifeShown}";
break;
}
case "activity_count":
{
long id = WatchCaption.CurrentUnitId;
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = ActivityLog.CountFor(id);
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
pass = cmp == "exact" || cmp == "==" ? have == want : have >= want;
detail = $"activity_count={have} want={want} id={id} shown={WatchCaption.LastActivityShown}";
break;
}
case "activity_prose_variety":
{
long id = WatchCaption.CurrentUnitId;
IReadOnlyList<ActivityEntry> entries = ActivityLog.LatestForSubject(id, 12);
var distinct = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < entries.Count; i++)
{
if (entries[i] != null && !string.IsNullOrEmpty(entries[i].DisplayLine))
{
distinct.Add(entries[i].DisplayLine);
}
}
int want = ParseCountExpect(cmd, defaultValue: 2);
pass = distinct.Count >= want;
detail = $"distinct={distinct.Count} want={want} total={entries.Count}";
break;
}
case "activity_first_scoring":
{
pass = WorldActivityScanner.HarnessActivityFirstScoringOk(out string scoreDetail);
detail = scoreDetail;
break;
}
case "activity_names_colored":
{
long id = WatchCaption.CurrentUnitId;
IReadOnlyList<ActivityEntry> entries = ActivityLog.LatestForSubject(id, 8);
bool any = false;
bool colored = false;
for (int i = 0; i < entries.Count; i++)
{
ActivityEntry e = entries[i];
if (e == null || string.IsNullOrEmpty(e.DisplayLineRich))
{
continue;
}
any = true;
if (e.DisplayLineRich.IndexOf("<color=", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
colored = true;
break;
}
}
pass = any && colored;
detail = $"any={any} colored={colored} preview='{WatchCaption.LastHistoryPreview}'";
break;
}
case "activity_log_contains":
{
long id = WatchCaption.CurrentUnitId;
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
IReadOnlyList<ActivityEntry> entries =
ActivityLog.LatestForSubject(id, ActivityLog.MaxLinesPerSubject);
bool hit = false;
string sample = "";
for (int i = 0; i < entries.Count; i++)
{
ActivityEntry e = entries[i];
if (e == null)
{
continue;
}
string line = e.DisplayLine ?? e.Line ?? "";
if (string.IsNullOrEmpty(sample))
{
sample = line;
}
if (!string.IsNullOrEmpty(needle)
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
hit = true;
sample = line;
break;
}
}
pass = hit;
detail = $"hit={hit} needle='{needle}' sample='{sample}' count={entries.Count}";
break;
}
case "activity_prose_sentence":
{
// Ensure mood/task labels become sentences, not "{name} laughing".
long id = WatchCaption.CurrentUnitId;
string actorName = "";
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
actorName = MoveCamera._focus_unit.getName() ?? "";
}
}
catch
{
actorName = "";
}
ActivityProse.Format(
ActivityKind.TaskStart,
"laughing",
actorName,
"",
false,
"",
"Laughing",
id,
99,
out string plain,
out _);
bool ok = !string.IsNullOrEmpty(plain)
&& plain.IndexOf("laugh", System.StringComparison.OrdinalIgnoreCase) >= 0
&& !plain.Equals(
(string.IsNullOrEmpty(actorName) ? "Someone" : actorName) + " laughing",
System.StringComparison.Ordinal);
pass = ok;
detail = $"plain='{plain}' actor='{actorName}'";
break;
}
case "lore_detail_pane":
{
string want = (cmd.value ?? "life").Trim().ToLowerInvariant();
ChronicleHud.DetailPane have = ChronicleHud.ActiveDetailPane;
bool match = want.StartsWith("act")
? have == ChronicleHud.DetailPane.Activity
: have == ChronicleHud.DetailPane.Life;
pass = Chronicle.HudVisible && ChronicleHud.DetailUnitId != 0 && match;
detail =
$"pane={have} want={want} actRows={ChronicleHud.LastDetailActivityRows} lifeRows={ChronicleHud.LastDetailHistoryRows}";
break;
}
case "lore_activity_shown":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = ChronicleHud.LastDetailActivityRows;
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
pass = Chronicle.HudVisible
&& ChronicleHud.ActiveDetailPane == ChronicleHud.DetailPane.Activity
&& (cmp == "exact" || cmp == "==" ? have == want : have >= want);
detail = $"actRows={have} want={want} pane={ChronicleHud.ActiveDetailPane}";
break;
}
case "activity_band_cold_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
ActivityBand band = ActivityInterestTable.Classify(focus);
bool cold = WorldActivityScanner.IsFocusActivityCold(focus);
string want = (cmd.value ?? "cold").Trim().ToLowerInvariant();
pass = want == "cold" ? cold : !cold;
detail = $"band={band} cold={cold} want={want} probe={ActivityInterestTable.DumpFocusProbe(focus)}";
break;
}
case "chronicle_world_latest_contains":
case "world_memory_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
ChronicleEntry latest = Chronicle.LatestWorld;
string line = latest != null ? latest.Line : "";
string lore = latest != null ? latest.HudLine : "";
string display = latest != null ? latest.DisplayLine : "";
pass = latest != null
&& latest.Kind != ChronicleKind.AgeChapter
&& !string.IsNullOrEmpty(needle)
&& (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0);
detail = $"world_latest='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}'";
break;
}
case "world_memory_age":
{
pass = !string.IsNullOrEmpty(Chronicle.CurrentAgeId)
|| Chronicle.MemoryCount > 0;
detail = $"ageId='{Chronicle.CurrentAgeId}' ageName='{Chronicle.CurrentAgeName}' memory={Chronicle.MemoryCount}";
break;
}
case "world_memory_world_only":
{
// World tab must not mix in character Kill/Death/Lover rows.
IReadOnlyList<ChronicleEntry> snap = Chronicle.SnapshotMemory();
int bad = 0;
int worldRows = 0;
for (int i = 0; i < snap.Count; i++)
{
ChronicleEntry e = snap[i];
if (e == null || e.Kind == ChronicleKind.AgeChapter)
{
continue;
}
if (e.Kind == ChronicleKind.World)
{
worldRows++;
continue;
}
bad++;
}
pass = bad == 0;
detail = $"badKinds={bad} worldRows={worldRows} memory={snap.Count}";
break;
}
case "chronicle_subjects_capped":
{
int subjects = Chronicle.HistorySubjectCount;
int dossiers = Chronicle.FinalDossierCount;
pass = subjects <= Chronicle.MaxHistorySubjects
&& dossiers <= Chronicle.MaxHistorySubjects;
detail =
$"subjects={subjects} dossiers={dossiers} max={Chronicle.MaxHistorySubjects} revision={Chronicle.HistoryRevision}";
break;
}
case "chronicle_memory_capped":
{
int memory = Chronicle.MemoryCount;
pass = memory <= Chronicle.MaxLandmarks;
detail = $"memory={memory} max={Chronicle.MaxLandmarks}";
break;
}
case "dossier_favorite":
case "is_favorite":
{
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
bool fav = false;
try
{
fav = unit != null && unit.isAlive() && unit.isFavorite();
}
catch
{
// ignore
}
if (!fav)
{
long id = WatchCaption.CurrentUnitId;
if (id == 0)
{
id = ChronicleHud.DetailUnitId;
}
if (id == 0 && unit != null)
{
try
{
id = unit.getID();
}
catch
{
id = 0;
}
}
fav = Chronicle.IsFavoriteSubject(id);
}
string wantRaw = (cmd.value ?? "true").Trim().ToLowerInvariant();
bool want = wantRaw != "false" && wantRaw != "0" && wantRaw != "off";
pass = fav == want;
detail = $"favorite={fav} want={want}";
break;
}
case "dossier_history_shown":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
string scope = (cmd.tier ?? "").Trim().ToLowerInvariant();
int have = scope == "life"
? WatchCaption.LastLifeShown
: scope == "activity"
? WatchCaption.LastActivityShown
: WatchCaption.LastHistoryShown;
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
if (cmp == "exact" || cmp == "==")
{
pass = have == want;
}
else
{
pass = have >= want;
}
detail =
$"shown={have} want={want} scope={scope} total={WatchCaption.LastHistoryShown} act={WatchCaption.LastActivityShown} life={WatchCaption.LastLifeShown} scrollable={WatchCaption.LastHistoryScrollable} histFill={WatchCaption.LastHistoryFillsBody}";
break;
}
case "dossier_history_scrollable":
{
// Dossier history is no longer scrollable; full history lives in Lore.
pass = !WatchCaption.LastHistoryScrollable && WatchCaption.LastHistoryShown <= 3;
detail =
$"scrollable={WatchCaption.LastHistoryScrollable} shown={WatchCaption.LastHistoryShown}";
break;
}
case "lore_detail":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = ChronicleHud.DetailUnitId != 0;
pass = Chronicle.HudVisible && have == want;
detail =
$"detailId={ChronicleHud.DetailUnitId} want={want} tab={ChronicleHud.ActiveTab} follow={ChronicleHud.FollowFocus}";
break;
}
case "lore_follow":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = ChronicleHud.FollowFocus;
pass = Chronicle.HudVisible && ChronicleHud.DetailUnitId != 0 && have == want;
detail =
$"follow={have} want={want} detailId={ChronicleHud.DetailUnitId}";
break;
}
case "fallen_focus":
{
string want = (cmd.value ?? "").Trim().ToLowerInvariant();
string have = (Chronicle.LastFallenFocusDetail ?? "").Trim().ToLowerInvariant();
if (want == "killer" || want.StartsWith("killer"))
{
pass = have.StartsWith("killer:");
}
else if (want == "place")
{
pass = have == "place";
}
else if (want == "none")
{
pass = have == "none" || string.IsNullOrEmpty(have);
}
else
{
pass = have == want;
}
detail = $"fallenFocus={Chronicle.LastFallenFocusDetail} want={cmd.value}";
break;
}
case "death_manner":
{
long id = ChronicleHud.DetailUnitId;
DeathManner have = Chronicle.LatestDeathMannerFor(id);
string haveLabel = Chronicle.DeathMannerLabel(have);
string want = (cmd.value ?? "").Trim().ToLowerInvariant();
pass = id != 0
&& (haveLabel == want
|| have.ToString().Equals(want, System.StringComparison.OrdinalIgnoreCase));
detail = $"manner={have} label={haveLabel} want={cmd.value} detailId={id}";
break;
}
case "lore_follows_focus":
{
long focusId = WatchCaption.CurrentUnitId;
if (focusId == 0)
{
focusId = Chronicle.CurrentHistorySubjectId();
}
long loreId = ChronicleHud.DetailUnitId;
pass = Chronicle.HudVisible
&& ChronicleHud.FollowFocus
&& SpectatorMode.Active
&& focusId != 0
&& loreId == focusId;
detail =
$"follow={ChronicleHud.FollowFocus} idle={SpectatorMode.Active} focusId={focusId} loreId={loreId}";
break;
}
case "lore_history_shown":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
long detailId = ChronicleHud.DetailUnitId;
int haveData = Chronicle.HistoryCountFor(detailId);
int haveUi = ChronicleHud.LastDetailHistoryRows;
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
bool dataOk = cmp == "exact" || cmp == "==" ? haveData == want : haveData >= want;
bool uiOk = cmp == "exact" || cmp == "==" ? haveUi == want : haveUi >= want;
pass = Chronicle.HudVisible && detailId != 0 && dataOk && uiOk;
detail =
$"loreHist={haveData} uiRows={haveUi} want={want} detailId={detailId} dossierId={WatchCaption.CurrentUnitId}";
break;
}
case "dossier_lore_history_match":
{
long dossierId = WatchCaption.CurrentUnitId;
long loreId = ChronicleHud.DetailUnitId;
int lifeShown = WatchCaption.LastLifeShown;
int loreData = Chronicle.HistoryCountFor(loreId);
int loreUi = ChronicleHud.LastDetailHistoryRows;
bool idsMatch = dossierId != 0 && dossierId == loreId;
// Life lines must agree with Lore; activity-only peek does not imply chronicle rows.
bool countsAgree = lifeShown <= 0
? loreData == 0 && loreUi == 0
: loreData >= lifeShown && loreUi >= Mathf.Min(lifeShown, 24);
pass = Chronicle.HudVisible && idsMatch && countsAgree;
detail =
$"dossierId={dossierId} loreId={loreId} lifeShown={lifeShown} act={WatchCaption.LastActivityShown} loreData={loreData} loreUi={loreUi} preview={WatchCaption.LastHistoryPreview}";
break;
}
case "dossier_pinned":
{
bool want = ParseBool(cmd.value, defaultValue: true);
bool have = WatchCaption.PinnedWhilePaused && WatchCaption.Visible;
pass = have == want;
detail =
$"pinned={WatchCaption.PinnedWhilePaused} visible={WatchCaption.Visible} idle={SpectatorMode.Active} want={want}";
break;
}
case "dossier_unit_matches_lore":
{
long dossierId = WatchCaption.CurrentUnitId;
long loreId = ChronicleHud.DetailUnitId;
pass = dossierId != 0 && loreId != 0 && dossierId == loreId;
detail =
$"dossierId={dossierId} loreId={loreId} headline={WatchCaption.LastHeadline}";
break;
}
case "dossier_species":
{
string want = (cmd.value ?? cmd.asset ?? "").Trim();
string have = WatchCaption.Current != null ? WatchCaption.Current.SpeciesId ?? "" : "";
pass = !string.IsNullOrEmpty(want)
&& have.Equals(want, System.StringComparison.OrdinalIgnoreCase);
detail =
$"species={have} want={want} headline={WatchCaption.LastHeadline} avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback}";
break;
}
case "dossier_matches_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
UnitDossier dossier = WatchCaption.Current;
string focusSpecies = focus?.asset != null ? focus.asset.id : "";
long focusId = 0;
try
{
focusId = focus != null ? focus.getID() : 0;
}
catch
{
focusId = 0;
}
pass = focus != null
&& focus.isAlive()
&& dossier != null
&& dossier.UnitId == focusId
&& !string.IsNullOrEmpty(focusSpecies)
&& focusSpecies.Equals(dossier.SpeciesId ?? "", System.StringComparison.OrdinalIgnoreCase)
&& DossierAvatar.ShowingLiveAvatar
&& !DossierAvatar.ShowingSpeciesFallback;
detail =
$"focusId={focusId} dossierId={(dossier != null ? dossier.UnitId : 0)} focusSpecies={focusSpecies} dossierSpecies={(dossier != null ? dossier.SpeciesId : "")} avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback}";
break;
}
case "dossier_avatar_live":
{
pass = DossierAvatar.ShowingLiveAvatar && !DossierAvatar.ShowingSpeciesFallback;
detail =
$"avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback} dossierId={WatchCaption.CurrentUnitId}";
break;
}
case "dossier_avatar_species":
{
pass = DossierAvatar.ShowingSpeciesFallback && !DossierAvatar.ShowingLiveAvatar;
detail =
$"avatarLive={DossierAvatar.ShowingLiveAvatar} avatarSpecies={DossierAvatar.ShowingSpeciesFallback} dossierId={WatchCaption.CurrentUnitId}";
break;
}
case "fallen_place_no_follow":
{
// Place focus must clear unit follow so the camera stays at the death site.
pass = Chronicle.LastFallenFocusDetail == "place" && !MoveCamera.hasFocusUnit();
detail =
$"fallenFocus={Chronicle.LastFallenFocusDetail} hasFocus={MoveCamera.hasFocusUnit()} pos={Chronicle.LastFallenFocusPosition}";
break;
}
case "world_jump_place":
{
pass = Chronicle.LastJumpDetail == "place"
&& !MoveCamera.hasFocusUnit()
&& Chronicle.LastJumpPosition.sqrMagnitude > 0.0001f;
detail =
$"jump={Chronicle.LastJumpDetail} hasFocus={MoveCamera.hasFocusUnit()} pos={Chronicle.LastJumpPosition}";
break;
}
case "fallen_killer_camera":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long focusId = 0;
try
{
focusId = focus != null ? focus.getID() : 0;
}
catch
{
focusId = 0;
}
string detailStr = Chronicle.LastFallenFocusDetail ?? "";
bool killerTag = detailStr.StartsWith("killer:");
long killerId = 0;
if (killerTag)
{
long.TryParse(detailStr.Substring("killer:".Length), out killerId);
}
pass = killerTag
&& killerId != 0
&& focusId == killerId
&& WatchCaption.CurrentUnitId != 0
&& WatchCaption.CurrentUnitId != focusId;
detail =
$"fallenFocus={detailStr} focusId={focusId} dossierId={WatchCaption.CurrentUnitId}";
break;
}
case "lore_zoom_blocked":
{
MoveCamera cam = MoveCamera.instance;
if (cam == null)
{
pass = false;
detail = "no camera";
break;
}
float before = cam.getTargetZoom();
CameraZoomPatches.ForceAbsorbForHarness = true;
try
{
MoveCamera.zoomInWheel(null);
MoveCamera.zoomOutWheel(null);
}
finally
{
CameraZoomPatches.ForceAbsorbForHarness = false;
}
float after = cam.getTargetZoom();
pass = Mathf.Abs(after - before) < 0.001f;
detail = $"zoomBefore={before} zoomAfter={after} absorb={ChronicleHud.ShouldAbsorbScrollZoom()}";
break;
}
case "dossier_history_fills_body":
{
pass = WatchCaption.LastHistoryShown > 0 && WatchCaption.LastHistoryFillsBody;
detail =
$"histFill={WatchCaption.LastHistoryFillsBody} shown={WatchCaption.LastHistoryShown} size={WatchCaption.LastPanelSize}";
break;
}
case "hud_no_overlap":
{
bool hasDossier = WatchCaption.TryGetScreenPixelRect(out Rect dossier);
bool hasMemory = ChronicleHud.TryGetScreenPixelRect(out Rect memory);
bool overlap = false;
if (hasDossier && hasMemory && ChronicleHud.Visible)
{
overlap = dossier.Overlaps(memory);
}
pass = hasDossier && (!ChronicleHud.Visible || (hasMemory && !overlap));
detail =
$"dossier={(hasDossier ? dossier.ToString() : "-")} memory={(hasMemory ? memory.ToString() : "-")} visible={ChronicleHud.Visible} overlap={overlap}";
break;
}
case "world_memory_compact":
{
bool hasMemory = ChronicleHud.TryGetScreenPixelRect(out Rect memory);
// Same canvas scale as dossier (no localScale damp). Reject tiny or near-fullscreen.
float minW = Screen.width * 0.22f;
float maxW = Screen.width * 0.55f;
float minH = Screen.height * 0.16f;
float maxH = Screen.height * 0.50f;
pass = ChronicleHud.Visible
&& hasMemory
&& memory.width >= minW
&& memory.height >= minH
&& memory.width <= maxW
&& memory.height <= maxH;
detail =
$"memory={(hasMemory ? memory.ToString() : "-")} screen={Screen.width}x{Screen.height} band=({minW:0}-{maxW:0} x {minH:0}-{maxH:0})";
break;
}
case "chronicle_tab":
case "lore_tab":
{
string wantRaw = (cmd.value ?? cmd.label ?? "world").Trim().ToLowerInvariant();
ChronicleHud.LoreTab want = ChronicleHud.LoreTab.World;
if (wantRaw == "fallen" || wantRaw == "dead")
{
want = ChronicleHud.LoreTab.Fallen;
}
else if (wantRaw == "living"
|| wantRaw == "characters"
|| wantRaw == "character"
|| wantRaw == "chars"
|| wantRaw == "history")
{
want = ChronicleHud.LoreTab.Living;
}
pass = Chronicle.HudVisible && ChronicleHud.ActiveTab == want;
detail =
$"hud={Chronicle.HudVisible} tab={ChronicleHud.ActiveTab} want={want} recent={Chronicle.RecentFocusCount}";
break;
}
case "fallen_list_top":
{
string want = (cmd.value ?? cmd.label ?? "").Trim();
string have = ChronicleHud.LastListTopTitle ?? "";
pass = Chronicle.HudVisible
&& ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen
&& ChronicleHud.DetailUnitId == 0
&& !string.IsNullOrEmpty(want)
&& have.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0;
detail = $"top='{have}' want='{want}' tab={ChronicleHud.ActiveTab}";
break;
}
case "lore_list_stale":
{
bool want = ParseBool(cmd.value, defaultValue: true);
if (string.IsNullOrEmpty(cmd.value) && !string.IsNullOrEmpty(cmd.expect)
&& cmd.expect != "lore_list_stale")
{
want = ParseBool(cmd.expect, defaultValue: true);
}
bool have = ChronicleHud.CastListStale;
bool castTab = ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Living
|| ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen;
pass = Chronicle.HudVisible && castTab && ChronicleHud.DetailUnitId == 0 && have == want;
detail =
$"stale={have} want={want} tab={ChronicleHud.ActiveTab} revision={Chronicle.HistoryRevision}";
break;
}
case "lore_recent":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int have = Chronicle.RecentFocusCount;
pass = have >= want;
detail = $"recent={have} min={want}";
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 bool CaptureHarnessScreenshot(string fileName)
{
try
{
string dir = HarnessDir();
if (string.IsNullOrEmpty(dir))
{
return false;
}
Directory.CreateDirectory(dir);
string name = string.IsNullOrEmpty(fileName) ? "hud.png" : fileName;
if (!name.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
{
name += ".png";
}
string path = Path.Combine(dir, name);
// Absolute path required so Unity does not write under the game cwd.
ScreenCapture.CaptureScreenshot(path);
LastScreenshotPath = path;
string cropPath = Path.Combine(
dir,
Path.GetFileNameWithoutExtension(name) + "-dossier.png");
if (ModClass.Instance != null)
{
ModClass.Instance.StartCoroutine(CaptureDossierCropEndOfFrame(cropPath));
}
LogService.LogInfo(
$"[IdleSpectator][HARNESS] screenshot queued path={path} crop={cropPath} layoutOk={WatchCaption.LastLayoutOk} size={WatchCaption.LastPanelSize}");
return true;
}
catch (Exception ex)
{
LastScreenshotPath = "";
LogService.LogInfo("[IdleSpectator][HARNESS] screenshot failed: " + ex.Message);
return false;
}
}
private static IEnumerator CaptureDossierCropEndOfFrame(string cropPath)
{
yield return new WaitForEndOfFrame();
try
{
if (!WatchCaption.TryGetScreenPixelRect(out Rect rect))
{
LogService.LogInfo("[IdleSpectator][HARNESS] dossier crop skipped: no visible dossier rect");
yield break;
}
int x = Mathf.Clamp(Mathf.FloorToInt(rect.xMin), 0, Screen.width - 1);
int y = Mathf.Clamp(Mathf.FloorToInt(rect.yMin), 0, Screen.height - 1);
int w = Mathf.Clamp(Mathf.CeilToInt(rect.width), 1, Screen.width - x);
int h = Mathf.Clamp(Mathf.CeilToInt(rect.height), 1, Screen.height - y);
if (w < 16 || h < 16)
{
LogService.LogInfo($"[IdleSpectator][HARNESS] dossier crop skipped: tiny rect {w}x{h}");
yield break;
}
Texture2D tex = new Texture2D(w, h, TextureFormat.RGB24, false);
tex.ReadPixels(new Rect(x, y, w, h), 0, 0);
tex.Apply();
byte[] png = tex.EncodeToPNG();
UnityEngine.Object.Destroy(tex);
File.WriteAllBytes(cropPath, png);
LogService.LogInfo(
$"[IdleSpectator][HARNESS] dossier crop written path={cropPath} rect=({x},{y},{w},{h}) screen={Screen.width}x{Screen.height}");
}
catch (Exception ex)
{
LogService.LogInfo("[IdleSpectator][HARNESS] dossier crop failed: " + ex.Message);
}
}
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");
}