worldbox-observer-mod/IdleSpectator/Story/LifeSagaRoster.cs
DazedAnon dafe79dfa2 feat(saga): escalate hard-arc admit and add soak probes
- Grow climax admit heat with arc age and re-stamp while theater holds
- Let prolonged stranger fights challenge Cap so Active chrome can light
- Add soak_probe that dumps roster vs focus without wiping saga state
- Gate with saga_hard_arc_escalates; bump mod to 0.29.59
2026-07-22 12:25:11 -05:00

2165 lines
55 KiB
C#

using System;
using System.Collections.Generic;
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 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 float _nextScanAt;
private static float _roleCacheAt = -999f;
private static bool _dirty = true;
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();
PreviousIds.Clear();
PlotAuthorCache.Clear();
SpeciesCountCache.Clear();
_nextScanAt = 0f;
_roleCacheAt = -999f;
_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;
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;
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 : 6f;
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=3, MC=2, MC cast=1, else 0.</summary>
public static int SoftBiasRank(InterestCandidate c)
{
if (c == null)
{
return 0;
}
if (TipTouchesPrefer(c))
{
return 3;
}
if (TipTouchesMc(c))
{
return 2;
}
return TipTouchesMcCast(c) ? 1 : 0;
}
/// <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;
}
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0 || slot.UnitId == unitId)
{
continue;
}
if (IsInMcOrbit(slot.UnitId, unitId))
{
return true;
}
}
return false;
}
/// <summary>Near-tie breaker: Prefer > MC > MC cast > neither.</summary>
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
}
/// <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)
{
if (tip == null)
{
return 0;
}
int n = 0;
long a = tip.SubjectId;
long b = tip.RelatedId;
long f = EventFeedUtil.SafeId(tip.FollowUnit);
if (IsMc(a))
{
n++;
}
if (b != 0 && b != a && IsMc(b))
{
n++;
}
if (f != 0 && f != a && f != b && IsMc(f))
{
n++;
}
return n;
}
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;
LifeSagaMemory.RecordHardArc(
unitId,
kind == LifeSagaChapterKind.Love ? StoryArcKind.Love
: kind == LifeSagaChapterKind.Grief ? StoryArcKind.Grief
: kind == LifeSagaChapterKind.Combat ? StoryArcKind.CombatDuel
: kind == LifeSagaChapterKind.War ? StoryArcKind.WarFront
: kind == LifeSagaChapterKind.Plot ? StoryArcKind.Plot
: StoryArcKind.None,
trimmed);
}
/// <summary>
/// Hard-arc admit heat grows with climax age so prolonged theater can challenge a full Cap.
/// One-shot heat 1.5 loses to a roster of notables; ~2 minutes reaches <see cref="HeatCap"/>.
/// </summary>
public static float HardArcAdmitHeat(StoryArc arc, float now = -1f)
{
if (now < 0f)
{
now = Time.unscaledTime;
}
if (arc == null || arc.StartedAt <= 0f)
{
return 1.5f;
}
float age = Mathf.Max(0f, now - arc.StartedAt);
return Mathf.Min(HeatCap, 1.5f + age / 12f);
}
public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now)
{
if (arc == null || !arc.IsActive)
{
return;
}
string line = FormatChapterLine(arc, tip);
if (string.IsNullOrEmpty(line))
{
return;
}
bool hard = IsHardStoryKind(arc.Kind);
float subjectHeat = hard ? HardArcAdmitHeat(arc, now) : 1.5f;
float relatedHeat = hard ? Mathf.Max(1.2f, subjectHeat * 0.85f) : 1.2f;
if (arc.CastIds != null)
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
long id = arc.CastIds[i];
if (hard)
{
ConsiderAdmit(id, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(id, arc.Kind, line);
}
if (IsMc(id))
{
LifeSagaMemory.RecordHardArc(id, arc.Kind, line, tip?.RelatedId ?? 0);
StampChapter(id, ToChapterKind(arc.Kind), line, now);
}
}
}
if (tip != null)
{
if (hard)
{
ConsiderAdmit(tip.SubjectId, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(tip.SubjectId, arc.Kind, line);
if (tip.RelatedId != 0)
{
ConsiderAdmit(tip.RelatedId, relatedHeat, hardArcAdmit: true, now);
MarkHardArcContext(tip.RelatedId, arc.Kind, line);
}
long follow = EventFeedUtil.SafeId(tip.FollowUnit);
if (follow != 0 && follow != tip.SubjectId && follow != tip.RelatedId)
{
ConsiderAdmit(follow, subjectHeat, hardArcAdmit: true, now);
MarkHardArcContext(follow, arc.Kind, line);
}
}
if (IsMc(tip.SubjectId))
{
StampChapter(tip.SubjectId, ToChapterKind(arc.Kind), line, now);
}
if (tip.RelatedId != 0 && IsMc(tip.RelatedId))
{
StampChapter(tip.RelatedId, ToChapterKind(arc.Kind), line, now);
}
long followStamp = EventFeedUtil.SafeId(tip.FollowUnit);
if (followStamp != 0
&& followStamp != tip.SubjectId
&& followStamp != tip.RelatedId
&& IsMc(followStamp))
{
StampChapter(followStamp, ToChapterKind(arc.Kind), line, now);
}
}
}
public static string FormatChapterLine(StoryArc arc, InterestCandidate tip)
{
if (arc == null)
{
return "";
}
if (!string.IsNullOrEmpty(arc.Headline))
{
return CleanChapterLine(arc.Headline);
}
if (tip != null && !string.IsNullOrEmpty(tip.Label))
{
string cleaned = CleanChapterLine(tip.Label);
if (!string.IsNullOrEmpty(cleaned))
{
return cleaned;
}
}
switch (arc.Kind)
{
case StoryArcKind.WarFront:
return "War front";
case StoryArcKind.CombatDuel:
return "Duel";
case StoryArcKind.CombatMass:
return "Battle";
case StoryArcKind.Love:
return "Love";
case StoryArcKind.Grief:
return "Grief";
case StoryArcKind.Plot:
return "Plot";
default:
return "";
}
}
public static void Tick(float now)
{
if (now < _nextScanAt && !_dirty)
{
PruneDead(now);
return;
}
_nextScanAt = now + 2.5f;
Refill(now);
}
/// <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;
/// nobodies only when <paramref name="hardArcAdmit"/> (hard-arc chapter path).
/// </summary>
public static bool ConsiderAdmit(long unitId, float heat, bool hardArcAdmit, float now = -1f)
{
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)
{
existing.Heat = Mathf.Min(HeatCap, existing.Heat + Mathf.Max(0f, heat));
existing.TouchedAt = now;
RefreshSlot(existing, actor, now);
_dirty = true;
return true;
}
EnsureRoleCache(now, force: false);
bool admission = IsAdmissionCandidate(actor);
if (!admission && !hardArcAdmit)
{
return false;
}
LifeSagaSlot neu = BuildSlot(
actor,
now,
hardArcAdmit && !admission ? LifeSagaAdmissionReason.HardArc : 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 NoteUnit(long unitId, float heat, float now)
{
LifeSagaSlot s = Get(unitId);
if (s == null)
{
return;
}
s.Heat = Mathf.Min(HeatCap, s.Heat + heat);
s.TouchedAt = now;
_dirty = true;
}
private static void Refill(float now)
{
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);
}
// Harness batches need a stable roster; world scan would re-admit leftovers from prior steps.
// Live AFK (Busy=false) still discovers chiefs/captains/singletons via the scan below.
// Tests use force/consider admit or `saga_world_scan` when natural discovery is required.
if (!AgentHarness.Busy)
{
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;
}
LifeSagaSlot neu = BuildSlot(actor, now);
Scratch.Add(neu);
seen.Add(id);
}
}
}
catch
{
// ignore scan failures
}
}
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = true;
}
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;
continue;
}
RefreshSlot(s, a, now);
}
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 score = standing + heat;
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;
}
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 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;
}
try
{
object value = t.GetField(name)?.GetValue(target)
?? t.GetProperty(name)?.GetValue(target, null);
if (value != null)
{
return value;
}
}
catch
{
// try next
}
try
{
var method = t.GetMethod(name, Type.EmptyTypes);
if (method != null)
{
object value = method.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)
{
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;
s.Standing = BaseInterest(actor);
if (s.AdmittedAt <= 0f)
{
s.AdmittedAt = now;
}
if (s.AdmissionReason == LifeSagaAdmissionReason.None)
{
s.AdmissionReason = InferAdmissionReason(s);
}
}
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 void MarkHardArcContext(long unitId, StoryArcKind kind, string line)
{
LifeSagaSlot slot = Get(unitId);
if (slot == null || slot.AdmissionReason != LifeSagaAdmissionReason.HardArc)
{
return;
}
slot.AdmissionArcKind = kind;
slot.AdmissionContext = CleanChapterLine(line);
_dirty = true;
}
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 bool IsHardStoryKind(StoryArcKind kind) =>
kind == StoryArcKind.CombatDuel
|| kind == StoryArcKind.CombatMass
|| kind == StoryArcKind.WarFront
|| kind == StoryArcKind.Plot
|| kind == StoryArcKind.Love
|| kind == StoryArcKind.Grief;
private static LifeSagaChapterKind ToChapterKind(StoryArcKind kind)
{
switch (kind)
{
case StoryArcKind.CombatDuel:
case StoryArcKind.CombatMass:
return LifeSagaChapterKind.Combat;
case StoryArcKind.WarFront:
return LifeSagaChapterKind.War;
case StoryArcKind.Plot:
return LifeSagaChapterKind.Plot;
case StoryArcKind.Love:
return LifeSagaChapterKind.Love;
case StoryArcKind.Grief:
return LifeSagaChapterKind.Grief;
default:
return LifeSagaChapterKind.Unknown;
}
}
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 StoryArcKind AdmissionArcKind;
public string AdmissionContext = "";
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,
HardArc
}
public enum LifeSagaChapterKind
{
Unknown,
Combat,
War,
Plot,
Love,
Grief
}
public sealed class LifeSagaChapter
{
public LifeSagaChapterKind Kind;
public string Line = "";
public float At;
}