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:
parent
3f56cf457e
commit
d5803bbd59
11 changed files with 1488 additions and 2 deletions
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
215
IdleSpectator/ActivityStatusHarness.cs
Normal file
215
IdleSpectator/ActivityStatusHarness.cs
Normal 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', ' ');
|
||||
}
|
||||
}
|
||||
192
IdleSpectator/ActivityStatusProse.cs
Normal file
192
IdleSpectator/ActivityStatusProse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
251
IdleSpectator/ActorStatusPatches.cs
Normal file
251
IdleSpectator/ActorStatusPatches.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
155
IdleSpectator/StatusGameApi.cs
Normal file
155
IdleSpectator/StatusGameApi.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ REGRESSION_SCENARIOS=(
|
|||
world_log
|
||||
chronicle_smoke
|
||||
activity_idle_smoke
|
||||
activity_status_smoke
|
||||
)
|
||||
|
||||
scenario=""
|
||||
|
|
|
|||
Loading…
Reference in a new issue