using System;
using UnityEngine;
namespace IdleSpectator;
///
/// Presentation-layer character beat: Identity + Beat + Context.
/// Does not mutate InterestCandidate.Label or weaken identity_filter.
///
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 ?? "";
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.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;
}
///
/// 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.
///
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;
}
///
/// Soft live tips (love/rest/dream) should not hitch closed biography
/// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may.
///
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 = "";
}