- 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
1253 lines
40 KiB
C#
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;
|
|
}
|