using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Sole writer from happiness hooks into Activity / Chronicle Life. /// Patches publish occurrences; presentation tiers and overlap live here. /// public static class HappinessEventRouter { public const float AmbientRingCooldown = 25f; public const float CorrelationWindow = 3f; public const float AggregateWindow = 0.75f; public const int AggregateSampleCap = 8; public const int MaxRecentOccurrences = 256; private static readonly List Recent = new List(MaxRecentOccurrences); private static readonly Dictionary AmbientRingAt = new Dictionary(); private static readonly Dictionary CorrelationAt = new Dictionary(); private static readonly Dictionary Aggregates = new Dictionary(); private static int _notesSinceClear; private static int _ringWritesSinceClear; private static int _lifeWritesSinceClear; private static int _mergedSinceClear; private static int _suppressedSinceClear; private static int _aggregateSummariesSinceClear; private static string _lastEffectId = ""; private static long _counterSubjectId; private static ulong _nextSequence = 1; private static Action _subscribers; private sealed class AggregateBucket { public string EffectId; public float StartedAt; public int Total; public int Sampled; public Actor FirstNotable; public string CivKey = ""; } public static int NotesSinceClear => _notesSinceClear; public static int RingWritesSinceClear => _ringWritesSinceClear; public static int LifeWritesSinceClear => _lifeWritesSinceClear; public static int MergedSinceClear => _mergedSinceClear; public static int SuppressedSinceClear => _suppressedSinceClear; public static int AggregateSummariesSinceClear => _aggregateSummariesSinceClear; public static string LastEffectId => _lastEffectId ?? ""; public static ulong CurrentSequence => _nextSequence > 0 ? _nextSequence - 1 : 0; public static void ClearSession() { Recent.Clear(); AmbientRingAt.Clear(); CorrelationAt.Clear(); Aggregates.Clear(); _notesSinceClear = 0; _ringWritesSinceClear = 0; _lifeWritesSinceClear = 0; _mergedSinceClear = 0; _suppressedSinceClear = 0; _aggregateSummariesSinceClear = 0; _lastEffectId = ""; _counterSubjectId = 0; _nextSequence = 1; InterestFeeds.OnHappinessRouterCleared(); } /// Push listener for spectator interest intake (in addition to ). public static void Subscribe(Action handler) { if (handler == null) { return; } _subscribers += handler; } public static void Unsubscribe(Action handler) { if (handler == null) { return; } _subscribers -= handler; } /// /// Drain occurrences with Sequence > . /// Returns the highest sequence drained (pass back next call). Diagnostics may still use . /// public static ulong DrainSince(ulong sinceSeq, List destination) { if (destination == null) { return sinceSeq; } destination.Clear(); ulong max = sinceSeq; for (int i = 0; i < Recent.Count; i++) { HappinessOccurrence occ = Recent[i]; if (occ == null || occ.Sequence <= sinceSeq) { continue; } destination.Add(occ); if (occ.Sequence > max) { max = occ.Sequence; } } return max; } public static void ResetCountersForSubject(long subjectId) { _counterSubjectId = subjectId; _notesSinceClear = 0; _ringWritesSinceClear = 0; _lifeWritesSinceClear = 0; _mergedSinceClear = 0; _suppressedSinceClear = 0; _aggregateSummariesSinceClear = 0; _lastEffectId = ""; } public static IReadOnlyList Latest(int max = 32) { if (max <= 0 || Recent.Count == 0) { return Array.Empty(); } int start = Math.Max(0, Recent.Count - max); var list = new List(Recent.Count - start); for (int i = start; i < Recent.Count; i++) { list.Add(Recent[i]); } return list; } /// Publish a happiness effect that the game actually applied (or harness force). public static HappinessOccurrence PublishApplied( Actor subject, string effectId, int amount, HappinessSourceHook hook, Actor related = null, HappinessRelationRole role = HappinessRelationRole.None, bool forceRing = false, string relatedNameOverride = null) { return PublishCore( subject, effectId, amount, hook, related, role, applied: true, suppressedByPsychopath: false, forceRing, relatedNameOverride); } /// Record that the game refused to apply happiness (psychopath/egg/no emotions). public static void PublishSuppressed(Actor subject, string effectId, HappinessSourceHook hook) { if (_counterSubjectId == 0 || (subject != null && subject.getID() == _counterSubjectId)) { _suppressedSinceClear++; } InterestDropLog.Record("no_emotions_or_egg", effectId ?? ""); var occ = BuildOccurrence( subject, effectId, amount: 0, hook, related: null, role: HappinessRelationRole.None, applied: false, suppressedByPsychopath: true); occ.Merged = true; Remember(occ); } private static HappinessOccurrence PublishCore( Actor subject, string effectId, int amount, HappinessSourceHook hook, Actor related, HappinessRelationRole role, bool applied, bool suppressedByPsychopath, bool forceRing, string relatedNameOverride = null) { if (subject == null || string.IsNullOrEmpty(effectId) || !applied) { return null; } FlushStaleAggregates(); HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId); if (related == null && HappinessContextBag.TryGetRelated(subject, out Actor bagRelated, out HappinessRelationRole bagRole)) { related = bagRelated; if (role == HappinessRelationRole.None) { role = bagRole; } } if (role == HappinessRelationRole.None) { role = entry.RelationRole != HappinessRelationRole.None ? entry.RelationRole : HappinessContextBag.RoleForDeathEffect(effectId); } if (entry.Presentation == HappinessPresentationTier.Aggregate && !forceRing) { return PublishAggregateMember(subject, effectId, amount, hook, related, role, entry); } HappinessOccurrence occ = BuildOccurrence( subject, effectId, amount, hook, related, role, applied, suppressedByPsychopath, relatedNameOverride); FillProse(occ, entry); if (ShouldMerge(occ, entry)) { occ.Merged = true; EnrichEarlierOccurrence(occ); if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) { _mergedSinceClear++; _notesSinceClear++; _lastEffectId = effectId; } Remember(occ); return occ; } bool writeRing = forceRing || ShouldWriteRing(occ, entry); if (writeRing) { ActivityLog.NoteHappiness(occ); occ.WroteActivityRing = true; if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) { _ringWritesSinceClear++; } MarkAmbient(occ); } if (entry.Life == HappinessLifePolicy.ProjectToLife && entry.Overlap != HappinessOverlapPolicy.CanonicalMilestone) { NoteSagaFromHappiness(occ, subject, related, effectId); if (Chronicle.NoteHappinessLife(occ, subject, related)) { occ.WroteLife = true; if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) { _lifeWritesSinceClear++; } } } MarkCorrelation(occ, entry); if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) { _notesSinceClear++; _lastEffectId = effectId; } Remember(occ); return occ; } private static void NoteSagaFromHappiness( HappinessOccurrence occ, Actor subject, Actor related, string effectId) { if (subject == null || string.IsNullOrEmpty(effectId)) { return; } string id = effectId; if (id.IndexOf("become_king", System.StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("became_king", System.StringComparison.OrdinalIgnoreCase) >= 0) { LifeSagaMemory.RecordRole(subject, "become_king", "happiness:" + id); return; } if (id.IndexOf("become_leader", System.StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("became_leader", System.StringComparison.OrdinalIgnoreCase) >= 0) { LifeSagaMemory.RecordRole(subject, "become_leader", "happiness:" + id); return; } if (id.IndexOf("become_alpha", System.StringComparison.OrdinalIgnoreCase) >= 0) { LifeSagaMemory.RecordRole(subject, "become_alpha", "happiness:" + id); return; } if (id.IndexOf("just_found_house", System.StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("found_house", System.StringComparison.OrdinalIgnoreCase) >= 0) { LifeSagaMemory.RecordHome(subject, gained: true, provenance: "happiness:" + id); return; } if (id.IndexOf("just_lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0) { LifeSagaMemory.RecordHome(subject, gained: false, provenance: "happiness:" + id); return; } if (id.IndexOf("death_lover", System.StringComparison.OrdinalIgnoreCase) >= 0 || id.IndexOf("grief", System.StringComparison.OrdinalIgnoreCase) >= 0) { if (related != null) { LifeSagaMemory.RecordBondBroken(subject, related, "happiness:" + id); } } _ = occ; } private static HappinessOccurrence PublishAggregateMember( Actor subject, string effectId, int amount, HappinessSourceHook hook, Actor related, HappinessRelationRole role, HappinessCatalogEntry entry) { string civKey = CivKey(subject, effectId); float now = Time.unscaledTime; if (!Aggregates.TryGetValue(civKey, out AggregateBucket bucket) || now - bucket.StartedAt > AggregateWindow) { bucket = new AggregateBucket { EffectId = effectId, StartedAt = now, CivKey = civKey }; Aggregates[civKey] = bucket; } bucket.Total++; bool notable = IsNotable(subject); bool takeSample = bucket.Sampled < AggregateSampleCap && (notable ? bucket.FirstNotable == null : true); if (notable && bucket.FirstNotable == null) { bucket.FirstNotable = subject; } if (takeSample) { bucket.Sampled++; // Notables and early samples get individual ring lines (one notable max). HappinessOccurrence sample = BuildOccurrence( subject, effectId, amount, hook, related, role, applied: true, suppressedByPsychopath: false); sample.Presentation = HappinessPresentationTier.Aggregate; sample.CivEventKey = civKey; sample.SampledCount = 1; sample.TotalAffectedCount = bucket.Total; FillProse(sample, entry); bool writeRing = notable || bucket.Sampled <= 3; if (writeRing) { ActivityLog.NoteHappiness(sample); sample.WroteActivityRing = true; if (_counterSubjectId == 0 || sample.SubjectId == _counterSubjectId) { _ringWritesSinceClear++; } } if (_counterSubjectId == 0 || sample.SubjectId == _counterSubjectId) { _notesSinceClear++; _lastEffectId = effectId; } Remember(sample); return sample; } // Overflow: count only; summary flushed later. HappinessOccurrence skipped = BuildOccurrence( subject, effectId, amount, hook, related, role, applied: true, suppressedByPsychopath: false); skipped.Presentation = HappinessPresentationTier.Aggregate; skipped.CivEventKey = civKey; skipped.TotalAffectedCount = bucket.Total; skipped.Merged = true; FillProse(skipped, entry); Remember(skipped); return skipped; } public static void FlushAggregatesNow() { FlushStaleAggregates(force: true); } private static void FlushStaleAggregates(bool force = false) { float now = Time.unscaledTime; if (Aggregates.Count == 0) { return; } var done = new List(); foreach (KeyValuePair kv in Aggregates) { if (force || now - kv.Value.StartedAt >= AggregateWindow) { done.Add(kv.Key); } } for (int i = 0; i < done.Count; i++) { string key = done[i]; AggregateBucket bucket = Aggregates[key]; Aggregates.Remove(key); if (bucket.Total <= bucket.Sampled) { continue; } Actor subject = bucket.FirstNotable; if (subject == null) { continue; } HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(bucket.EffectId); HappinessOccurrence summary = BuildOccurrence( subject, bucket.EffectId, amount: HappinessGameApi.ResolveAmount(bucket.EffectId), HappinessSourceHook.AggregateSummary, related: null, role: HappinessRelationRole.None, applied: true, suppressedByPsychopath: false); summary.Presentation = HappinessPresentationTier.Aggregate; summary.CivEventKey = bucket.CivKey; summary.SampledCount = bucket.Sampled; summary.TotalAffectedCount = bucket.Total; int others = Math.Max(0, bucket.Total - bucket.Sampled); string baseClause = ActivityHappinessProse.Clause(entry, "", out _, out _); summary.PlainClause = baseClause + " (and " + others + " others)"; summary.RichClause = summary.PlainClause; summary.FactLine = "Happiness " + bucket.EffectId + " x" + bucket.Total; ActivityLog.NoteHappiness(summary); summary.WroteActivityRing = true; _aggregateSummariesSinceClear++; if (_counterSubjectId == 0 || summary.SubjectId == _counterSubjectId) { _ringWritesSinceClear++; _notesSinceClear++; _lastEffectId = bucket.EffectId; } Remember(summary); } } private static HappinessOccurrence BuildOccurrence( Actor subject, string effectId, int amount, HappinessSourceHook hook, Actor related, HappinessRelationRole role, bool applied, bool suppressedByPsychopath, string relatedNameOverride = null) { HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId); long subjectId = 0; string subjectName = "Someone"; string species = ""; Vector3 pos = Vector3.zero; try { subjectId = subject.getID(); subjectName = subject.getName(); if (string.IsNullOrEmpty(subjectName)) { subjectName = subject.asset != null ? subject.asset.id : "creature"; } species = subject.asset != null ? subject.asset.id : ""; pos = subject.current_position; } catch { // keep defaults } long relatedId = 0; string relatedName = ""; string relatedSpecies = ""; if (related != null) { try { relatedId = related.getID(); relatedName = related.getName(); if (string.IsNullOrEmpty(relatedName)) { relatedName = related.asset != null ? related.asset.id : "creature"; } relatedSpecies = related.asset != null ? related.asset.id : ""; } catch { // ignore } } if (string.IsNullOrEmpty(relatedName) && !string.IsNullOrEmpty(relatedNameOverride)) { relatedName = relatedNameOverride.Trim(); } StampWorldDate(out double worldTime, out string dateLabel); string corr = subjectId + "|" + effectId + "|" + relatedId + "|" + Math.Floor(worldTime); return new HappinessOccurrence { EffectId = effectId.Trim(), Amount = amount, SubjectId = subjectId, SubjectName = subjectName, SubjectSpecies = species, RelatedId = relatedId, RelatedName = relatedName, RelatedSpecies = relatedSpecies, RelationRole = role, Position = pos, WorldTime = worldTime, DateLabel = dateLabel, SourceHook = hook, CorrelationKey = corr, SuppressedByPsychopath = suppressedByPsychopath, Applied = applied, Presentation = entry.Presentation, Life = entry.Life, Overlap = entry.Overlap, Context = entry.Context, InterestCategory = entry.InterestCategory, CanonicalMilestoneKey = entry.CanonicalMilestoneKey, StatusOverlapId = entry.StatusOverlapId, StatusOverlapKind = entry.StatusOverlapKind }; } private static void FillProse(HappinessOccurrence occ, HappinessCatalogEntry entry) { string clause = ActivityHappinessProse.Clause( entry, occ.RelatedName, out bool usedRelated, out _); if (entry.Context == HappinessContextRequirement.RequiresRelatedActor && string.IsNullOrEmpty(occ.RelatedName)) { // Still author a subject-only line at runtime for safety; audit fails separately. clause = ActivityHappinessProse.Clause(entry, "", out _, out _); usedRelated = false; } occ.PlainClause = clause; if (usedRelated && !string.IsNullOrEmpty(occ.RelatedName)) { occ.RichClause = clause.Replace( occ.RelatedName, ActivityProse.ColorName(occ.RelatedName)); } else { occ.RichClause = clause; } occ.FactLine = "Happiness " + occ.EffectId + (occ.Amount != 0 ? " " + (occ.Amount > 0 ? "+" : "") + occ.Amount : ""); } private static bool ShouldMerge(HappinessOccurrence occ, HappinessCatalogEntry entry) { float now = Time.unscaledTime; if (entry.Overlap == HappinessOverlapPolicy.CanonicalMilestone && !string.IsNullOrEmpty(entry.CanonicalMilestoneKey)) { string key = "canon|" + occ.SubjectId + "|" + entry.CanonicalMilestoneKey; if (CorrelationAt.TryGetValue(key, out float at) && now - at <= CorrelationWindow) { return true; } } if (entry.Overlap == HappinessOverlapPolicy.Continuation && !string.IsNullOrEmpty(entry.StatusOverlapId)) { string key = "status|" + occ.SubjectId + "|" + entry.StatusOverlapId; if (CorrelationAt.TryGetValue(key, out float at) && now - at <= CorrelationWindow) { // Same-second status+happiness: keep status as the visible phase; merge happiness ring. return true; } } string selfKey = "happ|" + occ.SubjectId + "|" + occ.EffectId + "|" + occ.RelatedId; if (CorrelationAt.TryGetValue(selfKey, out float selfAt) && now - selfAt <= 0.35f) { return true; } return false; } private static bool ShouldWriteRing(HappinessOccurrence occ, HappinessCatalogEntry entry) { if (entry.Presentation == HappinessPresentationTier.Signal) { return true; } if (entry.Presentation == HappinessPresentationTier.Ambient) { string key = occ.SubjectId + "|" + occ.EffectId; float now = Time.unscaledTime; if (AmbientRingAt.TryGetValue(key, out float at) && now - at < AmbientRingCooldown) { return false; } return true; } return true; } /// /// A bridge callback can know the named partner after the first canonical signal was /// recorded. The drain stores occurrence references, so enriching the earlier survivor /// preserves that participant without creating a duplicate camera beat. /// private static void EnrichEarlierOccurrence(HappinessOccurrence incoming) { if (incoming == null || incoming.RelatedId == 0 || string.IsNullOrEmpty(incoming.RelatedName)) { return; } for (int i = Recent.Count - 1; i >= 0 && i >= Recent.Count - 12; i--) { HappinessOccurrence prior = Recent[i]; if (prior == null || prior.Merged || prior.SubjectId != incoming.SubjectId || !string.Equals(prior.EffectId, incoming.EffectId, StringComparison.OrdinalIgnoreCase) || prior.RelatedId != 0) { continue; } prior.RelatedId = incoming.RelatedId; prior.RelatedName = incoming.RelatedName; prior.RelatedSpecies = incoming.RelatedSpecies; if (prior.RelationRole == HappinessRelationRole.None) { prior.RelationRole = incoming.RelationRole; } if (!string.IsNullOrEmpty(incoming.PlainClause)) { prior.PlainClause = incoming.PlainClause; prior.RichClause = incoming.RichClause; } return; } } private static void MarkAmbient(HappinessOccurrence occ) { if (occ.Presentation != HappinessPresentationTier.Ambient) { return; } AmbientRingAt[occ.SubjectId + "|" + occ.EffectId] = Time.unscaledTime; } private static void MarkCorrelation(HappinessOccurrence occ, HappinessCatalogEntry entry) { float now = Time.unscaledTime; CorrelationAt["happ|" + occ.SubjectId + "|" + occ.EffectId + "|" + occ.RelatedId] = now; if (!string.IsNullOrEmpty(entry.CanonicalMilestoneKey)) { CorrelationAt["canon|" + occ.SubjectId + "|" + entry.CanonicalMilestoneKey] = now; } if (!string.IsNullOrEmpty(entry.StatusOverlapId)) { CorrelationAt["status|" + occ.SubjectId + "|" + entry.StatusOverlapId] = now; } PruneDict(CorrelationAt, now, CorrelationWindow * 4f); PruneDict(AmbientRingAt, now, AmbientRingCooldown * 4f); } /// Called when Chronicle logs a canonical lover/friend/kill milestone. public static void NoteCanonicalMilestone(long subjectId, string milestoneKey) { if (subjectId == 0 || string.IsNullOrEmpty(milestoneKey)) { return; } CorrelationAt["canon|" + subjectId + "|" + milestoneKey] = Time.unscaledTime; } /// Called when status gain/loss is logged so Continuation overlaps can merge. public static void NoteStatusPhase(long subjectId, string statusId) { if (subjectId == 0 || string.IsNullOrEmpty(statusId)) { return; } CorrelationAt["status|" + subjectId + "|" + statusId] = Time.unscaledTime; } private static void Remember(HappinessOccurrence occ) { if (occ == null) { return; } occ.Sequence = _nextSequence++; Recent.Add(occ); while (Recent.Count > MaxRecentOccurrences) { Recent.RemoveAt(0); } Action handlers = _subscribers; if (handlers != null) { try { handlers(occ); } catch { // Interest consumers must not break happiness logging. } } } /// Harness-only: assign sequence and remember an already-built occurrence. public static void RememberForHarness(HappinessOccurrence occ) { if (occ == null) { return; } occ.Applied = true; Remember(occ); } private static bool IsNotable(Actor actor) { if (actor == null) { return false; } try { if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null && MoveCamera._focus_unit.getID() == actor.getID()) { return true; } } catch { // ignore } try { if (actor.isFavorite()) { return true; } } catch { // ignore } try { if (actor.isKing() || actor.isCityLeader()) { return true; } } catch { // ignore } return false; } private static string CivKey(Actor subject, string effectId) { string kingdom = ""; try { if (subject.kingdom != null) { kingdom = subject.kingdom.name ?? ""; } } catch { kingdom = ""; } return effectId + "|" + kingdom + "|" + Math.Floor(Time.unscaledTime / AggregateWindow); } private static void StampWorldDate(out double worldTime, out string dateLabel) { worldTime = 0; dateLabel = ""; try { if (World.world == null) { return; } worldTime = World.world.getCurWorldTime(); int[] raw = Date.getRawDate(worldTime); if (raw == null || raw.Length < 3) { dateLabel = "y" + Date.getCurrentYear() + " m" + Date.getCurrentMonth(); return; } string monthName = Date.formatMonth(raw[1]); if (string.IsNullOrEmpty(monthName)) { monthName = "m" + raw[1]; } dateLabel = monthName + " " + raw[2]; } catch { dateLabel = ""; } } private static void PruneDict(Dictionary dict, float now, float maxAge) { if (dict.Count < 256) { return; } var remove = new List(); foreach (KeyValuePair kv in dict) { if (now - kv.Value > maxAge) { remove.Add(kv.Key); } } for (int i = 0; i < remove.Count; i++) { dict.Remove(remove[i]); } } }