worldbox-observer-mod/IdleSpectator/Story/SagaProse.cs
DazedAnon 086921bd95 feat(saga): enhance saga probes and caption handling.
- Introduce new probes for cross-arc correlation and caption status banners
- Implement saga relation cache revision tracking
- Update harness scenarios to include new story and caption probes
- Refactor LifeSagaMemory to mark mutations for better state management
- Improve WatchCaption to handle status banners and hover previews effectively
2026-07-22 19:26:58 -05:00

1253 lines
40 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Single voice owner for saga/caption English.
/// Evidence drives copy; enum/taxonomy names never appear in player-facing strings.
/// </summary>
public static class SagaProse
{
public static readonly string[] BanList =
{
"earned rival",
"earned a rival",
"rivalry with",
"saga hero",
"fellow saga hero",
"war foe",
"plot ally",
"likely successor",
"a life in motion",
"carries a kingdom",
"holds the pack together",
"a scheme is still unfolding",
"lineage hangs by a thread",
"what they built still needs defending"
};
private static readonly HashSet<string> TitleSmallWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"of", "the", "a", "an", "in", "on", "and", "for", "to"
};
private static int _memoryRevision;
private static long _cacheFocusId;
private static long _cacheOtherId;
private static int _cacheRevision = -1;
private static long _cacheLoverId;
private static long _cacheFriendId;
private static RelationEvidence _cacheEvidence;
public static void BumpMemoryRevision()
{
_memoryRevision++;
_cacheRevision = -1;
}
public static int MemoryRevision => _memoryRevision;
public static bool ContainsBannedPhrase(string text)
{
if (string.IsNullOrEmpty(text))
{
return false;
}
string lower = text.ToLowerInvariant();
for (int i = 0; i < BanList.Length; i++)
{
if (lower.IndexOf(BanList[i], StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
public static string TitleLabel(string raw)
{
if (string.IsNullOrEmpty(raw))
{
return "";
}
string titled = ActivityAssetCatalog.TitleCaseWords(raw.Trim());
if (string.IsNullOrEmpty(titled))
{
return "";
}
string[] parts = titled.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length <= 1)
{
return titled;
}
var sb = new StringBuilder(titled.Length);
for (int i = 0; i < parts.Length; i++)
{
if (i > 0)
{
sb.Append(' ');
}
string p = parts[i];
bool small = i > 0 && i < parts.Length - 1 && TitleSmallWords.Contains(p);
if (small)
{
sb.Append(p.ToLowerInvariant());
}
else
{
sb.Append(p);
}
}
return sb.ToString();
}
public static string Sentence(string raw)
{
if (string.IsNullOrEmpty(raw))
{
return "";
}
string s = raw.Trim();
if (s.Length == 0)
{
return "";
}
char first = s[0];
if (char.IsLetter(first) && char.IsLower(first))
{
return char.ToUpperInvariant(first) + s.Substring(1);
}
return s;
}
public static string Clause(string raw)
{
if (string.IsNullOrEmpty(raw))
{
return "";
}
string s = raw.Trim().TrimStart('·', ' ').Trim();
if (s.Length == 0)
{
return "";
}
char first = s[0];
if (char.IsLetter(first) && char.IsUpper(first))
{
return char.ToLowerInvariant(first) + s.Substring(1);
}
return s;
}
/// <summary>Identity title for MCs. Empty when no real role (caller falls back to species/job).</summary>
public static string Title(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (slot != null)
{
if (slot.IsKing)
{
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
return string.IsNullOrEmpty(kingdom)
? TitleLabel("King")
: TitleLabel("King of " + kingdom);
}
if (slot.IsClanChief)
{
return TitleLabel("Clan Chief");
}
if (slot.IsLeader)
{
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
return string.IsNullOrEmpty(city)
? TitleLabel("City Leader")
: TitleLabel("Leader of " + city);
}
if (slot.IsAlpha)
{
return TitleLabel("Pack Alpha");
}
if (slot.IsArmyCaptain)
{
return TitleLabel("Army Captain");
}
if (slot.IsFounder)
{
return TitleLabel("Founder");
}
if (slot.IsPlotAuthor)
{
return TitleLabel("Plotter");
}
if (slot.IsSingleton)
{
return TitleLabel("Last of Their Kind");
}
}
if (memory != null)
{
for (int i = 0; i < memory.Facts.Count; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null)
{
continue;
}
if (f.Kind == LifeSagaFactKind.Founding)
{
return TitleLabel("Founder");
}
if (f.Kind == LifeSagaFactKind.RoleChange)
{
string note = f.Note ?? "";
if (note.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0)
{
string kingdom = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
return string.IsNullOrEmpty(kingdom)
? TitleLabel("King")
: TitleLabel("King of " + kingdom);
}
if (note.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
return TitleLabel("Pack Alpha");
}
if (note.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0
|| note.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0)
{
return TitleLabel("Clan Chief");
}
if (note.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0)
{
string city = FirstNonEmpty(slot?.CityKey, CityName(actor));
return string.IsNullOrEmpty(city)
? TitleLabel("City Leader")
: TitleLabel("Leader of " + city);
}
if (note.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0
|| note.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0)
{
return TitleLabel("Army Captain");
}
}
}
}
return "";
}
public static string FormatFactLine(LifeSagaFact fact)
{
if (fact == null)
{
return "";
}
bool unresolved = !fact.Resolved;
string line = FormatFactBody(fact, unresolved);
return Sentence(line);
}
public static string FormatLegacyLine(LifeSagaFact fact)
{
string body = FormatFactLine(fact);
if (string.IsNullOrEmpty(body))
{
return "";
}
if (!string.IsNullOrEmpty(fact?.DateLabel))
{
return fact.DateLabel + " · " + body;
}
return body;
}
public static string StakeLine(LifeSagaFact fact)
{
return FormatFactLine(fact);
}
public static string CastLabelFromEvidence(RelationEvidence e)
{
if (e == null || e.Class == RelationClass.None)
{
return "";
}
switch (e.Class)
{
case RelationClass.Bond:
return e.BondBroken ? TitleLabel("Once Loved") : TitleLabel("Lover");
case RelationClass.Friend:
return e.BestFriend ? TitleLabel("Best Friend") : TitleLabel("Friend");
case RelationClass.Kin:
if (e.KinKind == KinKind.Parent) return TitleLabel("Parent");
if (e.KinKind == KinKind.Child) return TitleLabel("Child");
if (e.KinKind == KinKind.Heir) return TitleLabel("Heir");
if (e.KinKind == KinKind.Packmate) return TitleLabel("Packmate");
return TitleLabel("Kin");
case RelationClass.Foe:
if (e.FoeKind == FoeKind.War) return TitleLabel("War Enemy");
if (e.FoeKind == FoeKind.Plot) return TitleLabel("Plot Enemy");
if (e.FoeKind == FoeKind.Blood) return TitleLabel("Blood Foe");
return TitleLabel("Old Foe");
case RelationClass.PlotPartner:
return TitleLabel("Plot Partner");
case RelationClass.Grief:
return TitleLabel("Once Loved");
default:
return "";
}
}
public static string CastLabel(LifeSagaFact fact, Actor other)
{
if (fact == null)
{
return "";
}
RelationEvidence e = EvidenceFromFact(fact, other);
return CastLabelFromEvidence(e);
}
public static string CastLabelLive(string kind)
{
switch ((kind ?? "").ToLowerInvariant())
{
case "lover": return TitleLabel("Lover");
case "bestfriend":
case "best friend": return TitleLabel("Best Friend");
case "friend": return TitleLabel("Friend");
case "parent": return TitleLabel("Parent");
case "child": return TitleLabel("Child");
case "heir": return TitleLabel("Heir");
case "packmate": return TitleLabel("Packmate");
case "kin": return TitleLabel("Kin");
case "onceloved":
case "once loved":
case "lost lover": return TitleLabel("Once Loved");
default: return TitleLabel(kind ?? "");
}
}
/// <summary>
/// Beat tip suffix without leading " · ". Empty when redundant or unknown.
/// tipVerbClass: "combat" | "love" | "grief" | "plot" | "other"
/// </summary>
public static string BeatClause(
Actor focus,
Actor other,
RelationEvidence e,
string tipLabel,
string tipVerbClass)
{
if (e == null || e.Class == RelationClass.None || other == null)
{
return "";
}
string tip = tipLabel ?? "";
string tipLower = tip.ToLowerInvariant();
string verb = (tipVerbClass ?? "other").ToLowerInvariant();
// Tip-verb class can flip bond vs foe when both apply.
RelationClass use = e.Class;
if (e.AlsoFoe && verb == "combat" && e.Class == RelationClass.Bond)
{
use = RelationClass.Foe;
}
else if (e.AlsoBond && (verb == "love" || verb == "grief") && e.Class == RelationClass.Foe)
{
use = RelationClass.Bond;
}
switch (use)
{
case RelationClass.Bond:
if (LooksLikeLoveTip(tipLower))
{
return "";
}
if (e.BondBroken)
{
return verb == "grief" || verb == "love" ? Clause("once their lover") : "";
}
return Clause("their lover");
case RelationClass.Friend:
return e.BestFriend ? Clause("their closest friend") : Clause("their friend");
case RelationClass.Kin:
if (e.KinKind == KinKind.Parent) return Clause("their parent");
if (e.KinKind == KinKind.Child) return Clause("their child");
if (e.KinKind == KinKind.Heir) return Clause("their heir");
if (e.KinKind == KinKind.Packmate) return Clause("a packmate");
return Clause("their kin");
case RelationClass.Foe:
{
RelationEvidence foe = e.AlsoFoe && e.FoeKind == FoeKind.None
? e
: e;
FoeKind fk = foe.FoeKind != FoeKind.None ? foe.FoeKind : e.FoeKind;
int count = foe.RematchCount > 0 ? foe.RematchCount : e.RematchCount;
string realm = FirstNonEmpty(foe.RealmOrPlot, e.RealmOrPlot);
if (fk == FoeKind.Rematch || count > 0)
{
if (count >= 2)
{
return Clause("their " + Ordinal(count) + " clash");
}
return Clause("again and again");
}
if (fk == FoeKind.War)
{
return string.IsNullOrEmpty(realm)
? Clause("an enemy from war")
: Clause("enemy from the " + realm + " war");
}
if (fk == FoeKind.Plot)
{
return string.IsNullOrEmpty(realm)
? Clause("a foe in a scheme")
: Clause("foe in the " + realm + " scheme");
}
if (fk == FoeKind.Blood)
{
return Clause("blood feud");
}
return Clause("an old foe");
}
case RelationClass.PlotPartner:
{
string plot = e.RealmOrPlot;
return string.IsNullOrEmpty(plot)
? Clause("their plot partner")
: Clause("plot partner in " + plot);
}
case RelationClass.Grief:
if (tipLower.IndexOf("mourn", StringComparison.Ordinal) >= 0
|| tipLower.IndexOf("grief", StringComparison.Ordinal) >= 0)
{
return "";
}
return "";
default:
return "";
}
}
public static RelationEvidence ResolveRelation(long focusId, long otherId, Actor focus, Actor other)
{
if (focusId == 0 || otherId == 0 || focusId == otherId)
{
return RelationEvidence.None;
}
long loverId = focus != null ? EventFeedUtil.SafeId(ActorRelation.GetLover(focus)) : 0;
long friendId = focus != null ? EventFeedUtil.SafeId(ActorRelation.GetBestFriend(focus)) : 0;
if (_cacheFocusId == focusId
&& _cacheOtherId == otherId
&& _cacheRevision == _memoryRevision
&& _cacheLoverId == loverId
&& _cacheFriendId == friendId
&& _cacheEvidence != null)
{
return _cacheEvidence;
}
RelationEvidence best = RelationEvidence.None;
int bestPri = 99;
// Live bond
if (loverId == otherId)
{
Consider(ref best, ref bestPri, new RelationEvidence
{
Class = RelationClass.Bond,
OtherId = otherId,
Priority = 1
});
}
// Live friend
if (friendId == otherId)
{
Consider(ref best, ref bestPri, new RelationEvidence
{
Class = RelationClass.Friend,
BestFriend = true,
OtherId = otherId,
Priority = 5
});
}
// Live kin
if (focus != null && other != null)
{
foreach (Actor p in ActorRelation.EnumerateParents(focus, 4))
{
if (EventFeedUtil.SafeId(p) == otherId)
{
Consider(ref best, ref bestPri, new RelationEvidence
{
Class = RelationClass.Kin,
KinKind = KinKind.Parent,
OtherId = otherId,
Priority = 4
});
break;
}
}
foreach (Actor c in ActorRelation.EnumerateChildren(focus, 4))
{
if (EventFeedUtil.SafeId(c) == otherId)
{
Consider(ref best, ref bestPri, new RelationEvidence
{
Class = RelationClass.Kin,
KinKind = KinKind.Child,
OtherId = otherId,
Priority = 4
});
break;
}
}
if (LifeSagaPresentation.TryGetLikelySuccessor(focus, out Actor heir, out _)
&& EventFeedUtil.SafeId(heir) == otherId)
{
Consider(ref best, ref bestPri, new RelationEvidence
{
Class = RelationClass.Kin,
KinKind = KinKind.Heir,
OtherId = otherId,
Priority = 4
});
}
}
LifeSagaLifeMemory mem = LifeSagaMemory.Get(focusId);
bool alsoBond = loverId == otherId;
bool alsoFoe = false;
RelationEvidence foeEv = null;
if (mem != null)
{
for (int i = 0; i < mem.Facts.Count; i++)
{
LifeSagaFact f = mem.Facts[i];
if (f == null || f.OtherId != otherId)
{
continue;
}
RelationEvidence fromFact = EvidenceFromFact(f, other);
if (fromFact.Class == RelationClass.Bond && fromFact.BondBroken)
{
Consider(ref best, ref bestPri, fromFact);
}
else if (fromFact.Class == RelationClass.Foe)
{
alsoFoe = true;
foeEv = fromFact;
Consider(ref best, ref bestPri, fromFact);
}
else if (fromFact.Class != RelationClass.None)
{
Consider(ref best, ref bestPri, fromFact);
}
}
}
if (LifeSagaMemory.TryGetEarnedRival(focusId, out LifeSagaFact rival)
&& rival != null
&& rival.OtherId == otherId)
{
RelationEvidence r = EvidenceFromFact(rival, other);
alsoFoe = true;
foeEv = r;
Consider(ref best, ref bestPri, r);
}
if (best != null && best.Class != RelationClass.None)
{
best.AlsoBond = alsoBond || best.Class == RelationClass.Bond;
best.AlsoFoe = alsoFoe || best.Class == RelationClass.Foe;
if (foeEv != null && best.Class == RelationClass.Bond)
{
best.FoeKind = foeEv.FoeKind;
best.RematchCount = foeEv.RematchCount;
best.RealmOrPlot = foeEv.RealmOrPlot;
}
}
_cacheFocusId = focusId;
_cacheOtherId = otherId;
_cacheRevision = _memoryRevision;
_cacheLoverId = loverId;
_cacheFriendId = friendId;
_cacheEvidence = best ?? RelationEvidence.None;
return _cacheEvidence;
}
public static string ClassifyTipVerb(string tipLabel)
{
string t = (tipLabel ?? "").ToLowerInvariant();
if (t.IndexOf("fight", StringComparison.Ordinal) >= 0
|| t.IndexOf("duel", StringComparison.Ordinal) >= 0
|| t.IndexOf("battle", StringComparison.Ordinal) >= 0
|| t.IndexOf("skirmish", StringComparison.Ordinal) >= 0
|| t.IndexOf("mass -", StringComparison.Ordinal) >= 0
|| t.IndexOf("war -", StringComparison.Ordinal) >= 0
|| t.IndexOf("attack", StringComparison.Ordinal) >= 0
|| t.IndexOf("slay", StringComparison.Ordinal) >= 0
|| t.IndexOf("killed", StringComparison.Ordinal) >= 0)
{
return "combat";
}
if (t.IndexOf("lover", StringComparison.Ordinal) >= 0
|| t.IndexOf("love", StringComparison.Ordinal) >= 0
|| t.IndexOf("smitten", StringComparison.Ordinal) >= 0
|| t.IndexOf("kiss", StringComparison.Ordinal) >= 0
|| t.IndexOf("intimacy", StringComparison.Ordinal) >= 0
|| t.IndexOf("bound", StringComparison.Ordinal) >= 0
|| t.IndexOf("mate", StringComparison.Ordinal) >= 0
|| t.IndexOf("pregnant", StringComparison.Ordinal) >= 0)
{
return "love";
}
if (t.IndexOf("mourn", StringComparison.Ordinal) >= 0
|| t.IndexOf("grief", StringComparison.Ordinal) >= 0
|| t.IndexOf("fallen", StringComparison.Ordinal) >= 0
|| t.IndexOf("stands over", StringComparison.Ordinal) >= 0)
{
return "grief";
}
if (t.IndexOf("plot", StringComparison.Ordinal) >= 0)
{
return "plot";
}
// Sleep / dream / rest tips hitch poorly against closed biography stakes.
if (t.IndexOf("sleep", StringComparison.Ordinal) >= 0
|| t.IndexOf("dream", StringComparison.Ordinal) >= 0
|| t.IndexOf("asleep", StringComparison.Ordinal) >= 0
|| t.IndexOf("wakes", StringComparison.Ordinal) >= 0
|| t.IndexOf("rests", StringComparison.Ordinal) >= 0
|| t.IndexOf("naps", StringComparison.Ordinal) >= 0)
{
return "rest";
}
return "other";
}
public static string RoleFallbackStake(LifeSagaAdmissionReason reason, LifeSagaSlot slot, Actor actor)
{
switch (reason)
{
case LifeSagaAdmissionReason.King:
{
string k = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
return string.IsNullOrEmpty(k) ? Sentence("Rules a kingdom") : Sentence("Rules " + k);
}
case LifeSagaAdmissionReason.Alpha:
return Sentence("Leads the pack");
case LifeSagaAdmissionReason.PlotAuthor:
return Sentence("Leads a plot");
case LifeSagaAdmissionReason.Singleton:
return Sentence("Last of their kind");
case LifeSagaAdmissionReason.Founder:
return Sentence("Defends what they founded");
case LifeSagaAdmissionReason.CityLeader:
{
string c = FirstNonEmpty(slot?.CityKey, CityName(actor));
return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c);
}
default:
return "";
}
}
/// <summary>True when a remembered role change merely restates the current Identity title.</summary>
public static bool RoleChangeEchoesTitle(LifeSagaFact fact, string title)
{
if (fact == null
|| fact.Kind != LifeSagaFactKind.RoleChange
|| string.IsNullOrEmpty(title))
{
return false;
}
string body = FormatFactLine(fact).ToLowerInvariant();
string current = title.ToLowerInvariant();
string[] roles =
{
"king", "queen", "leader", "alpha", "chief", "captain", "founder", "plotter"
};
for (int i = 0; i < roles.Length; i++)
{
string role = roles[i];
if (body.IndexOf(role, StringComparison.Ordinal) >= 0
&& current.IndexOf(role, StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
public static bool IsEnsembleTip(string tip)
{
if (string.IsNullOrEmpty(tip))
{
return false;
}
string t = tip.TrimStart();
return t.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("War", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Plot", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Pack", StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Outbreak", StringComparison.OrdinalIgnoreCase);
}
private static void Consider(ref RelationEvidence best, ref int bestPri, RelationEvidence cand)
{
if (cand == null || cand.Class == RelationClass.None)
{
return;
}
int pri = cand.Priority > 0 ? cand.Priority : ClassPriority(cand.Class);
if (pri < bestPri)
{
bestPri = pri;
best = cand;
best.Priority = pri;
}
}
private static int ClassPriority(RelationClass c)
{
switch (c)
{
case RelationClass.Bond: return 1;
case RelationClass.Foe: return 2;
case RelationClass.PlotPartner: return 3;
case RelationClass.Kin: return 4;
case RelationClass.Friend: return 5;
case RelationClass.Grief: return 1;
default: return 9;
}
}
private static RelationEvidence EvidenceFromFact(LifeSagaFact fact, Actor other)
{
if (fact == null)
{
return RelationEvidence.None;
}
var e = new RelationEvidence { OtherId = fact.OtherId };
switch (fact.Kind)
{
case LifeSagaFactKind.BondFormed:
e.Class = RelationClass.Bond;
e.Priority = 1;
return e;
case LifeSagaFactKind.BondBroken:
e.Class = RelationClass.Bond;
e.BondBroken = true;
e.Priority = 1;
return e;
case LifeSagaFactKind.FriendFormed:
e.Class = RelationClass.Friend;
e.Priority = 5;
return e;
case LifeSagaFactKind.ParentChild:
e.Class = RelationClass.Kin;
e.KinKind = LifeSagaMemory.IsParentPerspective(fact) ? KinKind.Child : KinKind.Parent;
e.Priority = 4;
return e;
case LifeSagaFactKind.RivalEarned:
e.Class = RelationClass.Foe;
e.Priority = 2;
FillFoeFromNote(e, fact.Note);
return e;
case LifeSagaFactKind.WarJoin:
e.Class = RelationClass.Foe;
e.FoeKind = FoeKind.War;
e.RealmOrPlot = FirstNonEmpty(fact.Note, fact.KingdomKey);
e.Priority = 2;
return e;
case LifeSagaFactKind.PlotJoin:
// Same-side plot join = partner; opposition notes use RivalEarned.
e.Class = RelationClass.PlotPartner;
e.RealmOrPlot = fact.Note ?? "";
e.Priority = 3;
return e;
case LifeSagaFactKind.Death:
case LifeSagaFactKind.HardArcGrief:
e.Class = RelationClass.Grief;
e.Priority = 1;
return e;
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.HardArcCombat:
case LifeSagaFactKind.CombatEncounter:
// Not automatically a cast foe unless RivalEarned.
return RelationEvidence.None;
default:
return RelationEvidence.None;
}
}
private static void FillFoeFromNote(RelationEvidence e, string note)
{
if (string.IsNullOrEmpty(note))
{
e.FoeKind = FoeKind.Generic;
return;
}
if (note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
{
e.FoeKind = FoeKind.War;
e.RealmOrPlot = note.Substring("opposed_in_war:".Length).Trim();
return;
}
if (note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
{
e.FoeKind = FoeKind.Plot;
e.RealmOrPlot = note.Substring("opposed_in_plot:".Length).Trim();
return;
}
if (note.StartsWith("killed_kin", StringComparison.Ordinal)
|| note.StartsWith("kin_slain", StringComparison.Ordinal))
{
e.FoeKind = FoeKind.Blood;
return;
}
if (note.StartsWith("mc_rematch:", StringComparison.Ordinal)
|| note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
{
e.FoeKind = FoeKind.Rematch;
int colon = note.IndexOf(':');
if (colon >= 0 && colon + 1 < note.Length
&& int.TryParse(note.Substring(colon + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n))
{
e.RematchCount = n;
}
return;
}
e.FoeKind = FoeKind.Generic;
}
private static string FormatFactBody(LifeSagaFact fact, bool unresolved)
{
string other = !string.IsNullOrEmpty(fact.Other.Name) ? fact.Other.Name : "";
switch (fact.Kind)
{
case LifeSagaFactKind.BondFormed:
return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other;
case LifeSagaFactKind.BondBroken:
return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other;
case LifeSagaFactKind.FriendFormed:
return string.IsNullOrEmpty(other) ? "Made a true friend" : "Befriended " + other;
case LifeSagaFactKind.ParentChild:
if (!LifeSagaMemory.IsParentPerspective(fact))
{
return string.IsNullOrEmpty(other) ? "Was born" : "Born to " + other;
}
return string.IsNullOrEmpty(other) ? "Had a child" : "Had child " + other;
case LifeSagaFactKind.Kill:
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
case LifeSagaFactKind.Death:
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
case LifeSagaFactKind.RivalEarned:
return FormatRivalBody(fact, other);
case LifeSagaFactKind.WarJoin:
{
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
if (unresolved)
{
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm))
{
return "Still at war with " + other + " of " + realm;
}
if (!string.IsNullOrEmpty(other))
{
return "Still at war with " + other;
}
return string.IsNullOrEmpty(realm) ? "Still at war" : "Still at war over " + realm;
}
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm))
{
return "Went to war against " + other + " (" + realm + ")";
}
if (!string.IsNullOrEmpty(other))
{
return "Went to war against " + other;
}
return string.IsNullOrEmpty(realm) ? "Entered a war" : "Went to war (" + realm + ")";
}
case LifeSagaFactKind.WarEnd:
{
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
return string.IsNullOrEmpty(realm) ? "War ended" : "War with " + realm + " ended";
}
case LifeSagaFactKind.PlotJoin:
{
string plot = fact.Note ?? "";
if (unresolved)
{
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot))
{
return "Still tangled in " + plot + " with " + other;
}
if (!string.IsNullOrEmpty(other))
{
return "Still plotting with " + other;
}
return string.IsNullOrEmpty(plot) ? "Still tangled in a plot" : "Still tangled in " + plot;
}
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot))
{
return "Plotted with " + other + " (" + plot + ")";
}
if (!string.IsNullOrEmpty(other))
{
return "Plotted with " + other;
}
return string.IsNullOrEmpty(plot) ? "Joined a plot" : "Joined " + plot;
}
case LifeSagaFactKind.PlotEnd:
{
string plot = fact.Note ?? "";
return string.IsNullOrEmpty(plot) ? "Plot resolved" : "Plot resolved (" + plot + ")";
}
case LifeSagaFactKind.Founding:
return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home";
case LifeSagaFactKind.RoleChange:
return FormatRoleNote(fact.Note);
case LifeSagaFactKind.HomeGained:
return "Found a home";
case LifeSagaFactKind.HomeLost:
return "Lost a home";
case LifeSagaFactKind.HardArcLove:
return FirstNonEmpty(StripBannedNote(fact.Note), "Bound with someone close");
case LifeSagaFactKind.HardArcGrief:
return FirstNonEmpty(StripBannedNote(fact.Note), "Grief took hold");
case LifeSagaFactKind.HardArcCombat:
return FirstNonEmpty(StripBannedNote(fact.Note), "Survived a hard fight");
case LifeSagaFactKind.HardArcWar:
return FirstNonEmpty(StripBannedNote(fact.Note), "Marked by war");
case LifeSagaFactKind.HardArcPlot:
return FirstNonEmpty(StripBannedNote(fact.Note), "Entangled in a plot");
case LifeSagaFactKind.CombatEncounter:
return string.IsNullOrEmpty(other) ? "Fought again" : "Clashed with " + other;
default:
return FirstNonEmpty(StripBannedNote(fact.Note), "A turning point");
}
}
private static string FormatRivalBody(LifeSagaFact fact, string other)
{
string note = fact.Note ?? "";
if (note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
{
string realm = note.Substring("opposed_in_war:".Length).Trim();
if (!fact.Resolved)
{
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm))
{
return "Still at war with " + other + " of " + realm;
}
return string.IsNullOrEmpty(other) ? "Still at war" : "Still at war with " + other;
}
return string.IsNullOrEmpty(other) ? "Went to war" : "Went to war against " + other;
}
if (note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
{
string plot = note.Substring("opposed_in_plot:".Length).Trim();
if (!fact.Resolved)
{
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot))
{
return "Locked against " + other + " in " + plot;
}
return string.IsNullOrEmpty(other) ? "Locked in a plot" : "Locked against " + other;
}
return string.IsNullOrEmpty(other) ? "Opposed in a plot" : "Opposed " + other + " in a plot";
}
if (note.StartsWith("killed_kin", StringComparison.Ordinal)
|| note.StartsWith("kin_slain", StringComparison.Ordinal))
{
return string.IsNullOrEmpty(other) ? "Blood feud over kin" : "Blood feud with " + other;
}
if (note.StartsWith("mc_rematch:", StringComparison.Ordinal)
|| note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
{
int colon = note.IndexOf(':');
int n = 0;
if (colon >= 0)
{
int.TryParse(note.Substring(colon + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out n);
}
if (n >= 2 && !string.IsNullOrEmpty(other))
{
return "Has clashed " + n + " times with " + other;
}
return string.IsNullOrEmpty(other) ? "Fought again and again" : "Fought " + other + " again and again";
}
return string.IsNullOrEmpty(other) ? "Feud continues" : "Feud with " + other;
}
private static string FormatRoleNote(string roleId)
{
if (string.IsNullOrEmpty(roleId))
{
return "Rose in standing";
}
if (roleId.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became " + TitleLabel("King");
}
if (roleId.IndexOf("leader", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became a " + TitleLabel("Leader");
}
if (roleId.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became " + TitleLabel("Pack Alpha");
}
if (roleId.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0
|| roleId.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became " + TitleLabel("Clan Chief");
}
if (roleId.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0
|| roleId.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became " + TitleLabel("Army Captain");
}
// Unknown game/mod role ids are evidence, not player-facing copy.
return "Rose in standing";
}
private static string StripBannedNote(string note)
{
if (string.IsNullOrEmpty(note) || ContainsBannedPhrase(note))
{
return "";
}
return note;
}
private static bool LooksLikeLoveTip(string tipLower)
{
return tipLower.IndexOf("lover", StringComparison.Ordinal) >= 0
|| tipLower.IndexOf("love", StringComparison.Ordinal) >= 0
|| tipLower.IndexOf("smitten", StringComparison.Ordinal) >= 0
|| tipLower.IndexOf("kiss", StringComparison.Ordinal) >= 0
|| tipLower.IndexOf("intimacy", StringComparison.Ordinal) >= 0
|| tipLower.IndexOf("bound", StringComparison.Ordinal) >= 0;
}
private static string Ordinal(int n)
{
if (n <= 0)
{
return "next";
}
int mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 13)
{
return n + "th";
}
switch (n % 10)
{
case 1: return n + "st";
case 2: return n + "nd";
case 3: return n + "rd";
default: return n + "th";
}
}
private static string KingdomName(Actor actor)
{
try
{
return actor?.kingdom != null ? actor.kingdom.name ?? "" : "";
}
catch
{
return "";
}
}
private static string CityName(Actor actor)
{
try
{
return actor?.city != null ? actor.city.name ?? "" : "";
}
catch
{
return "";
}
}
private static string FirstNonEmpty(params string[] values)
{
if (values == null)
{
return "";
}
for (int i = 0; i < values.Length; i++)
{
if (!string.IsNullOrEmpty(values[i]))
{
return values[i];
}
}
return "";
}
}
public enum RelationClass
{
None = 0,
Bond,
Friend,
Kin,
Foe,
PlotPartner,
Grief
}
public enum KinKind
{
None = 0,
Parent,
Child,
Heir,
Packmate,
Kin
}
public enum FoeKind
{
None = 0,
Rematch,
War,
Plot,
Blood,
Generic
}
public sealed class RelationEvidence
{
public static readonly RelationEvidence None = new RelationEvidence();
public RelationClass Class;
public KinKind KinKind;
public FoeKind FoeKind;
public bool BondBroken;
public bool BestFriend;
public bool AlsoBond;
public bool AlsoFoe;
public int RematchCount;
public string RealmOrPlot = "";
public long OtherId;
public int Priority;
}