Compare commits
2 commits
72a4a2e164
...
d5803bbd59
| Author | SHA1 | Date | |
|---|---|---|---|
| d5803bbd59 | |||
| 3f56cf457e |
15 changed files with 1643 additions and 24 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>();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
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();
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.91",
|
||||
"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