using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Owns short multi-beat story commitment above InterestDirector. /// Never calls MoveCamera - injects / boosts candidates and reports hold policy. /// Watching + parked short arcs resume when their tip returns; life-saga rail is separate. /// public static class StoryPlanner { private const int BoardCap = 4; private const float ParkedMaxSeconds = 180f; private static StoryArc _active; private static readonly List Parked = new List(BoardCap); private static CrisisChapter _crisis; private static float _crisisCooldownUntil; private static float _softFillQuietUntil; private static bool _coldHookFired; private static string _coldHookClimaxKey = ""; private static string _coldHookAnchor = ""; private static readonly List PendingScratch = new List(64); private static readonly List ActorScratch = new List(16); /// Currently watching storyline (not parked). public static StoryArc Active => _active != null && _active.IsActive && !_active.IsParked ? _active : null; /// Live crisis chapter overlay (war / disaster / outbreak), or null. public static CrisisChapter Crisis => _crisis != null && _crisis.IsLive ? _crisis : null; public static bool CrisisActive => Crisis != null; /// /// Soft-life crumbs and character-fill are suppressed briefly after a hard story/crisis end. /// public static bool SoftFillQuietActive => Time.unscaledTime < _softFillQuietUntil; /// Character-fill ambient is suppressed while a crisis chapter is live or soft-fill quiet. public static bool SuppressAmbientFill => CrisisActive || SoftFillQuietActive; /// Watching + parked arcs for dossier rail / harness (excludes Done). public static void CopyBoard(List into) { if (into == null) { return; } into.Clear(); StoryArc watch = Active; if (watch != null) { into.Add(watch); } for (int i = 0; i < Parked.Count; i++) { StoryArc p = Parked[i]; if (p != null && p.IsActive) { into.Add(p); } } } public static int BoardCount { get { int n = Active != null ? 1 : 0; for (int i = 0; i < Parked.Count; i++) { if (Parked[i] != null && Parked[i].IsActive) { n++; } } return n; } } /// /// Quiet dossier spine: "Duel · Aftermath". /// Empty when no live arc, or when the current watch tip is not that story beat. /// public static string FormatSpineLabel(InterestCandidate watch = null, StoryArc arc = null) { watch = watch ?? InterestDirector.CurrentCandidate; // Crisis closer is not a short-arc spine beat. if (IsCrisisEpilogueCandidate(watch)) { return ""; } // Sticky combat can reframe Battle↔Duel without SwitchTo - keep Kind honest first. SyncActiveClimax(watch); arc = arc ?? Active; if (arc == null || !arc.IsActive || !BelongsToActiveStoryBeat(watch, arc)) { return ""; } string kind = FormatKindLabel(arc.Kind); // Combat Kind word from sticky/live scale (not tip Label prose). string scaleKind = CombatSpineKindFromScale(watch, arc.Kind); if (!string.IsNullOrEmpty(scaleKind)) { kind = scaleKind; } string phase = FormatPhaseLabel(arc.Phase); if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase)) { return ""; } return kind + " · " + phase; } /// /// Spine Kind word from sticky/live ensemble scale (Duel/Skirmish/Battle/Mass). /// Empty when not a combat arc. /// private static string CombatSpineKindFromScale(InterestCandidate watch, StoryArcKind arcKind) { if (arcKind == StoryArcKind.CombatDuel) { return "Duel"; } if (arcKind != StoryArcKind.CombatMass) { return ""; } EnsembleScale scale = EnsembleScale.Skirmish; if (watch?.Sticky != null && watch.Sticky.HasPresentedScale) { scale = watch.Sticky.PresentedScale; } else if (watch != null) { int fighters = Mathf.Max( watch.ParticipantCount, watch.Sticky != null ? watch.Sticky.TotalCount : 0); scale = LiveEnsemble.ScaleForCount(Mathf.Max(1, fighters)); } switch (scale) { case EnsembleScale.Pair: return "Duel"; case EnsembleScale.Skirmish: return "Skirmish"; case EnsembleScale.Battle: return "Battle"; default: return "Mass"; } } /// /// When the current combat tip changes scale in place (no director SwitchTo), /// retarget the active climax Kind (Mass ↔ Duel) so the spine matches the tip. /// public static void SyncActiveClimax(InterestCandidate current = null) { current = current ?? InterestDirector.CurrentCandidate; if (_active == null || !_active.IsActive || _active.Phase != StoryPhase.Climax || current == null) { return; } if (!TryClassifyClimax(current, out StoryArcKind kind, out bool hardHold)) { return; } string anchor = AnchorFor(current, kind); bool sameAnchor = string.Equals(_active.AnchorKey, anchor, StringComparison.Ordinal); bool sameCombatTheater = IsCombatKind(_active.Kind) && IsCombatKind(kind) && SameCombatTheater(_active, current); if (!sameAnchor && !sameCombatTheater) { return; } if (_active.Kind != kind) { _active.Kind = kind; _active.Id = "arc:" + kind + ":" + (_active.AnchorKey ?? anchor); } if (!string.IsNullOrEmpty(anchor) && (sameAnchor || sameCombatTheater)) { _active.AnchorKey = anchor; } if (!string.IsNullOrEmpty(current.Key)) { _active.ClimaxKey = current.Key; } _active.HardHold = hardHold || _active.HardHold; NoteCastFrom(current, _active); StampHeadline(_active, current); } private static bool IsCombatKind(StoryArcKind kind) => kind == StoryArcKind.CombatDuel || kind == StoryArcKind.CombatMass; private static bool SameCombatTheater(StoryArc arc, InterestCandidate c) { if (arc == null || c == null) { return false; } if (!string.IsNullOrEmpty(arc.ClimaxKey) && string.Equals(arc.ClimaxKey, c.Key, StringComparison.Ordinal)) { return true; } if (c.PairOwnerId != 0 && c.PairPartnerId != 0) { string pair = EventCatalog.Combat.PairKey(c.PairOwnerId, c.PairPartnerId); if (!string.IsNullOrEmpty(arc.AnchorKey) && string.Equals(arc.AnchorKey, pair, StringComparison.Ordinal)) { return true; } } return arc.ContainsCast(c.SubjectId) && (c.RelatedId == 0 || arc.ContainsCast(c.RelatedId)); } /// /// True when is the active arc's climax tip or story aftermath/epilogue beat. /// Cast membership alone is not enough (avoids stale Love/Duel spines on sleep/hatch tips). /// public static bool BelongsToActiveStoryBeat(InterestCandidate c, StoryArc arc = null) { arc = arc ?? Active; if (c == null || arc == null || !arc.IsActive) { return false; } if (IsStoryAssetCandidate(c)) { // Aftermath/epilogue tips only while the arc is in those phases, // and only for THIS arc (CorrelationKey) - never a stale peer story tip. if (arc.Phase != StoryPhase.Aftermath && arc.Phase != StoryPhase.Epilogue) { return false; } if (string.IsNullOrEmpty(arc.AnchorKey)) { return false; } string expect = "story:" + arc.AnchorKey; return string.Equals(c.CorrelationKey, expect, StringComparison.Ordinal); } // Cold climax tip must not show "Mass · Aftermath" while fleeing after inject. if (!string.IsNullOrEmpty(arc.ClimaxKey) && string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal)) { return arc.Phase == StoryPhase.Climax; } // Same sticky theater refreshed under the same anchor while still in climax. // Kind may still be syncing Mass↔Duel - allow either combat kind on the same theater. if (arc.Phase == StoryPhase.Climax && TryClassifyClimax(c, out StoryArcKind kind, out _) && IsCombatKind(arc.Kind) && IsCombatKind(kind) && (kind == arc.Kind || SameCombatTheater(arc, c) || string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal))) { return true; } if (arc.Phase == StoryPhase.Climax && TryClassifyClimax(c, out kind, out _) && kind == arc.Kind && string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal)) { return true; } return false; } public static string FormatKindLabel(StoryArcKind kind) { switch (kind) { case StoryArcKind.CombatDuel: return "Duel"; case StoryArcKind.CombatMass: return "Mass"; case StoryArcKind.WarFront: return "War front"; case StoryArcKind.Plot: return "Plot"; case StoryArcKind.Love: return "Love"; case StoryArcKind.Grief: return "Grief"; default: return ""; } } public static string FormatPhaseLabel(StoryPhase phase) { switch (phase) { case StoryPhase.Climax: return "Climax"; case StoryPhase.Aftermath: return "Aftermath"; case StoryPhase.Epilogue: return "Epilogue"; default: return ""; } } public static void Clear() { _active = null; Parked.Clear(); _crisis = null; _crisisCooldownUntil = 0f; _softFillQuietUntil = 0f; _coldHookFired = false; _coldHookClimaxKey = ""; _coldHookAnchor = ""; // LifeSagaRoster survives short-arc Clear (browse / brief idle-off). } /// /// Drop live crisis + pending crisis closers without wiping the storyline board. /// Used at harness batch end so free AFK does not inherit a fake crisis ending. /// public static void PurgeCloserLeftovers() { float now = Time.unscaledTime; if (_crisis != null) { _crisis.Advance(CrisisPhase.Done, now); _crisis = null; } InterestRegistry.ForceExpireContaining("epilogue:crisis", now); InterestRegistry.ForceExpireContaining(StoryReason.EpilogueCrisis, now); InterestCandidate cur = InterestDirector.CurrentCandidate; if (cur != null && IsCrisisEpilogueCandidate(cur)) { InterestDirector.HarnessEndCurrent("purge_closer"); } } /// Arm soft-fill quiet after a hard story/crisis beat ends. public static void NoteHardStoryQuiet(float now = -1f) { if (now < 0f) { now = Time.unscaledTime; } StoryWeights s = InterestScoringConfig.Story; float quiet = s.softFillQuietSeconds > 0f ? s.softFillQuietSeconds : 18f; _softFillQuietUntil = Mathf.Max(_softFillQuietUntil, now + quiet); } /// /// Bounded quiet refresh after a soft crumb ends so AFK does not immediately re-surf. /// Caps total quiet at 1.5x the base window from now. /// public static void NoteSoftCrumbQuietRefresh(float now = -1f) { if (now < 0f) { now = Time.unscaledTime; } StoryWeights s = InterestScoringConfig.Story; float quiet = s.softFillQuietSeconds > 0f ? s.softFillQuietSeconds : 18f; float refresh = quiet * 0.35f; float capUntil = now + quiet * 1.5f; float next = Mathf.Max(_softFillQuietUntil, now + refresh); _softFillQuietUntil = Mathf.Min(next, capUntil); } public static void Tick(float now) { TickCrisis(now); ExpireParked(now); if (_active == null || !_active.IsActive) { return; } SyncActiveClimax(InterestDirector.CurrentCandidate); // Stale arcs without a living cast eventually end. if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue) { if (now - _active.PhaseStartedAt > 90f) { EndArc(_active, now); } } } /// Promote a parked short arc to watching (harness / natural tip return). public static bool TryResume(string arcId) { if (string.IsNullOrEmpty(arcId)) { return false; } for (int i = 0; i < Parked.Count; i++) { StoryArc p = Parked[i]; if (p == null || !string.Equals(p.Id, arcId, StringComparison.Ordinal)) { continue; } if (!p.IsActive) { Parked.RemoveAt(i); return false; } float now = Time.unscaledTime; if (_active != null && _active.IsActive && !string.Equals(_active.Id, p.Id, StringComparison.Ordinal)) { ParkWatching(now); } Parked.RemoveAt(i); p.ParkedAt = 0f; _active = p; return true; } return Active != null && string.Equals(Active.Id, arcId, StringComparison.Ordinal); } private static StoryArc FindBoardArc(string arcId) { if (string.IsNullOrEmpty(arcId)) { return null; } if (_active != null && _active.IsActive && string.Equals(_active.Id, arcId, StringComparison.Ordinal)) { return _active; } for (int i = 0; i < Parked.Count; i++) { StoryArc p = Parked[i]; if (p != null && string.Equals(p.Id, arcId, StringComparison.Ordinal)) { return p; } } return null; } /// /// Resolve a synthetic short-story beat to the one board arc that authored it. /// Correlation is the ownership boundary: cast overlap, labels, and partial keys /// must never let a parked arc's closer mutate the currently watched arc. /// private static StoryArc ResolveStoryArc(InterestCandidate candidate) { if (!IsStoryAssetCandidate(candidate) || IsCrisisEpilogueCandidate(candidate)) { return null; } string correlation = (candidate.CorrelationKey ?? "").Trim(); if (string.IsNullOrEmpty(correlation)) { return null; } if (_active != null && StoryArcMatchesCorrelation(_active, correlation)) { return _active; } for (int i = 0; i < Parked.Count; i++) { StoryArc parked = Parked[i]; if (parked != null && StoryArcMatchesCorrelation(parked, correlation)) { return parked; } } return null; } private static bool StoryArcMatchesCorrelation(StoryArc arc, string correlation) { if (arc == null || !arc.IsActive || string.IsNullOrEmpty(arc.AnchorKey)) { return false; } return string.Equals("story:" + arc.AnchorKey, correlation, StringComparison.Ordinal); } /// /// Selection defense-in-depth for expired/orphaned synthetic story beats. /// Crisis closers use their crisis chapter instead of the short-arc board. /// public static bool HasLiveStoryOwner(InterestCandidate candidate) { if (!IsStoryAssetCandidate(candidate)) { return true; } if (IsCrisisEpilogueCandidate(candidate)) { return MatchesCrisis(candidate); } return ResolveStoryArc(candidate) != null; } private static bool ResumeResolvedArc(StoryArc arc, float now) { if (arc == null || !arc.IsActive) { return false; } if (ReferenceEquals(_active, arc)) { arc.ParkedAt = 0f; return true; } int parkedAt = Parked.IndexOf(arc); if (parkedAt < 0) { return false; } if (_active != null && _active.IsActive) { ParkWatching(now); } // ParkWatching can insert the previous active arc at the front, so resolve // the target index again before removing it. parkedAt = Parked.IndexOf(arc); if (parkedAt < 0 || !arc.IsActive) { return false; } Parked.RemoveAt(parkedAt); arc.ParkedAt = 0f; _active = arc; return true; } private static void ParkWatching(float now) { if (_active == null || !_active.IsActive) { _active = null; return; } // Soft scraps end on cutaway (no board parking); hard storylines park. if (!_active.HardHold) { EndArc(_active, now); _active = null; return; } StoryArc parked = _active; _active = null; parked.ParkedAt = now; RemoveParkedById(parked.Id); Parked.Insert(0, parked); TrimParkedBoard(); } private static void EndArc(StoryArc arc, float now) { if (arc == null) { return; } StoryArcKind kind = arc.Kind; arc.Advance(StoryPhase.Done, now); arc.ParkedAt = 0f; if (IsHardStoryKind(kind)) { NoteHardStoryQuiet(now); } } private static bool IsHardStoryKind(StoryArcKind kind) => kind == StoryArcKind.CombatDuel || kind == StoryArcKind.CombatMass || kind == StoryArcKind.WarFront || kind == StoryArcKind.Plot || kind == StoryArcKind.Love || kind == StoryArcKind.Grief; private static void ExpireParked(float now) { for (int i = Parked.Count - 1; i >= 0; i--) { StoryArc p = Parked[i]; if (p == null || !p.IsActive) { Parked.RemoveAt(i); continue; } if (p.ParkedAt > 0f && now - p.ParkedAt > ParkedMaxSeconds) { EndArc(p, now); Parked.RemoveAt(i); } } } private static void RemoveParkedById(string id) { if (string.IsNullOrEmpty(id)) { return; } for (int i = Parked.Count - 1; i >= 0; i--) { if (Parked[i] != null && string.Equals(Parked[i].Id, id, StringComparison.Ordinal)) { Parked.RemoveAt(i); } } } private static void TrimParkedBoard() { // Watching + parked <= BoardCap. Prefer dropping anonymous scraps over Prefer/MC/cast arcs. int watching = _active != null && _active.IsActive ? 1 : 0; while (Parked.Count + watching > BoardCap && Parked.Count > 0) { int dropAt = 0; int worst = int.MaxValue; for (int i = 0; i < Parked.Count; i++) { int keep = ParkedArcKeepScore(Parked[i]); if (keep <= worst) { worst = keep; dropAt = i; } } StoryArc drop = Parked[dropAt]; Parked.RemoveAt(dropAt); if (drop != null) { EndArc(drop, Time.unscaledTime); } } } /// Prefer=3, MC=2, MC cast=1, else 0 for parked-board trim. private static int ParkedArcKeepScore(StoryArc arc) { if (arc?.CastIds == null || arc.CastIds.Count == 0) { return 0; } int best = 0; for (int i = 0; i < arc.CastIds.Count; i++) { long id = arc.CastIds[i]; if (id == 0) { continue; } if (LifeSagaRoster.IsPrefer(id)) { best = Mathf.Max(best, 3); } else if (LifeSagaRoster.IsMc(id)) { best = Mathf.Max(best, 2); } else if (LifeSagaRoster.IsMcCast(id)) { best = Mathf.Max(best, 1); } } return best; } public static void OnSwitchTo(InterestCandidate next, float now) { if (next == null) { return; } LifeSagaRoster.NoteFeatured(next); if (IsStoryAssetCandidate(next)) { if (IsCrisisEpilogueCandidate(next) && _crisis != null && _crisis.IsLive) { _crisis.Advance(CrisisPhase.Epilogue, now); // Crisis closer is not a short-arc beat - end watching combat arc so // dossier spine / cast state cannot linger under the closer. if (_active != null && _active.IsActive) { EndArc(_active, now); _active = null; } return; } StoryArc owner = ResolveStoryArc(next); if (owner == null || !ResumeResolvedArc(owner, now)) { // An expired/orphaned closer may still reach this defense if it was // selected in the same frame its board arc ended. Never mutate a peer arc. return; } if (_active != null && _active.IsActive && ReferenceEquals(_active, owner)) { if (string.Equals(next.AssetId, StoryReason.EpilogueRelated, StringComparison.OrdinalIgnoreCase) || next.Key.StartsWith("epilogue:", StringComparison.OrdinalIgnoreCase)) { _active.Advance(StoryPhase.Epilogue, now); } else { _active.Advance(StoryPhase.Aftermath, now); } NoteCastFrom(next, _active); } return; } NoteCrisisFromCandidate(next, now); if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold)) { BeginOrRefreshArc(next, kind, hardHold, now); return; } // Camera left the story for an unrelated tip - park hard storylines; end soft scraps. if (_active != null && _active.IsActive && !BelongsToActiveStoryBeat(next, _active)) { ParkWatching(now); } } public static void OnEndCurrent(InterestCandidate ended, float now) { if (ended == null) { return; } // Crisis chapters often have no soft StoryArc - still close when the closer tip ends. if (IsCrisisEpilogueCandidate(ended) && _crisis != null && _crisis.IsLive) { CloseCrisis(now); } if (_active == null || !_active.IsActive) { return; } // follow_lost / max_cap often end a soft duel before quiet-grace cold. // Match by theater (Mass↔Duel key flip), not exact ClimaxKey only. if (_active.Phase == StoryPhase.Climax && BelongsToClimaxTheater(ended, _active)) { OnClimaxCold(ended); } if (IsStoryAssetCandidate(ended)) { StoryArc owner = ResolveStoryArc(ended); if (!ReferenceEquals(owner, _active)) { return; } if (_active.Phase == StoryPhase.Aftermath) { TryInjectEpilogue(_active, now); } else if (_active.Phase == StoryPhase.Epilogue) { EndArc(_active, now); _active = null; } } } /// /// Called when a climax scene first goes inactive (entering quiet grace). /// public static void OnClimaxCold(InterestCandidate climax) { if (climax == null) { return; } float now = Time.unscaledTime; // Sticky combat can reframe keys in place before cold - keep arc/climax aligned. if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax) { SyncActiveClimax(climax); } string climaxKey = climax.Key ?? ""; string anchor = _active != null && _active.IsActive ? (_active.AnchorKey ?? "") : ""; if (string.IsNullOrEmpty(anchor) && TryClassifyClimax(climax, out StoryArcKind classifyKind, out _)) { anchor = AnchorFor(climax, classifyKind); } // Once per climax theater (anchor), not per sticky key refresh. if (_coldHookFired && (!string.IsNullOrEmpty(_coldHookAnchor) && string.Equals(_coldHookAnchor, anchor, StringComparison.Ordinal) || string.Equals(_coldHookClimaxKey, climaxKey, StringComparison.Ordinal))) { return; } if (!TryClassifyClimax(climax, out StoryArcKind kind, out bool hardHold) && (_active == null || !_active.IsActive)) { return; } if (_active == null || !_active.IsActive || !BelongsToClimaxTheater(climax, _active)) { if (!TryClassifyClimax(climax, out kind, out hardHold)) { return; } BeginOrRefreshArc(climax, kind, hardHold, now); anchor = _active != null ? (_active.AnchorKey ?? anchor) : anchor; } else if (!string.IsNullOrEmpty(climaxKey)) { _active.ClimaxKey = climaxKey; if (!string.IsNullOrEmpty(_active.AnchorKey)) { anchor = _active.AnchorKey; } } _coldHookFired = true; _coldHookClimaxKey = !string.IsNullOrEmpty(climaxKey) ? climaxKey : (_active != null ? (_active.ClimaxKey ?? "") : ""); _coldHookAnchor = anchor ?? ""; if (TryClaimNaturalGrief(climax, _active)) { _active.Advance(StoryPhase.Aftermath, now); return; } // Grief climax is already the aftermath beat - do not synthesize a twin. if (_active.Kind == StoryArcKind.Grief) { EndArc(_active, now); _active = null; return; } if (HasStrongerPendingPeer(climax)) { // Natural grief/love aftermath already queued - do not synthesize a twin. // End climax ownership so spine/hold cannot stick across the peer tip. if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax) { EndArc(_active, now); _active = null; } return; } if (TryInjectAftermath(climax, _active, now)) { _active.Advance(StoryPhase.Aftermath, now); // Short-arc theater linger already closes this crisis - do not also fire // epilogue_crisis into the next free-AFK soak (war_front 12v8 auto-opens crisis). if (_crisis != null && _crisis.IsLive && ((_crisis.Kind == CrisisKind.War && _active.Kind == StoryArcKind.WarFront) || MatchesCrisis(climax))) { CloseCrisis(now); } NoteHardStoryQuiet(now); return; } if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax) { EndArc(_active, now); _active = null; } } /// /// Same climax theater even when sticky combat refreshes the candidate key (Battle↔Duel). /// private static bool BelongsToClimaxTheater(InterestCandidate c, StoryArc arc) { if (c == null || arc == null || !arc.IsActive) { return false; } if (!string.IsNullOrEmpty(arc.ClimaxKey) && string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal)) { return true; } if (BelongsToActiveStoryBeat(c, arc)) { return true; } return IsCombatKind(arc.Kind) && SameCombatTheater(arc, c); } public static bool IsContinuationOf(InterestCandidate current, InterestCandidate next) { if (current == null || next == null || !IsStoryAssetCandidate(next)) { return false; } if (_active == null || !_active.IsActive || !ReferenceEquals(ResolveStoryArc(next), _active)) { return false; } if (!string.IsNullOrEmpty(_active.ClimaxKey) && string.Equals(current.Key, _active.ClimaxKey, StringComparison.Ordinal)) { return true; } return true; } public static bool OwnsCandidate(InterestCandidate c) { if (c == null || _active == null || !_active.IsActive) { return false; } if (IsStoryAssetCandidate(c)) { return ReferenceEquals(ResolveStoryArc(c), _active); } if (!string.IsNullOrEmpty(_active.ClimaxKey) && string.Equals(c.Key, _active.ClimaxKey, StringComparison.Ordinal)) { return true; } return false; } public static float OwnershipBoost(InterestCandidate c) { float crisisBoost = CrisisOwnershipBoost(c); StoryArc storyOwner = IsStoryAssetCandidate(c) ? ResolveStoryArc(c) : null; if (!OwnsCandidate(c) || _active == null || !_active.IsActive) { // A parked arc's correlated closer keeps a modest boost so it can validly // resume. Orphaned story assets receive no short-arc ownership at all. if (storyOwner != null) { return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost); } return crisisBoost; } if (!_active.HardHold) { if (IsStoryAssetCandidate(c)) { return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost); } return crisisBoost; } if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue) { return Mathf.Max(crisisBoost, InterestScoringConfig.Story.arcOwnershipBoost); } return crisisBoost; } /// Score boost for tips that belong to the live crisis chapter. public static float CrisisOwnershipBoost(InterestCandidate c) { if (!MatchesCrisis(c)) { return 0f; } StoryWeights s = InterestScoringConfig.Story; return s.crisisOwnershipBoost > 0f ? s.crisisOwnershipBoost : 22f; } /// /// True when is part of the live crisis (signal tip or crisis closer). /// public static bool MatchesCrisis(InterestCandidate c) { if (c == null || _crisis == null || !_crisis.IsLive) { return false; } if (IsCrisisEpilogueCandidate(c)) { return true; } if (_crisis.Phase == CrisisPhase.Epilogue) { return false; } // Live WarFront tip always belongs to an open war crisis (even below enter threshold). if (_crisis.Kind == CrisisKind.War && c.Completion == InterestCompletionKind.WarFront) { return true; } // Local Mass/Battle on the war's kingdom pair keeps the war chapter warm / owned // so WarFront ↔ Mass thrash cannot starve LastSignalAt. if (_crisis.Kind == CrisisKind.War && c.Completion == InterestCompletionKind.CombatActive && StickyScoreboard.SharesKingdomTheater(c, _crisis.TheaterKeyA, _crisis.TheaterKeyB)) { return true; } // Disaster family: any live disaster/quake tip keeps the chapter warm. if (_crisis.Kind == CrisisKind.Disaster && IsDisasterCrisisSignal(c)) { return true; } // Outbreak family: status outbreak tips keep the chapter warm. if (_crisis.Kind == CrisisKind.Outbreak && c.Completion == InterestCompletionKind.StatusOutbreak) { return true; } if (!TryClassifyCrisis(c, out CrisisKind kind, out string anchor, out _)) { return false; } if (kind != _crisis.Kind) { return false; } if (!string.IsNullOrEmpty(_crisis.AnchorKey) && !string.IsNullOrEmpty(anchor) && !string.Equals(_crisis.AnchorKey, anchor, StringComparison.Ordinal)) { // Same kind of crisis may refresh under a new theater key (war sides). // Still match by kind while Active so count refreshes keep ownership. return kind == CrisisKind.War || kind == CrisisKind.Disaster || kind == CrisisKind.Outbreak; } return true; } /// /// True when the orange reason is a StoryPlanner FixedDwell beat (aftermath / epilogue). /// Used to suppress stale combat task detail under story closers. /// public static bool IsStoryOwnedTip(InterestCandidate c) => IsStoryAssetCandidate(c) || IsCrisisEpilogueCandidate(c); /// /// Raised cut-in margin while Aftermath/Epilogue holds against unrelated peers. /// Soft combat arcs also block same-cast life noise until the closer tip is watched /// (soak/harness: eat inject must not starve "stands over" after a fallen duel). /// public static float ArcHoldMargin(InterestCandidate current, InterestCandidate next) { if (_active == null || !_active.IsActive) { return 0f; } // Hold only while watching aftermath/epilogue (active short chapter). No rail commit. if (_active.Phase != StoryPhase.Aftermath && _active.Phase != StoryPhase.Epilogue) { return 0f; } if (current == null || next == null) { return 0f; } if (OwnsCandidate(next) || IsContinuationOf(current, next)) { return 0f; } // Natural grief/love peers may still claim the closer. if (IsNaturalAftermathPeer(next, current)) { return 0f; } StoryWeights s = InterestScoringConfig.Story; float margin = s.arcHoldMargin > 0f ? s.arcHoldMargin : 50f; // Soft duel: pending aftermath must not lose to same-cast eat/hatch noise. if (!_active.HardHold) { if (OwnsCandidate(current) || BelongsToClimaxTheater(current, _active)) { return margin; } return 0f; } // Hard arcs: hold while the camera is still on the story beat. if (!BelongsToActiveStoryBeat(current, _active)) { return 0f; } return margin; } /// /// Highest pending EventStrength for same-subject non-story life tips (cast noise ceiling). /// private static float PendingCastLifeCeiling(long subjectId) { if (subjectId == 0) { return 0f; } float best = 0f; PendingScratch.Clear(); InterestRegistry.CopyPending(PendingScratch); for (int i = 0; i < PendingScratch.Count; i++) { InterestCandidate p = PendingScratch[i]; if (p == null || p.SubjectId != subjectId || IsStoryAssetCandidate(p)) { continue; } if (IsNaturalAftermathPeer(p, null)) { continue; } if (p.EventStrength > best) { best = p.EventStrength; } } return best; } /// /// Raised cut-in margin while watching a crisis tip against unrelated peers. /// Epic peers at/above crisisDisasterStrengthMin still cut freely. /// public static float CrisisHoldMargin(InterestCandidate current, InterestCandidate next) { if (_crisis == null || !_crisis.IsLive || current == null || next == null) { return 0f; } if (!MatchesCrisis(current) || MatchesCrisis(next)) { return 0f; } StoryWeights s = InterestScoringConfig.Story; float epicFloor = s.crisisDisasterStrengthMin > 0f ? s.crisisDisasterStrengthMin : 95f; if (next.EventStrength >= epicFloor) { return 0f; } return s.crisisHoldMargin > 0f ? s.crisisHoldMargin : 40f; } public static bool PreferOver(InterestCandidate a, InterestCandidate b) { if (a == null || b == null) { return false; } if (CrisisActive) { bool aCrisis = MatchesCrisis(a); bool bCrisis = MatchesCrisis(b); if (aCrisis && !bCrisis) { return true; } } if (_active == null || !_active.IsActive) { return false; } // Injected aftermath/epilogue must win quiet-grace picks over cast life noise // (hatch/food/sleep), even when those tips have higher raw EventStrength. StoryArc aStoryOwner = IsStoryAssetCandidate(a) ? ResolveStoryArc(a) : null; StoryArc bStoryOwner = IsStoryAssetCandidate(b) ? ResolveStoryArc(b) : null; if (IsStoryAssetCandidate(a) && ReferenceEquals(aStoryOwner, _active) && !IsStoryAssetCandidate(b) && (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue || _active.Phase == StoryPhase.Climax) && !IsNaturalAftermathPeer(b, InterestDirector.CurrentCandidate)) { return true; } bool aOwn = ReferenceEquals(aStoryOwner, _active) || OwnsCandidate(a); bool bOwn = ReferenceEquals(bStoryOwner, _active) || OwnsCandidate(b); return aOwn && !bOwn; } private static void BeginOrRefreshArc( InterestCandidate climax, StoryArcKind kind, bool hardHold, float now) { string anchor = AnchorFor(climax, kind); string id = "arc:" + kind + ":" + anchor; // Resume parked storyline with same anchor/kind instead of spawning a twin. for (int i = 0; i < Parked.Count; i++) { StoryArc p = Parked[i]; if (p == null || !p.IsActive) { continue; } if (string.Equals(p.AnchorKey, anchor, StringComparison.Ordinal) && p.Kind == kind) { if (_active != null && _active.IsActive && !string.Equals(_active.Id, p.Id, StringComparison.Ordinal)) { ParkWatching(now); } Parked.RemoveAt(i); p.ParkedAt = 0f; p.ClimaxKey = climax.Key ?? ""; p.HardHold = hardHold || p.HardHold; if (p.Phase == StoryPhase.None || p.Phase == StoryPhase.Done) { p.Advance(StoryPhase.Climax, now); } NoteCastFrom(climax, p); StampHeadline(p, climax); _active = p; _coldHookFired = false; _coldHookClimaxKey = ""; _coldHookAnchor = ""; return; } } if (_active != null && _active.IsActive && string.Equals(_active.AnchorKey, anchor, StringComparison.Ordinal) && _active.Kind == kind) { _active.ClimaxKey = climax.Key ?? ""; _active.HardHold = hardHold || _active.HardHold; NoteCastFrom(climax, _active); StampHeadline(_active, climax); if (_active.Phase == StoryPhase.None || _active.Phase == StoryPhase.Done) { _active.Advance(StoryPhase.Climax, now); } return; } // New climax while another hard story is watching - park the old one. if (_active != null && _active.IsActive) { ParkWatching(now); } _active = new StoryArc { Id = id, Kind = kind, AnchorKey = anchor, ClimaxKey = climax.Key ?? "", StartedAt = now, HardHold = hardHold }; _active.Advance(StoryPhase.Climax, now); NoteCastFrom(climax, _active); StampHeadline(_active, climax); _coldHookFired = false; _coldHookClimaxKey = ""; _coldHookAnchor = ""; TrimParkedBoard(); } private static void StampHeadline(StoryArc arc, InterestCandidate climax) { if (arc == null) { return; } string built = BuildStorylineHeadline(arc, climax); if (string.IsNullOrEmpty(built)) { return; } if (string.IsNullOrEmpty(arc.Headline) || IsRicherHeadline(built, arc.Headline)) { arc.Headline = built; } } /// /// Cast/theater identity from sticky sides, named pair, or living cast - not Kind words. /// private static string BuildStorylineHeadline(StoryArc arc, InterestCandidate c) { if (c?.Sticky != null) { string sideA = FirstNonEmpty(c.Sticky.SideADisplay, c.Sticky.SideAKingdom); string sideB = FirstNonEmpty(c.Sticky.SideBDisplay, c.Sticky.SideBKingdom); sideA = CleanSideName(sideA); sideB = CleanSideName(sideB); if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB) && !string.Equals(sideA, sideB, StringComparison.OrdinalIgnoreCase)) { int countA = c.CombatSideACount > 0 ? c.CombatSideACount : c.Sticky.SideACount; int countB = c.CombatSideBCount > 0 ? c.CombatSideBCount : c.Sticky.SideBCount; if (countA > 0 && countB > 0) { return sideA + " (" + countA + ") vs " + sideB + " (" + countB + ")"; } return sideA + " vs " + sideB; } if (!string.IsNullOrEmpty(sideA) && (arc == null || arc.Kind == StoryArcKind.CombatMass || c.Completion == InterestCompletionKind.FamilyPack || c.Completion == InterestCompletionKind.StatusOutbreak || c.Completion == InterestCompletionKind.PlotActive)) { return sideA; } } string nameA = ""; string nameB = ""; if (c != null) { Actor owner = c.FollowUnit; if (owner == null || !owner.isAlive()) { owner = EventFeedUtil.FindAliveById( c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId); } Actor partner = c.RelatedUnit; if (partner == null || !partner.isAlive()) { long partnerId = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId; partner = EventFeedUtil.FindAliveById(partnerId); if (partner == null && partnerId != 0) { partner = EventFeedUtil.FindUnitById(partnerId); } } nameA = EventFeedUtil.SafeName(owner); nameB = EventFeedUtil.SafeName(partner); if (!string.IsNullOrEmpty(nameA) && !string.IsNullOrEmpty(nameB) && !string.Equals(nameA, nameB, StringComparison.OrdinalIgnoreCase)) { if (arc != null && (arc.Kind == StoryArcKind.Love || arc.Kind == StoryArcKind.Grief)) { return nameA + " & " + nameB; } return nameA + " vs " + nameB; } if (!string.IsNullOrEmpty(nameA)) { return nameA; } } return FormatCastHeadline(arc); } private static string FormatCastHeadline(StoryArc arc) { if (arc == null || arc.CastIds.Count == 0) { return ""; } string first = ""; string second = ""; for (int i = 0; i < arc.CastIds.Count && string.IsNullOrEmpty(second); i++) { Actor a = EventFeedUtil.FindUnitById(arc.CastIds[i]); string n = EventFeedUtil.SafeName(a); if (string.IsNullOrEmpty(n)) { continue; } if (string.IsNullOrEmpty(first)) { first = n; } else if (!string.Equals(first, n, StringComparison.OrdinalIgnoreCase)) { second = n; } } if (string.IsNullOrEmpty(first)) { return ""; } if (string.IsNullOrEmpty(second)) { return first; } if (arc.Kind == StoryArcKind.Love || arc.Kind == StoryArcKind.Grief) { return first + " & " + second; } return first + " vs " + second; } private static bool IsRicherHeadline(string next, string prev) { if (string.IsNullOrEmpty(prev)) { return true; } if (string.IsNullOrEmpty(next)) { return false; } bool nextVs = next.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0 || next.IndexOf(" & ", StringComparison.OrdinalIgnoreCase) >= 0; bool prevVs = prev.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0 || prev.IndexOf(" & ", StringComparison.OrdinalIgnoreCase) >= 0; if (nextVs && !prevVs) { return true; } if (prevVs && !nextVs) { return false; } return next.Length > prev.Length; } private static string FirstNonEmpty(string a, string b) { if (!string.IsNullOrEmpty(a)) { return a.Trim(); } return string.IsNullOrEmpty(b) ? "" : b.Trim(); } private static string CleanSideName(string raw) { if (string.IsNullOrEmpty(raw)) { return ""; } string s = raw.Trim(); // Drop trailing empty count markers from ensemble display. if (s.EndsWith(" ()", StringComparison.Ordinal)) { s = s.Substring(0, s.Length - 3).Trim(); } // Sticky keys sometimes land lowercase ("humans"); title-case short tokens for the rail. if (s.Length > 0 && s.Length <= 24 && IsAllLowerOrDigits(s)) { char[] chars = s.ToCharArray(); chars[0] = char.ToUpperInvariant(chars[0]); for (int i = 1; i < chars.Length; i++) { if (chars[i - 1] == ' ' || chars[i - 1] == '-' || chars[i - 1] == '_') { chars[i] = char.ToUpperInvariant(chars[i]); } } s = new string(chars).Replace('_', ' '); } return s; } private static bool IsAllLowerOrDigits(string s) { bool hasLetter = false; for (int i = 0; i < s.Length; i++) { char c = s[i]; if (char.IsLetter(c)) { hasLetter = true; if (char.IsUpper(c)) { return false; } } } return hasLetter; } private static bool TryClassifyClimax( InterestCandidate c, out StoryArcKind kind, out bool hardHold) { kind = StoryArcKind.None; hardHold = false; if (c == null || IsStoryAssetCandidate(c)) { return false; } if (c.Completion == InterestCompletionKind.WarFront || string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase)) { kind = StoryArcKind.WarFront; hardHold = true; return true; } if (c.Completion == InterestCompletionKind.PlotActive || string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase)) { kind = StoryArcKind.Plot; hardHold = true; return true; } if (c.Completion == InterestCompletionKind.HappinessGrief || (!string.IsNullOrEmpty(c.HappinessEffectId) && c.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase))) { kind = StoryArcKind.Grief; hardHold = true; return true; } if (IsLoveBeat(c)) { kind = StoryArcKind.Love; hardHold = true; return true; } if (c.Completion == InterestCompletionKind.CombatActive || string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) || string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase)) { bool mass = c.Sticky != null && c.Sticky.HasPresentedScale && c.Sticky.PresentedScale >= EnsembleScale.Skirmish; int fighters = Mathf.Max(c.ParticipantCount, c.Sticky != null ? c.Sticky.TotalCount : 0); if (!mass && fighters > InterestScoringConfig.W.duelMaxFighters) { mass = true; } bool notablePair = c.NotableParticipantCount >= 2; if (mass) { kind = StoryArcKind.CombatMass; hardHold = true; } else { kind = StoryArcKind.CombatDuel; // Soft anonymous / single-notable duels: aftermath OK, no hard arc hold. hardHold = notablePair; } return true; } return false; } /// /// When a story aftermath/epilogue Follow dies, promote another living cast member /// so the beat is not dropped as follow_lost. /// public static bool TryRecoverStoryFollow(InterestCandidate scene) { if (scene == null || !IsStoryAssetCandidate(scene)) { return false; } if (scene.HasFollowUnit) { return true; } StoryArc arc = Active; Actor next = null; if (arc != null && arc.IsActive) { ActorScratch.Clear(); for (int i = 0; i < arc.CastIds.Count; i++) { Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]); if (a != null) { ActorScratch.Add(a); } } // Prefer/MC among living arc cast only - never pull MCs outside the theater. next = LifeSagaRoster.PreferRosterUnit(ActorScratch); if (next == null && ActorScratch.Count > 0) { next = ActorScratch[0]; } } if (next == null && scene.RelatedUnit != null && scene.RelatedUnit.isAlive()) { next = scene.RelatedUnit; } if (next == null && scene.RelatedId != 0) { next = EventFeedUtil.FindAliveById(scene.RelatedId); } if (next == null) { return false; } scene.FollowUnit = next; scene.SubjectId = EventFeedUtil.SafeId(next); try { scene.Position = next.current_position; } catch { // keep } return scene.HasFollowUnit; } private static bool IsLoveBeat(InterestCandidate c) { if (c == null) { return false; } string asset = (c.AssetId ?? "").Trim().ToLowerInvariant(); // Outcome bonds only. Seeking / find_lover intent is camera-worthy but not a Love climax. if (asset == "set_lover" || asset == "fallen_in_love" || asset == "fell_in_love" || asset == AftermathLoveKey()) { return true; } if (asset == "find_lover" || asset == "clear_lover") { return false; } string hap = (c.HappinessEffectId ?? "").Trim().ToLowerInvariant(); if (hap == "fallen_in_love" || hap == "fell_in_love" || hap.IndexOf("married", StringComparison.Ordinal) >= 0) { return true; } // Death-of-lover grief is Grief, not Love. if (hap.StartsWith("death_", StringComparison.Ordinal)) { return false; } string cat = (c.Category ?? "").Trim(); return string.Equals(cat, "Relationship", StringComparison.OrdinalIgnoreCase) && asset == "set_lover"; } private static string AftermathLoveKey() => StoryReason.AftermathLoveLinger; private static string AnchorFor(InterestCandidate c, StoryArcKind kind) { if (c == null) { return "unknown"; } if (!string.IsNullOrEmpty(c.Key) && (c.Key.StartsWith("combat:pair:", StringComparison.OrdinalIgnoreCase) || c.Key.StartsWith("war:", StringComparison.OrdinalIgnoreCase) || c.Key.StartsWith("plot:", StringComparison.OrdinalIgnoreCase))) { return c.Key; } switch (kind) { case StoryArcKind.CombatDuel: case StoryArcKind.CombatMass: if (c.PairOwnerId != 0 && c.PairPartnerId != 0) { return EventCatalog.Combat.PairKey(c.PairOwnerId, c.PairPartnerId); } return !string.IsNullOrEmpty(c.Key) ? c.Key : "combat:" + c.SubjectId; case StoryArcKind.WarFront: return !string.IsNullOrEmpty(c.Key) ? c.Key : "war:" + c.SubjectId; case StoryArcKind.Plot: return !string.IsNullOrEmpty(c.Key) ? c.Key : "plot:" + c.SubjectId; case StoryArcKind.Love: long a = c.SubjectId; long b = c.RelatedId; if (a != 0 && b != 0) { long lo = a < b ? a : b; long hi = a < b ? b : a; return "love:" + lo + ":" + hi; } return "love:" + a; case StoryArcKind.Grief: return "grief:" + c.SubjectId + ":" + (c.HappinessEffectId ?? ""); default: return c.Key ?? ("story:" + c.SubjectId); } } private static void NoteCastFrom(InterestCandidate c, StoryArc arc) { if (c == null || arc == null) { return; } arc.NoteCast(c.SubjectId); arc.NoteCast(c.RelatedId); arc.NoteCast(c.PairOwnerId); arc.NoteCast(c.PairPartnerId); arc.NoteCast(c.TheaterLeadId); if (c.Sticky != null) { for (int i = 0; i < c.Sticky.SideAIds.Count && i < 6; i++) { arc.NoteCast(c.Sticky.SideAIds[i]); } for (int i = 0; i < c.Sticky.SideBIds.Count && i < 6; i++) { arc.NoteCast(c.Sticky.SideBIds[i]); } } } private static bool TryClaimNaturalGrief(InterestCandidate climax, StoryArc arc) { PendingScratch.Clear(); InterestRegistry.CopyPending(PendingScratch); InterestCandidate best = null; for (int i = 0; i < PendingScratch.Count; i++) { InterestCandidate p = PendingScratch[i]; if (p == null || p.LeadKind != InterestLeadKind.EventLed) { continue; } bool grief = p.Completion == InterestCompletionKind.HappinessGrief || (!string.IsNullOrEmpty(p.HappinessEffectId) && p.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase)); if (!grief) { continue; } if (arc != null && !arc.ContainsCast(p.SubjectId) && !arc.ContainsCast(p.RelatedId)) { // Still allow grief about climax cast deaths via RelatedId link to fallen. if (climax != null && p.RelatedId != climax.SubjectId && p.RelatedId != climax.RelatedId && p.SubjectId != climax.SubjectId) { continue; } } if (best == null || p.TotalScore > best.TotalScore) { best = p; } } if (best == null) { return false; } // Boost so grief wins quiet grace without a synthetic twin. StoryWeights s = InterestScoringConfig.Story; best.EventStrength = Mathf.Max(best.EventStrength, s.aftermathStrength); best.CorrelationKey = "story:" + (arc?.AnchorKey ?? ""); InterestScoring.RecalcTotal(best); if (arc != null) { NoteCastFrom(best, arc); arc.PendingBeats.Enqueue(new StoryBeat { Phase = StoryPhase.Aftermath, AssetId = best.AssetId, CandidateKey = best.Key, FollowId = best.SubjectId, RelatedId = best.RelatedId, EventStrength = best.EventStrength, MinWatch = best.MinWatch, MaxWatch = best.MaxWatch, SynthesizeIfMissing = false }); } return true; } private static bool HasStrongerPendingPeer(InterestCandidate climax) { PendingScratch.Clear(); InterestRegistry.CopyPending(PendingScratch); for (int i = 0; i < PendingScratch.Count; i++) { InterestCandidate p = PendingScratch[i]; if (p == null || p.LeadKind != InterestLeadKind.EventLed || IsStoryAssetCandidate(p)) { continue; } if (InterestScoring.IsFillScore(p.TotalScore)) { continue; } // Only true natural aftermath peers block synthesis - not hatch/food/village // cast noise that used to starve soft-duel linger tips. if (IsNaturalAftermathPeer(p, climax)) { return true; } } return false; } /// /// Grief / love-outcome tips that already finish the climax story for this cast. /// Ambient cast life (hatch, eat, sleep) must not suppress aftermath. /// private static bool IsNaturalAftermathPeer(InterestCandidate p, InterestCandidate climax) { if (p == null) { return false; } bool grief = p.Completion == InterestCompletionKind.HappinessGrief || (!string.IsNullOrEmpty(p.HappinessEffectId) && p.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase)); bool loveOutcome = IsLoveBeat(p); if (!grief && !loveOutcome) { return false; } if (climax == null && _active == null) { return true; } if (climax != null && (p.SubjectId == climax.SubjectId || p.SubjectId == climax.RelatedId || p.RelatedId == climax.SubjectId || p.RelatedId == climax.RelatedId || p.SubjectId == climax.PairOwnerId || p.SubjectId == climax.PairPartnerId || p.RelatedId == climax.PairOwnerId || p.RelatedId == climax.PairPartnerId)) { return true; } return _active != null && (_active.ContainsCast(p.SubjectId) || _active.ContainsCast(p.RelatedId)); } private static bool TryInjectAftermath(InterestCandidate climax, StoryArc arc, float now) { if (climax == null || arc == null) { return false; } Actor follow = ResolveAftermathFollow(climax); if (follow == null || !follow.isAlive()) { return false; } Actor related = ResolveAftermathRelated(climax, follow, arc.Kind); // Combat survivor aftermath requires a real fallen theater partner. // Attack-target gaps / sleep with both alive must not mint "stands over the fallen" // (soak: Norron vs Nerari thrash). Dead partners often clear RelatedUnit after kill - // still honor PairPartnerId / RelatedId when the unit is gone or confirmed dead. if (IsCombatKind(arc.Kind) && !CombatTheaterPartnerIsFallen(climax, related)) { return false; } string assetId = PickAftermathAsset(arc.Kind, related); StoryWeights s = InterestScoringConfig.Story; DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId); float strength = catalog != null && !catalog.IsFallback ? catalog.EventStrength : s.aftermathStrength; long followId = EventFeedUtil.SafeId(follow); long relatedId = EventFeedUtil.SafeId(related); if (relatedId == 0) { relatedId = climax.PairPartnerId != 0 ? climax.PairPartnerId : climax.RelatedId; } // Name fallen partners even when RelatedUnit was cleared after die(). Actor relatedForLabel = related; if (relatedForLabel == null && relatedId != 0) { relatedForLabel = EventFeedUtil.FindUnitById(relatedId); } string label = StoryReason.AftermathLabel(assetId, follow, relatedForLabel); // Beat pending same-cast life noise (eat/hatch injects) so soft-duel aftermath still lands. strength = Mathf.Max(strength, PendingCastLifeCeiling(followId) + 24f); // Camera RelatedUnit must be living; dead theater partners stay as RelatedId for cast/naming. Actor relatedLive = null; if (related != null) { try { if (related.isAlive()) { relatedLive = related; } } catch { relatedLive = null; } } if (IsCombatKind(arc.Kind)) { // Survivor tip never binds a living bystander as the "fallen" related. relatedLive = null; } // Unique per inject so a prior Selected aftermath cannot block the next cold. string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey + ":" + followId + ":" + Mathf.RoundToInt(now * 1000f); var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = "Story", Source = "story_planner", AssetId = assetId, Label = label, FollowUnit = follow, SubjectId = followId, RelatedUnit = relatedLive, RelatedId = relatedId, Position = follow.current_position, EventStrength = strength > 0f ? strength : s.aftermathStrength, VisualConfidence = 0.75f, Novelty = 1f, MinWatch = s.aftermathMinWatch > 0f ? s.aftermathMinWatch : 12f, MaxWatch = s.aftermathMaxWatch > 0f ? s.aftermathMaxWatch : 20f, Completion = InterestCompletionKind.FixedDwell, // Never park/resume - soak bounced "stands over the fallen" across life tips. Resumable = false, CorrelationKey = "story:" + arc.AnchorKey, CreatedAt = now, LastSeenAt = now, ExpiresAt = now + 45f }; InterestCandidate registered = EventFeedUtil.RegisterCandidate(candidate); if (registered == null) { return false; } arc.NoteCast(followId); // Theater partner (alive or fallen) only - never social fallbacks. if (relatedId != 0 && relatedId != followId) { arc.NoteCast(relatedId); } arc.PendingBeats.Enqueue(new StoryBeat { Phase = StoryPhase.Aftermath, AssetId = assetId, CandidateKey = registered.Key, FollowId = followId, RelatedId = relatedId, EventStrength = registered.EventStrength, MinWatch = registered.MinWatch, MaxWatch = registered.MaxWatch, SynthesizeIfMissing = false }); return true; } private static void TryInjectEpilogue(StoryArc arc, float now) { if (arc == null || arc.Phase == StoryPhase.Epilogue || arc.Phase == StoryPhase.Done) { return; } // Narrative threads carry consequences forward. The old synthetic related-character // closer duplicated that responsibility and generated generic unsupported epilogues. arc.Advance(StoryPhase.Done, now); } private static Actor ResolveAftermathFollow(InterestCandidate climax) { if (climax == null) { return null; } ActorScratch.Clear(); void AddCand(Actor a) { if (a == null || !a.isAlive()) { return; } for (int i = 0; i < ActorScratch.Count; i++) { if (ActorScratch[i] == a) { return; } } ActorScratch.Add(a); } AddCand(climax.FollowUnit); if (climax.TheaterLeadId != 0) { AddCand(EventFeedUtil.FindAliveById(climax.TheaterLeadId)); } if (climax.PairOwnerId != 0) { AddCand(EventFeedUtil.FindAliveById(climax.PairOwnerId)); } AddCand(climax.RelatedUnit); if (climax.PairPartnerId != 0) { AddCand(EventFeedUtil.FindAliveById(climax.PairPartnerId)); } if (climax.Sticky != null) { for (int i = 0; i < climax.Sticky.SideAIds.Count; i++) { AddCand(EventFeedUtil.FindAliveById(climax.Sticky.SideAIds[i])); } for (int i = 0; i < climax.Sticky.SideBIds.Count; i++) { AddCand(EventFeedUtil.FindAliveById(climax.Sticky.SideBIds[i])); } } Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch); if (preferred != null) { return preferred; } if (climax.FollowUnit != null && climax.FollowUnit.isAlive()) { return climax.FollowUnit; } if (climax.TheaterLeadId != 0) { Actor lead = EventFeedUtil.FindAliveById(climax.TheaterLeadId); if (lead != null) { return lead; } } if (climax.PairOwnerId != 0) { Actor owner = EventFeedUtil.FindAliveById(climax.PairOwnerId); if (owner != null) { return owner; } } if (climax.RelatedUnit != null && climax.RelatedUnit.isAlive()) { return climax.RelatedUnit; } if (climax.PairPartnerId != 0) { Actor partner = EventFeedUtil.FindAliveById(climax.PairPartnerId); if (partner != null) { return partner; } } if (climax.Sticky != null) { for (int i = 0; i < climax.Sticky.SideAIds.Count; i++) { Actor a = EventFeedUtil.FindAliveById(climax.Sticky.SideAIds[i]); if (a != null) { return a; } } for (int i = 0; i < climax.Sticky.SideBIds.Count; i++) { Actor a = EventFeedUtil.FindAliveById(climax.Sticky.SideBIds[i]); if (a != null) { return a; } } } // Mourner via relations of the fallen subject. Actor fallenPeer = EventFeedUtil.FindAliveById(climax.SubjectId); if (fallenPeer == null && climax.SubjectId != 0) { // Subject dead - look for lover/friend of related or pair partner. Actor partner = climax.RelatedUnit != null && climax.RelatedUnit.isAlive() ? climax.RelatedUnit : EventFeedUtil.FindAliveById(climax.RelatedId); if (partner != null) { Actor mourner = ActorRelation.GetLover(partner) ?? ActorRelation.GetBestFriend(partner); if (mourner != null) { return mourner; } return partner; } } return null; } /// /// Theater partner for aftermath naming. Combat never falls back to lover/best friend /// (those are living bystanders and produce lying "stands over X" tips). /// private static Actor ResolveAftermathRelated( InterestCandidate climax, Actor follow, StoryArcKind kind) { if (climax == null) { return null; } long followId = EventFeedUtil.SafeId(follow); if (kind == StoryArcKind.Love) { if (climax.RelatedUnit != null && climax.RelatedUnit.isAlive() && EventFeedUtil.SafeId(climax.RelatedUnit) != followId) { return climax.RelatedUnit; } if (climax.RelatedId != 0 && climax.RelatedId != followId) { Actor bonded = EventFeedUtil.FindAliveById(climax.RelatedId); if (bonded != null) { return bonded; } } Actor lover = ActorRelation.GetLover(follow); if (lover != null) { return lover; } return ActorRelation.GetBestFriend(follow); } if (kind == StoryArcKind.WarFront || kind == StoryArcKind.Plot) { return null; } // Combat / grief / default: climax theater partner only (alive or dead for naming). return ResolveTheaterPartnerAny(climax, followId); } /// /// Duel/mass partner from pair lock, RelatedId, or opposing sticky side - including dead units. /// private static Actor ResolveTheaterPartnerAny(InterestCandidate climax, long followId) { if (climax == null || followId == 0) { return null; } if (climax.RelatedUnit != null && EventFeedUtil.SafeId(climax.RelatedUnit) != followId) { return climax.RelatedUnit; } if (climax.PairPartnerId != 0 && climax.PairPartnerId != followId) { Actor p = EventFeedUtil.FindUnitById(climax.PairPartnerId); if (p != null) { return p; } } if (climax.PairOwnerId != 0 && climax.PairOwnerId != followId) { Actor p = EventFeedUtil.FindUnitById(climax.PairOwnerId); if (p != null) { return p; } } if (climax.RelatedId != 0 && climax.RelatedId != followId) { Actor p = EventFeedUtil.FindUnitById(climax.RelatedId); if (p != null) { return p; } } if (climax.Sticky == null) { return null; } bool followOnA = false; for (int i = 0; i < climax.Sticky.SideAIds.Count; i++) { if (climax.Sticky.SideAIds[i] == followId) { followOnA = true; break; } } var opposite = followOnA ? climax.Sticky.SideBIds : climax.Sticky.SideAIds; // Prefer a dead opposite (true fallen), else first opposite for lookup. Actor livingOpposite = null; for (int i = 0; i < opposite.Count; i++) { Actor a = EventFeedUtil.FindUnitById(opposite[i]); if (a == null || EventFeedUtil.SafeId(a) == followId) { continue; } try { if (!a.isAlive()) { return a; } } catch { return a; } if (livingOpposite == null) { livingOpposite = a; } } return livingOpposite; } private static string PickAftermathAsset(StoryArcKind kind, Actor related) { switch (kind) { case StoryArcKind.WarFront: return StoryReason.AftermathWarLinger; case StoryArcKind.Plot: return StoryReason.AftermathPlotFallout; case StoryArcKind.Love: return StoryReason.AftermathLoveLinger; case StoryArcKind.Grief: return StoryReason.AftermathMourner; default: return StoryReason.AftermathSurvivor; } } /// True when the theater partner exists and is confirmed dead. private static bool TheaterPartnerIsFallen(Actor related) { if (related == null) { return false; } try { return !related.isAlive(); } catch { // Unreachable / disposed actors count as fallen for naming. return true; } } /// /// Combat fallen check: live RelatedUnit, else durable pair / related ids after death clears. /// Null living partner with no durable id is not fallen (attack-gap false aftermath). /// private static bool CombatTheaterPartnerIsFallen(InterestCandidate climax, Actor related) { if (TheaterPartnerIsFallen(related)) { return true; } if (climax == null) { return false; } long id = climax.PairPartnerId != 0 ? climax.PairPartnerId : climax.RelatedId; if (id == 0) { return false; } Actor byId = EventFeedUtil.FindUnitById(id); if (byId == null) { // Removed from the world map after die() - treat as fallen. return true; } return TheaterPartnerIsFallen(byId); } private static bool IsStoryAssetCandidate(InterestCandidate c) => c != null && (StoryReason.IsStoryAsset(c.AssetId) || string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)); private static bool IsCrisisEpilogueCandidate(InterestCandidate c) => c != null && (StoryReason.IsCrisisEpilogue(c.AssetId) || (!string.IsNullOrEmpty(c.Key) && c.Key.StartsWith("epilogue:crisis:", StringComparison.OrdinalIgnoreCase))); /// Harness: prove a parked closer resumes only its correlated arc. public static bool HarnessProbeCrossArcCorrelation(out string detail) { Clear(); float now = Time.unscaledTime; var arcA = new StoryArc { Id = "arc:CombatDuel:harness-a", Kind = StoryArcKind.CombatDuel, AnchorKey = "harness-a", ClimaxKey = "harness:climax:a", StartedAt = now - 10f, HardHold = true }; arcA.NoteCast(101); arcA.Advance(StoryPhase.Aftermath, now - 2f); var arcB = new StoryArc { Id = "arc:CombatDuel:harness-b", Kind = StoryArcKind.CombatDuel, AnchorKey = "harness-b", ClimaxKey = "harness:climax:b", StartedAt = now - 8f, ParkedAt = now - 1f, HardHold = true }; arcB.NoteCast(202); arcB.Advance(StoryPhase.Aftermath, now - 1f); _active = arcA; Parked.Add(arcB); var closerB = new InterestCandidate { Key = "aftermath:harness:b", Source = "story_planner", AssetId = StoryReason.AftermathSurvivor, CorrelationKey = "story:harness-b", SubjectId = 202, RelatedId = 203, Completion = InterestCompletionKind.FixedDwell, LeadKind = InterestLeadKind.EventLed, EventStrength = 60f, Label = "Harness B aftermath" }; var orphan = new InterestCandidate { Key = "aftermath:harness:orphan", Source = "story_planner", AssetId = StoryReason.AftermathSurvivor, CorrelationKey = "story:missing", Completion = InterestCompletionKind.FixedDwell, LeadKind = InterestLeadKind.EventLed, EventStrength = 60f, Label = "Harness orphan aftermath" }; var unrelated = new InterestCandidate { Key = "harness:unrelated", Completion = InterestCompletionKind.FixedDwell, LeadKind = InterestLeadKind.EventLed, EventStrength = 40f, Label = "Harness unrelated" }; bool parkedOwnedAsActive = OwnsCandidate(closerB); bool parkedLive = HasLiveStoryOwner(closerB); bool parkedStealsPreference = PreferOver(closerB, unrelated); bool orphanLive = HasLiveStoryOwner(orphan); float orphanBoost = OwnershipBoost(orphan); StoryPhase aPhase = arcA.Phase; int aCast = arcA.CastIds.Count; OnSwitchTo(closerB, now); bool resumedB = ReferenceEquals(_active, arcB) && !arcB.IsParked; bool parkedA = arcA.IsParked && Parked.Contains(arcA); bool aUntouched = arcA.Phase == aPhase && arcA.CastIds.Count == aCast; bool bAdvanced = arcB.Phase == StoryPhase.Aftermath && arcB.ContainsCast(203); bool pass = !parkedOwnedAsActive && parkedLive && !parkedStealsPreference && !orphanLive && orphanBoost <= 0.01f && resumedB && parkedA && aUntouched && bAdvanced; detail = $"pass={pass} parkedLive={parkedLive} parkedOwn={parkedOwnedAsActive} " + $"parkedPrefer={parkedStealsPreference} orphanLive={orphanLive} orphanBoost={orphanBoost:0.#} " + $"active='{_active?.Id}' parkedA={parkedA} aUntouched={aUntouched} bAdvanced={bAdvanced}"; Clear(); return pass; } /// /// Harness: open a crisis chapter from the current tip even when live counts are below /// the product enter threshold (small harness kingdoms). /// public static bool HarnessBeginCrisisFromCurrent() { // Earthquake / epic WorldLog can open the chapter naturally before this step. if (CrisisActive) { return true; } InterestCandidate cur = InterestDirector.CurrentCandidate; if (cur == null) { return false; } float now = Time.unscaledTime; CrisisKind kind = CrisisKind.None; string anchor = cur.Key ?? "harness"; if (cur.Completion == InterestCompletionKind.WarFront) { kind = CrisisKind.War; } else if (cur.Completion == InterestCompletionKind.EarthquakeActive || string.Equals(cur.Category, "Disaster", StringComparison.OrdinalIgnoreCase) || string.Equals(cur.AssetId, "earthquake", StringComparison.OrdinalIgnoreCase)) { kind = CrisisKind.Disaster; anchor = "disaster"; } else if (cur.Completion == InterestCompletionKind.StatusOutbreak) { kind = CrisisKind.Outbreak; anchor = "outbreak"; } else if (TryClassifyCrisis(cur, out CrisisKind classified, out string classifiedAnchor, out _)) { kind = classified; anchor = classifiedAnchor; } if (kind == CrisisKind.None) { return false; } Actor follow = cur.FollowUnit; if (follow == null || !follow.isAlive()) { follow = EventFeedUtil.FindAliveById(cur.SubjectId); } BeginCrisis(kind, anchor, follow, now); StampCrisisTheater(_crisis, cur); return CrisisActive; } /// /// Harness: open crisis from the current tip / live signal if needed, then force the closer. /// public static bool HarnessForceCrisisEpilogue() { float now = Time.unscaledTime; if (_crisis == null || !_crisis.IsLive) { if (!HarnessBeginCrisisFromCurrent()) { if (!TryFindCrisisSignal(out CrisisKind kind, out string anchor, out Actor follow, out _)) { return false; } BeginCrisis(kind, anchor, follow, now); } } BeginCrisisEpilogue(now); return _crisis != null && _crisis.EpilogueInjected; } /// Open or refresh crisis from a switched tip (before maintain can dip counts). private static void NoteCrisisFromCandidate(InterestCandidate c, float now) { if (c == null) { return; } if (TryClassifyCrisis(c, out CrisisKind kind, out string anchor, out _)) { if (_crisis != null && _crisis.IsLive && _crisis.Kind == kind) { if (_crisis.Phase == CrisisPhase.Epilogue) { return; } _crisis.LastSignalAt = now; Actor follow = c.FollowUnit; if (follow != null && follow.isAlive()) { long id = EventFeedUtil.SafeId(follow); if (id != 0) { _crisis.FollowId = id; } } if (!string.IsNullOrEmpty(anchor) && !string.Equals(_crisis.AnchorKey, anchor, StringComparison.Ordinal)) { _crisis.AnchorKey = anchor; _crisis.Id = "crisis:" + kind + ":" + anchor; } return; } if (_crisis != null && _crisis.IsLive && _crisis.Phase == CrisisPhase.Epilogue) { return; } if (now < _crisisCooldownUntil && (_crisis == null || !_crisis.IsLive)) { return; } Actor openFollow = c.FollowUnit; if (openFollow == null || !openFollow.isAlive()) { openFollow = EventFeedUtil.FindAliveById(c.SubjectId); } BeginCrisis(kind, anchor, openFollow, now); return; } // Still on a war front after counts dipped - keep the chapter warm. if (_crisis != null && _crisis.IsLive && _crisis.Phase == CrisisPhase.Active && _crisis.Kind == CrisisKind.War && c.Completion == InterestCompletionKind.WarFront) { _crisis.LastSignalAt = now; } } private static void TickCrisis(float now) { StoryWeights s = InterestScoringConfig.Story; if (TryFindCrisisSignal(out CrisisKind kind, out string anchor, out Actor follow, out _)) { if (_crisis != null && _crisis.IsLive && _crisis.Kind == kind) { // Closer already owns the exit - still time out even if WorldLog keeps pulsing. if (_crisis.Phase == CrisisPhase.Epilogue) { float epiCap = s.crisisEpilogueMaxWatch > 0f ? s.crisisEpilogueMaxWatch : 16f; if (now - _crisis.PhaseStartedAt > epiCap + 8f) { CloseCrisis(now); } return; } _crisis.LastSignalAt = now; if (!string.IsNullOrEmpty(anchor) && !string.Equals(_crisis.AnchorKey, anchor, StringComparison.Ordinal)) { _crisis.AnchorKey = anchor; _crisis.Id = "crisis:" + kind + ":" + anchor; } if (follow != null) { long id = EventFeedUtil.SafeId(follow); if (id != 0) { _crisis.FollowId = id; } } return; } if (_crisis != null && _crisis.IsLive && _crisis.Kind != kind) { // Do not replace a live closer with a peer storm tip. if (_crisis.Phase == CrisisPhase.Epilogue) { return; } // Stronger / different crisis class replaces the live chapter. BeginCrisis(kind, anchor, follow, now); return; } if (now < _crisisCooldownUntil) { return; } BeginCrisis(kind, anchor, follow, now); return; } // War theater still current (WarFront or same-kingdom Mass) - keep chapter warm. InterestCandidate cur = InterestDirector.CurrentCandidate; if (_crisis != null && _crisis.IsLive && _crisis.Phase == CrisisPhase.Active && _crisis.Kind == CrisisKind.War && cur != null && MatchesCrisis(cur)) { _crisis.LastSignalAt = now; return; } if (_crisis == null || !_crisis.IsLive) { return; } if (_crisis.Phase == CrisisPhase.Epilogue) { float epiCap = s.crisisEpilogueMaxWatch > 0f ? s.crisisEpilogueMaxWatch : 16f; if (now - _crisis.PhaseStartedAt > epiCap + 8f) { CloseCrisis(now); } return; } float linger = CrisisLingerSeconds(_crisis.Kind, s); float maxDur = s.crisisMaxSeconds > 0f ? s.crisisMaxSeconds : 180f; bool stale = now - _crisis.LastSignalAt > linger; bool timedOut = now - _crisis.StartedAt > maxDur; if (stale || timedOut) { // Short-arc aftermath already on camera for this theater - quiet close. if (ShortArcOwnsCrisisExit()) { CloseCrisis(now); return; } BeginCrisisEpilogue(now); } } private static bool ShortArcOwnsCrisisExit() { if (_crisis == null || !_crisis.IsLive) { return false; } InterestCandidate cur = InterestDirector.CurrentCandidate; if (cur != null && !IsCrisisEpilogueCandidate(cur) && IsStoryAssetCandidate(cur)) { if (_crisis.Kind == CrisisKind.War && string.Equals( cur.AssetId, StoryReason.AftermathWarLinger, StringComparison.OrdinalIgnoreCase)) { return true; } if (_active != null && _active.IsActive && (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue) && _crisis.Kind == CrisisKind.War && _active.Kind == StoryArcKind.WarFront) { return true; } } // Aftermath already injected but not yet selected. if (_crisis.Kind == CrisisKind.War) { PendingScratch.Clear(); InterestRegistry.CopyPending(PendingScratch); for (int i = 0; i < PendingScratch.Count; i++) { InterestCandidate p = PendingScratch[i]; if (p != null && string.Equals( p.AssetId, StoryReason.AftermathWarLinger, StringComparison.OrdinalIgnoreCase)) { return true; } } } return false; } private static float CrisisLingerSeconds(CrisisKind kind, StoryWeights s) { float linger = s.crisisLingerSeconds > 0f ? s.crisisLingerSeconds : 10f; if (kind == CrisisKind.Disaster || kind == CrisisKind.Outbreak) { float disasterLinger = s.crisisDisasterLingerSeconds > 0f ? s.crisisDisasterLingerSeconds : 28f; linger = Mathf.Max(linger, disasterLinger); } return linger; } private static void BeginCrisis(CrisisKind kind, string anchor, Actor follow, float now) { if (kind == CrisisKind.None) { return; } string key = string.IsNullOrEmpty(anchor) ? kind.ToString().ToLowerInvariant() : anchor; long followId = EventFeedUtil.SafeId(follow); _crisis = new CrisisChapter { Kind = kind, Phase = CrisisPhase.Active, AnchorKey = key, Id = "crisis:" + kind + ":" + key, StartedAt = now, LastSignalAt = now, PhaseStartedAt = now, FollowId = followId, EpilogueInjected = false }; StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate); } private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip) { if (crisis == null) { return; } if (tip?.Sticky != null && StickyScoreboard.TryKingdomPair(tip.Sticky, out string a, out string b)) { crisis.TheaterKeyA = a; crisis.TheaterKeyB = b; return; } // Fall back to follow / related kingdoms when sticky keys are not locked yet. string fa = LiveEnsemble.KingdomKeyOf(tip?.FollowUnit); string fb = LiveEnsemble.KingdomKeyOf(tip?.RelatedUnit); if (!string.IsNullOrEmpty(fa) && !string.IsNullOrEmpty(fb) && !string.Equals(fa, fb, StringComparison.OrdinalIgnoreCase)) { crisis.TheaterKeyA = fa.Trim().ToLowerInvariant(); crisis.TheaterKeyB = fb.Trim().ToLowerInvariant(); } } private static void BeginCrisisEpilogue(float now) { if (_crisis == null || !_crisis.IsLive) { return; } if (_crisis.EpilogueInjected) { _crisis.Advance(CrisisPhase.Epilogue, now); return; } if (!TryInjectCrisisEpilogue(_crisis, now)) { CloseCrisis(now); return; } _crisis.EpilogueInjected = true; _crisis.Advance(CrisisPhase.Epilogue, now); _crisis.LastSignalAt = now; // Arm cool at closer inject so stacked disaster WorldLog tips cannot open a second // chapter the moment CloseCrisis runs while the storm is still logging. ArmCrisisCooldown(now, _crisis.Kind); NoteHardStoryQuiet(now); } private static void CloseCrisis(float now) { if (_crisis == null) { return; } CrisisKind kind = _crisis.Kind; _crisis.Advance(CrisisPhase.Done, now); ArmCrisisCooldown(now, kind); NoteHardStoryQuiet(now); } private static void ArmCrisisCooldown(float now, CrisisKind kind) { StoryWeights s = InterestScoringConfig.Story; float cool = s.crisisCooldownSeconds > 0f ? s.crisisCooldownSeconds : 45f; if (kind == CrisisKind.Disaster || kind == CrisisKind.Outbreak) { float disasterCool = s.crisisDisasterCooldownSeconds > 0f ? s.crisisDisasterCooldownSeconds : 120f; cool = Mathf.Max(cool, disasterCool); } _crisisCooldownUntil = Mathf.Max(_crisisCooldownUntil, now + cool); } private static bool TryFindCrisisSignal( out CrisisKind kind, out string anchor, out Actor follow, out float intensity) { kind = CrisisKind.None; anchor = ""; follow = null; intensity = 0f; InterestCandidate best = null; CrisisKind bestKind = CrisisKind.None; string bestAnchor = ""; float bestIntensity = 0f; InterestCandidate cur = InterestDirector.CurrentCandidate; if (TryClassifyCrisis(cur, out CrisisKind curKind, out string curAnchor, out float curIntensity) && curIntensity >= bestIntensity) { best = cur; bestKind = curKind; bestAnchor = curAnchor; bestIntensity = curIntensity; } PendingScratch.Clear(); InterestRegistry.CopyPending(PendingScratch); for (int i = 0; i < PendingScratch.Count; i++) { InterestCandidate p = PendingScratch[i]; if (!TryClassifyCrisis(p, out CrisisKind pKind, out string pAnchor, out float pIntensity)) { continue; } if (pIntensity > bestIntensity) { best = p; bestKind = pKind; bestAnchor = pAnchor; bestIntensity = pIntensity; } } if (best == null || bestKind == CrisisKind.None) { return false; } kind = bestKind; anchor = bestAnchor; intensity = bestIntensity; follow = best.FollowUnit; if (follow == null || !follow.isAlive()) { follow = EventFeedUtil.FindAliveById(best.SubjectId); } return true; } private static bool TryClassifyCrisis( InterestCandidate c, out CrisisKind kind, out string anchor, out float intensity) { kind = CrisisKind.None; anchor = ""; intensity = 0f; if (c == null || IsCrisisEpilogueCandidate(c)) { return false; } StoryWeights s = InterestScoringConfig.Story; int warMin = s.crisisWarParticipantMin > 0 ? s.crisisWarParticipantMin : 8; float disasterMin = s.crisisDisasterStrengthMin > 0f ? s.crisisDisasterStrengthMin : 95f; if (c.Completion == InterestCompletionKind.WarFront) { // Enter on live theater scale only. Sticky PeakParticipants is a hold floor for // scale labels - using it here let harness 12:8 locks open crisis on 2v1 worlds, // then epilogue_crisis leaked into the live soak after war_front_sticky. int sideA = Mathf.Max(0, c.CombatSideACount); int sideB = Mathf.Max(0, c.CombatSideBCount); int sides = sideA + sideB; int n = Mathf.Max(c.ParticipantCount, sides); bool contested = sideA >= 1 && sideB >= 1; if (n >= warMin && contested) { kind = CrisisKind.War; intensity = n + c.EventStrength * 0.01f; anchor = !string.IsNullOrEmpty(c.CorrelationKey) ? c.CorrelationKey : (!string.IsNullOrEmpty(c.Key) ? c.Key : "war"); return true; } } if (c.Completion == InterestCompletionKind.EarthquakeActive || (c.EventStrength >= disasterMin && IsDisasterCrisisSignal(c))) { kind = CrisisKind.Disaster; intensity = Mathf.Max(c.EventStrength, disasterMin); // Family anchor: tornado / quake / meteor pulses share one storm chapter. anchor = "disaster"; return true; } if (c.Completion == InterestCompletionKind.StatusOutbreak && c.EventStrength >= disasterMin) { kind = CrisisKind.Outbreak; intensity = c.EventStrength; anchor = "outbreak"; return true; } return false; } /// /// Disaster crisis signal via live AssetManager disasters, authored overlays, /// WorldLog Disaster category, or EarthquakeActive completion - never tip substrings. /// public static bool IsDisasterCrisisSignal(InterestCandidate c) { if (c == null) { return false; } if (c.Completion == InterestCompletionKind.EarthquakeActive) { return true; } if (string.Equals(c.Category, "Disaster", StringComparison.OrdinalIgnoreCase)) { return true; } string asset = (c.AssetId ?? "").Trim(); if (!string.IsNullOrEmpty(asset) && (EventCatalog.Disaster.IsLiveOrAuthored(asset) || EventCatalog.WorldLog.IsDisasterCategory(asset))) { return true; } return false; } /// /// Tip that should hold the camera against local Mass/Battle scraps. /// WarFront always holds (even below crisis enter threshold). Disaster / outbreak /// signals hold while their crisis chapter is live. /// public static bool IsCrisisCameraSignal(InterestCandidate c) { if (c == null) { return false; } // War tip owns the camera against Mass/Battle regardless of crisis chapter size. if (c.Completion == InterestCompletionKind.WarFront) { return true; } if (!CrisisActive || !MatchesCrisis(c)) { return false; } if (c.Completion == InterestCompletionKind.EarthquakeActive || c.Completion == InterestCompletionKind.StatusOutbreak) { return true; } return IsDisasterCrisisSignal(c); } private static bool TryInjectCrisisEpilogue(CrisisChapter crisis, float now) { if (crisis == null) { return false; } Actor follow = EventFeedUtil.FindAliveById(crisis.FollowId); if (follow == null || !follow.isAlive()) { follow = InterestDirector.CurrentCandidate?.FollowUnit; if (follow == null || !follow.isAlive()) { follow = EventFeedUtil.AnyAliveUnit(); } } if (follow == null || !follow.isAlive()) { return false; } StoryWeights s = InterestScoringConfig.Story; string assetId = StoryReason.EpilogueCrisis; DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId); float strength = catalog != null && !catalog.IsFallback ? catalog.EventStrength : (s.crisisEpilogueStrength > 0f ? s.crisisEpilogueStrength : 52f); string label = StoryReason.AftermathLabel(assetId, follow, null); long followId = EventFeedUtil.SafeId(follow); string key = "epilogue:crisis:" + crisis.Kind + ":" + crisis.AnchorKey + ":" + followId + ":" + Mathf.RoundToInt(now * 1000f); float maxWatch = s.crisisEpilogueMaxWatch > 0f ? s.crisisEpilogueMaxWatch : 16f; var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = "Story", Source = "story_planner", AssetId = assetId, Label = label, FollowUnit = follow, SubjectId = followId, Position = follow.current_position, EventStrength = strength, VisualConfidence = 0.8f, Novelty = 1f, MinWatch = 8f, MaxWatch = maxWatch, Completion = InterestCompletionKind.FixedDwell, Resumable = false, CorrelationKey = crisis.Id, CreatedAt = now, LastSeenAt = now, ExpiresAt = now + 45f }; InterestCandidate registered = EventFeedUtil.RegisterCandidate(candidate); if (registered == null) { return false; } crisis.FollowId = followId; return true; } }