960 lines
29 KiB
C#
960 lines
29 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 readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(64);
|
|
|
|
public static StoryArc Active => _active != null && _active.IsActive ? _active : null;
|
|
|
|
public static void Clear()
|
|
{
|
|
_active = null;
|
|
_coldHookFired = false;
|
|
_coldHookClimaxKey = "";
|
|
CausalHeat.Clear();
|
|
CharacterLedger.Clear();
|
|
}
|
|
|
|
public static void Tick(float now)
|
|
{
|
|
if (_active == null || !_active.IsActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
public static void OnEndCurrent(InterestCandidate ended, float now)
|
|
{
|
|
if (ended == null || _active == null || !_active.IsActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(_active.ClimaxKey)
|
|
&& string.Equals(ended.Key, _active.ClimaxKey, StringComparison.Ordinal)
|
|
&& _active.Phase == StoryPhase.Climax)
|
|
{
|
|
// Cold-hook should have advanced; if not, still try aftermath once.
|
|
if (!_coldHookFired || !string.Equals(_coldHookClimaxKey, ended.Key, StringComparison.Ordinal))
|
|
{
|
|
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;
|
|
// Once per climax key - director may re-enter inactive while min-dwell resets grace.
|
|
if (_coldHookFired
|
|
&& string.Equals(_coldHookClimaxKey, climax.Key, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryClassifyClimax(climax, out StoryArcKind kind, out bool hardHold)
|
|
&& (_active == null || !_active.IsActive))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_active == null || !_active.IsActive
|
|
|| !string.Equals(_active.ClimaxKey, climax.Key, StringComparison.Ordinal))
|
|
{
|
|
if (!TryClassifyClimax(climax, out kind, out hardHold))
|
|
{
|
|
return;
|
|
}
|
|
|
|
BeginOrRefreshArc(climax, kind, hardHold, now);
|
|
}
|
|
|
|
_coldHookFired = true;
|
|
_coldHookClimaxKey = climax.Key ?? "";
|
|
|
|
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 drama already queued (e.g. lover tip in combat_hold) - do not steal.
|
|
return;
|
|
}
|
|
|
|
if (TryInjectAftermath(climax, _active, now))
|
|
{
|
|
_active.Advance(StoryPhase.Aftermath, now);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = "";
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static bool IsLoveBeat(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (asset == "set_lover" || asset == "find_lover" || asset == AftermathLoveKey())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string hap = (c.HappinessEffectId ?? "").Trim().ToLowerInvariant();
|
|
if (hap.IndexOf("lover", StringComparison.Ordinal) >= 0
|
|
|| hap.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0
|
|
|| hap.IndexOf("married", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string cat = (c.Category ?? "").Trim();
|
|
return string.Equals(cat, "Relationship", StringComparison.OrdinalIgnoreCase)
|
|
&& (asset.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| asset.IndexOf("love", StringComparison.OrdinalIgnoreCase) >= 0);
|
|
}
|
|
|
|
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)
|
|
{
|
|
StoryWeights s = InterestScoringConfig.Story;
|
|
float floor = s.aftermathStrength - 5f;
|
|
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;
|
|
}
|
|
|
|
if (p.EventStrength < floor && p.TotalScore < floor)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Prefer natural life/story peers already queued for this cast.
|
|
if (climax != null
|
|
&& (p.SubjectId == climax.SubjectId
|
|
|| p.SubjectId == climax.RelatedId
|
|
|| p.RelatedId == climax.SubjectId
|
|
|| (_active != null && _active.ContainsCast(p.SubjectId))))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// High-strength parked Action tips (lover injects) also block synthetic aftermath.
|
|
if (p.EventStrength >= s.aftermathStrength)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
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);
|
|
string key = "aftermath:" + 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 = 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);
|
|
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;
|
|
}
|
|
|
|
private static Actor ResolveAftermathRelated(InterestCandidate climax, Actor follow)
|
|
{
|
|
if (climax == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long followId = EventFeedUtil.SafeId(follow);
|
|
if (climax.RelatedUnit != null
|
|
&& climax.RelatedUnit.isAlive()
|
|
&& EventFeedUtil.SafeId(climax.RelatedUnit) != followId)
|
|
{
|
|
return climax.RelatedUnit;
|
|
}
|
|
|
|
if (climax.FollowUnit != null
|
|
&& climax.FollowUnit.isAlive()
|
|
&& EventFeedUtil.SafeId(climax.FollowUnit) != followId)
|
|
{
|
|
return climax.FollowUnit;
|
|
}
|
|
|
|
if (climax.PairPartnerId != 0 && climax.PairPartnerId != followId)
|
|
{
|
|
Actor p = EventFeedUtil.FindAliveById(climax.PairPartnerId);
|
|
if (p != null)
|
|
{
|
|
return p;
|
|
}
|
|
}
|
|
|
|
if (climax.PairOwnerId != 0 && climax.PairOwnerId != followId)
|
|
{
|
|
Actor p = EventFeedUtil.FindAliveById(climax.PairOwnerId);
|
|
if (p != null)
|
|
{
|
|
return p;
|
|
}
|
|
}
|
|
|
|
Actor lover = ActorRelation.GetLover(follow);
|
|
if (lover != null)
|
|
{
|
|
return lover;
|
|
}
|
|
|
|
return ActorRelation.GetBestFriend(follow);
|
|
}
|
|
|
|
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 related != null && !related.isAlive()
|
|
? StoryReason.AftermathSurvivor
|
|
: StoryReason.AftermathSurvivor;
|
|
}
|
|
}
|
|
|
|
private static bool IsStoryAssetCandidate(InterestCandidate c) =>
|
|
c != null && (StoryReason.IsStoryAsset(c.AssetId)
|
|
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase));
|
|
}
|