12494 lines
454 KiB
C#
12494 lines
454 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 Actor _happinessPartner;
|
|
private static long _activitySubjectId;
|
|
private static bool _directorRunActive;
|
|
private static string _rememberedFocusKey = "";
|
|
private static string _rememberedTip = "";
|
|
private static long _rememberedRelatedId;
|
|
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, relationship interest feeds still Emit while <see cref="Busy"/>
|
|
/// (harness verification of live Harmony patches).
|
|
/// </summary>
|
|
public static bool ForceRelationshipFeeds;
|
|
|
|
/// <summary>
|
|
/// When true, event feeds/patches still Emit while <see cref="Busy"/>
|
|
/// for live verification (traits, WorldLog, wars, plots, books, meta, …).
|
|
/// </summary>
|
|
public static bool ForceLiveEventFeeds;
|
|
|
|
/// <summary>True when production feeds may register during harness Busy.</summary>
|
|
public static bool LiveFeedsAllowed =>
|
|
!Busy || ForceLiveEventFeeds || ForceRelationshipFeeds;
|
|
|
|
/// <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 "force_live_feeds":
|
|
{
|
|
bool on = ParseBool(cmd.value, defaultValue: true);
|
|
ForceLiveEventFeeds = on;
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"force_live_feeds={on}");
|
|
break;
|
|
}
|
|
|
|
case "drop_clear":
|
|
{
|
|
InterestDropLog.Clear();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: "drops_cleared");
|
|
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 score={InterestDirector.CurrentScoreLabel}");
|
|
break;
|
|
}
|
|
|
|
case "expire_grace":
|
|
{
|
|
InterestDirector.HarnessExpireInputGrace();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"in_grace={InterestDirector.InInputGrace}");
|
|
break;
|
|
}
|
|
|
|
case "remember_tip":
|
|
{
|
|
_rememberedTip = InterestDirector.CurrentCandidate?.Label
|
|
?? CameraDirector.LastWatchLabel
|
|
?? "";
|
|
bool okTip = !string.IsNullOrEmpty(_rememberedTip);
|
|
if (okTip)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, okTip, detail: $"tip='{_rememberedTip}'");
|
|
return;
|
|
}
|
|
case "remember_focus":
|
|
{
|
|
_rememberedFocusKey = FocusKey();
|
|
bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-"; if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}");
|
|
break;
|
|
}
|
|
case "remember_related":
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
long id = scene != null
|
|
? (scene.PairPartnerId != 0
|
|
? scene.PairPartnerId
|
|
: EventFeedUtil.SafeId(scene.RelatedUnit))
|
|
: 0;
|
|
_rememberedRelatedId = id;
|
|
bool okRel = id != 0;
|
|
if (okRel)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, okRel, detail: $"related={id}");
|
|
break;
|
|
}
|
|
|
|
case "spectator":
|
|
{
|
|
bool on = ParseBool(cmd.value, defaultValue: true);
|
|
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
|
// Manual idle toggle while Lore is open must not be undone by Lore close.
|
|
if (ChronicleHud.Visible)
|
|
{
|
|
ChronicleHud.NotifyManualIdleToggle();
|
|
}
|
|
|
|
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)
|
|
{
|
|
WatchCaption.ForceRefreshHistory();
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
long id = Chronicle.CurrentHistorySubjectId();
|
|
Emit(cmd, ok, detail:
|
|
$"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)} otherRows={WatchCaption.CountHistoryOtherRows()}");
|
|
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_force_headline":
|
|
{
|
|
string headline = !string.IsNullOrEmpty(cmd.value)
|
|
? cmd.value
|
|
: (!string.IsNullOrEmpty(cmd.label) ? cmd.label : "Zezqzo Tikzkek Tak (scorpion)");
|
|
WatchCaption.ForceNametagHeadline(headline);
|
|
_cmdOk++;
|
|
Emit(cmd, true, detail: $"headline='{WatchCaption.LastHeadline}' shown='{WatchCaption.ShownNameChipText}'");
|
|
break;
|
|
}
|
|
|
|
case "dossier_force_named_reason":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string name = "";
|
|
try
|
|
{
|
|
name = focus != null ? (focus.getName() ?? "") : "";
|
|
}
|
|
catch
|
|
{
|
|
name = "";
|
|
}
|
|
|
|
string clause = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "is fighting";
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, false, detail: "no_focus_name");
|
|
break;
|
|
}
|
|
|
|
// Optional related: label/expect name, else remembered happiness partner.
|
|
string relatedName = !string.IsNullOrEmpty(cmd.expect)
|
|
? cmd.expect.Trim()
|
|
: (!string.IsNullOrEmpty(cmd.label) ? cmd.label.Trim() : "");
|
|
long relatedId = 0;
|
|
Actor relatedActor = null;
|
|
if (string.Equals(relatedName, "auto", System.StringComparison.OrdinalIgnoreCase)
|
|
|| string.IsNullOrEmpty(relatedName))
|
|
{
|
|
relatedActor = _happinessPartner != null && _happinessPartner.isAlive()
|
|
? _happinessPartner
|
|
: null;
|
|
if (relatedActor != null)
|
|
{
|
|
relatedId = EventFeedUtil.SafeId(relatedActor);
|
|
relatedName = SafeName(relatedActor);
|
|
}
|
|
else
|
|
{
|
|
relatedName = "";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
relatedId = ActivityLog.ResolveLivingUnitIdByName(
|
|
relatedName,
|
|
EventFeedUtil.SafeId(focus));
|
|
}
|
|
|
|
string reason = name + " " + clause.Trim();
|
|
if (!string.IsNullOrEmpty(relatedName)
|
|
&& reason.IndexOf(relatedName, System.StringComparison.OrdinalIgnoreCase) < 0)
|
|
{
|
|
reason = reason + " " + relatedName;
|
|
}
|
|
|
|
bool ok = WatchCaption.ForceJobLabelOnFocus(
|
|
!string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "farmer",
|
|
reason,
|
|
relatedId,
|
|
relatedName);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"reason='{WatchCaption.Current?.ReasonLine}' relatedId={relatedId} clickable={WatchCaption.ReasonOtherClickable}");
|
|
break;
|
|
}
|
|
|
|
case "dossier_click_reason_other":
|
|
{
|
|
bool ok = WatchCaption.ClickReasonOther(out long otherId);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"clicked={ok} otherId={otherId} lore={ChronicleHud.DetailUnitId}");
|
|
break;
|
|
}
|
|
|
|
case "dossier_click_history_other":
|
|
{
|
|
WatchCaption.ForceRefreshHistory();
|
|
bool ok = WatchCaption.ClickFirstHistoryOtherRow(out long otherId);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"clicked={ok} otherId={otherId} otherRows={WatchCaption.CountHistoryOtherRows()} lore={ChronicleHud.DetailUnitId}");
|
|
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_age":
|
|
{
|
|
long id = WatchCaption.CurrentUnitId;
|
|
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
try
|
|
{
|
|
id = MoveCamera._focus_unit.getID();
|
|
}
|
|
catch
|
|
{
|
|
id = 0;
|
|
}
|
|
}
|
|
|
|
float seconds = 12f;
|
|
if (!string.IsNullOrEmpty(cmd.value)
|
|
&& float.TryParse(
|
|
cmd.value,
|
|
System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out float parsed)
|
|
&& parsed > 0f)
|
|
{
|
|
seconds = parsed;
|
|
}
|
|
|
|
int n = ActivityLog.HarnessAgeSubject(id, seconds);
|
|
WatchCaption.ForceRefreshHistory();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"aged={n} subject={id} seconds={seconds}");
|
|
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 "activity_status_reset":
|
|
{
|
|
long id = 0;
|
|
try
|
|
{
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
id = MoveCamera._focus_unit.getID();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
id = 0;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
id = WatchCaption.CurrentUnitId;
|
|
}
|
|
|
|
ActivityLog.ResetStatusCountersForSubject(id);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail:
|
|
$"subject={id} gains={ActivityLog.StatusGainsSinceClear} "
|
|
+ $"losses={ActivityLog.StatusLossesSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear}");
|
|
break;
|
|
}
|
|
|
|
case "status_apply":
|
|
{
|
|
// Prefer explicit asset / related target so pair invincible does not only hit Follow.
|
|
Actor focus = null;
|
|
if (!string.IsNullOrEmpty(cmd.asset) && cmd.asset != "auto")
|
|
{
|
|
focus = ResolveUnit(cmd.asset);
|
|
}
|
|
|
|
if (focus == null)
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning";
|
|
float timer = 0f;
|
|
if (!string.IsNullOrEmpty(cmd.label)
|
|
&& float.TryParse(cmd.label, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed))
|
|
{
|
|
timer = parsed;
|
|
}
|
|
|
|
bool expectBlocked = (cmd.expect ?? "").IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
bool optional = (cmd.expect ?? "").IndexOf("optional", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
bool applied = alive && StatusGameApi.TryAddStatus(focus, statusId, timer);
|
|
// Some units briefly refuse a status (immunity / race); one retry covers that class.
|
|
if (!applied && alive && !expectBlocked)
|
|
{
|
|
applied = StatusGameApi.TryAddStatus(focus, statusId, timer);
|
|
}
|
|
|
|
bool ok = expectBlocked ? !applied : applied;
|
|
if (optional)
|
|
{
|
|
// Seed-only second statuses: dossier only needs one chip for the assert.
|
|
ok = true;
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"status={statusId} applied={applied} expectBlocked={expectBlocked} optional={optional} alive={alive} "
|
|
+ $"gains={ActivityLog.StatusGainsSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear} "
|
|
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
|
|
break;
|
|
}
|
|
|
|
case "status_apply_nearby":
|
|
{
|
|
// value = status id; count = how many nearby living units to afflict (default 6).
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning";
|
|
float timer = 30f;
|
|
if (!string.IsNullOrEmpty(cmd.label)
|
|
&& float.TryParse(cmd.label, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedTimer))
|
|
{
|
|
timer = parsedTimer;
|
|
}
|
|
|
|
int want = Math.Max(1, cmd.count > 0 ? cmd.count : 6);
|
|
int applied = 0;
|
|
if (focus != null && focus.isAlive() && StatusGameApi.TryAddStatus(focus, statusId, timer))
|
|
{
|
|
applied++;
|
|
}
|
|
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (applied >= want)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (actor == null || actor == focus || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (StatusGameApi.TryAddStatus(actor, statusId, timer))
|
|
{
|
|
applied++;
|
|
}
|
|
}
|
|
|
|
bool ok = applied >= Math.Min(2, want);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(cmd, ok, detail: $"status={statusId} applied={applied}/{want}");
|
|
break;
|
|
}
|
|
|
|
case "status_remove":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning";
|
|
bool ok = focus != null && StatusGameApi.TryFinishStatus(focus, statusId);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"status={statusId} finished={ok} losses={ActivityLog.StatusLossesSinceClear} "
|
|
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
|
|
break;
|
|
}
|
|
|
|
case "hatch_emit":
|
|
{
|
|
Actor focus = ResolveUnit(
|
|
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (focus == null)
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
if (alive)
|
|
{
|
|
InterestFeeds.EmitHatch(focus, "harness");
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, alive, detail: alive ? EventReason.Hatch(focus) : "no unit");
|
|
break;
|
|
}
|
|
|
|
case "decision_emit":
|
|
{
|
|
// Live DeferredInterestFeed path (same as setDecisionCooldown patch).
|
|
Actor focus = ResolveUnit(
|
|
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (focus == null)
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
string decisionId = (cmd.expect ?? cmd.value ?? "sexual_reproduction_try").Trim();
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
if (!alive || string.IsNullOrEmpty(decisionId))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit or decision id");
|
|
break;
|
|
}
|
|
|
|
bool prevForce = ForceLiveEventFeeds;
|
|
ForceLiveEventFeeds = true;
|
|
try
|
|
{
|
|
DeferredInterestFeed.EmitDecision(decisionId, focus);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"emitted decision={decisionId} unit={SafeName(focus)}");
|
|
}
|
|
finally
|
|
{
|
|
ForceLiveEventFeeds = prevForce;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case "status_clear_all":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
bool ok = focus != null && StatusGameApi.TryFinishAll(focus);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"cleared={ok} losses={ActivityLog.StatusLossesSinceClear} "
|
|
+ $"active_count={StatusGameApi.CountActiveStatuses(focus)}");
|
|
break;
|
|
}
|
|
|
|
case "status_force_note":
|
|
{
|
|
long id = WatchCaption.CurrentUnitId;
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
if (id == 0 && focus != null)
|
|
{
|
|
try
|
|
{
|
|
id = focus.getID();
|
|
}
|
|
catch
|
|
{
|
|
id = 0;
|
|
}
|
|
}
|
|
|
|
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "zzz_harness_fake_status";
|
|
bool gained = (cmd.expect ?? "gain").IndexOf("loss", StringComparison.OrdinalIgnoreCase) < 0;
|
|
string actorName = "";
|
|
try
|
|
{
|
|
if (focus != null)
|
|
{
|
|
actorName = focus.getName() ?? "";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
actorName = "";
|
|
}
|
|
|
|
bool ok = ActivityLog.ForceStatusNote(id, statusId, gained, actorName);
|
|
WatchCaption.ForceRefreshHistory();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"forced status={statusId} gained={gained} id={id}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_reset":
|
|
{
|
|
long id = 0;
|
|
try
|
|
{
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
id = MoveCamera._focus_unit.getID();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
id = 0;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
id = WatchCaption.CurrentUnitId;
|
|
}
|
|
|
|
HappinessEventRouter.ResetCountersForSubject(id);
|
|
ActivityLog.ResetSampleCounters();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail:
|
|
$"subject={id} notes={HappinessEventRouter.NotesSinceClear} "
|
|
+ $"ring={HappinessEventRouter.RingWritesSinceClear}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_apply":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_ate";
|
|
int bonus = 0;
|
|
if (!string.IsNullOrEmpty(cmd.label)
|
|
&& int.TryParse(cmd.label, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedBonus))
|
|
{
|
|
bonus = parsedBonus;
|
|
}
|
|
|
|
bool expectBlocked = (cmd.expect ?? "").IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
bool applied = alive && HappinessGameApi.TryChangeHappiness(focus, effectId, bonus);
|
|
bool ok = expectBlocked ? !applied : applied;
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"effect={effectId} applied={applied} expectBlocked={expectBlocked} "
|
|
+ $"notes={HappinessEventRouter.NotesSinceClear} ring={HappinessEventRouter.RingWritesSinceClear} "
|
|
+ $"last={HappinessEventRouter.LastEffectId}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_force_note":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = 0;
|
|
try
|
|
{
|
|
if (focus != null)
|
|
{
|
|
id = focus.getID();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
id = 0;
|
|
}
|
|
|
|
// Prefer live focus over a stale dossier CurrentUnitId.
|
|
if (id == 0)
|
|
{
|
|
id = WatchCaption.CurrentUnitId;
|
|
}
|
|
|
|
string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_ate";
|
|
string related = cmd.label ?? "";
|
|
string actorName = "";
|
|
try
|
|
{
|
|
if (focus != null)
|
|
{
|
|
actorName = focus.getName() ?? "";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
actorName = "";
|
|
}
|
|
|
|
bool ok = ActivityLog.ForceHappinessNote(id, effectId, actorName, related);
|
|
Actor registerUnit = focus;
|
|
if (registerUnit == null && InterestDirector.CurrentCandidate != null)
|
|
{
|
|
registerUnit = InterestDirector.CurrentCandidate.FollowUnit;
|
|
}
|
|
|
|
if (registerUnit == null && id != 0)
|
|
{
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
try
|
|
{
|
|
if (actor != null && actor.getID() == id)
|
|
{
|
|
registerUnit = actor;
|
|
break;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if (registerUnit == null && MoveCamera.hasFocusUnit())
|
|
{
|
|
registerUnit = MoveCamera._focus_unit;
|
|
}
|
|
|
|
if (ok && registerUnit != null)
|
|
{
|
|
string label = string.IsNullOrEmpty(related)
|
|
? (actorName + " · " + effectId)
|
|
: (actorName + " mourns " + related);
|
|
InterestCandidate registered = InterestFeeds.RegisterHappinessSignal(
|
|
registerUnit, effectId, label, related);
|
|
if (registered == null)
|
|
{
|
|
ok = false;
|
|
}
|
|
}
|
|
else if (ok)
|
|
{
|
|
ok = false;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"forced happiness={effectId} related={related} id={id} unit={(registerUnit != null)} pending={InterestRegistry.PendingCount} count={InterestRegistry.Count}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_inject_all":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
if (!alive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no living focus");
|
|
break;
|
|
}
|
|
|
|
List<string> ids = ActivityAssetCatalog.EnumerateLiveHappinessIds();
|
|
int applied = 0;
|
|
int blocked = 0;
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
if (HappinessGameApi.TryChangeHappiness(focus, ids[i], 0))
|
|
{
|
|
applied++;
|
|
}
|
|
else
|
|
{
|
|
blocked++;
|
|
}
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
bool ok = ids.Count > 0 && applied + blocked == ids.Count;
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"live={ids.Count} applied={applied} blocked={blocked} "
|
|
+ $"notes={HappinessEventRouter.NotesSinceClear}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_remember_partner":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
|
_happinessPartner = focus;
|
|
bool ok = _happinessPartner != null;
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: ok ? $"partner={SafeName(_happinessPartner)}" : "no partner");
|
|
break;
|
|
}
|
|
|
|
case "happiness_bond_lovers":
|
|
{
|
|
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
Actor b = _happinessPartner;
|
|
bool ok = false;
|
|
try
|
|
{
|
|
if (a != null && b != null && a.isAlive() && b.isAlive() && a != b)
|
|
{
|
|
a.becomeLoversWith(b);
|
|
ok = true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
ok = false;
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"a={SafeName(a)} b={SafeName(b)} notes={HappinessEventRouter.NotesSinceClear}");
|
|
break;
|
|
}
|
|
|
|
case "relationship_set_parent":
|
|
{
|
|
Actor child = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
|
Actor parent = _happinessPartner;
|
|
bool ok = false;
|
|
string detail;
|
|
ForceRelationshipFeeds = true;
|
|
try
|
|
{
|
|
if (child != null && parent != null && child.isAlive() && parent.isAlive()
|
|
&& child != parent)
|
|
{
|
|
child.setParent1(parent);
|
|
ok = true;
|
|
detail = $"parent={SafeName(parent)} child={SafeName(child)}";
|
|
}
|
|
else
|
|
{
|
|
detail = "need focus child + remembered parent";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
detail = "exception: " + ex.Message;
|
|
ok = false;
|
|
}
|
|
finally
|
|
{
|
|
ForceRelationshipFeeds = false;
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: detail);
|
|
break;
|
|
}
|
|
|
|
case "happiness_bond_friends":
|
|
{
|
|
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
Actor b = _happinessPartner;
|
|
bool ok = false;
|
|
try
|
|
{
|
|
if (a != null && b != null && a.isAlive() && b.isAlive() && a != b)
|
|
{
|
|
a.setBestFriend(b, true);
|
|
b.setBestFriend(a, true);
|
|
ok = true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
ok = false;
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"a={SafeName(a)} b={SafeName(b)} notes={HappinessEventRouter.NotesSinceClear}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_kill_partner":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
Actor partner = _happinessPartner;
|
|
string err = "";
|
|
try
|
|
{
|
|
if (partner != null && partner.isAlive())
|
|
{
|
|
partner.die(true, AttackType.Divine);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
err = ex.Message ?? "die_exception";
|
|
}
|
|
|
|
bool ok = false;
|
|
try
|
|
{
|
|
ok = partner != null && !partner.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
ok = false;
|
|
}
|
|
|
|
// Keep the surviving focus so grief asserts resolve the right subject.
|
|
try
|
|
{
|
|
if (focus != null && focus.isAlive())
|
|
{
|
|
CameraDirector.FocusUnit(focus);
|
|
_activitySubjectId = focus.getID();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
WatchCaption.ForceRefreshHistory();
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"killed={SafeName(partner)} ok={ok}"
|
|
+ (string.IsNullOrEmpty(err) ? "" : " err=" + err));
|
|
break;
|
|
}
|
|
|
|
case "happiness_burst":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_started_war";
|
|
int n = Math.Max(1, cmd.count > 0 ? cmd.count : 64);
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
if (!alive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no living focus");
|
|
break;
|
|
}
|
|
|
|
float t0 = Time.realtimeSinceStartup;
|
|
int applied = 0;
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
if (HappinessGameApi.TryChangeHappiness(focus, effectId, 0))
|
|
{
|
|
applied++;
|
|
}
|
|
}
|
|
|
|
float ms = (Time.realtimeSinceStartup - t0) * 1000f;
|
|
HappinessEventRouter.FlushAggregatesNow();
|
|
bool ok = applied > 0 && ms < 250f;
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail:
|
|
$"effect={effectId} n={n} applied={applied} ms={ms:0.0} "
|
|
+ $"agg={HappinessEventRouter.AggregateSummariesSinceClear}");
|
|
break;
|
|
}
|
|
|
|
case "kill_focus":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
bool ok = false;
|
|
try
|
|
{
|
|
if (focus != null && focus.isAlive())
|
|
{
|
|
focus.die(true, AttackType.Age);
|
|
ok = !focus.isAlive();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
ok = false;
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"killed={ok}");
|
|
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();
|
|
// Offset so place/skull focus is distinct from following the killer.
|
|
Vector2 killerPos = focus.current_position;
|
|
deathPos = new Vector3(killerPos.x + 2.5f, killerPos.y, 0f);
|
|
}
|
|
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 && deathPos.sqrMagnitude > 0.0001f)
|
|
{
|
|
int tx = Mathf.FloorToInt(deathPos.x);
|
|
int ty = Mathf.FloorToInt(deathPos.y);
|
|
GraveMarkers.ForceAdd(
|
|
id,
|
|
name,
|
|
tx,
|
|
ty,
|
|
deathPos,
|
|
Chronicle.FormatGraveDeathSubtitle(id),
|
|
favorite);
|
|
}
|
|
|
|
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)} stacks={GraveMarkers.StackCount}");
|
|
break;
|
|
}
|
|
|
|
case "grave_clear":
|
|
{
|
|
GraveMarkers.Clear();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: "graves_cleared");
|
|
break;
|
|
}
|
|
|
|
case "grave_force":
|
|
{
|
|
// value=name or "A,B,C" (same tile), count=entries, tier="dx,dy" offset from focus/camera.
|
|
// Comma names / count>1 share one captured tile so a walking focus cannot split the stack.
|
|
string rawName = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "GraveDummy";
|
|
string[] named = rawName.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
for (int i = 0; i < named.Length; i++)
|
|
{
|
|
named[i] = named[i].Trim();
|
|
}
|
|
|
|
int n = named.Length > 1 ? named.Length : Math.Max(1, cmd.count);
|
|
Vector3 pos = Vector3.zero;
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
pos = MoveCamera._focus_unit.current_position;
|
|
}
|
|
else if (MoveCamera.instance != null)
|
|
{
|
|
pos = MoveCamera.instance.transform.position;
|
|
pos.z = 0f;
|
|
}
|
|
|
|
if (pos.sqrMagnitude < 0.0001f)
|
|
{
|
|
pos = new Vector3(48f, 48f, 0f);
|
|
}
|
|
|
|
int tx = Mathf.FloorToInt(pos.x);
|
|
int ty = Mathf.FloorToInt(pos.y);
|
|
if (!string.IsNullOrEmpty(cmd.tier) && cmd.tier.Contains(","))
|
|
{
|
|
string[] parts = cmd.tier.Split(',');
|
|
if (parts.Length >= 2
|
|
&& int.TryParse(parts[0].Trim(), out int ox)
|
|
&& int.TryParse(parts[1].Trim(), out int oy))
|
|
{
|
|
tx += ox;
|
|
ty += oy;
|
|
pos = new Vector3(tx + 0.5f, ty + 0.5f, 0f);
|
|
}
|
|
}
|
|
int before = GraveMarkers.EntryCountAt(tx, ty);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
string nm = named.Length > 1
|
|
? named[i]
|
|
: (n > 1 ? rawName + i : rawName);
|
|
long id = Chronicle.ForceOrphanHistory(
|
|
nm,
|
|
!string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "human",
|
|
1,
|
|
"Grave",
|
|
AttackType.Age,
|
|
0,
|
|
pos,
|
|
favorite: false);
|
|
GraveMarkers.ForceAdd(
|
|
id, nm, tx, ty, pos, Chronicle.FormatGraveDeathSubtitle(id), favorite: false);
|
|
}
|
|
|
|
int after = GraveMarkers.EntryCountAt(tx, ty);
|
|
bool gOk = after >= before + n && GraveMarkers.StackCount >= 1;
|
|
if (gOk)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
gOk,
|
|
detail: $"tile={tx},{ty} entries={after} stacks={GraveMarkers.StackCount} added={n}");
|
|
break;
|
|
}
|
|
|
|
case "grave_open":
|
|
{
|
|
Vector3 pos = Vector3.zero;
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
pos = MoveCamera._focus_unit.current_position;
|
|
}
|
|
else if (MoveCamera.instance != null)
|
|
{
|
|
pos = MoveCamera.instance.transform.position;
|
|
pos.z = 0f;
|
|
}
|
|
|
|
int tx = Mathf.FloorToInt(pos.x);
|
|
int ty = Mathf.FloorToInt(pos.y);
|
|
if (!string.IsNullOrEmpty(cmd.value) && cmd.value.Contains(","))
|
|
{
|
|
string[] parts = cmd.value.Split(',');
|
|
if (parts.Length >= 2
|
|
&& int.TryParse(parts[0].Trim(), out int px)
|
|
&& int.TryParse(parts[1].Trim(), out int py))
|
|
{
|
|
tx = px;
|
|
ty = py;
|
|
}
|
|
}
|
|
|
|
bool opened = ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: true);
|
|
if (opened)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
opened,
|
|
detail:
|
|
$"tile={tx},{ty} open={opened} scope={ChronicleHud.GraveStackScopeActive} detail={ChronicleHud.DetailUnitId} stacks={GraveMarkers.StackCount}");
|
|
break;
|
|
}
|
|
|
|
case "grave_search":
|
|
{
|
|
ChronicleHud.SetSearch(cmd.value ?? "");
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail:
|
|
$"query='{ChronicleHud.SearchText}' filtered={ChronicleHud.GraveStackFilteredCount} scope={ChronicleHud.GraveStackScopeActive}");
|
|
break;
|
|
}
|
|
|
|
case "grave_open_lore":
|
|
{
|
|
// value=name: search any stack. Else focus/camera tile, then current Lore grave scope.
|
|
string needle = (cmd.value ?? "").Trim();
|
|
int tx = 0;
|
|
int ty = 0;
|
|
long unitId = 0;
|
|
GraveMarkers.GraveStack stack = null;
|
|
GraveMarkers.GraveEntry pick = null;
|
|
|
|
if (!string.IsNullOrEmpty(needle)
|
|
&& GraveMarkers.TryFindEntryByName(needle, out pick, out stack))
|
|
{
|
|
tx = stack.TileX;
|
|
ty = stack.TileY;
|
|
unitId = pick.UnitId;
|
|
}
|
|
else
|
|
{
|
|
Vector3 pos = Vector3.zero;
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
pos = MoveCamera._focus_unit.current_position;
|
|
}
|
|
else if (MoveCamera.instance != null)
|
|
{
|
|
pos = MoveCamera.instance.transform.position;
|
|
pos.z = 0f;
|
|
}
|
|
|
|
tx = Mathf.FloorToInt(pos.x);
|
|
ty = Mathf.FloorToInt(pos.y);
|
|
if (!GraveMarkers.TryGetStack(tx, ty, out stack)
|
|
&& ChronicleHud.GraveStackScopeActive)
|
|
{
|
|
tx = ChronicleHud.GraveStackTileX;
|
|
ty = ChronicleHud.GraveStackTileY;
|
|
GraveMarkers.TryGetStack(tx, ty, out stack);
|
|
}
|
|
|
|
if (stack != null && stack.Entries.Count > 0)
|
|
{
|
|
pick = stack.Entries[stack.Entries.Count - 1];
|
|
unitId = pick.UnitId;
|
|
}
|
|
}
|
|
|
|
bool loreOk = false;
|
|
if (unitId != 0 && stack != null)
|
|
{
|
|
InspectUi.MarkGraveSession();
|
|
ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: true);
|
|
if (ChronicleHud.DetailUnitId != unitId)
|
|
{
|
|
ChronicleHud.OpenUnitHistory(
|
|
unitId, pauseIdle: true, followFocus: false, preferPlace: true);
|
|
}
|
|
|
|
loreOk = ChronicleHud.DetailUnitId == unitId
|
|
&& Chronicle.LastFallenFocusDetail == "place";
|
|
}
|
|
|
|
if (loreOk)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
loreOk,
|
|
detail:
|
|
$"tile={tx},{ty} unit={unitId} detail={ChronicleHud.DetailUnitId} fallenFocus={Chronicle.LastFallenFocusDetail} scope={ChronicleHud.GraveStackScopeActive}");
|
|
break;
|
|
}
|
|
|
|
case "grave_set_permanent":
|
|
{
|
|
// value=true|false; optional tier="tx,ty" else Lore grave scope / focus tile.
|
|
int tx = ChronicleHud.GraveStackTileX;
|
|
int ty = ChronicleHud.GraveStackTileY;
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
Vector3 p = MoveCamera._focus_unit.current_position;
|
|
int ftx = Mathf.FloorToInt(p.x);
|
|
int fty = Mathf.FloorToInt(p.y);
|
|
if (GraveMarkers.TryGetStack(ftx, fty, out _))
|
|
{
|
|
tx = ftx;
|
|
ty = fty;
|
|
}
|
|
}
|
|
|
|
if (!ChronicleHud.GraveStackScopeActive
|
|
&& !GraveMarkers.TryGetStack(tx, ty, out _)
|
|
&& MoveCamera.hasFocusUnit()
|
|
&& MoveCamera._focus_unit != null)
|
|
{
|
|
Vector3 p = MoveCamera._focus_unit.current_position;
|
|
tx = Mathf.FloorToInt(p.x);
|
|
ty = Mathf.FloorToInt(p.y);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(cmd.tier) && cmd.tier.Contains(","))
|
|
{
|
|
string[] parts = cmd.tier.Split(',');
|
|
if (parts.Length >= 2
|
|
&& int.TryParse(parts[0].Trim(), out int px)
|
|
&& int.TryParse(parts[1].Trim(), out int py))
|
|
{
|
|
tx = px;
|
|
ty = py;
|
|
}
|
|
}
|
|
|
|
bool want = ParseBool(
|
|
!string.IsNullOrEmpty(cmd.value) ? cmd.value : cmd.expect,
|
|
defaultValue: true);
|
|
bool setOk = GraveMarkers.TrySetStackPermanent(tx, ty, want);
|
|
if (setOk && ChronicleHud.GraveStackScopeActive)
|
|
{
|
|
// Refresh Keep chrome.
|
|
ChronicleHud.OpenGraveStack(tx, ty, pauseIdle: false);
|
|
}
|
|
|
|
if (setOk)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
setOk,
|
|
detail:
|
|
$"tile={tx},{ty} permanent={GraveMarkers.IsStackPermanent(tx, ty)} want={want}");
|
|
break;
|
|
}
|
|
|
|
case "grave_delete":
|
|
{
|
|
int before = GraveMarkers.StackCount;
|
|
bool delOk = ChronicleHud.DeleteActiveGraveStack();
|
|
bool gone = delOk
|
|
&& !ChronicleHud.GraveStackScopeActive
|
|
&& GraveMarkers.StackCount < before;
|
|
if (gone)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
gone,
|
|
detail:
|
|
$"deleted={delOk} stacks={GraveMarkers.StackCount} before={before} scope={ChronicleHud.GraveStackScopeActive} lore={Chronicle.HudVisible}");
|
|
break;
|
|
}
|
|
|
|
case "inspect_dismiss":
|
|
{
|
|
InspectUi.DismissAll();
|
|
bool cleared = !SpectatorMode.Active
|
|
&& !Chronicle.HudVisible
|
|
&& !WatchCaption.Visible
|
|
&& !ChronicleHud.GraveStackScopeActive
|
|
&& !InspectUi.GraveSessionActive;
|
|
if (cleared)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
cleared,
|
|
detail:
|
|
$"cleared={cleared} idle={SpectatorMode.Active} lore={Chronicle.HudVisible} dossier={WatchCaption.Visible} scope={ChronicleHud.GraveStackScopeActive} graveSession={InspectUi.GraveSessionActive}");
|
|
break;
|
|
}
|
|
|
|
case "lore_focus_killer":
|
|
{
|
|
bool kOk = ChronicleHud.FocusDetailKiller();
|
|
if (kOk)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
kOk,
|
|
detail: $"ok={kOk} fallenFocus={Chronicle.LastFallenFocusDetail} detail={ChronicleHud.DetailUnitId}");
|
|
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 "world_log_feed":
|
|
DoWorldLogFeed(cmd);
|
|
break;
|
|
|
|
case "domain_feed":
|
|
DoDomainFeed(cmd);
|
|
break;
|
|
|
|
case "interest_inject":
|
|
DoInterestInject(cmd);
|
|
break;
|
|
|
|
case "presentability_probe":
|
|
DoPresentabilityProbe(cmd);
|
|
break;
|
|
|
|
case "interest_force_session":
|
|
DoInterestForceSession(cmd);
|
|
break;
|
|
|
|
case "interest_combat_session":
|
|
DoInterestCombatSession(cmd);
|
|
break;
|
|
|
|
case "interest_war_session":
|
|
DoInterestWarSession(cmd);
|
|
break;
|
|
|
|
case "interest_earthquake_session":
|
|
DoInterestEarthquakeSession(cmd);
|
|
break;
|
|
|
|
case "interest_crisis_begin":
|
|
{
|
|
bool ok = StoryPlanner.HarnessBeginCrisisFromCurrent();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
CrisisChapter crisis = StoryPlanner.Crisis;
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: ok
|
|
? $"crisis={crisis?.Kind} phase={crisis?.Phase}"
|
|
: "no_crisis_tip");
|
|
break;
|
|
}
|
|
|
|
case "interest_crisis_end":
|
|
{
|
|
bool ok = StoryPlanner.HarnessForceCrisisEpilogue();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
CrisisChapter crisis = StoryPlanner.Crisis;
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: ok
|
|
? $"crisis={crisis?.Kind} phase={crisis?.Phase} epilogue=1"
|
|
: "no_crisis_signal");
|
|
break;
|
|
}
|
|
|
|
case "interest_plot_session":
|
|
DoInterestPlotSession(cmd);
|
|
break;
|
|
|
|
case "interest_family_session":
|
|
DoInterestFamilySession(cmd);
|
|
break;
|
|
|
|
case "interest_outbreak_session":
|
|
DoInterestOutbreakSession(cmd);
|
|
break;
|
|
|
|
case "combat_maintain_focus":
|
|
{
|
|
InterestDirector.HarnessMaintainCombatFocus();
|
|
// Prefer the candidate tip (Watch may debounce); LastWatchLabel can lag a hold.
|
|
string tip = InterestDirector.CurrentCandidate?.Label ?? CameraDirector.LastWatchLabel ?? "";
|
|
string focus = FocusLabel();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
|
|
break;
|
|
}
|
|
|
|
case "war_maintain_focus":
|
|
{
|
|
InterestDirector.HarnessMaintainWarFront();
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
string focus = FocusLabel();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
|
|
break;
|
|
}
|
|
|
|
case "plot_maintain_focus":
|
|
{
|
|
InterestDirector.HarnessMaintainPlotCell();
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
string focus = FocusLabel();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
|
|
break;
|
|
}
|
|
|
|
case "family_maintain_focus":
|
|
{
|
|
InterestDirector.HarnessMaintainFamilyPack();
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
string focus = FocusLabel();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
|
|
break;
|
|
}
|
|
|
|
case "outbreak_maintain_focus":
|
|
{
|
|
InterestDirector.HarnessMaintainStatusOutbreak();
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
string focus = FocusLabel();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}");
|
|
break;
|
|
}
|
|
|
|
case "combat_ensemble_escalate":
|
|
DoCombatEnsembleEscalate(cmd);
|
|
break;
|
|
|
|
case "war_ensemble_apply":
|
|
DoWarEnsembleApply(cmd);
|
|
break;
|
|
|
|
case "war_inject_theater_mass":
|
|
DoWarInjectTheaterMass(cmd);
|
|
break;
|
|
|
|
case "plot_ensemble_apply":
|
|
DoPlotEnsembleApply(cmd);
|
|
break;
|
|
|
|
case "family_ensemble_apply":
|
|
DoFamilyEnsembleApply(cmd);
|
|
break;
|
|
|
|
case "outbreak_ensemble_apply":
|
|
DoOutbreakEnsembleApply(cmd);
|
|
break;
|
|
|
|
case "combat_isolate_pair":
|
|
DoCombatIsolatePair(cmd);
|
|
break;
|
|
|
|
case "combat_wire_attack_sides":
|
|
DoCombatWireAttackSides(cmd);
|
|
break;
|
|
|
|
case "combat_wipe_sticky_side":
|
|
DoCombatWipeStickySide(cmd);
|
|
break;
|
|
|
|
case "combat_set_sticky_side_count":
|
|
DoCombatSetStickySideCount(cmd);
|
|
break;
|
|
|
|
case "combat_swap_attack_target":
|
|
DoCombatSwapAttackTarget(cmd);
|
|
break;
|
|
|
|
case "combat_kill_related":
|
|
DoCombatKillRelated(cmd);
|
|
break;
|
|
|
|
case "combat_probe_fighters":
|
|
DoCombatProbeFighters(cmd);
|
|
break;
|
|
|
|
case "combat_park_bystander_follow":
|
|
DoCombatParkBystanderFollow(cmd);
|
|
break;
|
|
|
|
case "interest_hijack_follow":
|
|
DoInterestHijackFollow(cmd);
|
|
break;
|
|
|
|
case "interest_variety_clear":
|
|
InterestVariety.Clear();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: "variety_cleared");
|
|
break;
|
|
|
|
case "interest_story_clear":
|
|
StoryPlanner.Clear();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: "story_cleared");
|
|
break;
|
|
|
|
case "interest_saga_clear":
|
|
LifeSagaRoster.Clear();
|
|
LifeSagaRail.Clear();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: "saga_cleared");
|
|
break;
|
|
|
|
case "interest_story_purge_leftovers":
|
|
StoryPlanner.PurgeCloserLeftovers();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"purged crisisActive={StoryPlanner.CrisisActive} softQuiet={StoryPlanner.SoftFillQuietActive}");
|
|
break;
|
|
|
|
case "story_resume_parked":
|
|
{
|
|
var board = new System.Collections.Generic.List<StoryArc>(4);
|
|
StoryPlanner.CopyBoard(board);
|
|
string id = "";
|
|
for (int i = 0; i < board.Count; i++)
|
|
{
|
|
if (board[i] != null && board[i].IsParked)
|
|
{
|
|
id = board[i].Id ?? "";
|
|
break;
|
|
}
|
|
}
|
|
|
|
bool ok = !string.IsNullOrEmpty(id) && StoryPlanner.TryResume(id);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok,
|
|
detail:
|
|
$"resumeOk={ok} id='{id}' watching='{StoryPlanner.Active?.Id}' board={StoryPlanner.BoardCount}");
|
|
break;
|
|
}
|
|
|
|
case "saga_prefer_focus":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = EventFeedUtil.SafeId(focus);
|
|
LifeSagaRoster.Tick(Time.unscaledTime);
|
|
bool ok = id != 0 && LifeSagaRoster.IsMc(id) && LifeSagaRoster.TogglePrefer(id);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok,
|
|
detail:
|
|
$"preferToggle id={id} onRoster={LifeSagaRoster.IsMc(id)} prefer={LifeSagaRoster.IsPrefer(id)}");
|
|
break;
|
|
}
|
|
|
|
case "saga_force_admit_focus":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = EventFeedUtil.SafeId(focus);
|
|
bool ok = LifeSagaRoster.HarnessForceAdmit(focus);
|
|
if (ok)
|
|
{
|
|
LifeSagaRoster.StampChapter(id, "Harness chapter", Time.unscaledTime);
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"admit id={id} roster={LifeSagaRoster.Count} on={ok}");
|
|
break;
|
|
}
|
|
|
|
case "interest_variety_note":
|
|
{
|
|
// Stamp the current (or pending needle in value) as the last shown variety arc.
|
|
InterestCandidate c = InterestDirector.CurrentCandidate;
|
|
string needle = (cmd.value ?? "").Trim();
|
|
if (!string.IsNullOrEmpty(needle))
|
|
{
|
|
var pending = new System.Collections.Generic.List<InterestCandidate>(64);
|
|
InterestRegistry.CopyPending(pending);
|
|
for (int i = 0; i < pending.Count; i++)
|
|
{
|
|
InterestCandidate p = pending[i];
|
|
if (p?.Key != null
|
|
&& p.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (c == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no candidate to note");
|
|
break;
|
|
}
|
|
|
|
InterestVariety.NoteSelection(c, updateMix: false);
|
|
InterestScoring.RecalcTotal(c);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"arc={InterestVariety.LastArcKey} streak={InterestVariety.ArcStreak} key={c.Key}");
|
|
break;
|
|
}
|
|
|
|
case "interest_variety_push_arc":
|
|
{
|
|
// value = arc key (or short "combat:duel"), label/count = times
|
|
string arc = (cmd.value ?? "").Trim();
|
|
if (!string.IsNullOrEmpty(arc) && !arc.StartsWith("arc:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
arc = "arc:" + arc;
|
|
}
|
|
|
|
int times = cmd.count > 0 ? cmd.count : ParseInt(cmd.label, 8);
|
|
if (string.IsNullOrEmpty(arc))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no arc");
|
|
break;
|
|
}
|
|
|
|
InterestVariety.HarnessPushArc(arc, times);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"pushed={arc} x{times} window={InterestVariety.ArcWindowCount}");
|
|
break;
|
|
}
|
|
|
|
case "scoring_reload":
|
|
{
|
|
bool ok = InterestScoringConfig.Reload();
|
|
bool catalogOk = EventCatalogConfig.Reload();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok: ok,
|
|
detail: ok
|
|
? $"loaded v={InterestScoringConfig.LoadedVersion} path={InterestScoringConfig.LoadedPath} "
|
|
+ $"eventCatalog={catalogOk} decisions={EventCatalogConfig.DecisionCount}"
|
|
: "scoring_reload_failed");
|
|
break;
|
|
}
|
|
|
|
case "event_catalog_reload":
|
|
{
|
|
bool ok = EventCatalogConfig.Reload();
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok: ok,
|
|
detail: ok
|
|
? $"loaded v={EventCatalogConfig.LoadedVersion} path={EventCatalogConfig.LoadedPath}"
|
|
: "event_catalog_reload_failed");
|
|
break;
|
|
}
|
|
|
|
case "interest_variety_seed":
|
|
{
|
|
// value = "events:chars" e.g. "2:10"
|
|
string raw = (cmd.value ?? "").Trim();
|
|
int events = 0;
|
|
int chars = 0;
|
|
int colon = raw.IndexOf(':');
|
|
if (colon >= 0)
|
|
{
|
|
int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out events);
|
|
int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out chars);
|
|
}
|
|
else
|
|
{
|
|
int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out events);
|
|
}
|
|
|
|
InterestVariety.HarnessSeedMix(events, chars);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"seeded events={events} chars={chars} share={InterestVariety.EventShare():0.##} samples={InterestVariety.MixSampleCount}");
|
|
break;
|
|
}
|
|
|
|
case "interest_end_session":
|
|
InterestDirector.HarnessEndCurrent(cmd.value ?? "harness");
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"ended key={InterestDirector.CurrentKey} interrupted={InterestDirector.InterruptedKey}");
|
|
break;
|
|
|
|
case "interest_release_force":
|
|
InterestDirector.HarnessReleaseForceActive();
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"released key={InterestDirector.CurrentKey} active={InterestDirector.CurrentIsActive}");
|
|
break;
|
|
|
|
case "combat_disengage_pair":
|
|
{
|
|
// Clear fight signals without force-cold - proves natural CombatStillActive release.
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
Actor a = scene?.FollowUnit;
|
|
Actor b = scene?.RelatedUnit;
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
ClearAttackTarget(a);
|
|
ClearAttackTarget(b);
|
|
TryClearCombatTask(a);
|
|
TryClearCombatTask(b);
|
|
}
|
|
|
|
// Drop LastSeenAt so hysteresis cannot bridge a cleared scrap; live fighting
|
|
// still refreshes hot via IsCombatParticipant on the next tick.
|
|
if (scene != null)
|
|
{
|
|
scene.LastSeenAt = Time.unscaledTime - 60f;
|
|
}
|
|
|
|
bool aFight = LiveEnsemble.IsCombatParticipant(a);
|
|
bool bFight = LiveEnsemble.IsCombatParticipant(b);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"disengaged a={SafeName(a)}:{aFight} b={SafeName(b)}:{bFight} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
|
|
break;
|
|
}
|
|
|
|
case "combat_spark_assets":
|
|
{
|
|
// Wire two species near the camera into a scrap without stamping the current scene.
|
|
try
|
|
{
|
|
string sideA = string.IsNullOrEmpty(cmd.asset) ? "bear" : cmd.asset.Trim();
|
|
string sideB = string.IsNullOrEmpty(cmd.value) ? "crocodile" : cmd.value.Trim();
|
|
Vector3 origin = CameraPos();
|
|
Actor a = WorldActivityScanner.FindNearestAliveUnit(origin, 80f, sideA);
|
|
Actor b = WorldActivityScanner.FindNearestAliveUnit(origin, 80f, sideB);
|
|
if (b != null && a != null && b == a)
|
|
{
|
|
b = FindNearestAliveAsset(sideB, preferUnfocused: true, otherThan: a);
|
|
}
|
|
|
|
int wired = 0;
|
|
if (TrySetAttackTarget(a, b))
|
|
{
|
|
wired++;
|
|
}
|
|
|
|
if (TrySetAttackTarget(b, a))
|
|
{
|
|
wired++;
|
|
}
|
|
|
|
int stickyA = 0;
|
|
int stickyB = 0;
|
|
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
|
if (cur != null)
|
|
{
|
|
stickyA = cur.CombatSideACount;
|
|
stickyB = cur.CombatSideBCount;
|
|
}
|
|
|
|
bool ok = wired > 0;
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"sparked={wired} a={SafeName(a)}:{sideA} b={SafeName(b)}:{sideB} sceneSticky={stickyA}:{stickyB}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "exception: " + ex.GetType().Name + ": " + ex.Message);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case "interest_mark_combat_cold":
|
|
{
|
|
// Drop attack/combat signals so live path cannot refresh hot; force-cold gates the hold.
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene != null)
|
|
{
|
|
ClearAttackTarget(scene.FollowUnit);
|
|
ClearAttackTarget(scene.RelatedUnit);
|
|
TryClearCombatTask(scene.FollowUnit);
|
|
TryClearCombatTask(scene.RelatedUnit);
|
|
}
|
|
|
|
InterestDirector.HarnessMarkCombatCold();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"combat_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
|
|
break;
|
|
}
|
|
|
|
case "interest_mark_sticky_cold":
|
|
case "story_mark_climax_cold":
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene != null)
|
|
{
|
|
ClearAttackTarget(scene.FollowUnit);
|
|
ClearAttackTarget(scene.RelatedUnit);
|
|
TryClearCombatTask(scene.FollowUnit);
|
|
TryClearCombatTask(scene.RelatedUnit);
|
|
}
|
|
|
|
InterestDirector.HarnessMarkStickySceneCold();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"sticky_cold key={InterestDirector.CurrentKey} tip='{CameraDirector.LastWatchLabel}' active={InterestDirector.CurrentIsActive}");
|
|
break;
|
|
}
|
|
|
|
case "interest_mark_inactive":
|
|
{
|
|
float ago = ParseFloat(cmd.value, 1f);
|
|
InterestDirector.HarnessMarkInactive(ago);
|
|
// Rebuild dossier so orange reason clears under quiet_grace / inactive dwell.
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
WatchCaption.SetFromActor(MoveCamera._focus_unit);
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey} reason='{WatchCaption.Current?.ReasonLine}'");
|
|
break;
|
|
}
|
|
|
|
case "interest_drop_follow":
|
|
{
|
|
// Null Follow only - keep SubjectId/Related for story cast recovery tests.
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_scene");
|
|
break;
|
|
}
|
|
|
|
long keepSubject = scene.SubjectId != 0
|
|
? scene.SubjectId
|
|
: EventFeedUtil.SafeId(scene.FollowUnit);
|
|
scene.FollowUnit = null;
|
|
if (scene.SubjectId == 0 && keepSubject != 0)
|
|
{
|
|
scene.SubjectId = keepSubject;
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"dropped_follow subjectId={scene.SubjectId} relatedId={scene.RelatedId}");
|
|
break;
|
|
}
|
|
|
|
case "interest_clear_follow":
|
|
{
|
|
Actor related = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (related == null)
|
|
{
|
|
related = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
// Prefer a different living unit than the current follow.
|
|
Actor follow = InterestDirector.CurrentCandidate?.FollowUnit;
|
|
if (related != null && follow != null && related == follow)
|
|
{
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor != null && actor != follow)
|
|
{
|
|
try
|
|
{
|
|
if (actor.isAlive())
|
|
{
|
|
related = actor;
|
|
break;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessClearFollowForHandoff(related);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"cleared related={SafeName(related)} key={InterestDirector.CurrentKey}");
|
|
break;
|
|
}
|
|
|
|
case "camera_clear_focus":
|
|
{
|
|
// Simulate vanilla clearing follow mid-idle (death / spectator desync).
|
|
CameraDirector.ClearFollow();
|
|
bool cleared = !MoveCamera.hasFocusUnit()
|
|
|| MoveCamera._focus_unit == null
|
|
|| !MoveCamera._focus_unit.isAlive();
|
|
if (cleared)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok: cleared, detail: $"cleared={cleared} idle={SpectatorMode.Active}");
|
|
break;
|
|
}
|
|
|
|
case "interest_expire_pending":
|
|
{
|
|
string needle = cmd.value ?? "";
|
|
InterestRegistry.ForceExpireContaining(needle, Time.unscaledTime);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"expired needle='{needle}' pending={InterestRegistry.PendingCount}");
|
|
break;
|
|
}
|
|
|
|
case "interest_feeds_tick":
|
|
{
|
|
int drained = InterestFeeds.HarnessDrainOnce();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"drained={drained} cursor={InterestFeeds.HappinessCursor} pending={InterestRegistry.PendingCount}");
|
|
break;
|
|
}
|
|
|
|
case "interest_peek_next":
|
|
{
|
|
InterestCandidate peek = InterestDirector.HarnessPeekNext();
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: peek == null
|
|
? "peek=null"
|
|
: $"peek={peek.Key} lead={peek.LeadKind} score={peek.TotalScore:0.#} label={peek.Label}");
|
|
break;
|
|
}
|
|
|
|
case "interest_civic_boost":
|
|
{
|
|
string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_started_war";
|
|
string kingdom = !string.IsNullOrEmpty(cmd.label) ? cmd.label : "harness";
|
|
float amount = ParseFloat(cmd.expect, 40f);
|
|
InterestFeeds.HarnessStampCivicBoost(kingdom, effectId, amount);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"civic kingdom={kingdom} effect={effectId} amt={amount:0.#}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_suppress":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
if (focus == null && InterestDirector.CurrentCandidate != null)
|
|
{
|
|
focus = InterestDirector.CurrentCandidate.FollowUnit;
|
|
}
|
|
|
|
if (focus == null)
|
|
{
|
|
focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "death_child";
|
|
if (focus == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit for happiness_suppress");
|
|
break;
|
|
}
|
|
|
|
HappinessEventRouter.PublishSuppressed(focus, effectId, HappinessSourceHook.Harness);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"suppressed={effectId} unit={SafeName(focus)} seq={HappinessEventRouter.CurrentSequence}");
|
|
break;
|
|
}
|
|
|
|
case "happiness_remember_only":
|
|
{
|
|
// Remember a Signal for DrainSince without immediate Ingest/Register.
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
if (focus == null && InterestDirector.CurrentCandidate != null)
|
|
{
|
|
focus = InterestDirector.CurrentCandidate.FollowUnit;
|
|
}
|
|
|
|
if (focus == null)
|
|
{
|
|
focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "death_lover";
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no living unit for happiness_remember_only");
|
|
break;
|
|
}
|
|
|
|
HappinessOccurrence published = HappinessEventRouter.PublishApplied(
|
|
focus,
|
|
effectId,
|
|
HappinessGameApi.ResolveAmount(effectId),
|
|
HappinessSourceHook.Harness,
|
|
related: null,
|
|
role: HappinessRelationRole.None,
|
|
forceRing: true);
|
|
bool ok = published != null;
|
|
if (ok)
|
|
{
|
|
InterestFeeds.HarnessEnsureCursorBefore(published.Sequence);
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"remembered={effectId} seq={HappinessEventRouter.CurrentSequence} cursor={InterestFeeds.HappinessCursor} unit={SafeName(focus)}");
|
|
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)
|
|
{
|
|
// Free AFK after harness must not inherit a leftover crisis closer tip.
|
|
StoryPlanner.PurgeCloserLeftovers();
|
|
LifeSagaRoster.Clear();
|
|
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);
|
|
try
|
|
{
|
|
_activitySubjectId = unit.getID();
|
|
}
|
|
catch
|
|
{
|
|
_activitySubjectId = 0;
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f);
|
|
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed);
|
|
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
|
|
{
|
|
Score = tierEvt,
|
|
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;
|
|
}
|
|
|
|
float tierEvt = EventStrengthFromTierHint(cmd.tier, 40f);
|
|
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.CharacterLed);
|
|
ParseInterestInjectOptions(
|
|
cmd.value,
|
|
out bool forceIgnored,
|
|
out InterestLeadKind? leadOpt,
|
|
out float eventStrength,
|
|
out float charSigIgnored,
|
|
out float maxWatchIgnored,
|
|
out float ttlIgnored,
|
|
out bool resumableIgnored,
|
|
out int participantsIgnored,
|
|
out int notablesIgnored);
|
|
_ = forceIgnored;
|
|
_ = charSigIgnored;
|
|
_ = maxWatchIgnored;
|
|
_ = ttlIgnored;
|
|
_ = resumableIgnored;
|
|
_ = participantsIgnored;
|
|
_ = notablesIgnored;
|
|
InterestLeadKind lead = leadOpt ?? tierLead;
|
|
if (eventStrength >= 0f)
|
|
{
|
|
tierEvt = eventStrength;
|
|
}
|
|
|
|
string tipLabel = string.IsNullOrEmpty(cmd.label)
|
|
? $"Harness {cmd.tier ?? "scene"}"
|
|
: cmd.label.Replace("{asset}", follow.asset != null ? follow.asset.id : (assetId ?? ""));
|
|
|
|
InterestEvent interest = new InterestEvent
|
|
{
|
|
Score = tierEvt,
|
|
Position = follow.current_position,
|
|
FollowUnit = follow,
|
|
Label = tipLabel,
|
|
CreatedAt = Time.unscaledTime,
|
|
AssetId = follow.asset != null ? follow.asset.id : assetId
|
|
};
|
|
|
|
bool eventLedNotice = lead == InterestLeadKind.EventLed
|
|
&& tierEvt >= InterestScoringConfig.W.noticeScoreMin;
|
|
InterestCandidate registered = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
interest.Label,
|
|
keySuffix: (interest.Label ?? "scene").Replace(" ", "_"),
|
|
lead: lead,
|
|
completion: InterestCompletionKind.FixedDwell,
|
|
forceActive: false,
|
|
eventStrength: tierEvt,
|
|
maxWatch: tierEvt >= 95f ? 40f : 25f);
|
|
if (registered == null)
|
|
{
|
|
InterestCollector.EnqueueDirect(interest);
|
|
}
|
|
else if (eventLedNotice)
|
|
{
|
|
// Only force the camera when score-margin cut-in would allow it (matches director policy).
|
|
InterestScoring.ScoreCheap(registered);
|
|
float cur = InterestDirector.CurrentScore;
|
|
bool noCurrent = InterestDirector.CurrentCandidate == null;
|
|
bool marginCut = registered.TotalScore >= cur + InterestScoringConfig.W.cutInMargin;
|
|
if (noCurrent || marginCut)
|
|
{
|
|
InterestDirector.HarnessForceSession(registered);
|
|
CameraDirector.Watch(registered.ToInterestEvent());
|
|
}
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={InterestDirector.CurrentKey != null && InterestDirector.CurrentKey.IndexOf((interest.Label ?? "").Replace(" ", "_"), System.StringComparison.OrdinalIgnoreCase) >= 0}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Feed a live WorldLog asset id through InterestFeeds as if WorldLog fired (coverage of authored catalog).
|
|
/// </summary>
|
|
private static void DoWorldLogFeed(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
string assetId = string.IsNullOrEmpty(cmd.asset) ? (cmd.value ?? "") : cmd.asset;
|
|
if (string.IsNullOrEmpty(assetId) || assetId == "auto")
|
|
{
|
|
assetId = "disaster_tornado";
|
|
}
|
|
|
|
if (!EventCatalog.WorldLog.HasAuthored(assetId))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "not_authored:" + assetId);
|
|
return;
|
|
}
|
|
|
|
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(assetId);
|
|
Actor follow = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
if (follow == null && entry.CreatesInterest)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit for world_log_feed");
|
|
return;
|
|
}
|
|
|
|
if (!entry.CreatesInterest)
|
|
{
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"skipped_no_interest id={assetId}");
|
|
return;
|
|
}
|
|
|
|
string kingdom = follow.kingdom != null ? (follow.kingdom.name ?? "Alpha") : "Alpha";
|
|
string key = "worldlog:" + assetId + ":" + kingdom;
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = entry.Category,
|
|
Source = "worldlog",
|
|
EventStrength = entry.EventStrength,
|
|
VisualConfidence = 0.7f,
|
|
Position = follow.current_position,
|
|
FollowUnit = follow,
|
|
SubjectId = follow.getID(),
|
|
Label = entry.MakeLabel(kingdom, "Beta"),
|
|
AssetId = assetId,
|
|
KingdomKey = kingdom,
|
|
SpeciesId = follow.asset != null ? follow.asset.id : "",
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 40f,
|
|
MinWatch = entry.MinWatch,
|
|
MaxWatch = entry.MaxWatch,
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proves unpresentable Labels are rejected at Register (selection = presentation).
|
|
/// </summary>
|
|
private static void DoPresentabilityProbe(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor unit = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset)
|
|
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
if (unit == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit for presentability_probe");
|
|
return;
|
|
}
|
|
|
|
InterestDropLog.Clear();
|
|
string name = EventFeedUtil.SafeName(unit);
|
|
string mode = (cmd.value ?? "fall").Trim().ToLowerInvariant();
|
|
string label;
|
|
if (mode == "good" || mode == "presentable")
|
|
{
|
|
label = string.IsNullOrEmpty(cmd.label)
|
|
? EventReason.Fight(unit, null)
|
|
: cmd.label;
|
|
if (!label.StartsWith(name, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(name))
|
|
{
|
|
label = EventPresentability.EnsureSubjectLedLabel(unit, label);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Article-led fall shape that dossier blanks via identity_filter.
|
|
label = string.IsNullOrEmpty(cmd.label)
|
|
? ("A Maple Tree falls near " + (string.IsNullOrEmpty(name) ? "someone" : name))
|
|
: cmd.label;
|
|
}
|
|
|
|
string key = "probe:presentability:" + EventFeedUtil.SafeId(unit) + ":" + Time.unscaledTime.ToString("0.###");
|
|
InterestCandidate registered = EventFeedUtil.Register(
|
|
key,
|
|
"probe",
|
|
"Spectacle",
|
|
label,
|
|
"presentability_probe",
|
|
88f,
|
|
unit.current_position,
|
|
unit);
|
|
|
|
bool expectReject = mode != "good" && mode != "presentable";
|
|
bool ok;
|
|
string detail;
|
|
if (expectReject)
|
|
{
|
|
ok = registered == null && InterestDropLog.RecentContains("unpresentable");
|
|
detail = $"reject label='{label}' registered={registered != null} drops='{InterestDropLog.FormatRecent(4)}'";
|
|
}
|
|
else
|
|
{
|
|
ok = registered != null && EventPresentability.WouldShow(registered);
|
|
detail = $"accept label='{label}' registered={registered != null} key={(registered != null ? registered.Key : "")}";
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok: ok, detail: detail);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Injects a Signal from a domain catalog while the harness is Busy
|
|
/// (domain feeds themselves early-out on Busy, so register here).
|
|
/// asset = domain, value = event/asset id.
|
|
/// </summary>
|
|
private static void DoDomainFeed(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
string domain = (cmd.asset ?? "").Trim().ToLowerInvariant();
|
|
string id = (cmd.value ?? cmd.expect ?? "").Trim();
|
|
Actor unit = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
if (unit == null && domain != "era")
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit for domain_feed");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
DiscreteEventEntry entry;
|
|
string key;
|
|
string label;
|
|
float strength;
|
|
string category;
|
|
string source;
|
|
|
|
switch (domain)
|
|
{
|
|
case "relationship":
|
|
case "rel":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "set_lover";
|
|
}
|
|
|
|
entry = EventCatalog.Relationship.GetOrFallback(id);
|
|
Actor related = ActorRelation.ResolvePackRelated(unit)
|
|
?? WorldActivityScanner.FindNearestAliveUnit(unit.current_position, 40f);
|
|
if (related == unit)
|
|
{
|
|
related = null;
|
|
}
|
|
|
|
key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit) + ":"
|
|
+ EventFeedUtil.SafeId(related);
|
|
label = entry.MakeLabel(
|
|
EventFeedUtil.SafeName(unit),
|
|
EventFeedUtil.SafeName(related));
|
|
strength = entry.EventStrength;
|
|
category = entry.Category;
|
|
source = "relationship";
|
|
InterestCandidate relReg = EventFeedUtil.Register(
|
|
key,
|
|
source,
|
|
category,
|
|
label,
|
|
id,
|
|
strength,
|
|
unit.current_position,
|
|
unit,
|
|
related: related);
|
|
if (relReg == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "register_failed domain=relationship id=" + id);
|
|
return;
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"domain=relationship id={id} key={key}");
|
|
return;
|
|
case "plot":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "new_war";
|
|
}
|
|
|
|
entry = EventCatalog.Plot.GetOrFallback(id);
|
|
key = "plot:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit);
|
|
label = EventReason.Plot(unit, entry.LabelTemplate, "new", entry.Id);
|
|
strength = entry.EventStrength;
|
|
category = entry.Category;
|
|
source = "plot";
|
|
break;
|
|
case "era":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "age_chaos";
|
|
}
|
|
|
|
entry = EventCatalog.Era.GetOrFallback(id);
|
|
key = "era:" + entry.Id;
|
|
label = entry.MakeLabel("", "");
|
|
strength = entry.EventStrength;
|
|
category = entry.Category;
|
|
source = "era";
|
|
Vector3 eraPos = unit != null
|
|
? unit.current_position
|
|
: (Camera.main != null
|
|
? new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, 0f)
|
|
: new Vector3(1f, 1f, 0f));
|
|
InterestCandidate eraReg = EventFeedUtil.Register(
|
|
key,
|
|
source,
|
|
category,
|
|
label,
|
|
id,
|
|
strength,
|
|
eraPos,
|
|
follow: unit,
|
|
locationOnly: unit == null);
|
|
if (eraReg == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "register_failed domain=era id=" + id);
|
|
return;
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"domain=era id={id} key={key}");
|
|
return;
|
|
case "book":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "history_book";
|
|
}
|
|
|
|
entry = EventCatalog.Book.GetOrFallback(id);
|
|
key = "book:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit);
|
|
label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new");
|
|
strength = entry.EventStrength;
|
|
category = entry.Category;
|
|
source = "book";
|
|
break;
|
|
case "trait":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "immortal";
|
|
}
|
|
|
|
entry = EventCatalog.Trait.GetOrFallback(id);
|
|
key = "trait:" + entry.Id + ":gains:" + EventFeedUtil.SafeId(unit);
|
|
label = EventReason.Trait(unit, entry.Id, gained: true);
|
|
strength = entry.EventStrength;
|
|
category = entry.Category;
|
|
source = "trait";
|
|
break;
|
|
case "meta":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "new_city";
|
|
}
|
|
|
|
key = "meta:" + id + ":" + EventFeedUtil.SafeId(unit);
|
|
label = EventReason.MetaNew(unit, id);
|
|
strength = 74f;
|
|
category = "Politics";
|
|
source = "meta";
|
|
break;
|
|
case "boat":
|
|
id = "boat_unload";
|
|
key = "boat:boat_unload:" + EventFeedUtil.SafeId(unit);
|
|
label = EventReason.Boat(unit, "unload");
|
|
strength = 72f;
|
|
category = "Travel";
|
|
source = "boat";
|
|
break;
|
|
case "building":
|
|
id = "building_complete";
|
|
key = "building:complete:harness:" + EventFeedUtil.SafeId(unit);
|
|
label = EventReason.BuildingComplete(unit, "temple_human", spectacle: true);
|
|
strength = 86f;
|
|
category = "Settlement";
|
|
source = "building";
|
|
break;
|
|
case "spell":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "summon_lightning";
|
|
}
|
|
|
|
{
|
|
DiscreteEventEntry spellEntry = EventCatalog.Spell.GetOrFallback(id);
|
|
key = "spell:" + spellEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
|
// Match live DeferredInterestFeed (EventReason.Library), not raw template {id}.
|
|
label = EventReason.Library(unit, "spell", spellEntry.Id);
|
|
strength = spellEntry.EventStrength;
|
|
category = spellEntry.Category;
|
|
source = "spell";
|
|
id = spellEntry.Id;
|
|
}
|
|
|
|
break;
|
|
case "item":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "sword";
|
|
}
|
|
|
|
{
|
|
DiscreteEventEntry itemEntry = EventCatalog.Item.GetOrFallback(id);
|
|
key = "item:" + itemEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
|
label = itemEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
|
strength = itemEntry.EventStrength;
|
|
category = itemEntry.Category;
|
|
source = "item";
|
|
id = itemEntry.Id;
|
|
}
|
|
|
|
break;
|
|
case "decision":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "declare_war";
|
|
}
|
|
|
|
{
|
|
DiscreteEventEntry decEntry = EventCatalog.Decision.GetOrFallback(id);
|
|
key = "decision:" + decEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
|
label = decEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
|
strength = decEntry.EventStrength;
|
|
category = decEntry.Category;
|
|
source = "decision";
|
|
id = decEntry.Id;
|
|
}
|
|
|
|
break;
|
|
case "power":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "lightning";
|
|
}
|
|
|
|
{
|
|
DiscreteEventEntry powEntry = EventCatalog.Power.GetOrFallback(id);
|
|
key = "power:" + powEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
|
label = powEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
|
strength = powEntry.EventStrength;
|
|
category = powEntry.Category;
|
|
source = "power";
|
|
id = powEntry.Id;
|
|
}
|
|
|
|
break;
|
|
case "subspecies_trait":
|
|
case "subspecies":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "biome_trait";
|
|
}
|
|
|
|
{
|
|
DiscreteEventEntry stEntry = EventCatalog.SubspeciesTrait.GetOrFallback(id);
|
|
key = "subspecies_trait:" + stEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
|
label = stEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
|
strength = stEntry.EventStrength;
|
|
category = stEntry.Category;
|
|
source = "subspecies_trait";
|
|
id = stEntry.Id;
|
|
}
|
|
|
|
break;
|
|
case "gene":
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = "warfare_1";
|
|
}
|
|
|
|
{
|
|
DiscreteEventEntry geneEntry = EventCatalog.Gene.GetOrFallback(id);
|
|
key = "gene:" + geneEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
|
label = geneEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
|
strength = geneEntry.EventStrength;
|
|
category = geneEntry.Category;
|
|
source = "gene";
|
|
id = geneEntry.Id;
|
|
}
|
|
|
|
break;
|
|
default:
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "unknown domain=" + domain);
|
|
return;
|
|
}
|
|
|
|
InterestCandidate registered = EventFeedUtil.Register(
|
|
key,
|
|
source,
|
|
category,
|
|
label,
|
|
id,
|
|
strength,
|
|
unit.current_position,
|
|
unit);
|
|
if (registered == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "register_failed domain=" + domain + " id=" + id);
|
|
return;
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(cmd, ok: true, detail: $"domain={domain} id={id} key={key}");
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "domain_feed error: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
private static void DoInterestInject(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (follow == null)
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
if (follow == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit for interest_inject");
|
|
return;
|
|
}
|
|
|
|
// pick=other: use a different living unit (same asset when possible) for ledger/near-tie tests.
|
|
if (!string.IsNullOrEmpty(cmd.value)
|
|
&& cmd.value.IndexOf("pick=other", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
Actor other = FindOtherAliveUnit(follow, cmd.asset);
|
|
if (other != null)
|
|
{
|
|
follow = other;
|
|
}
|
|
}
|
|
|
|
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
|
|
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
|
|
string label = string.IsNullOrEmpty(cmd.label) ? ("Inject " + (cmd.tier ?? "scene")) : cmd.label;
|
|
label = EventPresentability.EnsureSubjectLedLabel(follow, label);
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? label.Replace(" ", "_") : cmd.expect;
|
|
ParseInterestInjectOptions(
|
|
cmd.value,
|
|
out bool force,
|
|
out InterestLeadKind? leadOpt,
|
|
out float eventStrength,
|
|
out float charSig,
|
|
out float maxWatch,
|
|
out float ttl,
|
|
out bool resumable,
|
|
out int participants,
|
|
out int notables);
|
|
|
|
InterestLeadKind lead = leadOpt ?? tierLead;
|
|
if (eventStrength < 0f)
|
|
{
|
|
eventStrength = tierEvt;
|
|
}
|
|
|
|
if (charSig < 0f)
|
|
{
|
|
charSig = lead == InterestLeadKind.CharacterLed ? eventStrength : 10f;
|
|
}
|
|
|
|
if (maxWatch < 0f)
|
|
{
|
|
maxWatch = 30f;
|
|
}
|
|
|
|
if (ttl < 0f)
|
|
{
|
|
ttl = 60f;
|
|
}
|
|
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead,
|
|
force ? InterestCompletionKind.Manual : InterestCompletionKind.FixedDwell,
|
|
force,
|
|
eventStrength,
|
|
charSig,
|
|
maxWatch,
|
|
ttl,
|
|
related: null,
|
|
resumable);
|
|
if (c == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "inject_failed");
|
|
return;
|
|
}
|
|
|
|
if (participants > 0 || notables > 0)
|
|
{
|
|
c.ParticipantCount = participants;
|
|
c.NotableParticipantCount = notables;
|
|
if (participants >= 2 || c.AssetId == "live_battle")
|
|
{
|
|
c.Category = "Combat";
|
|
c.Verb = "fighting";
|
|
// Keep FixedDwell/Manual for harness injects - units are not actually fighting.
|
|
if (!force)
|
|
{
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
}
|
|
}
|
|
|
|
InterestScoring.RecalcTotal(c);
|
|
}
|
|
|
|
ParseInjectSideCounts(cmd.value, out int sideA, out int sideB);
|
|
if (sideA > 0 || sideB > 0)
|
|
{
|
|
c.CombatSideACount = sideA;
|
|
c.CombatSideBCount = sideB;
|
|
c.CombatSideFrame = EnsembleFrame.SpeciesVsSpecies;
|
|
if (c.ParticipantCount < sideA + sideB)
|
|
{
|
|
c.ParticipantCount = sideA + sideB;
|
|
}
|
|
|
|
c.Category = "Combat";
|
|
InterestScoring.RecalcTotal(c);
|
|
}
|
|
|
|
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
|
|
|
|
if (force)
|
|
{
|
|
InterestDirector.HarnessForceSession(c);
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} sides={c.CombatSideACount}/{c.CombatSideBCount} pending={InterestRegistry.PendingCount}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Map harness expect tokens onto relationship AssetIds used by StoryPlanner classification.
|
|
/// </summary>
|
|
private static void ApplyHarnessRelationshipAssetHint(InterestCandidate c, string expect)
|
|
{
|
|
if (c == null || string.IsNullOrEmpty(expect))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("just_had_child", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("parenthood", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.HappinessEffectId = "just_had_child";
|
|
c.AssetId = "just_had_child";
|
|
c.Category = "LifeChapter";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("just_kissed", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("intimacy", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.HappinessEffectId = "just_kissed";
|
|
c.AssetId = "just_kissed";
|
|
c.Category = "Social";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("just_found_house", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("found_house", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("finds_a_home", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.HappinessEffectId = "just_found_house";
|
|
c.AssetId = "just_found_house";
|
|
c.Category = "LifeChapter";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("just_killed", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.AssetId = "milestone_kill";
|
|
c.HappinessEffectId = "just_killed";
|
|
c.Category = "Relationship";
|
|
c.Source = "chronicle";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("fallen_in_love", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("smitten", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.HappinessEffectId = "fallen_in_love";
|
|
c.AssetId = "fallen_in_love";
|
|
c.Category = "Social";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("sexual_reproduction", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("decision_intent", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.AssetId = "sexual_reproduction_try";
|
|
c.Source = "decision";
|
|
c.Category = "Politics";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
// Decision path when tagged; relationship seeking otherwise.
|
|
bool asDecision = expect.IndexOf("decision", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
c.AssetId = "find_lover";
|
|
c.HappinessEffectId = asDecision ? "" : "find_lover";
|
|
c.Source = asDecision ? "decision" : "relationship";
|
|
c.Category = asDecision ? "Politics" : "Relationship";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("building_complete", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("construction", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.AssetId = expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0
|
|
? "tent_human"
|
|
: "building_complete";
|
|
c.Source = "building";
|
|
c.Category = "Settlement";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("taking_roots", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("soft_status", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.StatusId = "taking_roots";
|
|
c.AssetId = "taking_roots";
|
|
c.Completion = InterestCompletionKind.StatusPhase;
|
|
c.Category = "Status";
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("just_slept", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("rest_life", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("ends a rest", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.HappinessEffectId = "just_slept";
|
|
c.AssetId = "just_slept";
|
|
c.Source = "happiness";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
c.Category = "Daily";
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("sleeping", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("falls asleep", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.StatusId = "sleeping";
|
|
c.AssetId = "sleeping";
|
|
c.Source = "status";
|
|
c.Completion = InterestCompletionKind.StatusPhase;
|
|
c.Category = "Status";
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("hatch", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.HappinessEffectId = "just_got_out_of_egg";
|
|
c.AssetId = "just_got_out_of_egg";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("king_dead", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("worldlog_meta", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.AssetId = "king_dead";
|
|
c.Source = "worldlog";
|
|
c.Category = "Politics";
|
|
c.Completion = InterestCompletionKind.FixedDwell;
|
|
return;
|
|
}
|
|
|
|
if (expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
c.AssetId = "set_lover";
|
|
c.Category = "Relationship";
|
|
}
|
|
}
|
|
|
|
private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset)
|
|
{
|
|
if (World.world?.units == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long excludeId = EventFeedUtil.SafeId(exclude);
|
|
string wantAsset = (preferAsset ?? "").Trim();
|
|
if (wantAsset == "auto")
|
|
{
|
|
wantAsset = "";
|
|
}
|
|
|
|
Actor fallback = null;
|
|
try
|
|
{
|
|
foreach (Actor a in World.world.units)
|
|
{
|
|
if (a == null || !a.isAlive() || EventFeedUtil.SafeId(a) == excludeId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string id = a.asset != null ? a.asset.id : "";
|
|
if (!string.IsNullOrEmpty(wantAsset)
|
|
&& string.Equals(id, wantAsset, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return a;
|
|
}
|
|
|
|
if (fallback == null)
|
|
{
|
|
fallback = a;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
private static void ParseInjectSideCounts(string raw, out int sideA, out int sideB)
|
|
{
|
|
sideA = 0;
|
|
sideB = 0;
|
|
if (string.IsNullOrEmpty(raw))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string[] parts = raw.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
string p = parts[i].Trim();
|
|
int eq = p.IndexOf('=');
|
|
if (eq <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string key = p.Substring(0, eq).Trim().ToLowerInvariant();
|
|
string val = p.Substring(eq + 1).Trim();
|
|
if (key == "sidea" || key == "a")
|
|
{
|
|
int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out sideA);
|
|
}
|
|
else if (key == "sideb" || key == "b")
|
|
{
|
|
int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out sideB);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ParseInterestInjectOptions(
|
|
string raw,
|
|
out bool force,
|
|
out InterestLeadKind? lead,
|
|
out float eventStrength,
|
|
out float charSig,
|
|
out float maxWatch,
|
|
out float ttl,
|
|
out bool resumable,
|
|
out int participants,
|
|
out int notables)
|
|
{
|
|
force = false;
|
|
lead = null;
|
|
eventStrength = -1f;
|
|
charSig = -1f;
|
|
maxWatch = -1f;
|
|
ttl = -1f;
|
|
resumable = true;
|
|
participants = 0;
|
|
notables = 0;
|
|
if (string.IsNullOrEmpty(raw))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string[] parts = raw.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
string p = parts[i].Trim();
|
|
if (p.Equals("true", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("force", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
force = true;
|
|
continue;
|
|
}
|
|
|
|
if (p.Equals("false", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
force = false;
|
|
continue;
|
|
}
|
|
|
|
int eq = p.IndexOf('=');
|
|
if (eq <= 0)
|
|
{
|
|
if (p.Equals("event", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("eventled", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
lead = InterestLeadKind.EventLed;
|
|
}
|
|
else if (p.Equals("character", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("char", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("characterled", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
lead = InterestLeadKind.CharacterLed;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
string key = p.Substring(0, eq).Trim().ToLowerInvariant();
|
|
string val = p.Substring(eq + 1).Trim();
|
|
switch (key)
|
|
{
|
|
case "force":
|
|
force = ParseBool(val, false);
|
|
break;
|
|
case "lead":
|
|
if (val.StartsWith("c", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
lead = InterestLeadKind.CharacterLed;
|
|
}
|
|
else
|
|
{
|
|
lead = InterestLeadKind.EventLed;
|
|
}
|
|
|
|
break;
|
|
case "evt":
|
|
case "event":
|
|
case "strength":
|
|
eventStrength = ParseFloat(val, eventStrength);
|
|
break;
|
|
case "char":
|
|
case "charsig":
|
|
charSig = ParseFloat(val, charSig);
|
|
break;
|
|
case "max":
|
|
case "maxwatch":
|
|
maxWatch = ParseFloat(val, maxWatch);
|
|
break;
|
|
case "ttl":
|
|
ttl = ParseFloat(val, ttl);
|
|
break;
|
|
case "resumable":
|
|
resumable = ParseBool(val, true);
|
|
break;
|
|
case "n":
|
|
case "participants":
|
|
case "fighters":
|
|
int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out participants);
|
|
break;
|
|
case "notables":
|
|
case "notable":
|
|
int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out notables);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void DoInterestForceSession(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (follow == null)
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
if (follow == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no unit for interest_force_session");
|
|
return;
|
|
}
|
|
|
|
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
|
|
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
|
|
ParseInterestInjectOptions(
|
|
cmd.value,
|
|
out bool forceIgnored,
|
|
out InterestLeadKind? leadOpt,
|
|
out float eventStrength,
|
|
out float charSigIgnored,
|
|
out float maxWatchIgnored,
|
|
out float ttlIgnored,
|
|
out bool resumableIgnored,
|
|
out int participantsIgnored,
|
|
out int notablesIgnored);
|
|
_ = forceIgnored;
|
|
_ = charSigIgnored;
|
|
_ = maxWatchIgnored;
|
|
_ = ttlIgnored;
|
|
_ = resumableIgnored;
|
|
_ = participantsIgnored;
|
|
_ = notablesIgnored;
|
|
InterestLeadKind lead = leadOpt ?? tierLead;
|
|
if (eventStrength < 0f)
|
|
{
|
|
eventStrength = tierEvt;
|
|
}
|
|
|
|
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label;
|
|
string focusName = SafeName(follow);
|
|
if (!string.IsNullOrEmpty(focusName))
|
|
{
|
|
label = label.Replace("{a}", focusName);
|
|
}
|
|
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: lead,
|
|
completion: InterestCompletionKind.Manual,
|
|
forceActive: true,
|
|
eventStrength: eventStrength > 0f ? eventStrength : 70f,
|
|
maxWatch: 60f);
|
|
if (c != null && lead == InterestLeadKind.CharacterLed)
|
|
{
|
|
c.Category = "CharacterVignette";
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} score={InterestDirector.CurrentScoreLabel} active={InterestDirector.CurrentIsActive}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force a CombatActive session with follow + related so handoff/tip rewrite can be asserted.
|
|
/// asset = follow, value = related asset (optional), label overrides Fight prose.
|
|
/// </summary>
|
|
private static void DoInterestCombatSession(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (follow == null)
|
|
{
|
|
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
if (follow == null)
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
Actor related = null;
|
|
if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto")
|
|
{
|
|
related = ResolveUnit(cmd.value);
|
|
}
|
|
|
|
if (related == null || related == follow)
|
|
{
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor != null && actor != follow && actor.isAlive())
|
|
{
|
|
related = actor;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no follow unit for interest_combat_session");
|
|
return;
|
|
}
|
|
|
|
string label = string.IsNullOrEmpty(cmd.label)
|
|
? EventReason.Combat(new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Scale = EnsembleScale.Pair,
|
|
Frame = EnsembleFrame.NamedPair,
|
|
ParticipantCount = related != null ? 2 : 1,
|
|
Focus = follow,
|
|
Related = related,
|
|
SideA = new EnsembleSide { Display = SafeName(follow), Count = 1, Best = follow },
|
|
SideB = related != null
|
|
? new EnsembleSide { Display = SafeName(related), Count = 1, Best = related }
|
|
: null
|
|
})
|
|
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.CombatActive,
|
|
forceActive: true,
|
|
eventStrength: 120f,
|
|
characterSignificance: 20f,
|
|
maxWatch: 60f,
|
|
ttlSeconds: 60f,
|
|
related: related,
|
|
resumable: true);
|
|
if (c != null)
|
|
{
|
|
c.Category = "Combat";
|
|
c.Verb = "fighting";
|
|
c.ClearCombatSticky();
|
|
c.StampCombatPair(follow, related);
|
|
if (related != null)
|
|
{
|
|
c.ParticipantCount = 2;
|
|
// Optional: label/expect may say no_attack so ownership asserts are not race-killed.
|
|
string meta = ((cmd.label ?? "") + " " + (cmd.expect ?? "")).ToLowerInvariant();
|
|
bool wireAttack = meta.IndexOf("no_attack", StringComparison.Ordinal) < 0;
|
|
if (wireAttack)
|
|
{
|
|
TrySetAttackTarget(follow, related);
|
|
TrySetAttackTarget(related, follow);
|
|
}
|
|
else
|
|
{
|
|
// Keep the pair alive through ownership asserts (fast-timing worlds are lethal).
|
|
StatusGameApi.TryAddStatus(follow, "invincible", 120f);
|
|
StatusGameApi.TryAddStatus(related, "invincible", 120f);
|
|
}
|
|
}
|
|
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force a WarFront sticky session with kingdom sides (synthetic keys when units lack civs).
|
|
/// </summary>
|
|
private static void DoInterestWarSession(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (follow == null)
|
|
{
|
|
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
if (follow == null)
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
Actor related = null;
|
|
if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto")
|
|
{
|
|
related = ResolveUnit(cmd.value);
|
|
}
|
|
|
|
if (related == null || related == follow)
|
|
{
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor != null && actor != follow && actor.isAlive())
|
|
{
|
|
related = actor;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no follow unit for interest_war_session");
|
|
return;
|
|
}
|
|
|
|
string keyA = LiveEnsemble.KingdomKeyOf(follow);
|
|
string keyB = related != null ? LiveEnsemble.KingdomKeyOf(related) : "";
|
|
if (string.IsNullOrEmpty(keyA))
|
|
{
|
|
keyA = "Essiona";
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(keyB) || keyB.Equals(keyA, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
keyB = "Northreach";
|
|
}
|
|
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.WarFront,
|
|
Frame = EnsembleFrame.KingdomVsKingdom,
|
|
Scale = EnsembleScale.Battle,
|
|
ParticipantCount = 20,
|
|
Focus = follow,
|
|
Related = related,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = LiveEnsemble.KingdomDisplay(keyA),
|
|
KingdomDisplay = LiveEnsemble.KingdomDisplay(keyA),
|
|
Count = 12,
|
|
Best = follow
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = keyB,
|
|
Display = LiveEnsemble.KingdomDisplay(keyB),
|
|
KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB),
|
|
Count = 8,
|
|
Best = related
|
|
}
|
|
};
|
|
string label = EventReason.WarFront(ensemble);
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "war_front" : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.WarFront,
|
|
forceActive: true,
|
|
eventStrength: 120f,
|
|
characterSignificance: 20f,
|
|
maxWatch: 60f,
|
|
ttlSeconds: 60f,
|
|
related: related,
|
|
resumable: true);
|
|
if (c != null)
|
|
{
|
|
c.Category = "War";
|
|
c.AssetId = "live_war";
|
|
c.ClearCombatSticky();
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
c.ParticipantCount = ensemble.ParticipantCount;
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok && c != null)
|
|
{
|
|
// ForceSession clears sticky for CombatActive only; re-lock war sides.
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
ok = InterestDirector.HarnessApplyWarEnsemble(ensemble);
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' sides={keyA}/{keyB}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force an EarthquakeActive disaster session so crisis chapters can gate without live quakes.
|
|
/// </summary>
|
|
private static void DoInterestEarthquakeSession(HarnessCommand cmd)
|
|
{
|
|
if (!WorldReady())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "world_not_ready");
|
|
return;
|
|
}
|
|
|
|
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
|
if (follow == null)
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no follow unit for interest_earthquake_session");
|
|
return;
|
|
}
|
|
|
|
string label = EventReason.Earthquake(follow);
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "earthquake" : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.EarthquakeActive,
|
|
forceActive: true,
|
|
eventStrength: 95f,
|
|
characterSignificance: 12f,
|
|
maxWatch: 45f,
|
|
ttlSeconds: 45f,
|
|
related: null,
|
|
resumable: true);
|
|
if (c != null)
|
|
{
|
|
c.Category = "Disaster";
|
|
c.AssetId = "earthquake";
|
|
c.ClearCombatSticky();
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrite sticky war counts on the current WarFront scene.
|
|
/// value = "aCount:bCount" (default 12:8).
|
|
/// </summary>
|
|
private static void DoWarEnsembleApply(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.WarFront)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_war_scene");
|
|
return;
|
|
}
|
|
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
focus = scene.RelatedUnit;
|
|
}
|
|
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_focus");
|
|
return;
|
|
}
|
|
|
|
int countA = 12;
|
|
int countB = 8;
|
|
string raw = (cmd.value ?? "").Trim();
|
|
int colon = raw.IndexOf(':');
|
|
if (colon > 0)
|
|
{
|
|
int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out countA);
|
|
int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out countB);
|
|
}
|
|
|
|
countA = Math.Max(0, countA);
|
|
countB = Math.Max(0, countB);
|
|
string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) ? scene.CombatSideAKey : "Essiona";
|
|
string keyB = !string.IsNullOrEmpty(scene.CombatSideBKey) ? scene.CombatSideBKey : "Northreach";
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.WarFront,
|
|
Frame = EnsembleFrame.KingdomVsKingdom,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, countA + countB)),
|
|
ParticipantCount = countA + countB,
|
|
Focus = focus,
|
|
Related = scene.RelatedUnit,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = LiveEnsemble.KingdomDisplay(keyA),
|
|
KingdomDisplay = LiveEnsemble.KingdomDisplay(keyA),
|
|
Count = countA,
|
|
Best = focus
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = keyB,
|
|
Display = LiveEnsemble.KingdomDisplay(keyB),
|
|
KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB),
|
|
Count = countB,
|
|
Best = scene.RelatedUnit
|
|
}
|
|
};
|
|
// Keep locked keys; only rewrite counts via apply + refresh.
|
|
if (!scene.HasStickyCombatSides)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble);
|
|
}
|
|
else
|
|
{
|
|
scene.Sticky.SideACount = countA;
|
|
scene.Sticky.SideBCount = countB;
|
|
scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, countA + countB);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessApplyWarEnsemble(ensemble);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"tip='{CameraDirector.LastWatchLabel}' counts={countA}:{countB} participants={scene.ParticipantCount}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register a hot CombatActive Mass tip on the current WarFront kingdom pair
|
|
/// (pending peer) so war_theater_hold can be gated.
|
|
/// </summary>
|
|
private static void DoWarInjectTheaterMass(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate war = InterestDirector.CurrentCandidate;
|
|
if (war == null
|
|
|| war.Completion != InterestCompletionKind.WarFront
|
|
|| war.Sticky == null
|
|
|| !war.Sticky.HasOpposingSides)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_war_theater");
|
|
return;
|
|
}
|
|
|
|
// Copy the war sticky keys verbatim so SharesKingdomTheater cannot miss on aliases.
|
|
string keyA = war.CombatSideAKey;
|
|
string keyB = war.CombatSideBKey;
|
|
string dispA = !string.IsNullOrEmpty(war.CombatSideADisplay)
|
|
? war.CombatSideADisplay
|
|
: LiveEnsemble.KingdomDisplay(keyA);
|
|
string dispB = !string.IsNullOrEmpty(war.CombatSideBDisplay)
|
|
? war.CombatSideBDisplay
|
|
: LiveEnsemble.KingdomDisplay(keyB);
|
|
if (string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "empty_war_keys");
|
|
return;
|
|
}
|
|
|
|
Actor focus = war.FollowUnit;
|
|
Actor related = war.RelatedUnit;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_war_follow");
|
|
return;
|
|
}
|
|
|
|
int countA = 8;
|
|
int countB = 6;
|
|
string raw = (cmd.value ?? "").Trim();
|
|
int colon = raw.IndexOf(':');
|
|
if (colon > 0)
|
|
{
|
|
int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out countA);
|
|
int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out countB);
|
|
}
|
|
|
|
countA = Math.Max(1, countA);
|
|
countB = Math.Max(1, countB);
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Frame = EnsembleFrame.KingdomVsKingdom,
|
|
Scale = EnsembleScale.Mass,
|
|
ParticipantCount = countA + countB,
|
|
Focus = focus,
|
|
Related = related,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = dispA,
|
|
KingdomDisplay = dispA,
|
|
Count = countA,
|
|
Best = focus
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = keyB,
|
|
Display = dispB,
|
|
KingdomDisplay = dispB,
|
|
Count = countB,
|
|
Best = related
|
|
}
|
|
};
|
|
string label = EventReason.Combat(ensemble);
|
|
InterestCandidate mass = InterestFeeds.RegisterHarness(
|
|
focus,
|
|
label,
|
|
"war_theater_mass",
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.CombatActive,
|
|
forceActive: false,
|
|
eventStrength: 140f,
|
|
characterSignificance: 25f,
|
|
maxWatch: 40f,
|
|
ttlSeconds: 40f,
|
|
related: related,
|
|
resumable: true);
|
|
if (mass == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "register_failed");
|
|
return;
|
|
}
|
|
|
|
mass.Category = "Combat";
|
|
mass.AssetId = "live_battle";
|
|
mass.ClearCombatSticky();
|
|
StickyScoreboard.TryLockFromEnsemble(mass.Sticky, ensemble);
|
|
mass.ParticipantCount = ensemble.ParticipantCount;
|
|
mass.Label = label;
|
|
InterestScoring.ScoreCheap(mass);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"pending mass tip='{label}' theater={keyA}/{keyB} evt={mass.EventStrength:0.#} warTip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force a sticky StatusOutbreak session: Outbreak - Status (n).
|
|
/// value = status id (default cursed). Seeds live cursed carriers so maintain recounts stick.
|
|
/// </summary>
|
|
private static void DoInterestOutbreakSession(HarnessCommand cmd)
|
|
{
|
|
Actor follow = ResolveUnit(cmd.asset);
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no follow unit for interest_outbreak_session");
|
|
return;
|
|
}
|
|
|
|
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "cursed";
|
|
if (!StatusOutbreakFeed.IsOutbreakWorthy(statusId))
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "not_outbreak_worthy status=" + statusId);
|
|
return;
|
|
}
|
|
|
|
var carriers = EnsureStatusCarriers(follow, statusId, want: 4);
|
|
if (carriers.Count < 2)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "could not seed outbreak carriers status=" + statusId);
|
|
return;
|
|
}
|
|
|
|
Actor related = carriers.Count > 1 ? carriers[1] : null;
|
|
string keyA = "status:" + statusId;
|
|
int count = carriers.Count;
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.StatusOutbreak,
|
|
Frame = EnsembleFrame.SpeciesVsSpecies,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)),
|
|
ParticipantCount = count,
|
|
Focus = follow,
|
|
Related = related,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = LiveEnsemble.StatusDisplayName(statusId),
|
|
Count = count,
|
|
Best = follow
|
|
}
|
|
};
|
|
for (int i = 0; i < carriers.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(carriers[i]);
|
|
if (id != 0)
|
|
{
|
|
ensemble.ParticipantIds.Add(id);
|
|
}
|
|
}
|
|
|
|
string label = EventReason.StatusOutbreak(ensemble);
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "status_outbreak" : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.StatusOutbreak,
|
|
forceActive: true,
|
|
eventStrength: 108f,
|
|
characterSignificance: 18f,
|
|
maxWatch: 45f,
|
|
ttlSeconds: 45f,
|
|
related: related,
|
|
resumable: true);
|
|
if (c != null)
|
|
{
|
|
c.Category = "StatusTransformation";
|
|
c.AssetId = "live_outbreak";
|
|
c.StatusId = statusId;
|
|
c.ClearCombatSticky();
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
c.ParticipantCount = ensemble.ParticipantCount;
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok && c != null)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
ok = InterestDirector.HarnessApplyStatusOutbreakEnsemble(ensemble);
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' status={statusId} carriers={count}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrite sticky outbreak carrier count from live cursed units. value = count (default 4).
|
|
/// </summary>
|
|
private static void DoOutbreakEnsembleApply(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.StatusOutbreak)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_outbreak_scene");
|
|
return;
|
|
}
|
|
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
focus = scene.RelatedUnit;
|
|
}
|
|
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_focus");
|
|
return;
|
|
}
|
|
|
|
int want = 4;
|
|
string raw = (cmd.value ?? "").Trim();
|
|
if (!string.IsNullOrEmpty(raw))
|
|
{
|
|
int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out want);
|
|
}
|
|
|
|
want = Math.Max(0, want);
|
|
string statusId = !string.IsNullOrEmpty(scene.StatusId) ? scene.StatusId : "cursed";
|
|
var carriers = EnsureStatusCarriers(focus, statusId, want);
|
|
int count = carriers.Count;
|
|
Actor related = null;
|
|
for (int i = 0; i < carriers.Count; i++)
|
|
{
|
|
if (carriers[i] != null && carriers[i] != focus && carriers[i].isAlive())
|
|
{
|
|
related = carriers[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey)
|
|
? scene.CombatSideAKey
|
|
: "status:" + statusId;
|
|
string display = !string.IsNullOrEmpty(scene.Sticky?.SideADisplay)
|
|
? scene.Sticky.SideADisplay
|
|
: LiveEnsemble.StatusDisplayName(statusId);
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.StatusOutbreak,
|
|
Frame = EnsembleFrame.SpeciesVsSpecies,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)),
|
|
ParticipantCount = count,
|
|
Focus = focus,
|
|
Related = related ?? scene.RelatedUnit,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = display,
|
|
Count = count,
|
|
Best = focus
|
|
}
|
|
};
|
|
for (int i = 0; i < carriers.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(carriers[i]);
|
|
if (id != 0)
|
|
{
|
|
ensemble.ParticipantIds.Add(id);
|
|
}
|
|
}
|
|
|
|
if (scene.Sticky != null)
|
|
{
|
|
scene.Sticky.SideAIds.Clear();
|
|
for (int i = 0; i < carriers.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(carriers[i]);
|
|
if (id != 0)
|
|
{
|
|
scene.Sticky.SideAIds.Add(id);
|
|
}
|
|
}
|
|
|
|
scene.Sticky.SideACount = count;
|
|
scene.Sticky.SideADisplay = display;
|
|
scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, count);
|
|
}
|
|
|
|
if (!scene.HasStickyCombatSides)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessApplyStatusOutbreakEnsemble(ensemble);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"tip='{CameraDirector.LastWatchLabel}' count={count} want={want} participants={scene.ParticipantCount}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensure exactly <paramref name="want"/> living units near <paramref name="focus"/> carry
|
|
/// <paramref name="statusId"/> (clears extras so maintain/CollectStatusCarriers cannot inflate).
|
|
/// Spawns same-species units when the map is short.
|
|
/// </summary>
|
|
private static List<Actor> EnsureStatusCarriers(Actor focus, string statusId, int want)
|
|
{
|
|
var keep = new List<Actor>();
|
|
if (focus == null || !focus.isAlive() || string.IsNullOrEmpty(statusId) || want <= 0)
|
|
{
|
|
return keep;
|
|
}
|
|
|
|
StatusGameApi.TryAddStatus(focus, statusId, overrideTimer: 120f);
|
|
if (StatusGameApi.HasStatus(focus, statusId))
|
|
{
|
|
keep.Add(focus);
|
|
}
|
|
|
|
string assetId = focus.asset != null ? focus.asset.id : "human";
|
|
WorldTile tile = null;
|
|
try
|
|
{
|
|
tile = World.world.GetTile(
|
|
Mathf.RoundToInt(focus.current_position.x),
|
|
Mathf.RoundToInt(focus.current_position.y));
|
|
}
|
|
catch
|
|
{
|
|
tile = TileNearCamera();
|
|
}
|
|
|
|
var nearby = new List<Actor>();
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || actor == focus || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
nearby.Add(actor);
|
|
}
|
|
|
|
for (int i = 0; i < nearby.Count && keep.Count < want; i++)
|
|
{
|
|
Actor actor = nearby[i];
|
|
StatusGameApi.TryAddStatus(actor, statusId, overrideTimer: 120f);
|
|
if (StatusGameApi.HasStatus(actor, statusId) && !keep.Contains(actor))
|
|
{
|
|
keep.Add(actor);
|
|
}
|
|
}
|
|
|
|
int guard = 0;
|
|
while (keep.Count < want && tile != null && guard++ < want + 8)
|
|
{
|
|
Actor spawned = World.world.units.spawnNewUnit(
|
|
assetId,
|
|
tile,
|
|
pSpawnSound: false,
|
|
pMiracleSpawn: true);
|
|
if (spawned == null || !spawned.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
StatusGameApi.TryAddStatus(spawned, statusId, overrideTimer: 120f);
|
|
if (StatusGameApi.HasStatus(spawned, statusId) && !keep.Contains(spawned))
|
|
{
|
|
keep.Add(spawned);
|
|
}
|
|
}
|
|
|
|
// Clear the status from everyone else so Refresh cannot re-inflate the tip.
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive() || keep.Contains(actor))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (StatusGameApi.HasStatus(actor, statusId))
|
|
{
|
|
StatusGameApi.TryFinishStatus(actor, statusId);
|
|
}
|
|
}
|
|
|
|
return keep;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensure <paramref name="want"/> living same-species units are enrolled as a pack roster.
|
|
/// Spawns when needed so maintain recounts keep the tip count.
|
|
/// </summary>
|
|
private static List<Actor> EnsurePackMembers(Actor focus, int want)
|
|
{
|
|
var members = new List<Actor>();
|
|
if (focus == null || !focus.isAlive() || want <= 0)
|
|
{
|
|
return members;
|
|
}
|
|
|
|
members.Add(focus);
|
|
string species = LiveEnsemble.SpeciesKeyOf(focus);
|
|
string assetId = focus.asset != null ? focus.asset.id : "human";
|
|
WorldTile tile = null;
|
|
try
|
|
{
|
|
tile = World.world.GetTile(
|
|
Mathf.RoundToInt(focus.current_position.x),
|
|
Mathf.RoundToInt(focus.current_position.y));
|
|
}
|
|
catch
|
|
{
|
|
tile = TileNearCamera();
|
|
}
|
|
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (members.Count >= want)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (actor == null || actor == focus || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(species)
|
|
&& !string.Equals(LiveEnsemble.SpeciesKeyOf(actor), species, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
members.Add(actor);
|
|
}
|
|
|
|
int guard = 0;
|
|
while (members.Count < want && tile != null && guard++ < want + 8)
|
|
{
|
|
Actor spawned = World.world.units.spawnNewUnit(
|
|
assetId,
|
|
tile,
|
|
pSpawnSound: false,
|
|
pMiracleSpawn: true);
|
|
if (spawned != null && spawned.isAlive() && !members.Contains(spawned))
|
|
{
|
|
members.Add(spawned);
|
|
}
|
|
}
|
|
|
|
if (members.Count > want)
|
|
{
|
|
members.RemoveRange(want, members.Count - want);
|
|
}
|
|
|
|
return members;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force a sticky FamilyPack session: Pack - Species (n) · gathering.
|
|
/// Seeds a live same-species roster so maintain recounts keep the tip count.
|
|
/// </summary>
|
|
private static void DoInterestFamilySession(HarnessCommand cmd)
|
|
{
|
|
Actor follow = ResolveUnit(cmd.asset);
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no follow unit for interest_family_session");
|
|
return;
|
|
}
|
|
|
|
var members = EnsurePackMembers(follow, want: 5);
|
|
if (members.Count < 2)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "could not seed pack members");
|
|
return;
|
|
}
|
|
|
|
Actor related = members.Count > 1 ? members[1] : null;
|
|
if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto")
|
|
{
|
|
Actor named = ResolveUnit(cmd.value);
|
|
if (named != null && named.isAlive() && named != follow)
|
|
{
|
|
related = named;
|
|
if (!members.Contains(named))
|
|
{
|
|
members.Add(named);
|
|
}
|
|
}
|
|
}
|
|
|
|
string speciesKey = LiveEnsemble.SpeciesKeyOf(follow);
|
|
if (string.IsNullOrEmpty(speciesKey))
|
|
{
|
|
speciesKey = "human";
|
|
}
|
|
|
|
string packKey = "family:harness";
|
|
int count = members.Count;
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.FamilyPack,
|
|
Frame = EnsembleFrame.SpeciesVsSpecies,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)),
|
|
ParticipantCount = count,
|
|
Focus = follow,
|
|
Related = related,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = packKey,
|
|
Display = LiveEnsemble.SpeciesDisplay(speciesKey),
|
|
Count = count,
|
|
Best = follow
|
|
}
|
|
};
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(members[i]);
|
|
if (id != 0)
|
|
{
|
|
ensemble.ParticipantIds.Add(id);
|
|
}
|
|
}
|
|
|
|
string label = EventReason.FamilyPack(ensemble);
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "family_pack" : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.FamilyPack,
|
|
forceActive: true,
|
|
// Match live soft pack band so unit events can still cut in during playtests.
|
|
eventStrength: 48f,
|
|
characterSignificance: 12f,
|
|
maxWatch: 12f,
|
|
ttlSeconds: 20f,
|
|
related: related,
|
|
resumable: true);
|
|
if (c != null)
|
|
{
|
|
c.Category = "Relationship";
|
|
c.AssetId = "live_family";
|
|
c.ClearCombatSticky();
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
c.ParticipantCount = ensemble.ParticipantCount;
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok && c != null)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
ok = InterestDirector.HarnessApplyFamilyEnsemble(ensemble);
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' pack={packKey} members={count}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrite sticky family pack count from a live same-species roster.
|
|
/// value = member count (default 5).
|
|
/// </summary>
|
|
private static void DoFamilyEnsembleApply(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.FamilyPack)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_family_scene");
|
|
return;
|
|
}
|
|
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
focus = scene.RelatedUnit;
|
|
}
|
|
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_focus");
|
|
return;
|
|
}
|
|
|
|
int want = 5;
|
|
string raw = (cmd.value ?? "").Trim();
|
|
if (!string.IsNullOrEmpty(raw))
|
|
{
|
|
int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out want);
|
|
}
|
|
|
|
want = Math.Max(0, want);
|
|
var members = EnsurePackMembers(focus, want);
|
|
int count = members.Count;
|
|
Actor related = null;
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
if (members[i] != null && members[i] != focus && members[i].isAlive())
|
|
{
|
|
related = members[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Solo dip: drop related so Refresh/SeedFromEnsemble cannot re-inflate to 2.
|
|
if (count <= 1)
|
|
{
|
|
related = null;
|
|
scene.RelatedUnit = null;
|
|
scene.RelatedId = 0;
|
|
}
|
|
|
|
string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) ? scene.CombatSideAKey : "family:harness";
|
|
string species = LiveEnsemble.SpeciesKeyOf(focus);
|
|
string display = !string.IsNullOrEmpty(scene.Sticky?.SideADisplay)
|
|
? scene.Sticky.SideADisplay
|
|
: LiveEnsemble.SpeciesDisplay(species);
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.FamilyPack,
|
|
Frame = EnsembleFrame.SpeciesVsSpecies,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(2, count)),
|
|
ParticipantCount = count,
|
|
Focus = focus,
|
|
Related = related,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = display,
|
|
Count = count,
|
|
Best = focus
|
|
}
|
|
};
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(members[i]);
|
|
if (id != 0)
|
|
{
|
|
ensemble.ParticipantIds.Add(id);
|
|
}
|
|
}
|
|
|
|
if (scene.Sticky != null)
|
|
{
|
|
scene.Sticky.SideAIds.Clear();
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(members[i]);
|
|
if (id != 0)
|
|
{
|
|
scene.Sticky.SideAIds.Add(id);
|
|
}
|
|
}
|
|
|
|
scene.Sticky.SideACount = count;
|
|
scene.Sticky.SideADisplay = display;
|
|
scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, count);
|
|
}
|
|
|
|
if (!scene.HasStickyCombatSides)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessApplyFamilyEnsemble(ensemble);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"tip='{CameraDirector.LastWatchLabel}' count={count} want={want} participants={scene.ParticipantCount}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force a sticky PlotActive session: Plotters vs a target kingdom key.
|
|
/// </summary>
|
|
private static void DoInterestPlotSession(HarnessCommand cmd)
|
|
{
|
|
Actor follow = ResolveUnit(cmd.asset);
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
Actor related = null;
|
|
if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto")
|
|
{
|
|
related = ResolveUnit(cmd.value);
|
|
}
|
|
|
|
if (related == null || related == follow)
|
|
{
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor != null && actor != follow && actor.isAlive())
|
|
{
|
|
related = actor;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no follow unit for interest_plot_session");
|
|
return;
|
|
}
|
|
|
|
// Seed a real plotter roster (2+) so PlotCell sticky / completion stay group-shaped.
|
|
var plotters = EnsurePackMembers(follow, want: 3);
|
|
if (plotters.Count < 2)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "could not seed plotters");
|
|
return;
|
|
}
|
|
|
|
string keyA = "plot:rebellion";
|
|
string keyB = related != null ? LiveEnsemble.KingdomKeyOf(related) : "";
|
|
if (string.IsNullOrEmpty(keyB) || LiveEnsemble.IsPlotterSideKey(keyB))
|
|
{
|
|
keyB = "Essiona";
|
|
}
|
|
|
|
int countA = plotters.Count;
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.PlotCell,
|
|
Frame = EnsembleFrame.KingdomVsKingdom,
|
|
Scale = EnsembleScale.Skirmish,
|
|
ParticipantCount = countA + 12,
|
|
Focus = follow,
|
|
Related = related,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = "Plotters",
|
|
Count = countA,
|
|
Best = follow
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = keyB,
|
|
Display = LiveEnsemble.KingdomDisplay(keyB),
|
|
KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB),
|
|
Count = 12,
|
|
Best = related
|
|
}
|
|
};
|
|
for (int i = 0; i < plotters.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(plotters[i]);
|
|
if (id != 0)
|
|
{
|
|
ensemble.ParticipantIds.Add(id);
|
|
}
|
|
}
|
|
|
|
string label = EventReason.PlotCell(ensemble);
|
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "plot_cell" : cmd.expect;
|
|
InterestCandidate c = InterestFeeds.RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead: InterestLeadKind.EventLed,
|
|
completion: InterestCompletionKind.PlotActive,
|
|
forceActive: true,
|
|
eventStrength: 115f,
|
|
characterSignificance: 20f,
|
|
maxWatch: 50f,
|
|
ttlSeconds: 50f,
|
|
related: related,
|
|
resumable: true);
|
|
if (c != null)
|
|
{
|
|
c.Category = "Politics";
|
|
c.AssetId = "live_plot";
|
|
c.ClearCombatSticky();
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
c.ParticipantCount = ensemble.ParticipantCount;
|
|
InterestScoring.ScoreCheap(c);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessForceSession(c);
|
|
if (ok && c != null)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(c.Sticky, ensemble);
|
|
ok = InterestDirector.HarnessApplyPlotEnsemble(ensemble);
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} tip='{CameraDirector.LastWatchLabel}' sides={keyA}/{keyB}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrite sticky plot counts on the current PlotActive scene.
|
|
/// value = "aCount:bCount" (default 3:12).
|
|
/// </summary>
|
|
private static void DoPlotEnsembleApply(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.PlotActive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_plot_scene");
|
|
return;
|
|
}
|
|
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
focus = scene.RelatedUnit;
|
|
}
|
|
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_focus");
|
|
return;
|
|
}
|
|
|
|
int countA = 3;
|
|
int countB = 12;
|
|
string raw = (cmd.value ?? "").Trim();
|
|
int colon = raw.IndexOf(':');
|
|
if (colon > 0)
|
|
{
|
|
int.TryParse(raw.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out countA);
|
|
int.TryParse(raw.Substring(colon + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out countB);
|
|
}
|
|
|
|
countA = Math.Max(0, countA);
|
|
countB = Math.Max(0, countB);
|
|
// Resize the living plotter roster so maintain recount matches the requested count.
|
|
var plotters = EnsurePackMembers(focus, want: Math.Max(1, countA));
|
|
if (countA >= 2 && plotters.Count < 2)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "could not seed plotters for count=" + countA);
|
|
return;
|
|
}
|
|
|
|
if (plotters.Count > countA)
|
|
{
|
|
plotters.RemoveRange(countA, plotters.Count - countA);
|
|
}
|
|
|
|
countA = plotters.Count;
|
|
string keyA = !string.IsNullOrEmpty(scene.CombatSideAKey) ? scene.CombatSideAKey : "plot:rebellion";
|
|
string keyB = !string.IsNullOrEmpty(scene.CombatSideBKey) ? scene.CombatSideBKey : "Essiona";
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.PlotCell,
|
|
Frame = EnsembleFrame.KingdomVsKingdom,
|
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, countA + countB)),
|
|
ParticipantCount = countA + countB,
|
|
Focus = focus,
|
|
Related = scene.RelatedUnit,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = keyA,
|
|
Display = "Plotters",
|
|
Count = countA,
|
|
Best = focus
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = keyB,
|
|
Display = LiveEnsemble.KingdomDisplay(keyB),
|
|
KingdomDisplay = LiveEnsemble.KingdomDisplay(keyB),
|
|
Count = countB,
|
|
Best = scene.RelatedUnit
|
|
}
|
|
};
|
|
for (int i = 0; i < plotters.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(plotters[i]);
|
|
if (id != 0)
|
|
{
|
|
ensemble.ParticipantIds.Add(id);
|
|
}
|
|
}
|
|
|
|
if (scene.Sticky != null)
|
|
{
|
|
scene.Sticky.SideAIds.Clear();
|
|
for (int i = 0; i < plotters.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(plotters[i]);
|
|
if (id != 0)
|
|
{
|
|
scene.Sticky.SideAIds.Add(id);
|
|
}
|
|
}
|
|
|
|
scene.Sticky.SideACount = countA;
|
|
scene.Sticky.SideBCount = countB;
|
|
scene.Sticky.SideADisplay = "Plotters";
|
|
scene.Sticky.PeakParticipants = Math.Max(scene.Sticky.PeakParticipants, countA + countB);
|
|
}
|
|
|
|
if (!scene.HasStickyCombatSides)
|
|
{
|
|
StickyScoreboard.TryLockFromEnsemble(scene.Sticky, ensemble);
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessApplyPlotEnsemble(ensemble);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"tip='{CameraDirector.LastWatchLabel}' counts={countA}:{countB} participants={scene.ParticipantCount}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grow the current CombatActive scene into a sided multi-fighter ensemble reason.
|
|
/// value = participant count (default 6). Spawns extra follow/related species near the focus.
|
|
/// </summary>
|
|
private static void DoCombatEnsembleEscalate(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_combat_scene");
|
|
return;
|
|
}
|
|
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
Actor related = scene.RelatedUnit;
|
|
if ((focus == null || !focus.isAlive()) && related != null && related.isAlive())
|
|
{
|
|
focus = related;
|
|
related = null;
|
|
}
|
|
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
// Last resort: nearest living unit so escalate can still seed a pack.
|
|
focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
}
|
|
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_focus");
|
|
return;
|
|
}
|
|
|
|
// spawn_only: seed camps near the Duel without rewriting tip/sticky (natural escalate gate).
|
|
bool spawnOnly = (cmd.expect ?? "").IndexOf("spawn_only", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| string.Equals(cmd.label, "spawn_only", StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!spawnOnly)
|
|
{
|
|
scene.ClearCombatSticky();
|
|
}
|
|
|
|
int want = 6;
|
|
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
|
|
{
|
|
want = 6;
|
|
}
|
|
|
|
want = Mathf.Clamp(want, 3, 24);
|
|
string focusSpecies = focus.asset != null ? focus.asset.id : "human";
|
|
string relatedSpecies = related?.asset != null ? related.asset.id : "wolf";
|
|
if (string.Equals(relatedSpecies, focusSpecies, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
relatedSpecies = focusSpecies == "human" ? "wolf" : "human";
|
|
}
|
|
|
|
int sideA = want / 2;
|
|
int sideB = want - sideA;
|
|
// Already have focus (+ optional related); spawn the rest near the fight.
|
|
for (int i = 1; i < sideA; i++)
|
|
{
|
|
SpawnAssetNear(focusSpecies, focus.current_position, 2f + i * 0.4f);
|
|
}
|
|
|
|
if (related == null || !related.isAlive())
|
|
{
|
|
related = SpawnAssetNear(relatedSpecies, focus.current_position, 2.5f);
|
|
}
|
|
|
|
for (int i = 1; i < sideB; i++)
|
|
{
|
|
SpawnAssetNear(relatedSpecies, focus.current_position, 3f + i * 0.4f);
|
|
}
|
|
|
|
if (spawnOnly)
|
|
{
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"spawn_only n={want} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)}");
|
|
return;
|
|
}
|
|
|
|
var ensemble = new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Scale = LiveEnsemble.ScaleForCount(want),
|
|
Frame = EnsembleFrame.SpeciesVsSpecies,
|
|
ParticipantCount = want,
|
|
NotableParticipantCount = Mathf.Max(scene.NotableParticipantCount, 0),
|
|
Focus = focus,
|
|
Related = related,
|
|
Anchor = focus.current_position,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = focusSpecies,
|
|
Display = LiveEnsemble.SpeciesDisplay(focusSpecies),
|
|
Count = sideA,
|
|
Best = focus
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = relatedSpecies,
|
|
Display = LiveEnsemble.SpeciesDisplay(relatedSpecies),
|
|
Count = sideB,
|
|
Best = related
|
|
}
|
|
};
|
|
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(focus));
|
|
if (related != null)
|
|
{
|
|
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(related));
|
|
}
|
|
|
|
bool ok = InterestDirector.HarnessApplyCombatEnsemble(ensemble);
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Point camera Follow at another living unit without rewriting SubjectId / Label.
|
|
/// Proves life-tip reason principals reject bystander dossiers under a named tip.
|
|
/// asset = optional species for the bystander (default human).
|
|
/// </summary>
|
|
private static void DoInterestHijackFollow(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_scene");
|
|
return;
|
|
}
|
|
|
|
long subjectId = scene.SubjectId != 0
|
|
? scene.SubjectId
|
|
: EventFeedUtil.SafeId(scene.FollowUnit);
|
|
Actor bystander = null;
|
|
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
long id = EventFeedUtil.SafeId(actor);
|
|
if (id == 0 || id == subjectId || id == scene.RelatedId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string species = actor.asset != null ? actor.asset.id : "";
|
|
if (!string.IsNullOrEmpty(wantAsset)
|
|
&& !species.Equals(wantAsset, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (bystander == null)
|
|
{
|
|
bystander = actor;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
bystander = actor;
|
|
break;
|
|
}
|
|
|
|
if (bystander == null)
|
|
{
|
|
bystander = SpawnAssetNear(
|
|
wantAsset,
|
|
scene.FollowUnit != null
|
|
? scene.FollowUnit.current_position
|
|
: CameraPos(),
|
|
8f);
|
|
}
|
|
|
|
if (bystander == null || !bystander.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_bystander");
|
|
return;
|
|
}
|
|
|
|
// Keep SubjectId / Label on the original tip owner; only Follow + camera move.
|
|
if (scene.SubjectId == 0)
|
|
{
|
|
scene.SubjectId = subjectId;
|
|
}
|
|
|
|
scene.FollowUnit = bystander;
|
|
try
|
|
{
|
|
scene.Position = bystander.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.FocusUnit(bystander);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"hijackFollow={SafeName(bystander)} subjectId={scene.SubjectId} relatedId={scene.RelatedId} tip='{scene.Label}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spawn a non-fighting same-side unit, park combat Follow on them (rostered bystander),
|
|
/// while peers keep fighting. Maintain must retarget off the chore unit.
|
|
/// </summary>
|
|
private static void DoCombatParkBystanderFollow(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_combat_scene");
|
|
return;
|
|
}
|
|
|
|
Actor anchor = scene.FollowUnit;
|
|
if (anchor == null || !anchor.isAlive())
|
|
{
|
|
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
if (anchor == null || !anchor.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_anchor");
|
|
return;
|
|
}
|
|
|
|
string sideA = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
|
|
// Spawn a bit off the scrap so wolves do not instantly re-acquire the bystander.
|
|
Actor bystander = SpawnAssetNear(sideA, anchor.current_position, 6f);
|
|
if (bystander == null || !bystander.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "spawn_failed");
|
|
return;
|
|
}
|
|
|
|
StatusGameApi.TryAddStatus(bystander, "invincible", 12f);
|
|
StatusGameApi.TryAddStatus(bystander, "sleeping", 8f);
|
|
ClearAttackTarget(bystander);
|
|
TryClearCombatTask(bystander);
|
|
ClearAttackTarget(bystander);
|
|
|
|
long id = EventFeedUtil.SafeId(bystander);
|
|
if (id != 0 && scene.Sticky != null)
|
|
{
|
|
if (!scene.Sticky.SideAIds.Contains(id))
|
|
{
|
|
scene.Sticky.SideAIds.Add(id);
|
|
}
|
|
|
|
scene.Sticky.SideACount = Math.Max(scene.Sticky.SideACount, scene.Sticky.SideAIds.Count);
|
|
}
|
|
|
|
scene.FollowUnit = bystander;
|
|
scene.SubjectId = id;
|
|
try
|
|
{
|
|
scene.Position = bystander.current_position;
|
|
}
|
|
catch
|
|
{
|
|
// keep
|
|
}
|
|
|
|
CameraDirector.FocusUnit(bystander);
|
|
bool fighting = LiveEnsemble.IsCombatParticipant(bystander);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"parked={SafeName(bystander)} fighting={fighting} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
private static Actor FindNearestAliveAsset(
|
|
string assetId,
|
|
bool preferUnfocused = false,
|
|
Actor otherThan = null)
|
|
{
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
Vector3 origin = CameraPos();
|
|
Actor best = null;
|
|
float bestDist = float.MaxValue;
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || actor == otherThan || actor.asset == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.Equals(actor.asset.id, assetId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (preferUnfocused && focus != null && actor == focus)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Vector3 p = actor.current_position;
|
|
float dx = p.x - origin.x;
|
|
float dy = p.y - origin.y;
|
|
float d2 = dx * dx + dy * dy;
|
|
if (d2 < bestDist)
|
|
{
|
|
bestDist = d2;
|
|
best = actor;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// skip unstable actor
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void ClearAttackTarget(Actor unit)
|
|
{
|
|
if (unit == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var mi = typeof(Actor).GetMethod(
|
|
"setAttackTarget",
|
|
System.Reflection.BindingFlags.Instance
|
|
| System.Reflection.BindingFlags.Public
|
|
| System.Reflection.BindingFlags.NonPublic);
|
|
if (mi != null)
|
|
{
|
|
mi.Invoke(unit, new object[] { null });
|
|
return;
|
|
}
|
|
|
|
var prop = typeof(Actor).GetProperty(
|
|
"attack_target",
|
|
System.Reflection.BindingFlags.Instance
|
|
| System.Reflection.BindingFlags.Public
|
|
| System.Reflection.BindingFlags.NonPublic);
|
|
prop?.SetValue(unit, null, null);
|
|
}
|
|
catch
|
|
{
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
private static void TryClearCombatTask(Actor unit)
|
|
{
|
|
if (unit == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!unit.hasTask() || unit.ai?.task == null || !unit.ai.task.in_combat)
|
|
{
|
|
return;
|
|
}
|
|
|
|
unit.setTask((string)null);
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
unit.setTask("");
|
|
}
|
|
catch
|
|
{
|
|
// best-effort
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kill living units near the combat pair so ambient packs cannot escalate a 1v1 Duel tip.
|
|
/// value = radius (default 18). Keeps Follow + Related (+ optional PairOwner/Partner ids).
|
|
/// </summary>
|
|
private static void DoCombatIsolatePair(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
Actor keepA = scene?.FollowUnit;
|
|
Actor keepB = scene?.RelatedUnit;
|
|
if (keepA == null || !keepA.isAlive())
|
|
{
|
|
keepA = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
if ((keepB == null || !keepB.isAlive()) && scene != null)
|
|
{
|
|
if (scene.PairPartnerId != 0)
|
|
{
|
|
keepB = LiveEnsemble.FindTrackedActor(scene.PairPartnerId);
|
|
}
|
|
|
|
if ((keepB == null || !keepB.isAlive()) && scene.PairOwnerId != 0)
|
|
{
|
|
Actor owner = LiveEnsemble.FindTrackedActor(scene.PairOwnerId);
|
|
if (owner != null && owner != keepA && owner.isAlive())
|
|
{
|
|
keepB = owner;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (keepA == null || !keepA.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_pair");
|
|
return;
|
|
}
|
|
|
|
float radius = 18f;
|
|
if (!string.IsNullOrEmpty(cmd.value)
|
|
&& float.TryParse(cmd.value, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed)
|
|
&& parsed > 1f)
|
|
{
|
|
radius = parsed;
|
|
}
|
|
|
|
float r2 = radius * radius;
|
|
Vector2 origin = keepA.current_position;
|
|
int culled = 0;
|
|
var doomed = new List<Actor>(32);
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive() || actor == keepA || actor == keepB)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
Vector2 d = actor.current_position - origin;
|
|
if (d.sqrMagnitude > r2)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
doomed.Add(actor);
|
|
}
|
|
|
|
for (int i = 0; i < doomed.Count; i++)
|
|
{
|
|
try
|
|
{
|
|
doomed[i].die(true, AttackType.Divine);
|
|
culled++;
|
|
}
|
|
catch
|
|
{
|
|
// keep going
|
|
}
|
|
}
|
|
|
|
if (scene != null)
|
|
{
|
|
scene.ClearCombatSticky();
|
|
scene.ParticipantCount = keepB != null && keepB.isAlive() ? 2 : 1;
|
|
scene.CombatPeakParticipants = scene.ParticipantCount;
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"culled={culled} keepA={SafeName(keepA)} keepB={SafeName(keepB)} r={radius:0.#} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort: set attack_target across two species camps near the combat focus
|
|
/// so <see cref="LiveEnsemble.TryBuildCombat"/> sees a real multi-fighter cluster.
|
|
/// asset = side A species (default human), value = side B species (default wolf).
|
|
/// </summary>
|
|
private static void DoCombatWireAttackSides(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
Actor anchor = scene?.FollowUnit;
|
|
if (anchor == null || !anchor.isAlive())
|
|
{
|
|
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
if (anchor == null || !anchor.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_anchor");
|
|
return;
|
|
}
|
|
|
|
string sideA = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
|
|
string sideB = string.IsNullOrEmpty(cmd.value) ? "wolf" : cmd.value.Trim();
|
|
bool pairOnly = (cmd.expect ?? "").IndexOf("pair", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| string.Equals(cmd.label, "pair", StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (pairOnly)
|
|
{
|
|
Actor a = scene?.FollowUnit ?? anchor;
|
|
Actor b = scene?.RelatedUnit;
|
|
if (b == null || !b.isAlive())
|
|
{
|
|
// Nearest opposite-species unit.
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || actor == a || !actor.isAlive() || actor.asset == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (actor.asset.id.Equals(sideB, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
b = actor;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
int wiredPair = 0;
|
|
if (TrySetAttackTarget(a, b))
|
|
{
|
|
wiredPair++;
|
|
}
|
|
|
|
if (TrySetAttackTarget(b, a))
|
|
{
|
|
wiredPair++;
|
|
}
|
|
|
|
InterestDirector.HarnessMaintainCombatFocus();
|
|
bool okPair = wiredPair > 0;
|
|
if (okPair)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
okPair,
|
|
detail: $"pair wired={wiredPair} a={SafeName(a)} b={SafeName(b)} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
|
return;
|
|
}
|
|
|
|
var campA = new List<Actor>(8);
|
|
var campB = new List<Actor>(8);
|
|
Vector2 pos = anchor.current_position;
|
|
const float r2 = 16f * 16f;
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive() || actor.asset == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float dx = actor.current_position.x - pos.x;
|
|
float dy = actor.current_position.y - pos.y;
|
|
if (dx * dx + dy * dy > r2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string id = actor.asset.id ?? "";
|
|
if (id.Equals(sideA, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
campA.Add(actor);
|
|
}
|
|
else if (id.Equals(sideB, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
campB.Add(actor);
|
|
}
|
|
}
|
|
|
|
if (campA.Count == 0 || campB.Count == 0)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: $"empty camps a={campA.Count} b={campB.Count} near={SafeName(anchor)}");
|
|
return;
|
|
}
|
|
|
|
int wired = 0;
|
|
for (int i = 0; i < campA.Count; i++)
|
|
{
|
|
if (TrySetAttackTarget(campA[i], campB[i % campB.Count]))
|
|
{
|
|
wired++;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < campB.Count; i++)
|
|
{
|
|
if (TrySetAttackTarget(campB[i], campA[i % campA.Count]))
|
|
{
|
|
wired++;
|
|
}
|
|
}
|
|
|
|
// Enroll camps on the sticky scoreboard. AI often clears attack_target on pack
|
|
// extras within a tick; roster membership still lets Duel → Mass escalate.
|
|
if (scene != null && campA.Count > 0 && campB.Count > 0)
|
|
{
|
|
StampCombatCampsOntoScene(scene, sideA, sideB, campA, campB);
|
|
}
|
|
|
|
InterestDirector.HarnessMaintainCombatFocus();
|
|
bool ok = wired > 0;
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
ok,
|
|
detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} sticky={scene?.CombatSideACount ?? 0}:{scene?.CombatSideBCount ?? 0} peak={scene?.CombatPeakParticipants ?? 0} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
|
}
|
|
|
|
private static void StampCombatCampsOntoScene(
|
|
InterestCandidate scene,
|
|
string sideA,
|
|
string sideB,
|
|
List<Actor> campA,
|
|
List<Actor> campB)
|
|
{
|
|
if (scene?.Sticky == null || campA == null || campB == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LiveSceneStickyState sticky = scene.Sticky;
|
|
sticky.Kind = EnsembleKind.Combat;
|
|
sticky.Frame = EnsembleFrame.SpeciesVsSpecies;
|
|
sticky.SideAKey = sideA;
|
|
sticky.SideBKey = sideB;
|
|
sticky.SideADisplay = LiveEnsemble.SpeciesDisplay(sideA);
|
|
sticky.SideBDisplay = LiveEnsemble.SpeciesDisplay(sideB);
|
|
sticky.SideAIds.Clear();
|
|
sticky.SideBIds.Clear();
|
|
for (int i = 0; i < campA.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(campA[i]);
|
|
if (id != 0 && !sticky.SideAIds.Contains(id))
|
|
{
|
|
sticky.SideAIds.Add(id);
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < campB.Count; i++)
|
|
{
|
|
long id = EventFeedUtil.SafeId(campB[i]);
|
|
if (id != 0 && !sticky.SideBIds.Contains(id))
|
|
{
|
|
sticky.SideBIds.Add(id);
|
|
}
|
|
}
|
|
|
|
sticky.SideACount = sticky.SideAIds.Count;
|
|
sticky.SideBCount = sticky.SideBIds.Count;
|
|
int total = sticky.SideACount + sticky.SideBCount;
|
|
scene.ParticipantCount = Math.Max(scene.ParticipantCount, total);
|
|
if (total > scene.CombatPeakParticipants)
|
|
{
|
|
scene.CombatPeakParticipants = total;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: zero one sticky combat camp while keeping PeakParticipants high
|
|
/// (repro for Mass vs (0) mop-up labels). value = a|b (default b).
|
|
/// </summary>
|
|
private static void DoCombatWipeStickySide(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no sticky opposing combat sides");
|
|
return;
|
|
}
|
|
|
|
LiveSceneStickyState sticky = scene.Sticky;
|
|
string which = (cmd.value ?? "b").Trim().ToLowerInvariant();
|
|
bool wipeB = which != "a" && which != "sidea" && which != "0";
|
|
int peakBefore = sticky.PeakParticipants;
|
|
if (wipeB)
|
|
{
|
|
sticky.SideBIds.Clear();
|
|
sticky.SideBCount = 0;
|
|
}
|
|
else
|
|
{
|
|
sticky.SideAIds.Clear();
|
|
sticky.SideACount = 0;
|
|
}
|
|
|
|
// Keep peak Mass so the bug class (peak hold + empty camp) is exercised.
|
|
sticky.PeakParticipants = Math.Max(peakBefore, Math.Max(8, sticky.TotalCount));
|
|
scene.ParticipantCount = sticky.TotalCount;
|
|
scene.CombatSideACount = sticky.SideACount;
|
|
scene.CombatSideBCount = sticky.SideBCount;
|
|
scene.CombatPeakParticipants = sticky.PeakParticipants;
|
|
|
|
string reason = StickyScoreboard.FormatReason(scene, scene.FollowUnit, scene.RelatedUnit);
|
|
if (!string.IsNullOrEmpty(reason))
|
|
{
|
|
scene.Label = reason;
|
|
}
|
|
|
|
StickyScoreboard.StabilizeScale(sticky, new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Frame = sticky.Frame,
|
|
ParticipantCount = Math.Max(1, sticky.TotalCount),
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = sticky.SideAKey,
|
|
Display = sticky.SideADisplay,
|
|
Count = sticky.SideACount
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = sticky.SideBKey,
|
|
Display = sticky.SideBDisplay,
|
|
Count = sticky.SideBCount
|
|
},
|
|
Focus = scene.FollowUnit,
|
|
Related = scene.RelatedUnit
|
|
}, Time.unscaledTime);
|
|
|
|
string label = EventReason.Combat(new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Scale = sticky.HasPresentedScale ? sticky.PresentedScale : LiveEnsemble.ScaleForCount(Math.Max(1, sticky.TotalCount)),
|
|
Frame = sticky.Frame,
|
|
Focus = scene.FollowUnit,
|
|
Related = scene.RelatedUnit,
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = sticky.SideAKey,
|
|
Display = sticky.SideADisplay,
|
|
KingdomDisplay = sticky.SideAKingdom,
|
|
Count = sticky.SideACount
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = sticky.SideBKey,
|
|
Display = sticky.SideBDisplay,
|
|
KingdomDisplay = sticky.SideBKingdom,
|
|
Count = sticky.SideBCount
|
|
},
|
|
ParticipantCount = sticky.TotalCount
|
|
});
|
|
if (!string.IsNullOrEmpty(label))
|
|
{
|
|
scene.Label = label;
|
|
CameraDirector.Watch(scene.ToInterestEvent());
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"wiped={(wipeB ? "b" : "a")} sides={sticky.SideACount}/{sticky.SideBCount} peak={sticky.PeakParticipants} tip='{scene.Label}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: set one sticky camp's live count without clearing mop-up
|
|
/// (value = a:N or b:N). Used to soak 0↔1 pack↔vs thrash.
|
|
/// </summary>
|
|
private static void DoCombatSetStickySideCount(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no sticky opposing combat sides");
|
|
return;
|
|
}
|
|
|
|
string raw = (cmd.value ?? "b:1").Trim().ToLowerInvariant();
|
|
string which = "b";
|
|
int count = 1;
|
|
int colon = raw.IndexOf(':');
|
|
if (colon > 0)
|
|
{
|
|
which = raw.Substring(0, colon).Trim();
|
|
int.TryParse(raw.Substring(colon + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out count);
|
|
}
|
|
else if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int onlyCount))
|
|
{
|
|
count = onlyCount;
|
|
}
|
|
|
|
count = Math.Max(0, count);
|
|
LiveSceneStickyState sticky = scene.Sticky;
|
|
bool setB = which != "a" && which != "sidea" && which != "0";
|
|
if (setB)
|
|
{
|
|
sticky.SideBCount = count;
|
|
if (count <= 0)
|
|
{
|
|
sticky.SideBIds.Clear();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
sticky.SideACount = count;
|
|
if (count <= 0)
|
|
{
|
|
sticky.SideAIds.Clear();
|
|
}
|
|
}
|
|
|
|
scene.CombatSideACount = sticky.SideACount;
|
|
scene.CombatSideBCount = sticky.SideBCount;
|
|
scene.ParticipantCount = sticky.TotalCount;
|
|
StickyScoreboard.StabilizeScale(
|
|
sticky,
|
|
new LiveEnsemble
|
|
{
|
|
Kind = EnsembleKind.Combat,
|
|
Frame = sticky.Frame,
|
|
ParticipantCount = Math.Max(1, sticky.TotalCount),
|
|
SideA = new EnsembleSide
|
|
{
|
|
Key = sticky.SideAKey,
|
|
Display = sticky.SideADisplay,
|
|
KingdomDisplay = sticky.SideAKingdom,
|
|
Count = sticky.SideACount
|
|
},
|
|
SideB = new EnsembleSide
|
|
{
|
|
Key = sticky.SideBKey,
|
|
Display = sticky.SideBDisplay,
|
|
KingdomDisplay = sticky.SideBKingdom,
|
|
Count = sticky.SideBCount
|
|
},
|
|
Focus = scene.FollowUnit,
|
|
Related = scene.RelatedUnit
|
|
},
|
|
Time.unscaledTime);
|
|
|
|
string label = StickyScoreboard.FormatReason(scene, scene.FollowUnit, scene.RelatedUnit);
|
|
if (!string.IsNullOrEmpty(label))
|
|
{
|
|
scene.Label = label;
|
|
CameraDirector.Watch(scene.ToInterestEvent());
|
|
}
|
|
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail:
|
|
$"set={(setB ? "b" : "a")}:{count} sides={sticky.SideACount}/{sticky.SideBCount} mop={sticky.MopUpActive} tip='{scene.Label}'");
|
|
}
|
|
|
|
/// <summary>Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus.</summary>
|
|
private static void DoCombatProbeFighters(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
Actor anchor = scene?.FollowUnit;
|
|
if (anchor == null || !anchor.isAlive())
|
|
{
|
|
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
if (anchor == null || !anchor.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_anchor");
|
|
return;
|
|
}
|
|
|
|
float radius = ParseFloat(cmd.value, 18f);
|
|
if (radius < 4f)
|
|
{
|
|
radius = 18f;
|
|
}
|
|
|
|
LiveEnsemble.TryBuildCombat(
|
|
anchor.current_position,
|
|
radius,
|
|
out LiveEnsemble ensemble,
|
|
priorParticipantIds: scene?.ParticipantIds,
|
|
newcomerBonus: 8f);
|
|
int n = ensemble != null ? ensemble.ParticipantCount : 0;
|
|
string frame = ensemble != null ? ensemble.Frame.ToString() : "none";
|
|
string scale = ensemble != null ? ensemble.Scale.ToString() : "none";
|
|
bool sided = ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble);
|
|
bool focusFight = LiveEnsemble.IsCombatParticipant(anchor);
|
|
bool relatedFight = scene?.RelatedUnit != null
|
|
&& LiveEnsemble.IsCombatParticipant(scene.RelatedUnit);
|
|
_cmdOk++;
|
|
Emit(
|
|
cmd,
|
|
ok: true,
|
|
detail: $"n={n} r={radius:0.#} frame={frame} scale={scale} sided={sided} focusFight={focusFight} relatedFight={relatedFight} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kill the locked pair partner while leaving Follow fighting. First-partner lock must
|
|
/// not chain-adopt the next attack_target into a new Duel tip.
|
|
/// </summary>
|
|
private static void DoCombatKillRelated(HarnessCommand cmd)
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_combat_scene");
|
|
return;
|
|
}
|
|
|
|
Actor partner = scene.RelatedUnit;
|
|
if (partner == null || !partner.isAlive())
|
|
{
|
|
partner = EventFeedUtil.FindUnitById(scene.PairPartnerId);
|
|
}
|
|
|
|
if (partner == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_partner");
|
|
return;
|
|
}
|
|
|
|
long partnerId = EventFeedUtil.SafeId(partner);
|
|
string partnerName = SafeName(partner);
|
|
try
|
|
{
|
|
if (partner.isAlive())
|
|
{
|
|
partner.die(true, AttackType.Divine);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: $"die_failed={ex.Message}");
|
|
return;
|
|
}
|
|
|
|
bool dead = false;
|
|
try
|
|
{
|
|
dead = partner == null || !partner.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
dead = true;
|
|
}
|
|
|
|
// Keep the durable partner id so maintain proves we do not chain-replace.
|
|
if (partnerId != 0)
|
|
{
|
|
scene.PairPartnerId = partnerId;
|
|
}
|
|
|
|
// Drop dead RelatedUnit refs so later maintain / swap cannot touch destroyed actors.
|
|
scene.RelatedUnit = null;
|
|
scene.RelatedId = partnerId;
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null && MoveCamera.hasFocusUnit())
|
|
{
|
|
focus = MoveCamera._focus_unit;
|
|
}
|
|
|
|
ClearAttackTarget(focus);
|
|
|
|
// Thin Duel tips only: immediately leave NamedPair wording naming the corpse.
|
|
// Collective Mass/Battle tips stay for maintain / sticky framing.
|
|
string priorTip = scene.Label ?? "";
|
|
bool thinDuel = priorTip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|
|
if (dead && focus != null && thinDuel)
|
|
{
|
|
try
|
|
{
|
|
if (focus.isAlive())
|
|
{
|
|
string holdTip = EventReason.Fight(focus, null);
|
|
if (!string.IsNullOrEmpty(holdTip))
|
|
{
|
|
scene.Label = holdTip;
|
|
scene.FollowUnit = focus;
|
|
scene.SubjectId = EventFeedUtil.SafeId(focus);
|
|
CameraDirector.Watch(scene.ToInterestEvent());
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// maintain path still owns the hold
|
|
}
|
|
}
|
|
|
|
if (dead)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
dead,
|
|
detail: $"partner={partnerName} id={partnerId} lock={scene.PairPartnerId} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spawn a distractor and point the combat Follow's attack_target at them while the
|
|
/// locked pair partner stays alive. Maintain / director must not flip the Duel tip.
|
|
/// </summary>
|
|
private static void DoCombatSwapAttackTarget(HarnessCommand cmd)
|
|
{
|
|
try
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_combat_scene");
|
|
return;
|
|
}
|
|
|
|
Actor focus = scene.FollowUnit;
|
|
if (focus == null)
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
}
|
|
|
|
bool focusOk = false;
|
|
try
|
|
{
|
|
focusOk = focus != null && focus.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
focusOk = false;
|
|
}
|
|
|
|
if (!focusOk)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "no_focus");
|
|
return;
|
|
}
|
|
|
|
// Stale attack_target after partner death can NRE later Unity paths.
|
|
ClearAttackTarget(focus);
|
|
|
|
Actor partner = null;
|
|
try
|
|
{
|
|
partner = scene.RelatedUnit;
|
|
if (partner != null && !partner.isAlive())
|
|
{
|
|
partner = null;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
partner = null;
|
|
}
|
|
|
|
if (partner == null && scene.PairPartnerId != 0)
|
|
{
|
|
partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId);
|
|
}
|
|
|
|
string distractorAsset = !string.IsNullOrEmpty(cmd.asset)
|
|
? cmd.asset.Trim()
|
|
: "wolf";
|
|
if (string.IsNullOrEmpty(cmd.asset) && partner != null)
|
|
{
|
|
try
|
|
{
|
|
if (partner.asset != null && !string.IsNullOrEmpty(partner.asset.id))
|
|
{
|
|
distractorAsset = partner.asset.id;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
distractorAsset = "wolf";
|
|
}
|
|
}
|
|
|
|
Vector3 near = default;
|
|
try
|
|
{
|
|
near = focus.current_position;
|
|
}
|
|
catch
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "focus_pos_failed");
|
|
return;
|
|
}
|
|
|
|
Actor distractor = SpawnAssetNear(distractorAsset, near, 3.5f);
|
|
if (distractor == null)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "spawn_failed");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!distractor.isAlive())
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "spawn_dead");
|
|
return;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: "spawn_invalid");
|
|
return;
|
|
}
|
|
|
|
// Keep the locked partner alive so the hold must prefer them over the new target.
|
|
TryHarnessInvincible(partner, 12f);
|
|
TryHarnessInvincible(distractor, 8f);
|
|
bool wired = TrySetAttackTarget(focus, distractor);
|
|
TrySetAttackTarget(distractor, focus);
|
|
// Peer noise without requiring a living RelatedUnit (post partner-death).
|
|
try
|
|
{
|
|
InterestFeeds.OnActivityNote(focus, "fighting", hot: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Wire alone still exercises the hold; do not fail the step on feed NRE.
|
|
Emit(
|
|
cmd,
|
|
wired,
|
|
detail: $"wired={wired} activity_note_err={ex.Message} focus={SafeName(focus)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'");
|
|
if (wired)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (wired)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(
|
|
cmd,
|
|
wired,
|
|
detail: $"focus={SafeName(focus)} partner={SafeName(partner)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_cmdFail++;
|
|
Emit(cmd, ok: false, detail: $"swap_exception={ex.GetType().Name}:{ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void TryHarnessInvincible(Actor unit, float seconds)
|
|
{
|
|
if (unit == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!unit.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
StatusGameApi.TryAddStatus(unit, "invincible", seconds);
|
|
}
|
|
catch
|
|
{
|
|
// Status API can NRE on edge actor states - wire alone is enough for holds.
|
|
}
|
|
}
|
|
|
|
private static bool TrySetAttackTarget(Actor unit, Actor foe)
|
|
{
|
|
if (unit == null || foe == null || !unit.isAlive() || !foe.isAlive() || unit == foe)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var mi = typeof(Actor).GetMethod(
|
|
"setAttackTarget",
|
|
System.Reflection.BindingFlags.Instance
|
|
| System.Reflection.BindingFlags.Public
|
|
| System.Reflection.BindingFlags.NonPublic);
|
|
if (mi != null)
|
|
{
|
|
mi.Invoke(unit, new object[] { foe });
|
|
}
|
|
else
|
|
{
|
|
var prop = typeof(Actor).GetProperty(
|
|
"attack_target",
|
|
System.Reflection.BindingFlags.Instance
|
|
| System.Reflection.BindingFlags.Public
|
|
| System.Reflection.BindingFlags.NonPublic);
|
|
prop?.SetValue(unit, foe, null);
|
|
}
|
|
|
|
return unit.has_attack_target;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter)
|
|
{
|
|
try
|
|
{
|
|
if (AssetManager.actor_library?.get(assetId) == null || World.world == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
WorldTile tile = World.world.GetTile(
|
|
Mathf.RoundToInt(near.x + jitter),
|
|
Mathf.RoundToInt(near.y));
|
|
if (tile == null)
|
|
{
|
|
tile = World.world.GetTile(
|
|
Mathf.RoundToInt(near.x),
|
|
Mathf.RoundToInt(near.y));
|
|
}
|
|
|
|
if (tile == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Actor spawned = World.world.units.spawnNewUnit(
|
|
assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
|
|
if (spawned != null && spawned.isAlive())
|
|
{
|
|
_lastSpawned = spawned;
|
|
_lastSpawnedAssetId = assetId;
|
|
}
|
|
|
|
return spawned;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 ok;
|
|
string detail;
|
|
if (int.TryParse((cmd.value ?? "").Trim(), out int intVal)
|
|
&& (key == ModSettings.GravestoneMaxStacksId
|
|
|| key == ModSettings.GravestoneTtlSecondsId))
|
|
{
|
|
ok = ModSettings.TrySetInt(key, intVal);
|
|
detail = $"{key}={intVal}";
|
|
}
|
|
else
|
|
{
|
|
bool val = ParseBool(cmd.value, defaultValue: true);
|
|
ok = ModSettings.TrySetBool(key, val);
|
|
detail = $"{key}={val}";
|
|
}
|
|
|
|
if (ok)
|
|
{
|
|
_cmdOk++;
|
|
}
|
|
else
|
|
{
|
|
_cmdFail++;
|
|
}
|
|
|
|
Emit(cmd, ok, detail: detail);
|
|
}
|
|
|
|
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 "inspect_cleared":
|
|
{
|
|
pass = !SpectatorMode.Active
|
|
&& !Chronicle.HudVisible
|
|
&& !WatchCaption.Visible
|
|
&& !ChronicleHud.GraveStackScopeActive
|
|
&& !InspectUi.GraveSessionActive;
|
|
detail =
|
|
$"idle={SpectatorMode.Active} lore={Chronicle.HudVisible} dossier={WatchCaption.Visible} scope={ChronicleHud.GraveStackScopeActive} graveSession={InspectUi.GraveSessionActive}";
|
|
break;
|
|
}
|
|
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_asset":
|
|
{
|
|
string want = (cmd.value ?? cmd.asset ?? "").Trim();
|
|
string tipAsset = CameraDirector.LastWatchAssetId ?? "";
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& tipAsset.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
detail = $"tipAsset='{tipAsset}' want='{want}'";
|
|
break;
|
|
}
|
|
case "tip_not_partner":
|
|
{
|
|
// Remembered bystander must not be named as the fallen / sought victim.
|
|
// Subject can still be the partner ("Petelor stands over the fallen" is fine).
|
|
string partnerName = SafeName(_happinessPartner);
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
bool namedAsOther = !string.IsNullOrEmpty(partnerName)
|
|
&& (tip.IndexOf("stands over " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| tip.IndexOf("mourns " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| tip.IndexOf("seeks " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| tip.IndexOf("stays beside " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0);
|
|
pass = !namedAsOther;
|
|
detail = $"tip='{tip}' partner='{partnerName}' namedAsOther={namedAsOther}";
|
|
break;
|
|
}
|
|
case "story_phase":
|
|
{
|
|
string want = (cmd.value ?? "").Trim();
|
|
StoryArc arc = StoryPlanner.Active;
|
|
string have = arc != null ? arc.Phase.ToString() : "None";
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& string.Equals(have, want, StringComparison.OrdinalIgnoreCase);
|
|
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold} board={StoryPlanner.BoardCount}";
|
|
break;
|
|
}
|
|
case "story_board_count":
|
|
{
|
|
int want = ParseInt(cmd.value, 0);
|
|
int have = StoryPlanner.BoardCount;
|
|
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
|
if (cmp == "min")
|
|
{
|
|
pass = have >= want;
|
|
}
|
|
else if (cmp == "max")
|
|
{
|
|
pass = have <= want;
|
|
}
|
|
else
|
|
{
|
|
pass = have == want;
|
|
}
|
|
|
|
detail = $"boardCount={have} want={want} cmp={cmp}";
|
|
break;
|
|
}
|
|
case "story_parked_count":
|
|
{
|
|
int want = ParseInt(cmd.value, 0);
|
|
var board = new System.Collections.Generic.List<StoryArc>(4);
|
|
StoryPlanner.CopyBoard(board);
|
|
int parked = 0;
|
|
for (int i = 0; i < board.Count; i++)
|
|
{
|
|
if (board[i] != null && board[i].IsParked)
|
|
{
|
|
parked++;
|
|
}
|
|
}
|
|
|
|
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
|
pass = cmp == "min" ? parked >= want : parked == want;
|
|
detail = $"parked={parked} want={want} board={board.Count}";
|
|
break;
|
|
}
|
|
case "browse_paused":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = SpectatorMode.BrowsePaused;
|
|
pass = have == want;
|
|
detail = $"browsePaused={have} want={want} idle={SpectatorMode.Active}";
|
|
break;
|
|
}
|
|
case "saga_roster_count":
|
|
case "story_rail_count":
|
|
{
|
|
LifeSagaRoster.Tick(Time.unscaledTime);
|
|
LifeSagaRail.Refresh();
|
|
int want = ParseInt(cmd.value, 0);
|
|
int have = LifeSagaRoster.Count;
|
|
int shown = LifeSagaRail.LastShownCount;
|
|
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
|
string expectKey = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
|
int probe = expectKey == "story_rail_count" ? shown : have;
|
|
pass = cmp == "min" ? probe >= want : probe == want;
|
|
detail = $"roster={have} railShown={shown} want={want} cmp={cmp} visible={LifeSagaRail.Visible}";
|
|
break;
|
|
}
|
|
case "saga_overview_tip":
|
|
case "story_rail_tip":
|
|
{
|
|
string have = SampleRailTip();
|
|
string any = (cmd.value ?? "").Trim();
|
|
pass = false;
|
|
if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any))
|
|
{
|
|
string[] parts = any.Split('|');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
string needle = (parts[i] ?? "").Trim();
|
|
if (needle.Length > 0
|
|
&& have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
pass = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
detail = $"sagaTip='{have}' any='{any}' pass={pass}";
|
|
break;
|
|
}
|
|
case "saga_overview_tip_not":
|
|
case "story_rail_tip_not":
|
|
{
|
|
string have = SampleRailTip();
|
|
string any = (cmd.value ?? "").Trim();
|
|
pass = true;
|
|
if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any))
|
|
{
|
|
string[] parts = any.Split('|');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
string needle = (parts[i] ?? "").Trim();
|
|
if (needle.Length > 0
|
|
&& have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
pass = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
detail = $"railTip='{have}' not_any='{any}' pass={pass}";
|
|
break;
|
|
}
|
|
case "crisis_active":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = StoryPlanner.CrisisActive;
|
|
pass = have == want;
|
|
CrisisChapter crisis = StoryPlanner.Crisis;
|
|
detail = $"crisisActive={have} want={want} kind={crisis?.Kind} phase={crisis?.Phase}";
|
|
break;
|
|
}
|
|
case "soft_fill_quiet":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = StoryPlanner.SoftFillQuietActive;
|
|
pass = have == want;
|
|
detail = $"softFillQuiet={have} want={want}";
|
|
break;
|
|
}
|
|
case "crisis_kind":
|
|
{
|
|
string want = (cmd.value ?? "").Trim();
|
|
CrisisChapter crisis = StoryPlanner.Crisis;
|
|
string have = crisis != null ? crisis.Kind.ToString() : "None";
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& string.Equals(have, want, StringComparison.OrdinalIgnoreCase);
|
|
detail = $"crisisKind={have} want={want} active={StoryPlanner.CrisisActive}";
|
|
break;
|
|
}
|
|
case "story_spine":
|
|
{
|
|
string want = (cmd.value ?? "").Trim();
|
|
string have = WatchCaption.LastStorySpine ?? "";
|
|
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
|
|
// Prefer live dossier text; fall back to planner format if HUD not painted yet.
|
|
if (string.IsNullOrEmpty(have))
|
|
{
|
|
have = formatted;
|
|
}
|
|
|
|
bool wantEmpty = string.IsNullOrEmpty(want)
|
|
|| string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase);
|
|
if (wantEmpty)
|
|
{
|
|
pass = string.IsNullOrEmpty(have);
|
|
}
|
|
else if (want.IndexOf('|') >= 0)
|
|
{
|
|
// Alternatives: "Battle · Climax|Mass · Climax|Skirmish · Climax"
|
|
pass = false;
|
|
string[] alts = want.Split('|');
|
|
for (int i = 0; i < alts.Length; i++)
|
|
{
|
|
string alt = (alts[i] ?? "").Trim();
|
|
if (!string.IsNullOrEmpty(alt)
|
|
&& have.IndexOf(alt, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
pass = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
detail =
|
|
$"storySpine='{have}' want='{want}' formatted='{formatted}' "
|
|
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(InterestDirector.CurrentCandidate)} "
|
|
+ $"phase={StoryPlanner.Active?.Phase.ToString() ?? "None"}";
|
|
break;
|
|
}
|
|
case "story_hold_margin":
|
|
{
|
|
// value: "0" / "none" → expect no raised hold; "gt0" / "held" → expect hold > 0
|
|
string want = (cmd.value ?? "gt0").Trim();
|
|
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
|
InterestCandidate probe = new InterestCandidate
|
|
{
|
|
Key = "harness:story_hold_probe",
|
|
SubjectId = 999999001,
|
|
EventStrength = 40f,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Completion = InterestCompletionKind.FixedDwell,
|
|
Label = "HoldProbe"
|
|
};
|
|
InterestScoring.RecalcTotal(probe);
|
|
float margin = StoryPlanner.ArcHoldMargin(cur, probe);
|
|
bool wantHeld = string.Equals(want, "gt0", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(want, "held", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(want, "true", StringComparison.OrdinalIgnoreCase);
|
|
bool wantNone = string.Equals(want, "0", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(want, "false", StringComparison.OrdinalIgnoreCase);
|
|
if (wantHeld)
|
|
{
|
|
pass = margin > 0f;
|
|
}
|
|
else if (wantNone)
|
|
{
|
|
pass = margin <= 0f;
|
|
}
|
|
else if (float.TryParse(want, NumberStyles.Float, CultureInfo.InvariantCulture, out float wantF))
|
|
{
|
|
pass = Mathf.Abs(margin - wantF) < 0.5f;
|
|
}
|
|
else
|
|
{
|
|
pass = false;
|
|
}
|
|
|
|
detail =
|
|
$"storyHold={margin:0.#} want={want} phase={StoryPlanner.Active?.Phase.ToString() ?? "None"} "
|
|
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(cur)}";
|
|
break;
|
|
}
|
|
case "beat_cooled":
|
|
{
|
|
// value = happiness/asset needle; builds a probe on focus unit.
|
|
string needle = (cmd.value ?? "just_had_child").Trim();
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = EventFeedUtil.SafeId(focus);
|
|
InterestCandidate probe = new InterestCandidate
|
|
{
|
|
Key = "harness:beat_cool:" + needle,
|
|
SubjectId = id,
|
|
FollowUnit = focus,
|
|
HappinessEffectId = needle,
|
|
AssetId = needle,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Completion = InterestCompletionKind.FixedDwell,
|
|
EventStrength = 80f,
|
|
Label = needle
|
|
};
|
|
// Map decision/building/status needles onto the same chapter fields as injects.
|
|
ApplyHarnessRelationshipAssetHint(probe, needle);
|
|
pass = InterestVariety.IsBeatCooled(probe);
|
|
detail = $"beatCooled={pass} needle='{needle}' focus={SafeName(focus)} id={id}";
|
|
break;
|
|
}
|
|
case "ledger_has_focus":
|
|
case "saga_has_focus":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = EventFeedUtil.SafeId(focus);
|
|
LifeSagaRoster.Tick(Time.unscaledTime);
|
|
pass = id != 0 && LifeSagaRoster.IsMc(id);
|
|
detail = $"saga focus={SafeName(focus)} id={id} on={pass} prefer={LifeSagaRoster.IsPrefer(id)}";
|
|
break;
|
|
}
|
|
case "saga_prefer_focus_on":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
long id = EventFeedUtil.SafeId(focus);
|
|
pass = id != 0 && LifeSagaRoster.IsPrefer(id);
|
|
detail = $"prefer focus={SafeName(focus)} id={id} on={pass}";
|
|
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 "focus_fighting":
|
|
{
|
|
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
bool fighting = LiveEnsemble.IsCombatParticipant(unit);
|
|
bool want = string.IsNullOrEmpty(cmd.value)
|
|
|| !string.Equals(cmd.value, "false", StringComparison.OrdinalIgnoreCase);
|
|
pass = fighting == want;
|
|
detail = $"focus={SafeName(unit)} fighting={fighting} want={want}";
|
|
break;
|
|
}
|
|
case "related_fighting":
|
|
{
|
|
Actor unit = InterestDirector.CurrentCandidate?.RelatedUnit;
|
|
bool fighting = LiveEnsemble.IsCombatParticipant(unit);
|
|
bool want = string.IsNullOrEmpty(cmd.value)
|
|
|| !string.Equals(cmd.value, "false", StringComparison.OrdinalIgnoreCase);
|
|
pass = fighting == want;
|
|
detail = $"related={SafeName(unit)} fighting={fighting} want={want}";
|
|
break;
|
|
}
|
|
case "tip_matches_focus":
|
|
{
|
|
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
string name = SafeName(unit);
|
|
if (string.IsNullOrEmpty(tip))
|
|
{
|
|
// Cleared fight tip during combat cold is OK.
|
|
pass = true;
|
|
detail = "tip_empty focus=" + name;
|
|
}
|
|
else if (string.IsNullOrEmpty(name))
|
|
{
|
|
pass = false;
|
|
detail = "no_focus tip='" + tip + "'";
|
|
}
|
|
else
|
|
{
|
|
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
|
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
|
|
|| IsEnsembleCombatTip(tip);
|
|
// Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
|
|
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "tip_not_contains":
|
|
{
|
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
pass = string.IsNullOrEmpty(needle)
|
|
|| tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0;
|
|
detail = $"tip='{tip}' not_contains='{needle}'";
|
|
break;
|
|
}
|
|
case "tip_matches_any":
|
|
{
|
|
// value = pipe-separated needles; tip must contain at least one.
|
|
string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
pass = false;
|
|
if (!string.IsNullOrEmpty(raw))
|
|
{
|
|
string[] parts = raw.Split('|');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
string needle = (parts[i] ?? "").Trim();
|
|
if (needle.Length > 0
|
|
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
pass = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
detail = $"tip='{tip}' any='{raw}' pass={pass}";
|
|
break;
|
|
}
|
|
case "participant_count_min":
|
|
{
|
|
int want = 1;
|
|
if (!int.TryParse(string.IsNullOrEmpty(cmd.value) ? "1" : cmd.value, out want))
|
|
{
|
|
want = 1;
|
|
}
|
|
|
|
int have = InterestDirector.CurrentCandidate?.ParticipantCount ?? 0;
|
|
pass = have >= want;
|
|
detail = $"participants={have} want>={want}";
|
|
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 : "";
|
|
if (!string.IsNullOrEmpty(wantAsset) && wantAsset.IndexOf('|') >= 0)
|
|
{
|
|
string[] opts = wantAsset.Split('|');
|
|
pass = false;
|
|
for (int i = 0; i < opts.Length; i++)
|
|
{
|
|
if (opts[i].Trim().Equals(unitAsset, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
pass = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
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 "drop_contains":
|
|
{
|
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
pass = !string.IsNullOrEmpty(needle) && InterestDropLog.RecentContains(needle);
|
|
detail = $"needle='{needle}' drops='{InterestDropLog.FormatRecent(8)}'";
|
|
break;
|
|
}
|
|
case "no_placeholder_tip":
|
|
{
|
|
string formatted = CameraDirector.LastFormattedWatchTip ?? "";
|
|
pass = formatted.IndexOf("something interesting", StringComparison.OrdinalIgnoreCase) < 0;
|
|
detail = $"formattedTip='{formatted}'";
|
|
break;
|
|
}
|
|
case "has_focus":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool has = MoveCamera.hasFocusUnit()
|
|
&& MoveCamera._focus_unit != null
|
|
&& MoveCamera._focus_unit.isAlive();
|
|
pass = has == want;
|
|
detail = $"hasFocus={has} want={want}";
|
|
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":
|
|
{
|
|
// Legacy name: prefer tip/label match; else score band from old tier names.
|
|
string want = cmd.value ?? "";
|
|
string label = InterestDirector.CurrentLabel ?? "";
|
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
|
float score = InterestDirector.CurrentScore;
|
|
bool byText = (!string.IsNullOrEmpty(want)
|
|
&& (label.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| tip.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0));
|
|
bool byBand = false;
|
|
string band = want.Trim().ToLowerInvariant();
|
|
if (band == "ambient")
|
|
{
|
|
byBand = InterestScoring.IsFillScore(score);
|
|
}
|
|
else if (band == "curiosity")
|
|
{
|
|
byBand = score >= 35f && score < InterestScoringConfig.W.noticeScoreMin;
|
|
}
|
|
else if (band == "action")
|
|
{
|
|
byBand = score >= InterestScoringConfig.W.noticeScoreMin;
|
|
}
|
|
else if (band == "story")
|
|
{
|
|
byBand = score >= 75f;
|
|
}
|
|
else if (band == "epic")
|
|
{
|
|
byBand = InterestScoring.IsHotScore(score) || score >= 95f;
|
|
}
|
|
else if (band == "none")
|
|
{
|
|
byBand = InterestDirector.CurrentCandidate == null;
|
|
}
|
|
|
|
pass = byText || byBand;
|
|
detail = $"want='{want}' label='{label}' tip='{tip}' score={score:0.#} text={byText} band={byBand}";
|
|
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} score={InterestDirector.CurrentScoreLabel}";
|
|
break;
|
|
}
|
|
case "focus_same":
|
|
case "focus_unchanged":
|
|
{
|
|
string nowKey = FocusKey();
|
|
pass = !string.IsNullOrEmpty(_rememberedFocusKey)
|
|
&& _rememberedFocusKey != "-"
|
|
&& nowKey == _rememberedFocusKey;
|
|
detail = $"remembered={_rememberedFocusKey} now={nowKey}";
|
|
break;
|
|
}
|
|
case "tip_same":
|
|
case "tip_unchanged":
|
|
{
|
|
string nowTip = InterestDirector.CurrentCandidate?.Label
|
|
?? CameraDirector.LastWatchLabel
|
|
?? "";
|
|
pass = !string.IsNullOrEmpty(_rememberedTip) && nowTip == _rememberedTip;
|
|
detail = $"remembered='{_rememberedTip}' now='{nowTip}'";
|
|
break;
|
|
}
|
|
case "related_same":
|
|
case "pair_partner_same":
|
|
{
|
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
|
long nowId = 0;
|
|
if (scene != null)
|
|
{
|
|
nowId = scene.PairPartnerId != 0
|
|
? scene.PairPartnerId
|
|
: EventFeedUtil.SafeId(scene.RelatedUnit);
|
|
}
|
|
|
|
pass = _rememberedRelatedId != 0 && nowId == _rememberedRelatedId;
|
|
detail = $"remembered={_rememberedRelatedId} now={nowId}";
|
|
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 "pending_contains":
|
|
{
|
|
string needle = (cmd.value ?? cmd.label ?? "").Trim();
|
|
int have = InterestRegistry.CountKeysContaining(needle);
|
|
pass = !string.IsNullOrEmpty(needle) && have > 0;
|
|
detail = $"needle='{needle}' matches={have} pending={InterestRegistry.PendingCount}";
|
|
break;
|
|
}
|
|
case "scoring_config_loaded":
|
|
{
|
|
bool want = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
|
|
bool have = InterestScoringConfig.LoadedFromFile;
|
|
pass = have == want;
|
|
detail =
|
|
$"loaded={have} want={want} v={InterestScoringConfig.LoadedVersion} charEvent={InterestScoringConfig.W.charWeightEventLed:0.##}";
|
|
break;
|
|
}
|
|
case "session_key":
|
|
case "interest_session_key":
|
|
{
|
|
string want = (cmd.value ?? cmd.expect ?? "").Trim();
|
|
string have = InterestDirector.CurrentKey ?? "";
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& have.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
detail = $"key='{have}' contains='{want}'";
|
|
break;
|
|
}
|
|
case "session_active":
|
|
case "interest_session_active":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = InterestDirector.CurrentIsActive;
|
|
pass = have == want;
|
|
detail = $"active={have} want={want} key={InterestDirector.CurrentKey}";
|
|
break;
|
|
}
|
|
case "event_share":
|
|
case "interest_event_share":
|
|
{
|
|
float want = ParseFloat(cmd.value, 0.5f);
|
|
float have = InterestVariety.EventShare();
|
|
float tol = 0.35f;
|
|
if (!string.IsNullOrEmpty(cmd.label)
|
|
&& float.TryParse(cmd.label, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedTol))
|
|
{
|
|
tol = parsedTol;
|
|
}
|
|
|
|
pass = InterestVariety.MixSampleCount < 3 || Mathf.Abs(have - want) <= tol;
|
|
detail = $"share={have:0.##} want~={want:0.##} tol={tol:0.##} samples={InterestVariety.MixSampleCount}";
|
|
break;
|
|
}
|
|
case "happiness_drain_seq":
|
|
{
|
|
ulong have = HappinessEventRouter.CurrentSequence;
|
|
pass = have >= 0;
|
|
detail = $"happiness_seq={have}";
|
|
break;
|
|
}
|
|
case "happiness_cursor":
|
|
{
|
|
ulong have = InterestFeeds.HappinessCursor;
|
|
if (!string.IsNullOrEmpty(cmd.value)
|
|
&& ulong.TryParse(cmd.value, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong want))
|
|
{
|
|
pass = have == want;
|
|
detail = $"cursor={have} want={want}";
|
|
}
|
|
else
|
|
{
|
|
pass = true;
|
|
detail = $"cursor={have}";
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "interest_has_key":
|
|
{
|
|
string want = (cmd.value ?? cmd.expect ?? "").Trim();
|
|
bool have = false;
|
|
string found = "";
|
|
if (!string.IsNullOrEmpty(want))
|
|
{
|
|
if ((InterestDirector.CurrentKey ?? "").IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
have = true;
|
|
found = InterestDirector.CurrentKey;
|
|
}
|
|
else
|
|
{
|
|
var pending = new System.Collections.Generic.List<InterestCandidate>(64);
|
|
InterestRegistry.CopyPending(pending);
|
|
for (int i = 0; i < pending.Count; i++)
|
|
{
|
|
InterestCandidate c = pending[i];
|
|
if (c?.Key != null
|
|
&& c.Key.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
have = true;
|
|
found = c.Key;
|
|
break;
|
|
}
|
|
|
|
if (c?.HappinessEffectId != null
|
|
&& c.HappinessEffectId.IndexOf(want, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
have = true;
|
|
found = c.Key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!have && InterestRegistry.CountKeysContaining(want) > 0)
|
|
{
|
|
have = true;
|
|
found = "registry:" + want;
|
|
}
|
|
}
|
|
}
|
|
|
|
pass = have;
|
|
detail = $"want='{want}' found='{found}' pending={InterestRegistry.PendingCount}";
|
|
break;
|
|
}
|
|
case "interest_label_contains":
|
|
{
|
|
string want = (cmd.value ?? cmd.expect ?? "").Trim();
|
|
bool have = false;
|
|
string found = "";
|
|
if (!string.IsNullOrEmpty(want))
|
|
{
|
|
string currentLabel = InterestDirector.CurrentLabel ?? "";
|
|
if (currentLabel.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
have = true;
|
|
found = currentLabel;
|
|
}
|
|
else
|
|
{
|
|
var pending = new System.Collections.Generic.List<InterestCandidate>(64);
|
|
InterestRegistry.CopyPending(pending);
|
|
for (int i = 0; i < pending.Count; i++)
|
|
{
|
|
InterestCandidate c = pending[i];
|
|
string label = c?.Label ?? "";
|
|
if (label.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
have = true;
|
|
found = label;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pass = have;
|
|
detail = $"want='{want}' found='{found}' pending={InterestRegistry.PendingCount}";
|
|
break;
|
|
}
|
|
case "interest_no_key":
|
|
{
|
|
string want = (cmd.value ?? cmd.expect ?? "").Trim();
|
|
int n = InterestRegistry.CountKeysContaining(want);
|
|
bool inCurrent = !string.IsNullOrEmpty(want)
|
|
&& (InterestDirector.CurrentKey ?? "")
|
|
.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
pass = n == 0 && !inCurrent;
|
|
detail = $"want_absent='{want}' count={n} current={InterestDirector.CurrentKey}";
|
|
break;
|
|
}
|
|
case "interest_registry_count":
|
|
{
|
|
string needle = cmd.label ?? "";
|
|
int want = ParseCountExpect(cmd, defaultValue: 0);
|
|
int have = string.IsNullOrEmpty(needle)
|
|
? InterestRegistry.Count
|
|
: InterestRegistry.CountKeysContaining(needle);
|
|
pass = have == want;
|
|
detail = $"count={have} want={want} needle='{needle}'";
|
|
break;
|
|
}
|
|
case "interrupted_key":
|
|
case "interest_interrupted":
|
|
{
|
|
string want = (cmd.value ?? "").Trim();
|
|
string have = InterestDirector.InterruptedKey ?? "";
|
|
if (string.IsNullOrEmpty(want))
|
|
{
|
|
pass = !string.IsNullOrEmpty(have);
|
|
}
|
|
else if (want.Equals("empty", StringComparison.OrdinalIgnoreCase)
|
|
|| want.Equals("none", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
pass = string.IsNullOrEmpty(have);
|
|
}
|
|
else
|
|
{
|
|
pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
detail = $"interrupted='{have}' want='{want}'";
|
|
break;
|
|
}
|
|
case "interest_lead":
|
|
{
|
|
string want = (cmd.value ?? "EventLed").Trim();
|
|
InterestLeadKind? have = InterestDirector.CurrentLeadKind;
|
|
pass = have.HasValue
|
|
&& have.Value.ToString().Equals(want, StringComparison.OrdinalIgnoreCase);
|
|
detail = $"lead={have} want={want} key={InterestDirector.CurrentKey}";
|
|
break;
|
|
}
|
|
case "interest_peek_lead":
|
|
{
|
|
string want = (cmd.value ?? "EventLed").Trim();
|
|
InterestCandidate peek = InterestDirector.HarnessPeekNext();
|
|
pass = peek != null
|
|
&& peek.LeadKind.ToString().Equals(want, StringComparison.OrdinalIgnoreCase);
|
|
detail = peek == null
|
|
? "peek=null"
|
|
: $"peek_lead={peek.LeadKind} want={want} key={peek.Key}";
|
|
break;
|
|
}
|
|
case "interest_score_order":
|
|
{
|
|
// value = winnerNeedle, label = loserNeedle — winner TotalScore must be >= loser.
|
|
string winnerNeedle = (cmd.value ?? "").Trim();
|
|
string loserNeedle = (cmd.label ?? "").Trim();
|
|
InterestCandidate win = null;
|
|
InterestCandidate lose = null;
|
|
var all = new System.Collections.Generic.List<InterestCandidate>(64);
|
|
InterestRegistry.CopyPending(all);
|
|
if (InterestDirector.CurrentCandidate != null)
|
|
{
|
|
all.Add(InterestDirector.CurrentCandidate);
|
|
}
|
|
|
|
for (int i = 0; i < all.Count; i++)
|
|
{
|
|
InterestCandidate c = all[i];
|
|
if (c?.Key == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (win == null
|
|
&& c.Key.IndexOf(winnerNeedle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
win = c;
|
|
}
|
|
|
|
if (lose == null
|
|
&& c.Key.IndexOf(loserNeedle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
lose = c;
|
|
}
|
|
}
|
|
|
|
if (win != null)
|
|
{
|
|
InterestScoring.RecalcTotal(win);
|
|
}
|
|
|
|
if (lose != null)
|
|
{
|
|
InterestScoring.RecalcTotal(lose);
|
|
}
|
|
|
|
pass = win != null && lose != null && win.TotalScore >= lose.TotalScore;
|
|
detail = $"win={win?.Key}({win?.TotalScore:0.#}) lose={lose?.Key}({lose?.TotalScore:0.#})"
|
|
+ $" arc={InterestVariety.LastArcKey} streak={InterestVariety.ArcStreak}";
|
|
break;
|
|
}
|
|
case "variety_arc":
|
|
{
|
|
string want = (cmd.value ?? "").Trim();
|
|
string have = InterestVariety.LastArcKey ?? "";
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
detail = $"arc='{have}' want='{want}' streak={InterestVariety.ArcStreak}";
|
|
break;
|
|
}
|
|
case "variety_arc_streak":
|
|
{
|
|
int want = ParseInt(cmd.value, 1);
|
|
pass = InterestVariety.ArcStreak == want;
|
|
detail = $"streak={InterestVariety.ArcStreak} want={want} arc={InterestVariety.LastArcKey}";
|
|
break;
|
|
}
|
|
case "civic_boost":
|
|
{
|
|
string effectId = (cmd.value ?? "").Trim();
|
|
bool want = string.IsNullOrEmpty(cmd.label) || ParseBool(cmd.label, true);
|
|
bool have = InterestFeeds.HasCivicBoostContaining(effectId);
|
|
pass = have == want;
|
|
detail = $"civic={have} want={want} effect='{effectId}' count={InterestFeeds.CivicBoostCount}";
|
|
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)} reason='{dossier?.ReasonLine}'";
|
|
break;
|
|
}
|
|
case "caption_not_contains":
|
|
{
|
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
string caption = WatchCaption.LastCaptionText ?? "";
|
|
string detailLine = WatchCaption.LastDetail ?? "";
|
|
UnitDossier dossier = WatchCaption.Current;
|
|
string dossierDetail = dossier?.DetailLine ?? "";
|
|
bool hit = (!string.IsNullOrEmpty(needle)
|
|
&& (caption.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| detailLine.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| dossierDetail.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0));
|
|
pass = !hit;
|
|
detail =
|
|
$"needle='{needle}' hit={hit} caption='{caption.Replace("\n", " | ")}' detail='{detailLine}'";
|
|
break;
|
|
}
|
|
case "tooltip_library_ok":
|
|
{
|
|
pass = DossierAssetTips.RunTooltipInventoryAudit(HarnessDir(), out string tipDetail);
|
|
detail = tipDetail;
|
|
break;
|
|
}
|
|
case "history_other_rows":
|
|
{
|
|
WatchCaption.ForceRefreshHistory();
|
|
int have = WatchCaption.CountHistoryOtherRows();
|
|
int want = 1;
|
|
if (!string.IsNullOrEmpty(cmd.value)
|
|
&& int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
|
|
{
|
|
want = parsed;
|
|
}
|
|
|
|
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
|
|
pass = cmp == "exact" ? have == want : have >= want;
|
|
long subjectId = WatchCaption.CurrentUnitId;
|
|
if (subjectId == 0)
|
|
{
|
|
subjectId = ResolveActivitySubjectId();
|
|
}
|
|
|
|
int withRelated = 0;
|
|
var bits = new System.Text.StringBuilder();
|
|
IReadOnlyList<ActivityEntry> ring = ActivityLog.LatestForSubject(subjectId, 8);
|
|
for (int i = 0; i < ring.Count; i++)
|
|
{
|
|
ActivityEntry e = ring[i];
|
|
if (e == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (e.RelatedId != 0 && e.RelatedId != subjectId)
|
|
{
|
|
withRelated++;
|
|
}
|
|
|
|
if (bits.Length > 0)
|
|
{
|
|
bits.Append(';');
|
|
}
|
|
|
|
bits.Append(e.TaskId ?? "")
|
|
.Append(':')
|
|
.Append(e.RelatedId)
|
|
.Append(':')
|
|
.Append(e.TargetLabel ?? "");
|
|
}
|
|
|
|
detail =
|
|
$"otherRows={have} want={want} cmp={cmp} subject={subjectId} visible={WatchCaption.Visible} "
|
|
+ $"ringRelated={withRelated} ring=[{bits}] hist='{WatchCaption.LastHistoryJoined}'";
|
|
break;
|
|
}
|
|
case "lore_detail_is_other":
|
|
{
|
|
long loreId = ChronicleHud.DetailUnitId;
|
|
long dossierId = WatchCaption.CurrentUnitId;
|
|
long cameraId = 0;
|
|
bool cameraFollow = false;
|
|
try
|
|
{
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
cameraId = MoveCamera._focus_unit.getID();
|
|
cameraFollow = MoveCamera.isCameraFollowingUnit(MoveCamera._focus_unit);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
cameraId = 0;
|
|
cameraFollow = false;
|
|
}
|
|
|
|
bool fallenKiller = (Chronicle.LastFallenFocusDetail ?? "")
|
|
.StartsWith("killer:", System.StringComparison.Ordinal);
|
|
// After row-click OpenUnitHistory(other): Lore + dossier + camera on that living unit.
|
|
bool dossierOk = loreId != 0 && loreId == dossierId;
|
|
bool cameraOk = fallenKiller
|
|
|| (cameraId != 0 && cameraId == loreId && cameraFollow);
|
|
pass = dossierOk && cameraOk;
|
|
detail =
|
|
$"loreId={loreId} dossierId={dossierId} cameraId={cameraId} cameraFollow={cameraFollow} "
|
|
+ $"follow={ChronicleHud.FollowFocus} fallenKiller={fallenKiller}";
|
|
break;
|
|
}
|
|
case "reason_names_colored":
|
|
{
|
|
string reason = WatchCaption.Current?.ReasonLine ?? "";
|
|
bool hasColor = reason.IndexOf(ActivityProse.NameColorHex, System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| reason.IndexOf("<color=", System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
pass = hasColor && !string.IsNullOrEmpty(reason);
|
|
detail = $"colored={hasColor} reason='{reason}'";
|
|
break;
|
|
}
|
|
case "reason_other_clickable":
|
|
{
|
|
bool want = string.IsNullOrEmpty(cmd.value)
|
|
|| !string.Equals(cmd.value.Trim(), "false", System.StringComparison.OrdinalIgnoreCase);
|
|
bool have = WatchCaption.ReasonOtherClickable;
|
|
pass = have == want;
|
|
detail =
|
|
$"clickable={have} want={want} relatedId={WatchCaption.Current?.ReasonRelatedId ?? 0} "
|
|
+ $"reason='{WatchCaption.Current?.ReasonLine}'";
|
|
break;
|
|
}
|
|
case "fallen_killer_banner":
|
|
{
|
|
string banner = WatchCaption.LastCaptionText ?? "";
|
|
string reason = "";
|
|
try
|
|
{
|
|
// Banner is shown on reason text while status banner is active.
|
|
reason = WatchCaption.Current?.ReasonLine ?? "";
|
|
}
|
|
catch
|
|
{
|
|
reason = "";
|
|
}
|
|
|
|
string hay = (banner + " " + reason).ToLowerInvariant();
|
|
bool has = hay.IndexOf("camera on killer", System.StringComparison.Ordinal) >= 0;
|
|
bool killerFocus = (Chronicle.LastFallenFocusDetail ?? "")
|
|
.StartsWith("killer:", System.StringComparison.Ordinal);
|
|
pass = killerFocus && has;
|
|
detail =
|
|
$"fallenFocus={Chronicle.LastFallenFocusDetail} bannerHit={has} caption='{banner}'";
|
|
break;
|
|
}
|
|
case "citizen_job_labels_ok":
|
|
{
|
|
// Full live citizen_job_library: non-empty Title Case labels; resource-role
|
|
// families reorder; bare-prefix siblings (miner_deposit) do not.
|
|
List<string> ids = ActivityAssetCatalog.EnumerateLiveCitizenJobIds();
|
|
int empty = 0;
|
|
int underscored = 0;
|
|
int familyBad = 0;
|
|
int siblingBad = 0;
|
|
var lines = new List<string>(ids.Count + 1) { "id\tlabel" };
|
|
var prefixCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
var idSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
string rawId = ids[i];
|
|
if (string.IsNullOrEmpty(rawId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
idSet.Add(rawId);
|
|
int us = rawId.IndexOf('_');
|
|
if (us > 0 && us < rawId.Length - 1)
|
|
{
|
|
string p = rawId.Substring(0, us);
|
|
prefixCounts[p] = prefixCounts.TryGetValue(p, out int c) ? c + 1 : 1;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
string id = ids[i];
|
|
string label = ActivityAssetCatalog.FormatCitizenJobIdLabel(id);
|
|
lines.Add(id + "\t" + label);
|
|
if (string.IsNullOrEmpty(label))
|
|
{
|
|
empty++;
|
|
continue;
|
|
}
|
|
|
|
if (label.IndexOf('_') >= 0)
|
|
{
|
|
underscored++;
|
|
}
|
|
|
|
int us = id.IndexOf('_');
|
|
if (us <= 0 || us >= id.Length - 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string prefix = id.Substring(0, us);
|
|
bool family = prefixCounts.TryGetValue(prefix, out int pc)
|
|
&& pc >= 2
|
|
&& !idSet.Contains(prefix);
|
|
string titlePrefix = ActivityAssetCatalog.TitleCaseWords(prefix);
|
|
if (family)
|
|
{
|
|
// gatherer_herbs → Herb Gatherer (role last)
|
|
if (!label.EndsWith(titlePrefix, StringComparison.Ordinal)
|
|
|| label.StartsWith(titlePrefix + " ", StringComparison.Ordinal))
|
|
{
|
|
familyBad++;
|
|
}
|
|
}
|
|
else if (idSet.Contains(prefix)
|
|
&& label.EndsWith(titlePrefix, StringComparison.Ordinal)
|
|
&& !label.StartsWith(titlePrefix, StringComparison.Ordinal))
|
|
{
|
|
// miner_deposit must stay Miner Deposit, not Deposit Miner
|
|
siblingBad++;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
System.IO.File.WriteAllLines(
|
|
System.IO.Path.Combine(HarnessDir(), "citizen-job-labels.tsv"),
|
|
lines);
|
|
}
|
|
catch
|
|
{
|
|
// diagnostic dump only
|
|
}
|
|
|
|
pass = ids.Count > 0 && empty == 0 && underscored == 0 && familyBad == 0 && siblingBad == 0;
|
|
detail =
|
|
$"jobs={ids.Count} empty={empty} underscored={underscored} "
|
|
+ $"familyBad={familyBad} siblingBad={siblingBad} sample='{(ids.Count > 0 ? ActivityAssetCatalog.FormatCitizenJobIdLabel(ids[0]) : "")}'";
|
|
break;
|
|
}
|
|
case "library_asset_labels_ok":
|
|
{
|
|
// Every live library id used by EventReason.Library must resolve to a tip
|
|
// without snake_case, and item tips must not use "forges" except forge-y ids.
|
|
int total = 0;
|
|
int empty = 0;
|
|
int underscored = 0;
|
|
int forgeBad = 0;
|
|
var lines = new List<string> { "kind\tid\tdisplay\tsample_label" };
|
|
IReadOnlyList<string> kinds = LibraryAssetNames.AllKinds;
|
|
for (int k = 0; k < kinds.Count; k++)
|
|
{
|
|
string kind = kinds[k];
|
|
List<string> ids = LibraryAssetNames.EnumerateLiveIds(kind);
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
string id = ids[i];
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
total++;
|
|
string display = LibraryAssetNames.DisplayName(kind, id);
|
|
string sample = EventReason.Library(null, kind, id);
|
|
lines.Add(kind + "\t" + id + "\t" + display + "\t" + sample);
|
|
if (string.IsNullOrEmpty(display))
|
|
{
|
|
empty++;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(display) && display.IndexOf('_') >= 0)
|
|
{
|
|
underscored++;
|
|
}
|
|
|
|
if (kind == "item")
|
|
{
|
|
bool forgey = id.IndexOf("forge", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("smith", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| id.IndexOf("anvil", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
string verb = LibraryAssetNames.ItemVerb(id);
|
|
if (!forgey
|
|
&& string.Equals(verb, "forges", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
forgeBad++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
System.IO.File.WriteAllLines(
|
|
System.IO.Path.Combine(HarnessDir(), "library-asset-labels.tsv"),
|
|
lines);
|
|
}
|
|
catch
|
|
{
|
|
// diagnostic dump only
|
|
}
|
|
|
|
pass = total > 0 && empty == 0 && underscored == 0 && forgeBad == 0;
|
|
detail =
|
|
$"assets={total} empty={empty} underscored={underscored} forgeBad={forgeBad} "
|
|
+ $"kinds={kinds.Count}";
|
|
break;
|
|
}
|
|
case "camera_a_inventory_ok":
|
|
{
|
|
CameraAInventoryResult inv = CameraAInventoryHarness.RunAudit(HarnessDir());
|
|
pass = inv.Passed;
|
|
detail = inv.Detail;
|
|
break;
|
|
}
|
|
case "library_ambient_policy_ok":
|
|
{
|
|
// Signal-only libraries: ambient dials must be B; phenotypes all B;
|
|
// spectacle spells stay A; utility spells stay B.
|
|
int dialBad = 0;
|
|
int phenotypeCamera = 0;
|
|
int spellFxCamera = 0;
|
|
int spellSpectacleMissing = 0;
|
|
int signalCamera = 0;
|
|
var lines = new List<string>
|
|
{
|
|
"domain\tid\tcreates_interest\tstrength\tnotes"
|
|
};
|
|
|
|
string[] signalOnlyKeys =
|
|
{
|
|
"spell", "power", "item", "gene", "phenotype", "worldLaw", "subspeciesTrait",
|
|
"biome", "cultureTrait", "religionTrait", "clanTrait", "languageTrait"
|
|
};
|
|
for (int i = 0; i < signalOnlyKeys.Length; i++)
|
|
{
|
|
string key = signalOnlyKeys[i];
|
|
if (EventCatalogConfig.TryGetLibraryDefaults(key, out LibraryCatalogDefaults dial)
|
|
&& dial != null
|
|
&& dial.AmbientCreatesInterest)
|
|
{
|
|
dialBad++;
|
|
lines.Add(key + "\t*\t\t\tambientCreatesInterest_true");
|
|
}
|
|
}
|
|
|
|
foreach (string id in EventCatalog.Phenotype.AuthoredIds)
|
|
{
|
|
DiscreteEventEntry e = EventCatalog.Phenotype.GetOrFallback(id);
|
|
lines.Add("phenotypes\t" + id + "\t" + e.CreatesInterest + "\t"
|
|
+ e.EventStrength.ToString("0.#") + "\t");
|
|
if (e.CreatesInterest)
|
|
{
|
|
phenotypeCamera++;
|
|
}
|
|
}
|
|
|
|
string[] fxSpells =
|
|
{
|
|
"cast_silence", "cast_grass_seeds", "spawn_vegetation", "spawn_skeleton",
|
|
"cast_shield", "cast_cure"
|
|
};
|
|
for (int i = 0; i < fxSpells.Length; i++)
|
|
{
|
|
DiscreteEventEntry e = EventCatalog.Spell.GetOrFallback(fxSpells[i]);
|
|
lines.Add("spells\t" + fxSpells[i] + "\t" + e.CreatesInterest + "\t"
|
|
+ e.EventStrength.ToString("0.#") + "\tfx");
|
|
if (e.CreatesInterest)
|
|
{
|
|
spellFxCamera++;
|
|
}
|
|
}
|
|
|
|
string[] spectacleSpells =
|
|
{
|
|
"summon_lightning", "summon_tornado", "cast_curse", "cast_fire",
|
|
"cast_blood_rain", "teleport"
|
|
};
|
|
for (int i = 0; i < spectacleSpells.Length; i++)
|
|
{
|
|
DiscreteEventEntry e = EventCatalog.Spell.GetOrFallback(spectacleSpells[i]);
|
|
lines.Add("spells\t" + spectacleSpells[i] + "\t" + e.CreatesInterest + "\t"
|
|
+ e.EventStrength.ToString("0.#") + "\tspectacle");
|
|
if (!e.CreatesInterest)
|
|
{
|
|
spellSpectacleMissing++;
|
|
}
|
|
else
|
|
{
|
|
signalCamera++;
|
|
}
|
|
}
|
|
|
|
// Mage / warlock creature drop powers must be camera Signal (soak: evil mages ignored).
|
|
string[] magePowers =
|
|
{
|
|
"evil_mage", "white_mage", "necromancer", "plague_doctor", "druid"
|
|
};
|
|
int magePowerMissing = 0;
|
|
for (int i = 0; i < magePowers.Length; i++)
|
|
{
|
|
DiscreteEventEntry e = EventCatalog.Power.GetOrFallback(magePowers[i]);
|
|
lines.Add("powers\t" + magePowers[i] + "\t" + e.CreatesInterest + "\t"
|
|
+ e.EventStrength.ToString("0.#") + "\tmage");
|
|
if (!e.CreatesInterest)
|
|
{
|
|
magePowerMissing++;
|
|
}
|
|
else
|
|
{
|
|
signalCamera++;
|
|
}
|
|
}
|
|
|
|
// Genes / clan / language are all B (never own the camera).
|
|
int geneCamera = 0;
|
|
foreach (string id in EventCatalog.Gene.AuthoredIds)
|
|
{
|
|
if (EventCatalog.Gene.GetOrFallback(id).CreatesInterest)
|
|
{
|
|
geneCamera++;
|
|
}
|
|
}
|
|
|
|
int clanCamera = 0;
|
|
foreach (string id in EventCatalog.ClanTrait.AuthoredIds)
|
|
{
|
|
if (EventCatalog.ClanTrait.GetOrFallback(id).CreatesInterest)
|
|
{
|
|
clanCamera++;
|
|
}
|
|
}
|
|
|
|
int languageCamera = 0;
|
|
foreach (string id in EventCatalog.LanguageTrait.AuthoredIds)
|
|
{
|
|
if (EventCatalog.LanguageTrait.GetOrFallback(id).CreatesInterest)
|
|
{
|
|
languageCamera++;
|
|
}
|
|
}
|
|
|
|
// Spot-check other Signal classes still have camera rows after demote.
|
|
int lawSignal = 0;
|
|
foreach (string id in EventCatalog.WorldLaw.AuthoredIds)
|
|
{
|
|
if (EventCatalog.WorldLaw.GetOrFallback(id).CreatesInterest)
|
|
{
|
|
lawSignal++;
|
|
}
|
|
}
|
|
|
|
int itemSignal = 0;
|
|
foreach (string id in EventCatalog.Item.AuthoredIds)
|
|
{
|
|
if (EventCatalog.Item.GetOrFallback(id).CreatesInterest)
|
|
{
|
|
itemSignal++;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
System.IO.File.WriteAllLines(
|
|
System.IO.Path.Combine(HarnessDir(), "library-ambient-policy.tsv"),
|
|
lines);
|
|
}
|
|
catch
|
|
{
|
|
// diagnostic dump only
|
|
}
|
|
|
|
int wantSignal = spectacleSpells.Length + magePowers.Length;
|
|
pass = dialBad == 0
|
|
&& phenotypeCamera == 0
|
|
&& geneCamera == 0
|
|
&& clanCamera == 0
|
|
&& languageCamera == 0
|
|
&& spellFxCamera == 0
|
|
&& spellSpectacleMissing == 0
|
|
&& magePowerMissing == 0
|
|
&& lawSignal > 0
|
|
&& itemSignal > 0
|
|
&& signalCamera == wantSignal;
|
|
detail =
|
|
$"dialBad={dialBad} phenotypeCamera={phenotypeCamera} geneCamera={geneCamera} "
|
|
+ $"clanCamera={clanCamera} languageCamera={languageCamera} "
|
|
+ $"spellFxCamera={spellFxCamera} spellSpectacleMissing={spellSpectacleMissing} "
|
|
+ $"magePowerMissing={magePowerMissing} "
|
|
+ $"lawSignal={lawSignal} itemSignal={itemSignal} spectacleOk={signalCamera}/{wantSignal}";
|
|
break;
|
|
}
|
|
case "dossier_not_contains":
|
|
{
|
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
UnitDossier dossier = WatchCaption.Current;
|
|
string reason = dossier?.ReasonLine ?? "";
|
|
string caption = WatchCaption.LastCaptionText ?? "";
|
|
bool hit = (!string.IsNullOrEmpty(needle) && reason.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
|| (!string.IsNullOrEmpty(needle) && caption.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
|| (dossier != null && dossier.ContainsIgnoreCase(needle));
|
|
// Prefer reason-row check: fail only if the reason line (or full dossier) still shows the bad phrase.
|
|
bool reasonHit = !string.IsNullOrEmpty(needle)
|
|
&& reason.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
pass = !reasonHit;
|
|
detail = $"needle_absent_in_reason='{needle}' reason='{reason}' hit={reasonHit}";
|
|
break;
|
|
}
|
|
case "reason_owns_focus":
|
|
{
|
|
// Reason must not name a different live unit than the owned active candidate.
|
|
// Non-empty reason also requires focus to be a tip principal (duel pair / roster).
|
|
UnitDossier dossier = WatchCaption.Current;
|
|
string reason = dossier?.ReasonLine ?? "";
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
InterestCandidate owned = focus != null
|
|
? InterestDirector.TryGetOwnedReasonCandidate(focus)
|
|
: null;
|
|
if (string.IsNullOrEmpty(reason))
|
|
{
|
|
pass = true;
|
|
detail = "empty_reason_ok active=" + InterestDirector.CurrentIsActive;
|
|
break;
|
|
}
|
|
|
|
if (owned == null || focus == null)
|
|
{
|
|
pass = false;
|
|
detail = $"reason_without_owned_active focus={(focus != null)} active={InterestDirector.CurrentIsActive} reason='{reason}'";
|
|
break;
|
|
}
|
|
|
|
if (!InterestDirector.UnitIsReasonPrincipal(focus, owned))
|
|
{
|
|
pass = false;
|
|
detail = $"reason_without_principal reason='{reason}'";
|
|
break;
|
|
}
|
|
|
|
string stranger = FindStrangerNamedInReason(reason, owned);
|
|
pass = string.IsNullOrEmpty(stranger);
|
|
detail = pass
|
|
? $"owned reason='{reason}'"
|
|
: $"stranger_in_reason='{stranger}' reason='{reason}'";
|
|
break;
|
|
}
|
|
case "reason_empty":
|
|
{
|
|
UnitDossier dossier = WatchCaption.Current;
|
|
string reason = dossier?.ReasonLine ?? "";
|
|
bool wantEmpty = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
|
|
bool isEmpty = string.IsNullOrEmpty(reason);
|
|
pass = isEmpty == wantEmpty;
|
|
detail = $"reason='{reason}' empty={isEmpty} wantEmpty={wantEmpty} active={InterestDirector.CurrentIsActive} quiet={InterestDirector.InQuietGrace}";
|
|
break;
|
|
}
|
|
case "reason_matches_any":
|
|
{
|
|
// value = pipe-separated needles; orange ReasonLine must contain at least one.
|
|
string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
|
string reason = WatchCaption.Current?.ReasonLine ?? "";
|
|
pass = false;
|
|
if (!string.IsNullOrEmpty(raw))
|
|
{
|
|
string[] parts = raw.Split('|');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
string needle = (parts[i] ?? "").Trim();
|
|
if (needle.Length > 0
|
|
&& reason.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
pass = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
detail = $"reason='{reason}' any='{raw}' pass={pass}";
|
|
break;
|
|
}
|
|
case "outbreak_worthy":
|
|
{
|
|
string statusId = (cmd.value ?? "").Trim();
|
|
bool want = ParseBool(cmd.label, defaultValue: true);
|
|
bool have = StatusOutbreakFeed.IsOutbreakWorthy(statusId);
|
|
pass = have == want;
|
|
detail = $"status={statusId} worthy={have} want={want}";
|
|
break;
|
|
}
|
|
case "decision_worthy":
|
|
{
|
|
string decisionId = (cmd.value ?? "").Trim();
|
|
bool want = ParseBool(cmd.label, defaultValue: true);
|
|
bool have = EventCatalog.Decision.IsCameraWorthy(decisionId);
|
|
pass = have == want;
|
|
detail = $"decision={decisionId} worthy={have} want={want}";
|
|
break;
|
|
}
|
|
case "dossier_task_refresh":
|
|
{
|
|
// Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI.
|
|
// Empty live AI is ok - chip may keep a sticky prior label (never cleared on gaps).
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string live = UnitDossier.ReadLiveTask(focus);
|
|
string after = WatchCaption.HarnessBlankAndRefreshTask();
|
|
string chip = WatchCaption.ShownTaskChipText;
|
|
if (string.IsNullOrEmpty(live))
|
|
{
|
|
pass = string.IsNullOrEmpty(after);
|
|
detail = $"live_empty dossier='{after}' sticky_chip='{chip}'";
|
|
break;
|
|
}
|
|
|
|
pass = after == live && !string.IsNullOrEmpty(chip);
|
|
detail = $"live='{live}' dossier='{after}' chip='{chip}'";
|
|
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 : "";
|
|
bool inLatest = latest != null
|
|
&& !string.IsNullOrEmpty(needle)
|
|
&& line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
// Live lover/friend patches can append after a death sample in the same wait;
|
|
// still pass when the needle exists on the focus subject's recent history.
|
|
bool inRecent = false;
|
|
if (!inLatest && !string.IsNullOrEmpty(needle))
|
|
{
|
|
IReadOnlyList<ChronicleEntry> snap = Chronicle.SnapshotForFocus();
|
|
for (int i = snap.Count - 1; i >= 0 && i >= snap.Count - 8; i--)
|
|
{
|
|
ChronicleEntry e = snap[i];
|
|
if (e != null
|
|
&& !string.IsNullOrEmpty(e.Line)
|
|
&& e.Line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
inRecent = true;
|
|
display = e.DisplayLine;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
pass = inLatest || inRecent;
|
|
detail =
|
|
$"latest='{(latest != null ? latest.DisplayLine : "")}' hit='{display}' needle='{needle}' date='{(latest != null ? latest.DateLabel : "")}' recent={inRecent}";
|
|
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 = 250f;
|
|
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 <= UnitDossier.MaxTraitChips;
|
|
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_statuses_ok":
|
|
{
|
|
UnitDossier dossier = WatchCaption.Current;
|
|
string preview = WatchCaption.LastStatusesPreview ?? "";
|
|
int count = dossier != null ? dossier.TopStatuses.Count : 0;
|
|
bool noEllipsis = preview.IndexOf("...", System.StringComparison.Ordinal) < 0;
|
|
bool bounded = count >= 0 && count <= UnitDossier.MaxStatusChips;
|
|
int wantMin = ParseCountExpect(cmd, defaultValue: 0);
|
|
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
|
|
bool countOk = cmp == "exact" || cmp == "=="
|
|
? count == wantMin
|
|
: count >= wantMin;
|
|
pass = bounded && noEllipsis && countOk;
|
|
detail =
|
|
$"statuses={count} want={wantMin} cmp={cmp} preview='{preview}' layoutOk={WatchCaption.LastLayoutOk}";
|
|
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 = ResolveActivitySubjectId();
|
|
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} id={id}";
|
|
break;
|
|
}
|
|
case "activity_log_not_contains":
|
|
{
|
|
long id = ResolveActivitySubjectId();
|
|
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_verb_dedupe":
|
|
{
|
|
// Distinct play-like task ids should collapse to one Activity line within the cooldown.
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
pass = false;
|
|
detail = "no_live_focus";
|
|
break;
|
|
}
|
|
|
|
ActivityLog.ResetSampleCounters();
|
|
string[] keys =
|
|
{
|
|
"task_unit_play",
|
|
"child_play",
|
|
"fun_move",
|
|
"child_run_around",
|
|
"frolic"
|
|
};
|
|
int added = ActivityLog.HarnessSpamSameVerb(focus, keys);
|
|
int suppressed = ActivityLog.VerbSuppressedSinceClear;
|
|
pass = added == 1 && suppressed >= keys.Length - 1;
|
|
detail =
|
|
$"added={added} want_added=1 suppressed={suppressed} want_suppressed>={keys.Length - 1} keys={keys.Length}";
|
|
break;
|
|
}
|
|
case "activity_verb_exempt":
|
|
{
|
|
// Combat / high-signal verbs skip same-verb cooldown.
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
if (focus == null || !focus.isAlive())
|
|
{
|
|
pass = false;
|
|
detail = "no_live_focus";
|
|
break;
|
|
}
|
|
|
|
ActivityLog.ResetSampleCounters();
|
|
string[] keys =
|
|
{
|
|
"task_attack",
|
|
"punch_a_building",
|
|
"tantrum",
|
|
"task_unit_attack"
|
|
};
|
|
int added = ActivityLog.HarnessSpamSameVerb(focus, keys);
|
|
int suppressed = ActivityLog.VerbSuppressedSinceClear;
|
|
pass = added == keys.Length && suppressed == 0;
|
|
detail =
|
|
$"added={added} want_added={keys.Length} suppressed={suppressed} want_suppressed=0";
|
|
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;
|
|
|
|
// Building/place labels must never become companion "with …" on work/unload.
|
|
// Objects use "at the …"; settlements use "in …".
|
|
ActivityContext bonfireCtx = ActivityContext.ForHarness(
|
|
actorName,
|
|
"",
|
|
"human",
|
|
place: "bonfire",
|
|
placeIsObject: true);
|
|
ActivityProse.Format(
|
|
ActivityKind.TaskStart,
|
|
"type_bonfire",
|
|
bonfireCtx,
|
|
"Works a bonfire",
|
|
12,
|
|
1,
|
|
out string bonfireLine,
|
|
out _);
|
|
bool bonfireOk = !string.IsNullOrEmpty(bonfireLine)
|
|
&& bonfireLine.IndexOf("at the bonfire", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
&& bonfireLine.IndexOf("with bonfire", System.StringComparison.OrdinalIgnoreCase) < 0
|
|
&& bonfireLine.IndexOf("in bonfire", System.StringComparison.OrdinalIgnoreCase) < 0;
|
|
|
|
ActivityContext unloadTownCtx = ActivityContext.ForHarness(
|
|
actorName,
|
|
"",
|
|
"human",
|
|
place: "Riverhold",
|
|
carrying: "wheat");
|
|
ActivityProse.Format(
|
|
ActivityKind.ActionBeat,
|
|
"BehUnloadResources",
|
|
unloadTownCtx,
|
|
"Unloads wheat",
|
|
13,
|
|
1,
|
|
out string unloadTownLine,
|
|
out _);
|
|
bool unloadTownOk = !string.IsNullOrEmpty(unloadTownLine)
|
|
&& unloadTownLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
&& unloadTownLine.IndexOf("in Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
&& unloadTownLine.IndexOf("with town", System.StringComparison.OrdinalIgnoreCase) < 0;
|
|
|
|
// 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
|
|
&& bonfireOk && unloadTownOk;
|
|
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} bonfireOk={bonfireOk} unloadTownOk={unloadTownOk} "
|
|
+ $"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}' "
|
|
+ $"bonfire='{bonfireLine}' unloadTown='{unloadTownLine}'";
|
|
break;
|
|
}
|
|
case "activity_taxonomy_audit":
|
|
{
|
|
ActivityVoiceAuditResult audit = ActivityVoiceHarness.RunAudit(HarnessDir());
|
|
pass = audit.Passed;
|
|
detail = audit.Detail;
|
|
break;
|
|
}
|
|
case "activity_status_audit":
|
|
{
|
|
ActivityStatusAuditResult audit = ActivityStatusHarness.RunAudit(HarnessDir());
|
|
pass = audit.Passed;
|
|
detail = audit.Detail;
|
|
break;
|
|
}
|
|
case "activity_happiness_audit":
|
|
{
|
|
ActivityHappinessAuditResult audit = ActivityHappinessHarness.RunAudit(HarnessDir());
|
|
pass = audit.Passed;
|
|
detail = audit.Detail;
|
|
break;
|
|
}
|
|
case "world_log_audit":
|
|
{
|
|
WorldLogEventAuditResult audit = WorldLogEventHarness.RunAudit(HarnessDir());
|
|
pass = audit.Passed;
|
|
detail = audit.Detail;
|
|
break;
|
|
}
|
|
case "disaster_war_inventory":
|
|
case "disaster_war_audit":
|
|
{
|
|
DisasterWarAuditResult audit = DisasterWarHarness.RunAudit(HarnessDir());
|
|
WorldLogEventHarness.DumpDisasterWarInventory(HarnessDir());
|
|
pass = audit.Passed;
|
|
detail = audit.Detail;
|
|
break;
|
|
}
|
|
case "domain_event_audit":
|
|
{
|
|
DomainEventAuditResult audit = DomainEventHarness.RunAudit(HarnessDir());
|
|
pass = audit.Passed;
|
|
detail = audit.Detail;
|
|
break;
|
|
}
|
|
case "event_inject_coverage":
|
|
{
|
|
Actor focus = ResolveUnit(null)
|
|
?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
|
|
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
EventInjectCoverageResult coverage =
|
|
EventInjectCoverageHarness.Run(HarnessDir(), focus);
|
|
pass = coverage.Passed;
|
|
detail = coverage.Detail;
|
|
break;
|
|
}
|
|
case "event_live_apply_loop":
|
|
{
|
|
Actor focus = ResolveUnit(null)
|
|
?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
|
|
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
EventLiveApplyLoopResult loop =
|
|
EventLiveApplyLoopHarness.Run(HarnessDir(), focus);
|
|
pass = loop.Passed;
|
|
detail = loop.Detail;
|
|
break;
|
|
}
|
|
case "event_live_relationship":
|
|
{
|
|
Actor focus = ResolveUnit(null)
|
|
?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
|
|
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
EventLiveRelationshipResult rel =
|
|
EventLiveRelationshipHarness.Run(HarnessDir(), focus);
|
|
pass = rel.Passed;
|
|
detail = rel.Detail;
|
|
break;
|
|
}
|
|
case "event_live_pipelines":
|
|
{
|
|
Actor focus = ResolveUnit(null)
|
|
?? (MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null)
|
|
?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
|
EventLivePipelinesResult pipes =
|
|
EventLivePipelinesHarness.Run(HarnessDir(), focus);
|
|
pass = pipes.Passed;
|
|
detail = pipes.Detail;
|
|
break;
|
|
}
|
|
case "event_live_coverage":
|
|
{
|
|
// Report-only: seed if empty, write TSV, fail only on live_level=fail.
|
|
if (EventLiveCoverageLedger.RowCount == 0)
|
|
{
|
|
EventLiveCoverageLedger.SeedFromCatalogs();
|
|
}
|
|
|
|
string summary = EventLiveCoverageLedger.Write(HarnessDir());
|
|
EventLiveCoverageSummary counts = EventLiveCoverageLedger.Summarize();
|
|
pass = counts.Fail == 0;
|
|
detail = summary;
|
|
break;
|
|
}
|
|
case "mutation_discovery":
|
|
{
|
|
MutationDiscoveryResult discovery = MutationDiscoveryHarness.Run(HarnessDir());
|
|
pass = discovery.Passed;
|
|
detail = discovery.Detail;
|
|
break;
|
|
}
|
|
case "activity_happiness_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = HappinessEventRouter.NotesSinceClear;
|
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
|
pass = mode == "min" ? got >= want : got == want;
|
|
detail = $"notes={got} want={want} mode={mode} last={HappinessEventRouter.LastEffectId}";
|
|
break;
|
|
}
|
|
case "activity_happiness_ring_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = HappinessEventRouter.RingWritesSinceClear;
|
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
|
pass = mode == "min" ? got >= want : got == want;
|
|
detail = $"ring={got} want={want} mode={mode}";
|
|
break;
|
|
}
|
|
case "activity_happiness_life_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = HappinessEventRouter.LifeWritesSinceClear;
|
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
|
pass = mode == "min" ? got >= want : got == want;
|
|
detail = $"life={got} want={want} mode={mode}";
|
|
break;
|
|
}
|
|
case "activity_happiness_task_id":
|
|
{
|
|
long id = ResolveActivitySubjectId();
|
|
|
|
string want = cmd.value ?? "";
|
|
string got = "";
|
|
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1);
|
|
if (list != null && list.Count > 0 && list[0] != null)
|
|
{
|
|
got = list[0].TaskId ?? "";
|
|
}
|
|
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& got.Equals(want, StringComparison.OrdinalIgnoreCase);
|
|
detail = $"got='{got}' want='{want}' id={id}";
|
|
break;
|
|
}
|
|
case "activity_happiness_merged":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = HappinessEventRouter.MergedSinceClear;
|
|
string mode = (cmd.label ?? "min").Trim().ToLowerInvariant();
|
|
pass = mode == "exact" ? got == want : got >= want;
|
|
detail = $"merged={got} want={want} mode={mode}";
|
|
break;
|
|
}
|
|
case "activity_status_gain_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = ActivityLog.StatusGainsSinceClear;
|
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
|
pass = mode == "min" ? got >= want : got == want;
|
|
detail = $"gains={got} want={want} mode={mode} last={ActivityLog.LastStatusGainId}";
|
|
break;
|
|
}
|
|
case "activity_status_loss_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = ActivityLog.StatusLossesSinceClear;
|
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
|
pass = mode == "min" ? got >= want : got == want;
|
|
detail = $"losses={got} want={want} mode={mode} last={ActivityLog.LastStatusLossId}";
|
|
break;
|
|
}
|
|
case "activity_status_refresh_count":
|
|
{
|
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
|
int got = ActivityLog.StatusRefreshesSinceClear;
|
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
|
pass = mode == "min" ? got >= want : got == want;
|
|
detail = $"refreshes={got} want={want} mode={mode}";
|
|
break;
|
|
}
|
|
case "activity_status_active":
|
|
{
|
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
string statusId = cmd.value ?? "";
|
|
bool expectActive = true;
|
|
string rawExpect = (cmd.label ?? cmd.expect ?? "true").Trim().ToLowerInvariant();
|
|
if (rawExpect == "false" || rawExpect == "0" || rawExpect == "no")
|
|
{
|
|
expectActive = false;
|
|
}
|
|
|
|
bool active = StatusGameApi.HasStatus(focus, statusId);
|
|
pass = active == expectActive;
|
|
detail = $"status={statusId} active={active} expectActive={expectActive}";
|
|
break;
|
|
}
|
|
case "activity_status_task_id":
|
|
{
|
|
long id = WatchCaption.CurrentUnitId;
|
|
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
try
|
|
{
|
|
id = MoveCamera._focus_unit.getID();
|
|
}
|
|
catch
|
|
{
|
|
id = 0;
|
|
}
|
|
}
|
|
|
|
string want = cmd.value ?? "";
|
|
string got = "";
|
|
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1);
|
|
if (list != null && list.Count > 0 && list[0] != null)
|
|
{
|
|
got = list[0].TaskId ?? "";
|
|
}
|
|
|
|
pass = !string.IsNullOrEmpty(want)
|
|
&& got.Equals(want, StringComparison.OrdinalIgnoreCase);
|
|
detail = $"got='{got}' want='{want}'";
|
|
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 display = latest != null ? latest.DisplayLine : "";
|
|
pass = false;
|
|
if (!string.IsNullOrEmpty(needle))
|
|
{
|
|
IReadOnlyList<ChronicleEntry> snap = Chronicle.SnapshotMemory();
|
|
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)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string line = e.Line ?? "";
|
|
string lore = e.HudLine ?? "";
|
|
if (line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| lore.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
pass = true;
|
|
display = e.DisplayLine ?? display;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
detail = $"world_hit='{display}' needle='{needle}' age='{Chronicle.CurrentAgeName}' latest='{(latest != null ? latest.DisplayLine : "")}'";
|
|
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;
|
|
// want=true: Lore open with a subject; want=false: no detail (panel may be closed).
|
|
pass = want ? (Chronicle.HudVisible && have) : !have;
|
|
detail =
|
|
$"detailId={ChronicleHud.DetailUnitId} want={want} visible={Chronicle.HudVisible} 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 "grave_stacks":
|
|
{
|
|
// Prefer value when set so want=0 works (count>0 would skip an explicit 0).
|
|
int want = !string.IsNullOrEmpty(cmd.value)
|
|
? ParseInt(cmd.value, 0)
|
|
: cmd.count;
|
|
int have = GraveMarkers.StackCount;
|
|
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
|
if (cmp == "min" || cmp == ">=" || string.IsNullOrEmpty(cmp))
|
|
{
|
|
pass = have >= want;
|
|
}
|
|
else if (cmp == "max" || cmp == "<=")
|
|
{
|
|
pass = have <= want;
|
|
}
|
|
else if (cmp == "eq" || cmp == "==")
|
|
{
|
|
pass = have == want;
|
|
}
|
|
else
|
|
{
|
|
pass = have >= want;
|
|
}
|
|
|
|
detail = $"stacks={have} want={want} cmp={cmp}";
|
|
break;
|
|
}
|
|
case "grave_entries":
|
|
{
|
|
int want = cmd.count > 0 ? cmd.count : ParseInt(cmd.value, 1);
|
|
Vector3 pos = Vector3.zero;
|
|
if (MoveCamera.instance != null)
|
|
{
|
|
pos = MoveCamera.instance.transform.position;
|
|
pos.z = 0f;
|
|
}
|
|
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
pos = MoveCamera._focus_unit.current_position;
|
|
}
|
|
|
|
int tx = Mathf.FloorToInt(pos.x);
|
|
int ty = Mathf.FloorToInt(pos.y);
|
|
int have = GraveMarkers.EntryCountAt(tx, ty);
|
|
pass = have >= want;
|
|
detail = $"tile={tx},{ty} entries={have} want={want}";
|
|
break;
|
|
}
|
|
case "grave_stack_scope":
|
|
case "grave_panel":
|
|
{
|
|
// grave_panel kept as alias during transition; means Lore grave-stack scope.
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
bool have = Chronicle.HudVisible && ChronicleHud.GraveStackScopeActive;
|
|
pass = have == want;
|
|
detail =
|
|
$"scope={ChronicleHud.GraveStackScopeActive} lore={Chronicle.HudVisible} want={want} detail={ChronicleHud.DetailUnitId} filtered={ChronicleHud.GraveStackFilteredCount}";
|
|
break;
|
|
}
|
|
case "grave_permanent":
|
|
{
|
|
bool want = ParseBool(cmd.value, defaultValue: true);
|
|
int tx = ChronicleHud.GraveStackTileX;
|
|
int ty = ChronicleHud.GraveStackTileY;
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
Vector3 p = MoveCamera._focus_unit.current_position;
|
|
int ftx = Mathf.FloorToInt(p.x);
|
|
int fty = Mathf.FloorToInt(p.y);
|
|
if (GraveMarkers.TryGetStack(ftx, fty, out _))
|
|
{
|
|
tx = ftx;
|
|
ty = fty;
|
|
}
|
|
}
|
|
|
|
bool have = GraveMarkers.IsStackPermanent(tx, ty);
|
|
pass = have == want;
|
|
detail = $"tile={tx},{ty} permanent={have} want={want}";
|
|
break;
|
|
}
|
|
case "grave_search_filtered":
|
|
{
|
|
int want = cmd.count > 0 ? cmd.count : ParseInt(cmd.value, 1);
|
|
pass = ChronicleHud.GraveStackScopeActive
|
|
&& ChronicleHud.DetailUnitId == 0
|
|
&& ChronicleHud.GraveStackFilteredCount == want;
|
|
detail =
|
|
$"filtered={ChronicleHud.GraveStackFilteredCount} want={want} query='{ChronicleHud.SearchText}' scope={ChronicleHud.GraveStackScopeActive}";
|
|
break;
|
|
}
|
|
case "grave_search_layout":
|
|
{
|
|
bool fills = ChronicleHud.GraveSearchFillsTools;
|
|
pass = ChronicleHud.GraveStackScopeActive
|
|
&& ChronicleHud.DetailUnitId == 0
|
|
&& fills;
|
|
detail =
|
|
$"fills={fills} scope={ChronicleHud.GraveStackScopeActive} detail={ChronicleHud.DetailUnitId}";
|
|
break;
|
|
}
|
|
case "grave_death_subtitle":
|
|
{
|
|
string needle = (cmd.value ?? "").Trim();
|
|
long id = ChronicleHud.DetailUnitId;
|
|
string have = id != 0 ? Chronicle.FormatGraveDeathSubtitle(id) : "";
|
|
pass = !string.IsNullOrEmpty(needle)
|
|
&& !string.IsNullOrEmpty(have)
|
|
&& have.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
|
detail = $"subtitle='{have}' wantContains='{needle}' detailId={id}";
|
|
break;
|
|
}
|
|
case "death_manner":
|
|
{
|
|
long id = ChronicleHud.DetailUnitId;
|
|
DeathManner have = Chronicle.LatestDeathMannerFor(id);
|
|
string haveLabel = Chronicle.DeathMannerLabel(have);
|
|
string want = (cmd.value ?? "").Trim();
|
|
pass = id != 0
|
|
&& (haveLabel.Equals(want, System.StringComparison.OrdinalIgnoreCase)
|
|
|| 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 == focusId
|
|
&& DossierAvatar.ShowingLiveAvatar;
|
|
detail =
|
|
$"fallenFocus={detailStr} focusId={focusId} dossierId={WatchCaption.CurrentUnitId} avatarLive={DossierAvatar.ShowingLiveAvatar}";
|
|
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 drops = InterestDropLog.FormatRecent(6);
|
|
string snap =
|
|
$"idle={SpectatorMode.Active} focus={MoveCamera.hasFocusUnit()} powerBar={CanvasMain.isBottomBarShowing()} unit={FocusLabel()} tip={CameraDirector.LastWatchLabel} tipAsset={CameraDirector.LastWatchAssetId} bad={StateProbe.BadEventCount} score={InterestDirector.CurrentScoreLabel} discoveryPending={SpeciesDiscovery.PendingCount} caption={WatchCaption.LastHeadline} chronicle={Chronicle.Count} drops={InterestDropLog.Count}";
|
|
if (!string.IsNullOrEmpty(drops))
|
|
{
|
|
snap += " recentDrop=[" + drops + "]";
|
|
}
|
|
|
|
_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
|
|
{
|
|
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 bool IsEnsembleCombatTip(string tip)
|
|
{
|
|
if (string.IsNullOrEmpty(tip))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("War -", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("Plot -", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase)
|
|
|| tip.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
private static long ResolveActivitySubjectId()
|
|
{
|
|
// Prefer the camera focus (harness apply/assert target) over watch caption,
|
|
// which can still point at a prior spectator subject during nested regression.
|
|
try
|
|
{
|
|
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
|
{
|
|
long focusId = MoveCamera._focus_unit.getID();
|
|
if (focusId != 0)
|
|
{
|
|
_activitySubjectId = focusId;
|
|
return focusId;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
if (_activitySubjectId != 0)
|
|
{
|
|
return _activitySubjectId;
|
|
}
|
|
|
|
return WatchCaption.CurrentUnitId;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// If the reason line names a living unit that is not the scene subject/related, return that name.
|
|
/// </summary>
|
|
private static string FindStrangerNamedInReason(string reason, InterestCandidate scene)
|
|
{
|
|
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
|
|
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
|
try
|
|
{
|
|
foreach (Actor unit in World.world.units)
|
|
{
|
|
if (unit == null || !unit.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string name = SafeName(unit);
|
|
if (string.IsNullOrEmpty(name) || name.Length < 3)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(subjectName)
|
|
&& name.Equals(subjectName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(relatedName)
|
|
&& name.Equals(relatedName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (reason.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return name;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
|
|
private static float EventStrengthFromTierHint(string raw, float fallback)
|
|
{
|
|
if (string.IsNullOrEmpty(raw))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
switch (raw.Trim().ToLowerInvariant())
|
|
{
|
|
case "ambient":
|
|
return 25f;
|
|
case "curiosity":
|
|
return 40f;
|
|
case "action":
|
|
// Clears cutInMargin (35) over enriched Curiosity CharacterLed (~64).
|
|
return 100f;
|
|
case "story":
|
|
return 80f;
|
|
case "epic":
|
|
// Clears cutInMargin over Action (~108 TotalScore).
|
|
return 150f;
|
|
default:
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
private static InterestLeadKind LeadFromTierHint(string raw, InterestLeadKind fallback)
|
|
{
|
|
if (string.IsNullOrEmpty(raw))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
string s = raw.Trim().ToLowerInvariant();
|
|
if (s == "ambient" || s == "curiosity" || s == "story")
|
|
{
|
|
return InterestLeadKind.CharacterLed;
|
|
}
|
|
|
|
if (s == "action" || s == "epic")
|
|
{
|
|
return InterestLeadKind.EventLed;
|
|
}
|
|
|
|
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 string SampleRailTip()
|
|
{
|
|
LifeSagaRoster.Tick(Time.unscaledTime);
|
|
LifeSagaRail.Refresh();
|
|
string have = LifeSagaRail.LastTipSample ?? "";
|
|
if (!string.IsNullOrEmpty(have))
|
|
{
|
|
return have;
|
|
}
|
|
|
|
var slots = new System.Collections.Generic.List<LifeSagaSlot>(10);
|
|
LifeSagaRoster.CopySlots(slots);
|
|
if (slots.Count > 0 && slots[0] != null)
|
|
{
|
|
return LifeSagaOverview.Build(slots[0]) ?? "";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
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 int ParseInt(string raw, int defaultValue)
|
|
{
|
|
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int 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 = "";
|
|
_rememberedTip = "";
|
|
_rememberedRelatedId = 0;
|
|
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");
|
|
}
|