using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// 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. /// public static class LegacyChapterBuilder { private const int RetainedChapterLimit = 24; public static List Build( long characterId, string currentTitle, LifeSagaFact currentStake, IReadOnlyCollection currentCast, int displayLimit = 4) { var candidates = new List(RetainedChapterLimit); var seen = new HashSet(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 into, HashSet seen) { List 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 candidates, HashSet 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 candidates, HashSet seen) { IReadOnlyList 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 currentCast, List candidates, HashSet seen) { List 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 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 candidates, int limit) { if (limit <= 0) { candidates.Clear(); return; } var selected = new List(limit); var buckets = new HashSet(); 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; } }