using System.Collections.Generic; using System.Diagnostics; 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; private const float EarlyEpisodeSwitchMargin = 30f; private const float EstablishedEpisodeSwitchMargin = 20f; private const float SchedulerIntervalSeconds = 0.5f; 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 float LastTickMs { get; private set; } public static int LastCandidateCount { get; private set; } public static float LastThreadCopyMs { get; private set; } public static float LastThreadScoreMs { get; private set; } public static float LastPendingCopyMs { get; private set; } public static float LastProposalMs { get; private set; } public static void Clear() { ActiveEpisode = null; CurrentProposal = null; OpenThreads.Clear(); Pending.Clear(); _nextTickAt = 0f; _lastProposal = ""; LastTickMs = 0f; LastCandidateCount = 0; LastThreadCopyMs = 0f; LastThreadScoreMs = 0f; LastPendingCopyMs = 0f; LastProposalMs = 0f; NarrativeExposureLedger.Clear(); } public static void Tick(float now) { if (now < _nextTickAt) return; long started = Stopwatch.GetTimestamp(); _nextTickAt = float.PositiveInfinity; try { TickDue(now); } finally { LastTickMs = (float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency); // Schedule from completed work, expressed in the caller's time domain. // A slow tick therefore yields before running again instead of entering // a permanent catch-up loop on the following frame. _nextTickAt = now + SchedulerIntervalSeconds + LastTickMs / 1000f; } } private static void TickDue(float now) { LastThreadCopyMs = 0f; LastThreadScoreMs = 0f; LastPendingCopyMs = 0f; LastProposalMs = 0f; long stageStarted = Stopwatch.GetTimestamp(); NarrativeStoryStore.CopyOpenThreads(OpenThreads); LastThreadCopyMs = ElapsedMs(stageStarted); LastCandidateCount = OpenThreads.Count; NarrativeThread best = null; float bestScore = float.MinValue; stageStarted = Stopwatch.GetTimestamp(); for (int i = 0; i < OpenThreads.Count; i++) { NarrativeThread thread = OpenThreads[i]; if (thread != null && (ActiveEpisode == null || ActiveEpisode.ThreadId != thread.Id)) { thread.CoverageDebt = Mathf.Min(20f, thread.CoverageDebt + 0.035f); } float score = Score(thread, now); if (score > bestScore) { best = thread; bestScore = score; } } LastThreadScoreMs = ElapsedMs(stageStarted); 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; ActiveEpisode.CompletionReason = ""; AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread)); } stageStarted = Stopwatch.GetTimestamp(); InterestRegistry.CopyPending(Pending); LastPendingCopyMs = ElapsedMs(stageStarted); stageStarted = Stopwatch.GetTimestamp(); CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread); LastProposalMs = ElapsedMs(stageStarted); 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"); } private static float ElapsedMs(long started) => (float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency); 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) { NarrativeFunction function = NarrativeFunctionPolicy.Classify(actual); EpisodeShotRole role = EpisodeShotSelector.RoleFor( actual, function, kind, ActiveEpisode); string semanticKey = EpisodeShotSelector.SemanticKey(actual, role); if (IsCombatShot(actual)) { ActiveEpisode.CombatShotCount++; } if (actual.SubjectId != 0 && actual.SubjectId == ActiveEpisode.LastSubjectId) ActiveEpisode.RepeatedSubjectCount++; else ActiveEpisode.RepeatedSubjectCount = 1; ActiveEpisode.LastSubjectId = actual.SubjectId; ActiveEpisode.Cover(role); if (!string.IsNullOrEmpty(semanticKey)) { ActiveEpisode.RecentSemanticKeys.Remove(semanticKey); ActiveEpisode.RecentSemanticKeys.Insert(0, semanticKey); while (ActiveEpisode.RecentSemanticKeys.Count > 10) ActiveEpisode.RecentSemanticKeys.RemoveAt( ActiveEpisode.RecentSemanticKeys.Count - 1); } ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime; ActiveEpisode.LastShotKey = actual.Key ?? ""; ActiveEpisode.InterruptedAt = -1f; if (thread != null) thread.CoverageDebt = Mathf.Max(0f, thread.CoverageDebt - 3f); NarrativeExposureLedger.NoteCut( ActiveEpisode.ProtagonistId, ActiveEpisode.ThreadId, SemanticFamily(actual), UnityEngine.Time.unscaledTime); ActiveEpisode.GoalMet = ActiveEpisode.PendingRoles.Count == 0 && ActiveEpisode.CoveredRoles.Count > 0; if (ActiveEpisode.GoalMet) ActiveEpisode.CompletionReason = "grammar_complete"; } 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 bool HarnessProbeGrammar(out string detail) { NarrativeExposureLedger.Clear(); var episode = new EpisodePlan { ThreadId = "grammar:thread", ProtagonistId = 501, Purpose = EpisodePurpose.Develop }; episode.CoveredRoles.Add(EpisodeShotRole.Development); episode.PendingRoles.Add(EpisodeShotRole.Pressure); episode.RecentSemanticKeys.Add("Development:routine:501"); var thread = new NarrativeThread { Id = episode.ThreadId, ProtagonistId = 501, Status = NarrativeThreadStatus.Active }; var repeated = new InterestCandidate { Key = "repeated", AssetId = "routine", SubjectId = 501, EventStrength = 92f, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.Development }; var pressure = new InterestCandidate { Key = "pressure", AssetId = "war_started", SubjectId = 501, EventStrength = 60f, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.Escalation }; EpisodeShotProposal progressive = EpisodeShotSelector.Pick( new List { repeated, pressure }, episode, thread, requirePresentable: false); var texture = new InterestCandidate { Key = "texture", AssetId = "sleeping", SubjectId = 501, EventStrength = 80f, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.CharacterTexture }; EpisodeShotProposal noForced = EpisodeShotSelector.Pick( new List { texture }, episode, thread, requirePresentable: false); var critical = new InterestCandidate { Key = "critical", AssetId = "earthquake", SubjectId = 900, Category = "Disaster", EventStrength = 100f, HasNarrativeFunction = true, NarrativeFunction = NarrativeFunction.WorldContext }; EpisodeShotProposal interrupt = EpisodeShotSelector.Pick( new List { critical }, episode, thread, requirePresentable: false); bool ok = progressive?.Candidate?.Key == "pressure" && progressive.Role == EpisodeShotRole.Pressure && noForced == null && interrupt?.Candidate?.Key == "critical" && interrupt.Role == EpisodeShotRole.WorldContext; detail = "progressive=" + (progressive?.Candidate?.Key ?? "none") + "/" + (progressive?.Role.ToString() ?? "none") + " noForced=" + (noForced == null) + " interrupt=" + (interrupt?.Candidate?.Key ?? "none") + "/" + (interrupt?.Role.ToString() ?? "none") + " pass=" + ok; return ok; } public static bool HarnessProbeEpisodeContinuity(out string detail) { var early = new EpisodePlan { GoalMet = false }; early.CoveredRoles.Add(EpisodeShotRole.Establish); var established = new EpisodePlan { GoalMet = false }; established.CoveredRoles.Add(EpisodeShotRole.Establish); established.CoveredRoles.Add(EpisodeShotRole.Development); var complete = new EpisodePlan { GoalMet = true }; float earlyMargin = ThreadSwitchMargin(early); float establishedMargin = ThreadSwitchMargin(established); float completeMargin = ThreadSwitchMargin(complete); bool ok = earlyMargin > establishedMargin && establishedMargin == EstablishedEpisodeSwitchMargin && completeMargin == EstablishedEpisodeSwitchMargin; detail = "margin=" + earlyMargin.ToString("0") + "/" + establishedMargin.ToString("0") + "/" + completeMargin.ToString("0") + " pass=" + ok; return ok; } 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 + NarrativeExposureLedger.CharacterDebt(thread.ProtagonistId) + NarrativeExposureLedger.ThreadDebt(thread.Id) + 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; float switchMargin = ThreadSwitchMargin(ActiveEpisode); return settled || stale || bestScore >= activeScore + switchMargin ? best : active; } private static float ThreadSwitchMargin(EpisodePlan episode) { if (episode != null && !episode.GoalMet && episode.CoveredRoles.Count < 2) { return EarlyEpisodeSwitchMargin; } return EstablishedEpisodeSwitchMargin; } 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 }; AddPendingForPurpose(episode, episode.Purpose); 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 void AddPendingForPurpose(EpisodePlan episode, EpisodePurpose purpose) { if (episode == null) return; void Add(EpisodeShotRole role) { if (!episode.CoveredRoles.Contains(role) && !episode.PendingRoles.Contains(role)) episode.PendingRoles.Add(role); } switch (purpose) { case EpisodePurpose.Establish: Add(EpisodeShotRole.Establish); Add(EpisodeShotRole.Development); break; case EpisodePurpose.Payoff: Add(EpisodeShotRole.TurningPoint); Add(EpisodeShotRole.Payoff); break; case EpisodePurpose.Consequence: Add(EpisodeShotRole.Consequence); break; case EpisodePurpose.Reintroduce: Add(EpisodeShotRole.Reintroduction); Add(EpisodeShotRole.Development); break; default: Add(EpisodeShotRole.Development); Add(EpisodeShotRole.Pressure); break; } } private static bool IsCombatShot(InterestCandidate candidate) { return NarrativeExposureLedger.IsCombat(candidate); } internal static string SemanticFamily(InterestCandidate candidate) { if (candidate == null) return ""; NarrativeDevelopmentKind kind = candidate.NarrativePresentation?.DevelopmentKind ?? NarrativeDevelopmentKind.Unknown; if (kind != NarrativeDevelopmentKind.Unknown) return kind.ToString(); if (!string.IsNullOrEmpty(candidate.HappinessEffectId)) return candidate.HappinessEffectId; if (!string.IsNullOrEmpty(candidate.Category)) return candidate.Category; return candidate.AssetId ?? ""; } 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; } } }