449 lines
13 KiB
C#
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--;
|
|
}
|
|
}
|
|
}
|