using System; using System.Collections.Generic; 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; 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 ContextShownAt = new Dictionary(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; } /// /// 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); // 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; } /// /// 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. /// 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(); foreach (KeyValuePair 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; } /// /// 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 ConnectionLine = ""; public string BeatLine = ""; public string ContextLine = ""; public long ReasonRelatedId; public string Fingerprint = ""; }