1173 lines
36 KiB
C#
1173 lines
36 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
public enum ActivityKind
|
|
{
|
|
TaskStart,
|
|
ActionBeat,
|
|
StatusGain,
|
|
StatusLoss,
|
|
Happiness
|
|
}
|
|
|
|
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;
|
|
/// <summary>
|
|
/// Suppress repeat Task/Beat lines that resolve to the same verb (e.g. play/child_* churn).
|
|
/// Status and milestones are exempt.
|
|
/// </summary>
|
|
private const float SameVerbCooldown = 10f;
|
|
|
|
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, string> LastLoggedVerb = new Dictionary<long, string>();
|
|
private static readonly Dictionary<long, float> LastLoggedVerbAt = new Dictionary<long, float>();
|
|
private static readonly Dictionary<long, int> LineIndex = new Dictionary<long, int>();
|
|
private static int _verbSuppressedSinceClear;
|
|
|
|
private static int _notesSinceClear;
|
|
private static int _tasksSinceClear;
|
|
private static int _beatsSinceClear;
|
|
private static int _statusGainsSinceClear;
|
|
private static int _statusLossesSinceClear;
|
|
private static int _statusRefreshesSinceClear;
|
|
private static string _lastStatusGainId = "";
|
|
private static string _lastStatusLossId = "";
|
|
private static int _happinessSinceClear;
|
|
private static string _lastHappinessId = "";
|
|
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary>
|
|
private static long _statusCounterSubjectId;
|
|
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 int StatusGainsSinceClear => _statusGainsSinceClear;
|
|
public static int StatusLossesSinceClear => _statusLossesSinceClear;
|
|
public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
|
|
public static string LastStatusGainId => _lastStatusGainId ?? "";
|
|
public static string LastStatusLossId => _lastStatusLossId ?? "";
|
|
public static int HappinessSinceClear => _happinessSinceClear;
|
|
public static string LastHappinessId => _lastHappinessId ?? "";
|
|
public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear;
|
|
|
|
public static void ClearSession()
|
|
{
|
|
BySubject.Clear();
|
|
LastTaskId.Clear();
|
|
LastBeatAt.Clear();
|
|
LastLoggedVerb.Clear();
|
|
LastLoggedVerbAt.Clear();
|
|
LineIndex.Clear();
|
|
ResetSampleCounters();
|
|
HappinessEventRouter.ClearSession();
|
|
}
|
|
|
|
/// <summary>Harness: reset rate counters without wiping the ring.</summary>
|
|
public static void ResetSampleCounters()
|
|
{
|
|
_notesSinceClear = 0;
|
|
_tasksSinceClear = 0;
|
|
_beatsSinceClear = 0;
|
|
_statusGainsSinceClear = 0;
|
|
_statusLossesSinceClear = 0;
|
|
_statusRefreshesSinceClear = 0;
|
|
_verbSuppressedSinceClear = 0;
|
|
_lastStatusGainId = "";
|
|
_lastStatusLossId = "";
|
|
_happinessSinceClear = 0;
|
|
_lastHappinessId = "";
|
|
_clearedAt = Time.unscaledTime;
|
|
TaskHitsSinceClear.Clear();
|
|
}
|
|
|
|
public static void ResetStatusCounters()
|
|
{
|
|
_statusGainsSinceClear = 0;
|
|
_statusLossesSinceClear = 0;
|
|
_statusRefreshesSinceClear = 0;
|
|
_lastStatusGainId = "";
|
|
_lastStatusLossId = "";
|
|
_statusCounterSubjectId = 0;
|
|
}
|
|
|
|
/// <summary>Harness: reset counters and only count the focus subject afterward.</summary>
|
|
public static void ResetStatusCountersForSubject(long subjectId)
|
|
{
|
|
ResetStatusCounters();
|
|
_statusCounterSubjectId = subjectId;
|
|
}
|
|
|
|
public static void NoteStatusRefresh(string statusId, long subjectId = 0)
|
|
{
|
|
_ = statusId;
|
|
if (_statusCounterSubjectId != 0 && subjectId != 0 && subjectId != _statusCounterSubjectId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_statusRefreshesSinceClear++;
|
|
}
|
|
|
|
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} "
|
|
+ $"status_gains={_statusGainsSinceClear} status_losses={_statusLossesSinceClear} "
|
|
+ $"status_refreshes={_statusRefreshesSinceClear} verb_suppressed={_verbSuppressedSinceClear} "
|
|
+ $"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;
|
|
}
|
|
|
|
if (ShouldSuppressSameVerb(id, taskId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastTaskId[id] = taskId;
|
|
RememberLoggedVerb(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
|
|
});
|
|
|
|
try
|
|
{
|
|
ActivityBand band = ActivityInterestTable.Classify(actor);
|
|
bool hot = band == ActivityBand.Hot
|
|
|| actor.has_attack_target
|
|
|| (taskId != null && (taskId.Contains("attack")
|
|
|| taskId.Contains("hunt")
|
|
|| taskId.Contains("fire")));
|
|
if (hot)
|
|
{
|
|
InterestFeeds.OnActivityNote(actor, taskId, hot: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// interest feed must not break activity logging
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Curated behaviour beat. <paramref name="actorTarget"/> is a living unit name only;
|
|
/// <paramref name="placeHint"/> is a building/place/object label and never becomes a companion.
|
|
/// </summary>
|
|
public static void NoteActionBeat(
|
|
Actor actor,
|
|
string actionKey,
|
|
string rawFact,
|
|
string actorTarget = "",
|
|
string placeHint = "")
|
|
{
|
|
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;
|
|
}
|
|
|
|
if (ShouldSuppressSameVerb(id, actionKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastBeatAt[id] = now;
|
|
RememberLoggedVerb(id, actionKey);
|
|
ActivityContext ctx = ActivityInterestTable.BuildContext(actor, actionKey);
|
|
string jobId = ctx.JobId;
|
|
|
|
if (string.IsNullOrEmpty(ctx.TargetName) && !string.IsNullOrEmpty(actorTarget))
|
|
{
|
|
ctx.TargetName = actorTarget;
|
|
ctx.TargetIsActor = true;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(ctx.PlaceLabel) && !string.IsNullOrEmpty(placeHint))
|
|
{
|
|
ctx.PlaceLabel = placeHint;
|
|
ctx.PlaceIsObject = 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";
|
|
ctx.PlaceIsObject = false;
|
|
}
|
|
}
|
|
|
|
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 = actionKey,
|
|
JobId = jobId,
|
|
TargetLabel = !string.IsNullOrEmpty(ctx.TargetName)
|
|
? ctx.TargetName
|
|
: (ctx.PlaceLabel ?? ""),
|
|
Line = fact,
|
|
DisplayLine = display,
|
|
DisplayLineRich = displayRich
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor.
|
|
/// </summary>
|
|
public static void NoteMilestone(Actor actor, string actionKey, string rawLine, string target = "")
|
|
{
|
|
if (actor == null || string.IsNullOrEmpty(actionKey) || string.IsNullOrEmpty(rawLine))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool allowDead = string.Equals(actionKey, "milestone_death", System.StringComparison.Ordinal);
|
|
if (!allowDead)
|
|
{
|
|
try
|
|
{
|
|
if (!actor.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
long id;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string actorName;
|
|
try
|
|
{
|
|
actorName = actor.getName();
|
|
if (string.IsNullOrEmpty(actorName))
|
|
{
|
|
actorName = actor.asset != null ? actor.asset.id : "creature";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
actorName = "creature";
|
|
}
|
|
|
|
string targetName = target ?? "";
|
|
string fact = rawLine;
|
|
string clause = LowerFirst(fact);
|
|
string display = string.IsNullOrEmpty(clause) ? actorName : actorName + " " + clause;
|
|
string displayRich;
|
|
if (string.IsNullOrEmpty(targetName))
|
|
{
|
|
displayRich = ActivityProse.ColorName(actorName) + (string.IsNullOrEmpty(clause) ? "" : " " + clause);
|
|
}
|
|
else
|
|
{
|
|
// Prefer a clean subject + verb + colored target when the fact is a short Life line.
|
|
string richTarget = ActivityProse.ColorName(targetName);
|
|
if (string.Equals(actionKey, "milestone_kill", System.StringComparison.Ordinal))
|
|
{
|
|
display = actorName + " killed " + targetName;
|
|
displayRich = ActivityProse.ColorName(actorName) + " killed " + richTarget;
|
|
}
|
|
else if (string.Equals(actionKey, "milestone_lover", System.StringComparison.Ordinal))
|
|
{
|
|
display = actorName + " became lovers with " + targetName;
|
|
displayRich = ActivityProse.ColorName(actorName) + " became lovers with " + richTarget;
|
|
}
|
|
else if (string.Equals(actionKey, "milestone_friend", System.StringComparison.Ordinal))
|
|
{
|
|
display = actorName + " befriended " + targetName;
|
|
displayRich = ActivityProse.ColorName(actorName) + " befriended " + richTarget;
|
|
}
|
|
else
|
|
{
|
|
displayRich = ActivityProse.ColorName(actorName)
|
|
+ (string.IsNullOrEmpty(clause) ? "" : " " + clause);
|
|
}
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
RecordSample(actionKey, isBeat: true);
|
|
Append(id, new ActivityEntry
|
|
{
|
|
CreatedAt = now,
|
|
SubjectId = id,
|
|
Kind = ActivityKind.ActionBeat,
|
|
TaskId = actionKey,
|
|
JobId = "",
|
|
TargetLabel = targetName,
|
|
Line = fact,
|
|
DisplayLine = display,
|
|
DisplayLineRich = displayRich
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Status gain/loss Activity line (no beat cooldown). Living actors only.
|
|
/// </summary>
|
|
public static void NoteStatusChange(Actor actor, string statusId, bool gained)
|
|
{
|
|
if (actor == null || string.IsNullOrEmpty(statusId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!actor.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id;
|
|
try
|
|
{
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string actorName;
|
|
try
|
|
{
|
|
actorName = actor.getName();
|
|
if (string.IsNullOrEmpty(actorName))
|
|
{
|
|
actorName = actor.asset != null ? actor.asset.id : "creature";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
actorName = "creature";
|
|
}
|
|
|
|
ForceStatusNote(id, statusId.Trim(), gained, actorName);
|
|
try
|
|
{
|
|
HappinessEventRouter.NoteStatusPhase(id, statusId.Trim());
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
try
|
|
{
|
|
InterestFeeds.OnStatusChange(actor, statusId.Trim(), gained);
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/// <summary>Harness / forced path for status lines without a live transition.</summary>
|
|
public static bool ForceStatusNote(
|
|
long subjectId,
|
|
string statusId,
|
|
bool gained,
|
|
string actorName = "")
|
|
{
|
|
if (subjectId == 0 || string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string id = statusId.Trim();
|
|
string name = string.IsNullOrEmpty(actorName) ? "Someone" : actorName;
|
|
string phrase = ActivityStatusProse.Phrase(id, gained, out _);
|
|
string clause = LowerFirst(phrase);
|
|
string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause;
|
|
string displayRich = ActivityProse.ColorName(name)
|
|
+ (string.IsNullOrEmpty(clause) ? "" : " " + clause);
|
|
string key = ActivityStatusProse.TaskKey(id, gained);
|
|
string fact = (gained ? "Gained " : "Lost ") + id;
|
|
|
|
if (gained)
|
|
{
|
|
if (_statusCounterSubjectId == 0 || subjectId == _statusCounterSubjectId)
|
|
{
|
|
_statusGainsSinceClear++;
|
|
_lastStatusGainId = id;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (_statusCounterSubjectId == 0 || subjectId == _statusCounterSubjectId)
|
|
{
|
|
_statusLossesSinceClear++;
|
|
_lastStatusLossId = id;
|
|
}
|
|
}
|
|
|
|
RecordSample(key, isBeat: true);
|
|
Append(subjectId, new ActivityEntry
|
|
{
|
|
CreatedAt = Time.unscaledTime,
|
|
SubjectId = subjectId,
|
|
Kind = gained ? ActivityKind.StatusGain : ActivityKind.StatusLoss,
|
|
TaskId = key,
|
|
JobId = "",
|
|
TargetLabel = id,
|
|
Line = fact,
|
|
DisplayLine = display,
|
|
DisplayLineRich = displayRich
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Happiness / emotion Activity line (no beat cooldown). Presentation already decided by router.</summary>
|
|
public static void NoteHappiness(HappinessOccurrence occ)
|
|
{
|
|
if (occ == null || occ.SubjectId == 0 || string.IsNullOrEmpty(occ.EffectId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string name = string.IsNullOrEmpty(occ.SubjectName) ? "Someone" : occ.SubjectName;
|
|
string clause = occ.PlainClause ?? "";
|
|
string richClause = !string.IsNullOrEmpty(occ.RichClause) ? occ.RichClause : clause;
|
|
string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause;
|
|
string displayRich = ActivityProse.ColorName(name)
|
|
+ (string.IsNullOrEmpty(richClause) ? "" : " " + richClause);
|
|
string key = ActivityHappinessProse.TaskKey(occ.EffectId);
|
|
string fact = !string.IsNullOrEmpty(occ.FactLine) ? occ.FactLine : ("Happiness " + occ.EffectId);
|
|
|
|
if (_statusCounterSubjectId == 0 || occ.SubjectId == _statusCounterSubjectId)
|
|
{
|
|
_happinessSinceClear++;
|
|
_lastHappinessId = occ.EffectId;
|
|
}
|
|
|
|
RecordSample(key, isBeat: true);
|
|
Append(occ.SubjectId, new ActivityEntry
|
|
{
|
|
CreatedAt = Time.unscaledTime,
|
|
SubjectId = occ.SubjectId,
|
|
Kind = ActivityKind.Happiness,
|
|
TaskId = key,
|
|
JobId = occ.Amount.ToString(),
|
|
TargetLabel = !string.IsNullOrEmpty(occ.RelatedName) ? occ.RelatedName : occ.EffectId,
|
|
Line = fact,
|
|
DisplayLine = display,
|
|
DisplayLineRich = displayRich
|
|
});
|
|
}
|
|
|
|
/// <summary>Harness: inject a happiness ring line without a live game apply.</summary>
|
|
public static bool ForceHappinessNote(
|
|
long subjectId,
|
|
string effectId,
|
|
string actorName = "",
|
|
string relatedName = "",
|
|
int amount = 0)
|
|
{
|
|
if (subjectId == 0 || string.IsNullOrEmpty(effectId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Actor subject = null;
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
try
|
|
{
|
|
if (actor != null && actor.getID() == subjectId)
|
|
{
|
|
subject = actor;
|
|
break;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// continue
|
|
}
|
|
}
|
|
|
|
// Prefer the live router path so spectator DrainSince sees the occurrence.
|
|
if (subject != null && subject.isAlive())
|
|
{
|
|
HappinessOccurrence published = HappinessEventRouter.PublishApplied(
|
|
subject,
|
|
effectId.Trim(),
|
|
amount != 0 ? amount : HappinessGameApi.ResolveAmount(effectId),
|
|
HappinessSourceHook.Harness,
|
|
related: null,
|
|
role: HappinessRelationRole.None,
|
|
forceRing: true);
|
|
if (published != null && !string.IsNullOrEmpty(relatedName))
|
|
{
|
|
published.RelatedName = relatedName;
|
|
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
|
|
string clause = ActivityHappinessProse.Clause(entry, relatedName, out bool usedRelated, out _);
|
|
published.PlainClause = clause;
|
|
published.RichClause = usedRelated
|
|
? clause.Replace(relatedName, ActivityProse.ColorName(relatedName))
|
|
: clause;
|
|
}
|
|
|
|
if (published != null)
|
|
{
|
|
InterestFeeds.IngestForHarness(published);
|
|
string label = !string.IsNullOrEmpty(published.PlainClause)
|
|
? ((published.SubjectName ?? "") + " " + published.PlainClause).Trim()
|
|
: "";
|
|
InterestFeeds.RegisterHappinessSignal(subject, effectId.Trim(), label, relatedName);
|
|
}
|
|
|
|
return published != null;
|
|
}
|
|
|
|
HappinessCatalogEntry fallbackEntry = EventCatalog.Happiness.GetOrFallback(effectId);
|
|
string fallbackClause = ActivityHappinessProse.Clause(
|
|
fallbackEntry, relatedName, out bool usedRel, out _);
|
|
string rich = usedRel && !string.IsNullOrEmpty(relatedName)
|
|
? fallbackClause.Replace(relatedName, ActivityProse.ColorName(relatedName))
|
|
: fallbackClause;
|
|
var occ = new HappinessOccurrence
|
|
{
|
|
EffectId = effectId.Trim(),
|
|
Amount = amount != 0 ? amount : HappinessGameApi.ResolveAmount(effectId),
|
|
SubjectId = subjectId,
|
|
SubjectName = string.IsNullOrEmpty(actorName) ? "Someone" : actorName,
|
|
RelatedName = relatedName ?? "",
|
|
PlainClause = fallbackClause,
|
|
RichClause = rich,
|
|
FactLine = "Happiness " + effectId.Trim(),
|
|
Presentation = fallbackEntry.Presentation,
|
|
InterestCategory = fallbackEntry.InterestCategory,
|
|
Applied = true,
|
|
SourceHook = HappinessSourceHook.Harness
|
|
};
|
|
NoteHappiness(occ);
|
|
HappinessEventRouter.RememberForHarness(occ);
|
|
InterestFeeds.IngestForHarness(occ);
|
|
return true;
|
|
}
|
|
|
|
private static string LowerFirst(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (text.Length == 1)
|
|
{
|
|
return text.ToLowerInvariant();
|
|
}
|
|
|
|
return char.ToLowerInvariant(text[0]) + text.Substring(1);
|
|
}
|
|
|
|
/// <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 = "",
|
|
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;
|
|
// Explicit harness place strings are settlement/realm names, not building objects.
|
|
ctx.PlaceIsObject = false;
|
|
}
|
|
|
|
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 string ResolveVerbKey(string key)
|
|
{
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string verb = ActivityVerbMap.Resolve(key);
|
|
return string.IsNullOrEmpty(verb) ? key.Trim().ToLowerInvariant() : verb;
|
|
}
|
|
|
|
private static bool ShouldSuppressSameVerb(long subjectId, string key)
|
|
{
|
|
string verb = ResolveVerbKey(key);
|
|
if (string.IsNullOrEmpty(verb) || ActivityVerbMap.IsSameVerbCooldownExempt(verb))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
if (LastLoggedVerb.TryGetValue(subjectId, out string prev)
|
|
&& prev == verb
|
|
&& LastLoggedVerbAt.TryGetValue(subjectId, out float at)
|
|
&& now - at < SameVerbCooldown)
|
|
{
|
|
_verbSuppressedSinceClear++;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void RememberLoggedVerb(long subjectId, string key)
|
|
{
|
|
string verb = ResolveVerbKey(key);
|
|
if (string.IsNullOrEmpty(verb))
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastLoggedVerb[subjectId] = verb;
|
|
LastLoggedVerbAt[subjectId] = Time.unscaledTime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: attempt several same-verb task keys on a live actor; returns how many lines were added.
|
|
/// </summary>
|
|
public static int HarnessSpamSameVerb(Actor actor, string[] taskKeys)
|
|
{
|
|
if (actor == null || taskKeys == null || taskKeys.Length == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
long id;
|
|
try
|
|
{
|
|
if (!actor.isAlive())
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
id = actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (id == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
int before = CountFor(id);
|
|
// Fresh window: allow the first key through regardless of prior task/verb state.
|
|
LastTaskId.Remove(id);
|
|
LastLoggedVerb.Remove(id);
|
|
LastLoggedVerbAt.Remove(id);
|
|
for (int i = 0; i < taskKeys.Length; i++)
|
|
{
|
|
string key = taskKeys[i];
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Drop exact-id gate so each distinct play-like key can attempt a line.
|
|
LastTaskId.Remove(id);
|
|
NoteTask(actor, key);
|
|
}
|
|
|
|
return Mathf.Max(0, CountFor(id) - before);
|
|
}
|
|
|
|
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);
|
|
LastLoggedVerb.Remove(id);
|
|
LastLoggedVerbAt.Remove(id);
|
|
LineIndex.Remove(id);
|
|
need--;
|
|
}
|
|
}
|
|
}
|