- 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
1542 lines
47 KiB
C#
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));
|
|
}
|