using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties /// and hard FixedDwell beat cooldowns (same life beat / subject cannot reclaim immediately). /// 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); private static readonly List RecentMix = 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); /// Whether the last saw both pools non-empty (for mix metering). public static bool LastPickHadBothPools { get; private set; } public static void Clear() { RecentMix.Clear(); CooldownUntil.Clear(); LastPickHadBothPools = false; } /// 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; } 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); } // Moment beats: hard suppress the same story on the same subject. if (IsMomentBeat(chosen)) { float beatUntil = Time.unscaledTime + FixedDwellBeatCooldownSeconds; foreach (string beatKey in EnumerateBeatKeys(chosen)) { StampCooldown(beatKey, beatUntil); } } } /// /// 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 || !IsMomentBeat(c)) { return false; } if (now < 0f) { now = Time.unscaledTime; } foreach (string beatKey in EnumerateBeatKeys(c)) { if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until) { return true; } } return false; } /// /// 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 (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); int topN = Mathf.Min(3, Ranked.Count); if (topN <= 0) { return null; } // Probabilistic among top-N after penalties. int pick = Rng.Next(topN); return Ranked[pick]; } 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 IEnumerable EnumerateBeatKeys(InterestCandidate c) { if (c == null) { yield break; } long subject = c.SubjectId; if (!string.IsNullOrEmpty(c.HappinessEffectId)) { yield return "beat:hap:" + c.HappinessEffectId + ":" + subject; } if (!string.IsNullOrEmpty(c.StatusId)) { yield return "beat:status:" + c.StatusId + ":" + subject; } // 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; } string labelNorm = NormalizeBeatLabel(c.Label); if (!string.IsNullOrEmpty(labelNorm)) { yield return "beat:label:" + labelNorm + ":" + subject; } } 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)) { 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; } } }