Compare commits

...

2 commits

15 changed files with 1643 additions and 24 deletions

View file

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

View file

@ -57,12 +57,72 @@ public static class ActivityClauseAssembler
// Traits stay on the dossier chips - they only salt variety below, never named here.
string target = BuildObservedTargetClause(ctx, action, verb);
action = EnsurePlaceClause(action, ctx, verb, key);
string carrying = BuildCarryingClause(ctx, action);
string pressure = BuildPressureClause(ctx, action + target);
string line = subject + " " + action + target + carrying + pressure;
return CollapseSpaces(line).Trim();
}
/// <summary>
/// Attach {place_at} when the action still lacks a place token.
/// Objects (bonfire, house, …) appear on site-work verbs; settlements stay settle/trade/farm/unload.
/// </summary>
private static string EnsurePlaceClause(string action, ActivityContext ctx, string verb, string key)
{
if (ctx == null
|| string.IsNullOrEmpty(ctx.PlaceLabel)
|| string.IsNullOrEmpty(action)
|| action.IndexOf("{place_at}", StringComparison.Ordinal) >= 0
|| action.IndexOf("{place}", StringComparison.Ordinal) >= 0)
{
return action;
}
if (ctx.PlaceIsObject)
{
if (WantsObjectPlace(verb) || IsUnloadKey(key))
{
return action + "{place_at}";
}
return action;
}
if (WantsSettlementPlace(verb) || IsUnloadKey(key))
{
return action + "{place_at}";
}
return action;
}
private static bool IsUnloadKey(string key)
{
return !string.IsNullOrEmpty(key)
&& (key.Equals("BehUnloadResources", StringComparison.OrdinalIgnoreCase)
|| key.Equals("BehThrowResources", StringComparison.OrdinalIgnoreCase));
}
private static bool WantsSettlementPlace(string verb)
{
return verb == "settle" || verb == "trade" || verb == "farm";
}
private static bool WantsObjectPlace(string verb)
{
return verb == "work"
|| verb == "haul"
|| verb == "farm"
|| verb == "trade"
|| verb == "settle"
|| verb == "fire"
|| verb == "pollinate"
|| verb == "fish"
|| verb == "heal"
|| verb == "read";
}
private static bool IsCompanionVerb(string verb)
{
return verb == "social"

View file

@ -48,6 +48,11 @@ public sealed class ActivityContext
public bool TargetIsActor;
/// <summary>Building, city, kingdom, or soft place label for clauses.</summary>
public string PlaceLabel = "";
/// <summary>
/// True when <see cref="PlaceLabel"/> is a concrete building/object (bonfire, house, …).
/// False for settlements, realms, and soft places like "the wilds".
/// </summary>
public bool PlaceIsObject;
/// <summary>Observed terrain under the actor when this snapshot was captured.</summary>
public ActivityTerrain Terrain;
public string CarryingLabel = "";
@ -78,7 +83,8 @@ public sealed class ActivityContext
bool isKing = false,
bool isLeader = false,
bool isWarrior = false,
ActivityTerrain terrain = ActivityTerrain.Unknown)
ActivityTerrain terrain = ActivityTerrain.Unknown,
bool placeIsObject = false)
{
ActivityContext ctx = new ActivityContext
{
@ -87,6 +93,7 @@ public sealed class ActivityContext
TargetIsActor = !string.IsNullOrEmpty(targetName),
SpeciesId = speciesId ?? "",
PlaceLabel = place ?? "",
PlaceIsObject = placeIsObject,
CarryingLabel = carrying ?? "",
JobId = jobId ?? "",
TaskKey = taskKey ?? "",
@ -130,6 +137,7 @@ public sealed class ActivityContext
&& ActivityInterestTable.LooksLikeSpeciesPublic(ctx.PlaceLabel, ctx.SpeciesId))
{
ctx.PlaceLabel = "";
ctx.PlaceIsObject = false;
}
ctx.RoleLabel = ActivityInterestTable.DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);

View file

