- Compose Identity, Beat, and Context without mutating camera labels - Move Cast and Legacy details into a hover-only panel - Centralize relation, title, stake, and legacy prose - Make rail clicks toggle Prefer without pinning a Saga subject - Add harness coverage for caption composition and hover behavior
539 lines
17 KiB
C#
539 lines
17 KiB
C#
using System;
|
|
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;
|
|
|
|
private static long _lastCaptionUnitId;
|
|
private static float _lastCaptionAt = -999f;
|
|
private static float _lastCutawayAt = -999f;
|
|
private static long _cutawayFromId;
|
|
private static string _lastFingerprint = "";
|
|
|
|
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 = "";
|
|
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.ReasonRelatedId = reasonRelatedId;
|
|
|
|
string beat = presentableBeat ?? "";
|
|
if (statusBannerOwnsBeat)
|
|
{
|
|
// Banner owns orange row; composer still fills Identity + Context.
|
|
model.BeatLine = "";
|
|
}
|
|
else
|
|
{
|
|
model.BeatLine = 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);
|
|
|
|
model.Fingerprint =
|
|
focusId + "|" + model.IdentityLine + "|" + 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;
|
|
}
|
|
|
|
// Ensemble tips: no cast-name injection; rematch clause OK when pair/related already named.
|
|
bool ensemble = SagaProse.IsEnsembleTip(beat);
|
|
Actor other = otherId != 0 ? EventFeedUtil.FindAliveById(otherId) : null;
|
|
if (other == null && otherId != 0)
|
|
{
|
|
other = EventFeedUtil.FindUnitById(otherId);
|
|
}
|
|
|
|
if (focusId != 0 && otherId != 0 && other != null)
|
|
{
|
|
RelationEvidence ev = SagaProse.ResolveRelation(focusId, otherId, focus, other);
|
|
string verb = SagaProse.ClassifyTipVerb(beat);
|
|
string clause = SagaProse.BeatClause(focus, other, ev, beat, verb);
|
|
if (!string.IsNullOrEmpty(clause))
|
|
{
|
|
// Skip weak clauses on pure ensemble without the other name in tip.
|
|
if (ensemble)
|
|
{
|
|
string otherName = EventFeedUtil.SafeName(other);
|
|
if (!string.IsNullOrEmpty(otherName)
|
|
&& beat.IndexOf(otherName, StringComparison.OrdinalIgnoreCase) < 0
|
|
&& tip != null
|
|
&& tip.PairPartnerId == 0
|
|
&& tip.RelatedId == 0)
|
|
{
|
|
clause = "";
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(clause)
|
|
&& result.IndexOf(clause, StringComparison.OrdinalIgnoreCase) < 0)
|
|
{
|
|
result = result.TrimEnd() + " · " + clause;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
// 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, beatLine, tipMood, identityLine ?? "");
|
|
if (stake == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return SagaProse.StakeLine(stake) ?? "";
|
|
}
|
|
|
|
private static LifeSagaFact PickHitchStake(
|
|
long focusId,
|
|
string beatLine,
|
|
string tipMood,
|
|
string identityLine)
|
|
{
|
|
LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId);
|
|
if (best != null
|
|
&& !BeatCoversStake(beatLine, best)
|
|
&& !ShouldOmitStakeHitch(best, tipMood, identityLine))
|
|
{
|
|
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))
|
|
{
|
|
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>
|
|
/// 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 BeatLine = "";
|
|
public string ContextLine = "";
|
|
public long ReasonRelatedId;
|
|
public string Fingerprint = "";
|
|
}
|