using System.Collections.Generic; namespace IdleSpectator; public sealed class NarrativeTelemetryEntry { public float At; public string ThreadId = ""; public long ProtagonistId; public string ProposedKey = ""; public string ActualKey = ""; public string Reason = ""; public NarrativeInterruptClass InterruptClass; public EpisodeShotRole ShotRole; public float CoverageDebt; public bool ActualRelated; } /// Bounded in-memory trace for shadow-vs-actual story scheduling. public static class NarrativeTelemetry { private const int Cap = 256; private static readonly List Entries = new List(Cap); public static int Count => Entries.Count; public static int RelatedActualCount { get; private set; } public static int ComparedCount { get; private set; } public static int RelatedProposalCount { get; private set; } public static int CriticalProposalCount { get; private set; } public static int AlignedSampleCount { get; private set; } public static int PermittedInterruptSampleCount { get; private set; } public static int PermittedContinuitySampleCount { get; private set; } public static int UnalignedSampleCount { get; private set; } public static int UnrelatedActualCount => ComparedCount - RelatedActualCount; public static void Clear() { Entries.Clear(); RelatedActualCount = 0; ComparedCount = 0; RelatedProposalCount = 0; CriticalProposalCount = 0; AlignedSampleCount = 0; PermittedInterruptSampleCount = 0; PermittedContinuitySampleCount = 0; UnalignedSampleCount = 0; } public static void NoteProposal(EpisodePlan episode, EpisodeShotProposal proposal, float now) { if (episode == null || proposal?.Candidate == null) return; if (proposal.InterruptClass == NarrativeInterruptClass.RelatedEscalation) RelatedProposalCount++; if (proposal.InterruptClass == NarrativeInterruptClass.WorldCritical) CriticalProposalCount++; Entries.Add(new NarrativeTelemetryEntry { At = now, ThreadId = episode.ThreadId, ProtagonistId = episode.ProtagonistId, ProposedKey = proposal.Candidate.Key ?? "", Reason = proposal.Reason ?? "", InterruptClass = proposal.InterruptClass, ShotRole = proposal.Role, CoverageDebt = NarrativeExposureLedger.ThreadDebt(episode.ThreadId) }); Trim(); } public static void NoteActual(EpisodePlan episode, NarrativeThread thread, InterestCandidate actual) { if (episode == null || actual == null) return; bool related = InterruptPolicy.IsRelated(actual, episode, thread); ComparedCount++; if (related) RelatedActualCount++; NarrativeTelemetryEntry last = Entries.Count > 0 ? Entries[Entries.Count - 1] : null; if (last != null && last.ThreadId == episode.ThreadId && string.IsNullOrEmpty(last.ActualKey)) { last.ActualKey = actual.Key ?? ""; last.ActualRelated = related; } } public static void NoteCurrentAlignment( EpisodePlan episode, NarrativeThread thread, InterestCandidate current) { if (episode == null || current == null) return; if (InterruptPolicy.IsRelated(current, episode, thread)) { AlignedSampleCount++; return; } NarrativeInterruptClass kind = InterruptPolicy.Classify(current, episode, thread); if (InterruptPolicy.MayInterrupt(kind)) { AlignedSampleCount++; PermittedInterruptSampleCount++; return; } // Live sticky scenes are deliberately allowed to finish their immediate outcome // before an episode reclaims the camera; report them separately from true drift. if (current.Completion == InterestCompletionKind.CombatActive || current.Completion == InterestCompletionKind.WarFront || current.Completion == InterestCompletionKind.PlotActive || current.Completion == InterestCompletionKind.StatusOutbreak) { AlignedSampleCount++; PermittedContinuitySampleCount++; return; } UnalignedSampleCount++; } public static string Summary => "compared=" + ComparedCount + " actualRelated=" + RelatedActualCount + " actualUnrelated=" + UnrelatedActualCount + " proposals=" + Count + " relatedProposals=" + RelatedProposalCount + " criticalProposals=" + CriticalProposalCount + " alignedSamples=" + AlignedSampleCount + " permittedInterruptSamples=" + PermittedInterruptSampleCount + " permittedContinuitySamples=" + PermittedContinuitySampleCount + " unalignedSamples=" + UnalignedSampleCount; private static void Trim() { while (Entries.Count > Cap) Entries.RemoveAt(0); } }