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 = ""; }