worldbox-observer-mod/IdleSpectator/ActivityLog.cs

880 lines
26 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
public enum ActivityKind
{
TaskStart,
ActionBeat,
StatusGain,
StatusLoss
}
public sealed class ActivityEntry
{
public float CreatedAt;
public long SubjectId;
public ActivityKind Kind;
public string TaskId = "";
public string JobId = "";
public string TargetLabel = "";
/// <summary>Stable fact line for asserts.</summary>
public string Line = "";
/// <summary>Varied display prose for HUD (plain text).</summary>
public string DisplayLine = "";
/// <summary>Display prose with colored names (Unity rich text).</summary>
public string DisplayLineRich = "";
}
/// <summary>
/// Ephemeral per-character activity ring (tasks / action beats). Not Chronicle History.
/// </summary>
public static class ActivityLog
{
public const int MaxLinesPerSubject = 32;
public const int MaxSubjects = 4000;
private const float ActionBeatCooldown = 2.5f;
private static readonly Dictionary<long, List<ActivityEntry>> BySubject =
new Dictionary<long, List<ActivityEntry>>();
private static readonly Dictionary<long, string> LastTaskId = new Dictionary<long, string>();
private static readonly Dictionary<long, float> LastBeatAt = new Dictionary<long, float>();
private static readonly Dictionary<long, int> LineIndex = new Dictionary<long, int>();
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>();
public static int SubjectCount => BySubject.Count;
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()
{
BySubject.Clear();
LastTaskId.Clear();
LastBeatAt.Clear();
LineIndex.Clear();
ResetSampleCounters();
}
/// <summary>Harness: reset rate counters without wiping the ring.</summary>
public static void ResetSampleCounters()
{
_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);
float perSec = _notesSinceClear / elapsed;
float perMin = perSec * 60f;
int lines = 0;
foreach (var kv in BySubject)
{
lines += kv.Value != null ? kv.Value.Count : 0;
}
string top = "";
if (TaskHitsSinceClear.Count > 0)
{
var ranked = new List<KeyValuePair<string, int>>(TaskHitsSinceClear);
ranked.Sort((a, b) => b.Value.CompareTo(a.Value));
int n = Mathf.Min(8, ranked.Count);
for (int i = 0; i < n; i++)
{
if (i > 0)
{
top += ", ";
}
top += ranked[i].Key + "=" + ranked[i].Value;
}
}
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}]";
}
public static int CountFor(long subjectId)
{
if (subjectId == 0)
{
return 0;
}
return BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) ? list.Count : 0;
}
public static IReadOnlyList<ActivityEntry> LatestForSubject(long subjectId, int max)
{
if (subjectId == 0 || max <= 0)
{
return System.Array.Empty<ActivityEntry>();
}
if (!BySubject.TryGetValue(subjectId, out List<ActivityEntry> list) || list.Count == 0)
{
return System.Array.Empty<ActivityEntry>();
}
int take = Mathf.Min(max, list.Count);
var result = new ActivityEntry[take];
// Newest first (matches Chronicle.LatestForSubject peek order).
for (int i = 0; i < take; i++)
{
result[i] = list[list.Count - 1 - i];
}
return result;
}
public static void NoteTask(Actor actor, string taskId)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(taskId))
{
return;
}
long id;
try
{
id = actor.getID();
}
catch
{
return;
}
if (id == 0)
{
return;
}
if (LastTaskId.TryGetValue(id, out string prev) && prev == taskId)
{
return;
}
LastTaskId[id] = taskId;
ActivityContext ctx = ActivityInterestTable.BuildContext(actor, taskId);
string jobId = ctx.JobId;
string actorName = ctx.ActorName;
string targetName = ctx.TargetName;
bool targetIsActor = ctx.TargetIsActor;
string place = ctx.PlaceLabel;
string loc = "";
try
{
if (actor.hasTask() && actor.ai?.task != null)
{
loc = actor.ai.task.getLocalizedText() ?? "";
}
}
catch
{
// ignore
}
string targetLabel = !string.IsNullOrEmpty(targetName) ? targetName : place;
string raw = string.IsNullOrEmpty(loc) ? ("Task: " + taskId) : loc;
if (!string.IsNullOrEmpty(targetLabel))
{
raw = raw + " → " + targetLabel;
}
int idx = NextIndex(id);
ActivityProse.Format(
ActivityKind.TaskStart,
taskId,
ctx,
raw,
id,
idx,
out string display,
out string displayRich);
RecordSample(taskId, isBeat: false);
Append(id, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = id,
Kind = ActivityKind.TaskStart,
TaskId = taskId,
JobId = jobId,
TargetLabel = targetLabel,
Line = raw,
DisplayLine = display,
DisplayLineRich = displayRich
});
}
/// <summary>
/// Capture safety net: if the live task id differs from the last logged id, note it.
/// </summary>
public static void EnsureCurrentTask(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return;
}
string taskId = ActivityInterestTable.SafeTaskId(actor);
if (string.IsNullOrEmpty(taskId))
{
return;
}
NoteTask(actor, taskId);
}
/// <summary>
/// Curated behaviour beat. <paramref name="actorTarget"/> is a living unit name only;
/// <paramref name="placeHint"/> is a building/place/object label and never becomes a companion.
/// </summary>
public static void NoteActionBeat(
Actor actor,
string actionKey,
string rawFact,
string actorTarget = "",
string placeHint = "")
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(actionKey))
{
return;
}
long id;
try
{
id = actor.getID();
}
catch
{
return;
}
if (id == 0)
{
return;
}
float now = Time.unscaledTime;
if (LastBeatAt.TryGetValue(id, out float last) && now - last < ActionBeatCooldown)
{
return;
}
LastBeatAt[id] = now;
ActivityContext ctx = ActivityInterestTable.BuildContext(actor, actionKey);
string jobId = ctx.JobId;
if (string.IsNullOrEmpty(ctx.TargetName) && !string.IsNullOrEmpty(actorTarget))
{
ctx.TargetName = actorTarget;
ctx.TargetIsActor = true;
}
if (string.IsNullOrEmpty(ctx.PlaceLabel) && !string.IsNullOrEmpty(placeHint))
{
ctx.PlaceLabel = placeHint;
ctx.PlaceIsObject = true;
}
if (actionKey == "BehUnloadResources" || actionKey == "BehThrowResources")
{
if (string.IsNullOrEmpty(ctx.CarryingLabel))
{
ctx.CarryingLabel = ActivityInterestTable.TryGetCarryingLabel(actor);
}
if (string.IsNullOrEmpty(ctx.CarryingLabel))
{
ctx.CarryingLabel = "goods";
}
if (string.IsNullOrEmpty(ctx.PlaceLabel))
{
ctx.PlaceLabel = !string.IsNullOrEmpty(ctx.CityName) ? ctx.CityName : "town";
ctx.PlaceIsObject = false;
}
}
string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact;
int idx = NextIndex(id);
ActivityProse.Format(
ActivityKind.ActionBeat,
actionKey,
ctx,
fact,
id,
idx,
out string display,
out string displayRich);
RecordSample(actionKey, isBeat: true);
Append(id, new ActivityEntry
{
CreatedAt = now,
SubjectId = id,
Kind = ActivityKind.ActionBeat,
TaskId = actionKey,
JobId = jobId,
TargetLabel = !string.IsNullOrEmpty(ctx.TargetName)
? ctx.TargetName
: (ctx.PlaceLabel ?? ""),
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
}
/// <summary>
/// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor.
/// </summary>
public static void NoteMilestone(Actor actor, string actionKey, string rawLine, string target = "")
{
if (actor == null || string.IsNullOrEmpty(actionKey) || string.IsNullOrEmpty(rawLine))
{
return;
}
bool allowDead = string.Equals(actionKey, "milestone_death", System.StringComparison.Ordinal);
if (!allowDead)
{
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";
}
string targetName = target ?? "";
string fact = rawLine;
string clause = LowerFirst(fact);
string display = string.IsNullOrEmpty(clause) ? actorName : actorName + " " + clause;
string displayRich;
if (string.IsNullOrEmpty(targetName))
{
displayRich = ActivityProse.ColorName(actorName) + (string.IsNullOrEmpty(clause) ? "" : " " + clause);
}
else
{
// Prefer a clean subject + verb + colored target when the fact is a short Life line.
string richTarget = ActivityProse.ColorName(targetName);
if (string.Equals(actionKey, "milestone_kill", System.StringComparison.Ordinal))
{
display = actorName + " killed " + targetName;
displayRich = ActivityProse.ColorName(actorName) + " killed " + richTarget;
}
else if (string.Equals(actionKey, "milestone_lover", System.StringComparison.Ordinal))
{
display = actorName + " became lovers with " + targetName;
displayRich = ActivityProse.ColorName(actorName) + " became lovers with " + richTarget;
}
else if (string.Equals(actionKey, "milestone_friend", System.StringComparison.Ordinal))
{
display = actorName + " befriended " + targetName;
displayRich = ActivityProse.ColorName(actorName) + " befriended " + richTarget;
}
else
{
displayRich = ActivityProse.ColorName(actorName)
+ (string.IsNullOrEmpty(clause) ? "" : " " + clause);
}
}
float now = Time.unscaledTime;
RecordSample(actionKey, isBeat: true);
Append(id, new ActivityEntry
{
CreatedAt = now,
SubjectId = id,
Kind = ActivityKind.ActionBeat,
TaskId = actionKey,
JobId = "",
TargetLabel = targetName,
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
}
/// <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))
{
return "";
}
if (text.Length == 1)
{
return text.ToLowerInvariant();
}
return char.ToLowerInvariant(text[0]) + text.Substring(1);
}
/// <summary>Harness: inject activity without a live AI transition.</summary>
public static bool ForceNote(
long subjectId,
string taskOrAction,
string rawLine,
bool asBeat = false,
string actorName = "",
string targetName = "",
string speciesId = "",
string place = "",
string carrying = "",
string jobId = "",
string traitId = "",
bool isChild = false,
bool inCombat = false,
bool isKing = false,
bool isLeader = false,
bool isWarrior = false)
{
if (subjectId == 0 || string.IsNullOrEmpty(taskOrAction))
{
return false;
}
int idx = NextIndex(subjectId);
string fact = string.IsNullOrEmpty(rawLine) ? taskOrAction : rawLine;
ActivityKind kind = asBeat ? ActivityKind.ActionBeat : ActivityKind.TaskStart;
// Prefer live focus context when available, then apply harness overrides.
ActivityContext ctx = ActivityContext.Empty;
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
&& MoveCamera._focus_unit.getID() == subjectId)
{
ctx = ActivityInterestTable.BuildContext(MoveCamera._focus_unit, taskOrAction);
}
}
catch
{
ctx = ActivityContext.Empty;
}
if (ctx == null || string.IsNullOrEmpty(ctx.ActorName))
{
ctx = ActivityContext.ForHarness(
actorName,
targetName,
speciesId,
place,
carrying,
jobId,
taskOrAction,
traitId,
isChild,
inCombat,
isKing,
isLeader,
isWarrior);
}
else
{
if (!string.IsNullOrEmpty(actorName))
{
ctx.ActorName = actorName;
}
if (!string.IsNullOrEmpty(targetName))
{
ctx.TargetName = targetName;
ctx.TargetIsActor = true;
}
if (!string.IsNullOrEmpty(speciesId))
{
ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(speciesId);
if (asset != null)
{
ActivityVoiceResolver.ApplyToContext(ctx, asset);
ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(asset);
}
else
{
ctx.SpeciesId = speciesId;
ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(speciesId);
ctx.Voice = ActivityVoiceResolver.Resolve(speciesId);
ctx.BaseSpeciesId = ctx.Voice.BaseSpeciesId;
ctx.Family = ctx.Voice.BaseFamily;
ctx.MannerTag = ActivityVoiceResolver.TagName(ctx.Voice.EffectiveActionTag);
ctx.ModifierTag = ActivityVoiceResolver.ModifierName(ctx.Voice.Modifier);
ctx.IsCiv = ctx.Voice.BaseIsCiv;
ctx.IsHumanoid = ctx.Voice.BaseIsHumanoid;
ctx.IsAnimal = ctx.Voice.BaseIsAnimal;
}
}
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))
{
ctx.CarryingLabel = carrying;
}
if (!string.IsNullOrEmpty(jobId))
{
ctx.JobId = jobId;
}
if (!string.IsNullOrEmpty(traitId))
{
ctx.TopTraitId = traitId;
ctx.TopTraitLabel = ActivityInterestTable.TraitLabelFromId(traitId);
}
if (isChild)
{
ctx.IsChild = true;
}
if (inCombat)
{
ctx.InCombat = true;
ctx.HasAttackTarget = ctx.HasAttackTarget || ctx.TargetIsActor;
}
if (isKing || isLeader || isWarrior)
{
ctx.IsKing = isKing || ctx.IsKing;
ctx.IsLeader = isLeader || ctx.IsLeader;
ctx.IsWarrior = isWarrior || ctx.IsWarrior;
ctx.RoleLabel = ActivityInterestTable.DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);
}
ctx.TaskKey = taskOrAction;
}
ActivityProse.Format(
kind,
taskOrAction,
ctx,
fact,
subjectId,
idx,
out string display,
out string displayRich);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
Kind = kind,
TaskId = taskOrAction,
JobId = ctx.JobId ?? "",
TargetLabel = targetName ?? "",
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
return true;
}
private static void RecordSample(string key, bool isBeat)
{
if (_clearedAt < 0f)
{
_clearedAt = Time.unscaledTime;
}
_notesSinceClear++;
if (isBeat)
{
_beatsSinceClear++;
}
else
{
_tasksSinceClear++;
}
string k = string.IsNullOrEmpty(key) ? "?" : key;
TaskHitsSinceClear.TryGetValue(k, out int n);
TaskHitsSinceClear[k] = n + 1;
}
private static int NextIndex(long id)
{
LineIndex.TryGetValue(id, out int idx);
idx++;
LineIndex[id] = idx;
return idx;
}
private static void Append(long id, ActivityEntry entry)
{
if (!BySubject.TryGetValue(id, out List<ActivityEntry> list))
{
list = new List<ActivityEntry>();
BySubject[id] = list;
PruneSubjectsIfNeeded();
}
list.Add(entry);
while (list.Count > MaxLinesPerSubject)
{
list.RemoveAt(0);
}
}
private static void PruneSubjectsIfNeeded()
{
if (BySubject.Count <= MaxSubjects)
{
return;
}
long focusId = 0;
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
focusId = MoveCamera._focus_unit.getID();
}
}
catch
{
focusId = 0;
}
var ranked = new List<(long id, float lastAt)>(BySubject.Count);
foreach (KeyValuePair<long, List<ActivityEntry>> kv in BySubject)
{
if (kv.Key == focusId)
{
continue;
}
float last = 0f;
List<ActivityEntry> list = kv.Value;
if (list != null && list.Count > 0 && list[list.Count - 1] != null)
{
last = list[list.Count - 1].CreatedAt;
}
ranked.Add((kv.Key, last));
}
ranked.Sort((a, b) => a.lastAt.CompareTo(b.lastAt));
int need = BySubject.Count - MaxSubjects;
for (int i = 0; i < ranked.Count && need > 0; i++)
{
long id = ranked[i].id;
BySubject.Remove(id);
LastTaskId.Remove(id);
LastBeatAt.Remove(id);
LineIndex.Remove(id);
need--;
}
}
}