worldbox-observer-mod/IdleSpectator/Story/StoryPlanner.cs
DazedAnon 086921bd95 feat(saga): enhance saga probes and caption handling.
- Introduce new probes for cross-arc correlation and caption status banners
- Implement saga relation cache revision tracking
- Update harness scenarios to include new story and caption probes
- Refactor LifeSagaMemory to mark mutations for better state management
- Improve WatchCaption to handle status banners and hover previews effectively
2026-07-22 19:26:58 -05:00

3480 lines
106 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public static class StoryPlanner
{
private const int BoardCap = 4;
private const float ParkedMaxSeconds = 180f;
private static StoryArc _active;
private static readonly List<StoryArc> Parked = new List<StoryArc>(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<InterestCandidate> PendingScratch = new List<InterestCandidate>(64);
private static readonly List<Actor> ActorScratch = new List<Actor>(16);
/// <summary>Currently watching storyline (not parked).</summary>
public static StoryArc Active =>
_active != null && _active.IsActive && !_active.IsParked ? _active : null;
/// <summary>Live crisis chapter overlay (war / disaster / outbreak), or null.</summary>
public static CrisisChapter Crisis => _crisis != null && _crisis.IsLive ? _crisis : null;
public static bool CrisisActive => Crisis != null;
/// <summary>
/// Soft-life crumbs and character-fill are suppressed briefly after a hard story/crisis end.
/// </summary>
public static bool SoftFillQuietActive => Time.unscaledTime < _softFillQuietUntil;
/// <summary>Character-fill ambient is suppressed while a crisis chapter is live or soft-fill quiet.</summary>
public static bool SuppressAmbientFill => CrisisActive || SoftFillQuietActive;
/// <summary>Watching + parked arcs for dossier rail / harness (excludes Done).</summary>
public static void CopyBoard(List<StoryArc> 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;
}
}
/// <summary>
/// Quiet dossier spine: "Duel · Aftermath".
/// Empty when no live arc, or when the current watch tip is not that story beat.
/// </summary>
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;
}
/// <summary>
/// Spine Kind word from sticky/live ensemble scale (Duel/Skirmish/Battle/Mass).
/// Empty when not a combat arc.
/// </summary>
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";
}
}
/// <summary>
/// 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.
/// </summary>
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);
// Re-challenge Cap while climax holds: admit heat escalates with arc age so a
// prolonged duel/war/plot on a stranger can still join the rail (Active chrome).
if (IsHardStoryKind(_active.Kind))
{
LifeSagaRoster.StampChapterFromArc(_active, current, Time.unscaledTime);
}
}
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));
}
/// <summary>
/// True when <paramref name="c"/> 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).
/// </summary>
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 = "";
CausalHeat.Clear();
// LifeSagaRoster survives short-arc Clear (browse / brief idle-off).
}
/// <summary>
/// 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.
/// </summary>
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");
}
}
/// <summary>Arm soft-fill quiet after a hard story/crisis beat ends.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
/// <summary>Promote a parked short arc to watching (harness / natural tip return).</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Selection defense-in-depth for expired/orphaned synthetic story beats.
/// Crisis closers use their crisis chapter instead of the short-arc board.
/// </summary>
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);
}
}
}
/// <summary>Prefer=3, MC=2, MC cast=1, else 0 for parked-board trim.</summary>
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;
}
CausalHeat.NoteFeatured(next.SubjectId);
if (next.RelatedId != 0)
{
CausalHeat.NoteFeatured(next.RelatedId, 0.6f);
}
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);
LifeSagaRoster.StampChapterFromArc(_active, next, now);
}
return;
}
NoteCrisisFromCandidate(next, now);
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
{
BeginOrRefreshArc(next, kind, hardHold, now);
if (_active != null && _active.IsActive)
{
LifeSagaRoster.StampChapterFromArc(_active, next, 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;
}
}
}
/// <summary>
/// Called when a climax scene first goes inactive (entering quiet grace).
/// </summary>
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;
}
}
/// <summary>
/// Same climax theater even when sticky combat refreshes the candidate key (Battle↔Duel).
/// </summary>
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;
}
/// <summary>Score boost for tips that belong to the live crisis chapter.</summary>
public static float CrisisOwnershipBoost(InterestCandidate c)
{
if (!MatchesCrisis(c))
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
return s.crisisOwnershipBoost > 0f ? s.crisisOwnershipBoost : 22f;
}
/// <summary>
/// True when <paramref name="c"/> is part of the live crisis (signal tip or crisis closer).
/// </summary>
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;
}
/// <summary>
/// True when the orange reason is a StoryPlanner FixedDwell beat (aftermath / epilogue).
/// Used to suppress stale combat task detail under story closers.
/// </summary>
public static bool IsStoryOwnedTip(InterestCandidate c) =>
IsStoryAssetCandidate(c) || IsCrisisEpilogueCandidate(c);
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// Highest pending EventStrength for same-subject non-story life tips (cast noise ceiling).
/// </summary>
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;
}
/// <summary>
/// Raised cut-in margin while watching a crisis tip against unrelated peers.
/// Epic peers at/above <c>crisisDisasterStrengthMin</c> still cut freely.
/// </summary>
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 = "";
CausalHeat.NoteCast(_active.CastIds);
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;
}
}
/// <summary>
/// Cast/theater identity from sticky sides, named pair, or living cast - not Kind words.
/// </summary>
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;
}
/// <summary>
/// When a story aftermath/epilogue Follow dies, promote another living cast member
/// so the beat is not dropped as follow_lost.
/// </summary>
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;
}
/// <summary>
/// Grief / love-outcome tips that already finish the climax story for this cast.
/// Ambient cast life (hatch, eat, sleep) must not suppress aftermath.
/// </summary>
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
});
CausalHeat.NoteFeatured(followId);
return true;
}
private static void TryInjectEpilogue(StoryArc arc, float now)
{
if (arc == null || arc.Phase == StoryPhase.Epilogue || arc.Phase == StoryPhase.Done)
{
return;
}
// Crisis chapter owns the closer - do not also fire a related soft epilogue.
if (_crisis != null && _crisis.IsLive)
{
if (!_crisis.EpilogueInjected)
{
BeginCrisisEpilogue(now);
}
if (_crisis.EpilogueInjected)
{
arc.Advance(StoryPhase.Done, now);
return;
}
}
Actor follow = null;
Actor related = null;
ActorScratch.Clear();
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a == null)
{
continue;
}
Actor lover = ActorRelation.GetLover(a);
Actor friend = ActorRelation.GetBestFriend(a);
bool hasBond = (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
|| (friend != null && friend.isAlive());
if (hasBond)
{
ActorScratch.Add(a);
}
}
Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch);
if (preferred != null)
{
follow = preferred;
related = ActorRelation.GetLover(preferred);
if (related == null || !related.isAlive() || EventFeedUtil.SafeId(related) == EventFeedUtil.SafeId(preferred))
{
related = ActorRelation.GetBestFriend(preferred);
}
}
else
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a == null)
{
continue;
}
Actor lover = ActorRelation.GetLover(a);
if (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
{
follow = a;
related = lover;
break;
}
Actor friend = ActorRelation.GetBestFriend(a);
if (friend != null && friend.isAlive())
{
follow = a;
related = friend;
break;
}
if (follow == null)
{
follow = a;
}
}
}
if (follow == null)
{
arc.Advance(StoryPhase.Done, now);
return;
}
// Need a distinct related for a meaningful epilogue.
if (related == null)
{
arc.Advance(StoryPhase.Done, now);
return;
}
StoryWeights s = InterestScoringConfig.Story;
string assetId = StoryReason.EpilogueRelated;
string label = StoryReason.AftermathLabel(assetId, follow, related);
string key = "epilogue:" + arc.Kind + ":" + arc.AnchorKey;
long followId = EventFeedUtil.SafeId(follow);
long relatedId = EventFeedUtil.SafeId(related);
var candidate = new InterestCandidate
{
Key = key,
LeadKind = InterestLeadKind.EventLed,
Category = "Story",
Source = "story_planner",
AssetId = assetId,
Label = label,
FollowUnit = follow,
SubjectId = followId,
RelatedUnit = related,
RelatedId = relatedId,
Position = follow.current_position,
EventStrength = s.epilogueStrength,
VisualConfidence = 0.7f,
Novelty = 1f,
MinWatch = 8f,
MaxWatch = s.epilogueMaxWatch > 0f ? s.epilogueMaxWatch : 14f,
Completion = InterestCompletionKind.FixedDwell,
Resumable = false,
CorrelationKey = "story:" + arc.AnchorKey,
CreatedAt = now,
LastSeenAt = now,
ExpiresAt = now + 40f
};
if (EventFeedUtil.RegisterCandidate(candidate) == null)
{
arc.Advance(StoryPhase.Done, now);
return;
}
arc.Advance(StoryPhase.Epilogue, now);
CausalHeat.NoteFeatured(followId);
}
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;
}
/// <summary>
/// Theater partner for aftermath naming. Combat never falls back to lover/best friend
/// (those are living bystanders and produce lying "stands over X" tips).
/// </summary>
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);
}
/// <summary>
/// Duel/mass partner from pair lock, RelatedId, or opposing sticky side - including dead units.
/// </summary>
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;
}
}
/// <summary>True when the theater partner exists and is confirmed dead.</summary>
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;
}
}
/// <summary>
/// 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).
/// </summary>
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)));
/// <summary>Harness: prove a parked closer resumes only its correlated arc.</summary>
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;
}
/// <summary>
/// Harness: open a crisis chapter from the current tip even when live counts are below
/// the product enter threshold (small harness kingdoms).
/// </summary>
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;
}
/// <summary>
/// Harness: open crisis from the current tip / live signal if needed, then force the closer.
/// </summary>
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;
}
/// <summary>Open or refresh crisis from a switched tip (before maintain can dip counts).</summary>
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);
if (followId != 0)
{
CausalHeat.NoteFeatured(followId, 1.1f);
}
}
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;
}
/// <summary>
/// Disaster crisis signal via live AssetManager disasters, authored overlays,
/// WorldLog Disaster category, or EarthquakeActive completion - never tip substrings.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
CausalHeat.NoteFeatured(followId, 1.2f);
return true;
}
}