- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters - Schedule episode shots with critical interrupts, replay guards, and a combat budget - Admit emerging main characters through story momentum instead of spectacle - Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity - Expand narrative harness coverage, soak auditing, and product documentation
3324 lines
102 KiB
C#
3324 lines
102 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);
|
|
}
|
|
|
|
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 = "";
|
|
// 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;
|
|
}
|
|
|
|
LifeSagaRoster.NoteFeatured(next);
|
|
|
|
if (IsStoryAssetCandidate(next))
|
|
{
|
|
if (IsCrisisEpilogueCandidate(next) && _crisis != null && _crisis.IsLive)
|
|
{
|
|
_crisis.Advance(CrisisPhase.Epilogue, now);
|
|
// Crisis closer is not a short-arc beat - end watching combat arc so
|
|
// dossier spine / cast state cannot linger under the closer.
|
|
if (_active != null && _active.IsActive)
|
|
{
|
|
EndArc(_active, now);
|
|
_active = null;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
StoryArc owner = ResolveStoryArc(next);
|
|
if (owner == null || !ResumeResolvedArc(owner, now))
|
|
{
|
|
// An expired/orphaned closer may still reach this defense if it was
|
|
// selected in the same frame its board arc ended. Never mutate a peer arc.
|
|
return;
|
|
}
|
|
|
|
if (_active != null && _active.IsActive && ReferenceEquals(_active, owner))
|
|
{
|
|
if (string.Equals(next.AssetId, StoryReason.EpilogueRelated, StringComparison.OrdinalIgnoreCase)
|
|
|| next.Key.StartsWith("epilogue:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_active.Advance(StoryPhase.Epilogue, now);
|
|
}
|
|
else
|
|
{
|
|
_active.Advance(StoryPhase.Aftermath, now);
|
|
}
|
|
|
|
NoteCastFrom(next, _active);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
NoteCrisisFromCandidate(next, now);
|
|
|
|
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
|
|
{
|
|
BeginOrRefreshArc(next, kind, hardHold, now);
|
|
return;
|
|
}
|
|
|
|
// Camera left the story for an unrelated tip - park hard storylines; end soft scraps.
|
|
if (_active != null && _active.IsActive && !BelongsToActiveStoryBeat(next, _active))
|
|
{
|
|
ParkWatching(now);
|
|
}
|
|
}
|
|
|
|
public static void OnEndCurrent(InterestCandidate ended, float now)
|
|
{
|
|
if (ended == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Crisis chapters often have no soft StoryArc - still close when the closer tip ends.
|
|
if (IsCrisisEpilogueCandidate(ended) && _crisis != null && _crisis.IsLive)
|
|
{
|
|
CloseCrisis(now);
|
|
}
|
|
|
|
if (_active == null || !_active.IsActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// follow_lost / max_cap often end a soft duel before quiet-grace cold.
|
|
// Match by theater (Mass↔Duel key flip), not exact ClimaxKey only.
|
|
if (_active.Phase == StoryPhase.Climax && BelongsToClimaxTheater(ended, _active))
|
|
{
|
|
OnClimaxCold(ended);
|
|
}
|
|
|
|
if (IsStoryAssetCandidate(ended))
|
|
{
|
|
StoryArc owner = ResolveStoryArc(ended);
|
|
if (!ReferenceEquals(owner, _active))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_active.Phase == StoryPhase.Aftermath)
|
|
{
|
|
TryInjectEpilogue(_active, now);
|
|
}
|
|
else if (_active.Phase == StoryPhase.Epilogue)
|
|
{
|
|
EndArc(_active, now);
|
|
_active = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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 = "";
|
|
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
|
|
});
|
|
return true;
|
|
}
|
|
|
|
private static void TryInjectEpilogue(StoryArc arc, float now)
|
|
{
|
|
if (arc == null || arc.Phase == StoryPhase.Epilogue || arc.Phase == StoryPhase.Done)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Narrative threads carry consequences forward. The old synthetic related-character
|
|
// closer duplicated that responsibility and generated generic unsupported epilogues.
|
|
arc.Advance(StoryPhase.Done, now);
|
|
}
|
|
|
|
private static Actor ResolveAftermathFollow(InterestCandidate climax)
|
|
{
|
|
if (climax == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
ActorScratch.Clear();
|
|
void AddCand(Actor a)
|
|
{
|
|
if (a == null || !a.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < ActorScratch.Count; i++)
|
|
{
|
|
if (ActorScratch[i] == a)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
ActorScratch.Add(a);
|
|
}
|
|
|
|
AddCand(climax.FollowUnit);
|
|
if (climax.TheaterLeadId != 0)
|
|
{
|
|
AddCand(EventFeedUtil.FindAliveById(climax.TheaterLeadId));
|
|
}
|
|
|
|
if (climax.PairOwnerId != 0)
|
|
{
|
|
AddCand(EventFeedUtil.FindAliveById(climax.PairOwnerId));
|
|
}
|
|
|
|
AddCand(climax.RelatedUnit);
|
|
if (climax.PairPartnerId != 0)
|
|
{
|
|
AddCand(EventFeedUtil.FindAliveById(climax.PairPartnerId));
|
|
}
|
|
|
|
if (climax.Sticky != null)
|
|
{
|
|
for (int i = 0; i < climax.Sticky.SideAIds.Count; i++)
|
|
{
|
|
AddCand(EventFeedUtil.FindAliveById(climax.Sticky.SideAIds[i]));
|
|
}
|
|
|
|
for (int i = 0; i < climax.Sticky.SideBIds.Count; i++)
|
|
{
|
|
AddCand(EventFeedUtil.FindAliveById(climax.Sticky.SideBIds[i]));
|
|
}
|
|
}
|
|
|
|
Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch);
|
|
if (preferred != null)
|
|
{
|
|
return preferred;
|
|
}
|
|
|
|
if (climax.FollowUnit != null && climax.FollowUnit.isAlive())
|
|
{
|
|
return climax.FollowUnit;
|
|
}
|
|
|
|
if (climax.TheaterLeadId != 0)
|
|
{
|
|
Actor lead = EventFeedUtil.FindAliveById(climax.TheaterLeadId);
|
|
if (lead != null)
|
|
{
|
|
return lead;
|
|
}
|
|
}
|
|
|
|
if (climax.PairOwnerId != 0)
|
|
{
|
|
Actor owner = EventFeedUtil.FindAliveById(climax.PairOwnerId);
|
|
if (owner != null)
|
|
{
|
|
return owner;
|
|
}
|
|
}
|
|
|
|
if (climax.RelatedUnit != null && climax.RelatedUnit.isAlive())
|
|
{
|
|
return climax.RelatedUnit;
|
|
}
|
|
|
|
if (climax.PairPartnerId != 0)
|
|
{
|
|
Actor partner = EventFeedUtil.FindAliveById(climax.PairPartnerId);
|
|
if (partner != null)
|
|
{
|
|
return partner;
|
|
}
|
|
}
|
|
|
|
if (climax.Sticky != null)
|
|
{
|
|
for (int i = 0; i < climax.Sticky.SideAIds.Count; i++)
|
|
{
|
|
Actor a = EventFeedUtil.FindAliveById(climax.Sticky.SideAIds[i]);
|
|
if (a != null)
|
|
{
|
|
return a;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < climax.Sticky.SideBIds.Count; i++)
|
|
{
|
|
Actor a = EventFeedUtil.FindAliveById(climax.Sticky.SideBIds[i]);
|
|
if (a != null)
|
|
{
|
|
return a;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mourner via relations of the fallen subject.
|
|
Actor fallenPeer = EventFeedUtil.FindAliveById(climax.SubjectId);
|
|
if (fallenPeer == null && climax.SubjectId != 0)
|
|
{
|
|
// Subject dead - look for lover/friend of related or pair partner.
|
|
Actor partner = climax.RelatedUnit != null && climax.RelatedUnit.isAlive()
|
|
? climax.RelatedUnit
|
|
: EventFeedUtil.FindAliveById(climax.RelatedId);
|
|
if (partner != null)
|
|
{
|
|
Actor mourner = ActorRelation.GetLover(partner) ?? ActorRelation.GetBestFriend(partner);
|
|
if (mourner != null)
|
|
{
|
|
return mourner;
|
|
}
|
|
|
|
return partner;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
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;
|
|
return true;
|
|
}
|
|
}
|