@ -214,36 +214,39 @@ public static class ActivityInterestTable
public static string TargetLabel(Actor actor)
{
ResolveTarget(actor, out string name, out _, out string place);
ResolveTarget(actor, out string name, out _, out string place, out _);
return !string.IsNullOrEmpty(name) ? name : place;
}
/// <summary>Living actor interactee name only - never a building/place label.</summary>
public static string ActorTargetName(Actor actor)
{
ResolveTarget(actor, out string name, out bool isActor, out _);
ResolveTarget(actor, out string name, out bool isActor, out _, out _);
return isActor ? name : "";
}
/// <summary>Building/tile/settlement label only - never an actor name.</summary>
public static string PlaceOrObjectLabel(Actor actor)
{
ResolveTarget(actor, out _, out bool isActor, out string place);
ResolveTarget(actor, out _, out bool isActor, out string place, out _);
return isActor ? "" : (place ?? "");
}
/// <summary>
/// Prefer live actor targets (by name). Places/buildings stay as type labels.
/// <paramref name="placeIsObject"/> is true only for concrete building targets.
/// </summary>
public static void ResolveTarget(
Actor actor,
out string targetName,
out bool targetIsActor,
out string placeLabel)
out string placeLabel,
out bool placeIsObject)
{
targetName = "";
targetIsActor = false;
placeLabel = "";
placeIsObject = false;
if (actor == null)
{
return;
@ -298,10 +301,12 @@ public static class ActivityInterestTable
if (!string.IsNullOrEmpty(type))
{
placeLabel = type.Replace("type_", "");
placeIsObject = true;
return;
}
placeLabel = string.IsNullOrEmpty(id) ? "a building" : id;
placeIsObject = true;
return;
}
@ -466,10 +471,15 @@ public static class ActivityInterestTable
ctx.InCombat = ctx.HasAttackTarget;
}
ResolveTarget(actor, out string targetName, out bool targetIsActor, out string place);
ResolveTarget(actor, out string targetName, out bool targetIsActor, out string place, out bool placeIsObject);
ctx.TargetName = targetName;
ctx.TargetIsActor = targetIsActor;
ctx.PlaceLabel = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId);
string picked = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId);
ctx.PlaceLabel = picked;
ctx.PlaceIsObject = placeIsObject
&& !string.IsNullOrEmpty(picked)
&& !string.IsNullOrEmpty(place)
&& picked.Equals(place.Trim(), StringComparison.OrdinalIgnoreCase);
ctx.CarryingLabel = TryGetCarryingLabel(actor);
ctx.TopTraitId = TryTopInterestingTraitId(actor);
@ -565,8 +575,8 @@ public static class ActivityInterestTable
}
/// <summary>
/// Real places only. Reject species ids, family/taxonomy buckets, and soft markers.
/// Wild kingdoms are often named after a species or type (buffalo, insect) - those are not places.
/// Prefer a concrete resolved object/building from AI targets over settlement names.
/// City/kingdom fill in only when there is no usable object/place from ResolveTarget.
/// </summary>
public static string PickPlaceLabel(
string resolvedPlace,
@ -574,16 +584,16 @@ public static class ActivityInterestTable
string kingdomName,
string speciesId)
{
if (IsUsablePlace(cityName, speciesId))
{
return cityName.Trim();
}
if (IsConcreteResolvedPlace(resolvedPlace, speciesId))
{
return resolvedPlace.Trim();
}
if (IsUsablePlace(cityName, speciesId))
{
return cityName.Trim();
}
// Kingdom only when it reads like a settlement/realm name, not a species bucket.
if (IsUsablePlace(kingdomName, speciesId) && LooksLikeRealmName(kingdomName))
{

View file

@ -6,7 +6,9 @@ namespace IdleSpectator;
public enum ActivityKind
{
TaskStart,
ActionBeat
ActionBeat,
StatusGain,
StatusLoss
}
public sealed class ActivityEntry
@ -43,6 +45,13 @@ public static class ActivityLog
private static int _notesSinceClear;
private static int _tasksSinceClear;
private static int _beatsSinceClear;
private static int _statusGainsSinceClear;
private static int _statusLossesSinceClear;
private static int _statusRefreshesSinceClear;
private static string _lastStatusGainId = "";
private static string _lastStatusLossId = "";
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary>
private static long _statusCounterSubjectId;
private static float _clearedAt = -1f;
private static readonly Dictionary<string, int> TaskHitsSinceClear =
new Dictionary<string, int>();
@ -51,6 +60,11 @@ public static class ActivityLog
public static int NotesSinceClear => _notesSinceClear;
public static int TasksSinceClear => _tasksSinceClear;
public static int BeatsSinceClear => _beatsSinceClear;
public static int StatusGainsSinceClear => _statusGainsSinceClear;
public static int StatusLossesSinceClear => _statusLossesSinceClear;
public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
public static string LastStatusGainId => _lastStatusGainId ?? "";
public static string LastStatusLossId => _lastStatusLossId ?? "";
public static void ClearSession()
{
@ -67,10 +81,43 @@ public static class ActivityLog
_notesSinceClear = 0;
_tasksSinceClear = 0;
_beatsSinceClear = 0;
_statusGainsSinceClear = 0;
_statusLossesSinceClear = 0;
_statusRefreshesSinceClear = 0;
_lastStatusGainId = "";
_lastStatusLossId = "";
_clearedAt = Time.unscaledTime;
TaskHitsSinceClear.Clear();
}
public static void ResetStatusCounters()
{
_statusGainsSinceClear = 0;
_statusLossesSinceClear = 0;
_statusRefreshesSinceClear = 0;
_lastStatusGainId = "";
_lastStatusLossId = "";
_statusCounterSubjectId = 0;
}
/// <summary>Harness: reset counters and only count the focus subject afterward.</summary>
public static void ResetStatusCountersForSubject(long subjectId)
{
ResetStatusCounters();
_statusCounterSubjectId = subjectId;
}
public static void NoteStatusRefresh(string statusId, long subjectId = 0)
{
_ = statusId;
if (_statusCounterSubjectId != 0 && subjectId != 0 && subjectId != _statusCounterSubjectId)
{
return;
}
_statusRefreshesSinceClear++;
}
public static string FormatSampleStats()
{
float elapsed = _clearedAt < 0f ? 0f : Mathf.Max(0.01f, Time.unscaledTime - _clearedAt);
@ -102,6 +149,8 @@ public static class ActivityLog
float perSubjectPerMin = BySubject.Count > 0 ? perMin / BySubject.Count : 0f;
return
$"elapsed={elapsed:0.0}s notes={_notesSinceClear} tasks={_tasksSinceClear} beats={_beatsSinceClear} "
+ $"status_gains={_statusGainsSinceClear} status_losses={_statusLossesSinceClear} "
+ $"status_refreshes={_statusRefreshesSinceClear} "
+ $"per_sec={perSec:0.00} per_min={perMin:0.0} per_subject_per_min={perSubjectPerMin:0.00} "
+ $"subjects={BySubject.Count} ring_lines={lines} top=[{top}]";
}
@ -287,6 +336,7 @@ public static class ActivityLog
if (string.IsNullOrEmpty(ctx.PlaceLabel) && !string.IsNullOrEmpty(placeHint))
{
ctx.PlaceLabel = placeHint;
ctx.PlaceIsObject = true;
}
if (actionKey == "BehUnloadResources" || actionKey == "BehThrowResources")
@ -304,6 +354,7 @@ public static class ActivityLog
if (string.IsNullOrEmpty(ctx.PlaceLabel))
{
ctx.PlaceLabel = !string.IsNullOrEmpty(ctx.CityName) ? ctx.CityName : "town";
ctx.PlaceIsObject = false;
}
}
@ -441,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))
@ -555,6 +715,8 @@ public static class ActivityLog
if (!string.IsNullOrEmpty(place))
{
ctx.PlaceLabel = place;
// Explicit harness place strings are settlement/realm names, not building objects.
ctx.PlaceIsObject = false;
}
if (!string.IsNullOrEmpty(carrying))

