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(); } 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; } if (!WorldLogInterestTable.TryGetTier(message.asset_id, out InterestTier tier)) { return; } Vector3 position = message.getLocation(); Actor unit = null; if (message.hasFollowLocation()) { unit = message.unit; } if (unit == null && position == Vector3.zero) { return; } string assetId = message.asset_id ?? "worldlog"; string kingdom = message.special1 ?? ""; string key = "worldlog:" + assetId + ":" + kingdom; float boost = PeekCivicBoost(kingdom, assetId); var candidate = new InterestCandidate { Key = key, Urgency = tier, LeadKind = InterestLeadKind.EventLed, Category = tier >= InterestTier.Epic ? "Politics" : "Settlement", Source = "worldlog", EventStrength = 60f + (int)tier * 20f + boost, VisualConfidence = unit != null ? 0.7f : 0.4f, Position = unit != null ? unit.current_position : position, FollowUnit = unit, SubjectId = SafeId(unit), Label = WorldLogInterestTable.MakeLabel(message), AssetId = assetId, KingdomKey = kingdom, SpeciesId = unit?.asset != null ? unit.asset.id : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 40f, MinWatch = tier >= InterestTier.Epic ? 8f : 6f, MaxWatch = tier >= InterestTier.Epic ? 35f : 28f, Completion = InterestCompletionKind.FixedDwell }; InterestScoring.ScoreCheap(candidate); InterestRegistry.Upsert(candidate); } /// 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 ?? ""; string key = "direct:" + interest.Tier + ":" + subjectId + ":" + (interest.Label ?? "") + ":" + asset; InterestLeadKind lead = interest.Tier >= InterestTier.Action ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed; InterestCompletionKind completion = interest.Tier >= InterestTier.Action ? 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, Urgency = interest.Tier, LeadKind = lead, Category = CategoryForTier(interest.Tier, asset), Source = "direct", EventStrength = interest.Score > 0f ? interest.Score : 40f + (int)interest.Tier * 15f, CharacterSignificance = lead == InterestLeadKind.CharacterLed ? interest.Score : 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, CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 30f, MinWatch = interest.Tier <= InterestTier.Curiosity ? 1.2f : 1.5f, MaxWatch = interest.Tier >= InterestTier.Epic ? 40f : 25f, Completion = completion }; InterestScoring.ScoreCheap(candidate); return InterestRegistry.Upsert(candidate); } public static InterestCandidate RegisterHarness( Actor follow, InterestTier urgency, string label, string keySuffix, InterestLeadKind lead, InterestCompletionKind completion, bool forceActive, float eventStrength, float maxWatch) { return RegisterHarness( follow, urgency, label, keySuffix, lead, completion, forceActive, eventStrength, characterSignificance: lead == InterestLeadKind.CharacterLed ? eventStrength : 10f, maxWatch, ttlSeconds: 60f, related: null, resumable: true); } public static InterestCandidate RegisterHarness( Actor follow, InterestTier urgency, 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 ?? urgency.ToString()) + ":" + id; float now = Time.unscaledTime; var candidate = new InterestCandidate { Key = key, Urgency = urgency, LeadKind = lead, Category = CategoryForTier(urgency, ""), Source = "harness", EventStrength = eventStrength, CharacterSignificance = characterSignificance, VisualConfidence = 0.9f, Position = follow.current_position, FollowUnit = follow, RelatedUnit = related, SubjectId = id, Label = label ?? ("Harness " + urgency), 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 InterestRegistry.Upsert(candidate); } public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot) { if (actor == null || !actor.isAlive() || AgentHarness.Busy || !hot) { return; } long id = SafeId(actor); string verb = taskOrBeat ?? "activity"; string key = "activity:" + id + ":" + verb; bool combat = actor.has_attack_target; var candidate = new InterestCandidate { Key = key, Urgency = InterestTier.Action, LeadKind = InterestLeadKind.EventLed, Category = combat ? "Combat" : "Work", Source = "activity", EventStrength = combat ? 75f : 50f, VisualConfidence = 0.8f, Position = actor.current_position, FollowUnit = actor, SubjectId = id, Label = combat ? ("Fighting: " + SafeName(actor)) : (SafeName(actor) + " · " + verb), 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 + 20f, MinWatch = 3f, MaxWatch = 30f, Completion = combat ? InterestCompletionKind.CombatActive : InterestCompletionKind.ActivityActive }; candidate.ParticipantIds.Add(id); InterestScoring.ScoreCheap(candidate); InterestRegistry.Upsert(candidate); } public static void OnStatusChange(Actor actor, string statusId, bool gained) { if (actor == null || !actor.isAlive() || !gained || string.IsNullOrEmpty(statusId)) { return; } if (AgentHarness.Busy) { return; } // Visible spectacle statuses amplify / extend scenes; crying extends grief. bool spectacle = statusId == "burning" || statusId == "possessed" || statusId == "crying" || statusId == "frozen" || statusId == "shocked"; if (!spectacle) { return; } long id = SafeId(actor); if (statusId == "crying") { // Extend existing grief candidate rather than spawning a second scene. string griefPrefix = "happiness:death_"; ExtendMatching(id, griefPrefix, statusId); return; } string key = "status:" + statusId + ":" + id; var candidate = new InterestCandidate { Key = key, Urgency = InterestTier.Action, LeadKind = InterestLeadKind.EventLed, Category = "StatusTransformation", Source = "status", EventStrength = 55f, VisualConfidence = 0.85f, Position = actor.current_position, FollowUnit = actor, SubjectId = id, StatusId = statusId, Label = SafeName(actor) + " · " + statusId, AssetId = actor.asset != null ? actor.asset.id : "", SpeciesId = actor.asset != null ? actor.asset.id : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 18f, MinWatch = 2f, MaxWatch = 22f, Completion = InterestCompletionKind.StatusPhase }; InterestScoring.ScoreCheap(candidate); InterestRegistry.Upsert(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; InterestTier urgency = milestoneKey.Contains("kill") || milestoneKey.Contains("death") ? InterestTier.Action : InterestTier.Curiosity; var candidate = new InterestCandidate { Key = key, Urgency = urgency, 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); InterestRegistry.Upsert(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 || !occ.Applied || occ.SuppressedByPsychopath) { return; } // Harness scenarios inject via SourceHook.Harness; ignore live world noise while Busy. if (AgentHarness.Busy && occ.SourceHook != HappinessSourceHook.Harness) { return; } if (occ.Presentation == HappinessPresentationTier.Ambient) { // Enrichment only - never owns the camera. 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)); } 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; string key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId + ":" + CorrBucket(occ.CorrelationKey); bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_"); InterestTier urgency = InterestScoring.UrgencyForHappiness(occ.EffectId, subject); float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId); var candidate = new InterestCandidate { Key = key, Urgency = urgency, LeadKind = InterestLeadKind.EventLed, Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat), Source = "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 = !string.IsNullOrEmpty(occ.PlainClause) ? (occ.SubjectName + " " + occ.PlainClause) : (occ.SubjectName + " · " + occ.EffectId), AssetId = occ.SubjectSpecies, SpeciesId = occ.SubjectSpecies, HappinessEffectId = occ.EffectId, StatusId = grief ? "crying" : "", CorrelationKey = occ.CorrelationKey, CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + 35f, MinWatch = 4f, MaxWatch = 28f, Completion = grief ? InterestCompletionKind.HappinessGrief : InterestCompletionKind.FixedDwell }; candidate.ParticipantIds.Add(occ.SubjectId); if (occ.RelatedId != 0) { candidate.ParticipantIds.Add(occ.RelatedId); } InterestScoring.ScoreCheap(candidate); InterestRegistry.Upsert(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_"); InterestTier urgency = InterestScoring.UrgencyForHappiness(effectId, subject); 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, Urgency = urgency, 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 InterestRegistry.Upsert(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); } // Character-led vignette seeds (not sole one-best monopoly: register best + a few notables). WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); } private static void 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; InterestRegistry.Upsert(c); return; } } } 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 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 CategoryForTier(InterestTier tier, string asset) { if (asset == "live_battle") { return "Combat"; } if (tier >= InterestTier.Epic) { return "Politics"; } if (tier == InterestTier.Story) { return "Settlement"; } if (tier == InterestTier.Action) { return "Combat"; } if (tier == InterestTier.Curiosity) { return "Discovery"; } return "CharacterVignette"; } 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"; } }