using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties. /// 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; 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); } } /// /// 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; } 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 novelty. 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 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; } } }