using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Story-first scheduler and sole camera-story owner. /// public static class StoryScheduler { private const float EpisodeNoProgressSeconds = 14f; private const float PayoffSettleSeconds = 8f; private const float EpisodeMaxSeconds = 75f; public const int EpisodeCombatShotBudget = 3; private static readonly List OpenThreads = new List(32); private static readonly List Pending = new List(96); private static float _nextTickAt; private static string _lastProposal = ""; public static EpisodePlan ActiveEpisode { get; private set; } public static EpisodeShotProposal CurrentProposal { get; private set; } public static string LastProposal => _lastProposal ?? ""; public static void Clear() { ActiveEpisode = null; CurrentProposal = null; OpenThreads.Clear(); Pending.Clear(); _nextTickAt = 0f; _lastProposal = ""; } public static void Tick(float now) { if (now < _nextTickAt) return; _nextTickAt = now + 0.5f; NarrativeStoryStore.CopyOpenThreads(OpenThreads); NarrativeThread best = null; float bestScore = float.MinValue; for (int i = 0; i < OpenThreads.Count; i++) { NarrativeThread thread = OpenThreads[i]; float score = Score(thread, now); if (score > bestScore) { best = thread; bestScore = score; } } NarrativeThread activeThread = ActiveEpisode != null ? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId) : null; if (ActiveEpisode != null && !EpisodeStillValid(ActiveEpisode, activeThread, now)) { ActiveEpisode = null; CurrentProposal = null; activeThread = null; } if (best == null && activeThread == null) { ActiveEpisode = null; CurrentProposal = null; _lastProposal = "none"; return; } NarrativeThread selectedThread = RetainOrChoose(activeThread, best, bestScore, now); if (selectedThread == null) { ActiveEpisode = null; CurrentProposal = null; _lastProposal = "none"; return; } if (ActiveEpisode == null || ActiveEpisode.ThreadId != selectedThread.Id) { ActiveEpisode = BuildEpisode(selectedThread, now); } else if (selectedThread.LatestMeaningfulChangeId != ActiveEpisode.EntryDevelopmentId) { ActiveEpisode.EntryDevelopmentId = selectedThread.LatestMeaningfulChangeId; ActiveEpisode.LastProgressAt = now; ActiveEpisode.CombatShotCount = 0; ActiveEpisode.GoalMet = false; } InterestRegistry.CopyPending(Pending); CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread); if (CurrentProposal != null) { NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now); } if (CurrentProposal?.Candidate != null) { NarrativeTelemetry.NoteCurrentAlignment( ActiveEpisode, selectedThread, InterestDirector.CurrentCandidate); } _lastProposal = selectedThread.Id + " protagonist=" + selectedThread.ProtagonistId + " phase=" + selectedThread.Phase + " score=" + Score(selectedThread, now).ToString("0.0") + " shot=" + (CurrentProposal?.Candidate?.Key ?? "none") + " reason=" + (CurrentProposal?.Reason ?? "none"); } public static void NoteActualSelection(InterestCandidate actual) { if (ActiveEpisode == null || actual == null) return; NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId); NarrativeTelemetry.NoteActual(ActiveEpisode, thread, actual); NarrativeInterruptClass kind = InterruptPolicy.Classify(actual, ActiveEpisode, thread); if (kind == NarrativeInterruptClass.RelatedEscalation) { if (IsCombatShot(actual)) { ActiveEpisode.CombatShotCount++; } ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime; ActiveEpisode.LastShotKey = actual.Key ?? ""; ActiveEpisode.InterruptedAt = -1f; ActiveEpisode.GoalMet = thread != null && (thread.Phase == NarrativePhase.Outcome || thread.Phase == NarrativePhase.Consequence); } else if (kind == NarrativeInterruptClass.WorldCritical || kind == NarrativeInterruptClass.StoryPayoff) { ActiveEpisode.InterruptedAt = UnityEngine.Time.unscaledTime; } } /// /// A narrative episode may establish and develop a fight without following every new /// opponent. A decisive world-critical combat signal still bypasses the budget. /// public static bool MayUseCombatShot(EpisodePlan episode, InterestCandidate candidate) { if (episode == null || candidate == null || !IsCombatShot(candidate)) { return true; } if (candidate.EventStrength >= 95f) { return true; } return episode.CombatShotCount < EpisodeCombatShotBudget; } public static bool MayUseCombatShot(InterestCandidate candidate) => MayUseCombatShot(ActiveEpisode, candidate); /// Camera-owner selection from candidates already admitted by director safety gates. public static InterestCandidate SelectForCamera( IList eligible, InterestCandidate current, float now) { if (ActiveEpisode == null) return null; NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId); if (!EpisodeStillValid(ActiveEpisode, thread, now)) { ActiveEpisode = null; CurrentProposal = null; return null; } EpisodeShotProposal proposal = EpisodeShotSelector.Pick(eligible, ActiveEpisode, thread); CurrentProposal = proposal; if (proposal?.Candidate == null || proposal.Candidate == current) return null; return proposal.Candidate; } public static bool ShouldHoldForEpisode(float now) { if (ActiveEpisode == null || CurrentProposal?.Candidate == null) return false; NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId); return EpisodeStillValid(ActiveEpisode, thread, now) && now - ActiveEpisode.LastProgressAt < EpisodeNoProgressSeconds; } public static bool HarnessProbePolicy(out string detail) { var episode = new EpisodePlan { ThreadId = "probe:a", ProtagonistId = 101 }; episode.EligibleCastIds.Add(102); var thread = new NarrativeThread { Id = "probe:a", ProtagonistId = 101, AnchorKey = "family-a", Status = NarrativeThreadStatus.Active }; var candidates = new List { new InterestCandidate { Key = "unrelated", Label = "Unrelated event", SubjectId = 201, EventStrength = 90f }, new InterestCandidate { Key = "related", Label = "Family develops", AssetId = "add_child", SubjectId = 102, EventStrength = 60f } }; EpisodeShotProposal relatedPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false); var episodeB = new EpisodePlan { ThreadId = "probe:b", ProtagonistId = 201 }; var threadB = new NarrativeThread { Id = "probe:b", ProtagonistId = 201, AnchorKey = "family-b", Status = NarrativeThreadStatus.Active }; EpisodeShotProposal concurrentPick = EpisodeShotSelector.Pick( candidates, episodeB, threadB, requirePresentable: false); candidates.Add(new InterestCandidate { Key = "critical", Label = "Kingdom shattered", SubjectId = 301, Category = "Disaster", EventStrength = 100f }); EpisodeShotProposal criticalPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false); NarrativeInterruptClass ordinary = InterruptPolicy.Classify(candidates[0], episode, thread); var saturated = new EpisodePlan { ThreadId = "probe:combat", ProtagonistId = 101, CombatShotCount = EpisodeCombatShotBudget }; var routineCombat = new InterestCandidate { SubjectId = 101, Completion = InterestCompletionKind.CombatActive, EventStrength = 80f }; var criticalCombat = new InterestCandidate { SubjectId = 101, Completion = InterestCompletionKind.CombatActive, EventStrength = 100f }; bool routineBlocked = !MayUseCombatShot(saturated, routineCombat); bool criticalAllowed = MayUseCombatShot(saturated, criticalCombat); bool nonCombatAllowed = MayUseCombatShot(saturated, candidates[1]); bool pass = relatedPick?.Candidate?.Key == "related" && concurrentPick?.Candidate?.Key == "unrelated" && criticalPick?.Candidate?.Key == "critical" && ordinary == NarrativeInterruptClass.OrdinaryInteresting && !InterruptPolicy.MayInterrupt(ordinary) && routineBlocked && criticalAllowed && nonCombatAllowed; detail = "related=" + (relatedPick?.Candidate?.Key ?? "none") + " concurrent=" + (concurrentPick?.Candidate?.Key ?? "none") + " critical=" + (criticalPick?.Candidate?.Key ?? "none") + " ordinary=" + ordinary + " combatBudget=" + routineBlocked + "/" + criticalAllowed + "/" + nonCombatAllowed + " pass=" + pass; return pass; } public static float StoryPotential(long characterId) { CharacterStoryState state = NarrativeStoryStore.Character(characterId); return NarrativeStoryStore.EffectiveStoryPotential(state); } private static float Score(NarrativeThread thread, float now) { if (thread == null || (!thread.IsOpen && !(thread.Status == NarrativeThreadStatus.Resolved && now - thread.UpdatedAt < 20f))) { return float.MinValue; } CharacterStoryState state = NarrativeStoryStore.Character(thread.ProtagonistId); float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now); float freshness = Mathf.Max(0f, 8f - (now - thread.UpdatedAt) / 15f); float payoff = thread.Phase == NarrativePhase.TurningPoint ? 8f : thread.Phase == NarrativePhase.Outcome ? 7f : thread.Phase == NarrativePhase.Consequence ? 6f : 0f; float prefer = LifeSagaRoster.IsPrefer(thread.ProtagonistId) ? 8f : 0f; float mc = LifeSagaRoster.IsMc(thread.ProtagonistId) ? 4f : 0f; float repeat = ActiveEpisode != null && ActiveEpisode.ThreadId == thread.Id ? 2f : 0f; return potential + thread.Momentum + thread.Urgency + thread.CoverageDebt + freshness + payoff + prefer + mc + repeat; } private static NarrativeThread RetainOrChoose( NarrativeThread active, NarrativeThread best, float bestScore, float now) { if (active == null) return best; if (best == null || best.Id == active.Id) return active; float activeScore = Score(active, now); bool settled = ActiveEpisode != null && ActiveEpisode.GoalMet && now - ActiveEpisode.LastProgressAt >= PayoffSettleSeconds; bool stale = ActiveEpisode != null && now - ActiveEpisode.LastProgressAt >= EpisodeNoProgressSeconds; return settled || stale || bestScore >= activeScore + 20f ? best : active; } private static bool EpisodeStillValid(EpisodePlan episode, NarrativeThread thread, float now) { bool recentResolution = thread != null && thread.Status == NarrativeThreadStatus.Resolved && now - thread.UpdatedAt < 20f; if (episode == null || thread == null || (!thread.IsOpen && !recentResolution)) return false; if (now - episode.StartedAt >= EpisodeMaxSeconds) return false; if (episode.GoalMet && now - episode.LastProgressAt >= PayoffSettleSeconds) return false; return now - episode.LastProgressAt < EpisodeNoProgressSeconds || thread.LatestMeaningfulChangeId != episode.EntryDevelopmentId; } private static EpisodePlan BuildEpisode(NarrativeThread thread, float now) { var episode = new EpisodePlan { ThreadId = thread.Id, ProtagonistId = thread.ProtagonistId, EntryDevelopmentId = thread.LatestMeaningfulChangeId, Purpose = PurposeFor(thread), InformationGoal = thread.CentralQuestion ?? "", StartedAt = now, LastProgressAt = now }; for (int i = 0; i < thread.Cast.Count; i++) { NarrativeCastRole cast = thread.Cast[i]; if (cast != null && cast.CharacterId != 0) episode.EligibleCastIds.Add(cast.CharacterId); } return episode; } private static bool IsCombatShot(InterestCandidate candidate) { return candidate != null && (candidate.Completion == InterestCompletionKind.CombatActive || string.Equals(candidate.AssetId, "live_combat", System.StringComparison.OrdinalIgnoreCase) || string.Equals(candidate.AssetId, "live_battle", System.StringComparison.OrdinalIgnoreCase)); } private static EpisodePurpose PurposeFor(NarrativeThread thread) { if (thread == null) return EpisodePurpose.Develop; switch (thread.Phase) { case NarrativePhase.Setup: return EpisodePurpose.Establish; case NarrativePhase.TurningPoint: case NarrativePhase.Outcome: return EpisodePurpose.Payoff; case NarrativePhase.Consequence: return EpisodePurpose.Consequence; default: return EpisodePurpose.Develop; } } }