worldbox-observer-mod/IdleSpectator/Narrative/NarrativeProse.cs
DazedAnon c9000531fc feat(happiness): enhance happiness event handling and telemetry tracking
- Introduce bounded happiness probe to improve event diagnostics
- Update happiness drain logic to limit processed occurrences per tick
- Enhance telemetry for happiness processing times and maximum items
- Refactor narrative development to include new home-related events
- Improve narrative thread handling for home establishment and recovery
2026-07-23 22:45:18 -05:00

144 lines
5.9 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;
NarrativePresentationModel presentation = tip.NarrativePresentation;
if (presentation != null && presentation.PerspectiveId == focusId)
{
NarrativeDevelopment exact = !string.IsNullOrEmpty(presentation.DevelopmentId)
? NarrativeStoryStore.Development(presentation.DevelopmentId)
: null;
model = new NarrativeBeatModel
{
PerspectiveCharacterId = focusId,
DevelopmentId = presentation.DevelopmentId ?? "",
ActionKind = presentation.DevelopmentKind,
OtherId = presentation.OtherId,
Other = presentation.Other,
Change = presentation.Change ?? "",
Outcome = presentation.Outcome ?? "",
Confidence = presentation.Confidence
};
NarrativeThread exactThread = LatestThreadFor(focusId, presentation.DevelopmentId);
if (exactThread != null)
{
model.ThreadId = exactThread.Id;
model.ThreadPhase = exactThread.Phase;
model.ContextEvidence = exactThread.PressureEvidence;
}
beat = NarrativeRenderer.Beat(presentation);
context = ContextLine(model, exactThread);
if (!string.IsNullOrEmpty(beat))
{
NarrativePresentationCoverage.Note(
presentation.EvidenceSource,
beat,
presentation,
exact != null);
return true;
}
}
NarrativePresentationCoverage.Note("missing-presentation", tip.Label, null, false);
return false;
}
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.FoundHome:
return "Found a 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 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;
}
}