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;
}
}