- Implement checks for civilized terminology in camera focus events - Update event handling to suppress inappropriate pack language for civilized characters - Enhance narrative presentation to differentiate between family and pack language - Introduce new scenarios to validate significance of beats and relationships - Improve documentation to clarify narrative structure and event significance
393 lines
13 KiB
C#
393 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Adds a concise, evidence-backed explanation when an exact event participant matters
|
|
/// because of a current MC. Generic identity (species/job/traits) never belongs in the Beat.
|
|
/// </summary>
|
|
public static class BeatSignificance
|
|
{
|
|
private static readonly List<LifeSagaSlot> Slots =
|
|
new List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
|
|
|
/// <summary>
|
|
/// Character grounding is footage, not an event. Its live task already appears in the
|
|
/// green task chip, so it must never manufacture an orange Beat.
|
|
/// </summary>
|
|
public static bool SuppressBeat(InterestCandidate candidate)
|
|
{
|
|
return candidate != null
|
|
&& (candidate.LeadKind == InterestLeadKind.CharacterLed
|
|
|| candidate.Completion == InterestCompletionKind.CharacterVignette);
|
|
}
|
|
|
|
public static string Enrich(
|
|
Actor focus,
|
|
InterestCandidate candidate,
|
|
string beat)
|
|
{
|
|
if (string.IsNullOrEmpty(beat) || candidate == null || SuppressBeat(candidate))
|
|
{
|
|
return SuppressBeat(candidate) ? "" : beat ?? "";
|
|
}
|
|
|
|
long focusId = EventFeedUtil.SafeId(focus);
|
|
long participantId = ExactOtherParticipant(candidate, focusId);
|
|
if (participantId == 0
|
|
|| participantId == focusId
|
|
|| LifeSagaRoster.IsMc(participantId)
|
|
|| SemanticsAlreadyExplainRelation(candidate, beat))
|
|
{
|
|
return beat;
|
|
}
|
|
|
|
string participantName = ExactOtherName(candidate, participantId);
|
|
if (string.IsNullOrEmpty(participantName)
|
|
|| !ContainsWholeName(StripRichText(beat), participantName))
|
|
{
|
|
return beat;
|
|
}
|
|
|
|
if (!TryDescribeMcRelation(
|
|
participantId,
|
|
out long anchorMcId,
|
|
out string anchorName,
|
|
out RelationEvidence relation))
|
|
{
|
|
return beat;
|
|
}
|
|
|
|
string qualifier = FormatQualifier(relation, anchorName, colorName: true);
|
|
if (string.IsNullOrEmpty(qualifier)
|
|
|| StripRichText(beat).IndexOf(
|
|
StripRichText(qualifier),
|
|
StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return beat;
|
|
}
|
|
|
|
// Preserve click ownership on the event's named participant. The qualifier explains
|
|
// significance but does not replace the event's subject/other ids.
|
|
return beat.TrimEnd() + " (" + qualifier + ")";
|
|
}
|
|
|
|
private static long ExactOtherParticipant(InterestCandidate candidate, long focusId)
|
|
{
|
|
if (candidate?.NarrativePresentation != null)
|
|
{
|
|
long semanticOther = candidate.NarrativePresentation.OtherId;
|
|
if (semanticOther != 0 && semanticOther != focusId)
|
|
{
|
|
return semanticOther;
|
|
}
|
|
}
|
|
|
|
if (candidate == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
long[] exact =
|
|
{
|
|
candidate.RelatedId,
|
|
EventFeedUtil.SafeId(candidate.RelatedUnit),
|
|
candidate.PairOwnerId == focusId ? candidate.PairPartnerId : 0,
|
|
candidate.PairPartnerId == focusId ? candidate.PairOwnerId : 0
|
|
};
|
|
for (int i = 0; i < exact.Length; i++)
|
|
{
|
|
if (exact[i] != 0 && exact[i] != focusId)
|
|
{
|
|
return exact[i];
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static string ExactOtherName(InterestCandidate candidate, long participantId)
|
|
{
|
|
NarrativePresentationModel presentation = candidate?.NarrativePresentation;
|
|
if (presentation != null
|
|
&& presentation.OtherId == participantId
|
|
&& !string.IsNullOrEmpty(presentation.Other.Name))
|
|
{
|
|
return presentation.Other.Name.Trim();
|
|
}
|
|
|
|
if (candidate?.RelatedUnit != null
|
|
&& EventFeedUtil.SafeId(candidate.RelatedUnit) == participantId)
|
|
{
|
|
string related = EventFeedUtil.SafeName(candidate.RelatedUnit);
|
|
if (!string.IsNullOrEmpty(related))
|
|
{
|
|
return related;
|
|
}
|
|
}
|
|
|
|
Actor live = EventFeedUtil.FindAliveById(participantId);
|
|
return EventFeedUtil.SafeName(live);
|
|
}
|
|
|
|
private static bool TryDescribeMcRelation(
|
|
long participantId,
|
|
out long anchorMcId,
|
|
out string anchorName,
|
|
out RelationEvidence relation)
|
|
{
|
|
anchorMcId = 0;
|
|
anchorName = "";
|
|
relation = RelationEvidence.None;
|
|
Actor participant = EventFeedUtil.FindAliveById(participantId);
|
|
|
|
Slots.Clear();
|
|
LifeSagaRoster.CopySlots(Slots);
|
|
int bestPriority = int.MaxValue;
|
|
for (int i = 0; i < Slots.Count; i++)
|
|
{
|
|
LifeSagaSlot slot = Slots[i];
|
|
if (slot == null || slot.UnitId == 0 || slot.UnitId == participantId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Actor mc = EventFeedUtil.FindAliveById(slot.UnitId);
|
|
RelationEvidence evidence =
|
|
SagaProse.ResolveRelation(slot.UnitId, participantId, mc, participant);
|
|
string name = !string.IsNullOrEmpty(slot.DisplayName)
|
|
? slot.DisplayName
|
|
: EventFeedUtil.SafeName(mc);
|
|
string candidateQualifier = FormatQualifier(evidence, name, colorName: false);
|
|
if (string.IsNullOrEmpty(candidateQualifier))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int priority = evidence.Priority > 0 ? evidence.Priority : 9;
|
|
if (anchorMcId != 0 && priority >= bestPriority)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
anchorMcId = slot.UnitId;
|
|
anchorName = name;
|
|
relation = evidence;
|
|
bestPriority = priority;
|
|
}
|
|
|
|
return anchorMcId != 0 && !string.IsNullOrEmpty(anchorName);
|
|
}
|
|
|
|
private static string FormatQualifier(
|
|
RelationEvidence relation,
|
|
string anchorName,
|
|
bool colorName)
|
|
{
|
|
if (relation == null
|
|
|| relation.Class == RelationClass.None
|
|
|| string.IsNullOrEmpty(anchorName))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string who = colorName ? ActivityProse.ColorName(anchorName) : anchorName;
|
|
switch (relation.Class)
|
|
{
|
|
case RelationClass.Bond:
|
|
return (relation.BondBroken ? "former partner of " : "partner of ") + who;
|
|
case RelationClass.Friend:
|
|
return (relation.BestFriend ? "best friend of " : "friend of ") + who;
|
|
case RelationClass.Kin:
|
|
switch (relation.KinKind)
|
|
{
|
|
case KinKind.Parent: return "parent of " + who;
|
|
case KinKind.Child: return "child of " + who;
|
|
case KinKind.Heir: return "heir of " + who;
|
|
case KinKind.Packmate: return "packmate of " + who;
|
|
default: return "kin of " + who;
|
|
}
|
|
case RelationClass.Foe:
|
|
switch (relation.FoeKind)
|
|
{
|
|
case FoeKind.Blood: return "blood foe of " + who;
|
|
case FoeKind.War: return "war enemy of " + who;
|
|
case FoeKind.Plot: return "plot enemy of " + who;
|
|
default: return "rival of " + who;
|
|
}
|
|
case RelationClass.PlotPartner:
|
|
return "plot ally of " + who;
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private static bool SemanticsAlreadyExplainRelation(
|
|
InterestCandidate candidate,
|
|
string beat)
|
|
{
|
|
NarrativePresentationModel presentation = candidate?.NarrativePresentation;
|
|
if (presentation != null
|
|
&& presentation.RelationRole != NarrativeRelationRole.None)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string plain = StripRichText(beat).ToLowerInvariant();
|
|
string[] relationWords =
|
|
{
|
|
"child", "parent", "lover", "partner", "friend", "kin",
|
|
"heir", "rival", "enemy", "foe", "ally"
|
|
};
|
|
for (int i = 0; i < relationWords.Length; i++)
|
|
{
|
|
if (plain.IndexOf(relationWords[i], StringComparison.Ordinal) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool ContainsWholeName(string text, string name)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int start = 0;
|
|
while (start <= text.Length - name.Length)
|
|
{
|
|
int at = text.IndexOf(name, start, StringComparison.OrdinalIgnoreCase);
|
|
if (at < 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool left = at == 0 || !IsNameChar(text[at - 1]);
|
|
int end = at + name.Length;
|
|
bool right = end >= text.Length || !IsNameChar(text[end]);
|
|
if (left && right)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
start = at + 1;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsNameChar(char value)
|
|
{
|
|
return char.IsLetterOrDigit(value) || value == '\'' || value == '-';
|
|
}
|
|
|
|
private static string StripRichText(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value) || value.IndexOf('<') < 0)
|
|
{
|
|
return value ?? "";
|
|
}
|
|
|
|
var chars = new System.Text.StringBuilder(value.Length);
|
|
bool tag = false;
|
|
for (int i = 0; i < value.Length; i++)
|
|
{
|
|
char ch = value[i];
|
|
if (ch == '<')
|
|
{
|
|
tag = true;
|
|
}
|
|
else if (ch == '>')
|
|
{
|
|
tag = false;
|
|
}
|
|
else if (!tag)
|
|
{
|
|
chars.Append(ch);
|
|
}
|
|
}
|
|
|
|
return chars.ToString();
|
|
}
|
|
|
|
public static bool HarnessProbePolicy(out string detail)
|
|
{
|
|
var fill = new InterestCandidate
|
|
{
|
|
LeadKind = InterestLeadKind.CharacterLed,
|
|
Completion = InterestCompletionKind.CharacterVignette,
|
|
Label = "Elf",
|
|
NarrativePresentation = new NarrativePresentationModel
|
|
{
|
|
Kind = NarrativePresentationKind.AuthoredObservation,
|
|
AuthoredClause = "Elf"
|
|
}
|
|
};
|
|
var child = new RelationEvidence
|
|
{
|
|
Class = RelationClass.Kin,
|
|
KinKind = KinKind.Child,
|
|
Priority = 4
|
|
};
|
|
var rival = new RelationEvidence
|
|
{
|
|
Class = RelationClass.Foe,
|
|
FoeKind = FoeKind.Rematch,
|
|
Priority = 2
|
|
};
|
|
|
|
bool fillSuppressed = SuppressBeat(fill);
|
|
string childQualifier = FormatQualifier(child, "Norron", colorName: false);
|
|
string rivalQualifier = FormatQualifier(rival, "Woof", colorName: false);
|
|
bool pass = fillSuppressed
|
|
&& childQualifier == "child of Norron"
|
|
&& rivalQualifier == "rival of Woof";
|
|
detail = "fillSuppressed=" + fillSuppressed
|
|
+ " child='" + childQualifier
|
|
+ "' rival='" + rivalQualifier
|
|
+ "' pass=" + pass;
|
|
return pass;
|
|
}
|
|
|
|
public static bool HarnessProbeLiveRelation(
|
|
Actor focus,
|
|
Actor participant,
|
|
out string detail)
|
|
{
|
|
long participantId = EventFeedUtil.SafeId(participant);
|
|
string participantName = EventFeedUtil.SafeName(participant);
|
|
var candidate = new InterestCandidate
|
|
{
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Completion = InterestCompletionKind.FixedDwell,
|
|
SubjectId = EventFeedUtil.SafeId(focus),
|
|
RelatedId = participantId,
|
|
RelatedUnit = participant,
|
|
NarrativePresentation = new NarrativePresentationModel
|
|
{
|
|
Kind = NarrativePresentationKind.AuthoredObservation,
|
|
PerspectiveId = EventFeedUtil.SafeId(focus),
|
|
Perspective = LifeSagaMemory.Snapshot(focus),
|
|
OtherId = participantId,
|
|
Other = LifeSagaMemory.Snapshot(participant),
|
|
AuthoredClause = "Kills " + participantName
|
|
}
|
|
};
|
|
|
|
string raw = "Kills " + participantName;
|
|
string enriched = Enrich(focus, candidate, raw);
|
|
string focusName = EventFeedUtil.SafeName(focus);
|
|
bool pass = focus != null
|
|
&& participant != null
|
|
&& LifeSagaRoster.IsMc(EventFeedUtil.SafeId(focus))
|
|
&& enriched.IndexOf("child of", StringComparison.OrdinalIgnoreCase) >= 0
|
|
&& enriched.IndexOf(focusName, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
detail = "raw='" + raw + "' enriched='" + enriched + "' pass=" + pass;
|
|
return pass;
|
|
}
|
|
}
|