- 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
284 lines
11 KiB
C#
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";
|
|
}
|
|
}
|
|
}
|