using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties, /// hard FixedDwell beat cooldowns, consecutive arc drop-off, and a rolling /// rarity window (common arcs lose score; rare/absent arcs gain score). /// public static class InterestVariety { public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget; public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow); public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds; public static float FixedDwellBeatCooldownSeconds => Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f); public static float ExactReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 90f); public static float RoutineReplayCooldownSeconds => Mathf.Max(FixedDwellBeatCooldownSeconds, 120f); private static readonly List RecentMix = new List(32); private static readonly List RecentArcs = new List(32); private static readonly Dictionary CooldownUntil = new Dictionary(64); private static readonly System.Random Rng = new System.Random(); private static readonly List EventPool = new List(64); private static readonly List CharPool = new List(64); private static readonly List Ranked = new List(16); private static string _lastArcKey = ""; private static int _arcStreak; /// Whether the last saw both pools non-empty (for mix metering). public static bool LastPickHadBothPools { get; private set; } /// Harness/debug: last shown variety arc key. public static string LastArcKey => _lastArcKey ?? ""; /// Harness/debug: consecutive shows of . public static int ArcStreak => _arcStreak; /// Harness/debug: tips recorded in the rarity window. public static int ArcWindowCount => RecentArcs.Count; public static bool IsCombatRunBlocked(InterestCandidate candidate, int maxRun = 4) { if (candidate == null || candidate.Completion != InterestCompletionKind.CombatActive || RecentArcs.Count < maxRun) { return false; } NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate); string text = ((candidate.AssetId ?? "") + " " + (candidate.Verb ?? "") + " " + (candidate.Label ?? "")).ToLowerInvariant(); bool decisive = function == NarrativeFunction.TurningPoint || function == NarrativeFunction.Resolution || function == NarrativeFunction.Consequence || candidate.EventStrength >= 95f || text.IndexOf("kill", StringComparison.Ordinal) >= 0 || text.IndexOf("slain", StringComparison.Ordinal) >= 0 || text.IndexOf("victory", StringComparison.Ordinal) >= 0; if (decisive) { return false; } int start = RecentArcs.Count - maxRun; for (int i = start; i < RecentArcs.Count; i++) { if (!IsCombatArc(RecentArcs[i])) { return false; } } return true; } public static bool HarnessProbeStabilityBudgets(out string detail) { Clear(); var texture = new InterestCandidate { SubjectId = 991101, AssetId = "find_lover", Verb = "decides to find a lover", Label = "Probe decides to find a lover", Completion = InterestCompletionKind.ActivityActive }; NoteSelection(texture, updateMix: false); bool textureBlocked = IsBeatCooled(texture); bool loverStillBlockedAtTenMinutes = IsBeatCooled(texture, Time.unscaledTime + 600f); bool loverEventuallyReopens = !IsBeatCooled(texture, Time.unscaledTime + 901f); Clear(); HarnessPushArc("arc:combat:duel", 4); var combat = new InterestCandidate { SubjectId = 991102, AssetId = "live_combat", Label = "Duel - Probe A vs Probe B", Completion = InterestCompletionKind.CombatActive, EventStrength = 80f }; bool combatBlocked = IsCombatRunBlocked(combat); combat.Label = "Probe A kills Probe B"; combat.Verb = "kills"; bool decisiveAllowed = !IsCombatRunBlocked(combat); Clear(); detail = "textureBlocked=" + textureBlocked + " lover10m=" + loverStillBlockedAtTenMinutes + " loverReopens=" + loverEventuallyReopens + " combatBlocked=" + combatBlocked + " decisiveAllowed=" + decisiveAllowed; return textureBlocked && loverStillBlockedAtTenMinutes && loverEventuallyReopens && combatBlocked && decisiveAllowed; } public static void Clear() { RecentMix.Clear(); RecentArcs.Clear(); CooldownUntil.Clear(); LastPickHadBothPools = false; _lastArcKey = ""; _arcStreak = 0; CameraDirector.ClearReplayLedger(); } /// Harness: push an arc into the rarity window without a full selection. public static void HarnessPushArc(string arcKey, int times = 1) { if (string.IsNullOrEmpty(arcKey)) { return; } int n = Mathf.Max(1, times); for (int i = 0; i < n; i++) { PushArc(arcKey); } } /// Harness: seed the rolling mix meter without touching cooldowns. public static void HarnessSeedMix(int eventLedCount, int characterLedCount) { RecentMix.Clear(); int e = Mathf.Max(0, eventLedCount); int c = Mathf.Max(0, characterLedCount); for (int i = 0; i < e; i++) { RecentMix.Add(InterestLeadKind.EventLed); } for (int i = 0; i < c; i++) { RecentMix.Add(InterestLeadKind.CharacterLed); } while (RecentMix.Count > MixWindow) { RecentMix.RemoveAt(0); } } public static void NoteSelection(InterestCandidate chosen) { NoteSelection(chosen, updateMix: LastPickHadBothPools); } public static void NoteSelection(InterestCandidate chosen, bool updateMix) { if (chosen == null) { return; } NoteVarietyArc(chosen); if (updateMix) { RecentMix.Add(chosen.LeadKind); while (RecentMix.Count > MixWindow) { RecentMix.RemoveAt(0); } } float until = Time.unscaledTime + (updateMix ? HardCooldownSeconds : HardCooldownSeconds * 0.5f); StampCooldown("unit:" + chosen.SubjectId, until); if (!string.IsNullOrEmpty(chosen.SpeciesId)) { StampCooldown("species:" + chosen.SpeciesId, until * 0.7f); } if (!string.IsNullOrEmpty(chosen.Category)) { StampCooldown("cat:" + chosen.Category, until * 0.5f); } if (!string.IsNullOrEmpty(chosen.CityKey)) { StampCooldown("city:" + chosen.CityKey, until * 0.6f); } if (!string.IsNullOrEmpty(chosen.KingdomKey)) { StampCooldown("kingdom:" + chosen.KingdomKey, until * 0.6f); } if (!string.IsNullOrEmpty(chosen.RegionKey)) { StampCooldown("region:" + chosen.RegionKey, until * 0.5f); } if (!string.IsNullOrEmpty(chosen.Verb)) { StampCooldown("verb:" + chosen.Verb, until * 0.4f); } string textureFamily = LowInformationTextureFamily(chosen); if (!string.IsNullOrEmpty(textureFamily) && chosen.SubjectId != 0) { float textureCooldown = string.Equals( textureFamily, "seeking_love", StringComparison.Ordinal) ? 900f : RoutineReplayCooldownSeconds; StampCooldown( "texture:" + chosen.SubjectId + ":" + textureFamily, Time.unscaledTime + textureCooldown); } // Moment beats: hard suppress the same story on the same subject. if (IsMomentBeat(chosen)) { float beatSeconds = FixedDwellBeatCooldownSeconds; if (IsSoftLifeChapter(chosen)) { float softCool = InterestScoringConfig.Story.familyLifeBeatCooldownSeconds; if (softCool > beatSeconds) { beatSeconds = softCool; } } float beatUntil = Time.unscaledTime + beatSeconds; foreach (string beatKey in EnumerateBeatKeys(chosen)) { StampCooldown(beatKey, beatUntil); } } // Sticky scenes are allowed to remain on screen while live, but once the editor // leaves one it must not immediately re-announce the identical phase/card. // The organic story-first soak otherwise produced Duel A-vs-B and "A is fighting" // several times in succession, plus repeated unchanged family-pack cards. string replayKey = EditorialReplayKey(chosen); if (!string.IsNullOrEmpty(replayKey)) { StampCooldown( replayKey, Time.unscaledTime + EditorialReplayCooldownSeconds); } string routineKey = RoutineReplayKey(chosen); if (!string.IsNullOrEmpty(routineKey)) { StampCooldown( routineKey, Time.unscaledTime + RoutineReplayCooldownSeconds); } string exactKey = ExactReplayKey(chosen); if (!string.IsNullOrEmpty(exactKey)) { StampCooldown( exactKey, Time.unscaledTime + ExactReplayCooldownSeconds); } // Soft life chapters cool ledger heat so the same villagers do not monopolize. // Soft-life cool no longer demotes saga MCs (CharacterLedger removed). } /// /// True when the exact sticky editorial card was shown recently. This is deliberately /// separate from moment-beat cooling: a live scene may continue, but cannot be selected /// again as a fresh cut until another editorial beat has had room to breathe. /// public static bool IsEditorialReplayCooled(InterestCandidate c, float now = -1f) { string key = EditorialReplayKey(c); if (string.IsNullOrEmpty(key)) { return false; } if (now < 0f) { now = Time.unscaledTime; } return CooldownUntil.TryGetValue(key, out float until) && now < until; } /// /// Prevent an identical player-facing sentence from being presented as a fresh cut, /// even when separate hooks generated different candidate keys. /// public static bool IsExactReplayCooled(InterestCandidate c, float now = -1f) { string key = ExactReplayKey(c); if (string.IsNullOrEmpty(key)) { return false; } if (now < 0f) now = Time.unscaledTime; return CooldownUntil.TryGetValue(key, out float until) && now < until; } public static bool HarnessProbeEditorialReplay(out string detail) { Clear(); var first = new InterestCandidate { Key = "combat:pair:1:2", Label = "Duel - Alpha vs Beta", Completion = InterestCompletionKind.CombatActive }; NoteSelection(first, updateMix: false); var replay = new InterestCandidate { Key = "combat:pair:1:2:refresh", Label = "Mass - Beta (3) vs Alpha (5)", Completion = InterestCompletionKind.CombatActive }; var different = new InterestCandidate { Key = "combat:pair:1:3", Label = "Duel - Alpha vs Gamma", Completion = InterestCompletionKind.CombatActive }; var texture = new InterestCandidate { Key = "status:sleeping:1", Label = "Alpha · Falls asleep", Completion = InterestCompletionKind.StatusPhase, StatusId = "sleeping" }; bool sameCooled = IsEditorialReplayCooled(replay); bool structuralExactCooled = IsExactReplayCooled(replay); bool differentOpen = !IsEditorialReplayCooled(different); bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture); var life = new InterestCandidate { Key = "relationship:1:2:first", Label = "Norron mates with Onaano", Completion = InterestCompletionKind.FixedDwell }; NoteSelection(life, updateMix: false); var lifeReplay = new InterestCandidate { Key = "relationship:1:2:second", Label = "Norron mates with Onaano", Completion = InterestCompletionKind.FixedDwell }; var lifeProgress = new InterestCandidate { Key = "relationship:1:2:home", Label = "Norron finds a home", Completion = InterestCompletionKind.FixedDwell }; bool exactLifeCooled = IsExactReplayCooled(lifeReplay); bool progressedLifeOpen = !IsExactReplayCooled(lifeProgress); bool ok = sameCooled && structuralExactCooled && differentOpen && textureUsesBeatPolicy && exactLifeCooled && progressedLifeOpen; detail = "sameCooled=" + sameCooled + " structuralExact=" + structuralExactCooled + " differentOpen=" + differentOpen + " textureUsesBeatPolicy=" + textureUsesBeatPolicy + " exactLife=" + exactLifeCooled + " progressedLife=" + progressedLifeOpen; Clear(); return ok; } /// /// Routine texture may recur in the simulation, but the editor should not rediscover the /// same person's sleep/play/laugh/status beat every short cooldown cycle. /// public static bool IsRoutineReplayCooled(InterestCandidate c, float now = -1f) { string key = RoutineReplayKey(c); if (string.IsNullOrEmpty(key)) { return false; } if (now < 0f) { now = Time.unscaledTime; } return CooldownUntil.TryGetValue(key, out float until) && now < until; } public static bool HarnessProbeRoutineReplay(out string detail) { Clear(); var sleeping = new InterestCandidate { SubjectId = 41, StatusId = "sleeping", AssetId = "sleeping", Completion = InterestCompletionKind.StatusPhase, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.CharacterTexture }; NoteSelection(sleeping, updateMix: false); var same = new InterestCandidate { SubjectId = 41, StatusId = "sleeping", AssetId = "sleeping", Completion = InterestCompletionKind.StatusPhase, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.CharacterTexture }; var other = new InterestCandidate { SubjectId = 42, StatusId = "sleeping", AssetId = "sleeping", Completion = InterestCompletionKind.StatusPhase, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.CharacterTexture }; var love = new InterestCandidate { SubjectId = 41, HappinessEffectId = "fallen_in_love", HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.ThreadOpener }; bool sameCooled = IsRoutineReplayCooled(same); bool otherOpen = !IsRoutineReplayCooled(other); bool storyOpen = !IsRoutineReplayCooled(love); bool ok = sameCooled && otherOpen && storyOpen; detail = "same=" + sameCooled + " other=" + otherOpen + " story=" + storyOpen + " pass=" + ok; Clear(); return ok; } public static bool HarnessProbeFamilyCoalescing(out string detail) { Clear(); var birthFact = new InterestCandidate { SubjectId = 41, RelatedId = 43, AssetId = "add_child", Completion = InterestCompletionKind.FixedDwell }; NoteSelection(birthFact, updateMix: false); var sameParentReaction = new InterestCandidate { SubjectId = 41, HappinessEffectId = "just_had_child", AssetId = "just_had_child", Completion = InterestCompletionKind.FixedDwell }; var otherParentReaction = new InterestCandidate { SubjectId = 42, HappinessEffectId = "just_had_child", AssetId = "just_had_child", Completion = InterestCompletionKind.FixedDwell }; bool echoCooled = IsBeatCooled(sameParentReaction); bool unrelatedOpen = !IsBeatCooled(otherParentReaction); bool ok = echoCooled && unrelatedOpen; detail = "echo=" + echoCooled + " unrelated=" + unrelatedOpen + " pass=" + ok; Clear(); return ok; } /// /// Score to subtract when this candidate matches the last shown variety arc. /// After one duel show, the next duel pays repeatArcPenaltyPer; a lover resets the streak. /// public static float RepeatArcPenalty(InterestCandidate c) { if (c == null || _arcStreak <= 0 || string.IsNullOrEmpty(_lastArcKey)) { return 0f; } string arc = VarietyArcKey(c); if (string.IsNullOrEmpty(arc) || !string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase)) { return 0f; } ScoringWeights w = InterestScoringConfig.W; float per = w.repeatArcPenaltyPer > 0f ? w.repeatArcPenaltyPer : 24f; float cap = w.repeatArcPenaltyCap > 0f ? w.repeatArcPenaltyCap : 72f; return Mathf.Min(cap, _arcStreak * per); } /// /// Rolling rarity: arcs over-represented in the recent tip window lose score; /// absent/rare arcs gain score. Combat as a family gets an extra monopoly penalty. /// public static float FrequencyAdjust(InterestCandidate c) { if (c == null || RecentArcs.Count == 0) { return 0f; } string arc = VarietyArcKey(c); if (string.IsNullOrEmpty(arc)) { return 0f; } ScoringWeights w = InterestScoringConfig.W; int window = Mathf.Max(4, w.arcFrequencyWindow > 0 ? w.arcFrequencyWindow : 16); int sample = Mathf.Min(RecentArcs.Count, window); if (sample <= 0) { return 0f; } int arcCount = 0; int combatCount = 0; int start = RecentArcs.Count - sample; for (int i = start; i < RecentArcs.Count; i++) { string a = RecentArcs[i]; if (string.Equals(a, arc, StringComparison.OrdinalIgnoreCase)) { arcCount++; } if (IsCombatArc(a)) { combatCount++; } } float share = arcCount / (float)sample; float fair = w.arcFairShare > 0.02f ? w.arcFairShare : 0.12f; float overMax = w.arcOverSharePenaltyMax > 0f ? w.arcOverSharePenaltyMax : 48f; float underMax = w.arcUnderShareBoostMax > 0f ? w.arcUnderShareBoostMax : 22f; float adjust = 0f; if (share > fair) { float t = Mathf.Clamp01((share - fair) / Mathf.Max(0.05f, 1f - fair)); adjust -= t * overMax; } else { float t = Mathf.Clamp01((fair - share) / fair); adjust += t * underMax; } if (IsCombatArc(arc)) { float combatShare = combatCount / (float)sample; float combatTarget = w.arcCombatShareTarget > 0.05f ? w.arcCombatShareTarget : 0.35f; float combatPen = w.arcCombatOverSharePenaltyMax > 0f ? w.arcCombatOverSharePenaltyMax : 36f; if (combatShare > combatTarget) { float t = Mathf.Clamp01((combatShare - combatTarget) / Mathf.Max(0.05f, 1f - combatTarget)); adjust -= t * combatPen; } } return adjust; } private static bool IsCombatArc(string arc) => !string.IsNullOrEmpty(arc) && (arc.StartsWith("arc:combat:", StringComparison.OrdinalIgnoreCase) || string.Equals(arc, "arc:war", StringComparison.OrdinalIgnoreCase)); /// /// Class-level arc for repeat drop-off (not per unit / pair id). /// Duels share one arc so back-to-back scraps lose score; Mass/Battle share another. /// public static string VarietyArcKey(InterestCandidate c) { if (c == null) { return ""; } if (c.Completion == InterestCompletionKind.WarFront || string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase)) { return "arc:war"; } if (string.Equals(c.Category, "Forage", StringComparison.OrdinalIgnoreCase) || string.Equals(c.AssetId, "building_consume_fruit", StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(c.Label) && c.Label.IndexOf("foraging", StringComparison.OrdinalIgnoreCase) >= 0)) { return "arc:forage"; } if (c.Completion == InterestCompletionKind.PlotActive || string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase)) { return "arc:plot"; } if (c.Completion == InterestCompletionKind.StatusOutbreak || string.Equals(c.AssetId, "live_outbreak", StringComparison.OrdinalIgnoreCase)) { return "arc:outbreak"; } if (c.Completion == InterestCompletionKind.CombatActive || string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase) || string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase) || string.Equals(c.Verb, "fighting", StringComparison.OrdinalIgnoreCase)) { if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)) { return "arc:combat:multi"; } ScoringWeights w = InterestScoringConfig.W; int sideSum = Mathf.Max(0, c.CombatSideACount) + Mathf.Max(0, c.CombatSideBCount); int size = Mathf.Max(Mathf.Max(0, c.ParticipantCount), sideSum); int duelMax = Mathf.Max(1, w.duelMaxFighters); if (size > 0 && size <= duelMax) { return "arc:combat:duel"; } return "arc:combat:multi"; } if (StoryReason.IsStoryAsset(c.AssetId) || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase) || string.Equals(c.Category, "Story", StringComparison.OrdinalIgnoreCase)) { if (!string.IsNullOrEmpty(c.AssetId) && c.AssetId.StartsWith("epilogue_", StringComparison.OrdinalIgnoreCase)) { return "arc:life:epilogue"; } return "arc:life:aftermath"; } if (!string.IsNullOrEmpty(c.HappinessEffectId)) { string h = c.HappinessEffectId.Trim().ToLowerInvariant(); if (h.IndexOf("death_", StringComparison.Ordinal) >= 0 || h.IndexOf("mourn", StringComparison.Ordinal) >= 0 || h.IndexOf("despair", StringComparison.Ordinal) >= 0 || h.IndexOf("grief", StringComparison.Ordinal) >= 0) { return "arc:life:grief"; } if (IsKillLifeHappinessId(h)) { return "arc:life:kill"; } if (h == "just_got_out_of_egg") { return "arc:life:hatch"; } if (IsIntimacyHappinessId(h)) { return "arc:life:intimacy"; } if (IsCourtshipHappinessId(h) || h.IndexOf("lover", StringComparison.Ordinal) >= 0 || h.IndexOf("love", StringComparison.Ordinal) >= 0 || h.IndexOf("married", StringComparison.Ordinal) >= 0 || h.IndexOf("wedding", StringComparison.Ordinal) >= 0) { return "arc:life:love"; } if (IsFamilyLifeHappinessId(h)) { return "arc:life:family"; } if (IsSettlementHappinessId(h)) { return "arc:life:home"; } if (IsRestLifeId(h)) { return "arc:life:rest"; } return "arc:hap:" + h; } if (!string.IsNullOrEmpty(c.StatusId)) { if (IsFamilyLifeHappinessId(c.StatusId)) { return "arc:life:family"; } if (IsSettlementHappinessId(c.StatusId)) { return "arc:life:home"; } if (IsCourtshipHappinessId(c.StatusId)) { return "arc:life:love"; } if (IsRestLifeId(c.StatusId)) { return "arc:life:rest"; } if (IsSoftStatusFxId(c.StatusId)) { return "arc:status:fx"; } return "arc:status:" + c.StatusId.Trim().ToLowerInvariant(); } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.Ordinal)) { if (IsKillLifeAssetId(asset)) { return "arc:life:kill"; } if (IsCourtshipAssetId(asset)) { return "arc:life:love"; } if (string.Equals(c.Category, "Spectacle", StringComparison.OrdinalIgnoreCase) || asset.StartsWith("cast_", StringComparison.Ordinal) || asset.StartsWith("summon_", StringComparison.Ordinal)) { return "arc:spectacle"; } if (EventCatalog.Decision.IsLifeIntentDecision(asset) || IsLifeIntentDecisionChapter(c)) { return "arc:decision:intent"; } if (IsRestLifeId(asset) || IsRestLifeChapter(c)) { return "arc:life:rest"; } if (IsWorldLogMetaAssetId(asset) || IsWorldLogMetaChapter(c)) { return "arc:worldlog:meta"; } if (IsConstructionChapter(c)) { return "arc:construction"; } if (string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase) || (c.Label != null && c.Label.IndexOf("decides to", StringComparison.OrdinalIgnoreCase) >= 0)) { return "arc:decision"; } return "arc:asset:" + asset; } if (IsLifeIntentDecisionChapter(c)) { return "arc:decision:intent"; } if (IsRestLifeChapter(c)) { return "arc:life:rest"; } if (IsWorldLogMetaChapter(c)) { return "arc:worldlog:meta"; } if (IsConstructionChapter(c)) { return "arc:construction"; } if (IsSoftStatusFxChapter(c)) { return "arc:status:fx"; } if (IsHatchLifeChapter(c)) { return "arc:life:hatch"; } if (IsKillLifeChapter(c)) { return "arc:life:kill"; } if (IsCourtshipLifeChapter(c)) { return "arc:life:love"; } if (!string.IsNullOrEmpty(c.Category)) { return "arc:cat:" + c.Category.Trim().ToLowerInvariant(); } if (!string.IsNullOrEmpty(c.Verb)) { return "arc:verb:" + c.Verb.Trim().ToLowerInvariant(); } return ""; } private static void NoteVarietyArc(InterestCandidate chosen) { string arc = VarietyArcKey(chosen); if (string.IsNullOrEmpty(arc)) { return; } if (string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase)) { _arcStreak = Mathf.Max(1, _arcStreak + 1); } else { _lastArcKey = arc; _arcStreak = 1; } PushArc(arc); } private static void PushArc(string arc) { if (string.IsNullOrEmpty(arc)) { return; } RecentArcs.Add(arc); int window = Mathf.Max(4, InterestScoringConfig.W.arcFrequencyWindow > 0 ? InterestScoringConfig.W.arcFrequencyWindow : 16); while (RecentArcs.Count > window) { RecentArcs.RemoveAt(0); } } /// /// True when a FixedDwell / moment beat for this candidate is still hard-cooled. /// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked. /// public static bool IsBeatCooled(InterestCandidate c, float now = -1f) { if (c == null) { return false; } if (now < 0f) { now = Time.unscaledTime; } string textureFamily = LowInformationTextureFamily(c); if (!string.IsNullOrEmpty(textureFamily) && c.SubjectId != 0 && CooldownUntil.TryGetValue( "texture:" + c.SubjectId + ":" + textureFamily, out float textureUntil) && now < textureUntil) { return true; } if (!IsMomentBeat(c)) { return false; } // Synthetic story beats finish the active arc - never hard-skip them for FixedDwell cool. // Soft novelty / repeat-arc penalties still apply via RecalcTotal. if (StoryReason.IsStoryAsset(c.AssetId) || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)) { return false; } foreach (string beatKey in EnumerateBeatKeys(c)) { if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until) { return true; } } return false; } private static string LowInformationTextureFamily(InterestCandidate c) { if (c == null) { return ""; } string text = ((c.AssetId ?? "") + " " + (c.HappinessEffectId ?? "") + " " + (c.StatusId ?? "") + " " + (c.Verb ?? "") + " " + (c.Label ?? "")).ToLowerInvariant(); if (text.IndexOf("sleep", StringComparison.Ordinal) >= 0 || text.IndexOf("nap", StringComparison.Ordinal) >= 0) return "rest"; if (text.IndexOf("laugh", StringComparison.Ordinal) >= 0 || text.IndexOf("play", StringComparison.Ordinal) >= 0) return "play"; if (text.IndexOf("caffeine", StringComparison.Ordinal) >= 0 || text.IndexOf("coffee", StringComparison.Ordinal) >= 0) return "caffeine"; if (text.IndexOf("find_lover", StringComparison.Ordinal) >= 0 || text.IndexOf("find a lover", StringComparison.Ordinal) >= 0) return "seeking_love"; if (text.IndexOf("wander", StringComparison.Ordinal) >= 0) return "wandering"; return ""; } /// /// Pick next candidate. Soft 70/30: prefer event-led when below target and pool non-empty. /// Empty event pool → character-led without inventing events (mix meter not updated for empty-side windows). /// public static InterestCandidate Pick(List pending, InterestCandidate current) { if (pending == null || pending.Count == 0) { return null; } EventPool.Clear(); CharPool.Clear(); float now = Time.unscaledTime; for (int i = 0; i < pending.Count; i++) { InterestCandidate c = pending[i]; if (c == null || !c.HasValidPosition) { continue; } if (current != null && c.Key == current.Key) { continue; } // Same life beat on the same subject already watched - hard skip. if (IsBeatCooled(c, now)) { InterestDropLog.Record("beat_cooldown", BeatDropDetail(c)); continue; } if (IsEditorialReplayCooled(c, now)) { InterestDropLog.Record("editorial_replay", c.Label ?? c.Key); continue; } if (IsExactReplayCooled(c, now)) { InterestDropLog.Record("exact_replay", c.Label ?? c.Key); continue; } if (IsRoutineReplayCooled(c, now)) { InterestDropLog.Record("routine_replay", c.Label ?? c.Key); continue; } if (c.LeadKind == InterestLeadKind.CharacterLed) { CharPool.Add(c); } else { EventPool.Add(c); } } bool bothHadCandidates = EventPool.Count > 0 && CharPool.Count > 0; bool preferEvent = EventPool.Count > 0; if (bothHadCandidates) { // Empty mix biases event-led; otherwise prefer event until share reaches target. preferEvent = MixSampleCount == 0 || EventShare() < EventLedTarget; } else if (EventPool.Count == 0) { preferEvent = false; } // Soft mix: empty event pool → character-led without inventing events. // Mix meter is updated in NoteSelection only when both pools were non-empty at pick time. LastPickHadBothPools = bothHadCandidates; List pool = preferEvent ? (EventPool.Count > 0 ? EventPool : CharPool) : (CharPool.Count > 0 ? CharPool : EventPool); if (pool.Count == 0) { return null; } Ranked.Clear(); for (int i = 0; i < pool.Count; i++) { InterestCandidate c = pool[i]; float penalty = NoveltyPenalty(c, now); // Epic / favorites bypass most soft novelty - never FixedDwell beat cooldowns // (those already hard-skipped above). if (InterestScoring.IsHotScore(c.TotalScore) || InterestScoring.IsNotable(c.FollowUnit)) { penalty *= 0.15f; } c.Novelty = Mathf.Clamp01(1f - penalty); InterestScoring.RecalcTotal(c); Ranked.Add(c); } Ranked.Sort(InterestScoring.CompareByUrgencyThenScore); InterestScoring.EnrichTopK(Ranked); // Prefer active story-owned beats when present. for (int i = 1; i < Ranked.Count; i++) { if (StoryPlanner.PreferOver(Ranked[i], Ranked[0])) { InterestCandidate owned = Ranked[i]; Ranked.RemoveAt(i); Ranked.Insert(0, owned); break; } } int topN = Mathf.Min(3, Ranked.Count); if (topN <= 0) { return null; } // Deterministic #1 unless near-tie among top scores. if (topN == 1) { return Ranked[0]; } float epsilon = InterestScoringConfig.Story.nearTieEpsilon; if (epsilon <= 0f) { epsilon = 8f; } float sagaEpsilon = InterestScoringConfig.Story.sagaNearTieEpsilon; if (sagaEpsilon <= 0f) { sagaEpsilon = 20f; } sagaEpsilon = Mathf.Max(sagaEpsilon, epsilon); // Large gap: take #1 unless a Prefer/MC/cast tip sits within the wider saga band. if (Ranked[0].TotalScore - Ranked[1].TotalScore >= epsilon) { InterestCandidate sagaCut = PickBestSagaInBand(Ranked, Ranked.Count, sagaEpsilon); if (sagaCut != null) { return sagaCut; } return Ranked[0]; } // Near-tie: Prefer'd MC, then any MC, then MC cast, else random among top-N. InterestCandidate bestSaga = PickBestSagaInBand(Ranked, topN, epsilon); if (bestSaga != null) { return bestSaga; } int pick = Rng.Next(topN); return Ranked[pick]; } /// /// Prefer (4) > CrossMC (3) > MC (2) > MC cast (1) among candidates within of #1. /// private static InterestCandidate PickBestSagaInBand( List ranked, int limit, float band) { if (ranked == null || ranked.Count == 0 || band < 0f) { return null; } int n = Mathf.Min(limit, ranked.Count); float top = ranked[0].TotalScore; InterestCandidate bestSaga = null; int bestRank = 0; for (int i = 0; i < n; i++) { InterestCandidate c = ranked[i]; if (c == null) { continue; } if (top - c.TotalScore >= band) { break; } int rank = LifeSagaRoster.SoftBiasRank(c); if (rank > bestRank) { bestRank = rank; bestSaga = c; } } return bestRank > 0 ? bestSaga : null; } public static float EventShare() { if (RecentMix.Count == 0) { return EventLedTarget; } int events = 0; for (int i = 0; i < RecentMix.Count; i++) { if (RecentMix[i] == InterestLeadKind.EventLed) { events++; } } return events / (float)RecentMix.Count; } public static int MixSampleCount => RecentMix.Count; private static bool IsMomentBeat(InterestCandidate c) { if (c == null) { return false; } switch (c.Completion) { case InterestCompletionKind.FixedDwell: case InterestCompletionKind.Manual: case InterestCompletionKind.HappinessGrief: // Discrete life / emotion beats (parenthood, grief, catalog moments). return true; case InterestCompletionKind.StatusPhase: // Unit status tips cool by status id; sticky outbreaks are excluded below. return string.IsNullOrEmpty(c.AssetId) || !c.AssetId.Equals("live_outbreak", System.StringComparison.OrdinalIgnoreCase); default: return false; } } private static string EditorialReplayKey(InterestCandidate c) { if (c == null) { return ""; } EnsureReplayKeys(c); return c.CachedEditorialReplayKey; } private static string BuildEditorialReplayKey( InterestCandidate c, string normalizedLabel) { bool stickyCard = c.Completion == InterestCompletionKind.CombatActive || c.Completion == InterestCompletionKind.WarFront || c.Completion == InterestCompletionKind.PlotActive || c.Completion == InterestCompletionKind.StatusOutbreak || c.Completion == InterestCompletionKind.FamilyPack || StoryReason.IsStoryAsset(c.AssetId) || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase); if (!stickyCard) { return ""; } if (!string.IsNullOrEmpty(normalizedLabel)) { return "replay:label:" + normalizedLabel; } string key = (c.Key ?? "").Trim().ToLowerInvariant(); return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key; } private static string ExactReplayKey(InterestCandidate c) { if (c == null) { return ""; } EnsureReplayKeys(c); return c.CachedExactReplayKey; } private static string RoutineReplayKey(InterestCandidate c) { if (c == null || c.SubjectId == 0) { return ""; } EnsureReplayKeys(c); return c.CachedRoutineReplayKey; } private static string BuildRoutineReplayKey(InterestCandidate c) { NarrativeFunction function = NarrativeFunctionPolicy.Classify(c); if (function != NarrativeFunction.CharacterTexture && !IsRestLifeChapter(c) && !IsSoftStatusFxChapter(c)) { return ""; } string family = c.StatusId; if (string.IsNullOrEmpty(family)) family = c.HappinessEffectId; if (string.IsNullOrEmpty(family)) family = c.NarrativePresentation?.ActionId; if (string.IsNullOrEmpty(family)) family = c.AssetId; if (string.IsNullOrEmpty(family)) family = NormalizeBeatLabel(c.Label); family = (family ?? "").Trim().ToLowerInvariant(); return string.IsNullOrEmpty(family) ? "" : "routine:" + c.SubjectId + ":" + family; } private static void EnsureReplayKeys(InterestCandidate c) { string label = c.Label ?? ""; string key = c.Key ?? ""; string assetId = c.AssetId ?? ""; string source = c.Source ?? ""; string statusId = c.StatusId ?? ""; string happinessId = c.HappinessEffectId ?? ""; string actionId = c.NarrativePresentation?.ActionId ?? ""; if (c.ReplayKeysCached && c.ReplayCacheSubjectId == c.SubjectId && c.ReplayCacheCompletion == c.Completion && c.ReplayCacheFunction == c.NarrativeFunction && c.ReplayCacheHasFunction == c.HasNarrativeFunction && string.Equals(c.ReplayCacheLabel, label, StringComparison.Ordinal) && string.Equals(c.ReplayCacheKey, key, StringComparison.Ordinal) && string.Equals(c.ReplayCacheAssetId, assetId, StringComparison.Ordinal) && string.Equals(c.ReplayCacheSource, source, StringComparison.Ordinal) && string.Equals(c.ReplayCacheStatusId, statusId, StringComparison.Ordinal) && string.Equals( c.ReplayCacheHappinessEffectId, happinessId, StringComparison.Ordinal) && string.Equals(c.ReplayCacheActionId, actionId, StringComparison.Ordinal)) { return; } c.ReplayKeysCached = true; c.ReplayCacheLabel = label; c.ReplayCacheKey = key; c.ReplayCacheAssetId = assetId; c.ReplayCacheSource = source; c.ReplayCacheStatusId = statusId; c.ReplayCacheHappinessEffectId = happinessId; c.ReplayCacheActionId = actionId; c.ReplayCacheSubjectId = c.SubjectId; c.ReplayCacheCompletion = c.Completion; c.ReplayCacheFunction = c.NarrativeFunction; c.ReplayCacheHasFunction = c.HasNarrativeFunction; string normalizedLabel = EventReason.ReplayStructureKey(label); c.CachedEditorialReplayKey = BuildEditorialReplayKey(c, normalizedLabel); c.CachedExactReplayKey = string.IsNullOrEmpty(normalizedLabel) ? "" : "exact:label:" + normalizedLabel; c.CachedRoutineReplayKey = BuildRoutineReplayKey(c); } /// /// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class. /// public static bool IsFamilyLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (!string.IsNullOrEmpty(c.HappinessEffectId) && IsFamilyLifeHappinessId(c.HappinessEffectId)) { return true; } if (!string.IsNullOrEmpty(c.StatusId) && IsFamilyLifeHappinessId(c.StatusId)) { return true; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); return asset.IndexOf("child", StringComparison.Ordinal) >= 0 || asset.IndexOf("pregnant", StringComparison.Ordinal) >= 0 || asset.IndexOf("birth", StringComparison.Ordinal) >= 0 || asset.IndexOf("baby", StringComparison.Ordinal) >= 0 || asset.IndexOf("newborn", StringComparison.Ordinal) >= 0; } /// /// Kiss / mate / intimacy chapter - same soft-life cool class as family (pair + world). /// public static bool IsIntimacyLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (!string.IsNullOrEmpty(c.HappinessEffectId) && IsIntimacyHappinessId(c.HappinessEffectId)) { return true; } if (!string.IsNullOrEmpty(c.StatusId) && IsIntimacyHappinessId(c.StatusId)) { return true; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (IsIntimacyHappinessId(asset)) { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.IndexOf("shares intimacy", StringComparison.Ordinal) >= 0 || label.IndexOf("mates with", StringComparison.Ordinal) >= 0; } /// /// House-found settlement chapter - repeats easily and steals between combat beats. /// (House-lost stays camera-worthy without the same soft cool.) /// public static bool IsSettlementLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (!string.IsNullOrEmpty(c.HappinessEffectId) && IsSettlementHappinessId(c.HappinessEffectId)) { return true; } if (!string.IsNullOrEmpty(c.StatusId) && IsSettlementHappinessId(c.StatusId)) { return true; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (IsSettlementHappinessId(asset)) { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.IndexOf("finds a home", StringComparison.Ordinal) >= 0 || label.IndexOf("settles into a house", StringComparison.Ordinal) >= 0; } /// /// Chronicle / happiness kill crumbs - repeat easily for hunters between combat tips. /// public static bool IsKillLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (!string.IsNullOrEmpty(c.HappinessEffectId) && IsKillLifeHappinessId(c.HappinessEffectId)) { return true; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (IsKillLifeAssetId(asset)) { return true; } if (!string.IsNullOrEmpty(c.Key) && c.Key.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0) { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.StartsWith("killed ", StringComparison.Ordinal); } /// /// Smitten / seeking-lover courtship crumbs (not bond outcomes like set_lover). /// public static bool IsCourtshipLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (!string.IsNullOrEmpty(c.HappinessEffectId) && IsCourtshipHappinessId(c.HappinessEffectId)) { return true; } if (!string.IsNullOrEmpty(c.StatusId) && IsCourtshipHappinessId(c.StatusId)) { return true; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (IsCourtshipAssetId(asset)) { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.IndexOf("is smitten", StringComparison.Ordinal) >= 0 || label.IndexOf("falls in love", StringComparison.Ordinal) >= 0 || label.IndexOf("seeking a lover", StringComparison.Ordinal) >= 0 || label.IndexOf("is seeking a lover", StringComparison.Ordinal) >= 0; } /// /// Decision intent crumbs (reproduce / find lover) - outcomes own the real story. /// public static bool IsLifeIntentDecisionChapter(InterestCandidate c) { if (c == null) { return false; } string asset = (c.AssetId ?? "").Trim(); if (EventCatalog.Decision.IsLifeIntentDecision(asset)) { return true; } // Live decision feed: Source is authoritative even when AssetId was species-clobbered. if (string.Equals(c.Source, "decision", StringComparison.OrdinalIgnoreCase) && (EventCatalog.Decision.IsLifeIntentDecision(asset) || asset.IndexOf("reproduction", StringComparison.OrdinalIgnoreCase) >= 0 || asset.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0 || asset.IndexOf("baby_make", StringComparison.OrdinalIgnoreCase) >= 0 || asset.IndexOf("have_child", StringComparison.OrdinalIgnoreCase) >= 0)) { return true; } // Label-only rebuilds still cool when AssetId was lost. string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.IndexOf("decides to try to reproduce", StringComparison.Ordinal) >= 0 || label.IndexOf("decides to find a lover", StringComparison.Ordinal) >= 0 || label.IndexOf("decides to reproduce", StringComparison.Ordinal) >= 0; } /// /// Ordinary civ construction finishes (tents/houses). Spectacle wonders stay hotter. /// public static bool IsConstructionChapter(InterestCandidate c) { if (c == null) { return false; } string asset = c.AssetId ?? ""; bool buildingSource = string.Equals(c.Source, "building", StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(c.Key) && c.Key.StartsWith( "building:complete:", StringComparison.OrdinalIgnoreCase)); if (buildingSource) { return !LibraryAssetNames.IsSpectacleBuilding(asset, null); } string label = (c.Label ?? "").Trim().ToLowerInvariant(); if (label.IndexOf(" finishes ", StringComparison.Ordinal) < 0 && !label.StartsWith("building finished", StringComparison.Ordinal)) { return false; } // Spectacle prose uses "wonder". return label.IndexOf("wonder", StringComparison.Ordinal) < 0; } /// /// Brief personal status FX (singing, rooting, moods) - not danger/combat statuses. /// public static bool IsSoftStatusFxChapter(InterestCandidate c) { if (c == null) { return false; } string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId; if (string.IsNullOrEmpty(status)) { return false; } if (c.Completion != InterestCompletionKind.StatusPhase && c.Completion != InterestCompletionKind.FixedDwell && string.IsNullOrEmpty(c.StatusId)) { return false; } return IsSoftStatusFxId(status); } /// Egg hatch crumbs - common and steals between richer beats. public static bool IsHatchLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (!string.IsNullOrEmpty(c.HappinessEffectId) && string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)) { return true; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); if (asset == "just_got_out_of_egg") { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.IndexOf("hatches from an egg", StringComparison.Ordinal) >= 0; } /// /// WorldLog politics meta crumbs (king death, city destroyed, favorite fell). /// Sticky and re-fire easily; outcomes stay in Chronicle. /// public static bool IsWorldLogMetaChapter(InterestCandidate c) { if (c == null) { return false; } bool worldLog = string.Equals(c.Source, "worldlog", StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(c.Key) && c.Key.StartsWith("worldlog:", StringComparison.OrdinalIgnoreCase)); if (!worldLog && string.IsNullOrEmpty(c.AssetId)) { return false; } if (IsWorldLogMetaAssetId(c.AssetId)) { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.StartsWith("king died:", StringComparison.Ordinal) || label.StartsWith("king killed:", StringComparison.Ordinal) || label.StartsWith("favorite fell:", StringComparison.Ordinal) || label.StartsWith("city destroyed:", StringComparison.Ordinal); } /// /// Family + intimacy + settlement + kill + courtship + intent/construction/status/hatch/meta/rest. /// public static bool IsSoftLifeChapter(InterestCandidate c) => IsFamilyLifeChapter(c) || IsIntimacyLifeChapter(c) || IsSettlementLifeChapter(c) || IsKillLifeChapter(c) || IsCourtshipLifeChapter(c) || IsLifeIntentDecisionChapter(c) || IsWorldLogMetaChapter(c) || IsConstructionChapter(c) || IsSoftStatusFxChapter(c) || IsHatchLifeChapter(c) || IsRestLifeChapter(c); /// /// Sleep / wake / dream cycle crumbs from live status + happiness inventory shape. /// One show cools the whole rest class so Falls asleep → ends a rest → dream cannot monopolize. /// public static bool IsRestLifeChapter(InterestCandidate c) { if (c == null) { return false; } if (IsRestLifeId(c.HappinessEffectId) || IsRestLifeId(c.StatusId) || IsRestLifeId(c.AssetId)) { return true; } string label = (c.Label ?? "").Trim().ToLowerInvariant(); return label.IndexOf("falls asleep", StringComparison.Ordinal) >= 0 || label.IndexOf("wakes up", StringComparison.Ordinal) >= 0 || label.IndexOf("ends a rest", StringComparison.Ordinal) >= 0 || label.IndexOf("finishes sleeping", StringComparison.Ordinal) >= 0 || label.IndexOf("dreams pleasantly", StringComparison.Ordinal) >= 0 || label.IndexOf("good dream", StringComparison.Ordinal) >= 0 || label.IndexOf("bad dream", StringComparison.Ordinal) >= 0 || label.IndexOf("nightmare", StringComparison.Ordinal) >= 0 || label.IndexOf("sleep without a house", StringComparison.Ordinal) >= 0; } /// /// Rest-cycle ids: sleeping, just_slept, had_*dream, had_nightmare, /// slept_outside - token shape from live libraries, not tip strings alone. /// public static bool IsRestLifeId(string id) { if (string.IsNullOrEmpty(id)) { return false; } string s = id.Trim().ToLowerInvariant(); return s.IndexOf("sleep", StringComparison.Ordinal) >= 0 || s.IndexOf("slept", StringComparison.Ordinal) >= 0 || s.IndexOf("dream", StringComparison.Ordinal) >= 0 || s.IndexOf("nightmare", StringComparison.Ordinal) >= 0; } /// /// Death / destruction WorldLog ids from the live library shape /// (king_*, favorite_*, city_destroyed, …) - not crowning / founding. /// public static bool IsWorldLogMetaAssetId(string assetId) { if (string.IsNullOrEmpty(assetId)) { return false; } string s = assetId.Trim().ToLowerInvariant(); if (s.IndexOf("destroyed", StringComparison.Ordinal) >= 0) { return true; } bool deathLike = s.IndexOf("dead", StringComparison.Ordinal) >= 0 || s.IndexOf("killed", StringComparison.Ordinal) >= 0 || s.IndexOf("_fell", StringComparison.Ordinal) >= 0; if (!deathLike) { return false; } return s.IndexOf("king", StringComparison.Ordinal) >= 0 || s.IndexOf("leader", StringComparison.Ordinal) >= 0 || s.IndexOf("favorite", StringComparison.Ordinal) >= 0; } /// /// Soft-fill quiet crumbs: soft life chapters, short interrupt FX, and ambient Pack fill. /// Pack must not own the camera during quiet (and no longer breaks quiet). /// public static bool IsSoftFillQuietCrumb(InterestCandidate c) => c != null && (c.Completion == InterestCompletionKind.FamilyPack || IsSoftLifeChapter(c) || IsShortInterruptFxChapter(c)); /// /// Brief interrupt FX that should not own AFK during soft-fill quiet. /// Kept non-outbreak; real danger statuses stay hot. /// public static bool IsShortInterruptFxChapter(InterestCandidate c) { if (c == null) { return false; } string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId; if (string.IsNullOrEmpty(status)) { return false; } if (c.Completion != InterestCompletionKind.StatusPhase && c.Completion != InterestCompletionKind.FixedDwell && string.IsNullOrEmpty(c.StatusId)) { return false; } return IsShortInterruptFxId(status); } public static bool IsShortInterruptFxId(string statusId) { if (string.IsNullOrEmpty(statusId)) { return false; } string s = statusId.Trim().ToLowerInvariant(); return s.IndexOf("stun", StringComparison.Ordinal) >= 0 || s.IndexOf("invincible", StringComparison.Ordinal) >= 0 || s == "surprised" || s == "confused"; } /// /// Soft personal status FX ids from the live status inventory shape: /// mood / life-cycle / brief FX. Danger and combat statuses stay hotter. /// public static bool IsSoftStatusFxId(string statusId) { if (string.IsNullOrEmpty(statusId)) { return false; } // Short interrupt FX count as soft crumbs for quiet + variety cooling. if (IsShortInterruptFxId(statusId)) { return true; } string s = statusId.Trim().ToLowerInvariant(); // Danger / combat / possession stay camera-sticky for repeats. if (s.IndexOf("burn", StringComparison.Ordinal) >= 0 || s.IndexOf("poison", StringComparison.Ordinal) >= 0 || s.IndexOf("drown", StringComparison.Ordinal) >= 0 || s.IndexOf("starv", StringComparison.Ordinal) >= 0 || s.IndexOf("possess", StringComparison.Ordinal) >= 0 || s.IndexOf("soul", StringComparison.Ordinal) >= 0 || s.IndexOf("curse", StringComparison.Ordinal) >= 0 || s.IndexOf("rage", StringComparison.Ordinal) >= 0 || s.IndexOf("frozen", StringComparison.Ordinal) >= 0 || s.IndexOf("shield", StringComparison.Ordinal) >= 0 || s.IndexOf("combat", StringComparison.Ordinal) >= 0 || s == "on_guard" || s == "angry" || s == "egg") { return false; } // Family / courtship / settlement already have dedicated soft chapters. if (IsFamilyLifeHappinessId(s) || IsCourtshipHappinessId(s) || IsSettlementHappinessId(s) || IsIntimacyHappinessId(s)) { return false; } // Mood / life-cycle / brief FX tokens (covers unauthored live ids of the same shape). return s.IndexOf("sing", StringComparison.Ordinal) >= 0 || s.IndexOf("laugh", StringComparison.Ordinal) >= 0 || s.IndexOf("swear", StringComparison.Ordinal) >= 0 || s.IndexOf("cry", StringComparison.Ordinal) >= 0 || s.IndexOf("root", StringComparison.Ordinal) >= 0 || s.IndexOf("budding", StringComparison.Ordinal) >= 0 || s.IndexOf("dream", StringComparison.Ordinal) >= 0 || s.IndexOf("nightmare", StringComparison.Ordinal) >= 0 || s.IndexOf("festive", StringComparison.Ordinal) >= 0 || s.IndexOf("afterglow", StringComparison.Ordinal) >= 0 || s.IndexOf("surprised", StringComparison.Ordinal) >= 0 || s.IndexOf("confused", StringComparison.Ordinal) >= 0 || s.IndexOf("just_ate", StringComparison.Ordinal) >= 0 || s.IndexOf("caffeine", StringComparison.Ordinal) >= 0 || s.IndexOf("cough", StringComparison.Ordinal) >= 0 || s.IndexOf("tantrum", StringComparison.Ordinal) >= 0 || s.IndexOf("strange_urge", StringComparison.Ordinal) >= 0 || s.IndexOf("voices", StringComparison.Ordinal) >= 0 || s.IndexOf("handsome", StringComparison.Ordinal) >= 0 || s.IndexOf("flick", StringComparison.Ordinal) >= 0 || s.IndexOf("ash_fever", StringComparison.Ordinal) >= 0 || s.IndexOf("motivated", StringComparison.Ordinal) >= 0 || s.IndexOf("inspired", StringComparison.Ordinal) >= 0 || s.IndexOf("enchant", StringComparison.Ordinal) >= 0 || s.IndexOf("powerup", StringComparison.Ordinal) >= 0 || s.IndexOf("slowness", StringComparison.Ordinal) >= 0 || s.IndexOf("magnet", StringComparison.Ordinal) >= 0 || s.IndexOf("dodge", StringComparison.Ordinal) >= 0 || s.IndexOf("dash", StringComparison.Ordinal) >= 0 || s.IndexOf("silence", StringComparison.Ordinal) >= 0 || s.IndexOf("boost", StringComparison.Ordinal) >= 0 || s.IndexOf("recovery_", StringComparison.Ordinal) >= 0; } private static bool IsFamilyLifeHappinessId(string happinessId) { if (string.IsNullOrEmpty(happinessId)) { return false; } string h = happinessId.Trim().ToLowerInvariant(); return h == "just_had_child" || h.IndexOf("pregnant", StringComparison.Ordinal) >= 0 || h.IndexOf("newborn", StringComparison.Ordinal) >= 0 || h.IndexOf("birth", StringComparison.Ordinal) >= 0 || h.IndexOf("had_child", StringComparison.Ordinal) >= 0 || h.IndexOf("had_baby", StringComparison.Ordinal) >= 0 || h.IndexOf("parenthood", StringComparison.Ordinal) >= 0; } private static bool IsIntimacyHappinessId(string happinessId) { if (string.IsNullOrEmpty(happinessId)) { return false; } string h = happinessId.Trim().ToLowerInvariant(); return h == "just_kissed" || h.IndexOf("kissed", StringComparison.Ordinal) >= 0 || h.IndexOf("intimacy", StringComparison.Ordinal) >= 0 || h == "mates" || h.StartsWith("mate_", StringComparison.Ordinal); } private static bool IsSettlementHappinessId(string happinessId) { if (string.IsNullOrEmpty(happinessId)) { return false; } string h = happinessId.Trim().ToLowerInvariant(); return h == "just_found_house" || h.IndexOf("found_house", StringComparison.Ordinal) >= 0 || h.IndexOf("find_house", StringComparison.Ordinal) >= 0 || h.IndexOf("found_home", StringComparison.Ordinal) >= 0; } private static bool IsKillLifeHappinessId(string happinessId) { if (string.IsNullOrEmpty(happinessId)) { return false; } string h = happinessId.Trim().ToLowerInvariant(); return h == "just_killed" || h == "milestone_kill" || h.IndexOf("just_killed", StringComparison.Ordinal) >= 0; } private static bool IsKillLifeAssetId(string assetId) { if (string.IsNullOrEmpty(assetId)) { return false; } string a = assetId.Trim().ToLowerInvariant(); return a == "milestone_kill" || a == "just_killed"; } private static bool IsCourtshipHappinessId(string happinessId) { if (string.IsNullOrEmpty(happinessId)) { return false; } string h = happinessId.Trim().ToLowerInvariant(); // Bond outcomes (set_lover / milestone_lover) stay hotter; cool intent + smitten only. return h == "fallen_in_love" || h == "find_lover" || h == "fell_in_love" || h.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0 || h.IndexOf("find_lover", StringComparison.Ordinal) >= 0; } private static bool IsCourtshipAssetId(string assetId) { if (string.IsNullOrEmpty(assetId)) { return false; } string a = assetId.Trim().ToLowerInvariant(); return a == "fallen_in_love" || a == "find_lover" || a == "fell_in_love"; } private static IEnumerable EnumerateBeatKeys(InterestCandidate c) { if (c == null) { yield break; } long subject = c.SubjectId; long related = c.RelatedId != 0 ? c.RelatedId : EventFeedUtil.SafeId(c.RelatedUnit); if (!string.IsNullOrEmpty(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":" + subject; // Family chapters cool the happiness id world-wide so parent A then parent B // cannot chain the same parenthood tip across the village cast. if (IsFamilyLifeHappinessId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:life:family:" + subject; } // Intimacy: cool the pair and the happiness id world-wide so one couple // cannot keep cutting back in between combat tips. if (IsIntimacyHappinessId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:life:intimacy:" + subject; if (related != 0) { yield return "beat:life:intimacy:" + related; yield return "beat:life:intimacy:pair:" + EventCatalog.Combat.PairKey(subject, related); } } if (IsSettlementHappinessId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:life:home:" + subject; } if (IsKillLifeHappinessId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:life:kill:" + subject; yield return "beat:life:kill:world"; } if (IsCourtshipHappinessId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:life:love:" + subject; yield return "beat:life:love:world"; if (related != 0) { yield return "beat:life:love:" + related; yield return "beat:life:love:pair:" + EventCatalog.Combat.PairKey(subject, related); } } if (string.Equals( c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)) { yield return "beat:hap:just_got_out_of_egg:world"; yield return "beat:life:hatch:" + subject; yield return "beat:life:hatch:world"; } if (IsRestLifeId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:life:rest:" + subject; yield return "beat:life:rest:world"; } // Happiness-path soft FX (dreams without StatusId bind) still cool as a class. if (IsSoftStatusFxId(c.HappinessEffectId) && !IsRestLifeId(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":world"; yield return "beat:status:fx:" + subject; yield return "beat:status:fx:world"; } } if (!string.IsNullOrEmpty(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":" + subject; if (IsFamilyLifeHappinessId(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":world"; } if (IsIntimacyHappinessId(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":world"; yield return "beat:life:intimacy:" + subject; } if (IsCourtshipHappinessId(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":world"; yield return "beat:life:love:" + subject; yield return "beat:life:love:world"; } if (IsRestLifeId(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":world"; yield return "beat:life:rest:" + subject; yield return "beat:life:rest:world"; } if (IsSoftStatusFxId(c.StatusId) && !IsRestLifeId(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":world"; yield return "beat:status:fx:" + subject; yield return "beat:status:fx:world"; } } // Discrete catalog asset ids (not species / not sticky live_* frames). string asset = c.AssetId ?? ""; if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.OrdinalIgnoreCase) && !string.Equals(asset, c.SpeciesId, StringComparison.OrdinalIgnoreCase) && !string.Equals(asset, "scored_unit", StringComparison.OrdinalIgnoreCase) && !string.Equals(asset, "harness", StringComparison.OrdinalIgnoreCase)) { yield return "beat:asset:" + asset + ":" + subject; if (IsKillLifeAssetId(asset)) { yield return "beat:asset:" + asset + ":world"; yield return "beat:life:kill:" + subject; yield return "beat:life:kill:world"; } if (IsCourtshipAssetId(asset)) { yield return "beat:asset:" + asset + ":world"; yield return "beat:life:love:" + subject; yield return "beat:life:love:world"; } // The relationship feed reports the concrete birth first ("gains a child"); // happiness can report the same parent's reaction a frame later. They are one // family chapter, so showing the fact suppresses that immediate emotional echo // without cooling unrelated parents or later births world-wide. if (string.Equals(asset, "add_child", StringComparison.OrdinalIgnoreCase)) { yield return "beat:life:family:" + subject; } if (EventCatalog.Decision.IsLifeIntentDecision(asset)) { yield return "beat:asset:" + asset + ":world"; yield return "beat:decision:intent:" + subject; yield return "beat:decision:intent:world"; } if (IsRestLifeId(asset)) { yield return "beat:asset:" + asset + ":world"; yield return "beat:life:rest:" + subject; yield return "beat:life:rest:world"; } if (IsWorldLogMetaAssetId(asset)) { yield return "beat:asset:" + asset + ":world"; yield return "beat:worldlog:meta:" + subject; yield return "beat:worldlog:meta:world"; } if (string.Equals(asset, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)) { yield return "beat:asset:" + asset + ":world"; yield return "beat:life:hatch:" + subject; yield return "beat:life:hatch:world"; } } // Chapter-level world keys (ungated): survive species AssetId / missing StatusId / label rebuilds. if (IsLifeIntentDecisionChapter(c)) { yield return "beat:decision:intent:" + subject; yield return "beat:decision:intent:world"; } if (IsRestLifeChapter(c)) { yield return "beat:life:rest:" + subject; yield return "beat:life:rest:world"; } if (IsConstructionChapter(c)) { yield return "beat:construction:" + subject; yield return "beat:construction:world"; } if (IsWorldLogMetaChapter(c) && string.IsNullOrEmpty(asset)) { yield return "beat:worldlog:meta:" + subject; yield return "beat:worldlog:meta:world"; } string labelNorm = NormalizeBeatLabel(c.Label); if (!string.IsNullOrEmpty(labelNorm)) { yield return "beat:label:" + labelNorm + ":" + subject; // Label-only soft chapters (missing HappinessEffectId) still cool as a class. if (string.IsNullOrEmpty(c.HappinessEffectId)) { if (IsIntimacyLifeChapter(c) && (labelNorm.IndexOf("intimacy", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("mates with", StringComparison.Ordinal) >= 0)) { yield return "beat:life:intimacy:" + subject; yield return "beat:life:intimacy:world"; if (related != 0) { yield return "beat:life:intimacy:pair:" + EventCatalog.Combat.PairKey(subject, related); } } if (IsSettlementLifeChapter(c) && (labelNorm.IndexOf("finds a home", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("settles into", StringComparison.Ordinal) >= 0)) { yield return "beat:life:home:" + subject; yield return "beat:life:home:world"; } if (IsKillLifeChapter(c) && labelNorm.StartsWith("killed ", StringComparison.Ordinal)) { yield return "beat:life:kill:" + subject; yield return "beat:life:kill:world"; } if (IsCourtshipLifeChapter(c) && (labelNorm.IndexOf("smitten", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("falls in love", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("seeking a lover", StringComparison.Ordinal) >= 0)) { yield return "beat:life:love:" + subject; yield return "beat:life:love:world"; if (related != 0) { yield return "beat:life:love:pair:" + EventCatalog.Combat.PairKey(subject, related); } } if (IsHatchLifeChapter(c) && labelNorm.IndexOf("hatches", StringComparison.Ordinal) >= 0) { yield return "beat:life:hatch:" + subject; yield return "beat:life:hatch:world"; } if (IsWorldLogMetaChapter(c)) { yield return "beat:worldlog:meta:" + subject; yield return "beat:worldlog:meta:world"; } if (IsRestLifeChapter(c) && (labelNorm.IndexOf("asleep", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("rest", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("dream", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("nightmare", StringComparison.Ordinal) >= 0 || labelNorm.IndexOf("sleep", StringComparison.Ordinal) >= 0)) { yield return "beat:life:rest:" + subject; yield return "beat:life:rest:world"; } } } } private static string NormalizeBeatLabel(string label) { if (string.IsNullOrEmpty(label)) { return ""; } string t = label.Trim().ToLowerInvariant(); // Drop leading proper name so "Ukalo feels the joy…" and rebuilds share a beat. int space = t.IndexOf(' '); if (space > 0 && space < t.Length - 1) { string rest = t.Substring(space + 1).Trim(); if (rest.StartsWith("feels ", StringComparison.Ordinal) || rest.StartsWith("has ", StringComparison.Ordinal) || rest.StartsWith("is ", StringComparison.Ordinal) || rest.StartsWith("becomes ", StringComparison.Ordinal) || rest.StartsWith("gains ", StringComparison.Ordinal) || rest.StartsWith("decides ", StringComparison.Ordinal) || rest.StartsWith("shares ", StringComparison.Ordinal) || rest.StartsWith("mates ", StringComparison.Ordinal) || rest.StartsWith("mourns ", StringComparison.Ordinal)) { t = rest; } } return t.Length > 64 ? t.Substring(0, 64) : t; } private static string BeatDropDetail(InterestCandidate c) { if (c == null) { return "beat"; } if (!string.IsNullOrEmpty(c.HappinessEffectId)) { return c.HappinessEffectId; } if (!string.IsNullOrEmpty(c.StatusId)) { return c.StatusId; } return c.AssetId ?? c.Key ?? "beat"; } private static float NoveltyPenalty(InterestCandidate c, float now) { float p = 0f; p += CooldownWeight("unit:" + c.SubjectId, now, 0.55f); if (!string.IsNullOrEmpty(c.SpeciesId)) { p += CooldownWeight("species:" + c.SpeciesId, now, 0.25f); } if (!string.IsNullOrEmpty(c.Category)) { p += CooldownWeight("cat:" + c.Category, now, 0.2f); } if (!string.IsNullOrEmpty(c.CityKey)) { p += CooldownWeight("city:" + c.CityKey, now, 0.2f); } if (!string.IsNullOrEmpty(c.KingdomKey)) { p += CooldownWeight("kingdom:" + c.KingdomKey, now, 0.2f); } if (!string.IsNullOrEmpty(c.Verb)) { p += CooldownWeight("verb:" + c.Verb, now, 0.15f); } return Mathf.Clamp01(p); } private static float CooldownWeight(string key, float now, float weight) { if (!CooldownUntil.TryGetValue(key, out float until) || now >= until) { return 0f; } float remain = until - now; float frac = Mathf.Clamp01(remain / HardCooldownSeconds); return weight * frac; } private static void StampCooldown(string key, float until) { if (string.IsNullOrEmpty(key)) { return; } if (CooldownUntil.TryGetValue(key, out float existing)) { CooldownUntil[key] = Mathf.Max(existing, until); } else { CooldownUntil[key] = until; } } }