Implement status tracking enhancements in ActivityLog and AgentHarness. Introduce methods for noting status gains and losses, resetting status counters, and auditing status activities. Update HUD icon handling for status-related actions and add new harness scenarios for status management. Increment version in mod.json to reflect these changes.

This commit is contained in:
DazedAnon 2026-07-15 13:14:36 -05:00
parent 3f56cf457e
commit d5803bbd59
11 changed files with 1488 additions and 2 deletions

View file

@ -113,6 +113,51 @@ public static class ActivityAssetCatalog
return ids;
}
public static List<string> EnumerateLiveStatusIds()
{
var ids = new List<string>();
try
{
StatusLibrary lib = AssetManager.status;
if (lib?.list == null)
{
return ids;
}
for (int i = 0; i < lib.list.Count; i++)
{
StatusAsset asset = lib.list[i];
if (asset != null && !string.IsNullOrEmpty(asset.id))
{
ids.Add(asset.id);
}
}
}
catch
{
// Library can be unavailable during early boot.
}
return ids;
}
public static StatusAsset TryGetStatusAsset(string statusId)
{
if (string.IsNullOrEmpty(statusId))
{
return null;
}
try
{
return AssetManager.status?.get(statusId.Trim());
}
catch
{
return null;
}
}
public static List<string> EnumerateLiveTaskIds()
{
var ids = new List<string>();

View file

@ -6,7 +6,9 @@ namespace IdleSpectator;
public enum ActivityKind
{
TaskStart,
ActionBeat
ActionBeat,
StatusGain,
StatusLoss
}
public sealed class ActivityEntry
@ -43,6 +45,13 @@ public static class ActivityLog
private static int _notesSinceClear;
private static int _tasksSinceClear;
private static int _beatsSinceClear;
private static int _statusGainsSinceClear;
private static int _statusLossesSinceClear;
private static int _statusRefreshesSinceClear;
private static string _lastStatusGainId = "";
private static string _lastStatusLossId = "";
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary>
private static long _statusCounterSubjectId;
private static float _clearedAt = -1f;
private static readonly Dictionary<string, int> TaskHitsSinceClear =
new Dictionary<string, int>();
@ -51,6 +60,11 @@ public static class ActivityLog
public static int NotesSinceClear => _notesSinceClear;
public static int TasksSinceClear => _tasksSinceClear;
public static int BeatsSinceClear => _beatsSinceClear;
public static int StatusGainsSinceClear => _statusGainsSinceClear;
public static int StatusLossesSinceClear => _statusLossesSinceClear;
public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
public static string LastStatusGainId => _lastStatusGainId ?? "";
public static string LastStatusLossId => _lastStatusLossId ?? "";
public static void ClearSession()
{
@ -67,10 +81,43 @@ public static class ActivityLog
_notesSinceClear = 0;
_tasksSinceClear = 0;
_beatsSinceClear = 0;
_statusGainsSinceClear = 0;
_statusLossesSinceClear = 0;
_statusRefreshesSinceClear = 0;
_lastStatusGainId = "";
_lastStatusLossId = "";
_clearedAt = Time.unscaledTime;
TaskHitsSinceClear.Clear();
}
public static void ResetStatusCounters()
{
_statusGainsSinceClear = 0;
_statusLossesSinceClear = 0;
_statusRefreshesSinceClear = 0;
_lastStatusGainId = "";
_lastStatusLossId = "";
_statusCounterSubjectId = 0;
}
/// <summary>Harness: reset counters and only count the focus subject afterward.</summary>
public static void ResetStatusCountersForSubject(long subjectId)
{
ResetStatusCounters();
_statusCounterSubjectId = subjectId;
}
public static void NoteStatusRefresh(string statusId, long subjectId = 0)
{
_ = statusId;
if (_statusCounterSubjectId != 0 && subjectId != 0 && subjectId != _statusCounterSubjectId)
{
return;
}
_statusRefreshesSinceClear++;
}
public static string FormatSampleStats()
{
float elapsed = _clearedAt < 0f ? 0f : Mathf.Max(0.01f, Time.unscaledTime - _clearedAt);
@ -102,6 +149,8 @@ public static class ActivityLog
float perSubjectPerMin = BySubject.Count > 0 ? perMin / BySubject.Count : 0f;
return
$"elapsed={elapsed:0.0}s notes={_notesSinceClear} tasks={_tasksSinceClear} beats={_beatsSinceClear} "
+ $"status_gains={_statusGainsSinceClear} status_losses={_statusLossesSinceClear} "
+ $"status_refreshes={_statusRefreshesSinceClear} "
+ $"per_sec={perSec:0.00} per_min={perMin:0.0} per_subject_per_min={perSubjectPerMin:0.00} "
+ $"subjects={BySubject.Count} ring_lines={lines} top=[{top}]";
}
@ -443,6 +492,115 @@ public static class ActivityLog
});
}
/// <summary>
/// Status gain/loss Activity line (no beat cooldown). Living actors only.
/// </summary>
public static void NoteStatusChange(Actor actor, string statusId, bool gained)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return;
}
try
{
if (!actor.isAlive())
{
return;
}
}
catch
{
return;
}
long id;
try
{
id = actor.getID();
}
catch
{
return;
}
if (id == 0)
{
return;
}
string actorName;
try
{
actorName = actor.getName();
if (string.IsNullOrEmpty(actorName))
{
actorName = actor.asset != null ? actor.asset.id : "creature";
}
}
catch
{
actorName = "creature";
}
ForceStatusNote(id, statusId.Trim(), gained, actorName);
}
/// <summary>Harness / forced path for status lines without a live transition.</summary>
public static bool ForceStatusNote(
long subjectId,
string statusId,
bool gained,
string actorName = "")
{
if (subjectId == 0 || string.IsNullOrEmpty(statusId))
{
return false;
}
string id = statusId.Trim();
string name = string.IsNullOrEmpty(actorName) ? "Someone" : actorName;
string phrase = ActivityStatusProse.Phrase(id, gained, out _);
string clause = LowerFirst(phrase);
string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause;
string displayRich = ActivityProse.ColorName(name)
+ (string.IsNullOrEmpty(clause) ? "" : " " + clause);
string key = ActivityStatusProse.TaskKey(id, gained);
string fact = (gained ? "Gained " : "Lost ") + id;
if (gained)
{
if (_statusCounterSubjectId == 0 || subjectId == _statusCounterSubjectId)
{
_statusGainsSinceClear++;
_lastStatusGainId = id;
}
}
else
{
if (_statusCounterSubjectId == 0 || subjectId == _statusCounterSubjectId)
{
_statusLossesSinceClear++;
_lastStatusLossId = id;
}
}
RecordSample(key, isBeat: true);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
Kind = gained ? ActivityKind.StatusGain : ActivityKind.StatusLoss,
TaskId = key,
JobId = "",
TargetLabel = id,
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
return true;
}
private static string LowerFirst(string text)
{
if (string.IsNullOrEmpty(text))

View file

@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
public sealed class ActivityStatusAuditResult
{
public bool Passed;
public string Detail = "";
public int Total;
public int MissingAuthored;
public int MissingIcon;
public int LocaleLeaks;
public int EmptyPhrase;
public int OrphanAuthored;
}
/// <summary>
/// Exhaustive live-library audit for status Activity prose and icons.
/// </summary>
public static class ActivityStatusHarness
{
public static ActivityStatusAuditResult RunAudit(string outputDirectory)
{
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds();
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in ActivityStatusProse.AuthoredIds)
{
authored.Add(id);
}
int missingAuthored = 0;
int missingIcon = 0;
int localeLeaks = 0;
int emptyPhrase = 0;
int orphanAuthored = 0;
var tsv = new StringBuilder();
tsv.AppendLine(
"id\tgain\tloss\tauthored\ticon\tlocale_leak\tduration\tallow_timer_reset\tnotes");
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < liveIds.Count; i++)
{
string id = liveIds[i];
liveSet.Add(id);
StatusAsset asset = ActivityAssetCatalog.TryGetStatusAsset(id);
string gain = ActivityStatusProse.Phrase(id, gained: true, out bool authoredGain);
string loss = ActivityStatusProse.Phrase(id, gained: false, out bool authoredLoss);
bool hasAuthored = authoredGain && authoredLoss && ActivityStatusProse.HasAuthored(id);
if (!hasAuthored)
{
missingAuthored++;
}
if (string.IsNullOrEmpty(gain) || string.IsNullOrEmpty(loss))
{
emptyPhrase++;
}
Sprite icon = HudIcons.FromStatusId(id)
?? HudIcons.FromUiIcon("iconConfused")
?? HudIcons.Task();
bool iconOk = icon != null;
if (!iconOk)
{
missingIcon++;
}
bool leak = LooksLikeLocaleLeak(gain, id) || LooksLikeLocaleLeak(loss, id);
if (leak)
{
localeLeaks++;
}
string duration = "";
string allowReset = "";
try
{
if (asset != null)
{
duration = asset.duration.ToString("0.##");
allowReset = asset.allow_timer_reset ? "1" : "0";
}
}
catch
{
// ignore
}
string notes = "";
if (!hasAuthored)
{
notes = "missing_authored";
}
else if (!iconOk)
{
notes = "missing_icon";
}
else if (leak)
{
notes = "locale_leak";
}
else if (string.IsNullOrEmpty(gain) || string.IsNullOrEmpty(loss))
{
notes = "empty_phrase";
}
else
{
notes = "ok";
}
tsv.Append(id).Append('\t')
.Append(Escape(gain)).Append('\t')
.Append(Escape(loss)).Append('\t')
.Append(hasAuthored ? "1" : "0").Append('\t')
.Append(iconOk ? "1" : "0").Append('\t')
.Append(leak ? "1" : "0").Append('\t')
.Append(duration).Append('\t')
.Append(allowReset).Append('\t')
.Append(notes).AppendLine();
// Smoke the milestone formatting + keys.
string gainKey = ActivityStatusProse.TaskKey(id, true);
string lossKey = ActivityStatusProse.TaskKey(id, false);
if (string.IsNullOrEmpty(gainKey) || string.IsNullOrEmpty(lossKey))
{
emptyPhrase++;
}
}
foreach (string id in authored)
{
if (!liveSet.Contains(id))
{
orphanAuthored++;
tsv.Append(id).Append('\t')
.Append('\t').Append('\t')
.Append("1\t0\t0\t\t\torphan_authored").AppendLine();
}
}
try
{
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(
Path.Combine(outputDirectory, "activity-status-audit.tsv"),
tsv.ToString(),
Encoding.UTF8);
}
}
catch
{
// Artifact write is best-effort.
}
bool passed = liveIds.Count > 20
&& missingAuthored == 0
&& missingIcon == 0
&& localeLeaks == 0
&& emptyPhrase == 0
&& orphanAuthored == 0;
return new ActivityStatusAuditResult
{
Passed = passed,
Total = liveIds.Count,
MissingAuthored = missingAuthored,
MissingIcon = missingIcon,
LocaleLeaks = localeLeaks,
EmptyPhrase = emptyPhrase,
OrphanAuthored = orphanAuthored,
Detail =
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
+ $"passed={passed}"
};
}
private static bool LooksLikeLocaleLeak(string phrase, string statusId)
{
if (string.IsNullOrEmpty(phrase))
{
return true;
}
if (phrase.IndexOf("status_", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
if (!string.IsNullOrEmpty(statusId)
&& statusId.IndexOf('_') >= 0
&& phrase.IndexOf(statusId, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
return false;
}
private static string Escape(string value)
{
if (string.IsNullOrEmpty(value))
{
return "";
}
return value.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' ');
}
}

View file

@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Authored milestone-style gain/loss phrases for every live WorldBox status asset.
/// Runtime fallback is HUD-only safety; the harness audit requires authored coverage.
/// </summary>
public static class ActivityStatusProse
{
private static readonly Dictionary<string, (string gain, string loss)> Authored =
new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase)
{
["afterglow"] = ("Basks in an afterglow", "Lets the afterglow fade"),
["angry"] = ("Flies into a rage", "Cools off"),
["ash_fever"] = ("Catches ash fever", "Recovers from ash fever"),
["being_suspicious"] = ("Starts acting suspicious", "Stops looking so suspicious"),
["budding"] = ("Begins budding", "Finishes budding"),
["burning"] = ("Catches fire", "Stops burning"),
["caffeinated"] = ("Gets a caffeine rush", "Comes down from caffeine"),
["confused"] = ("Becomes confused", "Regains clarity"),
["cough"] = ("Starts coughing", "Stops coughing"),
["crying"] = ("Breaks into tears", "Dries their eyes"),
["cursed"] = ("Is cursed", "Shakes off the curse"),
["dash"] = ("Dashes forward", "Ends the dash"),
["dodge"] = ("Dodges aside", "Stops dodging"),
["drowning"] = ("Starts drowning", "Gets air again"),
["egg"] = ("Turns into an egg", "Hatches from the egg"),
["enchanted"] = ("Becomes enchanted", "Loses the enchantment"),
["fell_in_love"] = ("Falls in love", "Lets the infatuation pass"),
["festive_spirit"] = ("Catches the festive spirit", "Settles out of the festive mood"),
["flicked"] = ("Gets flicked", "Recovers from the flick"),
["frozen"] = ("Freezes solid", "Thaws out"),
["had_bad_dream"] = ("Wakes from a bad dream", "Shakes off the bad dream"),
["had_good_dream"] = ("Wakes from a good dream", "Lets the good dream fade"),
["had_nightmare"] = ("Wakes from a nightmare", "Shakes off the nightmare"),
["handsome_migrant"] = ("Turns heads as a handsome migrant", "Stops drawing migrant attention"),
["inspired"] = ("Feels inspired", "Loses the spark of inspiration"),
["invincible"] = ("Becomes invincible", "Loses invincibility"),
["just_ate"] = ("Just finished eating", "Is ready to eat again"),
["laughing"] = ("Bursts out laughing", "Stops laughing"),
["magnetized"] = ("Becomes magnetized", "Loses the magnetic pull"),
["motivated"] = ("Feels motivated", "Loses motivation"),
["on_guard"] = ("Goes on guard", "Drops guard"),
["poisoned"] = ("Is poisoned", "Purges the poison"),
["possessed"] = ("Is possessed", "Breaks free of possession"),
["possessed_follower"] = ("Falls under a possessed follower's sway", "Escapes the follower's sway"),
["powerup"] = ("Powers up", "Loses the power-up"),
["pregnant"] = ("Becomes pregnant", "Is no longer pregnant"),
["pregnant_parthenogenesis"] = ("Becomes pregnant by parthenogenesis", "Ends the parthenogenic pregnancy"),
["rage"] = ("Enters a rage", "Leaves the rage behind"),
["recovery_combat_action"] = ("Needs a moment after combat", "Is ready for combat again"),
["recovery_plot"] = ("Needs a moment after a plot", "Is ready to plot again"),
["recovery_social"] = ("Needs a moment after socializing", "Is ready to socialize again"),
["recovery_spell"] = ("Needs a moment after casting", "Is ready to cast again"),
["shield"] = ("Raises a shield", "Drops the shield"),
["singing"] = ("Starts singing", "Stops singing"),
["sleeping"] = ("Falls asleep", "Wakes up"),
["slowness"] = ("Is slowed", "Regains normal speed"),
["soul_harvested"] = ("Has their soul harvested", "Recovers from soul harvest"),
["spell_boost"] = ("Gains a spell boost", "Loses the spell boost"),
["spell_silence"] = ("Is silenced", "Can cast again"),
["starving"] = ("Is starving", "Is no longer starving"),
["strange_urge"] = ("Feels a strange urge", "The strange urge passes"),
["stunned"] = ("Is stunned", "Shakes off the stun"),
["surprised"] = ("Is surprised", "Recovers from surprise"),
["swearing"] = ("Starts swearing", "Stops swearing"),
["taking_roots"] = ("Starts taking root", "Pulls free of the roots"),
["tantrum"] = ("Throws a tantrum", "Calms down from the tantrum"),
["uprooting"] = ("Starts uprooting", "Finishes uprooting"),
["voices_in_my_head"] = ("Hears voices", "The voices fall quiet"),
};
public static bool HasAuthored(string statusId)
{
return !string.IsNullOrEmpty(statusId) && Authored.ContainsKey(statusId.Trim());
}
public static IEnumerable<string> AuthoredIds => Authored.Keys;
/// <summary>
/// Phrase without actor name. Authored when possible; otherwise localized/humanized fallback.
/// </summary>
public static string Phrase(string statusId, bool gained, out bool authored)
{
authored = false;
string id = (statusId ?? "").Trim();
if (string.IsNullOrEmpty(id))
{
return gained ? "gains an unknown status" : "loses an unknown status";
}
if (Authored.TryGetValue(id, out (string gain, string loss) pair))
{
authored = true;
string phrase = gained ? pair.gain : pair.loss;
return string.IsNullOrEmpty(phrase)
? FallbackPhrase(id, gained)
: phrase;
}
return FallbackPhrase(id, gained);
}
public static string FallbackPhrase(string statusId, bool gained)
{
string title = StatusLabelFromId(statusId);
if (string.IsNullOrEmpty(title))
{
title = HumanizeId(statusId);
}
return gained ? ("Becomes " + title) : ("Is no longer " + title);
}
public static string StatusLabelFromId(string statusId)
{
if (string.IsNullOrEmpty(statusId))
{
return "";
}
try
{
StatusAsset asset = AssetManager.status?.get(statusId.Trim());
if (asset != null)
{
string locale = asset.getLocaleID();
if (!string.IsNullOrEmpty(locale))
{
string localized = LocalizedTextManager.getText(locale);
if (!string.IsNullOrEmpty(localized)
&& !localized.Equals(locale, StringComparison.Ordinal)
&& localized.IndexOf('_') < 0)
{
return localized.Trim().ToLowerInvariant();
}
}
}
}
catch
{
// Localization may be unavailable during early boot.
}
return HumanizeId(statusId);
}
public static string HumanizeId(string statusId)
{
if (string.IsNullOrEmpty(statusId))
{
return "unknown status";
}
return statusId.Replace('_', ' ').Trim().ToLowerInvariant();
}
public static string TaskKey(string statusId, bool gained)
{
string id = string.IsNullOrEmpty(statusId) ? "unknown" : statusId.Trim();
return (gained ? "status_gain:" : "status_loss:") + id;
}
public static bool TryParseTaskKey(string key, out string statusId, out bool gained)
{
statusId = "";
gained = false;
if (string.IsNullOrEmpty(key))
{
return false;
}
string k = key.Trim();
if (k.StartsWith("status_gain:", StringComparison.OrdinalIgnoreCase))
{
gained = true;
statusId = k.Substring("status_gain:".Length).Trim();
return !string.IsNullOrEmpty(statusId);
}
if (k.StartsWith("status_loss:", StringComparison.OrdinalIgnoreCase))
{
gained = false;
statusId = k.Substring("status_loss:".Length).Trim();
return !string.IsNullOrEmpty(statusId);
}
return false;
}
}

View file

@ -0,0 +1,251 @@
using System;
using HarmonyLib;
namespace IdleSpectator;
/// <summary>
/// Feeds Activity status gain/loss lines from authoritative WorldBox status transitions.
/// Gain = new dict entry (<see cref="BaseSimObject.addNewStatusEffect"/>).
/// Loss = <see cref="Status.finish"/> (remove, expire, clear) while the actor is still alive.
/// </summary>
[HarmonyPatch]
public static class ActorStatusPatches
{
[HarmonyPatch(typeof(BaseSimObject), "addNewStatusEffect")]
[HarmonyPostfix]
public static void PostfixAddNewStatusEffect(
BaseSimObject __instance,
StatusAsset pStatusAsset,
float pOverrideTimer,
bool pColorEffect,
bool pIsActor,
bool pHasAnyStatus)
{
_ = pOverrideTimer;
_ = pColorEffect;
_ = pHasAnyStatus;
if (__instance == null || pStatusAsset == null)
{
return;
}
Actor actor = ResolveLivingActor(__instance, pIsActor);
if (actor == null)
{
return;
}
ActivityLog.NoteStatusChange(actor, pStatusAsset.id, gained: true);
}
/// <summary>
/// Count timer-refresh reapplications for harness asserts (no Activity line).
/// </summary>
[HarmonyPatch(
typeof(BaseSimObject),
"addStatusEffect",
new Type[] { typeof(StatusAsset), typeof(float), typeof(bool) })]
[HarmonyPrefix]
public static void PrefixAddStatusEffect(
BaseSimObject __instance,
StatusAsset pStatusAsset,
out bool __state)
{
__state = false;
if (__instance == null || pStatusAsset == null || string.IsNullOrEmpty(pStatusAsset.id))
{
return;
}
try
{
if (!__instance.isActor())
{
return;
}
__state = ActorAlreadyHasStatus(__instance, pStatusAsset.id);
}
catch
{
__state = false;
}
}
[HarmonyPatch(
typeof(BaseSimObject),
"addStatusEffect",
new Type[] { typeof(StatusAsset), typeof(float), typeof(bool) })]
[HarmonyPostfix]
public static void PostfixAddStatusEffect(
BaseSimObject __instance,
StatusAsset pStatusAsset,
bool __result,
bool __state)
{
if (!__state || !__result || __instance == null || pStatusAsset == null)
{
return;
}
try
{
if (!__instance.isActor())
{
return;
}
Actor actor = __instance.a;
long actorId = 0;
try
{
if (actor != null)
{
actorId = actor.getID();
}
}
catch
{
actorId = 0;
}
ActivityLog.NoteStatusRefresh(pStatusAsset.id, actorId);
}
catch
{
// ignore
}
}
[HarmonyPatch(typeof(Status), nameof(Status.finish))]
[HarmonyPrefix]
public static void PrefixFinish(Status __instance, out bool __state)
{
__state = false;
try
{
__state = __instance != null && !__instance.is_finished;
}
catch
{
__state = false;
}
}
[HarmonyPatch(typeof(Status), nameof(Status.finish))]
[HarmonyPostfix]
public static void PostfixFinish(Status __instance, bool __state)
{
if (!__state || __instance == null)
{
return;
}
BaseSimObject sim;
StatusAsset asset;
try
{
sim = __instance.sim_object;
asset = __instance.asset;
}
catch
{
return;
}
if (sim == null || asset == null || string.IsNullOrEmpty(asset.id))
{
return;
}
Actor actor;
try
{
if (!sim.isActor())
{
return;
}
actor = sim.a;
}
catch
{
return;
}
if (actor == null)
{
return;
}
// Skip death/dispose cleanup bursts - death milestone covers that moment.
try
{
if (!actor.isAlive())
{
return;
}
}
catch
{
return;
}
ActivityLog.NoteStatusChange(actor, asset.id, gained: false);
}
private static Actor ResolveLivingActor(BaseSimObject sim, bool hintedActor)
{
if (sim == null)
{
return null;
}
try
{
if (!hintedActor && !sim.isActor())
{
return null;
}
Actor actor = sim.a;
if (actor == null || !actor.isAlive())
{
return null;
}
return actor;
}
catch
{
return null;
}
}
private static bool ActorAlreadyHasStatus(BaseSimObject sim, string statusId)
{
try
{
var ids = sim.getStatusesIds();
if (ids == null)
{
return false;
}
foreach (string id in ids)
{
if (!string.IsNullOrEmpty(id)
&& id.Equals(statusId, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch
{
// fall through
}
return false;
}
}

View file

@ -537,6 +537,204 @@ public static class AgentHarness
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":
{
Actor 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 alive = false;
try
{
alive = focus != null && focus.isAlive();
}
catch
{
alive = false;
}
bool applied = alive && StatusGameApi.TryAddStatus(focus, statusId, timer);
bool ok = expectBlocked ? !applied : applied;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
WatchCaption.ForceRefreshHistory();
Emit(
cmd,
ok,
detail:
$"status={statusId} applied={applied} expectBlocked={expectBlocked} alive={alive} "
+ $"gains={ActivityLog.StatusGainsSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear} "
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
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 "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 "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();
@ -2441,6 +2639,84 @@ public static class AgentHarness
detail = audit.Detail;
break;
}
case "activity_status_audit":
{
ActivityStatusAuditResult audit = ActivityStatusHarness.RunAudit(HarnessDir());
pass = audit.Passed;
detail = audit.Detail;
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.

View file

@ -38,10 +38,14 @@ internal static class HarnessScenarios
case "activity":
case "activity_idle_smoke":
return ActivityIdleSmoke();
case "activity_status_smoke":
return ActivityStatusSmoke();
case "activity_prose_showcase":
return ActivityProseShowcase();
case "activity_taxonomy_audit":
return ActivityTaxonomyAudit();
case "activity_status_audit":
return ActivityStatusAudit();
case "activity_rate":
case "activity_rate_sample":
return ActivityRateSample();
@ -120,11 +124,133 @@ internal static class HarnessScenarios
Nested("reg_worldlog", "world_log"),
Nested("reg_chronicle", "chronicle_smoke"),
Nested("reg_activity", "activity_idle_smoke"),
Nested("reg_activity_status", "activity_status_smoke"),
Step("reg_end", "fast_timing", value: "false"),
Step("reg_snap", "snapshot"),
};
}
private static List<HarnessCommand> ActivityStatusAudit()
{
return new List<HarnessCommand>
{
Step("asa0", "wait_world"),
Step("asa1", "pick_unit", asset: "auto"),
Step("asa2", "focus", asset: "auto"),
Step("asa3", "assert", expect: "activity_status_audit"),
Step("asa4", "snapshot")
};
}
private static List<HarnessCommand> ActivityStatusSmoke()
{
return new List<HarnessCommand>
{
Step("st0", "dismiss_windows"),
Step("st1", "wait_world"),
Step("st2", "set_setting", expect: "enabled", value: "true"),
Step("st3", "set_setting", expect: "show_dossier_caption", value: "true"),
Step("st4", "activity_clear"),
Step("st5", "activity_status_reset"),
Step("st5b", "spawn", asset: "human", count: 1),
Step("st6", "pick_unit", asset: "human"),
Step("st7", "spectator", value: "off"),
Step("st8", "spectator", value: "on"),
Step("st9", "focus", asset: "auto"),
Step("st10", "wait", wait: 0.25f),
Step("st10b", "activity_status_reset"),
Step("st11", "assert", expect: "activity_status_audit"),
// Gain path (confused is widely applicable across assets)
Step("st12", "status_apply", value: "confused"),
Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st14", "assert", expect: "activity_log_contains", value: "confused"),
Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"),
Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"),
// Refresh must not create another gain line
Step("st17", "status_apply", value: "confused"),
Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"),
// Second distinct gain
Step("st20", "status_apply", value: "sleeping"),
Step("st21", "assert", expect: "activity_status_gain_count", value: "2", label: "exact"),
Step("st22", "assert", expect: "activity_log_contains", value: "asleep"),
// Explicit removal
Step("st23", "status_remove", value: "confused"),
Step("st24", "wait", wait: 0.15f),
Step("st25", "assert", expect: "activity_status_loss_count", value: "1", label: "min"),
Step("st26", "assert", expect: "activity_log_contains", value: "clarity"),
Step("st27", "assert", expect: "activity_status_active", value: "confused", label: "false"),
// Authored burning phrase via force path (game may block burning on some units)
Step("st27b", "status_force_note", value: "burning", expect: "gain"),
Step("st27c", "assert", expect: "activity_log_contains", value: "catches fire"),
// Fresh living unit for expiry / clear / death sections
Step("st28", "spawn", asset: "human", count: 1),
Step("st28a", "pick_unit", asset: "human"),
Step("st28b", "focus", asset: "auto"),
Step("st28c", "activity_status_reset"),
Step("st28d", "fast_timing", value: "true"),
Step("st29", "status_apply", value: "surprised", label: "0.25"),
Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st31", "wait", wait: 1.25f),
Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"),
Step("st33", "assert", expect: "activity_status_loss_count", value: "1", label: "min"),
Step("st33b", "fast_timing", value: "false"),
// Bulk clear on another fresh unit
Step("st34", "spawn", asset: "human", count: 1),
Step("st34b", "pick_unit", asset: "human"),
Step("st34c", "focus", asset: "auto"),
Step("st34d", "activity_status_reset"),
Step("st35", "status_apply", value: "confused"),
Step("st36", "status_apply", value: "caffeinated"),
Step("st37", "status_clear_all"),
Step("st38", "wait", wait: 0.15f),
Step("st39", "assert", expect: "activity_status_loss_count", value: "2", label: "min"),
// Unknown fallback force note
Step("st40", "status_force_note", value: "zzz_harness_fake_status", expect: "gain"),
Step("st41", "assert", expect: "activity_log_contains", value: "zzz harness fake status"),
// Dead actor blocks gain; death dispose does not spam losses
Step("st42", "spawn", asset: "human", count: 1),
Step("st42b", "pick_unit", asset: "human"),
Step("st42c", "focus", asset: "auto"),
Step("st42d", "activity_status_reset"),
Step("st43", "status_apply", value: "poisoned"),
Step("st44", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
Step("st45", "kill_focus"),
Step("st46", "wait", wait: 0.2f),
Step("st47", "assert", expect: "activity_status_loss_count", value: "0", label: "exact"),
Step("st48", "status_apply", value: "confused", expect: "blocked"),
Step("st49", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
// UI on a fresh living unit
Step("st50", "spawn", asset: "human", count: 1),
Step("st50b", "pick_unit", asset: "human"),
Step("st51", "focus", asset: "auto"),
Step("st52", "activity_clear"),
Step("st53", "activity_status_reset"),
Step("st54", "status_apply", value: "confused"),
Step("st55", "status_apply", value: "sleeping"),
Step("st56", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
Step("st57", "assert", expect: "caption_layout_ok"),
Step("st58", "screenshot", value: "hud-activity-status-smoke.png"),
Step("st59", "wait", wait: 0.45f),
Step("st60", "lore_open_focus"),
Step("st61", "wait", wait: 0.2f),
Step("st62", "lore_detail_pane", value: "activity"),
Step("st63", "assert", expect: "lore_activity_shown", value: "1", label: "min"),
Step("st64", "assert", expect: "no_bad"),
Step("st99", "snapshot"),
};
}
private static List<HarnessCommand> Smoke()
{
return new List<HarnessCommand>
@ -486,6 +612,7 @@ internal static class HarnessScenarios
Step("act13d", "assert", expect: "activity_prose_grounding"),
Step("act13e", "assert", expect: "activity_prose_library"),
Step("act13f", "assert", expect: "activity_taxonomy_audit"),
Step("act13g", "assert", expect: "activity_status_audit"),
Step("act14", "activity_force", asset: "laughing", label: "Laughing", count: 1, value: "angle"),
Step("act14b", "assert", expect: "activity_log_contains", value: "celestial"),
Step("act14c", "activity_force", asset: "eating", label: "Taking a meal", count: 1),

View file

@ -192,6 +192,14 @@ public static class HudIcons
return ForChronicleKind(ChronicleKind.Friend);
}
if (ActivityStatusProse.TryParseTaskKey(key, out string statusId, out _))
{
return FromStatusId(statusId)
?? FromUiIcon("iconConfused")
?? Task()
?? FromUiIcon("iconClock");
}
string verb = ActivityVerbMap.Resolve(key);
if (string.IsNullOrEmpty(verb))
{
@ -349,6 +357,64 @@ public static class HudIcons
return null;
}
public static Sprite FromStatusId(string statusId)
{
if (string.IsNullOrEmpty(statusId))
{
return null;
}
try
{
StatusAsset asset = AssetManager.status?.get(statusId.Trim());
if (asset == null)
{
return null;
}
return FromStatus(asset);
}
catch
{
return null;
}
}
public static Sprite FromStatus(StatusAsset status)
{
if (status == null)
{
return null;
}
try
{
Sprite sprite = status.getSprite();
if (sprite != null)
{
return sprite;
}
}
catch
{
// fall through
}
try
{
if (!string.IsNullOrEmpty(status.path_icon))
{
return SpriteTextureLoader.getSprite(status.path_icon);
}
}
catch
{
// ignore
}
return null;
}
public static void Apply(UnityEngine.UI.Image image, Sprite sprite, bool preserveAspect = true)
{
if (image == null)

View file

@ -0,0 +1,155 @@
using System;
using System.Reflection;
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Reflection helpers for internal WorldBox status APIs used by the harness.</summary>
internal static class StatusGameApi
{
private static MethodInfo _addStatusString;
private static MethodInfo _finishAll;
public static bool TryAddStatus(Actor actor, string statusId, float overrideTimer = 0f, bool colorEffect = true)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
if (_addStatusString == null)
{
_addStatusString = AccessTools.Method(
typeof(BaseSimObject),
"addStatusEffect",
new[] { typeof(string), typeof(float), typeof(bool) });
}
if (_addStatusString == null)
{
return false;
}
object result = _addStatusString.Invoke(
actor,
new object[] { statusId.Trim(), overrideTimer, colorEffect });
return result is bool ok && ok;
}
catch
{
return false;
}
}
public static bool TryFinishStatus(Actor actor, string statusId)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
actor.finishStatusEffect(statusId.Trim());
return true;
}
catch
{
return false;
}
}
public static bool TryFinishAll(Actor actor)
{
if (actor == null)
{
return false;
}
try
{
if (_finishAll == null)
{
_finishAll = AccessTools.Method(typeof(BaseSimObject), "finishAllStatusEffects");
}
if (_finishAll == null)
{
return false;
}
_finishAll.Invoke(actor, null);
return true;
}
catch
{
return false;
}
}
public static bool HasStatus(Actor actor, string statusId)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
var ids = actor.getStatusesIds();
if (ids == null)
{
return false;
}
foreach (string id in ids)
{
if (!string.IsNullOrEmpty(id)
&& id.Equals(statusId, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch
{
// ignore
}
return false;
}
public static int CountActiveStatuses(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
int n = 0;
var ids = actor.getStatusesIds();
if (ids == null)
{
return 0;
}
foreach (string id in ids)
{
if (!string.IsNullOrEmpty(id))
{
n++;
}
}
return n;
}
catch
{
return 0;
}
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.12.93",
"version": "0.12.98",
"description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -35,6 +35,7 @@ REGRESSION_SCENARIOS=(
world_log
chronicle_smoke
activity_idle_smoke
activity_status_smoke
)
scenario=""