- 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
2165 lines
55 KiB
C#
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;
|
|
}
|