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 = ""; } /// Selects the best presentable shot that can explain its place in the active episode. 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 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"; } } }