worldbox-observer-mod/IdleSpectator/Narrative/NarrativeProse.cs
DazedAnon 739cc6492c feat(spectator): make narrative threads own camera direction
- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters
- Schedule episode shots with critical interrupts, replay guards, and a combat budget
- Admit emerging main characters through story momentum instead of spectacle
- Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity
- Expand narrative harness coverage, soak auditing, and product documentation
2026-07-23 12:17:18 -05:00

199 lines
8.6 KiB
C#

using System;
namespace IdleSpectator;
/// <summary>Evidence-backed renderer for structured narrative beats and consequences.</summary>
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());
}
}