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; /// Other living/fallen unit named by this line (0 when none / unresolved). public long RelatedId; 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 (plain text). public string DisplayLine = ""; /// Display prose with colored names (Unity rich text). public string DisplayLineRich = ""; } /// /// 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; /// /// Suppress repeat Task/Beat lines that resolve to the same verb (e.g. play/child_* churn). /// Status and milestones are exempt. /// private const float SameVerbCooldown = 10f; 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 LastLoggedVerb = new Dictionary(); private static readonly Dictionary LastLoggedVerbAt = new Dictionary(); private static readonly Dictionary LineIndex = new Dictionary(); 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 = ""; /// When non-zero, status counters only track this subject (harness isolation). private static long _statusCounterSubjectId; private static float _clearedAt = -1f; private static readonly Dictionary TaskHitsSinceClear = new Dictionary(); 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(); } /// Harness: reset rate counters without wiping the ring. 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; } /// Harness: reset counters and only count the focus subject afterward. 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>(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 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; } /// /// Newest-first peek filtered by against live actor state. /// Full ring is unchanged (Lore Activity / ). /// public static IReadOnlyList LatestRelevantForSubject( Actor actor, long subjectId, int max, int scanMax = 0) { if (subjectId == 0 || max <= 0) { return System.Array.Empty(); } if (!BySubject.TryGetValue(subjectId, out List list) || list.Count == 0) { return System.Array.Empty(); } int scan = scanMax > 0 ? Mathf.Min(scanMax, list.Count) : Mathf.Min(ActivityRelevance.PeekScanMax, list.Count); float now = Time.unscaledTime; var picked = new List(max); for (int i = 0; i < scan && picked.Count < max; i++) { ActivityEntry entry = list[list.Count - 1 - i]; if (ActivityRelevance.IsPeekRelevant(actor, entry, now)) { picked.Add(entry); } } // Never leave the peek empty when the ring has lines - show newest as last resort. if (picked.Count == 0 && list.Count > 0) { picked.Add(list[list.Count - 1]); } return picked; } /// Harness: age all lines for a subject so soft-window relevance expires. public static int HarnessAgeSubject(long subjectId, float secondsAgo) { if (subjectId == 0 || secondsAgo <= 0f || !BySubject.TryGetValue(subjectId, out List list)) { return 0; } float stamped = Time.unscaledTime - secondsAgo; int n = 0; // Keep the newest row "fresh" so soft-window peeks can still show it while // older rows fall out of the caption ring (activity_idle_smoke act17*). int last = -1; for (int i = list.Count - 1; i >= 0; i--) { if (list[i] != null) { last = i; break; } } for (int i = 0; i < list.Count; i++) { if (list[i] == null || i == last) { continue; } list[i].CreatedAt = stamped; n++; } return n; } 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 } } /// /// Capture safety net: if the live task id differs from the last logged id, note it. /// 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); } /// /// Curated behaviour beat. is a living unit name only; /// is a building/place/object label and never becomes a companion. /// public static void NoteActionBeat( Actor actor, string actionKey, string rawFact, string actorTarget = "", string placeHint = "", long relatedId = 0) { 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, RelatedId = relatedId, Kind = ActivityKind.ActionBeat, TaskId = actionKey, JobId = jobId, TargetLabel = !string.IsNullOrEmpty(ctx.TargetName) ? ctx.TargetName : (ctx.PlaceLabel ?? ""), Line = fact, DisplayLine = display, DisplayLineRich = displayRich }); } /// /// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor. /// public static void NoteMilestone( Actor actor, string actionKey, string rawLine, string target = "", long relatedId = 0) { 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, RelatedId = relatedId, Kind = ActivityKind.ActionBeat, TaskId = actionKey, JobId = "", TargetLabel = targetName, Line = fact, DisplayLine = display, DisplayLineRich = displayRich }); } /// /// Status gain/loss Activity line (no beat cooldown). Living actors only. /// 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"; } ResolveStatusRelated(actor, out long relatedId, out string relatedName); ForceStatusNote(id, statusId.Trim(), gained, actorName, relatedId, relatedName); try { HappinessEventRouter.NoteStatusPhase(id, statusId.Trim()); } catch { // ignore } try { InterestFeeds.OnStatusChange(actor, statusId.Trim(), gained); } catch { // ignore } } /// /// Related unit only when a happiness/relationship hook stamped the context bag /// (e.g. becomeLoversWith → fell_in_love). Do not invent lover links for every status. /// private static void ResolveStatusRelated(Actor actor, out long relatedId, out string relatedName) { relatedId = 0; relatedName = ""; if (actor == null) { return; } try { if (!HappinessContextBag.TryGetRelated(actor, out Actor bagRelated, out _) || bagRelated == null || !bagRelated.isAlive()) { return; } relatedId = EventFeedUtil.SafeId(bagRelated); relatedName = bagRelated.getName() ?? ""; if (string.IsNullOrEmpty(relatedName) && bagRelated.asset != null) { relatedName = bagRelated.asset.id ?? ""; } } catch { relatedId = 0; relatedName = ""; } } /// Harness / forced path for status lines without a live transition. public static bool ForceStatusNote( long subjectId, string statusId, bool gained, string actorName = "", long relatedId = 0, string relatedName = "") { 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); if (gained && !string.IsNullOrEmpty(relatedName) && !string.IsNullOrEmpty(clause) && clause.IndexOf(relatedName, System.StringComparison.OrdinalIgnoreCase) < 0) { // Keep short status verbs readable when we know who they are about. clause = clause + " with " + relatedName; } string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause; string displayRich; if (!string.IsNullOrEmpty(relatedName) && !string.IsNullOrEmpty(clause) && clause.IndexOf(relatedName, System.StringComparison.Ordinal) >= 0) { displayRich = ActivityProse.ColorName(name) + " " + clause.Replace(relatedName, ActivityProse.ColorName(relatedName)); } else { 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; } } if (relatedId == 0 && !string.IsNullOrEmpty(relatedName)) { relatedId = ResolveLivingUnitIdByName(relatedName, subjectId); } RecordSample(key, isBeat: true); Append(subjectId, new ActivityEntry { CreatedAt = Time.unscaledTime, SubjectId = subjectId, RelatedId = relatedId != subjectId ? relatedId : 0, Kind = gained ? ActivityKind.StatusGain : ActivityKind.StatusLoss, TaskId = key, JobId = "", TargetLabel = !string.IsNullOrEmpty(relatedName) ? relatedName : id, Line = fact, DisplayLine = display, DisplayLineRich = displayRich }); return true; } /// Happiness / emotion Activity line (no beat cooldown). Presentation already decided by router. 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); long relatedId = occ.RelatedId; if (relatedId == 0 && !string.IsNullOrEmpty(occ.RelatedName)) { relatedId = ResolveLivingUnitIdByName(occ.RelatedName, occ.SubjectId); } Append(occ.SubjectId, new ActivityEntry { CreatedAt = Time.unscaledTime, SubjectId = occ.SubjectId, RelatedId = relatedId != occ.SubjectId ? relatedId : 0, Kind = ActivityKind.Happiness, TaskId = key, JobId = occ.Amount.ToString(), TargetLabel = !string.IsNullOrEmpty(occ.RelatedName) ? occ.RelatedName : occ.EffectId, Line = fact, DisplayLine = display, DisplayLineRich = displayRich }); } /// Harness: inject a happiness ring line without a live game apply. 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: HappinessContextBag.RoleForDeathEffect(effectId.Trim()), forceRing: true, relatedNameOverride: relatedName); 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); } /// Harness: inject activity without a live AI transition. 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); long relatedId = 0; if (ctx != null && ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName)) { relatedId = ResolveLivingUnitIdByName(ctx.TargetName, subjectId); } Append(subjectId, new ActivityEntry { CreatedAt = Time.unscaledTime, SubjectId = subjectId, RelatedId = relatedId, Kind = kind, TaskId = taskOrAction, JobId = ctx.JobId ?? "", TargetLabel = targetName ?? "", Line = fact, DisplayLine = display, DisplayLineRich = displayRich }); return true; } /// Living unit id with exact name match, or 0. Used by dossier row links. public static long ResolveLivingUnitIdByName(string name, long excludeId) { if (string.IsNullOrEmpty(name) || World.world?.units == null) { return 0; } try { foreach (Actor unit in World.world.units) { if (unit == null || !unit.isAlive()) { continue; } long id = EventFeedUtil.SafeId(unit); if (id == 0 || id == excludeId) { continue; } string n = unit.getName(); if (!string.IsNullOrEmpty(n) && string.Equals(n, name, System.StringComparison.OrdinalIgnoreCase)) { return id; } } } catch { return 0; } return 0; } 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; } /// /// Harness: attempt several same-verb task keys on a live actor; returns how many lines were added. /// 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 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); LastLoggedVerb.Remove(id); LastLoggedVerbAt.Remove(id); LineIndex.Remove(id); need--; } } }