- 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
392 lines
14 KiB
C#
392 lines
14 KiB
C#
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;
|
||
}
|
||
}
|