worldbox-observer-mod/IdleSpectator/Narrative/EpisodeShotSelector.cs
DazedAnon ff4b40d00e feat(narrative): enhance narrative probes and presentation coverage.
- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy
- Implement narrative presentation coverage tracking for better event visibility
- Update harness scenarios to include new narrative ingestion and persistence tests
- Refactor event emission to include narrative development IDs and presentations
- Expand InterestCandidate to support narrative presentation models and roles
2026-07-23 14:12:23 -05:00

169 lines
7.1 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
public sealed class EpisodeShotProposal
{
public InterestCandidate Candidate;
public NarrativeInterruptClass InterruptClass;
public string Reason = "";
public float EditorialScore;
public EpisodeShotRole Role;
public string SemanticKey = "";
}
/// <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);
EpisodeShotRole role = RoleFor(c, function, kind, episode);
string semanticKey = SemanticKey(c, role);
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
if (related
&& !AdvancesEpisode(episode, role)
&& episode.RecentSemanticKeys.Contains(semanticKey))
{
continue;
}
if (related && function == NarrativeFunction.CharacterTexture
&& role != EpisodeShotRole.Reintroduction)
{
continue;
}
float score = EditorialScore(c, episode, kind, function, role);
var proposal = new EpisodeShotProposal
{
Candidate = c,
InterruptClass = kind,
EditorialScore = score,
Role = role,
SemanticKey = semanticKey,
Reason = Reason(kind) + ":" + role
};
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,
EpisodeShotRole role)
{
float relation = c.SubjectId == episode.ProtagonistId ? 40f
: c.RelatedId == episode.ProtagonistId ? 30f : 0f;
float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 90f
: kind == NarrativeInterruptClass.StoryPayoff ? 35f
: kind == NarrativeInterruptClass.RelatedEscalation ? 25f : 0f;
float roleProgress = kind == NarrativeInterruptClass.RelatedEscalation
? (AdvancesEpisode(episode, role) ? 32f : -12f)
: 0f;
float subjectRepeat = c.SubjectId != 0 && c.SubjectId == episode.LastSubjectId
? -8f * Mathf.Min(3, episode.RepeatedSubjectCount)
: 0f;
float balance = NarrativeExposureLedger.CharacterDebt(episode.ProtagonistId) * 1.5f;
float repetition = NarrativeExposureLedger.RepetitionPenalty(
episode.ProtagonistId, StoryScheduler.SemanticFamily(c));
return relation + interrupt + roleProgress + subjectRepeat
+ balance - repetition
+ NarrativeFunctionPolicy.EditorialBonus(function)
+ c.EventStrength * 0.35f
+ c.VisualConfidence * 10f + c.Novelty * 3f;
}
public static EpisodeShotRole RoleFor(
InterestCandidate candidate,
NarrativeFunction function,
NarrativeInterruptClass interrupt,
EpisodePlan episode)
{
if (interrupt == NarrativeInterruptClass.WorldCritical)
return EpisodeShotRole.WorldContext;
switch (function)
{
case NarrativeFunction.ThreadOpener: return EpisodeShotRole.Establish;
case NarrativeFunction.Development: return EpisodeShotRole.Development;
case NarrativeFunction.Escalation: return EpisodeShotRole.Pressure;
case NarrativeFunction.TurningPoint: return EpisodeShotRole.TurningPoint;
case NarrativeFunction.Resolution: return EpisodeShotRole.Payoff;
case NarrativeFunction.Consequence: return EpisodeShotRole.Consequence;
case NarrativeFunction.CharacterTexture:
return episode != null && episode.CoveredRoles.Count == 0
? EpisodeShotRole.Reintroduction
: EpisodeShotRole.Unknown;
case NarrativeFunction.WorldContext: return EpisodeShotRole.WorldContext;
default: return EpisodeShotRole.Unknown;
}
}
public static bool AdvancesEpisode(EpisodePlan episode, EpisodeShotRole role)
{
if (episode == null || role == EpisodeShotRole.Unknown
|| role == EpisodeShotRole.WorldContext)
return false;
return episode.PendingRoles.Contains(role) || !episode.HasCovered(role);
}
public static string SemanticKey(InterestCandidate candidate, EpisodeShotRole role)
{
if (candidate == null) return "";
string action = candidate.NarrativePresentation?.ActionId;
if (string.IsNullOrEmpty(action))
action = candidate.HappinessEffectId;
if (string.IsNullOrEmpty(action))
action = candidate.AssetId;
return role + ":" + (action ?? "") + ":" + candidate.SubjectId;
}
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";
}
}
}