worldbox-observer-mod/IdleSpectator/Narrative/StoryScheduler.cs
DazedAnon 739cc6492c feat(spectator): make narrative threads own camera direction
- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters
- Schedule episode shots with critical interrupts, replay guards, and a combat budget
- Admit emerging main characters through story momentum instead of spectacle
- Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity
- Expand narrative harness coverage, soak auditing, and product documentation
2026-07-23 12:17:18 -05:00

357 lines
14 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Story-first scheduler and sole camera-story owner.
/// </summary>
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<NarrativeThread> OpenThreads = new List<NarrativeThread>(32);
private static readonly List<InterestCandidate> Pending = new List<InterestCandidate>(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;
}
}
/// <summary>
/// A narrative episode may establish and develop a fight without following every new
/// opponent. A decisive world-critical combat signal still bypasses the budget.
/// </summary>
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);
/// <summary>Camera-owner selection from candidates already admitted by director safety gates.</summary>
public static InterestCandidate SelectForCamera(
IList<InterestCandidate> 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<InterestCandidate>
{
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;
}
}
}