- 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
169 lines
7.1 KiB
C#
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";
|
|
}
|
|
}
|
|
}
|