worldbox-observer-mod/IdleSpectator/Story/StoryPlanner.cs
DazedAnon 9a059c17dd fix(spectator): cool soft life crumbs and keep aftermath victim truth
- Soft-cool kill, courtship, intent, construction, status fx, hatch, rest, and worldlog meta
- Block cooled tips from resuming after a cut-in
- Name only dead theater partners in stands-over aftermath
- Add harness gates through 0.28.54
2026-07-21 16:45:12 -05:00

1542 lines
47 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.
/// </summary>
public static class StoryPlanner
{
private static StoryArc _active;
private static bool _coldHookFired;
private static string _coldHookClimaxKey = "";
private static string _coldHookAnchor = "";
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(64);
public static StoryArc Active => _active != null && _active.IsActive ? _active : null;
/// <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;
// 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 tip prose owns the Kind word (Battle/Skirmish/Mass/Duel) so the spine
// matches the orange tip, not a collapsed CombatMass bucket.
string tipKind = CombatSpineKindFromTip(watch);
if (!string.IsNullOrEmpty(tipKind))
{
kind = tipKind;
}
string phase = FormatPhaseLabel(arc.Phase);
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase))
{
return "";
}
return kind + " · " + phase;
}
/// <summary>
/// Spine Kind word from the live tip Label (Duel/Skirmish/Battle/Mass).
/// Empty when the tip is not a framed combat scale tip.
/// </summary>
private static string CombatSpineKindFromTip(InterestCandidate watch)
{
if (watch == null)
{
return "";
}
string label = watch.Label ?? "";
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))
{
return "Duel";
}
if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase))
{
return "Skirmish";
}
if (label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase))
{
return "Battle";
}
if (label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase))
{
return "Mass";
}
return "";
}
/// <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);
}
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;
_coldHookFired = false;
_coldHookClimaxKey = "";
_coldHookAnchor = "";
CausalHeat.Clear();
CharacterLedger.Clear();
}
public static void Tick(float 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)
{
_active.Advance(StoryPhase.Done, now);
}
}
}
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);
}
CharacterLedger.NoteFeatured(next);
if (IsStoryAssetCandidate(next))
{
if (_active != null && _active.IsActive)
{
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;
}
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
{
BeginOrRefreshArc(next, kind, hardHold, now);
return;
}
// Camera left the story for an unrelated tip - do not keep a lying active arc.
if (_active != null && _active.IsActive && !BelongsToActiveStoryBeat(next, _active))
{
_active.Advance(StoryPhase.Done, now);
}
}
public static void OnEndCurrent(InterestCandidate ended, float now)
{
if (ended == null || _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))
{
if (_active.Phase == StoryPhase.Aftermath)
{
TryInjectEpilogue(_active, now);
}
else if (_active.Phase == StoryPhase.Epilogue)
{
_active.Advance(StoryPhase.Done, now);
}
}
}
/// <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)
{
_active.Advance(StoryPhase.Done, now);
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)
{
_active.Advance(StoryPhase.Done, now);
}
return;
}
if (TryInjectAftermath(climax, _active, now))
{
_active.Advance(StoryPhase.Aftermath, now);
return;
}
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
{
_active.Advance(StoryPhase.Done, now);
}
}
/// <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)
{
return false;
}
if (!string.IsNullOrEmpty(_active.ClimaxKey)
&& string.Equals(current.Key, _active.ClimaxKey, StringComparison.Ordinal))
{
return true;
}
if (!string.IsNullOrEmpty(_active.AnchorKey)
&& !string.IsNullOrEmpty(next.Key)
&& next.Key.IndexOf(_active.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
return _active.ContainsCast(next.SubjectId) || _active.ContainsCast(next.RelatedId);
}
public static bool OwnsCandidate(InterestCandidate c)
{
if (c == null || _active == null || !_active.IsActive)
{
return false;
}
if (IsStoryAssetCandidate(c))
{
return true;
}
if (!string.IsNullOrEmpty(_active.ClimaxKey)
&& string.Equals(c.Key, _active.ClimaxKey, StringComparison.Ordinal))
{
return true;
}
return false;
}
public static float OwnershipBoost(InterestCandidate c)
{
if (!OwnsCandidate(c) || _active == null || !_active.HardHold)
{
// Aftermath/epilogue always get a modest boost so they can win quiet grace.
if (IsStoryAssetCandidate(c))
{
return InterestScoringConfig.Story.arcOwnershipBoost;
}
return 0f;
}
if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue)
{
return InterestScoringConfig.Story.arcOwnershipBoost;
}
return 0f;
}
/// <summary>
/// Raised cut-in margin while a hard arc holds Aftermath/Epilogue against unrelated peers.
/// Returns 0 when the planner does not impose an extra hold.
/// </summary>
public static float ArcHoldMargin(InterestCandidate current, InterestCandidate next)
{
if (_active == null || !_active.HardHold || !_active.IsActive)
{
return 0f;
}
if (_active.Phase != StoryPhase.Aftermath && _active.Phase != StoryPhase.Epilogue)
{
return 0f;
}
if (current == null || next == null)
{
return 0f;
}
// Hold only while the camera is still on the aftermath/epilogue beat.
// Cast life tips (parenthood, sleep) must not keep the raised wall or the village
// ping-pongs under a fake sticky margin.
if (!BelongsToActiveStoryBeat(current, _active))
{
return 0f;
}
if (OwnsCandidate(next) || IsContinuationOf(current, next))
{
return 0f;
}
// Same cast life beats may still cut without the raised margin.
if (_active.ContainsCast(next.SubjectId))
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
return s.arcHoldMargin > 0f ? s.arcHoldMargin : 50f;
}
public static bool PreferOver(InterestCandidate a, InterestCandidate b)
{
if (a == null || b == null || _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.
if (IsStoryAssetCandidate(a)
&& !IsStoryAssetCandidate(b)
&& (_active.Phase == StoryPhase.Aftermath
|| _active.Phase == StoryPhase.Epilogue
|| _active.Phase == StoryPhase.Climax)
&& !IsNaturalAftermathPeer(b, InterestDirector.CurrentCandidate))
{
return true;
}
bool aOwn = OwnsCandidate(a);
bool bOwn = OwnsCandidate(b);
return aOwn && !bOwn;
}
private static void BeginOrRefreshArc(
InterestCandidate climax,
StoryArcKind kind,
bool hardHold,
float now)
{
string anchor = AnchorFor(climax, kind);
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);
if (_active.Phase == StoryPhase.None || _active.Phase == StoryPhase.Done)
{
_active.Advance(StoryPhase.Climax, now);
}
return;
}
_active = new StoryArc
{
Id = "arc:" + kind + ":" + anchor,
Kind = kind,
AnchorKey = anchor,
ClimaxKey = climax.Key ?? "",
StartedAt = now,
HardHold = hardHold
};
_active.Advance(StoryPhase.Climax, now);
NoteCastFrom(climax, _active);
_coldHookFired = false;
_coldHookClimaxKey = "";
_coldHookAnchor = "";
CausalHeat.NoteCast(_active.CastIds);
}
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;
}
// Live tip prose wins over sticky scale-hold so spine Kind matches the Label
// (Duel - … must not keep Mass · Climax after a count dip).
CombatTipScaleHint(c, ref mass);
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>
/// Prefer the orange tip's presented scale over sticky hysteresis for arc Kind.
/// </summary>
private static void CombatTipScaleHint(InterestCandidate c, ref bool mass)
{
if (c == null)
{
return;
}
string label = c.Label ?? "";
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
{
mass = false;
return;
}
if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|| label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|| label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase))
{
mass = true;
}
}
/// <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)
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a != null)
{
next = a;
break;
}
}
}
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);
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;
string label = StoryReason.AftermathLabel(assetId, follow, related);
long followId = EventFeedUtil.SafeId(follow);
long relatedId = EventFeedUtil.SafeId(related);
// 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,
Resumable = true,
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);
CharacterLedger.Touch(followId, 1.2f);
return true;
}
private static void TryInjectEpilogue(StoryArc arc, float now)
{
if (arc == null || arc.Phase == StoryPhase.Epilogue || arc.Phase == StoryPhase.Done)
{
return;
}
Actor follow = null;
Actor related = null;
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 = true,
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);
CharacterLedger.Touch(followId, 0.8f);
}
private static Actor ResolveAftermathFollow(InterestCandidate climax)
{
if (climax == null)
{
return null;
}
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;
}
}
private static bool IsStoryAssetCandidate(InterestCandidate c) =>
c != null && (StoryReason.IsStoryAsset(c.AssetId)
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase));
}