worldbox-observer-mod/IdleSpectator/Narrative/EpisodeShotSelector.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

92 lines
3.7 KiB
C#

using System.Collections.Generic;
namespace IdleSpectator;
public sealed class EpisodeShotProposal
{
public InterestCandidate Candidate;
public NarrativeInterruptClass InterruptClass;
public string Reason = "";
public float EditorialScore;
}
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
public static class EpisodeShotSelector
{
public static EpisodeShotProposal Pick(
IList<InterestCandidate> candidates,
EpisodePlan episode,
NarrativeThread thread,
bool requirePresentable = true)
{
if (candidates == null || candidates.Count == 0 || episode == null) return null;
EpisodeShotProposal bestRelated = null;
EpisodeShotProposal bestInterrupt = null;
for (int i = 0; i < candidates.Count; i++)
{
InterestCandidate c = candidates[i];
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
if (c == null || c.Selected
|| InterestVariety.IsEditorialReplayCooled(c)
|| !StoryScheduler.MayUseCombatShot(episode, c)
|| (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue;
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
if (function == NarrativeFunction.Noise) continue;
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
float score = EditorialScore(c, episode, kind, function);
var proposal = new EpisodeShotProposal
{
Candidate = c,
InterruptClass = kind,
EditorialScore = score,
Reason = Reason(kind)
};
if (kind == NarrativeInterruptClass.RelatedEscalation)
{
if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal;
}
else if (InterruptPolicy.MayInterrupt(kind)
&& (bestInterrupt == null || score > bestInterrupt.EditorialScore))
{
bestInterrupt = proposal;
}
}
// A productive related development owns the episode. Critical/payoff material interrupts
// only when no related shot is ready, or when its editorial advantage is decisive.
if (bestRelated != null
&& (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f))
{
return bestRelated;
}
return bestInterrupt ?? bestRelated;
}
private static float EditorialScore(
InterestCandidate c,
EpisodePlan episode,
NarrativeInterruptClass kind,
NarrativeFunction function)
{
float relation = c.SubjectId == episode.ProtagonistId ? 40f
: c.RelatedId == episode.ProtagonistId ? 30f : 0f;
float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 80f
: kind == NarrativeInterruptClass.StoryPayoff ? 35f
: kind == NarrativeInterruptClass.RelatedEscalation ? 25f : 0f;
return relation + interrupt + NarrativeFunctionPolicy.EditorialBonus(function)
+ c.EventStrength * 0.35f
+ c.VisualConfidence * 10f + c.Novelty * 3f;
}
private static string Reason(NarrativeInterruptClass kind)
{
switch (kind)
{
case NarrativeInterruptClass.RelatedEscalation: return "active_episode";
case NarrativeInterruptClass.WorldCritical: return "world_critical_interrupt";
case NarrativeInterruptClass.StoryPayoff: return "story_payoff_interrupt";
default: return "not_episode_worthy";
}
}
}