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;
}
}
}