using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Bounded authored-cut ledger. Camera hold seconds do not count; only meaningful selections do. /// public static class NarrativeExposureLedger { private sealed class Exposure { public int Cuts; public float LastCutAt = -999f; public string LastSemanticFamily = ""; public int SameFamilyRun; } private static readonly Dictionary Characters = new Dictionary(); private static readonly Dictionary Threads = new Dictionary(StringComparer.Ordinal); private static readonly List PresentedCuts = new List(9); private static int _maxCharacterCuts; private static int _maxThreadCuts; private static int _combatRun; private const int PresentedWindow = 9; private const int CombatWindowBudget = 3; private const int CombatRunBudget = 3; public static void Clear() { Characters.Clear(); Threads.Clear(); PresentedCuts.Clear(); _maxCharacterCuts = 0; _maxThreadCuts = 0; _combatRun = 0; } public static void NoteCut( long characterId, string threadId, string semanticFamily, float now) { if (characterId != 0) { Exposure exposure = Get(Characters, characterId); Update(exposure, semanticFamily, now); _maxCharacterCuts = Math.Max(_maxCharacterCuts, exposure.Cuts); } if (!string.IsNullOrEmpty(threadId)) { Exposure exposure = Get(Threads, threadId); Update(exposure, semanticFamily, now); _maxThreadCuts = Math.Max(_maxThreadCuts, exposure.Cuts); } } public static float CharacterDebt(long characterId) { if (characterId == 0) return 0f; int cuts = Characters.TryGetValue(characterId, out Exposure exposure) ? exposure.Cuts : 0; return Mathf.Min(12f, Mathf.Max(0, _maxCharacterCuts - cuts) * 1.5f); } public static float ThreadDebt(string threadId) { if (string.IsNullOrEmpty(threadId)) return 0f; int cuts = Threads.TryGetValue(threadId, out Exposure exposure) ? exposure.Cuts : 0; return Mathf.Min(10f, Mathf.Max(0, _maxThreadCuts - cuts) * 1.25f); } public static float RepetitionPenalty(long characterId, string semanticFamily) { if (characterId == 0 || string.IsNullOrEmpty(semanticFamily) || !Characters.TryGetValue(characterId, out Exposure exposure) || !string.Equals( exposure.LastSemanticFamily, semanticFamily, StringComparison.Ordinal)) { return 0f; } return Mathf.Min(24f, exposure.SameFamilyRun * 6f); } /// /// Three combat cuts may establish, escalate, and resolve a conflict; another story /// family must then receive space. The rolling window also holds combat to one third /// of authored cuts. Criticality affects which combat cut wins, not whether combat may /// exceed the global story-first exposure budget. /// public static bool MayPresent(InterestCandidate candidate) { if (!IsCombat(candidate)) { return true; } int combat = 0; for (int i = 0; i < PresentedCuts.Count; i++) { if (PresentedCuts[i]) combat++; } return _combatRun < CombatRunBudget && combat < CombatWindowBudget; } public static void NotePresented(InterestCandidate candidate) { bool combat = IsCombat(candidate); PresentedCuts.Add(combat); while (PresentedCuts.Count > PresentedWindow) { PresentedCuts.RemoveAt(0); } _combatRun = combat ? _combatRun + 1 : 0; } public static bool HarnessProbeBalance(out string detail) { Clear(); NoteCut(1, "thread:a", "combat", 1f); NoteCut(1, "thread:a", "combat", 2f); NoteCut(1, "thread:a", "combat", 3f); NoteCut(2, "thread:b", "lineage", 4f); float underCovered = CharacterDebt(2); float prolific = CharacterDebt(1); float repetition = RepetitionPenalty(1, "combat"); float diverse = RepetitionPenalty(1, "lineage"); var combat = new InterestCandidate { Completion = InterestCompletionKind.CombatActive, EventStrength = 80f }; var criticalCombat = new InterestCandidate { Completion = InterestCompletionKind.CombatActive, EventStrength = 100f }; var family = new InterestCandidate { AssetId = "add_child", EventStrength = 70f }; NotePresented(combat); NotePresented(combat); NotePresented(combat); bool routineBlocked = !MayPresent(combat); bool criticalBounded = !MayPresent(criticalCombat); for (int i = 0; i < PresentedWindow; i++) NotePresented(family); bool routineReopened = MayPresent(combat); Clear(); var activityCombat = new InterestCandidate { Completion = InterestCompletionKind.ActivityActive, Category = "Combat", Label = "Aro is fighting Bex", EventStrength = 70f }; NotePresented(activityCombat); NotePresented(activityCombat); NotePresented(activityCombat); bool activityCombatBounded = !MayPresent(activityCombat); bool ok = underCovered > prolific && repetition > 0f && diverse == 0f && routineBlocked && criticalBounded && routineReopened && activityCombatBounded; detail = "debt=" + underCovered.ToString("0.0") + "/" + prolific.ToString("0.0") + " repetition=" + repetition.ToString("0.0") + "/" + diverse.ToString("0.0") + " combat=" + routineBlocked + "/" + criticalBounded + "/" + routineReopened + "/" + activityCombatBounded + " pass=" + ok; Clear(); return ok; } private static Exposure Get(Dictionary map, TKey key) { if (!map.TryGetValue(key, out Exposure exposure)) { exposure = new Exposure(); map[key] = exposure; } return exposure; } private static void Update(Exposure exposure, string family, float now) { exposure.Cuts++; exposure.LastCutAt = now; if (!string.IsNullOrEmpty(family) && string.Equals(exposure.LastSemanticFamily, family, StringComparison.Ordinal)) { exposure.SameFamilyRun++; } else { exposure.LastSemanticFamily = family ?? ""; exposure.SameFamilyRun = 1; } } internal static bool IsCombat(InterestCandidate candidate) { if (candidate == null) { return false; } if (candidate.Completion == InterestCompletionKind.CombatActive || candidate.Completion == InterestCompletionKind.WarFront || string.Equals(candidate.Category, "Combat", StringComparison.OrdinalIgnoreCase) || string.Equals(candidate.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase) || string.Equals(candidate.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(candidate.Verb) && candidate.Verb.IndexOf("fight", StringComparison.OrdinalIgnoreCase) >= 0)) { return true; } string label = candidate.Label ?? ""; return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) || label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase) || label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase) || label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase) || label.IndexOf(" fighting ", StringComparison.OrdinalIgnoreCase) >= 0 || label.IndexOf(" in a fight", StringComparison.OrdinalIgnoreCase) >= 0 || (label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0 && !label.StartsWith("War -", StringComparison.OrdinalIgnoreCase) && !label.StartsWith("Plot -", StringComparison.OrdinalIgnoreCase)); } }