worldbox-observer-mod/IdleSpectator/Narrative/EpisodeShotSelector.cs
DazedAnon 2293274d79 feat(event): enhance event handling and narrative presentation probes
- Introduce new probes for framing approval and family coalescing
- Implement exact presentation replay checks to prevent duplicate events
- Refactor InterestVariety to manage exact replay cooldowns effectively
- Update EventPresentability to improve candidate approval logic
- Enhance InterestFeeds to streamline event registration and labeling
2026-07-23 19:43:29 -05:00

284 lines
11 KiB
C#

using System.Collections.Generic;
using System.Diagnostics;
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 float LastReplayFilterMs { get; private set; }
public static float LastPresentabilityMs { get; private set; }
public static float LastClassificationMs { get; private set; }
public static float LastScoringMs { get; private set; }
public static int LastPresentabilityChecks { get; private set; }
public static string LastSlowCandidateKey { get; private set; } = "";
public static float LastSlowCandidateMs { get; private set; }
public static EpisodeShotProposal Pick(
IList<InterestCandidate> candidates,
EpisodePlan episode,
NarrativeThread thread,
bool requirePresentable = true)
{
ResetTiming();
if (candidates == null || candidates.Count == 0 || episode == null) return null;
bool trace = IdleHitchProbe.Enabled;
InterestCandidate bestRelatedCandidate = null;
NarrativeInterruptClass bestRelatedKind = NarrativeInterruptClass.OrdinaryInteresting;
EpisodeShotRole bestRelatedRole = EpisodeShotRole.Unknown;
string bestRelatedSemanticKey = "";
float bestRelatedScore = float.MinValue;
InterestCandidate bestInterruptCandidate = null;
NarrativeInterruptClass bestInterruptKind = NarrativeInterruptClass.OrdinaryInteresting;
EpisodeShotRole bestInterruptRole = EpisodeShotRole.Unknown;
float bestInterruptScore = float.MinValue;
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) continue;
long stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
bool replayCooled =
InterestVariety.IsEditorialReplayCooled(c)
|| InterestVariety.IsExactReplayCooled(c)
|| InterestVariety.IsRoutineReplayCooled(c);
if (trace)
{
LastReplayFilterMs += ElapsedMs(stageStarted);
}
if (replayCooled || !StoryScheduler.MayUseCombatShot(episode, c)) continue;
if (requirePresentable && !harness)
{
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
bool presentable = EventPresentability.WouldShow(c);
if (trace)
{
float elapsed = ElapsedMs(stageStarted);
LastPresentabilityMs += elapsed;
LastPresentabilityChecks++;
if (elapsed > LastSlowCandidateMs)
{
LastSlowCandidateMs = elapsed;
LastSlowCandidateKey = c.Key ?? "";
}
}
if (!presentable) continue;
}
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
if (function == NarrativeFunction.Noise)
{
if (trace)
{
LastClassificationMs += ElapsedMs(stageStarted);
}
continue;
}
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
EpisodeShotRole role = RoleFor(c, function, kind, episode);
bool related = kind == NarrativeInterruptClass.RelatedEscalation;
bool advances = related && AdvancesEpisode(episode, role);
string semanticKey = related && !advances ? SemanticKey(c, role) : "";
if (trace)
{
LastClassificationMs += ElapsedMs(stageStarted);
}
if (related
&& !advances
&& episode.RecentSemanticKeys.Contains(semanticKey))
{
continue;
}
if (related && function == NarrativeFunction.CharacterTexture
&& role != EpisodeShotRole.Reintroduction)
{
continue;
}
stageStarted = trace ? Stopwatch.GetTimestamp() : 0L;
float score = EditorialScore(c, episode, kind, function, role);
if (trace)
{
LastScoringMs += ElapsedMs(stageStarted);
}
if (kind == NarrativeInterruptClass.RelatedEscalation)
{
if (bestRelatedCandidate == null || score > bestRelatedScore)
{
bestRelatedCandidate = c;
bestRelatedKind = kind;
bestRelatedRole = role;
bestRelatedSemanticKey = string.IsNullOrEmpty(semanticKey)
? SemanticKey(c, role)
: semanticKey;
bestRelatedScore = score;
}
}
else if (InterruptPolicy.MayInterrupt(kind)
&& (bestInterruptCandidate == null || score > bestInterruptScore))
{
bestInterruptCandidate = c;
bestInterruptKind = kind;
bestInterruptRole = role;
bestInterruptScore = score;
}
}
// 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 (bestRelatedCandidate != null
&& (bestInterruptCandidate == null || bestInterruptScore < bestRelatedScore + 25f))
{
return BuildProposal(
bestRelatedCandidate,
bestRelatedKind,
bestRelatedRole,
bestRelatedSemanticKey,
bestRelatedScore);
}
if (bestInterruptCandidate != null)
{
return BuildProposal(
bestInterruptCandidate,
bestInterruptKind,
bestInterruptRole,
SemanticKey(bestInterruptCandidate, bestInterruptRole),
bestInterruptScore);
}
return null;
}
private static void ResetTiming()
{
LastReplayFilterMs = 0f;
LastPresentabilityMs = 0f;
LastClassificationMs = 0f;
LastScoringMs = 0f;
LastPresentabilityChecks = 0;
LastSlowCandidateKey = "";
LastSlowCandidateMs = 0f;
}
private static float ElapsedMs(long started) =>
(float)((Stopwatch.GetTimestamp() - started) * 1000d / Stopwatch.Frequency);
private static EpisodeShotProposal BuildProposal(
InterestCandidate candidate,
NarrativeInterruptClass kind,
EpisodeShotRole role,
string semanticKey,
float score)
{
return new EpisodeShotProposal
{
Candidate = candidate,
InterruptClass = kind,
EditorialScore = score,
Role = role,
SemanticKey = semanticKey ?? "",
Reason = Reason(kind) + ":" + role
};
}
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";
}
}
}