using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Converts live streams into candidates. /// Sole path for WorldLog / happiness / scanner / activity / status / chronicle → registry. /// public static class InterestFeeds { private static ulong _happinessCursor; private static readonly List HappinessDrain = new List(64); private static float _lastScannerAt = -999f; private const float ScannerInterval = 0.75f; // Civic aggregate boosts keyed by kingdom+effect. private static readonly Dictionary CivicBoostUntil = new Dictionary(32); public static void Reset() { _happinessCursor = HappinessEventRouter.CurrentSequence; HappinessDrain.Clear(); _lastScannerAt = -999f; CivicBoostUntil.Clear(); InterestDropLog.Clear(); } public static ulong HappinessCursor => _happinessCursor; public static int CivicBoostCount { get { int n = 0; foreach (KeyValuePair kv in CivicBoostUntil) { if (kv.Key != null && !kv.Key.EndsWith("|amt") && kv.Value > Time.unscaledTime) { n++; } } return n; } } public static bool HasCivicBoostContaining(string effectId) { if (string.IsNullOrEmpty(effectId)) { return CivicBoostCount > 0; } foreach (KeyValuePair kv in CivicBoostUntil) { if (kv.Key != null && !kv.Key.EndsWith("|amt") && kv.Key.IndexOf(effectId, System.StringComparison.OrdinalIgnoreCase) >= 0 && kv.Value > Time.unscaledTime) { return true; } } return false; } /// Harness: drain happiness once; returns how many occurrences were ingested. public static int HarnessDrainOnce() { DrainHappiness(); return HappinessDrain.Count; } /// Harness: stamp a civic boost as AggregateSummary would. public static void HarnessStampCivicBoost(string kingdom, string effectId, float amount) { StampCivicBoost(kingdom ?? "harness", effectId ?? "", amount); } public static void Tick() { DrainHappiness(); TickScanner(); PruneCivicBoosts(); } public static void OnWorldLogMessage(WorldLogMessage message) { if (message == null || AgentHarness.Busy) { return; } WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id); if (!EventCatalog.IsCameraWorthy(entry)) { InterestDropLog.Record( "createsInterest=false", "worldlog:" + (message.asset_id ?? "")); return; } float evtSeed = entry.EventStrength; Vector3 position = message.getLocation(); Actor unit = null; if (message.hasFollowLocation()) { unit = message.unit; } string assetId = message.asset_id ?? entry.Id ?? "worldlog"; string kingdom = message.special1 ?? ""; string key = "worldlog:" + assetId + ":" + kingdom; float boost = PeekCivicBoost(kingdom, assetId); string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category; string label = entry.MakeLabel(message); if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos)) { EventFeedUtil.Register( key, "worldlog", category, label, assetId, evtSeed + boost, pos, follow, minWatch: entry.MinWatch, maxWatch: entry.MaxWatch); return; } // Civic / disaster without a real unit: location-only (no stranger subject). if (position != Vector3.zero && !float.IsNaN(position.x)) { EventFeedUtil.Register( key, "worldlog", category, label, assetId, evtSeed + boost, position, follow: null, locationOnly: true, minWatch: entry.MinWatch, maxWatch: entry.MaxWatch); } } /// Legacy / discovery / harness enqueue path → registry. public static InterestCandidate RegisterDirect(InterestEvent interest) { if (interest == null || !interest.HasValidPosition) { return null; } Actor unit = interest.FollowUnit; long subjectId = SafeId(unit); string asset = interest.AssetId ?? ""; float score = interest.Score; bool hot = score >= InterestScoringConfig.W.noticeScoreMin || asset == "live_battle"; string key = "direct:" + subjectId + ":" + (interest.Label ?? "") + ":" + asset; InterestLeadKind lead = hot ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed; InterestCompletionKind completion = hot ? InterestCompletionKind.FixedDwell : InterestCompletionKind.CharacterVignette; if (asset == "live_battle") { key = "battle:hot:" + Mathf.FloorToInt(interest.Position.x) + ":" + Mathf.FloorToInt(interest.Position.y); lead = InterestLeadKind.EventLed; completion = InterestCompletionKind.CombatActive; } var candidate = new InterestCandidate { Key = key, LeadKind = lead, Category = asset == "live_battle" ? "Combat" : CategoryForLead(lead, completion, asset), Source = "direct", EventStrength = score > 0f ? score : 40f, CharacterSignificance = interest.CharacterSignificance > 0f ? interest.CharacterSignificance : (lead == InterestLeadKind.CharacterLed ? score * 0.5f : 0f), VisualConfidence = unit != null ? 0.75f : 0.4f, Position = interest.Position, FollowUnit = unit, SubjectId = subjectId, Label = interest.Label ?? "", AssetId = asset, SpeciesId = unit?.asset != null ? unit.asset.id : asset, ParticipantCount = interest.ParticipantCount, NotableParticipantCount = interest.NotableParticipantCount, Verb = asset == "live_battle" ? "fighting" : "", CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 30f, MinWatch = InterestScoring.IsFillScore(score) ? 1.2f : 1.5f, MaxWatch = InterestScoring.IsHotScore(score) ? 40f : 25f, Completion = completion }; InterestScoring.ScoreCheap(candidate); return EventFeedUtil.RegisterCandidate(candidate); } public static InterestCandidate RegisterHarness( Actor follow, string label, string keySuffix, InterestLeadKind lead, InterestCompletionKind completion, bool forceActive, float eventStrength, float maxWatch) { return RegisterHarness( follow, label, keySuffix, lead, completion, forceActive, eventStrength, characterSignificance: lead == InterestLeadKind.CharacterLed ? UnityEngine.Mathf.Min(40f, eventStrength * 0.4f) : 10f, maxWatch, ttlSeconds: 60f, related: null, resumable: true); } public static InterestCandidate RegisterHarness( Actor follow, string label, string keySuffix, InterestLeadKind lead, InterestCompletionKind completion, bool forceActive, float eventStrength, float characterSignificance, float maxWatch, float ttlSeconds, Actor related, bool resumable) { if (follow == null || !follow.isAlive()) { return null; } long id = SafeId(follow); string key = "harness:" + (keySuffix ?? "scene") + ":" + id; float now = Time.unscaledTime; var candidate = new InterestCandidate { Key = key, LeadKind = lead, Category = CategoryForLead(lead, completion, ""), Source = "harness", EventStrength = eventStrength, CharacterSignificance = characterSignificance, VisualConfidence = 0.9f, Position = follow.current_position, FollowUnit = follow, RelatedUnit = related, SubjectId = id, Label = label ?? "Harness", AssetId = follow.asset != null ? follow.asset.id : "harness", SpeciesId = follow.asset != null ? follow.asset.id : "", CreatedAt = now, LastSeenAt = now, ExpiresAt = now + (ttlSeconds > 0f ? ttlSeconds : 60f), MinWatch = 1f, MaxWatch = maxWatch > 0f ? maxWatch : 20f, Completion = completion, ForceActive = forceActive, Resumable = resumable }; if (related != null) { try { candidate.RelatedId = related.getID(); } catch { candidate.RelatedId = 0; } } InterestScoring.ScoreCheap(candidate); return EventFeedUtil.RegisterCandidate(candidate); } public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot) { if (actor == null || !actor.isAlive() || AgentHarness.Busy || !hot) { return; } // Non-combat hot activity is not an event reason - task chip covers live job. if (!actor.has_attack_target) { return; } long id = SafeId(actor); Actor foe = null; try { if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor()) { foe = actor.attack_target.a; } } catch { foe = null; } string verb = taskOrBeat ?? "fighting"; string key = EventCatalog.Combat.Key(id); var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = "Combat", Source = "activity", EventStrength = EventCatalog.Combat.ActivityStrength, VisualConfidence = 0.8f, Position = actor.current_position, FollowUnit = actor, RelatedUnit = foe, SubjectId = id, RelatedId = foe != null ? SafeId(foe) : 0, Label = EventReason.Fight(actor, foe), AssetId = actor.asset != null ? actor.asset.id : "", SpeciesId = actor.asset != null ? actor.asset.id : "", Verb = verb, CityKey = actor.city != null ? (actor.city.name ?? "") : "", KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl, MinWatch = EventCatalog.Combat.MinWatch, MaxWatch = EventCatalog.Combat.MaxWatch, Completion = InterestCompletionKind.CombatActive }; candidate.ParticipantIds.Add(id); InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); } public static void OnStatusChange(Actor actor, string statusId, bool gained) { if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(statusId)) { return; } if (AgentHarness.Busy) { return; } // Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often // suppressed (no emotions), so status loss owns the camera reason. if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase)) { EmitHatch(actor, "status_egg_loss"); return; } if (!gained) { InterestDropLog.Record("status_loss_ignored", statusId); return; } StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId); if (!EventCatalog.IsCameraWorthy(entry)) { InterestDropLog.Record("createsInterest=false", "status:" + statusId); return; } long id = SafeId(actor); if (entry.ExtendsGrief || statusId == "crying") { string griefPrefix = "happiness:death_"; if (ExtendMatching(id, griefPrefix, statusId)) { return; } // Standalone crying with no grief scene still creates interest. if (statusId != "crying" && entry.ExtendsGrief) { return; } } string phrase = ActivityStatusProse.Phrase(statusId, gained: true, out _); string key = "status:" + statusId + ":" + id; InterestCandidate registered = EventFeedUtil.Register( key, "status", string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category, EventReason.Status(actor, phrase), statusId, entry.EventStrength, actor.current_position, actor, minWatch: 2f, maxWatch: InterestScoringConfig.W.maxWatchStatus, completion: InterestCompletionKind.StatusPhase); if (registered != null) { registered.StatusId = statusId; registered.LastSeenAt = Time.unscaledTime; } } /// /// Hatch camera beat. Shared key with happiness just_got_out_of_egg so both paths merge. /// public static void EmitHatch(Actor actor, string source) { if (actor == null || !actor.isAlive()) { return; } long id = SafeId(actor); if (id == 0) { return; } float strength = Mathf.Max( InterestScoring.EventStrengthForHappiness("just_got_out_of_egg"), 88f); var candidate = new InterestCandidate { Key = HatchKey(id), LeadKind = InterestLeadKind.EventLed, Category = "LifeChapter", Source = string.IsNullOrEmpty(source) ? "hatch" : source, EventStrength = strength, VisualConfidence = 0.8f, Position = actor.current_position, FollowUnit = actor, SubjectId = id, Label = EventReason.Hatch(actor), AssetId = SafeAsset(actor), SpeciesId = SafeAsset(actor), HappinessEffectId = "just_got_out_of_egg", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 7f, MinWatch = 4f, MaxWatch = 15f, Completion = InterestCompletionKind.FixedDwell }; candidate.ParticipantIds.Add(id); InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); } private static string HatchKey(long subjectId) => "hatch:" + subjectId; private static string AlphaKey(long subjectId) => "alpha:" + subjectId; /// /// Family alpha succession. Shared key with happiness become_alpha so emotion and /// emotion-less paths merge into one camera beat. /// public static void EmitAlpha(Actor actor, string source) { if (actor == null || !actor.isAlive()) { return; } if (AgentHarness.Busy) { return; } long id = SafeId(actor); if (id == 0) { return; } DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback("become_alpha"); if (!EventCatalog.IsCameraWorthy(entry)) { InterestDropLog.Record("createsInterest=false", "become_alpha"); return; } float strength = Mathf.Max( entry.EventStrength, InterestScoring.EventStrengthForHappiness("become_alpha")); var candidate = new InterestCandidate { Key = AlphaKey(id), LeadKind = InterestLeadKind.EventLed, Category = string.IsNullOrEmpty(entry.Category) ? "LifeChapter" : entry.Category, Source = string.IsNullOrEmpty(source) ? "alpha" : source, EventStrength = strength, VisualConfidence = 0.8f, Position = actor.current_position, FollowUnit = actor, SubjectId = id, Label = EventReason.BecomeAlpha(actor), AssetId = SafeAsset(actor), SpeciesId = SafeAsset(actor), HappinessEffectId = "become_alpha", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 25f, MinWatch = 5f, MaxWatch = 22f, Completion = InterestCompletionKind.FixedDwell }; candidate.ParticipantIds.Add(id); InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); } public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label) { if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey)) { return; } if (AgentHarness.Busy) { return; } long sid = SafeId(subject); long rid = SafeId(related); // Shared key space with happiness canonical merges. string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid; var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = "Relationship", Source = "chronicle", EventStrength = 60f, VisualConfidence = 0.7f, Position = subject.current_position, FollowUnit = subject, RelatedUnit = related, SubjectId = sid, RelatedId = rid, Label = label ?? milestoneKey, AssetId = subject.asset != null ? subject.asset.id : "", SpeciesId = subject.asset != null ? subject.asset.id : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 25f, MinWatch = 3f, MaxWatch = 20f, Completion = InterestCompletionKind.FixedDwell }; InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); } private static void DrainHappiness() { ulong next = HappinessEventRouter.DrainSince(_happinessCursor, HappinessDrain); if (HappinessDrain.Count == 0) { _happinessCursor = next; return; } for (int i = 0; i < HappinessDrain.Count; i++) { IngestHappiness(HappinessDrain[i]); } _happinessCursor = next; } private static void IngestHappiness(HappinessOccurrence occ) { if (occ == null) { return; } if (!occ.Applied || occ.SuppressedByPsychopath) { InterestDropLog.Record( occ.SuppressedByPsychopath ? "suppressed" : "not_applied", occ.EffectId ?? ""); return; } if (AgentHarness.Busy && occ.SourceHook != HappinessSourceHook.Harness) { InterestDropLog.Record("busy", occ.EffectId ?? ""); return; } if (occ.Presentation == HappinessPresentationTier.Aggregate) { if (!occ.Merged || occ.SourceHook == HappinessSourceHook.AggregateSummary) { string kingdom = ""; try { // Prefer civ key fragment. string civ = occ.CivEventKey ?? ""; int pipe = civ.IndexOf('|'); if (pipe >= 0 && pipe + 1 < civ.Length) { int pipe2 = civ.IndexOf('|', pipe + 1); kingdom = pipe2 > pipe ? civ.Substring(pipe + 1, pipe2 - pipe - 1) : civ.Substring(pipe + 1); } } catch { kingdom = ""; } StampCivicBoost(kingdom, occ.EffectId, 25f + Mathf.Min(40f, occ.TotalAffectedCount)); } InterestDropLog.Record("aggregate", occ.EffectId ?? ""); return; } if (!EventCatalog.IsCameraWorthy(occ.Presentation)) { // Layer B only - never owns the camera. InterestDropLog.Record("ambient", occ.EffectId ?? ""); return; } // Signal: skip merged duplicates (canonical / continuation already covered). if (occ.Merged) { return; } Actor subject = FindUnitById(occ.SubjectId); if (subject == null || !subject.isAlive()) { return; } Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null; string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory; bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_"); float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId); bool hatchMoment = IsFreshLifeMoment(occ.EffectId); bool alphaMoment = IsAlphaMoment(occ.EffectId); // Hatch / alpha share keys with status-loss / Family.setAlpha so paths merge. string key; if (hatchMoment) { key = HatchKey(occ.SubjectId); } else if (alphaMoment) { key = AlphaKey(occ.SubjectId); } else { key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId + ":" + CorrBucket(occ.CorrelationKey); } // Hatch: short queue TTL, but on-camera floor comes from minCameraDwell. float ttl = hatchMoment ? 7f : 35f; float minWatch = hatchMoment ? 4f : 8f; float maxWatch = hatchMoment ? 15f : 28f; if (hatchMoment) { strength = Mathf.Max(strength, 88f); } if (alphaMoment) { strength = Mathf.Max(strength, EventCatalog.Relationship.GetOrFallback("become_alpha").EventStrength); ttl = 25f; minWatch = 5f; maxWatch = 22f; } string label; if (hatchMoment) { label = EventReason.Hatch(subject); } else if (alphaMoment) { label = EventReason.BecomeAlpha(subject); } else if (!string.IsNullOrEmpty(occ.PlainClause)) { label = occ.SubjectName + " " + occ.PlainClause; } else { label = occ.SubjectName + " · " + occ.EffectId; } var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat), Source = hatchMoment ? "happiness_hatch" : (alphaMoment ? "happiness_alpha" : "happiness"), EventStrength = strength, VisualConfidence = 0.75f, Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position, FollowUnit = subject, RelatedUnit = related, SubjectId = occ.SubjectId, RelatedId = occ.RelatedId, Label = label, AssetId = occ.SubjectSpecies, SpeciesId = occ.SubjectSpecies, HappinessEffectId = occ.EffectId, StatusId = grief ? "crying" : "", CorrelationKey = occ.CorrelationKey, CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + ttl, MinWatch = minWatch, MaxWatch = maxWatch, Completion = grief ? InterestCompletionKind.HappinessGrief : InterestCompletionKind.FixedDwell }; candidate.ParticipantIds.Add(occ.SubjectId); if (occ.RelatedId != 0) { candidate.ParticipantIds.Add(occ.RelatedId); } InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); } /// Harness / push path: ingest one occurrence into the registry now. public static void IngestForHarness(HappinessOccurrence occ) { IngestHappiness(occ); if (occ != null && occ.Sequence > _happinessCursor) { _happinessCursor = occ.Sequence; } } /// Direct registry insert for harness happiness signals (skips drain filters). public static InterestCandidate RegisterHappinessSignal( Actor subject, string effectId, string label, string relatedName = "") { if (subject == null || string.IsNullOrEmpty(effectId)) { return null; } long id = SafeId(subject); Vector3 pos = Vector3.zero; try { pos = subject.current_position; } catch { pos = Vector3.zero; } bool grief = effectId.StartsWith("death_"); float strength = InterestScoring.EventStrengthForHappiness(effectId); string key = "happiness:" + effectId + ":" + id + ":0:harness"; bool alive = false; try { alive = subject.isAlive(); } catch { alive = false; } var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = grief ? "FamilyEmotion" : "LifeChapter", Source = "happiness_harness", EventStrength = strength, VisualConfidence = 0.9f, Position = pos != Vector3.zero ? pos : new Vector3(1f, 1f, 0f), FollowUnit = alive ? subject : null, SubjectId = id, Label = !string.IsNullOrEmpty(label) ? label : (SafeName(subject) + " · " + effectId), AssetId = subject.asset != null ? subject.asset.id : "", SpeciesId = subject.asset != null ? subject.asset.id : "", HappinessEffectId = effectId, StatusId = grief ? "crying" : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 60f, MinWatch = 4f, MaxWatch = 28f, Completion = grief ? InterestCompletionKind.HappinessGrief : InterestCompletionKind.FixedDwell }; if (!string.IsNullOrEmpty(relatedName)) { candidate.Label = SafeName(subject) + " mourns " + relatedName; } InterestScoring.ScoreCheap(candidate); return EventFeedUtil.RegisterCandidate(candidate); } private static void TickScanner() { float now = Time.unscaledTime; if (now - _lastScannerAt < ScannerInterval) { return; } _lastScannerAt = now; if (AgentHarness.Busy) { return; } // Top battle cluster. InterestEvent battle = WorldActivityScanner.FindHottestBattle(); if (battle != null) { RegisterDirect(battle); } // Ongoing wars from WarManager (complements WorldLog war start/end). WarInterestFeed.Tick(); // Active plots from PlotManager / World.plots. PlotInterestFeed.Tick(); // Character-led vignette seeds (not sole one-best monopoly: register best + a few notables). WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); } private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId) { var pending = new List(32); InterestRegistry.CopyPending(pending); for (int i = 0; i < pending.Count; i++) { InterestCandidate c = pending[i]; if (c == null || c.SubjectId != subjectId) { continue; } if (c.Key != null && c.Key.StartsWith(keyPrefix)) { c.StatusId = statusId; c.LastSeenAt = Time.unscaledTime; c.ExpiresAt = Time.unscaledTime + 20f; EventFeedUtil.RegisterCandidate(c); return true; } } return false; } private static void StampCivicBoost(string kingdom, string effectId, float amount) { string key = (kingdom ?? "") + "|" + (effectId ?? ""); CivicBoostUntil[key] = Time.unscaledTime + 12f; // Store amount in a parallel way via Expires encoding isn't ideal; keep simple additive peek. CivicBoostUntil[key + "|amt"] = amount; } private static float PeekCivicBoost(string kingdom, string assetId) { // Map worldlog war assets to happiness war effects loosely. string effect = ""; if (assetId != null && assetId.Contains("war")) { effect = "just_started_war"; } else if (assetId != null && (assetId.Contains("city") || assetId.Contains("kingdom"))) { effect = "conquered_city"; } string key = (kingdom ?? "") + "|" + effect; if (!CivicBoostUntil.TryGetValue(key, out float until) || Time.unscaledTime > until) { return 0f; } return CivicBoostUntil.TryGetValue(key + "|amt", out float amt) ? amt : 15f; } private static void PruneCivicBoosts() { if (CivicBoostUntil.Count < 64) { return; } float now = Time.unscaledTime; var remove = new List(); foreach (KeyValuePair kv in CivicBoostUntil) { if (!kv.Key.EndsWith("|amt") && now > kv.Value) { remove.Add(kv.Key); remove.Add(kv.Key + "|amt"); } } for (int i = 0; i < remove.Count; i++) { CivicBoostUntil.Remove(remove[i]); } } private static bool IsFreshLifeMoment(string effectId) { if (string.IsNullOrEmpty(effectId)) { return false; } // Hatch moments only - just_born is spawn bookkeeping, not a birth/hatch beat. string id = effectId.Trim(); return string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) || string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase); } private static bool IsAlphaMoment(string effectId) { return !string.IsNullOrEmpty(effectId) && string.Equals(effectId.Trim(), "become_alpha", StringComparison.OrdinalIgnoreCase); } private static string MapHappinessCategory(string cat) { switch ((cat ?? "").ToLowerInvariant()) { case "grief": return "FamilyEmotion"; case "social": return "Relationship"; case "lifechapter": return "LifeChapter"; case "civic": return "Politics"; default: return "FamilyEmotion"; } } private static string CategoryForLead( InterestLeadKind lead, InterestCompletionKind completion, string asset) { if (asset == "live_battle" || completion == InterestCompletionKind.CombatActive) { return "Combat"; } if (completion == InterestCompletionKind.CharacterVignette || lead == InterestLeadKind.CharacterLed) { return "CharacterVignette"; } if (completion == InterestCompletionKind.HappinessGrief) { return "FamilyEmotion"; } if (completion == InterestCompletionKind.ActivityActive) { return "Work"; } return "Settlement"; } private static string CorrBucket(string corr) { if (string.IsNullOrEmpty(corr)) { return "0"; } int hash = corr.GetHashCode(); return (hash & 0xFFFF).ToString("x"); } private static Actor FindUnitById(long id) { if (id == 0) { return null; } foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { try { if (actor != null && actor.getID() == id) { return actor; } } catch { // continue } } return null; } private static long SafeId(Actor actor) { if (actor == null) { return 0; } try { return actor.getID(); } catch { return 0; } } private static string SafeName(Actor actor) { if (actor == null) { return "Someone"; } try { string n = actor.getName(); if (!string.IsNullOrEmpty(n)) { return n; } } catch { // ignore } return actor.asset != null ? actor.asset.id : "creature"; } private static string SafeAsset(Actor actor) { if (actor == null) { return ""; } try { return actor.asset != null ? actor.asset.id : ""; } catch { return ""; } } }