using System; namespace IdleSpectator; /// Evidence-backed renderer for structured narrative beats and consequences. public static class NarrativeProse { public static bool TryBuildBeat( Actor focus, InterestCandidate tip, out NarrativeBeatModel model, out string beat, out string context) { model = null; beat = ""; context = ""; long focusId = EventFeedUtil.SafeId(focus); if (focusId == 0 || tip == null) return false; NarrativeDevelopmentKind kind = KindForTip(tip); if (kind == NarrativeDevelopmentKind.Unknown) return false; NarrativeDevelopment d = NarrativeStoryStore.LatestFor(focusId, kind, 8f); if (d == null) return false; model = new NarrativeBeatModel { PerspectiveCharacterId = focusId, DevelopmentId = d.Id, ActionKind = d.Kind, OtherId = d.OtherId, Other = d.Other, Change = d.NewState, Outcome = d.Outcome, Confidence = d.Confidence }; NarrativeThread thread = LatestThreadFor(focusId, d.Id); if (thread != null) { model.ThreadId = thread.Id; model.ThreadPhase = thread.Phase; model.ContextEvidence = thread.PressureEvidence; } beat = BeatLine(model); context = ContextLine(model, thread); return !string.IsNullOrEmpty(beat); } public static string BeatLine(NarrativeBeatModel model) { if (model == null || model.Confidence < 0.75f) return ""; string other = model.Other.Name ?? ""; switch (model.ActionKind) { case NarrativeDevelopmentKind.BondFormed: return string.IsNullOrEmpty(other) ? "Finds love" : "Finds love with " + other; case NarrativeDevelopmentKind.BondBroken: return string.IsNullOrEmpty(other) ? "Parts from a lover" : "Parts from " + other; case NarrativeDevelopmentKind.ChildBorn: { int count = NarrativeStoryStore.CountConsequences( model.PerspectiveCharacterId, CharacterConsequenceKind.BecameParent); string first = count == 1 ? " first" : ""; return string.IsNullOrEmpty(other) ? "Welcomes a" + first + " child" : "Welcomes" + first + " child " + other; } case NarrativeDevelopmentKind.FriendFormed: return string.IsNullOrEmpty(other) ? "Makes a true friend" : "Befriends " + other; case NarrativeDevelopmentKind.Death: return string.IsNullOrEmpty(other) ? "Dies" : "Is slain by " + other; case NarrativeDevelopmentKind.Kill: return string.IsNullOrEmpty(other) ? "Takes a life" : "Slays " + other; case NarrativeDevelopmentKind.RoleGained: return string.IsNullOrEmpty(model.Change) ? "Rises in standing" : "Becomes " + Humanize(model.Change); case NarrativeDevelopmentKind.RoleLost: return string.IsNullOrEmpty(model.Change) ? "Loses their standing" : "Is no longer " + Humanize(model.Change); case NarrativeDevelopmentKind.HomeFounded: return "Founds a new home"; case NarrativeDevelopmentKind.HomeLost: return "Loses their home"; case NarrativeDevelopmentKind.WarJoined: return "Enters the war"; case NarrativeDevelopmentKind.WarEnded: return "Emerges from the war"; case NarrativeDevelopmentKind.PlotJoined: return "Enters a plot"; case NarrativeDevelopmentKind.PlotEnded: return "Sees the plot resolved"; case NarrativeDevelopmentKind.Transformation: return string.IsNullOrEmpty(model.Change) ? "Is transformed" : "Becomes " + Humanize(model.Change); case NarrativeDevelopmentKind.Recovery: return "Recovers"; default: return ""; } } public static string ContextLine(NarrativeBeatModel model, NarrativeThread thread) { if (model == null || thread == null) return ""; // Only show concrete pressure. A generic central question is useful scheduler state, // but is not evidence suitable for player-facing narration. if (!string.IsNullOrEmpty(thread.PressureEvidence)) return SagaProse.Sentence(thread.PressureEvidence); return ""; } public static string ConsequenceLine(CharacterConsequence consequence) { if (consequence == null || consequence.Confidence < 0.75f) return ""; string other = consequence.Other.Name ?? ""; switch (consequence.Kind) { case CharacterConsequenceKind.FoundLove: return string.IsNullOrEmpty(other) ? "Found love" : "Found love with " + other; case CharacterConsequenceKind.LostPartner: return string.IsNullOrEmpty(other) ? "Lost a partner" : "Lost " + other; case CharacterConsequenceKind.BecameParent: return string.IsNullOrEmpty(other) ? "Had a child" : "Welcomed child " + other; case CharacterConsequenceKind.WasBorn: return string.IsNullOrEmpty(other) ? "Was born" : "Was born to " + other; case CharacterConsequenceKind.LostLife: return string.IsNullOrEmpty(other) ? "Died" : "Was slain by " + other; case CharacterConsequenceKind.TookLife: return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other; case CharacterConsequenceKind.RoseToRole: return "Rose in standing"; case CharacterConsequenceKind.LostRole: return "Lost their former standing"; case CharacterConsequenceKind.FoundedHome: return "Founded a new home"; case CharacterConsequenceKind.LostHome: return "Lost their home"; case CharacterConsequenceKind.EnteredWar: return "Entered a war"; case CharacterConsequenceKind.SurvivedWar: return "Survived the war"; case CharacterConsequenceKind.JoinedPlot: return "Joined a plot"; case CharacterConsequenceKind.PlotResolved: return "Saw a plot resolved"; case CharacterConsequenceKind.Transformed: return "Was transformed"; case CharacterConsequenceKind.Recovered: return "Recovered"; default: return ""; } } public static string TryMergeChapter(string current, CharacterConsequence next) { if (string.IsNullOrEmpty(current) || next == null) return ""; // Partnership chapters keep formation and loss separate because both are identity-changing. // Lineage repeats collapse naturally in the presentation layer's family summary. return ""; } private static NarrativeDevelopmentKind KindForTip(InterestCandidate tip) { string id = (tip?.AssetId ?? "").ToLowerInvariant(); switch (id) { case "set_lover": return NarrativeDevelopmentKind.BondFormed; case "clear_lover": return NarrativeDevelopmentKind.BondBroken; case "add_child": return NarrativeDevelopmentKind.ChildBorn; case "baby_created": return NarrativeDevelopmentKind.ChildBorn; case "king_new": case "become_alpha": return NarrativeDevelopmentKind.RoleGained; default: return NarrativeDevelopmentKind.Unknown; } } private static NarrativeThread LatestThreadFor(long characterId, string developmentId) { CharacterStoryState state = NarrativeStoryStore.Character(characterId); if (state == null) return null; for (int i = 0; i < state.OpenThreadIds.Count; i++) { NarrativeThread t = NarrativeStoryStore.Thread(state.OpenThreadIds[i]); if (t != null && t.DevelopmentIds.Contains(developmentId)) return t; } for (int i = 0; i < state.ResolvedThreadIds.Count; i++) { NarrativeThread t = NarrativeStoryStore.Thread(state.ResolvedThreadIds[i]); if (t != null && t.DevelopmentIds.Contains(developmentId)) return t; } return null; } private static string Humanize(string raw) { if (string.IsNullOrEmpty(raw)) return ""; return ActivityAssetCatalog.TitleCaseWords(raw.Replace('_', ' ').Trim()); } }