using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; public enum ActivityKind { TaskStart, ActionBeat } public sealed class ActivityEntry { public float CreatedAt; public long SubjectId; public ActivityKind Kind; public string TaskId = ""; public string JobId = ""; public string TargetLabel = ""; /// Stable fact line for asserts. public string Line = ""; /// Varied display prose for HUD (plain text). public string DisplayLine = ""; /// Display prose with colored names (Unity rich text). public string DisplayLineRich = ""; } /// /// Ephemeral per-character activity ring (tasks / action beats). Not Chronicle History. /// public static class ActivityLog { public const int MaxLinesPerSubject = 32; public const int MaxSubjects = 4000; private const float ActionBeatCooldown = 2.5f; private static readonly Dictionary> BySubject = new Dictionary>(); private static readonly Dictionary LastTaskId = new Dictionary(); private static readonly Dictionary LastBeatAt = new Dictionary(); private static readonly Dictionary LineIndex = new Dictionary(); private static int _notesSinceClear; private static int _tasksSinceClear; private static int _beatsSinceClear; private static float _clearedAt = -1f; private static readonly Dictionary TaskHitsSinceClear = new Dictionary(); public static int SubjectCount => BySubject.Count; public static int NotesSinceClear => _notesSinceClear; public static int TasksSinceClear => _tasksSinceClear; public static int BeatsSinceClear => _beatsSinceClear; public static void ClearSession() { BySubject.Clear(); LastTaskId.Clear(); LastBeatAt.Clear(); LineIndex.Clear(); ResetSampleCounters(); } /// Harness: reset rate counters without wiping the ring. public static void ResetSampleCounters() { _notesSinceClear = 0; _tasksSinceClear = 0; _beatsSinceClear = 0; _clearedAt = Time.unscaledTime; TaskHitsSinceClear.Clear(); } 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>(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} " + $"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 list) ? list.Count : 0; } public static IReadOnlyList LatestForSubject(long subjectId, int max) { if (subjectId == 0 || max <= 0) { return System.Array.Empty(); } if (!BySubject.TryGetValue(subjectId, out List list) || list.Count == 0) { return System.Array.Empty(); } 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 }); } /// /// Capture safety net: if the live task id differs from the last logged id, note it. /// 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); } public static void NoteActionBeat(Actor actor, string actionKey, string rawFact, string target) { 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 taskId = ActivityInterestTable.SafeTaskId(actor); string jobId = ctx.JobId; if (string.IsNullOrEmpty(ctx.TargetName) && !string.IsNullOrEmpty(target)) { ctx.TargetName = target; ctx.TargetIsActor = 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"; } } 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 = string.IsNullOrEmpty(taskId) ? actionKey : taskId, JobId = jobId, TargetLabel = !string.IsNullOrEmpty(ctx.TargetName) ? ctx.TargetName : (ctx.PlaceLabel ?? ""), Line = fact, DisplayLine = display, DisplayLineRich = displayRich }); } /// Harness: inject activity without a live AI transition. 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; } 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 list)) { list = new List(); 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> kv in BySubject) { if (kv.Key == focusId) { continue; } float last = 0f; List 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--; } } }