From d5803bbd5998d122c652cd3da5435a6037b10b61 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 13:14:36 -0500 Subject: [PATCH] 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. --- IdleSpectator/ActivityAssetCatalog.cs | 45 ++++ IdleSpectator/ActivityLog.cs | 160 +++++++++++++- IdleSpectator/ActivityStatusHarness.cs | 215 +++++++++++++++++++ IdleSpectator/ActivityStatusProse.cs | 192 +++++++++++++++++ IdleSpectator/ActorStatusPatches.cs | 251 ++++++++++++++++++++++ IdleSpectator/AgentHarness.cs | 276 +++++++++++++++++++++++++ IdleSpectator/HarnessScenarios.cs | 127 ++++++++++++ IdleSpectator/HudIcons.cs | 66 ++++++ IdleSpectator/StatusGameApi.cs | 155 ++++++++++++++ IdleSpectator/mod.json | 2 +- scripts/harness-run.sh | 1 + 11 files changed, 1488 insertions(+), 2 deletions(-) create mode 100644 IdleSpectator/ActivityStatusHarness.cs create mode 100644 IdleSpectator/ActivityStatusProse.cs create mode 100644 IdleSpectator/ActorStatusPatches.cs create mode 100644 IdleSpectator/StatusGameApi.cs diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs index b8491b6..467887d 100644 --- a/IdleSpectator/ActivityAssetCatalog.cs +++ b/IdleSpectator/ActivityAssetCatalog.cs @@ -113,6 +113,51 @@ public static class ActivityAssetCatalog return ids; } + public static List EnumerateLiveStatusIds() + { + var ids = new List(); + 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 EnumerateLiveTaskIds() { var ids = new List(); diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index 2e6793f..b894288 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -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 = ""; + /// When non-zero, status counters only track this subject (harness isolation). + private static long _statusCounterSubjectId; private static float _clearedAt = -1f; private static readonly Dictionary TaskHitsSinceClear = new Dictionary(); @@ -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; + } + + /// Harness: reset counters and only count the focus subject afterward. + 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 }); } + /// + /// Status gain/loss Activity line (no beat cooldown). Living actors only. + /// + 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); + } + + /// Harness / forced path for status lines without a live transition. + 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)) diff --git a/IdleSpectator/ActivityStatusHarness.cs b/IdleSpectator/ActivityStatusHarness.cs new file mode 100644 index 0000000..a579316 --- /dev/null +++ b/IdleSpectator/ActivityStatusHarness.cs @@ -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; +} + +/// +/// Exhaustive live-library audit for status Activity prose and icons. +/// +public static class ActivityStatusHarness +{ + public static ActivityStatusAuditResult RunAudit(string outputDirectory) + { + List liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds(); + var authored = new HashSet(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(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', ' '); + } +} diff --git a/IdleSpectator/ActivityStatusProse.cs b/IdleSpectator/ActivityStatusProse.cs new file mode 100644 index 0000000..d573c1c --- /dev/null +++ b/IdleSpectator/ActivityStatusProse.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// +/// Authored milestone-style gain/loss phrases for every live WorldBox status asset. +/// Runtime fallback is HUD-only safety; the harness audit requires authored coverage. +/// +public static class ActivityStatusProse +{ + private static readonly Dictionary Authored = + new Dictionary(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 AuthoredIds => Authored.Keys; + + /// + /// Phrase without actor name. Authored when possible; otherwise localized/humanized fallback. + /// + 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; + } +} diff --git a/IdleSpectator/ActorStatusPatches.cs b/IdleSpectator/ActorStatusPatches.cs new file mode 100644 index 0000000..04fb394 --- /dev/null +++ b/IdleSpectator/ActorStatusPatches.cs @@ -0,0 +1,251 @@ +using System; +using HarmonyLib; + +namespace IdleSpectator; + +/// +/// Feeds Activity status gain/loss lines from authoritative WorldBox status transitions. +/// Gain = new dict entry (). +/// Loss = (remove, expire, clear) while the actor is still alive. +/// +[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); + } + + /// + /// Count timer-refresh reapplications for harness asserts (no Activity line). + /// + [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; + } +} diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index c5be61f..8724993 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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 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. diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 269cb53..d2a3392 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 ActivityStatusAudit() + { + return new List + { + 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 ActivityStatusSmoke() + { + return new List + { + 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 Smoke() { return new List @@ -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), diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs index 69641d0..7025904 100644 --- a/IdleSpectator/HudIcons.cs +++ b/IdleSpectator/HudIcons.cs @@ -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) diff --git a/IdleSpectator/StatusGameApi.cs b/IdleSpectator/StatusGameApi.cs new file mode 100644 index 0000000..75cb2ab --- /dev/null +++ b/IdleSpectator/StatusGameApi.cs @@ -0,0 +1,155 @@ +using System; +using System.Reflection; +using HarmonyLib; + +namespace IdleSpectator; + +/// Reflection helpers for internal WorldBox status APIs used by the harness. +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; + } + } +} diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index fcbd1d8..aba7969 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -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" } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index 2250a39..1a03e8f 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -35,6 +35,7 @@ REGRESSION_SCENARIOS=( world_log chronicle_smoke activity_idle_smoke + activity_status_smoke ) scenario=""