worldbox-observer-mod/IdleSpectator/Narrative/LegacyChapterBuilder.cs
DazedAnon ff4b40d00e feat(narrative): enhance narrative probes and presentation coverage.
- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy
- Implement narrative presentation coverage tracking for better event visibility
- Update harness scenarios to include new narrative ingestion and persistence tests
- Refactor event emission to include narrative development IDs and presentations
- Expand InterestCandidate to support narrative presentation models and roles
2026-07-23 14:12:23 -05:00

392 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Sole Legacy author. It consolidates semantic consequences and the few narrative facts that do
/// not yet project a consequence (earned rivalry and hard-arc observations) before presentation.
/// </summary>
public static class LegacyChapterBuilder
{
private const int RetainedChapterLimit = 24;
public static List<LifeSagaLegacyBeat> Build(
long characterId,
string currentTitle,
LifeSagaFact currentStake,
IReadOnlyCollection<long> currentCast,
int displayLimit = 4)
{
var candidates = new List<LifeSagaLegacyBeat>(RetainedChapterLimit);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddSemanticChapters(characterId, candidates, seen);
ConsolidateLineage(characterId, candidates, seen);
ConsolidateCombat(characterId, candidates, seen);
AddFactOnlyChapters(
characterId, currentTitle, currentStake, currentCast, candidates, seen);
candidates.Sort(Compare);
SelectDiverse(candidates, displayLimit);
return candidates;
}
private static void AddSemanticChapters(
long characterId,
List<LifeSagaLegacyBeat> into,
HashSet<string> seen)
{
List<NarrativeChapter> chapters =
NarrativeStoryStore.ChaptersFor(characterId, RetainedChapterLimit);
for (int i = 0; i < chapters.Count; i++)
{
NarrativeChapter chapter = chapters[i];
if (chapter == null || string.IsNullOrEmpty(chapter.Line))
{
continue;
}
string line = WithDate(chapter.DateLabel, chapter.Line);
if (!seen.Add(line))
{
continue;
}
NarrativeThread thread = NarrativeStoryStore.Thread(chapter.ThreadId);
into.Add(new LifeSagaLegacyBeat
{
ChapterId = chapter.Id ?? "",
SourceThreadId = chapter.ThreadId ?? "",
SourceDevelopmentId = SourceDevelopmentId(chapter.Id),
Kind = FactKind(thread),
Line = line,
Truth = "observed",
At = chapter.At,
DateLabel = chapter.DateLabel ?? "",
Importance = chapter.Importance + CompletenessBonus(thread)
});
}
}
private static void ConsolidateLineage(
long characterId,
List<LifeSagaLegacyBeat> candidates,
HashSet<string> seen)
{
int children = NarrativeStoryStore.CountConsequences(
characterId, CharacterConsequenceKind.BecameParent);
if (children < 3)
{
return;
}
for (int i = candidates.Count - 1; i >= 0; i--)
{
NarrativeThread thread = NarrativeStoryStore.Thread(candidates[i].SourceThreadId);
if (thread != null && thread.Kind == NarrativeThreadKind.Lineage)
{
seen.Remove(candidates[i].Line);
candidates.RemoveAt(i);
}
}
string line = "Raised a lineage of " + children + " children";
if (seen.Add(line))
{
candidates.Add(new LifeSagaLegacyBeat
{
ChapterId = "legacy:lineage:" + characterId,
Kind = LifeSagaFactKind.ParentChild,
Line = line,
Truth = "observed",
Importance = 92f
});
}
}
private static void ConsolidateCombat(
long characterId,
List<LifeSagaLegacyBeat> candidates,
HashSet<string> seen)
{
IReadOnlyList<LifeSagaFact> facts = LifeSagaMemory.FactsFor(characterId);
int kills = 0;
LifeSagaFact earliest = null;
LifeSagaFact latest = null;
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact fact = facts[i];
if (fact == null || fact.Kind != LifeSagaFactKind.Kill)
{
continue;
}
kills++;
if (earliest == null || fact.WorldTime < earliest.WorldTime) earliest = fact;
if (latest == null || fact.WorldTime > latest.WorldTime) latest = fact;
}
if (kills < 2 || latest == null)
{
return;
}
for (int i = candidates.Count - 1; i >= 0; i--)
{
NarrativeThread thread = NarrativeStoryStore.Thread(candidates[i].SourceThreadId);
if (candidates[i].Kind == LifeSagaFactKind.Kill
|| (thread != null && thread.Kind == NarrativeThreadKind.PersonalCombat))
{
seen.Remove(candidates[i].Line);
candidates.RemoveAt(i);
}
}
string date = DateRange(earliest?.DateLabel, latest.DateLabel);
string line = WithDate(date, "Slew " + kills + " opponents across repeated clashes");
if (seen.Add(line))
{
candidates.Add(new LifeSagaLegacyBeat
{
ChapterId = "legacy:kills:" + characterId,
Kind = LifeSagaFactKind.Kill,
Line = line,
Truth = "observed",
At = latest.At,
DateLabel = date,
Importance = Mathf.Min(98f, 76f + kills * 3f)
});
}
}
private static void AddFactOnlyChapters(
long characterId,
string currentTitle,
LifeSagaFact currentStake,
IReadOnlyCollection<long> currentCast,
List<LifeSagaLegacyBeat> candidates,
HashSet<string> seen)
{
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(characterId, RetainedChapterLimit);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact fact = facts[i];
if (fact == null || CoveredBySemanticProjection(fact)
|| IsCurrentStake(fact, currentStake)
|| EchoesCurrentCast(fact, currentCast)
|| SagaProse.RoleChangeEchoesTitle(fact, currentTitle)
|| (fact.Kind == LifeSagaFactKind.Founding
&& !string.IsNullOrEmpty(currentTitle)
&& currentTitle.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0))
{
continue;
}
string line = LifeSagaMemory.FormatLegacyLine(fact);
if (string.IsNullOrEmpty(line) || !seen.Add(line))
{
continue;
}
candidates.Add(new LifeSagaLegacyBeat
{
ChapterId = "legacy:fact:" + (fact.Key ?? i.ToString()),
SourceEventId = EventIdForFact(fact),
Kind = fact.Kind,
Line = line,
Truth = "observed",
At = fact.At,
DateLabel = fact.DateLabel ?? "",
Importance = fact.Strength
});
}
}
private static bool CoveredBySemanticProjection(LifeSagaFact fact)
{
switch (fact.Kind)
{
case LifeSagaFactKind.BondFormed:
case LifeSagaFactKind.BondBroken:
case LifeSagaFactKind.FriendFormed:
case LifeSagaFactKind.ParentChild:
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.Death:
case LifeSagaFactKind.RoleChange:
case LifeSagaFactKind.Founding:
case LifeSagaFactKind.HomeGained:
case LifeSagaFactKind.HomeLost:
case LifeSagaFactKind.WarJoin:
case LifeSagaFactKind.WarEnd:
case LifeSagaFactKind.PlotJoin:
case LifeSagaFactKind.PlotEnd:
return NarrativeEventStore.Count > 0;
default:
return false;
}
}
private static bool IsCurrentStake(LifeSagaFact fact, LifeSagaFact stake)
{
if (fact == null || stake == null) return false;
return (!string.IsNullOrEmpty(stake.Key)
&& string.Equals(stake.Key, fact.Key, StringComparison.Ordinal))
|| (stake.Kind == fact.Kind && stake.OtherId != 0 && stake.OtherId == fact.OtherId);
}
private static bool EchoesCurrentCast(
LifeSagaFact fact,
IReadOnlyCollection<long> currentCast)
{
if (fact == null || currentCast == null || fact.OtherId == 0)
{
return false;
}
bool contains = false;
foreach (long id in currentCast)
{
if (id == fact.OtherId)
{
contains = true;
break;
}
}
return contains
&& (fact.Kind == LifeSagaFactKind.BondFormed
|| fact.Kind == LifeSagaFactKind.FriendFormed
|| fact.Kind == LifeSagaFactKind.ParentChild);
}
private static int Compare(LifeSagaLegacyBeat a, LifeSagaLegacyBeat b)
{
int importance = b.Importance.CompareTo(a.Importance);
return importance != 0 ? importance : b.At.CompareTo(a.At);
}
private static void SelectDiverse(List<LifeSagaLegacyBeat> candidates, int limit)
{
if (limit <= 0)
{
candidates.Clear();
return;
}
var selected = new List<LifeSagaLegacyBeat>(limit);
var buckets = new HashSet<int>();
for (int pass = 0; pass < 2 && selected.Count < limit; pass++)
{
for (int i = 0; i < candidates.Count && selected.Count < limit; i++)
{
LifeSagaLegacyBeat candidate = candidates[i];
if (candidate == null || selected.Contains(candidate)) continue;
int bucket = Bucket(candidate.Kind);
if (pass == 0 && !buckets.Add(bucket)) continue;
selected.Add(candidate);
buckets.Add(bucket);
}
}
candidates.Clear();
candidates.AddRange(selected);
}
private static int Bucket(LifeSagaFactKind kind)
{
switch (kind)
{
case LifeSagaFactKind.BondFormed:
case LifeSagaFactKind.BondBroken:
case LifeSagaFactKind.FriendFormed:
case LifeSagaFactKind.ParentChild: return 1;
case LifeSagaFactKind.RoleChange:
case LifeSagaFactKind.Founding:
case LifeSagaFactKind.HomeGained:
case LifeSagaFactKind.HomeLost: return 2;
case LifeSagaFactKind.WarJoin:
case LifeSagaFactKind.WarEnd:
case LifeSagaFactKind.PlotJoin:
case LifeSagaFactKind.PlotEnd: return 3;
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.RivalEarned:
case LifeSagaFactKind.CombatEncounter: return 4;
case LifeSagaFactKind.Death:
case LifeSagaFactKind.HardArcGrief: return 5;
default: return 6;
}
}
private static LifeSagaFactKind FactKind(NarrativeThread thread)
{
if (thread == null) return LifeSagaFactKind.Unknown;
switch (thread.Kind)
{
case NarrativeThreadKind.Courtship:
case NarrativeThreadKind.Partnership: return LifeSagaFactKind.BondFormed;
case NarrativeThreadKind.SeparationGrief: return LifeSagaFactKind.BondBroken;
case NarrativeThreadKind.Lineage: return LifeSagaFactKind.ParentChild;
case NarrativeThreadKind.PersonalCombat: return LifeSagaFactKind.Kill;
case NarrativeThreadKind.Rivalry: return LifeSagaFactKind.RivalEarned;
case NarrativeThreadKind.WarInvolvement: return LifeSagaFactKind.WarJoin;
case NarrativeThreadKind.PlotBetrayal: return LifeSagaFactKind.PlotJoin;
case NarrativeThreadKind.RiseToRule:
case NarrativeThreadKind.Reign:
case NarrativeThreadKind.Succession: return LifeSagaFactKind.RoleChange;
case NarrativeThreadKind.Founding: return LifeSagaFactKind.Founding;
case NarrativeThreadKind.HomeLoss: return LifeSagaFactKind.HomeLost;
case NarrativeThreadKind.Transformation: return LifeSagaFactKind.HardArcLove;
case NarrativeThreadKind.Recovery: return LifeSagaFactKind.HomeGained;
default: return LifeSagaFactKind.Unknown;
}
}
private static float CompletenessBonus(NarrativeThread thread)
{
if (thread == null) return 0f;
if (thread.Status == NarrativeThreadStatus.Resolved) return 14f;
if (thread.Phase == NarrativePhase.Outcome || thread.Phase == NarrativePhase.Consequence)
{
return 9f;
}
return 0f;
}
private static string SourceDevelopmentId(string consequenceId)
{
CharacterConsequence consequence = NarrativeStoryStore.Consequence(consequenceId);
return consequence?.DevelopmentId ?? "";
}
private static string EventIdForFact(LifeSagaFact fact)
{
if (fact == null) return "";
foreach (NarrativeEventRecord record in NarrativeEventStore.All())
{
if (record != null
&& (record.CorrelationKey == fact.Key
|| record.DevelopmentId == fact.Key))
{
return record.Id;
}
}
return "";
}
private static string WithDate(string date, string line) =>
string.IsNullOrEmpty(date) ? line ?? "" : date + " · " + (line ?? "");
private static string DateRange(string first, string last)
{
if (string.IsNullOrEmpty(first)) return last ?? "";
if (string.IsNullOrEmpty(last) || string.Equals(first, last, StringComparison.Ordinal))
{
return first;
}
return first + "" + last;
}
}