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. public string DisplayLine = ""; } /// /// 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(); public static int SubjectCount => BySubject.Count; public static void ClearSession() { BySubject.Clear(); LastTaskId.Clear(); LastBeatAt.Clear(); LineIndex.Clear(); } 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; string jobId = ActivityInterestTable.SafeJobId(actor); string target = ActivityInterestTable.TargetLabel(actor); string loc = ""; try { if (actor.hasTask() && actor.ai?.task != null) { loc = actor.ai.task.getLocalizedText() ?? ""; } } catch { // ignore } string raw = string.IsNullOrEmpty(loc) ? ("Task: " + taskId) : loc; if (!string.IsNullOrEmpty(target)) { raw = raw + " → " + target; } int idx = NextIndex(id); string display = ActivityProse.Format( ActivityKind.TaskStart, taskId, raw, target, id, idx); Append(id, new ActivityEntry { CreatedAt = Time.unscaledTime, SubjectId = id, Kind = ActivityKind.TaskStart, TaskId = taskId, JobId = jobId, TargetLabel = target, Line = raw, DisplayLine = display }); } 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; string taskId = ActivityInterestTable.SafeTaskId(actor); string jobId = ActivityInterestTable.SafeJobId(actor); string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact; int idx = NextIndex(id); string display = ActivityProse.Format( ActivityKind.ActionBeat, actionKey, fact, target ?? "", id, idx); Append(id, new ActivityEntry { CreatedAt = now, SubjectId = id, Kind = ActivityKind.ActionBeat, TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId, JobId = jobId, TargetLabel = target ?? "", Line = fact, DisplayLine = display }); } /// Harness: inject activity without a live AI transition. public static bool ForceNote(long subjectId, string taskOrAction, string rawLine, bool asBeat = 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; string display = ActivityProse.Format(kind, taskOrAction, fact, "", subjectId, idx); Append(subjectId, new ActivityEntry { CreatedAt = Time.unscaledTime, SubjectId = subjectId, Kind = kind, TaskId = taskOrAction, Line = fact, DisplayLine = display }); return true; } 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--; } } }