worldbox-observer-mod/IdleSpectator/Story/StoryPlanner.cs
2026-07-21 13:35:07 -05:00

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));
}