worldbox-observer-mod/IdleSpectator/Story/CaptionComposer.cs
DazedAnon 188d091166 feat(spectator): focus camera on a five-character story ensemble
- Prioritize MCs, related cast, meaningful disasters, and exceptional events
- Show MC connections in the dossier when following related characters
- Promote rare creatures and recurring antagonists through bounded scoring
- Reduce roster churn and repetitive love/combat coverage
- Add performance telemetry, soak auditing, documentation, and harness coverage
2026-07-24 12:54:06 -05:00

852 lines
29 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Presentation-layer character beat: Identity + Beat + Context.
/// Does not mutate InterestCandidate.Label or weaken identity_filter.
/// </summary>
public static class CaptionComposer
{
public const float ReentrySeconds = 12f;
public const float ContextRepeatCooldownSeconds = 75f;
private static long _lastCaptionUnitId;
private static float _lastCaptionAt = -999f;
private static float _lastCutawayAt = -999f;
private static long _cutawayFromId;
private static string _lastFingerprint = "";
private static readonly Dictionary<string, float> ContextShownAt =
new Dictionary<string, float>(64);
private static string _activeContextKey = "";
private static string _activeContextBeatKey = "";
public static string LastIdentityLine { get; private set; } = "";
public static string LastBeatLine { get; private set; } = "";
public static string LastContextLine { get; private set; } = "";
public static long LastReasonRelatedId { get; private set; }
public static string LastFingerprint => _lastFingerprint;
public static void Clear()
{
_lastCaptionUnitId = 0;
_lastCaptionAt = -999f;
_lastCutawayAt = -999f;
_cutawayFromId = 0;
_lastFingerprint = "";
ContextShownAt.Clear();
_activeContextKey = "";
_activeContextBeatKey = "";
LastIdentityLine = "";
LastBeatLine = "";
LastContextLine = "";
LastReasonRelatedId = 0;
}
public static CaptionModel Compose(
Actor focus,
InterestCandidate ownedTip,
string presentableBeat,
long reasonRelatedId,
string spineLabel,
bool statusBannerOwnsBeat)
{
var model = new CaptionModel();
long focusId = EventFeedUtil.SafeId(focus);
float now = Time.time;
bool isReentry = false;
if (focusId != 0 && focusId != _lastCaptionUnitId)
{
// Test the returning subject before replacing the cutaway record with
// the subject we are leaving. Updating first made this comparison
// impossible: on A -> B -> A, _cutawayFromId was changed to B before
// A could be recognized as the returning life.
if (_cutawayFromId == focusId
&& _lastCutawayAt > 0f
&& now - _lastCutawayAt >= ReentrySeconds
&& LifeSagaRoster.IsMc(focusId))
{
isReentry = true;
}
if (_lastCaptionUnitId != 0)
{
_cutawayFromId = _lastCaptionUnitId;
_lastCutawayAt = now;
}
}
if (focusId != 0)
{
_lastCaptionUnitId = focusId;
_lastCaptionAt = now;
}
model.IdentityLine = BuildIdentityLine(focus, focusId);
model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId);
model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? "";
string narrativeContext = "";
bool structuredNarrativeBeat = false;
if (!statusBannerOwnsBeat
&& NarrativeProse.TryBuildBeat(
focus,
ownedTip,
out NarrativeBeatModel narrativeModel,
out string narrativeBeat,
out narrativeContext))
{
beat = narrativeBeat;
structuredNarrativeBeat = true;
if (narrativeModel.OtherId != 0)
{
model.ReasonRelatedId = narrativeModel.OtherId;
}
}
if (statusBannerOwnsBeat)
{
// Banner owns orange row; composer still fills Identity + Context.
model.BeatLine = "";
}
else
{
model.BeatLine = structuredNarrativeBeat
? beat
: EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
if (!isReentry)
{
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
}
}
model.ContextLine = BuildContext(
focusId,
ownedTip,
model.BeatLine,
model.IdentityLine,
spineLabel,
isReentry && !statusBannerOwnsBeat);
if (!string.IsNullOrEmpty(narrativeContext))
{
model.ContextLine = narrativeContext;
}
model.Fingerprint =
focusId + "|" + model.IdentityLine + "|" + model.ConnectionLine
+ "|" + model.BeatLine + "|" + model.ContextLine
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
LastIdentityLine = model.IdentityLine;
LastBeatLine = model.BeatLine;
LastContextLine = model.ContextLine;
LastReasonRelatedId = model.ReasonRelatedId;
_lastFingerprint = model.Fingerprint;
return model;
}
public static string TruncateIdentity(string identity, int maxChars)
{
if (string.IsNullOrEmpty(identity) || maxChars <= 0 || identity.Length <= maxChars)
{
return identity ?? "";
}
// Prefer Name · FirstTitleToken…
int sep = identity.IndexOf(" · ", StringComparison.Ordinal);
if (sep > 0)
{
string name = identity.Substring(0, sep);
string rest = identity.Substring(sep + 3).Trim();
string firstToken = rest;
int sp = rest.IndexOf(' ');
if (sp > 0)
{
firstToken = rest.Substring(0, sp);
}
string compact = name + " · " + firstToken;
if (compact.Length <= maxChars)
{
return compact + "…";
}
if (name.Length + 1 < maxChars)
{
return name.Substring(0, Math.Min(name.Length, maxChars - 1)) + "…";
}
}
return identity.Substring(0, Math.Max(1, maxChars - 1)) + "…";
}
private static string BuildIdentityLine(Actor focus, long focusId)
{
if (focus == null || focusId == 0)
{
return "";
}
string name = EventFeedUtil.SafeName(focus);
if (string.IsNullOrEmpty(name))
{
name = "Someone";
}
// Strip Prefer star if presentation added it elsewhere.
if (name.StartsWith("★ ", StringComparison.Ordinal))
{
name = name.Substring(2).Trim();
}
string speciesJob = UnitDossier.BuildHeadline(
name,
UnitDossier.BuildIdentityTag(
focus.asset != null ? focus.asset.id : "",
UnitDossier.ReadLiveJobLabel(focus)));
if (!LifeSagaRoster.IsMc(focusId))
{
return speciesJob;
}
LifeSagaSlot slot = LifeSagaRoster.Get(focusId);
LifeSagaLifeMemory memory = LifeSagaMemory.Get(focusId);
string title = SagaProse.Title(slot, focus, memory);
if (string.IsNullOrEmpty(title))
{
return speciesJob;
}
return name + " · " + title;
}
private static string EnrichBeat(
Actor focus,
long focusId,
InterestCandidate tip,
string beat,
ref long relatedId,
bool isReentry)
{
if (string.IsNullOrEmpty(beat))
{
return "";
}
string result = beat;
long otherId = relatedId;
if (otherId == 0 && tip != null)
{
otherId = tip.RelatedId != 0 ? tip.RelatedId : tip.PairPartnerId;
}
if (otherId == 0 && tip != null && tip.PairPartnerId != 0)
{
otherId = tip.PairPartnerId;
}
if (otherId != 0)
{
relatedId = otherId;
}
// The selected reason is a complete semantic beat. Relationship evidence belongs in the
// typed renderer; appending a generic "their child/lover/friend" clause here caused the
// same fact to be stated twice and could change the meaning of authored ensemble labels.
if (isReentry && LifeSagaRoster.IsMc(focusId))
{
string n = EventFeedUtil.SafeName(focus);
if (!string.IsNullOrEmpty(n)
&& result.StartsWith(n, StringComparison.Ordinal)
&& result.IndexOf(" again", StringComparison.OrdinalIgnoreCase) < 0)
{
// "Name again · rest" or "Name is …" → "Name again is …" is awkward.
// Prefer "Name again · " only when tip doesn't already start with Name + again.
if (result.StartsWith(n + " ", StringComparison.Ordinal))
{
result = n + " again · " + result.Substring(n.Length).TrimStart();
}
}
}
return result;
}
/// <summary>
/// Identity already names the focused life directly above the Beat. Remove only that
/// leading subject (plain or our exact rich-name wrapper), leaving other names intact.
/// </summary>
private static string RemoveIdentitySubjectPrefix(Actor focus, string beat)
{
if (focus == null || string.IsNullOrEmpty(beat))
{
return beat ?? "";
}
string name = EventFeedUtil.SafeName(focus);
if (string.IsNullOrEmpty(name))
{
return beat;
}
string rest = "";
string richName = ActivityProse.ColorName(name);
if (beat.StartsWith(richName + " ", StringComparison.Ordinal))
{
rest = beat.Substring(richName.Length).TrimStart();
}
else if (beat.StartsWith(name + " ", StringComparison.Ordinal))
{
rest = beat.Substring(name.Length).TrimStart();
}
return string.IsNullOrEmpty(rest) ? beat : SagaProse.Sentence(rest);
}
private static string BuildContext(
long focusId,
InterestCandidate tip,
string beatLine,
string identityLine,
string spineLabel,
bool isReentry)
{
string spine = spineLabel ?? "";
if (!string.IsNullOrEmpty(spine))
{
return spine;
}
if (!LifeSagaRoster.IsMc(focusId))
{
return "";
}
string tipMood = TipMood(beatLine, tip);
// A return to an MC may show a biography reminder only when there is no authored
// orange beat. Reentry must never let an unrelated legacy fact hitch onto a live event.
bool biographyReentry = isReentry && string.IsNullOrEmpty(beatLine);
// Walk strongest → weaker so a soft tip can skip a kill/role hitch and still
// show an ongoing war/plot/rival stake when one exists.
LifeSagaFact stake = PickHitchStake(
focusId,
tip,
beatLine,
tipMood,
identityLine ?? "",
biographyReentry);
if (stake == null)
{
return "";
}
string line = SagaProse.StakeLine(stake) ?? "";
return AdmitContextLine(focusId, tip, beatLine, stake, line);
}
private static LifeSagaFact PickHitchStake(
long focusId,
InterestCandidate tip,
string beatLine,
string tipMood,
string identityLine,
bool biographyReentry)
{
LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId);
if (best != null
&& !BeatCoversStake(beatLine, best)
&& !ShouldOmitStakeHitch(best, tipMood, identityLine)
&& StakeBelongsToBeat(best, tip, tipMood, biographyReentry))
{
return best;
}
var facts = LifeSagaMemory.FactsFor(focusId);
LifeSagaFact next = null;
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact f = facts[i];
if (f == null || f.Resolved || ReferenceEquals(f, best))
{
continue;
}
if (BeatCoversStake(beatLine, f)
|| ShouldOmitStakeHitch(f, tipMood, identityLine)
|| !StakeBelongsToBeat(f, tip, tipMood, biographyReentry))
{
continue;
}
// Only promote stake-worthy / fallback-worthy unresolved facts.
if (LifeSagaMemory.IsUnresolvedStakeCandidate(f)
&& (next == null
|| f.Strength > next.Strength
|| (Mathf.Approximately(f.Strength, next.Strength) && f.At > next.At)))
{
next = f;
}
}
return next;
}
/// <summary>
/// Context is connective tissue, not a random biography card. Reentry may remind the
/// viewer of the strongest open stake; ordinary cuts require semantic alignment with the
/// current beat or an actually ongoing pressure arc.
/// </summary>
private static bool StakeBelongsToBeat(
LifeSagaFact stake,
InterestCandidate tip,
string tipMood,
bool biographyReentry)
{
if (stake == null)
{
return false;
}
if (biographyReentry)
{
return true;
}
NarrativeDevelopmentKind developmentKind =
tip?.NarrativePresentation?.DevelopmentKind ?? NarrativeDevelopmentKind.Unknown;
if (developmentKind != NarrativeDevelopmentKind.Unknown)
{
if (!FactMatchesDevelopment(stake.Kind, developmentKind))
{
return false;
}
long otherId = tip.NarrativePresentation.OtherId != 0
? tip.NarrativePresentation.OtherId
: tip.RelatedId;
return otherId == 0 || stake.OtherId == 0 || otherId == stake.OtherId;
}
switch (tipMood)
{
case "love":
return IsLoveStake(stake.Kind);
case "grief":
return stake.Kind == LifeSagaFactKind.BondBroken
|| stake.Kind == LifeSagaFactKind.Death
|| stake.Kind == LifeSagaFactKind.HardArcGrief;
case "combat":
return stake.Kind == LifeSagaFactKind.CombatEncounter
|| stake.Kind == LifeSagaFactKind.RivalEarned
|| stake.Kind == LifeSagaFactKind.HardArcCombat
|| stake.Kind == LifeSagaFactKind.WarJoin
|| stake.Kind == LifeSagaFactKind.HardArcWar;
case "plot":
return stake.Kind == LifeSagaFactKind.PlotJoin
|| stake.Kind == LifeSagaFactKind.HardArcPlot;
case "rest":
return false;
}
NarrativeFunction function = tip != null
? NarrativeFunctionPolicy.Classify(tip)
: NarrativeFunction.Noise;
if (!NarrativeFunctionPolicy.ChangesStoryState(function))
{
return false;
}
if (tip != null)
{
switch (tip.Completion)
{
case InterestCompletionKind.CombatActive:
return stake.Kind == LifeSagaFactKind.CombatEncounter
|| stake.Kind == LifeSagaFactKind.RivalEarned
|| stake.Kind == LifeSagaFactKind.HardArcCombat;
case InterestCompletionKind.WarFront:
return stake.Kind == LifeSagaFactKind.WarJoin
|| stake.Kind == LifeSagaFactKind.HardArcWar;
case InterestCompletionKind.PlotActive:
return stake.Kind == LifeSagaFactKind.PlotJoin
|| stake.Kind == LifeSagaFactKind.HardArcPlot;
case InterestCompletionKind.HappinessGrief:
return stake.Kind == LifeSagaFactKind.BondBroken
|| stake.Kind == LifeSagaFactKind.Death
|| stake.Kind == LifeSagaFactKind.HardArcGrief;
}
}
// A durable but otherwise unclassified beat may carry an open pressure arc. Closed
// résumé facts (founded, slew, became) are deliberately excluded.
return stake.Kind == LifeSagaFactKind.RivalEarned
|| stake.Kind == LifeSagaFactKind.HardArcLove
|| stake.Kind == LifeSagaFactKind.HardArcGrief
|| stake.Kind == LifeSagaFactKind.HardArcCombat
|| stake.Kind == LifeSagaFactKind.HardArcWar
|| stake.Kind == LifeSagaFactKind.HardArcPlot;
}
private static bool FactMatchesDevelopment(
LifeSagaFactKind fact,
NarrativeDevelopmentKind development)
{
switch (development)
{
case NarrativeDevelopmentKind.BondFormed:
return fact == LifeSagaFactKind.BondFormed
|| fact == LifeSagaFactKind.HardArcLove;
case NarrativeDevelopmentKind.BondBroken:
return fact == LifeSagaFactKind.BondBroken
|| fact == LifeSagaFactKind.HardArcGrief;
case NarrativeDevelopmentKind.ChildBorn:
return fact == LifeSagaFactKind.ParentChild;
case NarrativeDevelopmentKind.FriendFormed:
return fact == LifeSagaFactKind.FriendFormed;
case NarrativeDevelopmentKind.Death:
return fact == LifeSagaFactKind.Death
|| fact == LifeSagaFactKind.HardArcGrief;
case NarrativeDevelopmentKind.Kill:
return fact == LifeSagaFactKind.Kill;
case NarrativeDevelopmentKind.RoleGained:
case NarrativeDevelopmentKind.RoleLost:
return fact == LifeSagaFactKind.RoleChange;
case NarrativeDevelopmentKind.HomeFounded:
return fact == LifeSagaFactKind.Founding;
case NarrativeDevelopmentKind.HomeGained:
return fact == LifeSagaFactKind.HomeGained;
case NarrativeDevelopmentKind.HomeLost:
return fact == LifeSagaFactKind.HomeLost;
case NarrativeDevelopmentKind.WarJoined:
case NarrativeDevelopmentKind.WarEnded:
return fact == LifeSagaFactKind.WarJoin
|| fact == LifeSagaFactKind.WarEnd
|| fact == LifeSagaFactKind.HardArcWar;
case NarrativeDevelopmentKind.PlotJoined:
case NarrativeDevelopmentKind.PlotEnded:
return fact == LifeSagaFactKind.PlotJoin
|| fact == LifeSagaFactKind.PlotEnd
|| fact == LifeSagaFactKind.HardArcPlot;
case NarrativeDevelopmentKind.RivalEncounter:
return fact == LifeSagaFactKind.CombatEncounter
|| fact == LifeSagaFactKind.RivalEarned
|| fact == LifeSagaFactKind.HardArcCombat;
default:
return false;
}
}
private static bool IsLoveStake(LifeSagaFactKind kind)
{
return kind == LifeSagaFactKind.BondFormed
|| kind == LifeSagaFactKind.BondBroken
|| kind == LifeSagaFactKind.ParentChild
|| kind == LifeSagaFactKind.HardArcLove;
}
private static string AdmitContextLine(
long focusId,
InterestCandidate tip,
string beatLine,
LifeSagaFact stake,
string line)
{
if (string.IsNullOrEmpty(line) || stake == null)
{
return "";
}
string stakeKey = focusId + "|"
+ (!string.IsNullOrEmpty(stake.Key)
? stake.Key
: stake.Kind + ":" + stake.OtherId);
string beatKey = focusId + "|" + (tip?.Key ?? "") + "|" + (beatLine ?? "");
float now = Time.time;
// Recomposition of the same visible beat must keep its context instead of blinking.
if (string.Equals(_activeContextKey, stakeKey, StringComparison.Ordinal)
&& string.Equals(_activeContextBeatKey, beatKey, StringComparison.Ordinal))
{
return line;
}
if (ContextShownAt.TryGetValue(stakeKey, out float shownAt)
&& now - shownAt < ContextRepeatCooldownSeconds)
{
_activeContextKey = "";
_activeContextBeatKey = "";
return "";
}
ContextShownAt[stakeKey] = now;
_activeContextKey = stakeKey;
_activeContextBeatKey = beatKey;
if (ContextShownAt.Count > 128)
{
PruneContextLedger(now);
}
return line;
}
private static void PruneContextLedger(float now)
{
var expired = new List<string>();
foreach (KeyValuePair<string, float> entry in ContextShownAt)
{
if (now - entry.Value >= ContextRepeatCooldownSeconds * 2f)
{
expired.Add(entry.Key);
}
}
for (int i = 0; i < expired.Count; i++)
{
ContextShownAt.Remove(expired[i]);
}
}
public static bool HarnessProbeContextPolicy(out string detail)
{
var founding = new LifeSagaFact
{
Kind = LifeSagaFactKind.Founding,
OtherId = 0
};
var bond = new LifeSagaFact
{
Kind = LifeSagaFactKind.BondFormed,
OtherId = 22
};
var child = new LifeSagaFact
{
Kind = LifeSagaFactKind.ParentChild,
OtherId = 33
};
var rival = new LifeSagaFact
{
Kind = LifeSagaFactKind.RivalEarned,
OtherId = 44
};
var kill = new LifeSagaFact
{
Kind = LifeSagaFactKind.Kill,
OtherId = 55
};
var texture = new InterestCandidate
{
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.CharacterTexture
};
var childBeat = new InterestCandidate
{
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Consequence,
RelatedId = 33,
NarrativePresentation = new NarrativePresentationModel
{
DevelopmentKind = NarrativeDevelopmentKind.ChildBorn,
OtherId = 33
}
};
var combat = new InterestCandidate
{
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Escalation,
Completion = InterestCompletionKind.CombatActive
};
var outbreak = new InterestCandidate
{
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.WorldContext,
Completion = InterestCompletionKind.StatusOutbreak
};
bool textureRejectsResume = !StakeBelongsToBeat(founding, texture, "", false);
bool childMatchesChild = StakeBelongsToBeat(child, childBeat, "love", false);
bool childRejectsBond = !StakeBelongsToBeat(bond, childBeat, "love", false);
bool combatMatchesRival = StakeBelongsToBeat(rival, combat, "combat", false);
bool reentryRecalls = StakeBelongsToBeat(founding, texture, "", true);
bool outbreakRejectsKill = !StakeBelongsToBeat(kill, outbreak, "", false);
bool restRejectsResume = !StakeBelongsToBeat(founding, texture, "rest", false);
bool ok = textureRejectsResume
&& childMatchesChild
&& childRejectsBond
&& combatMatchesRival
&& reentryRecalls
&& outbreakRejectsKill
&& restRejectsResume;
detail = "texture=" + textureRejectsResume
+ " child=" + childMatchesChild + "/" + childRejectsBond
+ " combat=" + combatMatchesRival
+ " reentry=" + reentryRecalls
+ " outbreak=" + outbreakRejectsKill
+ " rest=" + restRejectsResume
+ " pass=" + ok;
return ok;
}
/// <summary>
/// Soft live tips (love/rest/dream) should not hitch closed biography
/// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may.
/// </summary>
private static bool ShouldOmitStakeHitch(LifeSagaFact stake, string tipMood, string identityLine)
{
if (stake == null)
{
return true;
}
// RoleChange that already sits in Identity ("King of X") is always redundant.
if (stake.Kind == LifeSagaFactKind.RoleChange
&& RoleChangeEchoesIdentity(stake, identityLine))
{
return true;
}
// "Founder" in Identity already carries the founding fact; repeating
// "Founded kingdom" immediately below it adds no new information.
if (stake.Kind == LifeSagaFactKind.Founding
&& identityLine.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
bool soft = tipMood == "love" || tipMood == "rest";
if (!soft)
{
return false;
}
switch (stake.Kind)
{
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.CombatEncounter:
case LifeSagaFactKind.HardArcCombat:
case LifeSagaFactKind.RoleChange:
case LifeSagaFactKind.Founding:
case LifeSagaFactKind.HomeGained:
case LifeSagaFactKind.HomeLost:
return true;
default:
return false;
}
}
private static bool RoleChangeEchoesIdentity(LifeSagaFact stake, string identityLine)
{
if (string.IsNullOrEmpty(identityLine))
{
return false;
}
string body = SagaProse.StakeLine(stake) ?? "";
if (string.IsNullOrEmpty(body))
{
return false;
}
// "Became Pack Alpha" under Identity "Name · Pack Alpha".
string lowerId = identityLine.ToLowerInvariant();
string lowerBody = body.ToLowerInvariant();
if (lowerBody.StartsWith("became ", StringComparison.Ordinal))
{
string role = lowerBody.Substring("became ".Length).Trim();
if (role.Length >= 4 && lowerId.IndexOf(role, StringComparison.Ordinal) >= 0)
{
return true;
}
}
string[] roleTokens =
{
"king", "queen", "leader", "alpha", "chief", "captain", "founder"
};
for (int i = 0; i < roleTokens.Length; i++)
{
string t = roleTokens[i];
if (lowerBody.IndexOf(t, StringComparison.Ordinal) >= 0
&& lowerId.IndexOf(t, StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
private static string TipMood(string beatLine, InterestCandidate tip)
{
string verb = SagaProse.ClassifyTipVerb(beatLine);
if (verb == "love" || verb == "rest" || verb == "grief" || verb == "combat" || verb == "plot")
{
return verb;
}
if (tip != null && !string.IsNullOrEmpty(tip.StatusId))
{
string sid = tip.StatusId.ToLowerInvariant();
if (sid.IndexOf("pregnant", StringComparison.Ordinal) >= 0
|| sid.IndexOf("love", StringComparison.Ordinal) >= 0
|| sid.IndexOf("fell_in_love", StringComparison.Ordinal) >= 0)
{
return "love";
}
if (sid.IndexOf("sleep", StringComparison.Ordinal) >= 0
|| sid.IndexOf("dream", StringComparison.Ordinal) >= 0
|| sid.IndexOf("afterglow", StringComparison.Ordinal) >= 0)
{
return "rest";
}
}
return verb;
}
private static bool BeatCoversStake(string beat, LifeSagaFact stake)
{
if (string.IsNullOrEmpty(beat) || stake == null)
{
return false;
}
if (stake.OtherId != 0 && !string.IsNullOrEmpty(stake.Other.Name)
&& beat.IndexOf(stake.Other.Name, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
string stakeText = SagaProse.StakeLine(stake);
if (!string.IsNullOrEmpty(stakeText)
&& beat.IndexOf(stakeText, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
// Rematch clash clause already teaches the feud.
if (beat.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0
&& stake.Kind == LifeSagaFactKind.RivalEarned)
{
return true;
}
return false;
}
}
public sealed class CaptionModel
{
public string IdentityLine = "";
public string ConnectionLine = "";
public string BeatLine = "";
public string ContextLine = "";
public long ReasonRelatedId;
public string Fingerprint = "";
}