- 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
2515 lines
64 KiB
C#
2515 lines
64 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Reflection;
|
||
using UnityEngine;
|
||
|
||
namespace IdleSpectator;
|
||
|
||
/// <summary>
|
||
/// Durable dynamic top-10 life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
|
||
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
|
||
/// </summary>
|
||
public static class LifeSagaRoster
|
||
{
|
||
public const int Cap = 10;
|
||
public const int MaxChapters = 3;
|
||
public const float ChallengerMargin = 1.5f;
|
||
public const float HeatCap = 12f;
|
||
|
||
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap);
|
||
private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(48);
|
||
private static readonly List<Actor> ChallengerScratch = new List<Actor>(64);
|
||
private static readonly List<Actor> UnitScanSnapshot = new List<Actor>(512);
|
||
private static readonly HashSet<long> ScanSeen = new HashSet<long>();
|
||
private static readonly HashSet<long> PreviousIds = new HashSet<long>();
|
||
private static readonly HashSet<long> PlotAuthorCache = new HashSet<long>();
|
||
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
|
||
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
|
||
private static readonly List<long> TouchScratch = new List<long>(8);
|
||
private static readonly List<CharacterStoryState> NarrativeChallengerScratch =
|
||
new List<CharacterStoryState>(8);
|
||
private static float _nextScanAt;
|
||
private static float _nextNarrativeAdmissionAt;
|
||
private static float _roleCacheAt = -999f;
|
||
private static bool _dirty = true;
|
||
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
|
||
private static int _scanIndex;
|
||
private static float _scanStartedAt;
|
||
private static int _scanPinCount;
|
||
|
||
private enum WorldScanPhase
|
||
{
|
||
None,
|
||
Walking,
|
||
Commit
|
||
}
|
||
|
||
/// <summary>Units probed per frame during amortized world admission.</summary>
|
||
private const int WorldScanUnitsPerFrame = 260;
|
||
|
||
/// <summary>Cadence for starting a spread world admission scan.</summary>
|
||
private const float WorldScanIntervalSeconds = 3.5f;
|
||
|
||
/// <summary>Last timed world-scan refill cost in ms (hitch probe).</summary>
|
||
public static float LastWorldScanMs { get; private set; }
|
||
|
||
/// <summary>Last timed soft refill cost in ms (hitch probe).</summary>
|
||
public static float LastSoftRefillMs { get; private set; }
|
||
|
||
public static int Count => Slots.Count;
|
||
|
||
public static bool Dirty
|
||
{
|
||
get => _dirty;
|
||
set => _dirty = value;
|
||
}
|
||
|
||
public static void Clear()
|
||
{
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
Slots[i]?.Chapters.Clear();
|
||
}
|
||
|
||
Slots.Clear();
|
||
Scratch.Clear();
|
||
ChallengerScratch.Clear();
|
||
UnitScanSnapshot.Clear();
|
||
ScanSeen.Clear();
|
||
PreviousIds.Clear();
|
||
PlotAuthorCache.Clear();
|
||
SpeciesCountCache.Clear();
|
||
McOrbitCache.Clear();
|
||
NarrativeChallengerScratch.Clear();
|
||
_nextScanAt = 0f;
|
||
_nextNarrativeAdmissionAt = 0f;
|
||
_roleCacheAt = -999f;
|
||
_scanPhase = WorldScanPhase.None;
|
||
_scanIndex = 0;
|
||
_dirty = true;
|
||
}
|
||
|
||
public static void CopySlots(List<LifeSagaSlot> into)
|
||
{
|
||
if (into == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
into.Clear();
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
if (Slots[i] != null && Slots[i].UnitId != 0)
|
||
{
|
||
into.Add(Slots[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
public static LifeSagaSlot Get(long unitId)
|
||
{
|
||
if (unitId == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
if (Slots[i] != null && Slots[i].UnitId == unitId)
|
||
{
|
||
return Slots[i];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public static bool IsMc(long unitId) => Get(unitId) != null;
|
||
|
||
public static bool IsPrefer(long unitId)
|
||
{
|
||
LifeSagaSlot s = Get(unitId);
|
||
return s != null && s.Prefer;
|
||
}
|
||
|
||
public static bool IsPin(LifeSagaSlot s) =>
|
||
s != null && (s.Prefer || s.GameFavorite);
|
||
|
||
public static bool TogglePrefer(long unitId)
|
||
{
|
||
LifeSagaSlot s = Get(unitId);
|
||
if (s == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
s.Prefer = !s.Prefer;
|
||
_dirty = true;
|
||
return true;
|
||
}
|
||
|
||
public static bool SetPrefer(long unitId, bool prefer)
|
||
{
|
||
LifeSagaSlot s = Get(unitId);
|
||
if (s == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (s.Prefer == prefer)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
s.Prefer = prefer;
|
||
_dirty = true;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Harness: force a living unit onto the roster (bypasses admission for tests).</summary>
|
||
public static bool HarnessForceAdmit(Actor actor)
|
||
{
|
||
if (actor == null || !actor.isAlive())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (id == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
float now = Time.unscaledTime;
|
||
EnsureRoleCache(now, force: true);
|
||
LifeSagaSlot existing = Get(id);
|
||
if (existing != null)
|
||
{
|
||
RefreshSlot(existing, actor, now);
|
||
existing.Heat = Mathf.Max(existing.Heat, 4f);
|
||
existing.TouchedAt = now;
|
||
_dirty = true;
|
||
RebuildMcOrbitCache();
|
||
return true;
|
||
}
|
||
|
||
LifeSagaSlot neu = BuildSlot(actor, now);
|
||
neu.Heat = Mathf.Max(neu.Heat, 4f);
|
||
if (Slots.Count >= Cap)
|
||
{
|
||
int worst = FindWeakestNonPinIndex(now);
|
||
if (worst >= 0)
|
||
{
|
||
Slots.RemoveAt(worst);
|
||
}
|
||
else if (Slots.Count > 0)
|
||
{
|
||
// All pins - still allow harness to force by dropping last non-pin-less slot.
|
||
Slots.RemoveAt(Slots.Count - 1);
|
||
}
|
||
}
|
||
|
||
Slots.Add(neu);
|
||
_dirty = true;
|
||
RebuildMcOrbitCache();
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Harness: run a one-shot world admission scan while Busy normally suppresses discovery.
|
||
/// </summary>
|
||
public static void HarnessWorldScan()
|
||
{
|
||
float now = Time.unscaledTime;
|
||
EnsureRoleCache(now, force: true);
|
||
Scratch.Clear();
|
||
PreviousIds.Clear();
|
||
HashSet<long> seen = new HashSet<long>();
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
LifeSagaSlot s = Slots[i];
|
||
if (s == null || s.UnitId == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
RefreshSlot(s, a, now);
|
||
Scratch.Add(s);
|
||
seen.Add(s.UnitId);
|
||
PreviousIds.Add(s.UnitId);
|
||
}
|
||
|
||
try
|
||
{
|
||
if (World.world?.units != null)
|
||
{
|
||
foreach (Actor actor in World.world.units)
|
||
{
|
||
if (actor == null || !actor.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (id == 0 || seen.Contains(id))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!IsAdmissionCandidate(actor))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Scratch.Add(BuildSlot(actor, now));
|
||
seen.Add(id);
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
RankAndCap(Scratch, now, PreviousIds);
|
||
Slots.Clear();
|
||
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
|
||
{
|
||
Slots.Add(Scratch[i]);
|
||
}
|
||
|
||
_dirty = true;
|
||
_nextScanAt = now + 2.5f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Harness helper: set Standing/Heat for a roster unit so replace tests can age them out.
|
||
/// </summary>
|
||
public static bool HarnessSetScores(long unitId, float standing, float heat)
|
||
{
|
||
LifeSagaSlot s = Get(unitId);
|
||
if (s == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
s.Standing = standing;
|
||
s.Heat = Mathf.Clamp(heat, 0f, HeatCap);
|
||
s.TouchedAt = Time.unscaledTime - 1f;
|
||
s.AdmittedAt = Time.unscaledTime - 1f;
|
||
_dirty = true;
|
||
return true;
|
||
}
|
||
|
||
public static float ScoreBonus(InterestCandidate c)
|
||
{
|
||
if (c == null)
|
||
{
|
||
return 0f;
|
||
}
|
||
|
||
StoryWeights sw = InterestScoringConfig.Story;
|
||
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 12f;
|
||
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
|
||
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
|
||
float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
|
||
float cross = sw.sagaCrossMcBonus > 0f ? sw.sagaCrossMcBonus : 11f;
|
||
|
||
float best = BonusForUnit(c.SubjectId, mc, prefer, fav, cast);
|
||
if (c.RelatedId != 0)
|
||
{
|
||
best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav, cast) * 0.85f);
|
||
}
|
||
|
||
long followId = EventFeedUtil.SafeId(c.FollowUnit);
|
||
if (followId != 0 && followId != c.SubjectId && followId != c.RelatedId)
|
||
{
|
||
best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav, cast));
|
||
}
|
||
|
||
if (CountTouchedMcs(c) >= 2)
|
||
{
|
||
best += cross;
|
||
}
|
||
|
||
float cap = Mathf.Max(prefer, fav) + mc + cross;
|
||
return Mathf.Min(best, cap);
|
||
}
|
||
|
||
private static float BonusForUnit(long unitId, float mc, float prefer, float fav, float cast)
|
||
{
|
||
if (unitId == 0)
|
||
{
|
||
return 0f;
|
||
}
|
||
|
||
LifeSagaSlot s = Get(unitId);
|
||
if (s != null)
|
||
{
|
||
float bonus = mc;
|
||
if (s.Prefer)
|
||
{
|
||
bonus += prefer;
|
||
}
|
||
|
||
if (s.GameFavorite)
|
||
{
|
||
bonus += fav;
|
||
}
|
||
|
||
return bonus;
|
||
}
|
||
|
||
return IsMcCast(unitId) ? cast : 0f;
|
||
}
|
||
|
||
/// <summary>Among candidates, pick Prefer'd MC, else any MC, else null.</summary>
|
||
public static Actor PreferRosterUnit(IEnumerable<Actor> candidates)
|
||
{
|
||
if (candidates == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
float now = Time.unscaledTime;
|
||
float dull = DullSeconds();
|
||
Actor bestPrefer = null;
|
||
Actor bestMc = null;
|
||
float bestPreferScore = float.MinValue;
|
||
float bestMcScore = float.MinValue;
|
||
foreach (Actor a in candidates)
|
||
{
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(a);
|
||
LifeSagaSlot s = Get(id);
|
||
if (s == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float score = EffectiveInterest(s, now, dull);
|
||
if (s.Prefer && score >= bestPreferScore)
|
||
{
|
||
bestPreferScore = score;
|
||
bestPrefer = a;
|
||
}
|
||
|
||
if (score >= bestMcScore)
|
||
{
|
||
bestMcScore = score;
|
||
bestMc = a;
|
||
}
|
||
}
|
||
|
||
return bestPrefer ?? bestMc;
|
||
}
|
||
|
||
public static bool TipTouchesMc(InterestCandidate c)
|
||
{
|
||
if (c == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (IsMc(c.SubjectId) || IsMc(c.RelatedId))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return IsMc(EventFeedUtil.SafeId(c.FollowUnit));
|
||
}
|
||
|
||
public static bool TipTouchesPrefer(InterestCandidate c)
|
||
{
|
||
if (c == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (IsPrefer(c.SubjectId) || IsPrefer(c.RelatedId))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
|
||
}
|
||
|
||
public static bool TipTouchesMcCast(InterestCandidate c)
|
||
{
|
||
if (c == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (IsMcCast(c.SubjectId) || IsMcCast(c.RelatedId))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return IsMcCast(EventFeedUtil.SafeId(c.FollowUnit));
|
||
}
|
||
|
||
/// <summary>Prefer=4, CrossMC=3, MC=2, MC cast=1, else 0.</summary>
|
||
public static int SoftBiasRank(InterestCandidate c)
|
||
{
|
||
if (c == null)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
if (TipTouchesPrefer(c))
|
||
{
|
||
return 4;
|
||
}
|
||
|
||
if (CountTouchedMcs(c) >= 2)
|
||
{
|
||
return 3;
|
||
}
|
||
|
||
if (TipTouchesMc(c))
|
||
{
|
||
return 2;
|
||
}
|
||
|
||
return TipTouchesMcCast(c) ? 1 : 0;
|
||
}
|
||
|
||
/// <summary>Near-tie breaker: Prefer > CrossMC > MC > MC cast > neither.</summary>
|
||
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
|
||
{
|
||
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
|
||
}
|
||
|
||
/// <summary>True when unit is in a roster MC's live story orbit (not themselves an MC).</summary>
|
||
public static bool IsMcCast(long unitId)
|
||
{
|
||
if (unitId == 0 || IsMc(unitId))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return McOrbitCache.Contains(unitId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Roster MCs touched by the tip for rail involved chrome.
|
||
/// Bounded to subject/related/follow/pair/short-arc cast - not mass ParticipantIds.
|
||
/// </summary>
|
||
public static void CollectTouchedMcIds(InterestCandidate tip, List<long> dest)
|
||
{
|
||
if (dest == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
dest.Clear();
|
||
if (tip == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
void Add(long id)
|
||
{
|
||
if (id == 0 || !IsMc(id))
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < dest.Count; i++)
|
||
{
|
||
if (dest[i] == id)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
dest.Add(id);
|
||
}
|
||
|
||
Add(tip.SubjectId);
|
||
Add(tip.RelatedId);
|
||
Add(EventFeedUtil.SafeId(tip.FollowUnit));
|
||
Add(tip.PairOwnerId);
|
||
Add(tip.PairPartnerId);
|
||
Add(tip.TheaterLeadId);
|
||
|
||
StoryArc arc = StoryPlanner.Active;
|
||
if (arc?.CastIds != null)
|
||
{
|
||
for (int i = 0; i < arc.CastIds.Count; i++)
|
||
{
|
||
Add(arc.CastIds[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
public static int CountTouchedMcs(InterestCandidate tip)
|
||
{
|
||
CollectTouchedMcIds(tip, TouchScratch);
|
||
return TouchScratch.Count;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Heat roster MCs early while a sticky combat/war/plot tip still holds two living principals.
|
||
/// Helps Pick land on cross-MC moments before fact recording.
|
||
/// </summary>
|
||
public static void NoteFeaturedCrossMcPrincipals(InterestCandidate tip, float heat = 0.85f)
|
||
{
|
||
if (tip == null || heat <= 0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
CollectTouchedMcIds(tip, TouchScratch);
|
||
if (TouchScratch.Count < 2)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < TouchScratch.Count; i++)
|
||
{
|
||
NoteFeaturedUnit(TouchScratch[i], heat);
|
||
}
|
||
}
|
||
|
||
private static bool IsInMcOrbit(long mcId, long otherId)
|
||
{
|
||
if (mcId == 0 || otherId == 0 || mcId == otherId)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
Actor mc = EventFeedUtil.FindAliveById(mcId);
|
||
if (mc != null)
|
||
{
|
||
Actor lover = ActorRelation.GetLover(mc);
|
||
if (lover != null && EventFeedUtil.SafeId(lover) == otherId)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
Actor friend = ActorRelation.GetBestFriend(mc);
|
||
if (friend != null && EventFeedUtil.SafeId(friend) == otherId)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
try
|
||
{
|
||
foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
|
||
{
|
||
if (EventFeedUtil.SafeId(parent) == otherId)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
|
||
{
|
||
if (EventFeedUtil.SafeId(child) == otherId)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore relation probe failures
|
||
}
|
||
}
|
||
|
||
if (LifeSagaMemory.TryGetEarnedRival(mcId, out LifeSagaFact rival)
|
||
&& rival != null
|
||
&& rival.OtherId == otherId)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
public static void StampChapter(long unitId, string line, float now = -1f)
|
||
{
|
||
StampChapter(unitId, LifeSagaChapterKind.Unknown, line, now);
|
||
}
|
||
|
||
public static void StampChapter(
|
||
long unitId,
|
||
LifeSagaChapterKind kind,
|
||
string line,
|
||
float now = -1f)
|
||
{
|
||
if (unitId == 0 || string.IsNullOrEmpty(line))
|
||
{
|
||
return;
|
||
}
|
||
|
||
LifeSagaSlot s = Get(unitId);
|
||
if (s == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (now < 0f)
|
||
{
|
||
now = Time.unscaledTime;
|
||
}
|
||
|
||
string trimmed = CleanChapterLine(line);
|
||
if (string.IsNullOrEmpty(trimmed))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Never stamp the unit's own name as a chapter beat.
|
||
if (!string.IsNullOrEmpty(s.DisplayName)
|
||
&& string.Equals(trimmed, s.DisplayName.Trim(), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (s.Chapters.Count > 0
|
||
&& string.Equals(s.Chapters[0]?.Line, trimmed, StringComparison.Ordinal))
|
||
{
|
||
s.Chapters[0].Kind = kind;
|
||
s.Chapters[0].At = now;
|
||
s.LastChapterAt = now;
|
||
s.TouchedAt = now;
|
||
return;
|
||
}
|
||
|
||
s.Chapters.Insert(0, new LifeSagaChapter
|
||
{
|
||
Kind = kind,
|
||
Line = trimmed,
|
||
At = now
|
||
});
|
||
while (s.Chapters.Count > MaxChapters)
|
||
{
|
||
s.Chapters.RemoveAt(s.Chapters.Count - 1);
|
||
}
|
||
|
||
s.LastChapterAt = now;
|
||
s.TouchedAt = now;
|
||
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
|
||
_dirty = true;
|
||
}
|
||
|
||
public static void Tick(float now)
|
||
{
|
||
if (now >= _nextNarrativeAdmissionAt)
|
||
{
|
||
_nextNarrativeAdmissionAt = now + 1f;
|
||
ConsiderEmergingStories(now);
|
||
}
|
||
|
||
if (_scanPhase != WorldScanPhase.None)
|
||
{
|
||
ContinueAmortizedWorldScan(now);
|
||
return;
|
||
}
|
||
|
||
if (now < _nextScanAt)
|
||
{
|
||
if (_dirty)
|
||
{
|
||
// Membership / Prefer / heat-driven dirty: re-rank existing slots only.
|
||
// Full world admission stays on the timed cadence to avoid hitch spikes.
|
||
Refill(now, worldScan: false);
|
||
}
|
||
else
|
||
{
|
||
PruneDead(now);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
_nextScanAt = now + WorldScanIntervalSeconds;
|
||
if (AgentHarness.Busy)
|
||
{
|
||
Refill(now, worldScan: false);
|
||
return;
|
||
}
|
||
|
||
BeginAmortizedWorldScan(now);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Spread admission discovery across frames so a 2k-unit map cannot hitch on one Tick.
|
||
/// </summary>
|
||
private static void BeginAmortizedWorldScan(float now)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
EnsureRoleCache(now, force: false);
|
||
Scratch.Clear();
|
||
PreviousIds.Clear();
|
||
ScanSeen.Clear();
|
||
ChallengerScratch.Clear();
|
||
UnitScanSnapshot.Clear();
|
||
_scanPinCount = 0;
|
||
_scanStartedAt = now;
|
||
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
LifeSagaSlot s = Slots[i];
|
||
if (s == null || s.UnitId == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
RefreshSlot(s, a, now);
|
||
Scratch.Add(s);
|
||
ScanSeen.Add(s.UnitId);
|
||
PreviousIds.Add(s.UnitId);
|
||
if (IsPin(s))
|
||
{
|
||
_scanPinCount++;
|
||
}
|
||
}
|
||
|
||
if (_scanPinCount >= Cap)
|
||
{
|
||
FinishRefillFromScratch(now, worldScan: true, sw);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (World.world?.units != null)
|
||
{
|
||
foreach (Actor actor in World.world.units)
|
||
{
|
||
if (actor != null && actor.isAlive())
|
||
{
|
||
UnitScanSnapshot.Add(actor);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
_scanIndex = 0;
|
||
_scanPhase = WorldScanPhase.Walking;
|
||
sw.Stop();
|
||
LastWorldScanMs = (float)sw.Elapsed.TotalMilliseconds;
|
||
}
|
||
|
||
private static void ContinueAmortizedWorldScan(float now)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
if (_scanPhase == WorldScanPhase.Walking)
|
||
{
|
||
int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, UnitScanSnapshot.Count);
|
||
for (int i = _scanIndex; i < end; i++)
|
||
{
|
||
Actor actor = UnitScanSnapshot[i];
|
||
if (actor == null || !actor.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (id == 0 || ScanSeen.Contains(id))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!IsAdmissionCandidate(actor))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
ChallengerScratch.Add(actor);
|
||
ScanSeen.Add(id);
|
||
}
|
||
|
||
_scanIndex = end;
|
||
if (_scanIndex < UnitScanSnapshot.Count)
|
||
{
|
||
sw.Stop();
|
||
LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds);
|
||
return;
|
||
}
|
||
|
||
_scanPhase = WorldScanPhase.Commit;
|
||
}
|
||
|
||
if (_scanPhase == WorldScanPhase.Commit)
|
||
{
|
||
const int MaxScanAdmits = Cap * 2;
|
||
if (ChallengerScratch.Count > MaxScanAdmits)
|
||
{
|
||
ChallengerScratch.Sort(CompareAdmitPriorityDesc);
|
||
ChallengerScratch.RemoveRange(MaxScanAdmits, ChallengerScratch.Count - MaxScanAdmits);
|
||
}
|
||
|
||
for (int i = 0; i < ChallengerScratch.Count; i++)
|
||
{
|
||
Actor actor = ChallengerScratch[i];
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
if (id == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Scratch.Add(BuildSlot(actor, now));
|
||
}
|
||
|
||
FinishRefillFromScratch(now, worldScan: true, sw);
|
||
ChallengerScratch.Clear();
|
||
UnitScanSnapshot.Clear();
|
||
ScanSeen.Clear();
|
||
_scanPhase = WorldScanPhase.None;
|
||
_scanIndex = 0;
|
||
return;
|
||
}
|
||
|
||
sw.Stop();
|
||
}
|
||
|
||
private static void FinishRefillFromScratch(
|
||
float now,
|
||
bool worldScan,
|
||
System.Diagnostics.Stopwatch sw)
|
||
{
|
||
RankAndCap(Scratch, now, PreviousIds);
|
||
Slots.Clear();
|
||
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
|
||
{
|
||
Slots.Add(Scratch[i]);
|
||
}
|
||
|
||
_dirty = false;
|
||
RebuildMcOrbitCache();
|
||
sw.Stop();
|
||
float ms = (float)sw.Elapsed.TotalMilliseconds;
|
||
if (worldScan)
|
||
{
|
||
LastWorldScanMs = Mathf.Max(LastWorldScanMs, ms);
|
||
}
|
||
else
|
||
{
|
||
LastSoftRefillMs = ms;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Soft featured tips heat existing MCs only - never admit nobodies.
|
||
/// </summary>
|
||
public static void NoteFeatured(InterestCandidate scene)
|
||
{
|
||
if (scene == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
float now = Time.unscaledTime;
|
||
NoteUnit(scene.SubjectId, 0.8f, now);
|
||
if (scene.RelatedId != 0)
|
||
{
|
||
NoteUnit(scene.RelatedId, 0.5f, now);
|
||
}
|
||
|
||
long follow = EventFeedUtil.SafeId(scene.FollowUnit);
|
||
if (follow != 0)
|
||
{
|
||
NoteUnit(follow, 1f, now);
|
||
}
|
||
}
|
||
|
||
/// <summary>Soft heat bump for an existing roster MC (cross-MC moments).</summary>
|
||
public static void NoteFeaturedUnit(long unitId, float heat = 1f)
|
||
{
|
||
if (unitId == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
NoteUnit(unitId, heat, Time.unscaledTime);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Challenge Cap with a living unit. Admission roles always may compete;
|
||
/// ordinary characters only when coherent story momentum qualifies them.
|
||
/// </summary>
|
||
public static bool ConsiderAdmit(
|
||
long unitId,
|
||
float heat,
|
||
float now = -1f,
|
||
bool emergingStoryAdmit = false)
|
||
{
|
||
if (unitId == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (now < 0f)
|
||
{
|
||
now = Time.unscaledTime;
|
||
}
|
||
|
||
Actor actor = EventFeedUtil.FindAliveById(unitId);
|
||
if (actor == null || !actor.isAlive())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
LifeSagaSlot existing = Get(unitId);
|
||
if (existing != null)
|
||
{
|
||
// Heat-only: do not RefreshSlot or force a world Refill (idle hot path).
|
||
existing.Heat = Mathf.Min(HeatCap, existing.Heat + Mathf.Max(0f, heat));
|
||
existing.TouchedAt = now;
|
||
return true;
|
||
}
|
||
|
||
EnsureRoleCache(now, force: false);
|
||
bool admission = IsAdmissionCandidate(actor);
|
||
if (!admission && !emergingStoryAdmit)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
LifeSagaSlot neu = BuildSlot(
|
||
actor,
|
||
now,
|
||
emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory
|
||
: LifeSagaAdmissionReason.None);
|
||
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
|
||
Scratch.Clear();
|
||
PreviousIds.Clear();
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
if (Slots[i] != null && Slots[i].UnitId != 0)
|
||
{
|
||
Scratch.Add(Slots[i]);
|
||
PreviousIds.Add(Slots[i].UnitId);
|
||
}
|
||
}
|
||
|
||
Scratch.Add(neu);
|
||
RankAndCap(Scratch, now, PreviousIds);
|
||
Slots.Clear();
|
||
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
|
||
{
|
||
Slots.Add(Scratch[i]);
|
||
}
|
||
|
||
_dirty = true;
|
||
return IsMc(unitId);
|
||
}
|
||
|
||
private static void ConsiderEmergingStories(float now)
|
||
{
|
||
NarrativeStoryStore.CopyEmergingCharacters(NarrativeChallengerScratch, 6f, 4);
|
||
for (int i = 0; i < NarrativeChallengerScratch.Count; i++)
|
||
{
|
||
CharacterStoryState state = NarrativeChallengerScratch[i];
|
||
if (Get(state.CharacterId) != null) continue;
|
||
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
|
||
ConsiderAdmit(
|
||
state.CharacterId,
|
||
heat: Mathf.Clamp(potential * 0.5f, 1f, HeatCap),
|
||
now: now,
|
||
emergingStoryAdmit: true);
|
||
}
|
||
}
|
||
|
||
private static void NoteUnit(long unitId, float heat, float now)
|
||
{
|
||
LifeSagaSlot s = Get(unitId);
|
||
if (s == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Soft heat must not mark Dirty - that forced a full world Refill every tip.
|
||
s.Heat = Mathf.Min(HeatCap, s.Heat + heat);
|
||
s.TouchedAt = now;
|
||
}
|
||
|
||
private static void Refill(float now, bool worldScan)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
// Soft / harness path: never walk the whole world on this call.
|
||
// Live AFK discovery uses BeginAmortizedWorldScan instead.
|
||
EnsureRoleCache(now, force: false);
|
||
Scratch.Clear();
|
||
PreviousIds.Clear();
|
||
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
LifeSagaSlot s = Slots[i];
|
||
if (s == null || s.UnitId == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
RefreshSlot(s, a, now);
|
||
Scratch.Add(s);
|
||
PreviousIds.Add(s.UnitId);
|
||
}
|
||
|
||
_ = worldScan;
|
||
FinishRefillFromScratch(now, worldScan: false, sw);
|
||
}
|
||
|
||
private static void RebuildMcOrbitCache()
|
||
{
|
||
McOrbitCache.Clear();
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
LifeSagaSlot slot = Slots[i];
|
||
if (slot == null || slot.UnitId == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Actor mc = EventFeedUtil.FindAliveById(slot.UnitId);
|
||
if (mc == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
void Add(Actor a)
|
||
{
|
||
long id = EventFeedUtil.SafeId(a);
|
||
if (id != 0 && id != slot.UnitId)
|
||
{
|
||
McOrbitCache.Add(id);
|
||
}
|
||
}
|
||
|
||
Add(ActorRelation.GetLover(mc));
|
||
Add(ActorRelation.GetBestFriend(mc));
|
||
try
|
||
{
|
||
foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
|
||
{
|
||
Add(parent);
|
||
}
|
||
|
||
foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
|
||
{
|
||
Add(child);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
if (LifeSagaMemory.TryGetEarnedRival(slot.UnitId, out LifeSagaFact rival)
|
||
&& rival != null
|
||
&& rival.OtherId != 0)
|
||
{
|
||
McOrbitCache.Add(rival.OtherId);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void PruneDead(float now)
|
||
{
|
||
bool changed = false;
|
||
for (int i = Slots.Count - 1; i >= 0; i--)
|
||
{
|
||
LifeSagaSlot s = Slots[i];
|
||
if (s == null)
|
||
{
|
||
Slots.RemoveAt(i);
|
||
changed = true;
|
||
continue;
|
||
}
|
||
|
||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
Slots.RemoveAt(i);
|
||
changed = true;
|
||
}
|
||
// Do not RefreshSlot here - that ran ~20×/frame (Director + Rail) and dominated idle CPU.
|
||
}
|
||
|
||
if (changed)
|
||
{
|
||
_dirty = true;
|
||
_nextScanAt = now;
|
||
}
|
||
}
|
||
|
||
private static void RankAndCap(List<LifeSagaSlot> list, float now, HashSet<long> previousIds)
|
||
{
|
||
float dull = DullSeconds();
|
||
list.Sort((a, b) => CompareSlots(a, b, now, dull));
|
||
|
||
if (list.Count <= Cap)
|
||
{
|
||
ApplyChallengerMargin(list, previousIds, now, dull);
|
||
return;
|
||
}
|
||
|
||
Dictionary<string, int> kingdomCounts = new Dictionary<string, int>();
|
||
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
|
||
List<LifeSagaSlot> kept = new List<LifeSagaSlot>(Cap);
|
||
|
||
// Reserve Prefer / game-favorite pins first (full Cap cost).
|
||
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
|
||
{
|
||
LifeSagaSlot s = list[i];
|
||
if (s == null || !IsPin(s) || kept.Contains(s))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
kept.Add(s);
|
||
Bump(kingdomCounts, s.KingdomKey);
|
||
Bump(speciesCounts, s.SpeciesId);
|
||
}
|
||
|
||
for (int pass = 0; pass < 2; pass++)
|
||
{
|
||
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
|
||
{
|
||
LifeSagaSlot s = list[i];
|
||
if (s == null || kept.Contains(s) || IsPin(s))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bool kingdomCrowded = CountOf(kingdomCounts, s.KingdomKey) >= 2 && pass == 0;
|
||
bool speciesCrowded = CountOf(speciesCounts, s.SpeciesId) >= 2 && pass == 0;
|
||
if (pass == 0 && (kingdomCrowded || speciesCrowded))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
kept.Add(s);
|
||
Bump(kingdomCounts, s.KingdomKey);
|
||
Bump(speciesCounts, s.SpeciesId);
|
||
}
|
||
}
|
||
|
||
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
|
||
{
|
||
if (!kept.Contains(list[i]))
|
||
{
|
||
kept.Add(list[i]);
|
||
}
|
||
}
|
||
|
||
list.Clear();
|
||
list.AddRange(kept);
|
||
ApplyChallengerMargin(list, previousIds, now, dull);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Newcomers must beat the weakest displaced non-pin by ChallengerMargin.
|
||
/// </summary>
|
||
private static void ApplyChallengerMargin(
|
||
List<LifeSagaSlot> kept,
|
||
HashSet<long> previousIds,
|
||
float now,
|
||
float dull)
|
||
{
|
||
if (kept == null || previousIds == null || previousIds.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Collect displaced living incumbents (non-pins).
|
||
List<LifeSagaSlot> displaced = null;
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
LifeSagaSlot s = Slots[i];
|
||
if (s == null || s.UnitId == 0 || IsPin(s))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (IndexOfUnit(kept, s.UnitId) >= 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||
if (a == null || !a.isAlive())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (displaced == null)
|
||
{
|
||
displaced = new List<LifeSagaSlot>(4);
|
||
}
|
||
|
||
displaced.Add(s);
|
||
}
|
||
|
||
if (displaced == null || displaced.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
displaced.Sort((a, b) =>
|
||
EffectiveInterest(a, now, dull).CompareTo(EffectiveInterest(b, now, dull)));
|
||
|
||
for (int d = 0; d < displaced.Count; d++)
|
||
{
|
||
LifeSagaSlot incumbent = displaced[d];
|
||
float need = EffectiveInterest(incumbent, now, dull) + ChallengerMargin;
|
||
int worstNewcomer = -1;
|
||
float worstScore = float.MaxValue;
|
||
for (int i = 0; i < kept.Count; i++)
|
||
{
|
||
LifeSagaSlot s = kept[i];
|
||
if (s == null || IsPin(s) || previousIds.Contains(s.UnitId))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float score = EffectiveInterest(s, now, dull);
|
||
if (score < worstScore)
|
||
{
|
||
worstScore = score;
|
||
worstNewcomer = i;
|
||
}
|
||
}
|
||
|
||
if (worstNewcomer < 0)
|
||
{
|
||
break;
|
||
}
|
||
|
||
if (worstScore + 0.001f < need)
|
||
{
|
||
kept[worstNewcomer] = incumbent;
|
||
}
|
||
}
|
||
|
||
kept.Sort((a, b) => CompareSlots(a, b, now, dull));
|
||
while (kept.Count > Cap)
|
||
{
|
||
// Drop weakest non-pin from the end after re-sort (sort is desc, so end is weakest).
|
||
int drop = -1;
|
||
for (int i = kept.Count - 1; i >= 0; i--)
|
||
{
|
||
if (!IsPin(kept[i]))
|
||
{
|
||
drop = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (drop < 0)
|
||
{
|
||
break;
|
||
}
|
||
|
||
kept.RemoveAt(drop);
|
||
}
|
||
}
|
||
|
||
private static int CompareSlots(LifeSagaSlot a, LifeSagaSlot b, float now, float dull)
|
||
{
|
||
if (a == null && b == null)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
if (a == null)
|
||
{
|
||
return 1;
|
||
}
|
||
|
||
if (b == null)
|
||
{
|
||
return -1;
|
||
}
|
||
|
||
int pin = (IsPin(b) ? 1 : 0).CompareTo(IsPin(a) ? 1 : 0);
|
||
if (pin != 0)
|
||
{
|
||
return pin;
|
||
}
|
||
|
||
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
|
||
if (fav != 0)
|
||
{
|
||
return fav;
|
||
}
|
||
|
||
float ai = EffectiveInterest(a, now, dull);
|
||
float bi = EffectiveInterest(b, now, dull);
|
||
int cmp = bi.CompareTo(ai);
|
||
if (cmp != 0)
|
||
{
|
||
return cmp;
|
||
}
|
||
|
||
return b.TouchedAt.CompareTo(a.TouchedAt);
|
||
}
|
||
|
||
public static float EffectiveInterest(LifeSagaSlot s, float now = -1f, float dull = -1f)
|
||
{
|
||
if (s == null)
|
||
{
|
||
return 0f;
|
||
}
|
||
|
||
if (now < 0f)
|
||
{
|
||
now = Time.unscaledTime;
|
||
}
|
||
|
||
if (dull < 0f)
|
||
{
|
||
dull = DullSeconds();
|
||
}
|
||
|
||
float standing = s.Standing;
|
||
float heat = s.Heat;
|
||
float ageAnchor = Mathf.Max(s.AdmittedAt, Mathf.Max(s.TouchedAt, s.LastChapterAt));
|
||
if (ageAnchor <= 0f)
|
||
{
|
||
ageAnchor = now;
|
||
}
|
||
|
||
float age = now - ageAnchor;
|
||
if (age > 0f && dull > 0f && !IsPin(s))
|
||
{
|
||
// Heat decays; standing softens after dull window so idle early admits age out.
|
||
float heatFactor = Mathf.Clamp01(1f - (age / (dull * 1.25f)));
|
||
heat *= heatFactor;
|
||
if (age > dull)
|
||
{
|
||
standing *= 0.35f;
|
||
}
|
||
}
|
||
|
||
float storyPotential = Mathf.Min(12f, StoryScheduler.StoryPotential(s.UnitId) * 0.8f);
|
||
float score = standing + heat + storyPotential;
|
||
if (s.GameFavorite)
|
||
{
|
||
score += 50f;
|
||
}
|
||
|
||
if (s.Prefer)
|
||
{
|
||
score += 8f;
|
||
}
|
||
|
||
return score;
|
||
}
|
||
|
||
private static float DullSeconds()
|
||
{
|
||
StoryWeights sw = InterestScoringConfig.Story;
|
||
return sw.sagaDullSeconds > 0f ? sw.sagaDullSeconds : 300f;
|
||
}
|
||
|
||
private static void Bump(Dictionary<string, int> map, string key)
|
||
{
|
||
if (string.IsNullOrEmpty(key))
|
||
{
|
||
key = "_";
|
||
}
|
||
|
||
if (!map.TryGetValue(key, out int n))
|
||
{
|
||
n = 0;
|
||
}
|
||
|
||
map[key] = n + 1;
|
||
}
|
||
|
||
private static int CountOf(Dictionary<string, int> map, string key)
|
||
{
|
||
if (string.IsNullOrEmpty(key))
|
||
{
|
||
key = "_";
|
||
}
|
||
|
||
return map.TryGetValue(key, out int n) ? n : 0;
|
||
}
|
||
|
||
private static int IndexOfUnit(List<LifeSagaSlot> list, long unitId)
|
||
{
|
||
for (int i = 0; i < list.Count; i++)
|
||
{
|
||
if (list[i] != null && list[i].UnitId == unitId)
|
||
{
|
||
return i;
|
||
}
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
private static int FindWeakestNonPinIndex(float now)
|
||
{
|
||
float dull = DullSeconds();
|
||
int worst = -1;
|
||
float worstScore = float.MaxValue;
|
||
for (int i = 0; i < Slots.Count; i++)
|
||
{
|
||
if (Slots[i] == null || IsPin(Slots[i]))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
float score = EffectiveInterest(Slots[i], now, dull);
|
||
if (score < worstScore)
|
||
{
|
||
worstScore = score;
|
||
worst = i;
|
||
}
|
||
}
|
||
|
||
return worst;
|
||
}
|
||
|
||
private static bool IsAdmissionCandidate(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (actor.isFavorite())
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
if (InterestScoring.IsNotable(actor))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (IsPackAlpha(actor)
|
||
|| IsClanChief(actor)
|
||
|| IsArmyCaptain(actor)
|
||
|| IsActivePlotAuthor(actor)
|
||
|| IsFounder(actor)
|
||
|| IsSpeciesSingleton(actor))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
|
||
/// </summary>
|
||
private static int CompareAdmitPriorityDesc(Actor a, Actor b)
|
||
{
|
||
return AdmitPriority(b).CompareTo(AdmitPriority(a));
|
||
}
|
||
|
||
private static float AdmitPriority(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return 0f;
|
||
}
|
||
|
||
float p = 0f;
|
||
try
|
||
{
|
||
if (actor.isFavorite())
|
||
{
|
||
p += 1000f;
|
||
}
|
||
|
||
if (actor.isKing())
|
||
{
|
||
p += 400f;
|
||
}
|
||
|
||
if (actor.isCityLeader())
|
||
{
|
||
p += 300f;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
if (InterestScoring.IsNotable(actor))
|
||
{
|
||
p += 200f;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (actor.data != null)
|
||
{
|
||
p += Mathf.Min(80f, actor.data.kills * 0.5f);
|
||
}
|
||
|
||
p += Mathf.Min(60f, actor.renown * 0.25f);
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
if (IsClanChief(actor) || IsArmyCaptain(actor) || IsPackAlpha(actor))
|
||
{
|
||
p += 120f;
|
||
}
|
||
|
||
if (IsActivePlotAuthor(actor) || IsFounder(actor) || IsSpeciesSingleton(actor))
|
||
{
|
||
p += 80f;
|
||
}
|
||
|
||
return p;
|
||
}
|
||
|
||
public static bool IsPackAlpha(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
Family family = actor.family;
|
||
if (family == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
Actor alpha = family.getAlpha();
|
||
return alpha != null && alpha == actor;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public static bool IsClanChief(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
bool hasClan = false;
|
||
try
|
||
{
|
||
hasClan = actor.hasClan();
|
||
}
|
||
catch
|
||
{
|
||
hasClan = ReadMember(actor, "clan", "get_clan") != null;
|
||
}
|
||
|
||
if (!hasClan)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
object clan = ReadMember(actor, "clan", "get_clan");
|
||
if (clan == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
long chiefId = ReadLongMember(clan, "chief_id", "get_chief_id");
|
||
if (chiefId == 0)
|
||
{
|
||
object chief = ReadMember(clan, "chief", "getChief", "get_chief");
|
||
if (chief is Actor chiefActor)
|
||
{
|
||
return chiefActor == actor;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
long self = EventFeedUtil.SafeId(actor);
|
||
return self != 0 && chiefId == self;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public static bool IsArmyCaptain(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
object flag = ReadMember(actor, "is_army_captain", "get_is_army_captain");
|
||
if (flag is bool b && b)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// fall through
|
||
}
|
||
|
||
try
|
||
{
|
||
object army = ReadMember(actor, "army", "get_army");
|
||
if (army == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
object captain = ReadMember(army, "captain", "getCaptain", "get_captain");
|
||
return captain is Actor cap && cap == actor;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static readonly Dictionary<string, MemberInfo> MemberCache =
|
||
new Dictionary<string, MemberInfo>(128);
|
||
|
||
private static object ReadMember(object target, params string[] names)
|
||
{
|
||
if (target == null || names == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
Type t = target.GetType();
|
||
for (int i = 0; i < names.Length; i++)
|
||
{
|
||
string name = names[i];
|
||
if (string.IsNullOrEmpty(name))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string cacheKey = t.FullName + ":" + name;
|
||
if (!MemberCache.TryGetValue(cacheKey, out MemberInfo cached))
|
||
{
|
||
cached = (MemberInfo)t.GetField(name)
|
||
?? t.GetProperty(name)
|
||
?? (MemberInfo)t.GetMethod(name, Type.EmptyTypes);
|
||
MemberCache[cacheKey] = cached; // may be null - negative cache
|
||
}
|
||
|
||
if (cached == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (cached is FieldInfo fi)
|
||
{
|
||
object value = fi.GetValue(target);
|
||
if (value != null)
|
||
{
|
||
return value;
|
||
}
|
||
}
|
||
else if (cached is PropertyInfo pi)
|
||
{
|
||
object value = pi.GetValue(target, null);
|
||
if (value != null)
|
||
{
|
||
return value;
|
||
}
|
||
}
|
||
else if (cached is MethodInfo mi)
|
||
{
|
||
object value = mi.Invoke(target, null);
|
||
if (value != null)
|
||
{
|
||
return value;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// try next
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static long ReadLongMember(object target, params string[] names)
|
||
{
|
||
object value = ReadMember(target, names);
|
||
if (value is long l)
|
||
{
|
||
return l;
|
||
}
|
||
|
||
if (value is int i)
|
||
{
|
||
return i;
|
||
}
|
||
|
||
if (value is uint u)
|
||
{
|
||
return u;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
public static bool IsActivePlotAuthor(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
return id != 0 && PlotAuthorCache.Contains(id);
|
||
}
|
||
|
||
public static bool IsFounder(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
Family family = actor.family;
|
||
if (family != null)
|
||
{
|
||
Actor first = family.getFounderFirst();
|
||
Actor second = family.getFounderSecond();
|
||
if ((first != null && first == actor) || (second != null && second == actor))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
try
|
||
{
|
||
Kingdom kingdom = actor.kingdom;
|
||
if (kingdom != null && ActorMatchesFounder(kingdom, actor))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
try
|
||
{
|
||
City city = actor.city;
|
||
if (city != null && ActorMatchesFounder(city, actor))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
public static bool IsSpeciesSingleton(Actor actor)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return WorldActivityScanner.IsSingletonSpeciesPublic(actor, SpeciesCountCache);
|
||
}
|
||
|
||
private static bool ActorMatchesFounder(object meta, Actor actor)
|
||
{
|
||
if (meta == null || actor == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
long self = EventFeedUtil.SafeId(actor);
|
||
if (self == 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
foreach (string name in new[] { "founder", "creator", "getFounder", "getFounderFirst" })
|
||
{
|
||
object value = meta.GetType().GetField(name)?.GetValue(meta)
|
||
?? meta.GetType().GetProperty(name)?.GetValue(meta, null)
|
||
?? meta.GetType().GetMethod(name, Type.EmptyTypes)?.Invoke(meta, null);
|
||
if (value is Actor found && found == actor)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
try
|
||
{
|
||
foreach (string name in new[] { "founder_id", "get_founder_id", "main_founder_id_1" })
|
||
{
|
||
object value = meta.GetType().GetField(name)?.GetValue(meta)
|
||
?? meta.GetType().GetProperty(name)?.GetValue(meta, null)
|
||
?? meta.GetType().GetMethod(name, Type.EmptyTypes)?.Invoke(meta, null);
|
||
long fid = 0;
|
||
if (value is long l)
|
||
{
|
||
fid = l;
|
||
}
|
||
else if (value is int i)
|
||
{
|
||
fid = i;
|
||
}
|
||
|
||
if (fid != 0 && fid == self)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static void EnsureRoleCache(float now, bool force)
|
||
{
|
||
if (!force && now - _roleCacheAt < 2.4f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_roleCacheAt = now;
|
||
PlotAuthorCache.Clear();
|
||
SpeciesCountCache.Clear();
|
||
try
|
||
{
|
||
Dictionary<string, int> counts = WorldActivityScanner.CountSpeciesPopulations();
|
||
if (counts != null)
|
||
{
|
||
foreach (KeyValuePair<string, int> kv in counts)
|
||
{
|
||
SpeciesCountCache[kv.Key] = kv.Value;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
try
|
||
{
|
||
if (World.world?.plots == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (Plot plot in World.world.plots)
|
||
{
|
||
if (plot == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (!plot.isActive())
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// treat as active when isActive unavailable
|
||
}
|
||
|
||
Actor author = null;
|
||
try
|
||
{
|
||
author = plot.getAuthor();
|
||
}
|
||
catch
|
||
{
|
||
author = null;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(author);
|
||
if (id != 0)
|
||
{
|
||
PlotAuthorCache.Add(id);
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
private static LifeSagaSlot BuildSlot(
|
||
Actor actor,
|
||
float now,
|
||
LifeSagaAdmissionReason reason = LifeSagaAdmissionReason.None)
|
||
{
|
||
LifeSagaSlot s = new LifeSagaSlot();
|
||
s.AdmittedAt = now;
|
||
s.TouchedAt = now;
|
||
s.Heat = 0f;
|
||
RefreshSlot(s, actor, now);
|
||
s.AdmissionReason = reason != LifeSagaAdmissionReason.None
|
||
? reason
|
||
: InferAdmissionReason(s);
|
||
return s;
|
||
}
|
||
|
||
private static void RefreshSlot(LifeSagaSlot s, Actor actor, float now)
|
||
{
|
||
bool existed = s.UnitId != 0 && s.UnitId == EventFeedUtil.SafeId(actor);
|
||
bool wasKing = s.IsKing;
|
||
bool wasLeader = s.IsLeader;
|
||
bool wasAlpha = s.IsAlpha;
|
||
bool wasClanChief = s.IsClanChief;
|
||
bool wasArmyCaptain = s.IsArmyCaptain;
|
||
|
||
s.UnitId = EventFeedUtil.SafeId(actor);
|
||
s.DisplayName = SafeName(actor);
|
||
bool gameFav = false;
|
||
try
|
||
{
|
||
gameFav = actor.isFavorite();
|
||
}
|
||
catch
|
||
{
|
||
gameFav = false;
|
||
}
|
||
|
||
s.GameFavorite = gameFav;
|
||
InterestMetadataSnapshot meta = InterestScoring.GetOrBuildMeta(actor, now);
|
||
s.SpeciesId = meta?.SpeciesId ?? (actor.asset != null ? actor.asset.id : "");
|
||
s.KingdomKey = meta?.KingdomKey ?? "";
|
||
s.CityKey = meta?.CityKey ?? "";
|
||
s.IsKing = meta != null && meta.King;
|
||
s.IsLeader = meta != null && meta.Leader;
|
||
s.IsAlpha = IsPackAlpha(actor);
|
||
s.IsClanChief = IsClanChief(actor);
|
||
s.IsArmyCaptain = IsArmyCaptain(actor);
|
||
s.IsPlotAuthor = IsActivePlotAuthor(actor);
|
||
s.IsFounder = IsFounder(actor);
|
||
s.IsSingleton = IsSpeciesSingleton(actor);
|
||
s.Kills = meta?.Kills ?? 0;
|
||
s.Renown = meta?.Renown ?? 0f;
|
||
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
|
||
s.Standing = StandingFromFlags(s);
|
||
if (s.AdmittedAt <= 0f)
|
||
{
|
||
s.AdmittedAt = now;
|
||
}
|
||
|
||
if (s.AdmissionReason == LifeSagaAdmissionReason.None)
|
||
{
|
||
s.AdmissionReason = InferAdmissionReason(s);
|
||
}
|
||
|
||
if (existed)
|
||
{
|
||
NoteRisingRoles(
|
||
actor,
|
||
wasKing,
|
||
wasLeader,
|
||
wasAlpha,
|
||
wasClanChief,
|
||
wasArmyCaptain,
|
||
s.IsKing,
|
||
s.IsLeader,
|
||
s.IsAlpha,
|
||
s.IsClanChief,
|
||
s.IsArmyCaptain);
|
||
}
|
||
}
|
||
|
||
private static float StandingFromFlags(LifeSagaSlot s)
|
||
{
|
||
float score = 1f;
|
||
if (s == null)
|
||
{
|
||
return score;
|
||
}
|
||
|
||
if (s.GameFavorite)
|
||
{
|
||
score += 12f;
|
||
}
|
||
|
||
if (s.IsKing)
|
||
{
|
||
score += 10f;
|
||
}
|
||
else if (s.IsLeader)
|
||
{
|
||
score += 6f;
|
||
}
|
||
|
||
if (s.IsClanChief)
|
||
{
|
||
score += 8f;
|
||
}
|
||
|
||
if (s.IsAlpha)
|
||
{
|
||
score += 7f;
|
||
}
|
||
|
||
if (s.IsPlotAuthor)
|
||
{
|
||
score += 7f;
|
||
}
|
||
|
||
if (s.IsSingleton)
|
||
{
|
||
score += 6f;
|
||
}
|
||
|
||
if (s.IsFounder)
|
||
{
|
||
score += 5f;
|
||
}
|
||
|
||
if (s.IsArmyCaptain)
|
||
{
|
||
score += 4f;
|
||
}
|
||
|
||
score += Mathf.Min(8f, s.Kills * 0.05f);
|
||
score += Mathf.Min(4f, s.Renown / 100f);
|
||
// StandoutTraitScore skipped here - reflection-heavy and ran every Refill×Cap.
|
||
return score;
|
||
}
|
||
|
||
private static void NoteRisingRoles(
|
||
Actor actor,
|
||
bool wasKing,
|
||
bool wasLeader,
|
||
bool wasAlpha,
|
||
bool wasClanChief,
|
||
bool wasArmyCaptain,
|
||
bool isKing,
|
||
bool isLeader,
|
||
bool isAlpha,
|
||
bool isClanChief,
|
||
bool isArmyCaptain)
|
||
{
|
||
if (actor == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!wasKing && isKing)
|
||
{
|
||
LifeSagaMemory.RecordRole(actor, "become_king", "roster_live");
|
||
}
|
||
|
||
if (!wasLeader && isLeader)
|
||
{
|
||
LifeSagaMemory.RecordRole(actor, "become_leader", "roster_live");
|
||
}
|
||
|
||
if (!wasAlpha && isAlpha)
|
||
{
|
||
LifeSagaMemory.RecordRole(actor, "become_alpha", "roster_live");
|
||
}
|
||
|
||
if (!wasClanChief && isClanChief)
|
||
{
|
||
LifeSagaMemory.RecordRole(actor, "become_clan_chief", "roster_live");
|
||
}
|
||
|
||
if (!wasArmyCaptain && isArmyCaptain)
|
||
{
|
||
LifeSagaMemory.RecordRole(actor, "become_army_captain", "roster_live");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Harness: simulate a live role rising edge on an existing roster slot (does not mutate game offices).
|
||
/// </summary>
|
||
public static bool HarnessSimulateRoleRise(Actor actor, string roleId)
|
||
{
|
||
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(roleId))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
long id = EventFeedUtil.SafeId(actor);
|
||
LifeSagaSlot s = Get(id);
|
||
if (s == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
string role = roleId.Trim().ToLowerInvariant();
|
||
bool wasKing = s.IsKing;
|
||
bool wasLeader = s.IsLeader;
|
||
bool wasAlpha = s.IsAlpha;
|
||
bool wasClanChief = s.IsClanChief;
|
||
bool wasArmyCaptain = s.IsArmyCaptain;
|
||
|
||
if (role.IndexOf("king", System.StringComparison.Ordinal) >= 0)
|
||
{
|
||
s.IsKing = true;
|
||
}
|
||
else if (role.IndexOf("leader", System.StringComparison.Ordinal) >= 0)
|
||
{
|
||
s.IsLeader = true;
|
||
}
|
||
else if (role.IndexOf("alpha", System.StringComparison.Ordinal) >= 0)
|
||
{
|
||
s.IsAlpha = true;
|
||
}
|
||
else if (role.IndexOf("chief", System.StringComparison.Ordinal) >= 0
|
||
|| role.IndexOf("clan", System.StringComparison.Ordinal) >= 0)
|
||
{
|
||
s.IsClanChief = true;
|
||
}
|
||
else if (role.IndexOf("captain", System.StringComparison.Ordinal) >= 0
|
||
|| role.IndexOf("army", System.StringComparison.Ordinal) >= 0)
|
||
{
|
||
s.IsArmyCaptain = true;
|
||
}
|
||
else
|
||
{
|
||
return false;
|
||
}
|
||
|
||
NoteRisingRoles(
|
||
actor,
|
||
wasKing,
|
||
wasLeader,
|
||
wasAlpha,
|
||
wasClanChief,
|
||
wasArmyCaptain,
|
||
s.IsKing,
|
||
s.IsLeader,
|
||
s.IsAlpha,
|
||
s.IsClanChief,
|
||
s.IsArmyCaptain);
|
||
_dirty = true;
|
||
return true;
|
||
}
|
||
|
||
private static LifeSagaAdmissionReason InferAdmissionReason(LifeSagaSlot s)
|
||
{
|
||
if (s == null)
|
||
{
|
||
return LifeSagaAdmissionReason.None;
|
||
}
|
||
|
||
if (s.GameFavorite) return LifeSagaAdmissionReason.Favorite;
|
||
if (s.IsKing) return LifeSagaAdmissionReason.King;
|
||
if (s.IsClanChief) return LifeSagaAdmissionReason.ClanChief;
|
||
if (s.IsLeader) return LifeSagaAdmissionReason.CityLeader;
|
||
if (s.IsAlpha) return LifeSagaAdmissionReason.Alpha;
|
||
if (s.IsArmyCaptain) return LifeSagaAdmissionReason.ArmyCaptain;
|
||
if (s.IsFounder) return LifeSagaAdmissionReason.Founder;
|
||
if (s.IsPlotAuthor) return LifeSagaAdmissionReason.PlotAuthor;
|
||
if (s.IsSingleton) return LifeSagaAdmissionReason.Singleton;
|
||
if (s.Kills > 0) return LifeSagaAdmissionReason.NotableKills;
|
||
if (s.Renown > 0f) return LifeSagaAdmissionReason.NotableRenown;
|
||
return LifeSagaAdmissionReason.NotableLife;
|
||
}
|
||
|
||
private static float BaseInterest(Actor actor)
|
||
{
|
||
float score = 1f;
|
||
try
|
||
{
|
||
if (actor.isFavorite())
|
||
{
|
||
score += 12f;
|
||
}
|
||
|
||
if (actor.isKing())
|
||
{
|
||
score += 10f;
|
||
}
|
||
else if (actor.isCityLeader())
|
||
{
|
||
score += 6f;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
if (IsClanChief(actor))
|
||
{
|
||
score += 8f;
|
||
}
|
||
|
||
if (IsPackAlpha(actor))
|
||
{
|
||
score += 7f;
|
||
}
|
||
|
||
if (IsActivePlotAuthor(actor))
|
||
{
|
||
score += 7f;
|
||
}
|
||
|
||
if (IsSpeciesSingleton(actor))
|
||
{
|
||
score += 6f;
|
||
}
|
||
|
||
if (IsFounder(actor))
|
||
{
|
||
score += 5f;
|
||
}
|
||
|
||
if (IsArmyCaptain(actor))
|
||
{
|
||
score += 4f;
|
||
}
|
||
|
||
try
|
||
{
|
||
int kills = actor.data != null ? actor.data.kills : 0;
|
||
score += Mathf.Min(8f, kills * 0.05f);
|
||
score += Mathf.Min(4f, actor.renown / 100f);
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
score += LifeSagaOverview.StandoutTraitScore(actor) * 0.02f;
|
||
return score;
|
||
}
|
||
|
||
private static string CleanChapterLine(string line)
|
||
{
|
||
if (string.IsNullOrEmpty(line))
|
||
{
|
||
return "";
|
||
}
|
||
|
||
string trimmed = line.Trim();
|
||
// Opaque pair/pack tip dumps are not chapter prose.
|
||
if (trimmed.IndexOf("·", StringComparison.Ordinal) >= 0
|
||
|| trimmed.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase)
|
||
|| trimmed.StartsWith("Duel -", StringComparison.OrdinalIgnoreCase)
|
||
|| trimmed.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase)
|
||
|| trimmed.StartsWith("Skirmish -", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
// Keep short role words; rewrite combat/pack dumps to kind labels when possible.
|
||
if (trimmed.StartsWith("Duel -", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "Duel";
|
||
}
|
||
|
||
if (trimmed.StartsWith("Pack -", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "";
|
||
}
|
||
|
||
if (trimmed.StartsWith("Outbreak -", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "Outbreak";
|
||
}
|
||
|
||
if (trimmed.StartsWith("Skirmish -", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "Battle";
|
||
}
|
||
|
||
if (trimmed.IndexOf("·", StringComparison.Ordinal) >= 0)
|
||
{
|
||
// "Name · Becomes pregnant" style - keep trailing prose after last · if short.
|
||
int idx = trimmed.LastIndexOf('·');
|
||
if (idx >= 0 && idx + 1 < trimmed.Length)
|
||
{
|
||
string tail = trimmed.Substring(idx + 1).Trim();
|
||
if (tail.Length > 0 && tail.Length <= 48)
|
||
{
|
||
trimmed = tail;
|
||
}
|
||
else
|
||
{
|
||
return "";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (trimmed.Length > 72)
|
||
{
|
||
trimmed = trimmed.Substring(0, 72).TrimEnd() + "…";
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
private static string SafeName(Actor actor)
|
||
{
|
||
try
|
||
{
|
||
string n = actor.getName();
|
||
if (!string.IsNullOrEmpty(n))
|
||
{
|
||
return n;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
return actor.asset != null ? actor.asset.id : "Unit";
|
||
}
|
||
}
|
||
|
||
/// <summary>One life on the saga roster.</summary>
|
||
public sealed class LifeSagaSlot
|
||
{
|
||
public long UnitId;
|
||
public string DisplayName = "";
|
||
public string SpeciesId = "";
|
||
public string KingdomKey = "";
|
||
public string CityKey = "";
|
||
public bool GameFavorite;
|
||
public bool Prefer;
|
||
public bool IsKing;
|
||
public bool IsLeader;
|
||
public bool IsAlpha;
|
||
public bool IsClanChief;
|
||
public bool IsArmyCaptain;
|
||
public bool IsPlotAuthor;
|
||
public bool IsFounder;
|
||
public bool IsSingleton;
|
||
public int Kills;
|
||
public float Renown;
|
||
/// <summary>Live role/standing recomputed each refresh.</summary>
|
||
public float Standing;
|
||
/// <summary>Recent chapter/feature heat; decays with idle time.</summary>
|
||
public float Heat;
|
||
public float AdmittedAt;
|
||
public float TouchedAt;
|
||
public float LastChapterAt;
|
||
public LifeSagaAdmissionReason AdmissionReason;
|
||
public readonly List<LifeSagaChapter> Chapters =
|
||
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
|
||
|
||
/// <summary>Legacy alias for harness / debug - Standing + Heat.</summary>
|
||
public float Interest
|
||
{
|
||
get => Standing + Heat;
|
||
set => Heat = Mathf.Max(0f, value - Standing);
|
||
}
|
||
|
||
public Actor Resolve() => EventFeedUtil.FindAliveById(UnitId);
|
||
}
|
||
|
||
public enum LifeSagaAdmissionReason
|
||
{
|
||
None,
|
||
Favorite,
|
||
King,
|
||
ClanChief,
|
||
CityLeader,
|
||
Alpha,
|
||
ArmyCaptain,
|
||
Founder,
|
||
PlotAuthor,
|
||
Singleton,
|
||
NotableKills,
|
||
NotableRenown,
|
||
NotableLife,
|
||
EmergingStory
|
||
}
|
||
|
||
public enum LifeSagaChapterKind
|
||
{
|
||
Unknown,
|
||
Combat,
|
||
War,
|
||
Plot,
|
||
Love,
|
||
Grief
|
||
}
|
||
|
||
public sealed class LifeSagaChapter
|
||
{
|
||
public LifeSagaChapterKind Kind;
|
||
public string Line = "";
|
||
public float At;
|
||
}
|