295 lines
7.9 KiB
C#
295 lines
7.9 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.</summary>
|
|
public string DisplayLine = "";
|
|
}
|
|
|
|
/// <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>();
|
|
|
|
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<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);
|
|
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
|
|
});
|
|
}
|
|
|
|
/// <summary>Harness: inject activity without a live AI transition.</summary>
|
|
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<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--;
|
|
}
|
|
}
|
|
}
|