worldbox-observer-mod/IdleSpectator/AgentHarness.cs

4229 lines
160 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_milestone":
{
string kind = !string.IsNullOrEmpty(cmd.value)
? cmd.value
: (!string.IsNullOrEmpty(cmd.label) ? cmd.label : "kill");
string other = cmd.expect ?? "";
bool ok = Chronicle.ForceLifeMilestoneOnFocus(kind, other);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
long id = Chronicle.CurrentHistorySubjectId();
Emit(cmd, ok, detail:
$"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)}");
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 ?? "";
string speciesId = cmd.value ?? "";
string place = "";
string carrying = "";
// Optional: label "wheat@Riverhold" encodes carrying@place for unload tests.
if (!string.IsNullOrEmpty(cmd.label) && cmd.label.IndexOf('@') >= 0
&& (key.IndexOf("Unload", System.StringComparison.OrdinalIgnoreCase) >= 0
|| key.IndexOf("Throw", System.StringComparison.OrdinalIgnoreCase) >= 0))
{
string[] parts = cmd.label.Split(new[] { '@' }, 2);
if (parts.Length == 2)
{
carrying = parts[0].Trim();
place = parts[1].Trim();
}
}
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,
speciesId,
place,
carrying))
{
okN++;
}
}
WatchCaption.ForceRefreshHistory();
if (okN > 0)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, okN > 0, detail: $"forced={okN}/{n} key={key} activity={ActivityLog.CountFor(id)}");
break;
}
case "dossier_force_job":
{
string job = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "farmer";
string reason = cmd.label ?? "";
bool ok = WatchCaption.ForceJobLabelOnFocus(job, reason);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
string shown = WatchCaption.Current?.ReasonLine ?? "";
Emit(cmd, ok, detail: $"job='{job}' reasonOverride='{reason}' row='{shown}'");
break;
}
case "dossier_clear_job":
{
WatchCaption.ClearHarnessJobOverrides();
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
WatchCaption.SetFromActor(MoveCamera._focus_unit);
}
_cmdOk++;
Emit(cmd, true, detail: "job overrides cleared");
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_discovery_asset":
{
string assetId = ResolveAssetId(cmd);
bool want = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
bool have = SpeciesDiscovery.IsPending(assetId);
pass = have == want;
detail = $"asset={assetId} pending={have} want={want} total={SpeciesDiscovery.PendingCount}";
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 "dossier_history_not_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}' excludes='{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_log_not_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 = $"absent={!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 "activity_prose_alive":
{
// Generic tasks must read as living diary lines, not "{name} wanders".
string actorName = "Dorraa";
ActivityProse.Format(
ActivityKind.TaskStart,
"random_move",
ActivityContext.ForHarness(actorName, "", "buffalo"),
"Wandering",
11,
1,
out string moveLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"wait",
ActivityContext.ForHarness(actorName, "", "buffalo"),
"Waiting",
12,
1,
out string waitLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"city_idle_walking",
ActivityContext.ForHarness(actorName, "", "human", place: "Riverhold", jobId: "farmer"),
"Walking",
13,
1,
out string walkLine,
out _);
bool moveOk = !string.IsNullOrEmpty(moveLine)
&& moveLine.Length >= 28
&& !moveLine.Equals(actorName + " wanders", System.StringComparison.Ordinal)
&& (moveLine.IndexOf("wander", System.StringComparison.OrdinalIgnoreCase) >= 0
|| moveLine.IndexOf("drift", System.StringComparison.OrdinalIgnoreCase) >= 0
|| moveLine.IndexOf("roam", System.StringComparison.OrdinalIgnoreCase) >= 0
|| moveLine.IndexOf("ambl", System.StringComparison.OrdinalIgnoreCase) >= 0
|| moveLine.IndexOf("trudg", System.StringComparison.OrdinalIgnoreCase) >= 0
|| moveLine.IndexOf("move", System.StringComparison.OrdinalIgnoreCase) >= 0);
bool waitOk = !string.IsNullOrEmpty(waitLine)
&& waitLine.Length >= 18
&& !waitLine.Equals(actorName + " waits", System.StringComparison.Ordinal);
bool walkOk = !string.IsNullOrEmpty(walkLine)
&& walkLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0
&& walkLine.IndexOf("farmer", System.StringComparison.OrdinalIgnoreCase) < 0
&& walkLine.IndexOf(", a ", System.StringComparison.OrdinalIgnoreCase) < 0
&& walkLine.Length >= 28;
pass = moveOk && waitOk && walkOk;
detail =
$"moveOk={moveOk} waitOk={waitOk} walkOk={walkOk} "
+ $"move='{moveLine}' wait='{waitLine}' walk='{walkLine}'";
break;
}
case "activity_prose_species":
{
// Species stays on the dossier nametag. Lines must diverge by manner / taxonomy
// without spelling the species noun in the activity text.
string actorName = "Tester";
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
actorName = MoveCamera._focus_unit.getName() ?? "Tester";
}
}
catch
{
actorName = "Tester";
}
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(actorName, "", "dog"),
"Playing",
1,
1,
out string dogLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(actorName, "", "cat"),
"Playing",
2,
1,
out string catLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(actorName, "", "human", place: "Riverhold"),
"Playing",
3,
1,
out string humanLine,
out _);
ActivityProse.Format(
ActivityKind.ActionBeat,
"BehUnloadResources",
ActivityContext.ForHarness(
actorName,
"",
"human",
place: "Riverhold",
carrying: "wheat"),
"Unloads wheat",
4,
1,
out string unloadLine,
out _);
// Species must never leak into the place clause (wild kingdoms often = species id).
ActivityContext buffaloBadPlace = ActivityContext.ForHarness(
"Dorraa",
"",
"buffalo",
place: "buffalo");
buffaloBadPlace.PlaceLabel = ActivityInterestTable.PickPlaceLabel(
"buffalo",
"",
"buffalo",
"buffalo");
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
buffaloBadPlace,
"Playing",
5,
1,
out string buffaloLine,
out _);
ActivityContext buffaloGoodPlace = ActivityContext.ForHarness(
"Dorraa",
"",
"buffalo",
place: "Riverhold");
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
buffaloGoodPlace,
"Playing",
6,
1,
out string buffaloTownLine,
out _);
bool dogOk = !string.IsNullOrEmpty(dogLine)
&& dogLine.Length >= 16
&& dogLine.IndexOf(" the dog", System.StringComparison.OrdinalIgnoreCase) < 0;
bool catOk = !string.IsNullOrEmpty(catLine)
&& catLine.Length >= 16
&& catLine.IndexOf(" the cat", System.StringComparison.OrdinalIgnoreCase) < 0;
bool humanOk = !string.IsNullOrEmpty(humanLine)
&& !humanLine.Equals(dogLine, System.StringComparison.Ordinal)
&& !humanLine.Equals(catLine, System.StringComparison.Ordinal)
&& humanLine.IndexOf(" the human", System.StringComparison.OrdinalIgnoreCase) < 0;
bool unloadOk = !string.IsNullOrEmpty(unloadLine)
&& unloadLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0
&& unloadLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0
&& unloadLine.IndexOf("while carrying", System.StringComparison.OrdinalIgnoreCase) < 0;
bool distinct = !dogLine.Equals(catLine, System.StringComparison.Ordinal);
bool placeRejectOk = !string.IsNullOrEmpty(buffaloLine)
&& buffaloLine.IndexOf(" in buffalo", System.StringComparison.OrdinalIgnoreCase) < 0
&& buffaloLine.IndexOf(" the buffalo", System.StringComparison.OrdinalIgnoreCase) < 0;
// Play is not place-centric - city must not appear on play lines.
bool placeKeepOk = !string.IsNullOrEmpty(buffaloTownLine)
&& buffaloTownLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0
&& humanLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0;
ActivityProse.Format(
ActivityKind.ActionBeat,
"BehSocializeTalk",
ActivityContext.ForHarness(actorName, "Tomi", "human", place: "Celuford"),
"Talks",
11,
1,
out string socialLine,
out _);
bool socialBindOk = !string.IsNullOrEmpty(socialLine)
&& socialLine.IndexOf("Tomi", System.StringComparison.OrdinalIgnoreCase) >= 0
&& socialLine.IndexOf("with another", System.StringComparison.OrdinalIgnoreCase) < 0
&& socialLine.IndexOf("nearby company", System.StringComparison.OrdinalIgnoreCase) < 0;
int withTomi = 0;
int withIdx = 0;
while (withIdx >= 0)
{
withIdx = socialLine.IndexOf(
"with Tomi",
withIdx,
System.StringComparison.OrdinalIgnoreCase);
if (withIdx < 0)
{
break;
}
withTomi++;
withIdx += "with Tomi".Length;
}
socialBindOk = socialBindOk && withTomi <= 1;
// Taxonomy kingdoms like "insect" must never become place clauses.
string insectPlace = ActivityInterestTable.PickPlaceLabel(
"insect",
"",
"insect",
"beetle");
ActivityContext beetleCtx = ActivityContext.ForHarness("Eeeew", "", "beetle", place: insectPlace);
beetleCtx.PlaceLabel = insectPlace;
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
beetleCtx,
"Playing",
7,
1,
out string beetleLine,
out _);
bool insectRejectOk = string.IsNullOrEmpty(insectPlace)
&& !string.IsNullOrEmpty(beetleLine)
&& beetleLine.IndexOf(" in insect", System.StringComparison.OrdinalIgnoreCase) < 0
&& beetleLine.IndexOf(" the beetle", System.StringComparison.OrdinalIgnoreCase) < 0;
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness("Creaker", "", "frog"),
"Playing",
8,
1,
out string frogLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"child_random_jump",
ActivityContext.ForHarness("Creaker", "", "frog"),
"Jumping",
9,
1,
out string frogJumpLine,
out _);
bool frogOk = !string.IsNullOrEmpty(frogLine)
&& frogLine.Length >= 16
&& frogLine.IndexOf(" the frog", System.StringComparison.OrdinalIgnoreCase) < 0
&& (frogLine.IndexOf("hop", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogLine.IndexOf("leap", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogLine.IndexOf("bounc", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogLine.IndexOf("splash", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogLine.IndexOf("play", System.StringComparison.OrdinalIgnoreCase) >= 0);
bool frogJumpOk = !string.IsNullOrEmpty(frogJumpLine)
&& frogJumpLine.Length >= 16
&& frogJumpLine.IndexOf(" the frog", System.StringComparison.OrdinalIgnoreCase) < 0
&& frogJumpLine.IndexOf("young", System.StringComparison.OrdinalIgnoreCase) >= 0;
// Live ActorAsset taxonomy - not curated id maps.
ActivityContext frogCtx = ActivityContext.ForHarness("Creaker", "", "frog");
ActivityContext dogCtxLive = ActivityContext.ForHarness(actorName, "", "dog");
bool liveTaxOk = ActivityAssetCatalog.IsLiveActorAssetId("frog")
&& frogCtx.Voice.BaseBodyTag == ActivityBodyTag.Amphibian
&& frogCtx.TaxonomicClass.Equals(
"amphibia",
System.StringComparison.OrdinalIgnoreCase)
&& dogCtxLive.Family == ActivityVoiceFamilies.Canine
&& dogCtxLive.TaxonomicFamily.Equals(
"canidae",
System.StringComparison.OrdinalIgnoreCase);
// Species with no polish override still gets taxonomy manner without naming itself.
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness("Horn", "", "rhino"),
"Playing",
10,
1,
out string rhinoLine,
out _);
bool rhinoOk = !string.IsNullOrEmpty(rhinoLine)
&& rhinoLine.Length >= 16
&& rhinoLine.IndexOf(" the rhino", System.StringComparison.OrdinalIgnoreCase) < 0;
pass = dogOk && catOk && humanOk && unloadOk && distinct && placeRejectOk && placeKeepOk
&& insectRejectOk && frogOk && frogJumpOk && liveTaxOk && rhinoOk && socialBindOk;
detail =
$"dogOk={dogOk} catOk={catOk} humanOk={humanOk} unloadOk={unloadOk} distinct={distinct} "
+ $"placeRejectOk={placeRejectOk} placeKeepOk={placeKeepOk} insectRejectOk={insectRejectOk} "
+ $"frogOk={frogOk} frogJumpOk={frogJumpOk} liveTaxOk={liveTaxOk} rhinoOk={rhinoOk} "
+ $"socialBindOk={socialBindOk} "
+ $"frogFamily={frogCtx.Family} frogClass={frogCtx.TaxonomicClass} "
+ $"dogFamily={dogCtxLive.Family} dogTaxFamily={dogCtxLive.TaxonomicFamily} "
+ $"dog='{dogLine}' cat='{catLine}' human='{humanLine}' unload='{unloadLine}' "
+ $"buffalo='{buffaloLine}' buffaloTown='{buffaloTownLine}' beetle='{beetleLine}' "
+ $"frog='{frogLine}' frogJump='{frogJumpLine}' rhino='{rhinoLine}' social='{socialLine}'";
break;
}
case "activity_taxonomy_audit":
{
ActivityVoiceAuditResult audit = ActivityVoiceHarness.RunAudit(HarnessDir());
pass = audit.Passed;
detail = audit.Detail;
break;
}
case "activity_prose_showcase":
{
// Taxonomy must map to real buckets - never fantasy kingdoms -> monster.
string[] speciesIds = { "zombie", "angle", "lemon_snail", "dragon", "fairy" };
string[] wantFamily =
{
ActivityVoiceFamilies.Mammal,
ActivityVoiceFamilies.Mythic,
ActivityVoiceFamilies.Plant,
ActivityVoiceFamilies.Mythic,
ActivityVoiceFamilies.Mythic
};
ActivityBodyTag[] wantManner =
{
ActivityBodyTag.Undead,
ActivityBodyTag.Mythic,
ActivityBodyTag.Plant,
ActivityBodyTag.Dragon,
ActivityBodyTag.Insect
};
var verbs = new (string key, string fact, bool withTarget)[]
{
("task_unit_play", "Playing", false),
("move", "Moving", false),
("task_hunt", "Hunting", true),
("task_sleep", "Sleeping", false),
("task_eat", "Eating", false),
("laugh", "Laughing", false),
("task_attack", "Fighting", true)
};
var dump = new StringBuilder(2048);
int lineNo = 0;
int okLines = 0;
int leakLines = 0;
int classOk = 0;
for (int s = 0; s < speciesIds.Length; s++)
{
string sid = speciesIds[s];
ActivityContext probe = ActivityContext.ForHarness("Tester", "", sid);
bool mapped = probe.Family == wantFamily[s]
&& probe.Voice.EffectiveActionTag == wantManner[s];
if (mapped)
{
classOk++;
}
dump.AppendLine(
"=== " + sid
+ " label='" + probe.SpeciesLabel + "'"
+ " tax='" + probe.TaxonomicKingdom + "'"
+ " family=" + probe.Family
+ " manner=" + probe.MannerTag
+ " wantFamily=" + wantFamily[s]
+ " wantManner=" + wantManner[s]
+ " mapped=" + mapped
+ " live=" + ActivityAssetCatalog.IsLiveActorAssetId(sid)
+ " ===");
for (int v = 0; v < verbs.Length; v++)
{
string key = verbs[v].key;
string fact = verbs[v].fact;
bool withTarget = verbs[v].withTarget;
lineNo++;
string target = withTarget ? "Prey" : "";
ActivityContext ctx = ActivityContext.ForHarness("Tester", target, sid);
ActivityProse.Format(
ActivityKind.TaskStart,
key,
ctx,
fact,
9000 + s,
lineNo,
out string plain,
out _);
bool leak = !string.IsNullOrEmpty(probe.SpeciesLabel)
&& plain.IndexOf(
" the " + probe.SpeciesLabel,
StringComparison.OrdinalIgnoreCase) >= 0;
if (leak)
{
leakLines++;
}
else if (!string.IsNullOrEmpty(plain) && plain.Length >= 8)
{
okLines++;
}
dump.AppendLine(" " + key + ": " + plain);
Debug.Log(
"[IdleSpectator][SHOWCASE] " + sid + "|" + key + "|"
+ probe.MannerTag + "|" + plain);
}
}
int kingdomFail = 0;
string kingdomFailSample = "";
List<string> actors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
for (int i = 0; i < actors.Count; i++)
{
ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(actors[i]);
if (asset == null || asset.civ)
{
continue;
}
string tax = asset.name_taxonomic_kingdom ?? "";
if (string.IsNullOrEmpty(tax))
{
continue;
}
string fam = ActivityVoiceResolver.Resolve(asset).BaseFamily;
bool fantasyKingdom =
tax.Equals("plantae", StringComparison.OrdinalIgnoreCase)
|| tax.Equals("fungi", StringComparison.OrdinalIgnoreCase)
|| tax.Equals("protista", StringComparison.OrdinalIgnoreCase)
|| tax.Equals("machina", StringComparison.OrdinalIgnoreCase)
|| tax.Equals("mythoria", StringComparison.OrdinalIgnoreCase)
|| tax.Equals("gregalia", StringComparison.OrdinalIgnoreCase);
if (fantasyKingdom && fam == ActivityVoiceFamilies.Default)
{
kingdomFail++;
if (kingdomFailSample.Length < 180)
{
if (kingdomFailSample.Length > 0)
{
kingdomFailSample += "; ";
}
kingdomFailSample += actors[i] + ":" + tax + "->" + fam;
}
}
}
string path = Path.Combine(HarnessDir(), "activity-prose-showcase.txt");
try
{
File.WriteAllText(path, dump.ToString());
}
catch (Exception ex)
{
Debug.LogWarning("[IdleSpectator][SHOWCASE] write failed: " + ex.Message);
}
pass = okLines >= 20 && leakLines == 0 && classOk == speciesIds.Length && kingdomFail == 0;
detail = "okLines=" + okLines
+ " leakLines=" + leakLines
+ " classOk=" + classOk
+ " kingdomFail=" + kingdomFail
+ " kingdomFailSample='" + kingdomFailSample + "'"
+ " path=" + path;
break;
}
case "activity_prose_grounding":
{
// Same verb, different observed clauses must diverge and mention those facts.
const string verb = "BehUnloadResources";
ActivityProse.Format(
ActivityKind.ActionBeat,
verb,
ActivityContext.ForHarness(
"Mira",
"",
"human",
place: "Riverhold",
carrying: "wheat",
jobId: "farmer",
traitId: "curious",
isChild: true),
"Unloads wheat",
101,
1,
out string farmerLine,
out _);
ActivityProse.Format(
ActivityKind.ActionBeat,
verb,
ActivityContext.ForHarness(
"Mira",
"",
"human",
place: "Ironford",
carrying: "stone",
jobId: "miner",
traitId: "brave"),
"Unloads stone",
102,
1,
out string minerLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(
"Mira",
"Barkley",
"human",
place: "Riverhold",
jobId: "farmer",
isKing: true,
inCombat: true),
"Playing",
103,
1,
out string kingCombatLine,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_hunt",
ActivityContext.ForHarness(
"Gogith",
"Boortkin",
"human",
place: "Thasthuk",
isKing: true,
inCombat: true),
"Hunting",
107,
1,
out string huntCombatLine,
out _);
bool farmerOk = !string.IsNullOrEmpty(farmerLine)
&& farmerLine.IndexOf("young", System.StringComparison.OrdinalIgnoreCase) >= 0
&& farmerLine.IndexOf("farmer", System.StringComparison.OrdinalIgnoreCase) < 0
&& farmerLine.IndexOf(", a ", System.StringComparison.OrdinalIgnoreCase) < 0
&& farmerLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0
&& farmerLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0
&& farmerLine.IndexOf("while carrying", System.StringComparison.OrdinalIgnoreCase) < 0
&& farmerLine.IndexOf(" the human", System.StringComparison.OrdinalIgnoreCase) < 0
&& farmerLine.IndexOf("curious", System.StringComparison.OrdinalIgnoreCase) < 0;
bool minerOk = !string.IsNullOrEmpty(minerLine)
&& minerLine.IndexOf("miner", System.StringComparison.OrdinalIgnoreCase) < 0
&& minerLine.IndexOf(", a ", System.StringComparison.OrdinalIgnoreCase) < 0
&& minerLine.IndexOf("Ironford", System.StringComparison.OrdinalIgnoreCase) >= 0
&& minerLine.IndexOf("stone", System.StringComparison.OrdinalIgnoreCase) >= 0
&& minerLine.IndexOf("while carrying", System.StringComparison.OrdinalIgnoreCase) < 0
&& minerLine.IndexOf("brave", System.StringComparison.OrdinalIgnoreCase) < 0;
bool divergeOk = !farmerLine.Equals(minerLine, System.StringComparison.Ordinal);
// Quiet kings: role stays off Activity; combat / target pressure still surfaces.
bool kingOk = !string.IsNullOrEmpty(kingCombatLine)
&& kingCombatLine.IndexOf("king", System.StringComparison.OrdinalIgnoreCase) < 0
&& kingCombatLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0
&& (kingCombatLine.IndexOf("combat", System.StringComparison.OrdinalIgnoreCase) >= 0
|| kingCombatLine.IndexOf("keeping", System.StringComparison.OrdinalIgnoreCase) >= 0
|| kingCombatLine.IndexOf("Barkley", System.StringComparison.OrdinalIgnoreCase) >= 0);
bool huntPressureOk = !string.IsNullOrEmpty(huntCombatLine)
&& huntCombatLine.IndexOf("Boortkin", System.StringComparison.OrdinalIgnoreCase) >= 0
&& huntCombatLine.IndexOf("Thasthuk", System.StringComparison.OrdinalIgnoreCase) < 0
&& huntCombatLine.IndexOf("king", System.StringComparison.OrdinalIgnoreCase) < 0
&& huntCombatLine.IndexOf(
"under combat pressure",
System.StringComparison.OrdinalIgnoreCase) < 0;
// Taxonomy manner divergence without SpeciesOverrides (rhino vs beetle vs frog).
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness("A", "", "beetle"),
"Playing",
104,
1,
out string beetlePlay,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness("B", "", "frog"),
"Playing",
105,
1,
out string frogPlay,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness("C", "", "rhino"),
"Playing",
106,
1,
out string rhinoPlay,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(
"Crob",
"",
"crab",
terrain: ActivityTerrain.Land),
"Playing",
108,
1,
out string crabLandPlay,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(
"Pip",
"",
"piranha",
terrain: ActivityTerrain.Land),
"Playing",
109,
1,
out string fishLandPlay,
out _);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ActivityContext.ForHarness(
"Pip",
"",
"piranha",
terrain: ActivityTerrain.Liquid),
"Playing",
109,
1,
out string fishWaterPlay,
out _);
bool taxDiverge = !string.IsNullOrEmpty(beetlePlay)
&& !string.IsNullOrEmpty(frogPlay)
&& !string.IsNullOrEmpty(rhinoPlay)
&& !beetlePlay.Equals(frogPlay, System.StringComparison.Ordinal)
&& !frogPlay.Equals(rhinoPlay, System.StringComparison.Ordinal)
&& beetlePlay.IndexOf(" the beetle", System.StringComparison.OrdinalIgnoreCase) < 0
&& frogPlay.IndexOf(" the frog", System.StringComparison.OrdinalIgnoreCase) < 0
&& rhinoPlay.IndexOf(" the rhino", System.StringComparison.OrdinalIgnoreCase) < 0
&& (beetlePlay.IndexOf("leg", System.StringComparison.OrdinalIgnoreCase) >= 0
|| beetlePlay.IndexOf("rocks", System.StringComparison.OrdinalIgnoreCase) >= 0
|| beetlePlay.IndexOf("wing", System.StringComparison.OrdinalIgnoreCase) >= 0)
&& (frogPlay.IndexOf("hop", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogPlay.IndexOf("bounc", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogPlay.IndexOf("pops", System.StringComparison.OrdinalIgnoreCase) >= 0
|| frogPlay.IndexOf("toe", System.StringComparison.OrdinalIgnoreCase) >= 0)
&& (rhinoPlay.IndexOf("horn", System.StringComparison.OrdinalIgnoreCase) >= 0
|| rhinoPlay.IndexOf("stout", System.StringComparison.OrdinalIgnoreCase) >= 0
|| rhinoPlay.IndexOf("buck", System.StringComparison.OrdinalIgnoreCase) >= 0
|| rhinoPlay.IndexOf("pivot", System.StringComparison.OrdinalIgnoreCase) >= 0);
bool crabLandOk = !string.IsNullOrEmpty(crabLandPlay)
&& crabLandPlay.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) < 0
&& crabLandPlay.IndexOf("wet", System.StringComparison.OrdinalIgnoreCase) < 0
&& (crabLandPlay.IndexOf("claw", System.StringComparison.OrdinalIgnoreCase) >= 0
|| crabLandPlay.IndexOf("pincer", System.StringComparison.OrdinalIgnoreCase) >= 0
|| crabLandPlay.IndexOf("side", System.StringComparison.OrdinalIgnoreCase) >= 0);
bool fishTerrainOk = !string.IsNullOrEmpty(fishLandPlay)
&& !string.IsNullOrEmpty(fishWaterPlay)
&& fishLandPlay.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) < 0
&& fishLandPlay.IndexOf("wet", System.StringComparison.OrdinalIgnoreCase) < 0
&& fishWaterPlay.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0;
pass = farmerOk && minerOk && divergeOk && kingOk && huntPressureOk && taxDiverge
&& crabLandOk && fishTerrainOk;
detail =
$"farmerOk={farmerOk} minerOk={minerOk} divergeOk={divergeOk} kingOk={kingOk} "
+ $"huntPressureOk={huntPressureOk} taxDiverge={taxDiverge} "
+ $"crabLandOk={crabLandOk} fishTerrainOk={fishTerrainOk} "
+ $"farmer='{farmerLine}' miner='{minerLine}' king='{kingCombatLine}' hunt='{huntCombatLine}' "
+ $"beetle='{beetlePlay}' frog='{frogPlay}' rhino='{rhinoPlay}' "
+ $"crabLand='{crabLandPlay}' fishLand='{fishLandPlay}' fishWater='{fishWaterPlay}'";
break;
}
case "activity_prose_library":
{
// Coverage from live libraries - not curated shortlists.
List<string> actors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
List<string> tasks = ActivityVerbMap.EnumerateLiveTaskIds();
int actorFail = 0;
int actorChecked = 0;
string actorFailSample = "";
for (int i = 0; i < actors.Count; i++)
{
string sid = actors[i];
ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(sid);
if (asset == null)
{
continue;
}
// Stratified sample: every 3rd asset, plus always dog/cat/frog/beetle/rhino/human.
bool force = sid == "dog" || sid == "cat" || sid == "frog" || sid == "beetle"
|| sid == "rhino" || sid == "human";
if (!force && (i % 3) != 0)
{
continue;
}
actorChecked++;
ActivityContext ctx = ActivityContext.ForHarness("Lib", "", sid);
ActivityProse.Format(
ActivityKind.TaskStart,
"task_unit_play",
ctx,
"Playing",
200 + i,
1,
out string line,
out _);
bool humanoid = ctx.IsCiv && ctx.IsHumanoid;
bool ok = !string.IsNullOrEmpty(line) && line.Length >= 18;
// Dossier shows species - activity lines must not spell "the {species}".
string label = string.IsNullOrEmpty(ctx.SpeciesLabel) ? sid : ctx.SpeciesLabel;
if (!string.IsNullOrEmpty(label))
{
ok = ok && line.IndexOf(" the " + label, System.StringComparison.OrdinalIgnoreCase) < 0;
}
if (humanoid)
{
ok = ok && line.IndexOf(" the human", System.StringComparison.OrdinalIgnoreCase) < 0;
}
if (!ok)
{
actorFail++;
if (string.IsNullOrEmpty(actorFailSample))
{
actorFailSample = sid + ":'" + line + "'";
}
else if (actorFail <= 6 && actorFailSample.Length < 220)
{
actorFailSample += "; " + sid + ":'" + line + "'";
}
}
}
int taskFail = 0;
int taskChecked = 0;
string taskFailSample = "";
// Prefer live task library; fall back to snapshot file ids if library empty.
List<string> taskIds = tasks;
if (taskIds.Count == 0)
{
try
{
string path = System.IO.Path.Combine(ModClass.ModFolder, ".harness", "actor_task_ids.txt");
if (System.IO.File.Exists(path))
{
foreach (string line in System.IO.File.ReadAllLines(path))
{
if (!string.IsNullOrEmpty(line))
{
taskIds.Add(line.Trim());
}
}
}
}
catch
{
// ignore
}
}
for (int i = 0; i < taskIds.Count; i++)
{
string tid = taskIds[i];
if (string.IsNullOrEmpty(tid))
{
continue;
}
taskChecked++;
if (!ActivityVerbMap.HasActionCore(tid))
{
taskFail++;
if (string.IsNullOrEmpty(taskFailSample))
{
taskFailSample = tid + ":no-core";
}
continue;
}
ActivityProse.Format(
ActivityKind.TaskStart,
tid,
ActivityContext.ForHarness("Lib", "", "dog", taskKey: tid),
tid,
300 + i,
1,
out string line,
out _);
bool ok = !string.IsNullOrEmpty(line)
&& line.Length >= 16
&& !line.Equals("Lib " + tid, System.StringComparison.OrdinalIgnoreCase)
&& line.IndexOf("Lib", System.StringComparison.Ordinal) >= 0;
// Reject bare gerund dead ends like "Lib laughing"
string[] parts = line.Split(new[] { ' ' }, 3);
if (parts.Length == 2 && parts[1].EndsWith("ing", System.StringComparison.OrdinalIgnoreCase))
{
ok = false;
}
if (!ok)
{
taskFail++;
if (taskFailSample.IndexOf(tid, System.StringComparison.Ordinal) < 0
&& taskFailSample.Length < 120)
{
taskFailSample += (string.IsNullOrEmpty(taskFailSample) ? "" : ";")
+ tid + ":'" + line + "'";
}
}
}
bool actorsOk = actors.Count >= 20 && actorFail == 0 && actorChecked >= 10;
bool tasksOk = taskChecked >= 50 && taskFail == 0;
pass = actorsOk && tasksOk;
detail =
$"actors={actors.Count} actorChecked={actorChecked} actorFail={actorFail} "
+ $"tasksLive={tasks.Count} taskChecked={taskChecked} taskFail={taskFail} "
+ $"actorFailSample='{actorFailSample}' taskFailSample='{taskFailSample}'";
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 "fallen_list_top_not":
{
string reject = (cmd.value ?? cmd.label ?? "").Trim();
string have = ChronicleHud.LastListTopTitle ?? "";
pass = Chronicle.HudVisible
&& ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen
&& ChronicleHud.DetailUnitId == 0
&& !string.IsNullOrEmpty(reject)
&& have.IndexOf(reject, System.StringComparison.OrdinalIgnoreCase) < 0;
detail = $"top='{have}' reject='{reject}' tab={ChronicleHud.ActiveTab}";
break;
}
case "fallen_list_before":
{
string newer = (cmd.value ?? "").Trim();
string older = (cmd.label ?? "").Trim();
List<ChronicleCharacterSummary> rows = Chronicle.ListCharacters(
max: 0,
scope: Chronicle.CharacterListScope.Fallen);
int newerIndex = -1;
int olderIndex = -1;
for (int i = 0; i < rows.Count; i++)
{
string title = rows[i]?.Title ?? "";
if (newerIndex < 0
&& title.IndexOf(newer, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
newerIndex = i;
}
if (olderIndex < 0
&& title.IndexOf(older, System.StringComparison.OrdinalIgnoreCase) >= 0)
{
olderIndex = i;
}
}
pass = Chronicle.HudVisible
&& ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen
&& !string.IsNullOrEmpty(newer)
&& !string.IsNullOrEmpty(older)
&& newerIndex >= 0
&& olderIndex >= 0
&& newerIndex < olderIndex;
detail =
$"newer='{newer}' newerIndex={newerIndex} older='{older}' olderIndex={olderIndex} rows={rows.Count}";
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");
}