View file

@ -35,9 +35,14 @@ public static class ActivityProse
actorName,
targetIsActor ? targetName : "",
place: placeOrFallback);
if (!targetIsActor && !string.IsNullOrEmpty(targetName) && string.IsNullOrEmpty(ctx.PlaceLabel))
if (!targetIsActor && !string.IsNullOrEmpty(targetName))
{
ctx.PlaceLabel = targetName;
if (string.IsNullOrEmpty(ctx.PlaceLabel))
{
ctx.PlaceLabel = targetName;
}
ctx.PlaceIsObject = true;
}
if (targetIsActor)
@ -144,6 +149,47 @@ public static class ActivityProse
return HumanizeJob(jobId);
}
/// <summary>
/// Settlement / realm / wilds → " in X". Building/object → " at the X" (or " at a/an/the X").
/// </summary>
public static string FormatPlaceAtClause(string place, bool placeIsObject)
{
if (string.IsNullOrEmpty(place))
{
return "";
}
string noun = place.Trim().Replace('_', ' ');
if (string.IsNullOrEmpty(noun))
{
return "";
}
if (placeIsObject)
{
if (HasLeadingArticle(noun))
{
return " at " + noun;
}
return " at the " + LowerFirst(noun);
}
return " in " + noun;
}
private static bool HasLeadingArticle(string noun)
{
if (string.IsNullOrEmpty(noun))
{
return false;
}
return noun.StartsWith("a ", System.StringComparison.OrdinalIgnoreCase)
|| noun.StartsWith("an ", System.StringComparison.OrdinalIgnoreCase)
|| noun.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase);
}
/// <summary>Legacy helper used by older call sites / harness.</summary>
public static string Format(
ActivityKind kind,
@ -193,8 +239,8 @@ public static class ActivityProse
string carrying = string.IsNullOrEmpty(ctx.CarryingLabel) ? "goods" : ctx.CarryingLabel;
string withClause = string.IsNullOrEmpty(target) ? "" : (" with " + target);
// Empty place → empty clause (no forced "in town"). Job never appears in Activity.
string placeAt = string.IsNullOrEmpty(place) ? "" : (" in " + place);
// Empty place → empty clause. Objects use "at the", settlements use "in".
string placeAt = FormatPlaceAtClause(place, ctx.PlaceIsObject);
string t = template;
t = t.Replace("{actor}", actor);

View file

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

View file

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

View file

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

View file

@ -537,6 +537,204 @@ public static class AgentHarness
break;
}
case "activity_status_reset":
{
long id = 0;
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
id = MoveCamera._focus_unit.getID();
}
}
catch
{
id = 0;
}
if (id == 0)
{
id = WatchCaption.CurrentUnitId;
}
ActivityLog.ResetStatusCountersForSubject(id);
_cmdOk++;
Emit(
cmd,
ok: true,
detail:
$"subject={id} gains={ActivityLog.StatusGainsSinceClear} "
+ $"losses={ActivityLog.StatusLossesSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear}");
break;
}
case "status_apply":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning";
float timer = 0f;
if (!string.IsNullOrEmpty(cmd.label)
&& float.TryParse(cmd.label, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed))
{
timer = parsed;
}
bool expectBlocked = (cmd.expect ?? "").IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0;
bool alive = false;
try
{
alive = focus != null && focus.isAlive();
}
catch
{
alive = false;
}
bool applied = alive && StatusGameApi.TryAddStatus(focus, statusId, timer);
bool ok = expectBlocked ? !applied : applied;
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
WatchCaption.ForceRefreshHistory();
Emit(
cmd,
ok,
detail:
$"status={statusId} applied={applied} expectBlocked={expectBlocked} alive={alive} "
+ $"gains={ActivityLog.StatusGainsSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear} "
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
break;
}
case "status_remove":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "burning";
bool ok = focus != null && StatusGameApi.TryFinishStatus(focus, statusId);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
WatchCaption.ForceRefreshHistory();
Emit(
cmd,
ok,
detail:
$"status={statusId} finished={ok} losses={ActivityLog.StatusLossesSinceClear} "
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
break;
}
case "status_clear_all":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
bool ok = focus != null && StatusGameApi.TryFinishAll(focus);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
WatchCaption.ForceRefreshHistory();
Emit(
cmd,
ok,
detail:
$"cleared={ok} losses={ActivityLog.StatusLossesSinceClear} "
+ $"active_count={StatusGameApi.CountActiveStatuses(focus)}");
break;
}
case "status_force_note":
{
long id = WatchCaption.CurrentUnitId;
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (id == 0 && focus != null)
{
try
{
id = focus.getID();
}
catch
{
id = 0;
}
}
string statusId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "zzz_harness_fake_status";
bool gained = (cmd.expect ?? "gain").IndexOf("loss", StringComparison.OrdinalIgnoreCase) < 0;
string actorName = "";
try
{
if (focus != null)
{
actorName = focus.getName() ?? "";
}
}
catch
{
actorName = "";
}
bool ok = ActivityLog.ForceStatusNote(id, statusId, gained, actorName);
WatchCaption.ForceRefreshHistory();
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"forced status={statusId} gained={gained} id={id}");
break;
}
case "kill_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
bool ok = false;
try
{
if (focus != null && focus.isAlive())
{
focus.die(true, AttackType.Age);
ok = !focus.isAlive();
}
}
catch
{
ok = false;
}
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: $"killed={ok}");
break;
}
case "lore_detail_pane":
{
string raw = (cmd.value ?? cmd.label ?? "life").Trim().ToLowerInvariant();
@ -2297,11 +2495,13 @@ public static class AgentHarness
socialBindOk = socialBindOk && withTomi <= 1;
// Building/place labels must never become companion "with …" on work/unload.
// Objects use "at the …"; settlements use "in …".
ActivityContext bonfireCtx = ActivityContext.ForHarness(
actorName,
"bonfire",
"",
"human",
place: "bonfire");
place: "bonfire",
placeIsObject: true);
ActivityProse.Format(
ActivityKind.TaskStart,
"type_bonfire",
@ -2312,11 +2512,13 @@ public static class AgentHarness
out string bonfireLine,
out _);
bool bonfireOk = !string.IsNullOrEmpty(bonfireLine)
&& bonfireLine.IndexOf("with bonfire", System.StringComparison.OrdinalIgnoreCase) < 0;
&& bonfireLine.IndexOf("at the bonfire", System.StringComparison.OrdinalIgnoreCase) >= 0
&& bonfireLine.IndexOf("with bonfire", System.StringComparison.OrdinalIgnoreCase) < 0
&& bonfireLine.IndexOf("in bonfire", System.StringComparison.OrdinalIgnoreCase) < 0;
ActivityContext unloadTownCtx = ActivityContext.ForHarness(
actorName,
"town",
"",
"human",
place: "Riverhold",
carrying: "wheat");
@ -2331,6 +2533,7 @@ public static class AgentHarness
out _);
bool unloadTownOk = !string.IsNullOrEmpty(unloadTownLine)
&& unloadTownLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0
&& unloadTownLine.IndexOf("in Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0
&& unloadTownLine.IndexOf("with town", System.StringComparison.OrdinalIgnoreCase) < 0;
// Taxonomy kingdoms like "insect" must never become place clauses.
@ -2436,6 +2639,84 @@ public static class AgentHarness
detail = audit.Detail;
break;
}
case "activity_status_audit":
{
ActivityStatusAuditResult audit = ActivityStatusHarness.RunAudit(HarnessDir());
pass = audit.Passed;
detail = audit.Detail;
break;
}
case "activity_status_gain_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int got = ActivityLog.StatusGainsSinceClear;
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
pass = mode == "min" ? got >= want : got == want;
detail = $"gains={got} want={want} mode={mode} last={ActivityLog.LastStatusGainId}";
break;
}
case "activity_status_loss_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int got = ActivityLog.StatusLossesSinceClear;
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
pass = mode == "min" ? got >= want : got == want;
detail = $"losses={got} want={want} mode={mode} last={ActivityLog.LastStatusLossId}";
break;
}
case "activity_status_refresh_count":
{
int want = ParseCountExpect(cmd, defaultValue: 1);
int got = ActivityLog.StatusRefreshesSinceClear;
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
pass = mode == "min" ? got >= want : got == want;
detail = $"refreshes={got} want={want} mode={mode}";
break;
}
case "activity_status_active":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string statusId = cmd.value ?? "";
bool expectActive = true;
string rawExpect = (cmd.label ?? cmd.expect ?? "true").Trim().ToLowerInvariant();
if (rawExpect == "false" || rawExpect == "0" || rawExpect == "no")
{
expectActive = false;
}
bool active = StatusGameApi.HasStatus(focus, statusId);
pass = active == expectActive;
detail = $"status={statusId} active={active} expectActive={expectActive}";
break;
}
case "activity_status_task_id":
{
long id = WatchCaption.CurrentUnitId;
if (id == 0 && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
try
{
id = MoveCamera._focus_unit.getID();
}
catch
{
id = 0;
}
}
string want = cmd.value ?? "";
string got = "";
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1);
if (list != null && list.Count > 0 && list[0] != null)
{
got = list[0].TaskId ?? "";
}
pass = !string.IsNullOrEmpty(want)
&& got.Equals(want, StringComparison.OrdinalIgnoreCase);
detail = $"got='{got}' want='{want}'";
break;
}
case "activity_prose_showcase":
{
// Taxonomy must map to real buckets - never fantasy kingdoms -> monster.

View file

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

View file

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

View file

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

View file

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

View file

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