worldbox-observer-mod/IdleSpectator/ActivityLog.cs

449 lines
13 KiB
C#

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 = "";
/// <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 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 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;
_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<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} "
+ $"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;
string jobId = ActivityInterestTable.SafeJobId(actor);
ActivityInterestTable.ResolveTarget(
actor, out string targetName, out bool targetIsActor, out string place);
string actorName = ActivityInterestTable.ActorName(actor);
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,
actorName,
targetName,
targetIsActor,
place,
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);
}
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 actorName = ActivityInterestTable.ActorName(actor);
ActivityInterestTable.ResolveTarget(
actor, out string resolvedName, out bool targetIsActor, out string place);
if (string.IsNullOrEmpty(resolvedName) && !string.IsNullOrEmpty(target))
{
resolvedName = target;
targetIsActor = true;
}
string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact;
int idx = NextIndex(id);
ActivityProse.Format(
ActivityKind.ActionBeat,
actionKey,
actorName,
resolvedName,
targetIsActor,
place,
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(resolvedName) ? resolvedName : (place ?? ""),
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
});
}
/// <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 = "")
{
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;
ActivityProse.Format(
kind,
taskOrAction,
actorName,
targetName,
!string.IsNullOrEmpty(targetName),
"",
fact,
subjectId,
idx,
out string display,
out string displayRich);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
Kind = kind,
TaskId = taskOrAction,
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--;
}
}
}