using System; namespace IdleSpectator; /// /// Live-relevance policy for Activity History peek (dossier). /// The ring stays append-only; Lore Activity still shows the full journal. /// Peek only keeps lines that still match what the unit is doing (or just did). /// public static class ActivityRelevance { /// /// Lines newer than this always pass (beat just happened; AI may not have settled). /// public const float SoftWindowSeconds = 8f; /// How many newest ring lines to scan when filling a peek of size N. public const int PeekScanMax = 24; public enum Family { Ambient, Combat, Haul, Social, Romance, Work, Care, Travel, Milestone, Status, Happiness } public static Family FamilyForEntry(ActivityEntry entry) { if (entry == null) { return Family.Ambient; } switch (entry.Kind) { case ActivityKind.StatusGain: case ActivityKind.StatusLoss: return Family.Status; case ActivityKind.Happiness: return Family.Happiness; default: break; } string key = (entry.TaskId ?? "").Trim(); if (key.StartsWith("milestone_", StringComparison.OrdinalIgnoreCase)) { return Family.Milestone; } return FamilyForVerb(ActivityVerbMap.Resolve(key)); } public static Family FamilyForVerb(string verb) { if (string.IsNullOrEmpty(verb)) { return Family.Ambient; } switch (verb) { case "hunt": case "fight": case "flee": case "fire": case "ignite": case "extinguish": case "warrior": case "loot": case "steal": return Family.Combat; case "haul": return Family.Haul; case "social": case "group": return Family.Social; case "lover": return Family.Romance; case "farm": case "work": case "pollinate": case "fish": case "trade": case "build": case "read": case "heal": return Family.Work; case "eat": case "sleep": case "play": case "relieve": case "laugh": case "sing": case "cry": case "swear": case "wait": case "recharge": case "dream": return Family.Care; case "move": case "settle": case "boat": return Family.Travel; default: return Family.Ambient; } } /// /// Whether this ring line should appear in the dossier History peek for . /// public static bool IsPeekRelevant(Actor actor, ActivityEntry entry, float now) { if (entry == null) { return false; } if (now - entry.CreatedAt <= SoftWindowSeconds) { return true; } Family family = FamilyForEntry(entry); if (family == Family.Milestone) { // Life beats stay peek-worthy; Chronicle also covers them. return true; } if (actor == null || !actor.isAlive()) { return false; } if (family == Family.Status) { return StatusStillMatches(actor, entry); } if (family == Family.Happiness) { // Discrete mood beats age out of peek after the soft window. return false; } string entryVerb = ActivityVerbMap.Resolve(entry.TaskId ?? ""); string liveTaskId = ActivityInterestTable.SafeTaskId(actor); string liveVerb = ActivityVerbMap.Resolve(liveTaskId); if (!string.IsNullOrEmpty(entryVerb) && !string.IsNullOrEmpty(liveVerb) && (string.Equals(entryVerb, liveVerb, StringComparison.OrdinalIgnoreCase) || FamilyForVerb(entryVerb) == FamilyForVerb(liveVerb))) { return true; } return FamilyLivePredicate(actor, family); } /// /// Fingerprint of live AI bits that can change peek without adding ring lines. /// public static int LiveFingerprint(Actor actor, float now) { int h = 17; h = h * 31 + (int)(now / SoftWindowSeconds); if (actor == null) { return h; } try { h = h * 31 + (actor.has_attack_target ? 1 : 0); h = h * 31 + (actor.isCarryingResources() ? 1 : 0); string task = ActivityInterestTable.SafeTaskId(actor) ?? ""; h = h * 31 + (task?.GetHashCode() ?? 0); if (actor.hasTask() && actor.ai?.task != null) { h = h * 31 + (actor.ai.task.in_combat ? 1 : 0); h = h * 31 + (actor.ai.task.is_fireman ? 1 : 0); } } catch { // ignore } return h; } private static bool FamilyLivePredicate(Actor actor, Family family) { switch (family) { case Family.Combat: return CombatLive(actor); case Family.Haul: return HaulLive(actor); case Family.Social: case Family.Romance: case Family.Work: case Family.Care: case Family.Travel: case Family.Ambient: default: return false; } } private static bool CombatLive(Actor actor) { try { if (actor.has_attack_target) { return true; } if (actor.hasTask() && actor.ai?.task != null && (actor.ai.task.in_combat || actor.ai.task.is_fireman)) { return true; } } catch { // ignore } return false; } private static bool HaulLive(Actor actor) { try { return actor.isCarryingResources(); } catch { return false; } } private static bool StatusStillMatches(Actor actor, ActivityEntry entry) { string statusId = StatusIdFromEntry(entry); if (string.IsNullOrEmpty(statusId)) { return false; } try { bool has = actor.hasStatus(statusId); if (entry.Kind == ActivityKind.StatusGain) { return has; } if (entry.Kind == ActivityKind.StatusLoss) { return !has; } } catch { // ignore } return false; } private static string StatusIdFromEntry(ActivityEntry entry) { string key = (entry?.TaskId ?? "").Trim(); const string gain = "status_gain:"; const string loss = "status_loss:"; if (key.StartsWith(gain, StringComparison.OrdinalIgnoreCase)) { return key.Substring(gain.Length).Trim(); } if (key.StartsWith(loss, StringComparison.OrdinalIgnoreCase)) { return key.Substring(loss.Length).Trim(); } // NoteStatusChange may store bare status id or ActivityStatusProse.TaskKey. if (key.IndexOf(':') < 0 && !string.IsNullOrEmpty(key)) { return key; } return ""; } }