Compare commits

...

3 commits

Author SHA1 Message Date
4b6c829bd0 feat(saga): ship adaptive dossier tabs with durable memory
- Add Dossier/Saga tabs, Design A panel, and hover preview with read-pause
- Persist observed saga facts across roster churn; pin subject without stale fallback
- Restore Saga after Open Lore; hide redundant Kind · Climax spine under matching tips
- Densify dossier header/chips and keep the cast rail as stable top chrome
- Extend harness coverage for hover restore, empty pick, Evidence, and Lore return
2026-07-22 03:27:46 -05:00
798e3dce05 Life saga first pass 2026-07-21 23:46:20 -05:00
c23b2b9c1e feat(spectator): add crisis chapters and harden combat theater truth
- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers
- Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins
- Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight
- Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
2026-07-21 22:05:38 -05:00
54 changed files with 16012 additions and 2975 deletions

View file

@ -12,6 +12,7 @@ public static class ActorChroniclePatches
[HarmonyPostfix]
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
{
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
Chronicle.NoteKill(__instance, pDeadUnit);
}
@ -51,6 +52,7 @@ public static class ActorChroniclePatches
return;
}
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
Chronicle.NoteDeath(__instance, pType, __state);
GraveMarkers.AddDeath(__instance);
}
@ -59,6 +61,7 @@ public static class ActorChroniclePatches
[HarmonyPostfix]
public static void PostfixLovers(Actor __instance, Actor pTarget)
{
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
Chronicle.NoteLovers(__instance, pTarget);
}
@ -71,6 +74,7 @@ public static class ActorChroniclePatches
return;
}
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
Chronicle.NoteBestFriends(__instance, pActor);
}
}

View file

@ -191,6 +191,92 @@ public static class ActorRelation
}
}
/// <summary>Living parents reported by the running game's relation data.</summary>
public static IEnumerable<Actor> EnumerateParents(Actor actor, int max = 2)
{
if (actor == null || max <= 0)
{
yield break;
}
object parents = ReadMember(actor, "getParents", "get_parents", "parents");
if (parents == null)
{
parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids");
}
int yielded = 0;
var seen = new HashSet<long>();
if (parents is IEnumerable enumerable)
{
foreach (object raw in enumerable)
{
Actor parent = ResolveActor(raw) ?? ResolveActorId(raw);
if (!IsDistinctLivingRelation(actor, parent, seen))
{
continue;
}
yield return parent;
yielded++;
if (yielded >= max)
{
yield break;
}
}
}
if (yielded >= max)
{
yield break;
}
string[] singular =
{
"mother", "father", "parent", "mother_id", "father_id", "parent_id",
"parent_1", "parent_2", "parent1", "parent2"
};
for (int i = 0; i < singular.Length && yielded < max; i++)
{
object raw = ReadMember(actor, singular[i])
?? ReadMember(actor.data, singular[i]);
Actor parent = ResolveActor(raw) ?? ResolveActorId(raw);
if (!IsDistinctLivingRelation(actor, parent, seen))
{
continue;
}
yield return parent;
yielded++;
}
}
/// <summary>Current living combat target, when the live attack target is an actor.</summary>
public static Actor GetCombatOpponent(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
if (!actor.has_attack_target || actor.attack_target == null)
{
return null;
}
Actor opponent = ResolveActor(actor.attack_target);
return opponent != null && opponent != actor && opponent.isAlive()
? opponent
: null;
}
catch
{
return null;
}
}
/// <summary>
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
/// </summary>
@ -315,6 +401,50 @@ public static class ActorRelation
return null;
}
private static Actor ResolveActorId(object raw)
{
if (raw is long longId)
{
return FindUnitById(longId);
}
if (raw is int intId)
{
return FindUnitById(intId);
}
try
{
if (raw != null && long.TryParse(raw.ToString(), out long parsed))
{
return FindUnitById(parsed);
}
}
catch
{
// ignore
}
return null;
}
private static bool IsDistinctLivingRelation(Actor subject, Actor related, HashSet<long> seen)
{
if (related == null || related == subject || !related.isAlive())
{
return false;
}
try
{
return seen.Add(related.getID());
}
catch
{
return false;
}
}
private static Actor ReadActorMember(object target, params string[] names)
{
object raw = ReadMember(target, names);

File diff suppressed because it is too large Load diff

View file

@ -176,6 +176,7 @@ public static class Chronicle
{
ClearSession();
ActivityLog.ClearSession();
LifeSagaMemory.ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
@ -695,6 +696,7 @@ public static class Chronicle
_currentAgeId = "";
_currentAgeName = "";
ActivityLog.ClearSession();
LifeSagaMemory.ClearSession();
BumpRevision();
// Keep _boundWorld so re-enable on same map does not loop-clear.
}

View file

@ -800,13 +800,25 @@ public static class ChronicleHud
}
_pausedIdleForLore = false;
bool restoreSaga = LifeSagaViewController.TryRestoreAfterLore();
if (!ModSettings.Enabled || SpectatorMode.Active)
{
if (restoreSaga)
{
WatchCaption.RequestRelayout();
}
return;
}
SpectatorMode.SetActive(true, quiet: true);
LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle");
if (restoreSaga)
{
WatchCaption.RequestRelayout();
}
LogService.LogInfo("[IdleSpectator] Lore closed - resumed idle"
+ (restoreSaga ? " (restored Saga)" : ""));
}
/// <summary>
@ -888,7 +900,7 @@ public static class ChronicleHud
if (pauseIdle && SpectatorMode.Active)
{
_pausedIdleForLore = true;
SpectatorMode.SetActive(false, quiet: true);
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
}
else if (pauseIdle)
{
@ -1015,12 +1027,12 @@ public static class ChronicleHud
_followFocus = followFocus;
_detailPane = DetailPane.Life;
// Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
// would instantly drop the camera off the browse subject.
// Pause idle first: browsePause keeps StoryPlanner / registry for Lore close resume.
// SetActive(false) still ClearFollow() so focus the browse subject after.
if (pauseIdle && SpectatorMode.Active)
{
_pausedIdleForLore = true;
SpectatorMode.SetActive(false, quiet: true);
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
}
else if (pauseIdle)
{
@ -2283,7 +2295,7 @@ public static class ChronicleHud
{
if (SpectatorMode.Active)
{
SpectatorMode.SetActive(false, quiet: true);
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
WatchCaption.PinWhilePaused();
WatchCaption.ShowStatusBanner(
captured.Kind == ChronicleKind.World || captured.IsLegend

View file

@ -98,6 +98,14 @@ public static class DossierAvatar
}
}
public static void SetHostVisible(bool visible)
{
if (_host != null)
{
_host.gameObject.SetActive(visible);
}
}
public static void SetActive(bool active)
{
if (_host != null)
@ -106,6 +114,109 @@ public static class DossierAvatar
}
}
/// <summary>
/// Keep the live portrait under saga/dossier chrome that must paint on top of it.
/// </summary>
public static void DrawBehind(Transform sibling)
{
if (_host == null || sibling == null)
{
return;
}
// Insert immediately under the sibling so the opaque stone frame cannot cover copy.
int index = sibling.GetSiblingIndex();
_host.SetSiblingIndex(index);
if (_host.GetSiblingIndex() > sibling.GetSiblingIndex())
{
_host.SetSiblingIndex(sibling.GetSiblingIndex());
}
}
/// <summary>
/// Keep the compact stone-style dossier frame - strip king/clan vanity overrides.
/// </summary>
public static void ForceCompactFrame()
{
if (_host == null)
{
return;
}
if (_usingVanilla && _instance != null && _instance.gameObject.activeSelf)
{
try
{
_instance.show_banner_kingdom = false;
_instance.show_banner_clan = false;
if (_instance.kingdomBanner != null)
{
_instance.kingdomBanner.gameObject.SetActive(false);
}
if (_instance.clanBanner != null)
{
_instance.clanBanner.gameObject.SetActive(false);
}
if (_instance.avatarLoader != null)
{
_instance.avatarLoader.avatarSize = 0.55f;
}
// Clear king/leader ornate frame overrides when the game exposes them.
var prop = _instance.GetType().GetProperty("override_avatar_frames");
if (prop != null && prop.CanWrite)
{
prop.SetValue(_instance, false, null);
}
var field = _instance.GetType().GetField("override_avatar_frames");
if (field != null)
{
field.SetValue(_instance, false);
}
StripNamedVanity(_instance.transform);
}
catch
{
// keep showing whatever frame we have
}
}
}
private static void StripNamedVanity(Transform root)
{
if (root == null)
{
return;
}
for (int i = 0; i < root.childCount; i++)
{
Transform child = root.GetChild(i);
if (child == null)
{
continue;
}
string n = child.name ?? "";
string lower = n.ToLowerInvariant();
if (lower.Contains("banner")
|| lower.Contains("jewel")
|| lower.Contains("crown")
|| lower.Contains("king_frame")
|| lower.Contains("frame_king")
|| lower.Contains("ornate"))
{
child.gameObject.SetActive(false);
}
StripNamedVanity(child);
}
}
public static void Show(Actor actor)
{
if (_host == null)
@ -167,6 +278,7 @@ public static class DossierAvatar
_shownActorId = id;
_shownFormKey = formKey;
DisableInteractions(_instance.gameObject);
ForceCompactFrame();
return;
}
catch (System.Exception ex)

View file

@ -85,5 +85,25 @@ public static partial class EventCatalog
IsFallback = true
};
}
/// <summary>
/// True when <paramref name="disasterId"/> is an authored overlay or a live
/// <see cref="AssetManager.disasters"/> asset (not tip-string heuristics).
/// </summary>
public static bool IsLiveOrAuthored(string disasterId)
{
if (string.IsNullOrEmpty(disasterId))
{
return false;
}
string id = disasterId.Trim();
if (HasAuthored(id))
{
return true;
}
return ActivityAssetCatalog.TryGetDisasterAsset(id) != null;
}
}
}

View file

@ -149,11 +149,18 @@ public static partial class EventCatalog
|| s.Contains("equipment_rain")
|| s.EndsWith("_brush", StringComparison.Ordinal)
|| s.Contains("draw_")
|| s.Contains("paint"))
|| s.Contains("paint")
|| s.StartsWith("boat_", StringComparison.Ordinal))
{
return false;
}
// Creature drop powers that match live spectacle actors (evil_mage, necromancer, …).
if (WorldActivityScanner.IsSpectacleAssetId(s))
{
return true;
}
return s.Contains("meteor")
|| s.Contains("earthquake")
|| s.Contains("tornado")
@ -182,7 +189,10 @@ public static partial class EventCatalog
|| s.Contains("ash")
|| s.Contains("golden_brain")
|| s.Contains("corrupted_brain")
|| s.Contains("infection");
|| s.Contains("infection")
|| s.Contains("mage")
|| s.Contains("necromancer")
|| s.Contains("druid");
}
private static void Ensure()

View file

@ -17,15 +17,22 @@ public sealed class WorldLogEventEntry
public string MakeLabel(string special1, string special2)
{
string a = special1 ?? "";
string b = special2 ?? "";
string a = (special1 ?? "").Trim();
string b = (special2 ?? "").Trim();
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
return template
.Replace("{id}", Id ?? "")
string displayId = EventReason.HumanizeId(Id);
if (string.IsNullOrEmpty(displayId))
{
displayId = (Id ?? "").Trim();
}
string label = template
.Replace("{id}", displayId)
.Replace("{a}", a)
.Replace("{b}", b)
.Replace("{special1}", a)
.Replace("{special2}", b);
return CollapseSparseLabel(label, displayId);
}
public string MakeLabel(WorldLogMessage message)
@ -35,7 +42,79 @@ public sealed class WorldLogEventEntry
return MakeLabel("", "");
}
return MakeLabel(message.special1, message.special2);
string a = message.special1 ?? "";
string b = message.special2 ?? "";
// Many disaster WorldLogs leave special1 empty - prefer the follow unit name,
// else CollapseSparseLabel drops hanging "Tornado: " tails.
if (string.IsNullOrWhiteSpace(a) && message.unit != null)
{
a = EventFeedUtil.SafeName(message.unit) ?? "";
}
return MakeLabel(a, b);
}
/// <summary>
/// Drop empty-slot leftovers ("Tornado: ", "Event: ") so tips never show a bare colon.
/// </summary>
public static string CollapseSparseLabel(string label, string displayIdFallback)
{
if (string.IsNullOrWhiteSpace(label))
{
return string.IsNullOrEmpty(displayIdFallback) ? "Event" : displayIdFallback;
}
string s = label.Trim();
while (s.IndexOf(" ", StringComparison.Ordinal) >= 0)
{
s = s.Replace(" ", " ");
}
// Trailing separator after an empty {a}/{b}.
while (s.Length > 0)
{
char last = s[s.Length - 1];
if (last == ':' || last == '-' || last == '·' || last == '|')
{
s = s.Substring(0, s.Length - 1).TrimEnd();
continue;
}
break;
}
// Leading separator when {a} was first and empty.
while (s.Length > 0)
{
char first = s[0];
if (first == ':' || first == '-' || first == '·' || first == '|')
{
s = s.Substring(1).TrimStart();
continue;
}
break;
}
if (string.IsNullOrWhiteSpace(s))
{
return string.IsNullOrEmpty(displayIdFallback) ? "Event" : displayIdFallback;
}
return s;
}
/// <summary>True when a rendered tip would look empty or end with a hanging colon.</summary>
public static bool IsHangingLabel(string label)
{
if (string.IsNullOrWhiteSpace(label))
{
return true;
}
string s = label.TrimEnd();
return s.EndsWith(":", StringComparison.Ordinal)
|| s.EndsWith(": ", StringComparison.Ordinal);
}
}
@ -103,9 +182,11 @@ public static partial class EventCatalog
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
float strength = 40f;
string category = "WorldLog";
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
// Live disaster library / authored disaster overlays - not tip-string matching.
if (Disaster.IsLiveOrAuthored(id))
{
strength = 95f;
DisasterInterestEntry d = Disaster.GetOrFallback(id);
strength = d.EventStrength > 0f ? d.EventStrength : 95f;
category = "Disaster";
}
@ -123,6 +204,23 @@ public static partial class EventCatalog
};
}
/// <summary>WorldLog / disaster tips classified as Disaster via catalog or live library.</summary>
public static bool IsDisasterCategory(string assetId)
{
if (string.IsNullOrEmpty(assetId))
{
return false;
}
if (TryGet(assetId, out WorldLogEventEntry entry)
&& string.Equals(entry.Category, "Disaster", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return Disaster.IsLiveOrAuthored(assetId);
}
public static bool TryGetEventStrength(string assetId, out float eventStrength)
{
WorldLogEventEntry entry = GetOrFallback(assetId);

View file

@ -114,6 +114,26 @@ public static partial class EventCatalog
public static string KeyFor(Actor follow, Actor related)
{
// Spectacle 1vN scraps share one scene so attack_target hops merge instead of
// spawning combat:pair:owner:victimN keys that thrash Duel tips.
if (follow != null && WorldActivityScanner.IsSpectaclePublic(follow))
{
long sid = EventFeedUtil.SafeId(follow);
if (sid != 0)
{
return "combat:spectacle:" + sid;
}
}
if (related != null && WorldActivityScanner.IsSpectaclePublic(related))
{
long sid = EventFeedUtil.SafeId(related);
if (sid != 0)
{
return "combat:spectacle:" + sid;
}
}
long a = EventFeedUtil.SafeId(follow);
long b = EventFeedUtil.SafeId(related);
return b != 0 ? PairKey(a, b) : Key(a);

View file

@ -52,7 +52,67 @@ public static class EventReason
return false;
}
return string.Equals(prevKey, nextKey, StringComparison.Ordinal);
if (string.Equals(prevKey, nextKey, StringComparison.Ordinal))
{
return true;
}
// Pack mop-up ↔ thin vs remnant: Mass - Igguorn ↔ Battle - Igguorn vs The Godo.
return SameCombatTheaterCamps(prevKey, nextKey);
}
private static bool SameCombatTheaterCamps(string prevKey, string nextKey)
{
if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b))
{
return CampEq(pack, a) || CampEq(pack, b);
}
if (TryPackSide(nextKey, out pack) && TryVsSides(prevKey, out a, out b))
{
return CampEq(pack, a) || CampEq(pack, b);
}
return false;
}
private static bool TryPackSide(string key, out string pack)
{
pack = "";
if (string.IsNullOrEmpty(key) || !key.StartsWith("pack:", StringComparison.Ordinal))
{
return false;
}
pack = key.Substring(5);
return !string.IsNullOrEmpty(pack);
}
private static bool TryVsSides(string key, out string a, out string b)
{
a = "";
b = "";
if (string.IsNullOrEmpty(key) || key.StartsWith("pack:", StringComparison.Ordinal))
{
return false;
}
int bar = key.IndexOf('|');
if (bar <= 0 || bar >= key.Length - 1)
{
return false;
}
a = key.Substring(0, bar);
b = key.Substring(bar + 1);
return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b);
}
private static bool CampEq(string a, string b)
{
return !string.IsNullOrEmpty(a)
&& !string.IsNullOrEmpty(b)
&& string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}
private static bool TryCombatCampKey(string label, out string key)
@ -71,7 +131,9 @@ public static class EventReason
}
string tier = t.Substring(0, dash).Trim();
if (!tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase)
// Include Duel so A vs B ↔ B vs A is framing-only (no Watch / tip-order churn).
if (!tier.Equals("Duel", StringComparison.OrdinalIgnoreCase)
&& !tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase)
&& !tier.Equals("Battle", StringComparison.OrdinalIgnoreCase)
&& !tier.Equals("Mass", StringComparison.OrdinalIgnoreCase))
{

View file

@ -1492,6 +1492,13 @@ public sealed class LiveEnsemble
return false;
}
// Sleeping / stunned / frozen / lying units are not in a live scrap even if an
// attack_target pointer still lingers (soak + combat_focus cold after sleep).
if (IsCombatIncapacitated(actor))
{
return false;
}
try
{
if (actor.has_attack_target)
@ -1509,6 +1516,49 @@ public sealed class LiveEnsemble
}
}
/// <summary>
/// True when game state says the actor cannot meaningfully fight right now.
/// Uses Actor APIs + live sleeping status - not an authored id deny list.
/// </summary>
public static bool IsCombatIncapacitated(Actor actor)
{
if (actor == null)
{
return true;
}
// Parked only: sleep / freeze / unconscious. Do not treat stun or isLying as out of
// the scrap - those are normal combat FX and were collapsing Duels to "is fighting".
try
{
var ids = actor.getStatusesIds();
if (ids != null)
{
foreach (string id in ids)
{
if (string.IsNullOrEmpty(id))
{
continue;
}
string s = id.ToLowerInvariant();
if (s.IndexOf("sleep", StringComparison.Ordinal) >= 0
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|| s.IndexOf("uncon", StringComparison.Ordinal) >= 0)
{
return true;
}
}
}
}
catch
{
// ignore
}
return false;
}
/// <summary>
/// Sticky scoreboard roster: enroll matching side keys in radius, then count every
/// tracked member that is still alive until the scene grace ends.

View file

@ -18,6 +18,12 @@ public sealed class LiveSceneStickyState
public bool HasPresentedScale;
public EnsembleScale PresentedScale;
public float ScaleChangeSince = -999f;
/// <summary>
/// After a camp hits 0, stay in mop-up until the thin side recovers to
/// <see cref="StickyScoreboard.WipeRecoverMin"/> (stops 0↔1 Mass↔Battle thrash).
/// </summary>
public bool MopUpActive;
public float MopUpSince = -999f;
public string SideAKey = "";
public string SideADisplay = "";
@ -54,6 +60,8 @@ public sealed class LiveSceneStickyState
HasPresentedScale = false;
PresentedScale = EnsembleScale.Pair;
ScaleChangeSince = -999f;
MopUpActive = false;
MopUpSince = -999f;
SideAKey = "";
SideADisplay = "";
SideAKingdom = "";
@ -81,6 +89,8 @@ public sealed class LiveSceneStickyState
other.HasPresentedScale = HasPresentedScale;
other.PresentedScale = PresentedScale;
other.ScaleChangeSince = ScaleChangeSince;
other.MopUpActive = MopUpActive;
other.MopUpSince = MopUpSince;
other.SideAKey = SideAKey;
other.SideADisplay = SideADisplay;
other.SideAKingdom = SideAKingdom;

View file

@ -55,6 +55,21 @@ public static class MetaEventPatches
public static void PostfixNewClan(Clan __result)
{
EmitNew("new_clan", "Politics", 70f, __result, "clan");
Actor clanFounder = TryReadActor(__result);
if (clanFounder != null)
{
string clanKey = "";
try
{
clanKey = __result != null ? __result.getID().ToString() : "";
}
catch
{
clanKey = "";
}
LifeSagaMemory.RecordFounding(clanFounder, "clan", clanKey, "new_clan");
}
}
[HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))]
@ -77,6 +92,21 @@ public static class MetaEventPatches
{
// Outcome tip (decision intent like build_civ_city_here is Layer B).
EmitNew("new_city", "Politics", 86f, __result, "city");
Actor cityFounder = TryReadActor(__result);
if (cityFounder != null)
{
string cityKey = "";
try
{
cityKey = __result != null ? __result.getID().ToString() : "";
}
catch
{
cityKey = "";
}
LifeSagaMemory.RecordFounding(cityFounder, "city", cityKey, "new_city");
}
}
[HarmonyPatch(typeof(KingdomManager), nameof(KingdomManager.makeNewCivKingdom))]
@ -101,6 +131,17 @@ public static class MetaEventPatches
92f,
anchor,
EventReason.MetaNew(anchor, "kingdom"));
string kingdomKey = "";
try
{
kingdomKey = __result.getID().ToString();
}
catch
{
kingdomKey = "";
}
LifeSagaMemory.RecordFounding(anchor, "kingdom", kingdomKey, "new_kingdom");
}
private static void EmitNew(

View file

@ -16,6 +16,20 @@ public static class PlotEventPatches
}
PlotInterestFeed.EmitFromPlot(__result, "new");
try
{
Actor author = __result.getAuthor();
if (author != null && author.isAlive())
{
string plotKey = __result.getID().ToString();
string assetId = ReadPlotAssetId(__result);
LifeSagaMemory.RecordPlot(author, plotKey, assetId, finished: false, provenance: "newPlot");
}
}
catch
{
// ignore
}
}
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.cancelPlot))]
@ -40,6 +54,16 @@ public static class PlotEventPatches
}
PlotInterestFeed.EmitFromPlot(pObject, "join");
try
{
string plotKey = pObject.getID().ToString();
string assetId = ReadPlotAssetId(pObject);
LifeSagaMemory.RecordPlot(__instance, plotKey, assetId, finished: false, provenance: "setPlot");
}
catch
{
// ignore
}
}
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
@ -94,10 +118,38 @@ public static class PlotEventPatches
}
PlotInterestFeed.Emit(assetId, pActor, "complete");
try
{
string plotKey = __instance.getID().ToString();
LifeSagaMemory.RecordPlot(pActor, plotKey, assetId, finished: true, provenance: "finishPlot");
}
catch
{
// ignore
}
return;
}
PlotInterestFeed.EmitFromPlot(__instance, "complete");
try
{
Actor author = __instance.getAuthor();
if (author != null && author.isAlive())
{
string plotKey = __instance.getID().ToString();
LifeSagaMemory.RecordPlot(
author,
plotKey,
ReadPlotAssetId(__instance),
finished: true,
provenance: "finishPlot");
}
}
catch
{
// ignore
}
}
private static string ReadPlotAssetId(object plot)

View file

@ -38,9 +38,29 @@ public static class RelationshipEventPatches
return true;
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
[HarmonyPrefix]
public static void PrefixSetLover(Actor __instance, Actor pActor, out Actor __state)
{
__state = null;
try
{
if (__instance != null && EventOutcome.IsLiving(__instance) && __instance.hasLover())
{
__state = __instance.lover;
}
}
catch
{
__state = null;
}
_ = pActor;
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
[HarmonyPostfix]
public static void PostfixSetLover(Actor __instance, Actor pActor)
public static void PostfixSetLover(Actor __instance, Actor pActor, Actor __state)
{
if (!EventOutcome.IsLiving(__instance))
{
@ -50,10 +70,12 @@ public static class RelationshipEventPatches
// setLover is already the confirmed state transition.
if (pActor == null)
{
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
return;
}
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
}
@ -103,6 +125,7 @@ public static class RelationshipEventPatches
continue;
}
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
RelationshipInterestFeed.Emit("clear_lover", unit, null);
}
catch
@ -142,7 +165,11 @@ public static class RelationshipEventPatches
increaseChildren
&& EventOutcome.IsLiving(parent)
&& EventOutcome.IsLiving(child),
() => RelationshipInterestFeed.Emit("add_child", parent, child));
() =>
{
LifeSagaMemory.RecordParentChild(parent, child);
RelationshipInterestFeed.Emit("add_child", parent, child);
});
}
[HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))]
@ -150,6 +177,21 @@ public static class RelationshipEventPatches
public static void PostfixNewFamily(Family __result, Actor pActor)
{
Actor anchor = EventOutcome.IsLiving(pActor) ? pActor : null;
if (anchor != null)
{
string familyKey = "";
try
{
familyKey = __result != null ? __result.getID().ToString() : "";
}
catch
{
familyKey = "";
}
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
}
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
}
@ -405,6 +447,10 @@ public static class RelationshipEventPatches
"Family.setAlpha",
"become_alpha",
pNew && EventOutcome.IsLiving(pActor),
() => InterestFeeds.EmitAlpha(pActor, "family_set_alpha"));
() =>
{
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
});
}
}

View file

@ -18,6 +18,40 @@ public static class WarEventPatches
try
{
WarInterestFeed.EmitFromWarObject(__result, phase: "new");
Actor founder = null;
try
{
founder = __result?.main_attacker?.king;
}
catch
{
founder = null;
}
string warKey = "";
try
{
warKey = __result.getID().ToString();
}
catch
{
warKey = "";
}
if (founder != null && founder.isAlive() && !string.IsNullOrEmpty(warKey))
{
string kingdom = "";
try
{
kingdom = founder.kingdom != null ? founder.kingdom.name : "";
}
catch
{
kingdom = "";
}
LifeSagaMemory.RecordWar(founder, warKey, kingdom, ended: false, provenance: "newWar");
}
}
catch
{
@ -37,6 +71,38 @@ public static class WarEventPatches
try
{
WarInterestFeed.EmitFromWarObject(__args[0], phase: "end");
object war = __args[0];
string warKey = "";
try
{
if (war is War typed)
{
warKey = typed.getID().ToString();
}
}
catch
{
warKey = "";
}
if (!string.IsNullOrEmpty(warKey) && war is War endedWar)
{
Actor anchor = null;
try
{
anchor = endedWar.main_attacker?.king
?? endedWar.main_defender?.king;
}
catch
{
anchor = null;
}
if (anchor != null && anchor.isAlive())
{
LifeSagaMemory.RecordWar(anchor, warKey, "", ended: true, provenance: "endWar");
}
}
}
catch
{

View file

@ -11,6 +11,8 @@ namespace IdleSpectator;
public static class StickyScoreboard
{
public const float ScaleHoldSeconds = 2.5f;
/// <summary>Thin camp must reach this alive count to leave mop-up pack framing.</summary>
public const int WipeRecoverMin = 2;
public const float WarTheaterRadius = 48f;
public const float PlotTheaterRadius = 36f;
public const float FamilyTheaterRadius = 28f;
@ -157,11 +159,15 @@ namespace IdleSpectator;
sticky.PeakParticipants = n;
}
UpdateMopUp(sticky, now);
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
// Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up.
// Opposing camp wiped / mop-up: drop Peak Mass hold so tip tier matches living pack.
bool wipedOpposing = sticky.HasOpposingSides
&& sticky.TotalCount > 0
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
&& (sticky.MopUpActive
|| sticky.SideACount <= 0
|| sticky.SideBCount <= 0);
if (wipedOpposing)
{
sticky.HasPresentedScale = true;
@ -465,6 +471,8 @@ namespace IdleSpectator;
sticky.SideBCount = Math.Max(0, ensemble.SideB.Count);
}
// Lock-time scale is a peak floor - live refresh may dip without erasing the theater.
sticky.PeakParticipants = Math.Max(sticky.PeakParticipants, liveN);
return true;
}
@ -477,11 +485,14 @@ namespace IdleSpectator;
}
LiveSceneStickyState sticky = scene.Sticky;
UpdateMopUp(sticky, Time.unscaledTime);
GetPresentationCounts(sticky, out int countA, out int countB);
bool wipedOpposing = sticky.HasOpposingSides
&& sticky.TotalCount > 0
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
&& (countA <= 0 || countB <= 0);
int liveN = Math.Max(0, countA) + Math.Max(0, countB);
int scaleN = wipedOpposing
? Math.Max(1, sticky.TotalCount)
? Math.Max(1, liveN)
: Math.Max(3, sticky.PeakParticipants);
var ensemble = new LiveEnsemble
{
@ -495,7 +506,7 @@ namespace IdleSpectator;
Key = sticky.SideAKey,
Display = sticky.SideADisplay,
KingdomDisplay = sticky.SideAKingdom,
Count = sticky.SideACount,
Count = countA,
Best = focus
},
SideB = new EnsembleSide
@ -503,14 +514,224 @@ namespace IdleSpectator;
Key = sticky.SideBKey,
Display = sticky.SideBDisplay,
KingdomDisplay = sticky.SideBKingdom,
Count = sticky.SideBCount,
Count = countB,
Best = related
},
ParticipantCount = sticky.TotalCount
ParticipantCount = liveN
};
return EventReason.Ensemble(ensemble);
}
/// <summary>
/// Presentation counts for combat tips: mop-up forces a thin recovering camp to 0
/// until it reaches <see cref="WipeRecoverMin"/>.
/// </summary>
public static void GetPresentationCounts(LiveSceneStickyState sticky, out int countA, out int countB)
{
countA = 0;
countB = 0;
if (sticky == null)
{
return;
}
countA = Math.Max(0, sticky.SideACount);
countB = Math.Max(0, sticky.SideBCount);
if (!sticky.MopUpActive)
{
return;
}
if (countA > 0 && countA < WipeRecoverMin)
{
countA = 0;
}
if (countB > 0 && countB < WipeRecoverMin)
{
countB = 0;
}
}
private static void UpdateMopUp(LiveSceneStickyState sticky, float now)
{
if (sticky == null || !sticky.HasOpposingSides)
{
return;
}
int a = Math.Max(0, sticky.SideACount);
int b = Math.Max(0, sticky.SideBCount);
if (a <= 0 || b <= 0)
{
if (!sticky.MopUpActive)
{
sticky.MopUpActive = true;
sticky.MopUpSince = now;
}
return;
}
if (!sticky.MopUpActive)
{
return;
}
// Leave mop-up only when both camps are meaningfully back.
if (a >= WipeRecoverMin && b >= WipeRecoverMin)
{
sticky.MopUpActive = false;
sticky.MopUpSince = -999f;
}
}
/// <summary>
/// True when two sticky scenes share the same unordered kingdom theater
/// (WarFront ↔ local Mass on Igguorn vs The Godo).
/// Matches asset ids, display names, and KingdomDisplay aliases.
/// </summary>
public static bool SharesKingdomTheater(InterestCandidate a, InterestCandidate b)
{
if (a?.Sticky == null || b?.Sticky == null)
{
return false;
}
if (!TryKingdomAliasPair(a.Sticky, out string[] a1, out string[] a2)
|| !TryKingdomAliasPair(b.Sticky, out string[] b1, out string[] b2))
{
return false;
}
return (AliasesOverlap(a1, b1) && AliasesOverlap(a2, b2))
|| (AliasesOverlap(a1, b2) && AliasesOverlap(a2, b1));
}
/// <summary>True when sticky kingdom pair matches a stored crisis theater.</summary>
public static bool SharesKingdomTheater(InterestCandidate c, string theaterA, string theaterB)
{
if (c?.Sticky == null
|| string.IsNullOrEmpty(theaterA)
|| string.IsNullOrEmpty(theaterB)
|| !TryKingdomAliasPair(c.Sticky, out string[] a1, out string[] a2))
{
return false;
}
string[] tA = KingdomAliases(theaterA);
string[] tB = KingdomAliases(theaterB);
return (AliasesOverlap(a1, tA) && AliasesOverlap(a2, tB))
|| (AliasesOverlap(a1, tB) && AliasesOverlap(a2, tA));
}
public static bool TryKingdomPair(LiveSceneStickyState sticky, out string a, out string b)
{
a = "";
b = "";
if (!TryKingdomAliasPair(sticky, out string[] aAliases, out string[] bAliases))
{
return false;
}
a = aAliases.Length > 0 ? aAliases[0] : "";
b = bAliases.Length > 0 ? bAliases[0] : "";
return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) && !KingdomEq(a, b);
}
private static bool TryKingdomAliasPair(
LiveSceneStickyState sticky,
out string[] sideA,
out string[] sideB)
{
sideA = null;
sideB = null;
if (sticky == null)
{
return false;
}
if (sticky.Frame == EnsembleFrame.KingdomVsKingdom)
{
sideA = KingdomAliases(sticky.SideAKey, sticky.SideAKingdom, sticky.SideADisplay);
sideB = KingdomAliases(sticky.SideBKey, sticky.SideBKingdom, sticky.SideBDisplay);
}
else
{
sideA = KingdomAliases(sticky.SideAKingdom, sticky.SideAKey, sticky.SideADisplay);
sideB = KingdomAliases(sticky.SideBKingdom, sticky.SideBKey, sticky.SideBDisplay);
}
return sideA.Length > 0 && sideB.Length > 0 && !AliasesOverlap(sideA, sideB);
}
private static string[] KingdomAliases(params string[] raw)
{
var list = new System.Collections.Generic.List<string>(6);
for (int i = 0; i < raw.Length; i++)
{
AddKingdomAlias(list, raw[i]);
if (!string.IsNullOrEmpty(raw[i]))
{
AddKingdomAlias(list, LiveEnsemble.KingdomDisplay(raw[i]));
}
}
return list.ToArray();
}
private static void AddKingdomAlias(System.Collections.Generic.List<string> list, string key)
{
string n = NormKingdom(key);
if (string.IsNullOrEmpty(n))
{
return;
}
for (int i = 0; i < list.Count; i++)
{
if (string.Equals(list[i], n, StringComparison.Ordinal))
{
return;
}
}
list.Add(n);
}
private static bool AliasesOverlap(string[] a, string[] b)
{
if (a == null || b == null || a.Length == 0 || b.Length == 0)
{
return false;
}
for (int i = 0; i < a.Length; i++)
{
for (int j = 0; j < b.Length; j++)
{
if (KingdomEq(a[i], b[j]))
{
return true;
}
}
}
return false;
}
private static string NormKingdom(string key)
{
return string.IsNullOrEmpty(key) ? "" : key.Trim().ToLowerInvariant();
}
private static bool KingdomEq(string a, string b)
{
return !string.IsNullOrEmpty(a)
&& !string.IsNullOrEmpty(b)
&& string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}
private static void ApplyToEnsemble(
LiveSceneStickyState sticky,
LiveEnsemble ensemble,

View file

@ -284,6 +284,7 @@ public static class HappinessEventRouter
if (entry.Life == HappinessLifePolicy.ProjectToLife
&& entry.Overlap != HappinessOverlapPolicy.CanonicalMilestone)
{
NoteSagaFromHappiness(occ, subject, related, effectId);
if (Chronicle.NoteHappinessLife(occ, subject, related))
{
occ.WroteLife = true;
@ -305,6 +306,64 @@ public static class HappinessEventRouter
return occ;
}
private static void NoteSagaFromHappiness(
HappinessOccurrence occ,
Actor subject,
Actor related,
string effectId)
{
if (subject == null || string.IsNullOrEmpty(effectId))
{
return;
}
string id = effectId;
if (id.IndexOf("become_king", System.StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("became_king", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
LifeSagaMemory.RecordRole(subject, "become_king", "happiness:" + id);
return;
}
if (id.IndexOf("become_leader", System.StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("became_leader", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
LifeSagaMemory.RecordRole(subject, "become_leader", "happiness:" + id);
return;
}
if (id.IndexOf("become_alpha", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
LifeSagaMemory.RecordRole(subject, "become_alpha", "happiness:" + id);
return;
}
if (id.IndexOf("just_found_house", System.StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("found_house", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
LifeSagaMemory.RecordHome(subject, gained: true, provenance: "happiness:" + id);
return;
}
if (id.IndexOf("just_lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("lost_house", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
LifeSagaMemory.RecordHome(subject, gained: false, provenance: "happiness:" + id);
return;
}
if (id.IndexOf("death_lover", System.StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("grief", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
if (related != null)
{
LifeSagaMemory.RecordBondBroken(subject, related, "happiness:" + id);
}
}
_ = occ;
}
private static HappinessOccurrence PublishAggregateMember(
Actor subject,
string effectId,

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
using System;
using UnityEngine;
namespace IdleSpectator;
@ -140,6 +141,97 @@ public static class HudIcons
}
}
/// <summary>
/// Readable saga-rail face: live inspect sprite when usable, else species icon.
/// Skips egg forms and near-empty tiny crops that read as a single pixel.
/// </summary>
public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null)
{
if (actor != null && actor.isAlive())
{
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (!egg)
{
Sprite live = FromActorLive(actor);
if (IsUsableRailFace(live))
{
return live;
}
Sprite asset = FromActor(actor);
if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset))
{
return asset;
}
}
}
string species = speciesFallbackId;
if (string.IsNullOrEmpty(species) && actor?.asset != null)
{
species = actor.asset.id;
}
return FromSpeciesId(species);
}
public static bool IsUsableRailFace(Sprite sprite)
{
if (sprite == null)
{
return false;
}
try
{
// Reject 1-4px crops and blank atlas slices that look like dust on the rail.
float w = sprite.rect.width;
float h = sprite.rect.height;
if (w < 8f || h < 8f)
{
return false;
}
if (sprite.texture == null)
{
return false;
}
return true;
}
catch
{
return false;
}
}
private static bool LooksLikeEggSprite(Sprite sprite)
{
if (sprite == null)
{
return false;
}
try
{
string n = sprite.name ?? "";
return n.IndexOf("egg", StringComparison.OrdinalIgnoreCase) >= 0;
}
catch
{
return false;
}
}
public static Sprite ForChronicleKind(ChronicleKind kind)
{
switch (kind)

View file

@ -179,8 +179,9 @@ public sealed class InterestCandidate
/// <summary>
/// Lock durable 1v1 owner/partner ids (no-op when owner missing).
/// First living partner sticks across attack_target swaps; replace only when the
/// locked partner is dead/missing (or <paramref name="replacePartner"/>).
/// First living partner sticks across attack_target swaps. Replace only when the
/// locked partner is confirmed dead and the owner is no longer fighting
/// (or <paramref name="replacePartner"/>). Lookup misses never unlock the partner.
/// </summary>
public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false)
{
@ -212,12 +213,52 @@ public sealed class InterestCandidate
return;
}
// Locked partner still alive: keep them. Dead/missing: adopt the new foe once.
Actor locked = LiveEnsemble.FindTrackedActor(PairPartnerId);
if (locked == null || !locked.isAlive())
// Prefer FindUnitById: FindAliveById null means dead OR miss - misses must not thrash.
Actor locked = EventFeedUtil.FindUnitById(PairPartnerId);
bool lockedAlive = false;
bool lockedKnown = locked != null;
try
{
PairPartnerId = pid;
lockedAlive = locked != null && locked.isAlive();
}
catch
{
// Destroyed Unity object - treat as miss (keep lock).
lockedKnown = false;
lockedAlive = false;
}
if (lockedAlive)
{
return;
}
if (!lockedKnown)
{
// Unknown / unloaded / destroyed - keep the first partner id.
return;
}
// Confirmed dead. While the owner is still swinging (spectacle 1vN / scrap mop),
// do not chain-adopt the next attack_target into the durable lock.
Actor ownerActor = null;
try
{
ownerActor = owner != null && owner.isAlive()
? owner
: EventFeedUtil.FindAliveById(PairOwnerId);
}
catch
{
ownerActor = EventFeedUtil.FindAliveById(PairOwnerId);
}
if (ownerActor != null && LiveEnsemble.IsCombatParticipant(ownerActor))
{
return;
}
PairPartnerId = pid;
}
public bool HasCombatPairLock => PairOwnerId != 0;

View file

@ -25,7 +25,12 @@ public static class InterestCompletion
}
float age = now - sessionStartedAt;
if (age >= candidate.MaxWatch)
// Combat / war caps are owned by InterestDirector (MaxWatchFor + CombatFightIsHot).
// An early MaxWatch gate here made live scraps look cold so max_cap re-picked
// a new Duel partner every cap (spectacle 1vN thrash).
if (age >= candidate.MaxWatch
&& candidate.Completion != InterestCompletionKind.CombatActive
&& candidate.Completion != InterestCompletionKind.WarFront)
{
return false;
}
@ -94,52 +99,34 @@ public static class InterestCompletion
return false;
}
float now = Time.unscaledTime;
Actor unit = c.FollowUnit;
if (unit == null || !unit.isAlive())
{
return false;
// Spectacle / pair owner may still be swinging after a brief Follow gap.
unit = ResolveCombatHotAnchor(c);
}
float now = Time.unscaledTime;
bool hot = false;
try
// Single participant predicate: sleeping/stunned/frozen are cold even with a stale
// attack_target pointer (combat_focus natural cold; Norron attack-gap truth).
bool hot = unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit);
if (!hot && unit != null && unit.isAlive() && RelatedStillFightingUs(c, unit))
{
if (unit.has_attack_target)
{
hot = true;
}
else if (unit.hasTask() && unit.ai?.task != null
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
{
hot = true;
}
else if (RelatedStillFightingUs(c, unit))
{
hot = true;
}
}
catch
{
// ignore
hot = true;
}
if (!hot && c.AssetId == "live_battle")
if (!hot)
{
// Chunk-local only - never CollectAllCombatFighters during sticky maintain.
Vector3 pos = c.Position;
try
{
if (c.FollowUnit != null && c.FollowUnit.isAlive())
{
pos = c.FollowUnit.current_position;
}
}
catch
{
// keep Position
}
hot = PairPartnerStillFighting(c);
}
hot = WorldActivityScanner.HasLiveCombatNear(pos, 18f);
// Collective Mass/Battle: enrolled sticky fighters bridge attack_target gaps.
// Never use unscoped HasLiveCombatNear - ambient scraps within 18 tiles were
// pinning cold Duel pairs (Neen vs Yaaeore held by nearby Igguorn melees).
bool pairOrDuel = IsPairOrDuelCombat(c);
if (!hot && !pairOrDuel && c.HasStickyCombatSides)
{
hot = StickyRosterStillFighting(c);
}
if (hot)
@ -149,9 +136,129 @@ public static class InterestCompletion
return true;
}
// Hysteresis: WorldBox clears attack_target between swings.
// Pair/Duel: bridge swing gaps. Survivor aftermath requires a fallen partner, so a
// longer pair hold is safe - too short dropped mid-duel into false cold (Norron soak).
const float pairHysteresisSeconds = 5f;
const float combatHysteresisSeconds = 8f;
return now - c.LastSeenAt < combatHysteresisSeconds;
float hold = pairOrDuel ? pairHysteresisSeconds : combatHysteresisSeconds;
return now - c.LastSeenAt < hold;
}
/// <summary>
/// Pair-locked / Duel tips stay hot only while the owned cast fights - not nearby strangers.
/// </summary>
private static bool IsPairOrDuelCombat(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (c.HasCombatPairLock)
{
return true;
}
string tip = c.Label ?? "";
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool PairPartnerStillFighting(InterestCandidate c)
{
if (c == null)
{
return false;
}
Actor partner = c.RelatedUnit;
if ((partner == null || !partner.isAlive()) && c.PairPartnerId != 0)
{
partner = LiveEnsemble.FindTrackedActor(c.PairPartnerId);
}
if (partner == null || !partner.isAlive())
{
return false;
}
try
{
return LiveEnsemble.IsCombatParticipant(partner);
}
catch
{
return false;
}
}
private static bool StickyRosterStillFighting(InterestCandidate c)
{
LiveSceneStickyState sticky = c?.Sticky;
if (sticky == null)
{
return false;
}
if (RosterIdsStillFighting(sticky.SideAIds)
|| RosterIdsStillFighting(sticky.SideBIds))
{
return true;
}
return false;
}
private static bool RosterIdsStillFighting(List<long> ids)
{
if (ids == null || ids.Count == 0)
{
return false;
}
for (int i = 0; i < ids.Count; i++)
{
Actor unit = LiveEnsemble.FindTrackedActor(ids[i]);
if (unit != null && unit.isAlive() && LiveEnsemble.IsCombatParticipant(unit))
{
return true;
}
}
return false;
}
private static Actor ResolveCombatHotAnchor(InterestCandidate c)
{
if (c == null)
{
return null;
}
if (c.TheaterLeadId != 0)
{
Actor lead = LiveEnsemble.FindTrackedActor(c.TheaterLeadId);
if (lead != null && lead.isAlive())
{
return lead;
}
}
if (c.PairOwnerId != 0)
{
Actor owner = LiveEnsemble.FindTrackedActor(c.PairOwnerId);
if (owner != null && owner.isAlive())
{
return owner;
}
}
if (c.RelatedUnit != null && c.RelatedUnit.isAlive())
{
return c.RelatedUnit;
}
return null;
}
private static bool WarFrontStillActive(InterestCandidate c)
@ -181,6 +288,13 @@ public static class InterestCompletion
sidesLive = TryResolveWarStillActive(c.CorrelationKey);
}
// Sticky war tips stay hot while opposing camps remain locked, even when kingdom
// asset lookup fails (harness synthetic keys / display-name aliases).
if (!sidesLive && c.HasStickyCombatSides && c.CombatPeakParticipants > 0)
{
sidesLive = true;
}
if (sidesLive)
{
c.LastSeenAt = now;
@ -568,6 +682,11 @@ public static class InterestCompletion
return false;
}
if (LiveEnsemble.IsCombatIncapacitated(foe) || LiveEnsemble.IsCombatIncapacitated(unit))
{
return false;
}
try
{
if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive())

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,659 @@
using System;
using UnityEngine;
namespace IdleSpectator;
public static partial class InterestDirector
{
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
{
if (candidate == null)
{
return false;
}
// Same FixedDwell beat already watched on this subject - never cut back in.
if (InterestVariety.IsBeatCooled(candidate))
{
InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key);
return false;
}
if (_current == null)
{
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
}
float now = Time.unscaledTime;
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
if (IsCombatPartnerSwapNoise(_current, candidate))
{
InterestDropLog.Record(
"combat_partner_hold",
$"cur={_current.Key} next={candidate.Key}");
return false;
}
// Crisis signal tip (war / disaster / outbreak) owns the camera against Mass/Battle scraps.
// Must run before crisis cut-in: a leftover war crisis can MatchCrisis(Mass) while a
// small WarFront tip does not. Drop reason keeps war_theater_hold alias for harness.
if (StoryPlanner.IsCrisisCameraSignal(_current)
&& candidate.Completion == InterestCompletionKind.CombatActive)
{
string holdReason = _current.Completion == InterestCompletionKind.WarFront
? "war_theater_hold"
: "crisis_theater_hold";
InterestDropLog.Record(
holdReason,
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
return false;
}
// Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars
// against aftermath / ambient peers that outscore the closer on raw EventStrength.
if (StoryPlanner.CrisisActive
&& StoryPlanner.MatchesCrisis(candidate)
&& !StoryPlanner.MatchesCrisis(_current)
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
{
return true;
}
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
// Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom
// theater may reclaim the chapter (Mass → War) without waiting for combat cold.
if (CombatFightIsHot(_current, now)
&& !IsSameStoryArc(_current, candidate)
&& candidate.Completion != InterestCompletionKind.CombatActive)
{
bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.SharesKingdomTheater(_current, candidate);
if (warTheaterReclaim)
{
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
}
if (IsCombatUrgentPeer(_current, candidate))
{
InterestDropLog.Record(
"combat_urgent_cut",
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
}
InterestDropLog.Record(
"combat_hold",
$"cur={_current.Key} next={candidate.Key}");
return false;
}
bool inGrace = InQuietGrace;
if (inGrace)
{
// Story aftermath / same-arc continuation may cut freely during quiet grace.
if (IsSameStoryArc(_current, candidate)
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
{
return true;
}
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
// Prevents family-join / love status from stealing a fight between swings.
if (_current != null
&& (_current.Completion == InterestCompletionKind.CombatActive
|| _current.Completion == InterestCompletionKind.StatusPhase
|| _current.Completion == InterestCompletionKind.HappinessGrief
|| IsStickyStoryScene(_current)))
{
float graceCur = _current.TotalScore;
float graceNext = candidate.TotalScore;
float onCurrentGrace = now - _currentStartedAt;
ScoringWeights gw = InterestScoringConfig.W;
float graceMargin = IsSoftStickyCluster(_current)
? gw.cutInMargin
: Mathf.Max(
gw.cutInMargin,
gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f);
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
{
float valveMargin = gw.stickyVarietyValveMargin > 0f
? gw.stickyVarietyValveMargin
: 20f;
graceMargin = Mathf.Min(graceMargin, valveMargin);
if (graceNext >= graceCur - gw.rotateSlack
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
{
return true;
}
}
return graceNext >= graceCur + graceMargin
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
}
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
{
return false;
}
bool protectedScene = SessionProtected(now, onCurrent);
ScoringWeights w = InterestScoringConfig.W;
float curScore = _current.TotalScore;
float nextScore = candidate.TotalScore;
bool nextFill = InterestScoring.IsFillScore(nextScore);
bool curAmbient = IsAmbientShot(_current);
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
bool sticky = IsStickyStoryScene(_current);
float cutMargin = sticky
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
: w.cutInMargin;
bool varietyValve = VarietyValveOpen(_current, onCurrent)
&& IsVarietyValveCandidate(candidate);
if (varietyValve)
{
float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
cutMargin = Mathf.Min(cutMargin, valveMargin);
}
// Ambient fill: any real event may take the camera immediately (no min dwell).
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
{
return true;
}
// Soft family pack: yield to discrete unit events after a short hold.
if (IsSoftStickyCluster(_current)
&& !IsSoftStickyCluster(candidate)
&& !nextFill
&& !IsAmbientShot(candidate)
&& onCurrent >= SoftClusterYieldSeconds
&& nextScore >= curScore - w.rotateSlack)
{
return true;
}
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
{
bool hotEnough = InterestScoring.IsHotScore(nextScore)
|| nextScore >= curScore - w.rotateSlack;
bool storyEnough = nextScore >= w.noticeScoreMin
|| candidate.EventStrength >= w.noticeScoreMin;
if (hotEnough || storyEnough)
{
InterestDropLog.Record(
"variety_valve",
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
return true;
}
}
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
{
return true;
}
// StoryPlanner hard-arc / crisis hold against unrelated peers.
float storyHold = Mathf.Max(
StoryPlanner.ArcHoldMargin(_current, candidate),
StoryPlanner.CrisisHoldMargin(_current, candidate));
if (storyHold > 0f)
{
cutMargin = Mathf.Max(cutMargin, storyHold);
}
// Instant score-margin cut - no settle grace, no MinDwell block.
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
if (nextScore >= curScore + effectiveMargin)
{
return true;
}
if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
{
InterestDropLog.Record(
"below_margin",
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
+ (varietyValve ? " valve" : "")
+ (storyHold > 0f ? " story" : ""));
}
// Fill never cuts a protected non-fill session without margin (already failed above).
if (nextFill && !curFill && protectedScene)
{
return false;
}
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
if (nextFill && curFill && onCurrent < fillRotate)
{
return false;
}
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
if ((protectedScene || sticky) && !curAmbient)
{
return false;
}
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
{
return true;
}
return onCurrent >= MinDwellFor(_current)
&& sinceSwitch >= _switchCooldown
&& nextScore >= curScore - w.rotateSlack;
}
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
private const float SoftClusterYieldSeconds = 4f;
private static bool IsSoftStickyCluster(InterestCandidate c) =>
c != null && c.Completion == InterestCompletionKind.FamilyPack;
/// <summary>
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
/// </summary>
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
{
return false;
}
ScoringWeights w = InterestScoringConfig.W;
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
if (next.TotalScore >= current.TotalScore + stickyMargin)
{
return true;
}
// King / leadership deaths sit under the epic floor (king_killed=88) but must cut
// live scraps - soak missed a king slaying while parked on a Duel thrash.
if (IsLeadershipDeathPeer(next))
{
return true;
}
float epicMin = w.combatUrgentEventStrengthMin > 0f
? w.combatUrgentEventStrengthMin
: 95f;
float urgentMargin = w.combatUrgentCutInMargin > 0f
? w.combatUrgentCutInMargin
: 20f;
return next.EventStrength >= epicMin
&& next.TotalScore >= current.TotalScore + urgentMargin;
}
/// <summary>
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
/// </summary>
private static bool IsLeadershipDeathPeer(InterestCandidate c)
{
if (c == null)
{
return false;
}
string id = (c.AssetId ?? "").ToLowerInvariant();
if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0
|| id.IndexOf("king_dead", StringComparison.Ordinal) >= 0
|| id.IndexOf("king_died", StringComparison.Ordinal) >= 0
|| id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0
|| id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0)
{
return true;
}
if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return id.IndexOf("kill", StringComparison.Ordinal) >= 0
|| id.IndexOf("dead", StringComparison.Ordinal) >= 0
|| id.IndexOf("died", StringComparison.Ordinal) >= 0
|| id.IndexOf("slain", StringComparison.Ordinal) >= 0;
}
/// <summary>
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
/// </summary>
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
!_varietyValveUsed
&& VarietyValveTimeReady(scene, onCurrent)
&& !CombatFightIsHot(scene, Time.unscaledTime);
private static bool IsStickyStoryScene(InterestCandidate c)
{
if (c == null)
{
return false;
}
// FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster).
if (c.Completion == InterestCompletionKind.CombatActive
|| c.Completion == InterestCompletionKind.WarFront
|| c.Completion == InterestCompletionKind.PlotActive
|| c.Completion == InterestCompletionKind.StatusPhase
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| c.Completion == InterestCompletionKind.HappinessGrief)
{
return true;
}
// Hatch FixedDwell is a story beat - hold against weak peers.
if (!string.IsNullOrEmpty(c.HappinessEffectId)
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
if (!string.IsNullOrEmpty(c.Key)
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
{
return true;
}
return false;
}
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
// Hatch: same subject beat may refresh without full margin.
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
{
return true;
}
// Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
// Different combat:pair keys used to soft-cut and thrash Duel partners.
if (current.Completion == InterestCompletionKind.CombatActive
&& next.Completion == InterestCompletionKind.CombatActive
&& IsSameCombatPairArc(current, next))
{
return true;
}
// Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut).
if (current.Completion == InterestCompletionKind.CombatActive
&& next.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.SharesKingdomTheater(current, next))
{
return true;
}
if (current.Completion == InterestCompletionKind.WarFront
&& next.Completion == InterestCompletionKind.WarFront
&& StickyScoreboard.SharesKingdomTheater(current, next))
{
return true;
}
if (current.Completion == InterestCompletionKind.HappinessGrief
&& next.Completion == InterestCompletionKind.HappinessGrief
&& current.SubjectId != 0
&& current.SubjectId == next.SubjectId)
{
return true;
}
// StoryPlanner aftermath / epilogue of the active climax.
if (StoryPlanner.IsContinuationOf(current, next))
{
return true;
}
return false;
}
/// <summary>
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
/// </summary>
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
CollectCombatActorIds(current, out long curA, out long curB);
CollectCombatActorIds(next, out long nextA, out long nextB);
bool samePair = curA != 0
&& curB != 0
&& nextA != 0
&& nextB != 0
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
if (samePair)
{
return true;
}
// Duel → Mass/Battle escalate: shared fighter + larger theater.
bool nextMulti = next.ParticipantCount >= 3
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| HasStickyCollectiveMulti(next);
if (!nextMulti)
{
return false;
}
return SharesCombatFighterId(current, nextA)
|| SharesCombatFighterId(current, nextB)
|| SharesCombatFighterId(next, curA)
|| SharesCombatFighterId(next, curB);
}
/// <summary>
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
/// </summary>
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
{
if (current == null
|| next == null
|| current.Completion != InterestCompletionKind.CombatActive
|| next.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
// Multi escalate / different theater is not partner-swap noise.
bool nextMulti = next.ParticipantCount >= 3
|| HasStickyCollectiveMulti(next)
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
&& next.ParticipantCount > 2);
if (nextMulti && !IsThinPairCombat(next))
{
return false;
}
CollectCombatActorIds(current, out long curA, out long curB);
CollectCombatActorIds(next, out long nextA, out long nextB);
if (curA == 0 && curB == 0
&& current.TheaterLeadId == 0
&& current.PairOwnerId == 0
&& current.SubjectId == 0)
{
return false;
}
// Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise.
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
{
if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB))
{
return true;
}
return false;
}
if (!IsThinPairCombat(next))
{
return false;
}
// Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise.
// Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live.
bool sameUnorderedPair = curA != 0
&& curB != 0
&& nextA != 0
&& nextB != 0
&& ((curA == nextA && curB == nextB)
|| (curA == nextB && curB == nextA));
if (sameUnorderedPair)
{
long curSub = current.SubjectId != 0
? current.SubjectId
: (current.PairOwnerId != 0
? current.PairOwnerId
: EventFeedUtil.SafeId(current.FollowUnit));
long nextSub = next.SubjectId != 0
? next.SubjectId
: EventFeedUtil.SafeId(next.FollowUnit);
if (curSub != 0 && nextSub != 0 && curSub != nextSub)
{
return true;
}
return false;
}
// Shared fighter + different partner = attack_target thrash.
return SharesCombatOwnerOrLead(current, nextA, nextB)
|| SharesCombatFighterId(current, nextA)
|| SharesCombatFighterId(current, nextB);
}
private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB)
{
if (current == null)
{
return false;
}
long lead = current.TheaterLeadId != 0
? current.TheaterLeadId
: (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId);
if (lead == 0)
{
lead = EventFeedUtil.SafeId(current.FollowUnit);
}
return lead != 0 && (lead == nextA || lead == nextB);
}
private static bool IsThinPairCombat(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
{
return true;
}
string tip = c.Label ?? "";
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
string key = c.Key ?? "";
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|| (key.StartsWith("combat:", StringComparison.Ordinal)
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
}
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
{
a = 0;
b = 0;
if (c == null)
{
return;
}
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
if (a == 0)
{
a = EventFeedUtil.SafeId(c.FollowUnit);
}
if (b == 0)
{
b = EventFeedUtil.SafeId(c.RelatedUnit);
}
}
private static bool SharesCombatFighterId(InterestCandidate c, long id)
{
if (c == null || id == 0)
{
return false;
}
CollectCombatActorIds(c, out long a, out long b);
return id == a || id == b;
}
private static bool SameKeyPrefix(string a, string b, string prefix)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
{
return false;
}
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|| !b.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}
return string.Equals(a, b, StringComparison.Ordinal);
}
}

File diff suppressed because it is too large Load diff

View file

@ -269,7 +269,9 @@ public static class InterestRegistry
bool match = string.IsNullOrEmpty(needle)
|| (c.Key != null
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|| (c.AssetId != null
&& c.AssetId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
if (match)
{
c.ExpiresAt = now - 0.01f;

View file

@ -160,7 +160,7 @@ public static class InterestScoring
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
float causalBonus = CausalHeat.ScoreBonus(c);
float ledgerBonus = CharacterLedger.ScoreBonus(c);
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
c.TotalScore = c.EventStrength + scaleBonus
+ c.CharacterSignificance * charWeight
@ -169,7 +169,7 @@ public static class InterestScoring
- repeatPenalty
+ frequencyAdjust
+ causalBonus
+ ledgerBonus
+ sagaBonus
+ ownershipBonus;
string detail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
@ -190,9 +190,9 @@ public static class InterestScoring
detail += $" causal=+{causalBonus:0.#}";
}
if (ledgerBonus > 0.05f)
if (sagaBonus > 0.05f)
{
detail += $" ledger=+{ledgerBonus:0.#}";
detail += $" saga=+{sagaBonus:0.#}";
}
if (ownershipBonus > 0.05f)

View file

@ -36,12 +36,53 @@ public class StoryWeights
public int ledgerCap = 16;
public float ledgerWeight = 8f;
public float historyDensityWindowSeconds = 600f;
/// <summary>Multiply subject ledger heat after a family life chapter tip is shown.</summary>
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
public float ledgerBleedLifeChapter = 0.4f;
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
public float ledgerBleedVillage = 0.82f;
/// <summary>Life-saga roster soft MC tip bonus.</summary>
public float sagaMcWeight = 6f;
/// <summary>Extra soft bonus when the saga slot is Prefer'd.</summary>
public float sagaPreferWeight = 10f;
/// <summary>Extra soft bonus when the unit is a WorldBox favorite.</summary>
public float sagaGameFavoriteWeight = 8f;
/// <summary>Chapter starvation window before dull non-favorite MCs lose rank.</summary>
public float sagaDullSeconds = 300f;
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
public float familyLifeBeatCooldownSeconds = 90f;
// --- Crisis chapters (Phase 4) ---
/// <summary>WarFront needs at least this many participants to open a crisis chapter.</summary>
public int crisisWarParticipantMin = 8;
/// <summary>Disaster / outbreak EventStrength floor to open a crisis chapter.</summary>
public float crisisDisasterStrengthMin = 95f;
/// <summary>Score boost for tips that belong to the live crisis.</summary>
public float crisisOwnershipBoost = 22f;
/// <summary>Extra cut-in margin while watching a crisis tip against unrelated peers.</summary>
public float crisisHoldMargin = 40f;
/// <summary>Keep the chapter after the last crisis signal for this many seconds.</summary>
public float crisisLingerSeconds = 10f;
/// <summary>
/// Disaster / outbreak linger after the last WorldLog pulse (tornado tips are short FixedDwell).
/// </summary>
public float crisisDisasterLingerSeconds = 28f;
/// <summary>Hard cap on crisis Active phase length (seconds).</summary>
public float crisisMaxSeconds = 180f;
/// <summary>Do not reopen a crisis for this many seconds after Done.</summary>
public float crisisCooldownSeconds = 45f;
/// <summary>
/// Longer cool after disaster/outbreak closers so stacked storm tips cannot double-close.
/// </summary>
public float crisisDisasterCooldownSeconds = 120f;
/// <summary>Forced closer strength when a crisis chapter ends.</summary>
public float crisisEpilogueStrength = 52f;
/// <summary>Forced closer max watch (seconds).</summary>
public float crisisEpilogueMaxWatch = 16f;
/// <summary>
/// After a hard story/crisis closer, suppress soft-life crumbs and character-fill
/// for this many seconds so AFK does not immediately crumb-surf.
/// </summary>
public float softFillQuietSeconds = 18f;
}
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
@ -152,6 +193,15 @@ public class ScoringWeights
public float scannerHotBonus = 18f;
public float scannerWarmBonus = 8f;
public float scannerSpectacleBonus = 12f;
/// <summary>
/// Idle spectacles (evil mage pacing, dragon roosting) keep at least this action score
/// so warm civic chores cannot own ambient forever.
/// </summary>
public float scannerSpectacleIdleFloor = 48f;
/// <summary>
/// Prefer a live spectacle over a non-spectacle ambient pick when within this score gap.
/// </summary>
public float scannerSpectaclePreferMargin = 28f;
public float scannerActionKillsFactor = 0.2f;
public float scannerActionKillsCap = 10f;
public float scannerWarriorBonus = 6f;

View file

@ -164,7 +164,7 @@ public static class InterestVariety
}
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
CharacterLedger.CoolAfterSoftLifeBeat(chosen);
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
}
/// <summary>
@ -727,7 +727,33 @@ public static class InterestVariety
return Ranked[0];
}
// Probabilistic among top-N only when scores are within epsilon.
// Near-tie: Prefer'd MC, then any MC, else random among top-N.
InterestCandidate bestSaga = null;
int bestRank = -1;
for (int i = 0; i < topN; i++)
{
if (Ranked[0].TotalScore - Ranked[i].TotalScore >= epsilon)
{
break;
}
int rank = LifeSagaRoster.TipTouchesPrefer(Ranked[i])
? 2
: LifeSagaRoster.TipTouchesMc(Ranked[i])
? 1
: 0;
if (rank > bestRank)
{
bestRank = rank;
bestSaga = Ranked[i];
}
}
if (bestSaga != null && bestRank > 0)
{
return bestSaga;
}
int pick = Rng.Next(topN);
return Ranked[pick];
}
@ -1181,6 +1207,57 @@ public static class InterestVariety
|| s.IndexOf("favorite", StringComparison.Ordinal) >= 0;
}
/// <summary>
/// Soft-fill quiet crumbs: soft life chapters, short interrupt FX, and ambient Pack fill.
/// Pack must not own the camera during quiet (and no longer breaks quiet).
/// </summary>
public static bool IsSoftFillQuietCrumb(InterestCandidate c) =>
c != null
&& (c.Completion == InterestCompletionKind.FamilyPack
|| IsSoftLifeChapter(c)
|| IsShortInterruptFxChapter(c));
/// <summary>
/// Brief interrupt FX that should not own AFK during soft-fill quiet.
/// Kept non-outbreak; real danger statuses stay hot.
/// </summary>
public static bool IsShortInterruptFxChapter(InterestCandidate c)
{
if (c == null)
{
return false;
}
string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId;
if (string.IsNullOrEmpty(status))
{
return false;
}
if (c.Completion != InterestCompletionKind.StatusPhase
&& c.Completion != InterestCompletionKind.FixedDwell
&& string.IsNullOrEmpty(c.StatusId))
{
return false;
}
return IsShortInterruptFxId(status);
}
public static bool IsShortInterruptFxId(string statusId)
{
if (string.IsNullOrEmpty(statusId))
{
return false;
}
string s = statusId.Trim().ToLowerInvariant();
return s.IndexOf("stun", StringComparison.Ordinal) >= 0
|| s.IndexOf("invincible", StringComparison.Ordinal) >= 0
|| s == "surprised"
|| s == "confused";
}
/// <summary>
/// Soft personal status FX ids from the live status inventory shape:
/// mood / life-cycle / brief FX. Danger and combat statuses stay hotter.
@ -1192,11 +1269,16 @@ public static class InterestVariety
return false;
}
// Short interrupt FX count as soft crumbs for quiet + variety cooling.
if (IsShortInterruptFxId(statusId))
{
return true;
}
string s = statusId.Trim().ToLowerInvariant();
// Danger / combat / possession stay camera-sticky for repeats.
if (s.IndexOf("burn", StringComparison.Ordinal) >= 0
|| s.IndexOf("poison", StringComparison.Ordinal) >= 0
|| s.IndexOf("stun", StringComparison.Ordinal) >= 0
|| s.IndexOf("drown", StringComparison.Ordinal) >= 0
|| s.IndexOf("starv", StringComparison.Ordinal) >= 0
|| s.IndexOf("possess", StringComparison.Ordinal) >= 0
@ -1205,7 +1287,6 @@ public static class InterestVariety
|| s.IndexOf("rage", StringComparison.Ordinal) >= 0
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|| s.IndexOf("shield", StringComparison.Ordinal) >= 0
|| s.IndexOf("invincible", StringComparison.Ordinal) >= 0
|| s.IndexOf("combat", StringComparison.Ordinal) >= 0
|| s == "on_guard"
|| s == "angry"

View file

@ -12,6 +12,12 @@ public static class SpectatorMode
public static bool Active { get; private set; }
/// <summary>
/// True when idle was frozen for Lore/browse without wiping StoryPlanner / registry.
/// Resume via <see cref="SetActive"/>(true) restores that session instead of a full clear enable.
/// </summary>
public static bool BrowsePaused { get; private set; }
private static bool _forceFileChecked;
public static void Toggle()
@ -19,7 +25,11 @@ public static class SpectatorMode
SetActive(!Active);
}
public static void SetActive(bool active, bool quiet = false)
/// <param name="browsePause">
/// When disabling: freeze story/crisis/ledger/registry for Lore browse (do not Clear).
/// Ignored when enabling - resume uses <see cref="BrowsePaused"/>.
/// </param>
public static void SetActive(bool active, bool quiet = false, bool browsePause = false)
{
if (active && !ModSettings.Enabled)
{
@ -36,22 +46,45 @@ public static class SpectatorMode
if (Active)
{
WatchCaption.ClearPausePin();
InterestDirector.OnSpectatorEnabled();
bool fromBrowse = BrowsePaused;
BrowsePaused = false;
if (fromBrowse)
{
InterestDirector.OnSpectatorBrowseResumed();
}
else
{
InterestDirector.OnSpectatorEnabled();
}
SpeciesDiscovery.OnSpectatorEnabled();
Chronicle.OnSpectatorEnabled();
ChronicleHud.OnIdleResumed();
FocusRelationshipArrows.OnSpectatorEnabled();
LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
LogService.LogInfo(
fromBrowse
? "[IdleSpectator] Spectator resumed after Lore/browse pause"
: "[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
}
else
{
InterestDirector.OnSpectatorDisabled();
if (browsePause)
{
BrowsePaused = true;
InterestDirector.OnSpectatorBrowsePaused();
}
else
{
BrowsePaused = false;
InterestDirector.OnSpectatorDisabled();
}
SpeciesDiscovery.OnSpectatorDisabled();
FocusRelationshipArrows.OnSpectatorDisabled();
CameraDirector.ClearFollow();
if (quiet)
{
// Manual-input pause: leave dossier up so ShowStatusBanner can reuse it.
// Manual-input / Lore pause: leave dossier up so ShowStatusBanner can reuse it.
StateProbe.Reset();
}
else

View file

@ -1,295 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
/// </summary>
public static class CharacterLedger
{
private struct LedgerEntry
{
public long UnitId;
public float Heat;
public float TouchedAt;
}
private static readonly List<LedgerEntry> Entries = new List<LedgerEntry>(20);
private static readonly List<int> ScratchIdx = new List<int>(8);
public static void Clear()
{
Entries.Clear();
}
public static int Count => Entries.Count;
public static bool IsOnLedger(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId == unitId)
{
return true;
}
}
return false;
}
public static float Heat(long unitId)
{
if (unitId == 0)
{
return 0f;
}
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId == unitId)
{
return Entries[i].Heat;
}
}
return 0f;
}
public static float ScoreBonus(InterestCandidate c)
{
if (c == null)
{
return 0f;
}
StoryWeights s = InterestScoringConfig.Story;
float h = Heat(c.SubjectId);
if (h <= 0f && c.RelatedId != 0)
{
h = Heat(c.RelatedId) * 0.5f;
}
return h * s.ledgerWeight;
}
public static void Touch(long unitId, float heatAdd = 1f)
{
if (unitId == 0)
{
return;
}
float now = Time.unscaledTime;
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId != unitId)
{
continue;
}
LedgerEntry e = Entries[i];
e.Heat = Mathf.Min(8f, e.Heat + heatAdd);
e.TouchedAt = now;
Entries[i] = e;
return;
}
EnsureCapacityForNew(unitId);
Entries.Add(new LedgerEntry
{
UnitId = unitId,
Heat = Mathf.Clamp(heatAdd, 0.5f, 8f),
TouchedAt = now
});
}
public static void NoteFeatured(InterestCandidate scene)
{
if (scene == null)
{
return;
}
float add = 1f;
if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit)))
{
add += 1.5f;
}
if (InterestScoring.IsNotable(scene.FollowUnit))
{
add += 0.75f;
}
if (scene.SubjectId != 0)
{
Touch(scene.SubjectId, add);
float density = Chronicle.RecentHistoryCount(
scene.SubjectId,
InterestScoringConfig.Story.historyDensityWindowSeconds);
if (density > 0)
{
Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f));
}
}
if (scene.RelatedId != 0)
{
Touch(scene.RelatedId, add * 0.6f);
}
float causal = CausalHeat.Get(scene.SubjectId);
if (causal > 0.5f)
{
Touch(scene.SubjectId, Mathf.Min(1.5f, causal));
}
}
/// <summary>
/// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough
/// to monopolize the next few picks for the same village cast.
/// </summary>
public static void CoolAfterSoftLifeBeat(InterestCandidate scene)
{
if (scene == null || !InterestVariety.IsSoftLifeChapter(scene))
{
return;
}
StoryWeights s = InterestScoringConfig.Story;
float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f;
float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f;
Bleed(scene.SubjectId, subjectMul);
if (scene.RelatedId != 0)
{
Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f));
}
// Mild cool across the rest of the ledger so harness-featured villagers fade.
for (int i = 0; i < Entries.Count; i++)
{
long id = Entries[i].UnitId;
if (id == 0 || id == scene.SubjectId || id == scene.RelatedId)
{
continue;
}
Bleed(id, villageMul);
}
}
public static void Bleed(long unitId, float multiplyHeat)
{
if (unitId == 0 || multiplyHeat >= 0.999f)
{
return;
}
multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f);
for (int i = 0; i < Entries.Count; i++)
{
if (Entries[i].UnitId != unitId)
{
continue;
}
LedgerEntry e = Entries[i];
e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat);
Entries[i] = e;
return;
}
}
public static void Enumerate(List<long> into)
{
if (into == null)
{
return;
}
into.Clear();
for (int i = 0; i < Entries.Count; i++)
{
into.Add(Entries[i].UnitId);
}
}
public static Actor PreferLedgerUnit(IEnumerable<Actor> candidates)
{
if (candidates == null)
{
return null;
}
Actor best = null;
float bestHeat = -1f;
foreach (Actor a in candidates)
{
if (a == null || !a.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(a);
float h = Heat(id);
if (h > bestHeat)
{
bestHeat = h;
best = a;
}
}
return bestHeat > 0f ? best : null;
}
private static void EnsureCapacityForNew(long incomingId)
{
StoryWeights s = InterestScoringConfig.Story;
int cap = s.ledgerCap > 0 ? s.ledgerCap : 16;
if (Entries.Count < cap)
{
return;
}
// Never evict current arc cast.
ScratchIdx.Clear();
int worst = -1;
float worstHeat = float.MaxValue;
for (int i = 0; i < Entries.Count; i++)
{
long id = Entries[i].UnitId;
if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id))
{
continue;
}
if (id == incomingId)
{
continue;
}
float h = Entries[i].Heat;
if (h < worstHeat)
{
worstHeat = h;
worst = i;
}
}
if (worst >= 0)
{
Entries.RemoveAt(worst);
}
else if (Entries.Count > 0)
{
// All protected - drop oldest non-incoming.
Entries.RemoveAt(0);
}
}
}

View file

@ -0,0 +1,50 @@
namespace IdleSpectator;
/// <summary>Parallel multi-minute chapter over war / disaster / outbreak (not a short StoryArc).</summary>
public enum CrisisKind
{
None = 0,
War = 1,
Disaster = 2,
Outbreak = 3
}
public enum CrisisPhase
{
Active = 0,
Epilogue = 1,
Done = 2
}
/// <summary>
/// Long bias overlay: ownership boost, ambient suppress, forced closer.
/// Lives beside short <see cref="StoryArc"/> so unrelated tips do not thrash chapter state.
/// </summary>
public sealed class CrisisChapter
{
public CrisisKind Kind = CrisisKind.None;
public CrisisPhase Phase = CrisisPhase.Done;
public string AnchorKey = "";
public string Id = "";
public float StartedAt;
public float LastSignalAt;
public float PhaseStartedAt;
public long FollowId;
public bool EpilogueInjected;
/// <summary>Unordered kingdom theater pair for war chapters (matches local Mass scraps).</summary>
public string TheaterKeyA = "";
public string TheaterKeyB = "";
public bool IsLive => Phase == CrisisPhase.Active || Phase == CrisisPhase.Epilogue;
public void Advance(CrisisPhase next, float now)
{
if (Phase == next)
{
return;
}
Phase = next;
PhaseStartedAt = now;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,135 @@
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Compatibility façade over <see cref="LifeSagaPresentation"/>.
/// Prefer <see cref="LifeSagaPresentation.Build"/> for new UI.
/// </summary>
public static class LifeSagaOverview
{
public static LifeSagaSnapshot BuildSnapshot(LifeSagaSlot slot)
{
var snapshot = new LifeSagaSnapshot();
if (slot == null || slot.UnitId == 0)
{
return snapshot;
}
LifeSagaViewModel model = LifeSagaPresentation.Build(slot.UnitId);
snapshot.UnitId = model.UnitId;
snapshot.Header = model.Name;
snapshot.Subtitle = model.Title;
if (!string.IsNullOrEmpty(model.StakeLine))
{
snapshot.Rows.Add(new LifeSagaSnapshotRow
{
Kind = LifeSagaSnapshotRowKind.Headline,
Text = model.StakeLine
});
}
if (model.Cast.Count > 0)
{
var parts = new List<string>(model.Cast.Count);
for (int i = 0; i < model.Cast.Count; i++)
{
parts.Add(model.Cast[i].Relation + " " + model.Cast[i].Name);
}
snapshot.Rows.Add(new LifeSagaSnapshotRow
{
Kind = LifeSagaSnapshotRowKind.Circle,
Text = string.Join(" · ", parts)
});
}
if (!string.IsNullOrEmpty(model.EvidenceLine))
{
snapshot.Rows.Add(new LifeSagaSnapshotRow
{
Kind = LifeSagaSnapshotRowKind.Current,
Text = model.EvidenceTitle + ": " + model.EvidenceLine
});
}
for (int i = 0; i < model.Legacy.Count; i++)
{
snapshot.Rows.Add(new LifeSagaSnapshotRow
{
Kind = LifeSagaSnapshotRowKind.Chapter,
ChapterKind = ToChapterKind(model.Legacy[i].Kind),
Text = model.Legacy[i].Line
});
}
return snapshot;
}
public static string Build(LifeSagaSlot slot)
{
return LifeSagaPresentation.BuildPlainText(slot);
}
public static float StandoutTraitScore(Actor actor)
{
return UnitDossier.ReadStandoutTraitNames(actor, 1).Count > 0 ? 100f : 0f;
}
private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
{
switch (kind)
{
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.RivalEarned:
case LifeSagaFactKind.HardArcCombat:
return LifeSagaChapterKind.Combat;
case LifeSagaFactKind.WarJoin:
case LifeSagaFactKind.WarEnd:
case LifeSagaFactKind.HardArcWar:
return LifeSagaChapterKind.War;
case LifeSagaFactKind.PlotJoin:
case LifeSagaFactKind.PlotEnd:
case LifeSagaFactKind.HardArcPlot:
return LifeSagaChapterKind.Plot;
case LifeSagaFactKind.BondFormed:
case LifeSagaFactKind.HardArcLove:
return LifeSagaChapterKind.Love;
case LifeSagaFactKind.BondBroken:
case LifeSagaFactKind.Death:
case LifeSagaFactKind.HardArcGrief:
return LifeSagaChapterKind.Grief;
default:
return LifeSagaChapterKind.Unknown;
}
}
}
public enum LifeSagaSnapshotRowKind
{
Headline,
Current,
Circle,
Qualities,
Chapter
}
public sealed class LifeSagaSnapshotRow
{
public LifeSagaSnapshotRowKind Kind;
public LifeSagaChapterKind ChapterKind;
public string Text = "";
}
public sealed class LifeSagaSnapshot
{
public long UnitId;
public string Header = "";
public string Subtitle = "";
public readonly List<LifeSagaSnapshotRow> Rows = new List<LifeSagaSnapshotRow>();
public string ToPlainText()
{
return LifeSagaPresentation.BuildPlainText(UnitId);
}
}

View file

@ -0,0 +1,757 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Saga body matching Design A (cast + stakes): identity, stake, cast, evidence, legacy, Open Lore.
/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail.
/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
/// </summary>
public static class LifeSagaPanel
{
/// <summary>Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).</summary>
public const float BodyWidthFixed = 232f;
/// <summary>Hard cap so Saga never eats the screen.</summary>
public const float BodyHeightMax = 148f;
public const float BodyWidthMin = BodyWidthFixed;
public const float BodyWidthMax = BodyWidthFixed;
public const float BodyHeightMin = 56f;
public const float BodyHeightFixed = BodyHeightMax;
private const float Pad = 3f;
private const float AvatarSize = 32f;
private const float ColGap = 6f;
private const float CastFace = 16f;
private const float CastSlotW = 54f;
private const float LoreH = 11f;
private const float SecLabelH = 10f;
private const float LineH = 10f;
private const int NameFont = 9;
private const int TitleFont = 7;
private const int StakeFont = 8;
private const int BodyFont = 7;
private const int MaxCast = 4;
private const int MaxLegacy = 5;
private const int BuildVersion = 7;
private static GameObject _root;
private static RectTransform _rootRt;
private static Text _nameText;
private static Text _titleText;
private static Text _stakeText;
private static Text _castHead;
private static Text _evidenceHead;
private static Text _evidenceText;
private static Text _legacyHead;
private static Text _legacyText;
private static Button _loreBtn;
private static Text _loreBtnLabel;
private static readonly CastSlot[] CastSlots = new CastSlot[MaxCast];
private static long _boundId;
private static string _speciesId = "";
private static string _fingerprint = "";
private static bool _ownsAvatar;
private static int _builtVersion;
private static float _laidOutHeight = BodyHeightMin;
private sealed class CastSlot
{
public GameObject Root;
public Image FaceBg;
public Image Face;
public Text Name;
public Text Relation;
public long UnitId;
}
public static bool Visible => _root != null && _root.activeSelf;
public static float BodyHeight => _laidOutHeight;
public static float BodyWidthPx => BodyWidthFixed;
public static long BoundUnitId => _boundId;
public static bool OwnsAvatar => _ownsAvatar && Visible;
public static string LastLayoutDebug { get; private set; } = "";
public static void EnsureBuilt(Transform parent)
{
if (parent == null)
{
return;
}
if (_root != null && _builtVersion == BuildVersion)
{
return;
}
if (_root != null)
{
Object.Destroy(_root);
_root = null;
_rootRt = null;
_nameText = null;
_titleText = null;
_stakeText = null;
_castHead = null;
_evidenceHead = null;
_evidenceText = null;
_legacyHead = null;
_legacyText = null;
_loreBtn = null;
_loreBtnLabel = null;
}
_root = new GameObject("LifeSagaPanel", typeof(RectTransform));
_root.transform.SetParent(parent, false);
_rootRt = _root.GetComponent<RectTransform>();
_rootRt.pivot = new Vector2(0f, 1f);
_rootRt.anchorMin = new Vector2(0f, 1f);
_rootRt.anchorMax = new Vector2(0f, 1f);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
_nameText = MakeLabel(_root.transform, "SagaName", NameFont, HudTheme.ValueOrange, FontStyle.Bold);
_titleText = MakeLabel(_root.transform, "SagaTitle", TitleFont, HudTheme.Muted, FontStyle.Normal);
_stakeText = MakeLabel(_root.transform, "StakeBody", StakeFont, HudTheme.ValueOrange, FontStyle.Bold);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_castHead.text = "Cast";
_evidenceHead = MakeLabel(_root.transform, "EvidenceHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_evidenceHead.text = "Evidence";
_evidenceText = MakeLabel(_root.transform, "EvidenceBody", BodyFont, HudTheme.Body, FontStyle.Normal);
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
_legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
_legacyHead.text = "Legacy";
_legacyText = MakeLabel(_root.transform, "LegacyBody", BodyFont, HudTheme.Body, FontStyle.Normal);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
for (int i = 0; i < MaxCast; i++)
{
CastSlots[i] = BuildCast(i);
}
GameObject loreGo = new GameObject("SagaLoreBtn", typeof(RectTransform), typeof(Image), typeof(Button));
loreGo.transform.SetParent(_root.transform, false);
Image loreBg = loreGo.GetComponent<Image>();
loreBg.color = new Color(0f, 0f, 0f, 0f);
loreBg.raycastTarget = true;
_loreBtn = loreGo.GetComponent<Button>();
_loreBtn.targetGraphic = loreBg;
_loreBtn.onClick.AddListener(new UnityAction(OpenLore));
_loreBtnLabel = MakeLabel(loreGo.transform, "Label", BodyFont, HudTheme.ValueOrange, FontStyle.Normal);
_loreBtnLabel.text = "Open Lore";
_loreBtnLabel.alignment = TextAnchor.MiddleLeft;
RectTransform loreRt = loreGo.GetComponent<RectTransform>();
loreRt.pivot = new Vector2(0f, 1f);
loreRt.anchorMin = new Vector2(0f, 1f);
loreRt.anchorMax = new Vector2(0f, 1f);
loreRt.sizeDelta = new Vector2(72f, LoreH);
_builtVersion = BuildVersion;
_root.SetActive(false);
}
public static void Clear()
{
_boundId = 0;
_speciesId = "";
_fingerprint = "";
_ownsAvatar = false;
_laidOutHeight = BodyHeightMin;
if (_root != null)
{
_root.SetActive(false);
}
}
public static void SetVisible(bool visible)
{
if (_root == null)
{
return;
}
_root.SetActive(visible);
if (!visible)
{
_ownsAvatar = false;
}
}
public static void Bind(LifeSagaViewModel model, bool loreInteractive)
{
if (_root == null)
{
return;
}
if (model == null || model.UnitId == 0)
{
BindEmptyPickState();
return;
}
string fp = Fingerprint(model, loreInteractive);
bool same = string.Equals(fp, _fingerprint, System.StringComparison.Ordinal) && _boundId == model.UnitId;
_fingerprint = fp;
_boundId = model.UnitId;
_speciesId = model.SpeciesId ?? "";
if (!same)
{
_nameText.text = StripStar(model.Name);
_titleText.text = model.Title ?? "";
_stakeText.text = model.StakeLine ?? "";
for (int i = 0; i < CastSlots.Length; i++)
{
if (i < model.Cast.Count && model.Cast[i] != null)
{
BindCast(CastSlots[i], model.Cast[i]);
}
else
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
_evidenceText.text = FormatEvidence(model);
var legacyParts = new List<string>(MaxLegacy);
var seen = new HashSet<string>();
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
{
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
{
continue;
}
string line = model.Legacy[i].Line.Trim();
string key = line.ToLowerInvariant();
if (!string.IsNullOrEmpty(stakeKey)
&& string.Equals(key, stakeKey, System.StringComparison.Ordinal))
{
continue;
}
if (!seen.Add(key))
{
continue;
}
legacyParts.Add(line);
}
_legacyText.text = legacyParts.Count > 0 ? string.Join(" · ", legacyParts) : "";
}
if (_loreBtn != null)
{
_loreBtn.gameObject.SetActive(loreInteractive);
_loreBtn.interactable = loreInteractive;
}
RefreshAvatar(model);
Layout();
_root.SetActive(true);
}
public static float Place(float y, float padX)
{
if (!Visible || _rootRt == null)
{
return y;
}
_rootRt.anchoredPosition = new Vector2(padX, -y);
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
if (_ownsAvatar)
{
DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize);
DossierAvatar.DrawBehind(_root.transform);
DossierAvatar.ForceCompactFrame();
}
return y + _laidOutHeight + 2f;
}
private static string FormatEvidence(LifeSagaViewModel model)
{
if (model == null || string.IsNullOrEmpty(model.EvidenceLine))
{
return "";
}
var sb = new StringBuilder(96);
if (!string.IsNullOrEmpty(model.EvidenceTitle))
{
sb.Append(model.EvidenceTitle).Append(" · ");
}
sb.Append(model.EvidenceLine);
if (!string.IsNullOrEmpty(model.SecondaryEvidenceLine))
{
sb.Append(" · ");
if (!string.IsNullOrEmpty(model.SecondaryEvidenceTitle))
{
sb.Append(model.SecondaryEvidenceTitle).Append(" · ");
}
sb.Append(model.SecondaryEvidenceLine);
}
return sb.ToString();
}
private static void RefreshAvatar(LifeSagaViewModel model)
{
if (model == null || model.UnitId == 0)
{
_ownsAvatar = false;
return;
}
DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize);
DossierAvatar.SetHostVisible(true);
Actor live = EventFeedUtil.FindAliveById(model.UnitId);
if (live != null && live.isAlive())
{
DossierAvatar.Show(live);
DossierAvatar.ForceCompactFrame();
}
else
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
_ownsAvatar = true;
}
private static void Layout()
{
float colX = Pad + AvatarSize + ColGap;
float textW = BodyWidthFixed - colX - Pad;
float y = Pad;
PlaceText(_nameText, colX, y, textW, NameFont + 2f);
y += NameFont + 2f;
if (!string.IsNullOrEmpty(_titleText.text))
{
PlaceText(_titleText, colX, y, textW, LineH);
y += LineH;
}
else
{
Hide(_titleText);
}
y += 2f;
bool hasStake = !string.IsNullOrEmpty(_stakeText.text);
if (hasStake)
{
float stakeH = Mathf.Clamp(
EstimateWrapHeight(_stakeText.text, textW, StakeFont),
LineH,
LineH * 3f);
PlaceText(_stakeText, colX, y, textW, stakeH);
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
y += stakeH + 2f;
}
else
{
Hide(_stakeText);
}
int castN = 0;
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i].Root.activeSelf)
{
castN++;
}
}
if (castN > 0)
{
PlaceText(_castHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float castX = colX;
float castRowH = CastFace + 18f;
for (int i = 0; i < CastSlots.Length; i++)
{
if (!CastSlots[i].Root.activeSelf)
{
continue;
}
RectTransform rt = CastSlots[i].Root.GetComponent<RectTransform>();
rt.anchoredPosition = new Vector2(castX, -y);
rt.sizeDelta = new Vector2(CastSlotW, castRowH);
CastSlots[i].Root.transform.SetAsLastSibling();
castX += CastSlotW + 2f;
}
y += castRowH + 2f;
}
else
{
Hide(_castHead);
}
bool hasEvidence = !string.IsNullOrEmpty(_evidenceText.text);
bool hasLegacy = !string.IsNullOrEmpty(_legacyText.text);
bool loreActive = _loreBtn != null && _loreBtn.gameObject.activeSelf;
float loreReserve = loreActive ? LoreH + 2f : 0f;
float yMax = BodyHeightMax - Pad - loreReserve;
if (hasEvidence && y + SecLabelH + LineH <= yMax)
{
_evidenceHead.text = "Evidence";
PlaceText(_evidenceHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float evidenceH = Mathf.Clamp(
EstimateWrapHeight(_evidenceText.text, textW, BodyFont),
LineH,
LineH * 3f);
evidenceH = Mathf.Min(evidenceH, Mathf.Max(LineH, yMax - y - (hasLegacy ? SecLabelH + LineH : 0f)));
PlaceText(_evidenceText, colX, y, textW, evidenceH);
_evidenceText.horizontalOverflow = HorizontalWrapMode.Wrap;
_evidenceText.verticalOverflow = VerticalWrapMode.Overflow;
y += evidenceH + 2f;
}
else
{
Hide(_evidenceHead);
Hide(_evidenceText);
}
if (hasLegacy && y + SecLabelH + LineH <= yMax)
{
PlaceText(_legacyHead, colX, y, textW, SecLabelH);
y += SecLabelH;
float legacyH = Mathf.Clamp(
EstimateWrapHeight(_legacyText.text, textW, BodyFont),
LineH,
LineH * 3f);
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
PlaceText(_legacyText, colX, y, textW, legacyH);
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
_legacyText.verticalOverflow = VerticalWrapMode.Overflow;
y += legacyH + 2f;
}
else
{
Hide(_legacyHead);
Hide(_legacyText);
}
if (loreActive)
{
y = Mathf.Max(y, AvatarSize + Pad + 2f);
RectTransform loreRt = _loreBtn.GetComponent<RectTransform>();
loreRt.anchoredPosition = new Vector2(colX, -y);
loreRt.sizeDelta = new Vector2(72f, LoreH);
_loreBtn.transform.SetAsLastSibling();
if (_loreBtnLabel != null)
{
RectTransform labelRt = _loreBtnLabel.rectTransform;
labelRt.anchoredPosition = Vector2.zero;
labelRt.sizeDelta = new Vector2(72f, LoreH);
}
y += LoreH;
}
y = Mathf.Max(y + Pad, AvatarSize + Pad * 2f);
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
if (_rootRt != null)
{
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
}
LastLayoutDebug =
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' stake='{Short(_stakeText?.text)}' cast={castN} evidence={(hasEvidence ? 1 : 0)} legacy={(hasLegacy ? 1 : 0)}";
}
private static float EstimateWrapHeight(string text, float width, float fontSize)
{
if (string.IsNullOrEmpty(text))
{
return LineH;
}
float charsPerLine = Mathf.Max(8f, width / Mathf.Max(3.8f, fontSize * 0.55f));
int lines = Mathf.Max(1, Mathf.CeilToInt(text.Length / charsPerLine));
return lines * (fontSize + 2f);
}
private static CastSlot BuildCast(int index)
{
GameObject root = new GameObject("Cast" + index, typeof(RectTransform));
root.transform.SetParent(_root.transform, false);
RectTransform rt = root.GetComponent<RectTransform>();
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
GameObject bgGo = new GameObject("FaceBg", typeof(RectTransform), typeof(Image));
bgGo.transform.SetParent(root.transform, false);
RectTransform bgRt = bgGo.GetComponent<RectTransform>();
bgRt.pivot = new Vector2(0f, 1f);
bgRt.anchorMin = new Vector2(0f, 1f);
bgRt.anchorMax = new Vector2(0f, 1f);
bgRt.anchoredPosition = Vector2.zero;
bgRt.sizeDelta = new Vector2(CastFace, CastFace);
Image bg = bgGo.GetComponent<Image>();
bg.color = HudTheme.InsetFill;
bg.raycastTarget = false;
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(root.transform, false);
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchorMin = new Vector2(0f, 1f);
faceRt.anchorMax = new Vector2(0f, 1f);
faceRt.anchoredPosition = new Vector2(CastFace * 0.5f, -CastFace * 0.5f);
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
Image face = faceGo.GetComponent<Image>();
face.color = Color.white;
face.preserveAspect = true;
face.raycastTarget = false;
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Bold);
name.alignment = TextAnchor.UpperLeft;
RectTransform nameRt = name.rectTransform;
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal);
relation.alignment = TextAnchor.UpperLeft;
RectTransform relRt = relation.rectTransform;
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
root.SetActive(false);
return new CastSlot
{
Root = root,
FaceBg = bg,
Face = face,
Name = name,
Relation = relation
};
}
private static void BindCast(CastSlot slot, LifeSagaCastMember member)
{
slot.UnitId = member.UnitId;
slot.Name.text = FirstNonEmpty(member.Name, "Someone");
slot.Relation.text = FirstNonEmpty(member.Relation, "Linked");
Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null;
Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId)
?? HudIcons.FromSpeciesId(member.SpeciesId)
?? HudIcons.FromUiIcon("iconCitizen");
slot.Face.sprite = sprite;
slot.Face.enabled = sprite != null;
slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f);
slot.Root.SetActive(true);
}
private static void BindEmptyPickState()
{
_boundId = 0;
_speciesId = "";
_fingerprint = "empty-pick";
_ownsAvatar = false;
if (_nameText != null)
{
_nameText.text = "Pick a life";
}
if (_titleText != null)
{
_titleText.text = "from the rail";
}
if (_stakeText != null)
{
_stakeText.text = "Hover or click a glyph to read their saga.";
}
if (_evidenceText != null)
{
_evidenceText.text = "";
}
if (_legacyText != null)
{
_legacyText.text = "";
}
for (int i = 0; i < CastSlots.Length; i++)
{
if (CastSlots[i]?.Root != null)
{
CastSlots[i].Root.SetActive(false);
CastSlots[i].UnitId = 0;
}
}
if (_loreBtn != null)
{
_loreBtn.gameObject.SetActive(false);
_loreBtn.interactable = false;
}
DossierAvatar.SetHostVisible(false);
Layout();
if (_root != null)
{
_root.SetActive(true);
}
}
/// <summary>Harness: invoke Open Lore for the bound saga life.</summary>
public static void HarnessOpenLore()
{
OpenLore();
}
private static void OpenLore()
{
if (_boundId == 0 || LifeSagaViewController.IsHoverPreview)
{
return;
}
LifeSagaViewController.StashLoreReturn(_boundId);
ChronicleHud.OpenUnitHistory(_boundId, pauseIdle: true, followFocus: false);
}
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
{
int fitSize = size < 7 ? 7 : size;
Text text = HudCanvas.MakeText(parent, name, "", fitSize);
text.color = color;
text.fontStyle = style;
text.fontSize = fitSize;
text.resizeTextForBestFit = false;
text.alignment = TextAnchor.UpperLeft;
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
RectTransform rt = text.rectTransform;
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
return text;
}
private static void PlaceText(Text text, float x, float y, float w, float h)
{
if (text == null)
{
return;
}
text.resizeTextForBestFit = false;
text.enabled = true;
Color c = text.color;
if (c.a < 0.99f)
{
c.a = 1f;
text.color = c;
}
RectTransform rt = text.rectTransform;
rt.anchoredPosition = new Vector2(x, -y);
rt.sizeDelta = new Vector2(w, h);
text.gameObject.SetActive(!string.IsNullOrEmpty(text.text));
}
private static void Hide(Text text)
{
if (text != null)
{
text.gameObject.SetActive(false);
}
}
private static string Fingerprint(LifeSagaViewModel model, bool loreInteractive)
{
var sb = new StringBuilder(192);
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title);
sb.Append('|').Append(model.StakeLine).Append('|').Append(model.EvidenceLine);
sb.Append('|').Append(model.SecondaryEvidenceLine);
sb.Append('|').Append(loreInteractive ? '1' : '0');
for (int i = 0; i < model.Cast.Count; i++)
{
var c = model.Cast[i];
if (c == null)
{
continue;
}
sb.Append('|').Append(c.UnitId).Append(':').Append(c.Relation).Append(':').Append(c.Name);
}
for (int i = 0; i < model.Legacy.Count; i++)
{
if (model.Legacy[i] != null)
{
sb.Append('#').Append(model.Legacy[i].Line);
}
}
return sb.ToString();
}
private static string StripStar(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return name.StartsWith("★ ") ? name.Substring(2) : name;
}
private static string FirstNonEmpty(params string[] parts)
{
if (parts == null)
{
return "";
}
for (int i = 0; i < parts.Length; i++)
{
if (!string.IsNullOrEmpty(parts[i]))
{
return parts[i];
}
}
return "";
}
private static string Short(string s)
{
if (string.IsNullOrEmpty(s))
{
return "";
}
return s.Length <= 28 ? s : s.Substring(0, 27) + "…";
}
}

View file

@ -0,0 +1,871 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Adaptive saga presentation model. Consumes live actor state + LifeSagaMemory.
/// No task/status/trait/Chronicle dumps.
/// </summary>
public static class LifeSagaPresentation
{
private static bool _heirProbeDone;
private static bool _heirApiAvailable;
private static MethodInfo _heirMethod;
private static object _heirTargetSample;
public static LifeSagaViewModel Build(long unitId)
{
var model = new LifeSagaViewModel { UnitId = unitId };
if (unitId == 0)
{
return model;
}
LifeSagaSlot slot = LifeSagaRoster.Get(unitId);
Actor actor = EventFeedUtil.FindAliveById(unitId);
LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId);
string name = FirstNonEmpty(
slot?.DisplayName,
memory?.Identity.Name,
SafeName(actor),
"Someone");
model.Name = name;
if (slot != null && (slot.Prefer || slot.GameFavorite))
{
model.Name = "★ " + name;
}
model.Title = BuildTitle(slot, actor, memory);
model.SpeciesId = FirstNonEmpty(
slot?.SpeciesId,
memory?.Identity.SpeciesId,
actor != null && actor.asset != null ? actor.asset.id : "");
ResolveLenses(slot, actor, memory, out LifeSagaLens primary, out LifeSagaLens secondary);
model.PrimaryLens = primary;
model.SecondaryLens = secondary;
LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId);
model.StakeLine = stake != null
? LifeSagaMemory.FormatFactLine(stake)
: BuildFallbackStake(slot, actor, memory);
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
BuildCast(model, slot, actor, memory, primary);
BuildEvidence(model, primary, secondary, slot, actor, memory);
BuildLegacy(model, unitId, stake);
return model;
}
/// <summary>Compatibility plain text for harness diagnostics.</summary>
public static string BuildPlainText(long unitId)
{
return Build(unitId).ToPlainText();
}
public static string BuildPlainText(LifeSagaSlot slot)
{
return Build(slot?.UnitId ?? 0).ToPlainText();
}
/// <summary>
/// Live probe: only report an heir when a game-authored succession API is discoverable.
/// </summary>
public static bool TryGetLikelySuccessor(Actor actor, out Actor heir, out string evidence)
{
heir = null;
evidence = "";
EnsureHeirProbe();
if (!_heirApiAvailable || actor == null)
{
return false;
}
try
{
object clan = actor.GetType().GetProperty("clan")?.GetValue(actor, null)
?? actor.GetType().GetField("clan")?.GetValue(actor);
if (clan == null || _heirMethod == null)
{
return false;
}
object result = null;
if (_heirMethod.GetParameters().Length == 0)
{
result = _heirMethod.Invoke(clan, null);
}
else if (_heirMethod.GetParameters().Length == 1)
{
result = _heirMethod.Invoke(clan, new object[] { actor });
}
heir = result as Actor;
if (heir != null && heir.isAlive() && heir != actor)
{
evidence = "clan_succession";
return true;
}
}
catch
{
// omit rather than guess
}
heir = null;
return false;
}
public static bool HeirApiAvailable
{
get
{
EnsureHeirProbe();
return _heirApiAvailable;
}
}
private static void EnsureHeirProbe()
{
if (_heirProbeDone)
{
return;
}
_heirProbeDone = true;
try
{
Type clanType = typeof(Actor).Assembly.GetType("Clan")
?? Type.GetType("Clan, Assembly-CSharp");
if (clanType == null)
{
return;
}
string[] names =
{
"findNextHeir", "getNextHeir", "getHeir", "findHeir", "calculateHeir", "getSuccessor"
};
for (int i = 0; i < names.Length; i++)
{
MethodInfo m = clanType.GetMethod(
names[i],
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (m != null && (typeof(Actor).IsAssignableFrom(m.ReturnType) || m.ReturnType == typeof(object)))
{
_heirMethod = m;
_heirApiAvailable = true;
return;
}
}
// Culture succession helpers.
Type cultureType = typeof(Actor).Assembly.GetType("Culture");
if (cultureType != null)
{
MethodInfo m = cultureType.GetMethod(
"findNextHeir",
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
if (m != null)
{
_heirMethod = m;
_heirApiAvailable = true;
}
}
}
catch
{
_heirApiAvailable = false;
}
_ = _heirTargetSample;
}
private static void ResolveLenses(
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory,
out LifeSagaLens primary,
out LifeSagaLens secondary)
{
var scores = new Dictionary<LifeSagaLens, float>();
void Add(LifeSagaLens lens, float amount)
{
if (lens == LifeSagaLens.None || amount <= 0f)
{
return;
}
if (!scores.TryGetValue(lens, out float cur))
{
cur = 0f;
}
scores[lens] = cur + amount;
}
if (slot != null)
{
if (slot.IsKing || slot.IsClanChief || slot.IsLeader)
{
Add(LifeSagaLens.CrownClan, 40f);
}
if (slot.IsAlpha)
{
Add(LifeSagaLens.PackAlpha, 38f);
}
if (slot.IsPlotAuthor)
{
Add(LifeSagaLens.Plotter, 36f);
}
if (slot.IsFounder)
{
Add(LifeSagaLens.FounderWanderer, 30f);
}
if (slot.IsSingleton)
{
Add(LifeSagaLens.LastOfKind, 34f);
}
if (slot.Kills > 0 || slot.IsArmyCaptain)
{
Add(LifeSagaLens.WarriorKiller, Mathf.Min(35f, 12f + slot.Kills * 0.4f));
}
switch (slot.AdmissionReason)
{
case LifeSagaAdmissionReason.King:
case LifeSagaAdmissionReason.ClanChief:
case LifeSagaAdmissionReason.CityLeader:
Add(LifeSagaLens.CrownClan, 8f);
break;
case LifeSagaAdmissionReason.Alpha:
Add(LifeSagaLens.PackAlpha, 8f);
break;
case LifeSagaAdmissionReason.PlotAuthor:
Add(LifeSagaLens.Plotter, 8f);
break;
case LifeSagaAdmissionReason.Founder:
Add(LifeSagaLens.FounderWanderer, 8f);
break;
case LifeSagaAdmissionReason.Singleton:
Add(LifeSagaLens.LastOfKind, 8f);
break;
case LifeSagaAdmissionReason.NotableKills:
Add(LifeSagaLens.WarriorKiller, 8f);
break;
}
}
if (memory != null)
{
for (int i = 0; i < memory.Facts.Count; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null)
{
continue;
}
switch (f.Kind)
{
case LifeSagaFactKind.BondFormed:
case LifeSagaFactKind.BondBroken:
case LifeSagaFactKind.HardArcLove:
case LifeSagaFactKind.HardArcGrief:
Add(LifeSagaLens.LoveGrief, 12f);
break;
case LifeSagaFactKind.Kill:
case LifeSagaFactKind.RivalEarned:
case LifeSagaFactKind.HardArcCombat:
Add(LifeSagaLens.WarriorKiller, 14f);
break;
case LifeSagaFactKind.WarJoin:
case LifeSagaFactKind.HardArcWar:
Add(LifeSagaLens.CrownClan, 10f);
Add(LifeSagaLens.WarriorKiller, 8f);
break;
case LifeSagaFactKind.PlotJoin:
case LifeSagaFactKind.HardArcPlot:
Add(LifeSagaLens.Plotter, 16f);
break;
case LifeSagaFactKind.Founding:
case LifeSagaFactKind.HomeGained:
case LifeSagaFactKind.HomeLost:
Add(LifeSagaLens.FounderWanderer, 12f);
break;
case LifeSagaFactKind.RoleChange:
if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
Add(LifeSagaLens.PackAlpha, 14f);
}
else
{
Add(LifeSagaLens.CrownClan, 12f);
}
break;
case LifeSagaFactKind.ParentChild:
Add(LifeSagaLens.PackAlpha, 6f);
Add(LifeSagaLens.LoveGrief, 4f);
break;
}
}
}
if (actor != null && LifeSagaRoster.IsPackAlpha(actor))
{
Add(LifeSagaLens.PackAlpha, 10f);
}
primary = LifeSagaLens.FounderWanderer;
secondary = LifeSagaLens.None;
float best = -1f;
float second = -1f;
foreach (KeyValuePair<LifeSagaLens, float> kv in scores)
{
if (kv.Value > best)
{
second = best;
secondary = primary;
best = kv.Value;
primary = kv.Key;
}
else if (kv.Value > second)
{
second = kv.Value;
secondary = kv.Key;
}
}
if (secondary == primary || second < best * 0.45f)
{
secondary = LifeSagaLens.None;
}
}
private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (slot != null)
{
if (slot.IsKing)
{
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
}
if (slot.IsClanChief) return "Clan chief";
if (slot.IsLeader)
{
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
}
if (slot.IsAlpha) return "Pack alpha";
if (slot.IsArmyCaptain) return "Army captain";
if (slot.IsFounder) return "Founder";
if (slot.IsPlotAuthor) return "Plotter";
if (slot.IsSingleton) return "Last of their kind";
}
if (memory != null)
{
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding)
{
return "Founder";
}
}
}
return "A life in motion";
}
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
{
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
{
return LifeSagaMemory.FormatFactLine(rival);
}
if (slot != null)
{
switch (slot.AdmissionReason)
{
case LifeSagaAdmissionReason.King:
return "Carries a kingdom's fate";
case LifeSagaAdmissionReason.Alpha:
return "Holds the pack together";
case LifeSagaAdmissionReason.PlotAuthor:
return "A scheme is still unfolding";
case LifeSagaAdmissionReason.Singleton:
return "Their lineage hangs by a thread";
case LifeSagaAdmissionReason.Founder:
return "What they built still needs defending";
}
}
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
{
return "Succession points toward " + SafeName(heir);
}
return memory != null && memory.Facts.Count > 0
? LifeSagaMemory.FormatFactLine(memory.Facts[0])
: "";
}
private static void BuildCast(
LifeSagaViewModel model,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory,
LifeSagaLens primary)
{
var seen = new HashSet<long>();
long selfId = model.UnitId;
seen.Add(selfId);
void AddLive(Actor other, string label, string truth)
{
if (other == null || model.Cast.Count >= 4)
{
return;
}
long id = EventFeedUtil.SafeId(other);
if (id == 0 || !seen.Add(id))
{
return;
}
model.Cast.Add(new LifeSagaCastMember
{
UnitId = id,
Name = SafeName(other),
SpeciesId = other.asset != null ? other.asset.id : "",
Relation = label,
Truth = truth,
Alive = true
});
}
void AddObserved(LifeSagaIdentity identity, string label, string truth)
{
if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id))
{
return;
}
bool alive = EventFeedUtil.FindAliveById(identity.Id) != null;
model.Cast.Add(new LifeSagaCastMember
{
UnitId = identity.Id,
Name = FirstNonEmpty(identity.Name, "Someone"),
SpeciesId = identity.SpeciesId ?? "",
Relation = label,
Truth = truth,
Alive = alive
});
}
if (actor != null)
{
AddLive(ActorRelation.GetLover(actor), "Lover", "is");
AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is");
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
{
AddLive(parent, "Parent", "is");
}
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
{
AddLive(child, "Child", "is");
}
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
{
AddLive(heir, "Likely successor", "likely");
}
}
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
{
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
if (liveRival != null)
{
AddLive(liveRival, "Earned rival", "observed");
}
else
{
AddObserved(rivalFact.Other, "Earned rival", "observed");
}
}
// Former bonds from memory when live cast still has room.
if (memory != null && model.Cast.Count < 4)
{
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
{
LifeSagaFact f = memory.Facts[i];
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
{
continue;
}
if (f.Kind == LifeSagaFactKind.BondBroken)
{
AddObserved(f.Other, "Lost lover", "observed");
}
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
{
AddObserved(f.Other, "Child", "observed");
}
}
}
// Pack / kin peers fill remaining slots for pack-shaped and family-shaped lenses.
if (actor != null
&& model.Cast.Count < 4
&& (primary == LifeSagaLens.PackAlpha || primary == LifeSagaLens.LoveGrief))
{
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))
{
if (peer == null || peer == actor)
{
continue;
}
AddLive(peer, peerLabel, "is");
if (model.Cast.Count >= 4)
{
break;
}
}
}
_ = slot;
}
private static void BuildEvidence(
LifeSagaViewModel model,
LifeSagaLens primary,
LifeSagaLens secondary,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
model.EvidenceTitle = EvidenceTitle(primary);
model.EvidenceLine = EvidenceLine(primary, slot, actor, memory);
if (secondary != LifeSagaLens.None)
{
model.SecondaryEvidenceTitle = EvidenceTitle(secondary);
model.SecondaryEvidenceLine = EvidenceLine(secondary, slot, actor, memory);
}
}
private static string EvidenceTitle(LifeSagaLens lens)
{
switch (lens)
{
case LifeSagaLens.CrownClan: return "Realm";
case LifeSagaLens.PackAlpha: return "Pack";
case LifeSagaLens.WarriorKiller: return "Combat";
case LifeSagaLens.Plotter: return "Intrigue";
case LifeSagaLens.FounderWanderer: return "Journey";
case LifeSagaLens.LoveGrief: return "Bond";
case LifeSagaLens.LastOfKind: return "Survival";
default: return "Evidence";
}
}
private static string EvidenceLine(
LifeSagaLens lens,
LifeSagaSlot slot,
Actor actor,
LifeSagaLifeMemory memory)
{
switch (lens)
{
case LifeSagaLens.CrownClan:
{
string realm = FirstNonEmpty(slot?.KingdomKey, KingdomName(actor));
int wars = CountFacts(memory, LifeSagaFactKind.WarJoin);
if (!string.IsNullOrEmpty(realm) && wars > 0)
{
return realm + " · " + wars + " war" + (wars == 1 ? "" : "s") + " observed";
}
return FirstNonEmpty(realm, "Holding power");
}
case LifeSagaLens.PackAlpha:
{
int children = CountFacts(memory, LifeSagaFactKind.ParentChild);
return children > 0
? "Pack line with " + children + " recorded offspring"
: "Leads the family pack";
}
case LifeSagaLens.WarriorKiller:
{
int kills = slot?.Kills ?? 0;
int encounters = CountFacts(memory, LifeSagaFactKind.CombatEncounter)
+ CountFacts(memory, LifeSagaFactKind.Kill);
if (kills > 0)
{
return kills + " kills · " + encounters + " remembered clashes";
}
return encounters > 0 ? encounters + " remembered clashes" : "A fighter's reputation";
}
case LifeSagaLens.Plotter:
{
int plots = CountFacts(memory, LifeSagaFactKind.PlotJoin);
return plots > 0 ? plots + " plot thread" + (plots == 1 ? "" : "s") : "Scheming still";
}
case LifeSagaLens.FounderWanderer:
{
int founded = CountFacts(memory, LifeSagaFactKind.Founding);
return founded > 0 ? "Founded " + founded + " lasting place" + (founded == 1 ? "" : "s")
: FirstNonEmpty(CityName(actor), "Building a place in the world");
}
case LifeSagaLens.LoveGrief:
{
int bonds = CountFacts(memory, LifeSagaFactKind.BondFormed);
int losses = CountFacts(memory, LifeSagaFactKind.BondBroken);
if (losses > 0)
{
return "Carrying " + losses + " observed loss" + (losses == 1 ? "" : "es");
}
return bonds > 0 ? "Bound " + bonds + " time" + (bonds == 1 ? "" : "s") : "A life shaped by attachment";
}
case LifeSagaLens.LastOfKind:
return "The last living thread of their kind";
default:
return "";
}
}
private static void BuildLegacy(LifeSagaViewModel model, long unitId, LifeSagaFact stake)
{
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 5);
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact f = facts[i];
if (f == null)
{
continue;
}
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
if (stake != null
&& ((!string.IsNullOrEmpty(stake.Key)
&& string.Equals(stake.Key, f.Key, StringComparison.Ordinal))
|| (stake.Kind == f.Kind && stake.Other.Id != 0 && stake.Other.Id == f.Other.Id)))
{
continue;
}
string line = LifeSagaMemory.FormatFactLine(f);
if (string.IsNullOrEmpty(line)
|| string.Equals(line, model.StakeLine, StringComparison.OrdinalIgnoreCase)
|| !seenLines.Add(line))
{
continue;
}
model.Legacy.Add(new LifeSagaLegacyBeat
{
Kind = f.Kind,
Line = line,
Truth = f.Resolved ? "observed" : "observed",
At = f.At,
DateLabel = f.DateLabel ?? ""
});
if (model.Legacy.Count >= 4)
{
break;
}
}
}
private static int CountFacts(LifeSagaLifeMemory memory, LifeSagaFactKind kind)
{
if (memory == null)
{
return 0;
}
int n = 0;
for (int i = 0; i < memory.Facts.Count; i++)
{
if (memory.Facts[i] != null && memory.Facts[i].Kind == kind)
{
n++;
}
}
return n;
}
private static string KingdomName(Actor actor)
{
try { return actor?.kingdom?.name ?? ""; }
catch { return ""; }
}
private static string CityName(Actor actor)
{
try { return actor?.city?.name ?? ""; }
catch { return ""; }
}
private static string SafeName(Actor actor)
{
try
{
string name = actor?.getName();
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
catch
{
// ignore
}
return actor?.asset?.id ?? "";
}
private static string FirstNonEmpty(params string[] values)
{
if (values == null)
{
return "";
}
for (int i = 0; i < values.Length; i++)
{
if (!string.IsNullOrEmpty(values[i]))
{
return values[i];
}
}
return "";
}
}
public enum LifeSagaLens
{
None,
CrownClan,
PackAlpha,
WarriorKiller,
Plotter,
FounderWanderer,
LoveGrief,
LastOfKind
}
public sealed class LifeSagaCastMember
{
public long UnitId;
public string Name = "";
public string SpeciesId = "";
public string Relation = "";
public string Truth = "is";
public bool Alive;
}
public sealed class LifeSagaLegacyBeat
{
public LifeSagaFactKind Kind;
public string Line = "";
public string Truth = "observed";
public float At;
public string DateLabel = "";
}
public sealed class LifeSagaViewModel
{
public long UnitId;
public string Name = "";
public string Title = "";
public string SpeciesId = "";
public string StakeLine = "";
public string StakeProvenance = "";
public LifeSagaLens PrimaryLens;
public LifeSagaLens SecondaryLens;
public string EvidenceTitle = "";
public string EvidenceLine = "";
public string SecondaryEvidenceTitle = "";
public string SecondaryEvidenceLine = "";
public readonly List<LifeSagaCastMember> Cast = new List<LifeSagaCastMember>(4);
public readonly List<LifeSagaLegacyBeat> Legacy = new List<LifeSagaLegacyBeat>(5);
public string ToPlainText()
{
var sb = new StringBuilder(320);
if (!string.IsNullOrEmpty(Name))
{
sb.Append(Name);
}
if (!string.IsNullOrEmpty(Title))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(Title);
}
if (!string.IsNullOrEmpty(StakeLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append("At stake ").Append(StakeLine);
}
if (Cast.Count > 0)
{
if (sb.Length > 0) sb.Append('\n');
sb.Append("Cast ");
for (int i = 0; i < Cast.Count; i++)
{
if (i > 0) sb.Append(" · ");
sb.Append(Cast[i].Relation).Append(' ').Append(Cast[i].Name);
}
}
if (!string.IsNullOrEmpty(EvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(EvidenceTitle).Append(" ").Append(EvidenceLine);
}
if (!string.IsNullOrEmpty(SecondaryEvidenceLine))
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(SecondaryEvidenceTitle).Append(" ").Append(SecondaryEvidenceLine);
}
for (int i = 0; i < Legacy.Count; i++)
{
if (sb.Length > 0) sb.Append('\n');
sb.Append("Legacy ").Append(Legacy[i].Line);
}
return sb.ToString();
}
}

View file

@ -0,0 +1,605 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Dossier rail of life-saga MCs as a horizontal strip under the tabs.
/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews.
/// </summary>
public static class LifeSagaRail
{
private const int MaxSlots = LifeSagaRoster.Cap;
public const float SlotSize = 18f;
private const float Gap = 2f;
private const float BadgeSize = 7f;
private const float FaceRefreshSeconds = 0.28f;
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly Slot[] Slots = new Slot[MaxSlots];
private static GameObject _row;
private static RectTransform _rowRt;
private static string _fingerprint = "";
private static float _nextFaceRefreshAt;
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
public static string LastTipSample { get; private set; } = "";
/// <summary>Harness: true when the watched MC slot used Active (not Prefer) chrome.</summary>
public static bool LastActiveHighlight { get; private set; }
/// <summary>Harness: Prefer pip visible on first Prefer'd slot.</summary>
public static bool LastPreferPipVisible { get; private set; }
/// <summary>Harness: structured presentation row count for open preview (compat).</summary>
public static int LastHoverRowCount { get; private set; }
/// <summary>Harness: fixed saga body height while previewing.</summary>
public static float LastHoverHeight { get; private set; }
private sealed class Slot
{
public GameObject Root;
public RectTransform Rt;
public Image Bg;
public Image Face;
public Text Label;
public Image Badge;
public Image PreferPip;
public Button Button;
public long UnitId;
public string Tip = "";
public LifeSagaRailHover Hover;
}
public static bool Visible =>
_row != null && _row.activeSelf;
/// <summary>Natural rail row width after last Refresh (glyph strip only).</summary>
public static float RowWidthPx =>
_rowRt != null && _row != null && _row.activeSelf ? _rowRt.sizeDelta.x : 0f;
/// <summary>Natural rail row height after last Refresh.</summary>
public static float RowHeightPx =>
Visible ? SlotSize : 0f;
/// <summary>True when the pointer is over any visible rail glyph.</summary>
public static bool IsPointerOverRail()
{
if (_row == null || !_row.activeInHierarchy || _rowRt == null)
{
return false;
}
if (RectTransformUtility.RectangleContainsScreenPoint(_rowRt, Input.mousePosition, null))
{
return true;
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Rt == null || !Slots[i].Root.activeInHierarchy)
{
continue;
}
if (RectTransformUtility.RectangleContainsScreenPoint(Slots[i].Rt, Input.mousePosition, null))
{
return true;
}
}
return false;
}
public static int LastShownCount { get; private set; }
public static void EnsureBuilt(Transform parent)
{
if (_row != null || parent == null)
{
return;
}
_row = new GameObject("LifeSagaRail", typeof(RectTransform));
_row.transform.SetParent(parent, false);
_rowRt = _row.GetComponent<RectTransform>();
_rowRt.pivot = new Vector2(0f, 1f);
_rowRt.anchorMin = new Vector2(0f, 1f);
_rowRt.anchorMax = new Vector2(0f, 1f);
for (int i = 0; i < MaxSlots; i++)
{
Slots[i] = BuildSlot(_row.transform, i);
}
_row.SetActive(false);
}
public static void Clear()
{
_fingerprint = "";
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
LastPreferPipVisible = false;
LastHoverRowCount = 0;
LastHoverHeight = 0f;
LifeSagaViewController.CancelPreviewAndPause();
if (_row != null)
{
_row.SetActive(false);
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i]?.Root != null)
{
Slots[i].Root.SetActive(false);
Slots[i].UnitId = 0;
Slots[i].Tip = "";
}
}
}
public static void Refresh()
{
if (_row == null)
{
return;
}
if (!SpectatorMode.Active && !SpectatorMode.BrowsePaused)
{
Clear();
return;
}
if (!ModSettings.ShowDossierCaption || WatchCaption.HasStatusBannerActive())
{
Clear();
return;
}
float now = Time.unscaledTime;
LifeSagaRoster.Tick(now);
SlotScratch.Clear();
LifeSagaRoster.CopySlots(SlotScratch);
string fp = Fingerprint(SlotScratch);
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|| LifeSagaRoster.Dirty;
bool facesDue = now >= _nextFaceRefreshAt;
if (!structureChanged && !facesDue)
{
return;
}
if (structureChanged)
{
LifeSagaRoster.Dirty = false;
_fingerprint = fp;
LastShownCount = 0;
LastTipSample = "";
LastActiveHighlight = false;
LastPreferPipVisible = false;
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
float x = 0f;
for (int i = 0; i < Slots.Length; i++)
{
Slot slot = Slots[i];
if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0)
{
slot.Root.SetActive(false);
slot.UnitId = 0;
slot.Tip = "";
continue;
}
LifeSagaSlot saga = SlotScratch[i];
bool watching = watchId != 0 && watchId == saga.UnitId;
bool prefer = saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
ApplyFace(slot, saga);
ApplyBadge(slot, saga);
if (slot.PreferPip != null)
{
slot.PreferPip.gameObject.SetActive(prefer);
if (prefer)
{
LastPreferPipVisible = true;
}
}
if (watching)
{
slot.Bg.color = HudTheme.ValueOrange;
LastActiveHighlight = true;
}
else if (prefer)
{
slot.Bg.color = new Color(0.45f, 0.38f, 0.22f, 0.95f);
}
else
{
slot.Bg.color = new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.45f);
}
slot.Tip = LifeSagaPresentation.BuildPlainText(saga);
if (LastShownCount == 0)
{
LastTipSample = slot.Tip ?? "";
}
slot.Rt.anchoredPosition = new Vector2(x, 0f);
slot.Root.SetActive(true);
x += SlotSize + Gap;
LastShownCount++;
}
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0);
}
else
{
for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++)
{
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
{
continue;
}
ApplyFace(Slots[i], SlotScratch[i]);
}
}
if (LifeSagaViewController.IsHoverPreview)
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
LastHoverRowCount = model.Cast.Count + model.Legacy.Count
+ (string.IsNullOrEmpty(model.StakeLine) ? 0 : 1);
LastHoverHeight = LifeSagaPanel.BodyHeight;
}
else
{
LastHoverRowCount = 0;
LastHoverHeight = 0f;
}
_nextFaceRefreshAt = now + FaceRefreshSeconds;
}
/// <summary>
/// Place the horizontal rail under the tabs (stable top chrome).
/// Returns the next y below the rail.
/// </summary>
public static float Place(float y, float padX)
{
if (!Visible || _rowRt == null)
{
return y;
}
_rowRt.anchoredPosition = new Vector2(padX, -y);
return y + SlotSize + 2f;
}
internal static void ShowHover(int index)
{
if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
{
return;
}
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
LastHoverHeight = LifeSagaPanel.BodyHeight;
WatchCaption.RequestRelayout();
}
internal static void HideHover()
{
LifeSagaViewController.EndHoverPreview();
WatchCaption.RequestRelayout();
}
public static long UnitIdAt(int index)
{
if (index < 0 || index >= Slots.Length || Slots[index] == null)
{
return 0;
}
return Slots[index].UnitId;
}
private static Slot BuildSlot(Transform parent, int index)
{
GameObject go = new GameObject("SagaRail" + index, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
RectTransform rt = go.GetComponent<RectTransform>();
rt.pivot = new Vector2(0f, 1f);
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.sizeDelta = new Vector2(SlotSize, SlotSize);
Image bg = go.GetComponent<Image>();
bg.color = HudTheme.ToolFill;
bg.raycastTarget = true;
GameObject faceGo = new GameObject("Face", typeof(RectTransform), typeof(Image));
faceGo.transform.SetParent(go.transform, false);
RectTransform faceRt = faceGo.GetComponent<RectTransform>();
faceRt.anchorMin = Vector2.zero;
faceRt.anchorMax = Vector2.one;
faceRt.offsetMin = new Vector2(1f, 1f);
faceRt.offsetMax = new Vector2(-1f, -1f);
Image face = faceGo.GetComponent<Image>();
face.color = Color.white;
face.preserveAspect = true;
face.raycastTarget = false;
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 8);
label.alignment = TextAnchor.MiddleCenter;
label.resizeTextForBestFit = false;
label.raycastTarget = false;
RectTransform labelRt = label.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = Vector2.zero;
labelRt.offsetMax = Vector2.zero;
label.gameObject.SetActive(false);
GameObject badgeGo = new GameObject("Badge", typeof(RectTransform), typeof(Image));
badgeGo.transform.SetParent(go.transform, false);
RectTransform badgeRt = badgeGo.GetComponent<RectTransform>();
badgeRt.pivot = new Vector2(1f, 1f);
badgeRt.anchorMin = new Vector2(1f, 1f);
badgeRt.anchorMax = new Vector2(1f, 1f);
badgeRt.anchoredPosition = new Vector2(1f, 1f);
badgeRt.sizeDelta = new Vector2(BadgeSize, BadgeSize);
Image badge = badgeGo.GetComponent<Image>();
badge.color = Color.white;
badge.preserveAspect = true;
badge.raycastTarget = false;
badgeGo.SetActive(false);
GameObject pipGo = new GameObject("PreferPip", typeof(RectTransform), typeof(Image));
pipGo.transform.SetParent(go.transform, false);
RectTransform pipRt = pipGo.GetComponent<RectTransform>();
pipRt.pivot = new Vector2(0f, 0f);
pipRt.anchorMin = new Vector2(0f, 0f);
pipRt.anchorMax = new Vector2(0f, 0f);
pipRt.anchoredPosition = new Vector2(1f, 1f);
pipRt.sizeDelta = new Vector2(5f, 5f);
Image pip = pipGo.GetComponent<Image>();
pip.color = new Color(1f, 0.85f, 0.25f, 1f);
pip.raycastTarget = false;
Sprite star = HudIcons.FromUiIcon("iconFavorite")
?? HudIcons.FromUiIcon("iconStar")
?? HudIcons.FromUiIcon("iconImportant");
if (star != null)
{
pip.sprite = star;
}
pipGo.SetActive(false);
Button button = go.GetComponent<Button>();
button.targetGraphic = bg;
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
button.colors = colors;
int captured = index;
button.onClick.AddListener(new UnityAction(() => OnClick(captured)));
LifeSagaRailHover hover = go.AddComponent<LifeSagaRailHover>();
hover.Index = index;
go.SetActive(false);
return new Slot
{
Root = go,
Rt = rt,
Bg = bg,
Face = face,
Label = label,
Badge = badge,
PreferPip = pip,
Button = button,
Hover = hover
};
}
private static void ApplyFace(Slot slot, LifeSagaSlot saga)
{
if (slot == null || saga == null)
{
return;
}
Actor actor = saga.Resolve();
Sprite sprite = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
{
slot.Face.sprite = sprite;
slot.Face.color = Color.white;
slot.Face.preserveAspect = true;
slot.Face.gameObject.SetActive(true);
RectTransform faceRt = slot.Face.rectTransform;
faceRt.anchorMin = new Vector2(0.5f, 0.5f);
faceRt.anchorMax = new Vector2(0.5f, 0.5f);
faceRt.pivot = new Vector2(0.5f, 0.5f);
faceRt.anchoredPosition = Vector2.zero;
float w = Mathf.Max(1f, sprite.rect.width);
float h = Mathf.Max(1f, sprite.rect.height);
float max = SlotSize - 2f;
float scale = max / Mathf.Max(w, h);
faceRt.sizeDelta = new Vector2(w * scale, h * scale);
if (slot.Label != null)
{
slot.Label.gameObject.SetActive(false);
}
return;
}
if (slot.Face != null)
{
slot.Face.sprite = null;
slot.Face.gameObject.SetActive(false);
}
if (slot.Label != null)
{
slot.Label.text = GlyphFallback(saga);
slot.Label.color = HudTheme.Body;
slot.Label.gameObject.SetActive(true);
}
}
private static void ApplyBadge(Slot slot, LifeSagaSlot saga)
{
if (slot?.Badge == null || saga == null)
{
return;
}
string icon = null;
if (saga.IsKing)
{
icon = "iconKings";
}
else if (saga.IsClanChief)
{
icon = "iconClan";
}
else if (saga.IsLeader)
{
icon = "iconLeaders";
}
else if (saga.IsAlpha)
{
icon = "iconFamilies";
}
else if (saga.IsArmyCaptain)
{
icon = "iconArmy";
}
else if (saga.IsFounder)
{
icon = "iconCity";
}
else if (saga.IsPlotAuthor)
{
icon = "iconPlot";
}
Sprite sprite = !string.IsNullOrEmpty(icon) ? HudIcons.FromUiIcon(icon) : null;
if (sprite == null && saga.IsKing)
{
sprite = HudIcons.FromUiIcon("iconKing");
}
if (sprite == null)
{
slot.Badge.gameObject.SetActive(false);
return;
}
slot.Badge.sprite = sprite;
slot.Badge.gameObject.SetActive(true);
}
internal static void HarnessClick(int index)
{
OnClick(index);
}
private static void OnClick(int index)
{
if (index < 0 || index >= Slots.Length)
{
return;
}
long id = Slots[index].UnitId;
if (id == 0)
{
return;
}
if (!SpectatorMode.Active && SpectatorMode.BrowsePaused)
{
SpectatorMode.SetActive(true, quiet: true);
}
LifeSagaRoster.TogglePrefer(id);
if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|| (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
&& !LifeSagaViewController.IsHoverPreview))
{
LifeSagaViewController.SetPersistentSaga(id);
}
_fingerprint = "";
LifeSagaRoster.Dirty = true;
Refresh();
WatchCaption.RequestRelayout();
}
private static string GlyphFallback(LifeSagaSlot saga)
{
if (saga == null)
{
return "?";
}
if (saga.IsKing) return "K";
if (saga.IsClanChief) return "C";
if (saga.IsAlpha) return "A";
if (saga.IsArmyCaptain) return "P";
if (saga.IsLeader) return "L";
if (saga.Prefer || saga.GameFavorite) return "★";
string sp = saga.SpeciesId ?? "";
if (sp.Length > 0)
{
return char.ToUpperInvariant(sp[0]).ToString();
}
return "?";
}
private static string Fingerprint(List<LifeSagaSlot> board)
{
var sb = new StringBuilder();
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
sb.Append(watchId).Append('|');
for (int i = 0; i < board.Count; i++)
{
LifeSagaSlot s = board[i];
if (s == null)
{
continue;
}
sb.Append(s.UnitId).Append(':')
.Append(s.Prefer ? 'P' : '-')
.Append(s.GameFavorite ? 'F' : '-')
.Append(s.IsKing ? 'K' : '-')
.Append(s.IsClanChief ? 'C' : '-')
.Append(s.IsArmyCaptain ? 'A' : '-')
.Append(s.Chapters.Count).Append(':')
.Append(Mathf.RoundToInt(LifeSagaRoster.EffectiveInterest(s))).Append(':')
.Append(s.DisplayName ?? "").Append(';');
}
return sb.ToString();
}
}

View file

@ -0,0 +1,17 @@
using UnityEngine;
using UnityEngine.EventSystems;
namespace IdleSpectator;
/// <summary>Pointer hover bridge for <see cref="LifeSagaRail"/> slots.</summary>
public sealed class LifeSagaRailHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
public int Index;
public void OnPointerEnter(PointerEventData eventData) => LifeSagaRail.ShowHover(Index);
public void OnPointerExit(PointerEventData eventData) => LifeSagaRail.HideHover();
// Relayout may briefly deactivate slots; do not cancel sticky hover here.
// PointerExit + ViewController grace own the leave path; Clear() owns teardown.
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,317 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease.
/// </summary>
public static class LifeSagaViewController
{
public const string ReadPauseOwner = "saga_hover";
public const float HoverExitGraceSeconds = 0.35f;
public enum Tab
{
Dossier = 0,
Saga = 1
}
private static Tab _selectedTab = Tab.Dossier;
private static long _persistentSagaId;
private static bool _persistentPinned;
private static long _hoverPreviewId;
private static Tab _tabBeforeHover = Tab.Dossier;
private static float _hoverExitAt = -1f;
private static bool _pauseHeld;
private static bool _loreReturnToSaga;
private static long _loreReturnSagaId;
public static Tab SelectedTab => _selectedTab;
public static long PersistentSagaId => _persistentSagaId;
public static long HoverPreviewId => _hoverPreviewId;
public static bool IsHoverPreview => _hoverPreviewId != 0;
public static Tab EffectiveTab =>
_hoverPreviewId != 0 ? Tab.Saga : _selectedTab;
public static long EffectiveSagaId
{
get
{
if (_hoverPreviewId != 0)
{
return _hoverPreviewId;
}
return ResolvePersistentSagaId();
}
}
public static void SelectTab(Tab tab)
{
// Explicit tab click wins over hover preview restore.
if (_hoverPreviewId != 0 || _pauseHeld)
{
CancelPreviewAndPause();
_tabBeforeHover = tab;
}
_selectedTab = tab;
if (tab == Tab.Saga)
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else if (!IsValidMc(_persistentSagaId) || !_persistentPinned)
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
}
public static void SetPersistentSaga(long unitId)
{
if (unitId == 0 || !LifeSagaRoster.IsMc(unitId))
{
return;
}
_persistentSagaId = unitId;
_persistentPinned = true;
}
/// <summary>Stash Saga return target before opening Lore from the Saga panel.</summary>
public static void StashLoreReturn(long unitId)
{
_loreReturnToSaga = true;
_loreReturnSagaId = unitId;
}
/// <summary>
/// After Lore closes, restore Saga tab + subject when a stash is present.
/// Returns true when a restore was applied.
/// </summary>
public static bool TryRestoreAfterLore()
{
if (!_loreReturnToSaga)
{
return false;
}
long id = _loreReturnSagaId;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
CancelPreviewAndPause();
_selectedTab = Tab.Saga;
_tabBeforeHover = Tab.Saga;
if (IsValidMc(id))
{
_persistentSagaId = id;
_persistentPinned = true;
}
else
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
}
else
{
_persistentSagaId = 0;
_persistentPinned = false;
}
}
WatchCaption.RequestRelayout();
return true;
}
public static void BeginHoverPreview(long unitId)
{
if (unitId == 0)
{
return;
}
_hoverExitAt = -1f;
if (_hoverPreviewId == 0)
{
_tabBeforeHover = _selectedTab;
}
_hoverPreviewId = unitId;
if (!_pauseHeld)
{
InterestDirector.AcquireReadPause(ReadPauseOwner);
_pauseHeld = true;
}
WatchCaption.RequestRelayout();
}
public static void EndHoverPreview()
{
if (_hoverPreviewId == 0 && !_pauseHeld)
{
return;
}
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
}
public static void Tick()
{
if (_hoverPreviewId == 0)
{
return;
}
// Keep preview alive while the pointer stays on the dossier/rail chrome.
if (WatchCaption.IsPointerOverPanel() || LifeSagaRail.IsPointerOverRail())
{
_hoverExitAt = -1f;
return;
}
// Pointer left chrome after a panel dwell cleared the timer - re-arm grace.
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
return;
}
if (Time.unscaledTime < _hoverExitAt)
{
return;
}
FinishHoverExit();
}
public static void CancelPreviewAndPause()
{
_hoverExitAt = -1f;
_hoverPreviewId = 0;
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
_pauseHeld = false;
}
}
public static void Clear()
{
CancelPreviewAndPause();
_selectedTab = Tab.Dossier;
_persistentSagaId = 0;
_persistentPinned = false;
_tabBeforeHover = Tab.Dossier;
_loreReturnToSaga = false;
_loreReturnSagaId = 0;
}
/// <summary>
/// Harness: simulate Tick clearing the exit timer while the pointer dwells on the panel.
/// </summary>
public static void HarnessSimulatePanelDwell()
{
if (_hoverPreviewId == 0)
{
return;
}
_hoverExitAt = -1f;
}
/// <summary>
/// Harness: run one hover Tick as if the pointer is off rail and panel chrome.
/// </summary>
public static void HarnessTickExitAwayFromChrome()
{
if (_hoverPreviewId == 0)
{
return;
}
if (_hoverExitAt < 0f)
{
_hoverExitAt = Time.unscaledTime + HoverExitGraceSeconds;
return;
}
if (Time.unscaledTime < _hoverExitAt)
{
return;
}
FinishHoverExit();
}
public static LifeSagaViewModel BuildEffectivePresentation()
{
long id = EffectiveSagaId;
return id != 0 ? LifeSagaPresentation.Build(id) : new LifeSagaViewModel();
}
private static void FinishHoverExit()
{
_hoverExitAt = -1f;
_hoverPreviewId = 0;
_selectedTab = _tabBeforeHover;
if (_pauseHeld)
{
InterestDirector.ReleaseReadPause(ReadPauseOwner);
_pauseHeld = false;
}
WatchCaption.RequestRelayout();
}
/// <summary>
/// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC.
/// Never falls back to first-roster / stale last-active filler.
/// </summary>
private static long ResolvePersistentSagaId()
{
long watch = WatchedMcId();
if (watch != 0)
{
_persistentSagaId = watch;
_persistentPinned = true;
return watch;
}
if (_persistentPinned && IsValidMc(_persistentSagaId))
{
return _persistentSagaId;
}
_persistentSagaId = 0;
_persistentPinned = false;
return 0;
}
private static bool IsValidMc(long unitId)
{
return unitId != 0
&& LifeSagaRoster.IsMc(unitId)
&& EventFeedUtil.FindAliveById(unitId) != null;
}
private static long WatchedMcId()
{
long watchId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
return watchId != 0 && LifeSagaRoster.IsMc(watchId) ? watchId : 0;
}
}

View file

@ -32,8 +32,15 @@ public sealed class StoryArc
public StoryPhase Phase = StoryPhase.None;
public string AnchorKey = "";
public string ClimaxKey = "";
/// <summary>
/// Short cast/theater identity for the dossier rail (e.g. "Evil Mage vs Lona").
/// Not Kind/Phase taxonomy - who the story is about.
/// </summary>
public string Headline = "";
public float StartedAt;
public float PhaseStartedAt;
/// <summary>When parked off-camera (0 = currently watching / never parked).</summary>
public float ParkedAt;
/// <summary>True when this arc uses hard hold margins (not soft anonymous duels).</summary>
public bool HardHold;
public readonly List<long> CastIds = new List<long>(4);
@ -42,6 +49,8 @@ public sealed class StoryArc
public bool IsActive =>
Phase != StoryPhase.None && Phase != StoryPhase.Done;
public bool IsParked => ParkedAt > 0f && IsActive;
public bool ContainsCast(long unitId)
{
if (unitId == 0)

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ public static class StoryReason
public const string AftermathPlotFallout = "aftermath_plot_fallout";
public const string AftermathLoveLinger = "aftermath_love_linger";
public const string EpilogueRelated = "epilogue_related";
public const string EpilogueCrisis = "epilogue_crisis";
public static bool IsStoryAsset(string assetId)
{
@ -21,6 +22,11 @@ public static class StoryReason
|| assetId.StartsWith("epilogue_", System.StringComparison.OrdinalIgnoreCase);
}
public static bool IsCrisisEpilogue(string assetId)
{
return string.Equals(assetId, EpilogueCrisis, System.StringComparison.OrdinalIgnoreCase);
}
public static string AftermathLabel(string assetId, Actor follow, Actor related)
{
string name = EventFeedUtil.SafeName(follow);
@ -49,10 +55,12 @@ public static class StoryReason
return string.IsNullOrEmpty(other)
? name + " carries on after the story"
: name + " seeks " + other + " after the story";
case EpilogueCrisis:
return name + " surveys the aftermath of the crisis";
case AftermathSurvivor:
default:
// Only name a dead theater partner. Living lover/friend fallbacks must never
// print as the fallen (soak: "stands over Clemond" after a duel vs someone else).
// Only name a dead theater partner. Never claim "the fallen" while the partner lives
// (soak: Norron vs Nerari attack gaps minted false survivor tips).
if (related != null)
{
bool dead = false;
@ -69,6 +77,11 @@ public static class StoryReason
{
return name + " stands over " + other;
}
if (!dead)
{
return name + " catches their breath";
}
}
return name + " stands over the fallen";

View file

@ -229,6 +229,68 @@ public sealed class UnitDossier
FillTopStatuses(dossier, actor);
}
/// <summary>
/// Highest-value live status using the same scalable ranking as dossier chips.
/// </summary>
public static string ReadTopStatus(Actor actor)
{
var scratch = new UnitDossier();
FillTopStatuses(scratch, actor);
return scratch.TopStatuses.Count > 0
? scratch.TopStatuses[0]?.Name ?? ""
: "";
}
/// <summary>
/// Interesting, non-species-default traits using the dossier's live trait ranking.
/// </summary>
public static List<string> ReadStandoutTraitNames(Actor actor, int max, int minScore = 80)
{
var names = new List<string>(Math.Max(0, max));
if (actor == null || max <= 0)
{
return names;
}
try
{
var ranked = new List<ActorTrait>();
if (!actor.hasTraits())
{
return names;
}
foreach (ActorTrait trait in actor.getTraits())
{
if (trait == null
|| string.IsNullOrEmpty(trait.id)
|| IsDefaultTraitForActor(trait, actor)
|| TraitInterestScore(trait, actor) < minScore)
{
continue;
}
ranked.Add(trait);
}
ranked.Sort((a, b) => TraitInterestScore(b, actor).CompareTo(TraitInterestScore(a, actor)));
for (int i = 0; i < ranked.Count && names.Count < max; i++)
{
string name = TraitDisplayName(ranked[i]);
if (!string.IsNullOrEmpty(name) && !names.Contains(name))
{
names.Add(name);
}
}
}
catch
{
// Exotic actors may not expose a normal trait collection.
}
return names;
}
public bool ContainsIgnoreCase(string needle)
{
if (string.IsNullOrEmpty(needle))
@ -1290,8 +1352,18 @@ public sealed class UnitDossier
private static string BuildDetailLine(UnitDossier d)
{
// Compact text fallback for logs/harness: task + optional kingdom.
// Story/crisis FixedDwell reasons own the orange line - do not append combat
// task crumbs that read like a stale "Fighting · Kingdom" spine.
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(d.TaskText))
InterestCandidate watch = InterestDirector.CurrentCandidate;
bool suppressCombatTask = StoryPlanner.IsStoryOwnedTip(watch)
|| (!string.IsNullOrEmpty(d.ReasonLine)
&& (d.ReasonLine.IndexOf("surveys the aftermath",
System.StringComparison.OrdinalIgnoreCase) >= 0
|| d.ReasonLine.IndexOf("stands over",
System.StringComparison.OrdinalIgnoreCase) >= 0));
if (!string.IsNullOrEmpty(d.TaskText)
&& !(suppressCombatTask && IsCombatishTask(d.TaskText)))
{
sb.Append(d.TaskText);
}
@ -1322,6 +1394,21 @@ public sealed class UnitDossier
return sb.ToString();
}
private static bool IsCombatishTask(string task)
{
if (string.IsNullOrEmpty(task))
{
return false;
}
string t = task.Trim();
return t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Attack", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Battle", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf("Hunt", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private static void FillSex(UnitDossier d, Actor actor)
{
try

File diff suppressed because it is too large Load diff

View file

@ -11,13 +11,17 @@ namespace IdleSpectator;
/// </summary>
public static class WorldActivityScanner
{
private static readonly HashSet<string> SpectacleAssets = new HashSet<string>
/// <summary>Seed ids - live actor_library discovery extends this each refresh.</summary>
private static readonly string[] SpectacleAssetSeed =
{
"dragon",
"greg",
"demon",
"evil_mage",
"white_mage",
"necromancer",
"plague_doctor",
"druid",
"ufo",
"cold_one",
"walker",
@ -31,9 +35,31 @@ public static class WorldActivityScanner
"skeleton",
"zombie",
"ghost",
"mush"
"mush",
"crabzilla"
};
private static readonly string[] SpectacleIdTokens =
{
"mage",
"necromancer",
"druid",
"plague_doctor",
"dragon",
"demon",
"cold_one",
"tumor",
"bioblob",
"assimilator",
"crabzilla",
"fire_elemental",
"god_finger"
};
private static HashSet<string> _spectacleAssetIds;
private static float _spectacleIdsCachedAt = -999f;
private const float SpectacleIdsCacheSeconds = 30f;
private static readonly System.Random Rng = new System.Random();
private static float _lastScanAt = -999f;
private static InterestEvent _cachedBest;
@ -105,11 +131,8 @@ public static class WorldActivityScanner
continue;
}
if (battle.getDeathsTotal() > 0)
{
return true;
}
// Deaths alone are not live combat - finished battle tiles near a cold unit
// used to pin CombatStillActive forever.
if (LiveEnsemble.TryBuildCombat(bpos, 14f, out LiveEnsemble near)
&& near != null
&& near.ParticipantCount >= 1)
@ -312,10 +335,21 @@ public static class WorldActivityScanner
private static void ScanNow()
{
_cachedBattle = BuildHottestBattle();
// Busy war maps: skip O(units) character scoring when a real fight already won.
// Busy war maps: skip full O(units) scoring, but still consider live spectacles
// (evil mage / dragon) so mass civ scraps cannot erase them forever.
if (_cachedBattle != null && _cachedBattle.ParticipantCount >= 3)
{
_cachedBest = _cachedBattle;
InterestEvent spectacle = BuildBestSpectacleUnit();
if (spectacle != null
&& (_cachedBattle.FollowUnit == null || !IsSpectacle(_cachedBattle.FollowUnit))
&& _cachedBattle.NotableParticipantCount <= 0)
{
// Anonymous scrap vs a live spectacle - ambient follows the spectacle.
_cachedBest = spectacle;
return;
}
_cachedBest = PreferRicherAction(_cachedBattle, spectacle) ?? _cachedBattle;
return;
}
@ -877,6 +911,43 @@ public static class WorldActivityScanner
return null;
}
// Prefer spectacles over civ ambient. Idle/warm chores never beat a live mage/dragon;
// Hot combatants still need the prefer margin (spectacles should not erase every scrap).
if (!IsSpectacle(best))
{
Actor spectacle = null;
float spectacleScore = float.MinValue;
for (int i = 0; i < alive.Count; i++)
{
Actor actor = alive[i];
if (!IsSpectacle(actor))
{
continue;
}
float unitScore = ScoreActor(actor, speciesCounts);
if (unitScore > spectacleScore)
{
spectacleScore = unitScore;
spectacle = actor;
}
}
if (spectacle != null)
{
bool bestHotFight = best.has_attack_target
&& ActivityInterestTable.Classify(best) == ActivityBand.Hot;
float preferMargin = InterestScoringConfig.W.scannerSpectaclePreferMargin > 0f
? InterestScoringConfig.W.scannerSpectaclePreferMargin
: 28f;
if (!bestHotFight || spectacleScore + preferMargin >= bestScore)
{
best = spectacle;
bestScore = spectacleScore;
}
}
}
ActivityBand band = ActivityInterestTable.Classify(best);
SplitActorScore(best, speciesCounts, out float actionScore, out float charScore);
int fighters = 0;
@ -1021,12 +1092,15 @@ public static class WorldActivityScanner
SplitActorScore(actor, speciesCounts, out float action, out float character);
// Action-primary total used for ranking; character is a light tie-break.
float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
// Character Ledger bias for ambient fill - prefer watched lives over strangers.
// Life-saga bias for ambient fill - Prefer/MC must beat anonymous villagers post-quiet.
long id = EventFeedUtil.SafeId(actor);
float ledger = CharacterLedger.Heat(id);
if (ledger > 0f)
if (LifeSagaRoster.IsPrefer(id))
{
score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
score += Mathf.Max(8f, InterestScoringConfig.Story.sagaPreferWeight * 0.75f);
}
else if (LifeSagaRoster.IsMc(id))
{
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
}
float causal = CausalHeat.Get(id);
@ -1073,7 +1147,8 @@ public static class WorldActivityScanner
actionScore += w.scannerWarmBonus;
}
if (IsSpectacle(actor))
bool spectacle = IsSpectacle(actor);
if (spectacle)
{
actionScore += w.scannerSpectacleBonus;
}
@ -1111,17 +1186,32 @@ public static class WorldActivityScanner
characterScore += w.scannerSingletonBonus;
}
// Policy overlay (evil_mage / dragon / …) - same source as InterestScoring meta.
float speciesBonus = EventCatalogConfig.SpeciesBonus(speciesId);
if (speciesBonus > 0f)
{
characterScore += speciesBonus;
}
float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f;
characterScore += Mathf.Min(w.scannerCharRenownCap, actor.renown / renownDiv);
characterScore += Mathf.Min(w.scannerCharKillsCap, kills * w.scannerCharKillsFactor);
// Cold kings barely score - do not let identity monopolize the camera.
if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor))
// Spectacles keep full action (idle floor applied below).
if (band == ActivityBand.Cold && !actor.has_attack_target && !spectacle)
{
actionScore *= w.scannerColdActionMul;
characterScore *= w.scannerColdCharMul;
}
// Idle evil mages / dragons still beat warm chores for ambient fill.
if (spectacle && band == ActivityBand.Cold && !actor.has_attack_target)
{
float floor = w.scannerSpectacleIdleFloor > 0f ? w.scannerSpectacleIdleFloor : 42f;
actionScore = Mathf.Max(actionScore, floor);
}
actionScore += (actor.getID() % 7) * 0.01f;
}
@ -1163,16 +1253,88 @@ public static class WorldActivityScanner
return false;
}
string id = actor.asset.id;
if (SpectacleAssets.Contains(id))
return IsSpectacleAssetId(actor.asset.id);
}
/// <summary>
/// Live spectacle actor / power id class (mages, dragons, demons, …).
/// Seed + token heuristics + live <c>actor_library</c> discovery.
/// </summary>
public static bool IsSpectacleAssetId(string assetId)
{
if (string.IsNullOrEmpty(assetId))
{
return false;
}
string id = assetId.Trim().ToLowerInvariant();
if (id.StartsWith("boat_", StringComparison.Ordinal))
{
return false;
}
HashSet<string> live = EnsureSpectacleAssetIds();
if (live.Contains(id))
{
return true;
}
// Loose match for variant ids.
foreach (string key in SpectacleAssets)
return MatchesSpectacleToken(id);
}
private static HashSet<string> EnsureSpectacleAssetIds()
{
float now = Time.unscaledTime;
if (_spectacleAssetIds != null && now - _spectacleIdsCachedAt < SpectacleIdsCacheSeconds)
{
if (id.Contains(key))
return _spectacleAssetIds;
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
{
set.Add(SpectacleAssetSeed[i]);
}
List<string> liveActors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
for (int i = 0; i < liveActors.Count; i++)
{
string id = liveActors[i];
if (string.IsNullOrEmpty(id) || id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (MatchesSpectacleToken(id) || set.Contains(id))
{
set.Add(id);
}
}
_spectacleAssetIds = set;
_spectacleIdsCachedAt = now;
return _spectacleAssetIds;
}
private static bool MatchesSpectacleToken(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
for (int i = 0; i < SpectacleIdTokens.Length; i++)
{
if (s.IndexOf(SpectacleIdTokens[i], StringComparison.Ordinal) >= 0)
{
return true;
}
}
for (int i = 0; i < SpectacleAssetSeed.Length; i++)
{
if (s.IndexOf(SpectacleAssetSeed[i], StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
@ -1181,6 +1343,53 @@ public static class WorldActivityScanner
return false;
}
/// <summary>Best living spectacle unit for ambient ranking (mages, dragons, …).</summary>
private static InterestEvent BuildBestSpectacleUnit()
{
Actor best = null;
float bestScore = float.MinValue;
Dictionary<string, int> counts = CountSpeciesPopulations();
foreach (Actor actor in EnumerateAliveUnits())
{
if (actor == null || !actor.isAlive() || !IsSpectacle(actor))
{
continue;
}
if (actor.asset != null && actor.asset.is_boat)
{
continue;
}
float score = ScoreActor(actor, counts);
if (score > bestScore)
{
bestScore = score;
best = actor;
}
}
if (best == null)
{
return null;
}
SplitActorScore(best, counts, out float actionScore, out float charScore);
float rankChar = InterestScoringConfig.W.scannerRankCharWeight;
return new InterestEvent
{
Score = actionScore + charScore * rankChar,
Position = best.current_position,
FollowUnit = best,
Label = "",
CreatedAt = Time.unscaledTime,
AssetId = best.asset != null ? best.asset.id : "spectacle",
ParticipantCount = 0,
NotableParticipantCount = 0,
CharacterSignificance = charScore
};
}
private static bool IsSingletonSpecies(Actor actor, Dictionary<string, int> speciesCounts)
{
if (actor?.asset == null)

View file

@ -75,7 +75,11 @@ public static class WorldLogEventHarness
}
string labelSample = entry.MakeLabel("Alpha", "Beta");
if (string.IsNullOrEmpty(labelSample))
string emptySlotSample = entry.MakeLabel("", "");
bool hanging = WorldLogEventEntry.IsHangingLabel(labelSample)
|| WorldLogEventEntry.IsHangingLabel(emptySlotSample)
|| string.IsNullOrWhiteSpace(emptySlotSample);
if (hanging)
{
emptyLabel++;
}
@ -85,7 +89,7 @@ public static class WorldLogEventHarness
{
notes = "missing_authored";
}
else if (string.IsNullOrEmpty(labelSample))
else if (hanging)
{
notes = "empty_label";
}

View file

@ -209,6 +209,26 @@
"id": "demon",
"bonus": 12
},
{
"id": "evil_mage",
"bonus": 14
},
{
"id": "necromancer",
"bonus": 14
},
{
"id": "white_mage",
"bonus": 12
},
{
"id": "plague_doctor",
"bonus": 12
},
{
"id": "druid",
"bonus": 10
},
{
"id": "angle",
"bonus": 12
@ -2630,6 +2650,13 @@
"category": "Story",
"camera": true,
"label": "{a} carries on after the story"
},
{
"id": "epilogue_crisis",
"strength": 52,
"category": "Story",
"camera": true,
"label": "{a} surveys the aftermath of the crisis"
}
],
"traits": [

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.28.54",
"version": "0.29.48",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -83,6 +83,8 @@
"scannerHotBonus": 18,
"scannerWarmBonus": 8,
"scannerSpectacleBonus": 12,
"scannerSpectacleIdleFloor": 48,
"scannerSpectaclePreferMargin": 28,
"scannerActionKillsFactor": 0.2,
"scannerActionKillsCap": 10,
"scannerWarriorBonus": 6,
@ -129,7 +131,23 @@
"historyDensityWindowSeconds": 600,
"ledgerBleedLifeChapter": 0.4,
"ledgerBleedVillage": 0.82,
"familyLifeBeatCooldownSeconds": 90
"sagaMcWeight": 6,
"sagaPreferWeight": 10,
"sagaGameFavoriteWeight": 8,
"sagaDullSeconds": 300,
"familyLifeBeatCooldownSeconds": 90,
"crisisWarParticipantMin": 8,
"crisisDisasterStrengthMin": 95,
"crisisOwnershipBoost": 22,
"crisisHoldMargin": 40,
"crisisLingerSeconds": 10,
"crisisDisasterLingerSeconds": 28,
"crisisMaxSeconds": 180,
"crisisCooldownSeconds": 45,
"crisisDisasterCooldownSeconds": 120,
"crisisEpilogueStrength": 52,
"crisisEpilogueMaxWatch": 16,
"softFillQuietSeconds": 18
},
"sources": [

View file

@ -93,6 +93,9 @@ Also stay B: diet/sleep/random/check ticks, `warrior_train_with_dummy`, boat tra
Spell Signal: spectacle summons/curses/fire/teleport/meteor; FX silence/shield/cure/grass/vegetation/skeleton stay B.
Power Signal: disasters plus spectacle creature drops (`evil_mage`, `white_mage`, `necromancer`,
`druid`, `plague_doctor`, dragons/demons, …) via live spectacle asset discovery - not B ambient.
## Patch-only / no catalog rows
| Surface | Live / notes | Wire status | Priority |

105
docs/life-saga.md Normal file
View file

@ -0,0 +1,105 @@
# Life-saga roster
Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
Chronicle stays a history book and never drives the camera.
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
## Ownership
| Concern | Owner |
|---------|--------|
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
| Durable observed facts | `LifeSagaMemory` |
| Adaptive presentation | `LifeSagaPresentation` |
| Dossier / Saga tabs + hover preview | `LifeSagaViewController` |
| Saga body UI | `LifeSagaPanel` |
| Rail icons / Prefer click | `LifeSagaRail` |
| Camera read-pause lease | `InterestDirector` |
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
| Full history | Chronicle / Lore |
## Admission
Live discovery (no tip-string allowlists):
- Game favorite, king, city leader, kills/renown floors (`InterestScoring.IsNotable`)
- Pack alpha, clan chief, army captain, active plot author, founder, species singleton
Standing is recomputed from live roles each refill.
Heat comes from chapter stamps / featured tips and decays with idle time.
Each slot keeps its original admission reason even when its live role later changes.
Hard-arc admissions also retain the arc kind and chapter context that brought the life onto the roster.
## Dynamic top-10
- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
- Admission-role units always compete on the periodic scan.
- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief).
- Soft `NoteFeatured` heats existing MCs only.
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
## Camera (soft)
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > CausalHeat.
- Near-ties prefer Prefer'd MCs, then any MC.
- Ambient fill prefers roster MCs over random villagers.
- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
## Rail UX
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
- Active chrome = camera follow only; Prefer uses a star pip / distinct border.
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
- While on the **Saga** tab, click also pins that life as the persistent Saga subject.
- Tabs sit above the cast rail; the rail is a **horizontal strip under the tabs** so Dossier↔Saga body swaps cannot move the glyphs.
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
- Hovering a glyph temporarily previews that life in the Saga body and pauses camera switching.
- Leaving the rail (or panel after a dwell) re-arms a short grace, then restores the prior tab and releases the pause lease.
- Clicking a tab during hover cancels the preview immediately and applies that tab.
- Tabbed chrome locks a constant outer width so Dossier↔Saga hover cannot slide glyphs.
- Saga body matches Design A: identity, stake (accent wrap), Cast (face + name + relation), Evidence (lens module), Legacy, Open Lore.
- Open Lore from Saga stashes return-to-Saga; Lore close restores the Saga tab and subject when still an MC.
- Body height sizes to content up to a cap; lines wrap fully (no ellipsis truncation).
- Glyph hover keeps the preview while the pointer stays on the rail or dossier panel.
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
- Never show live task/status/trait rows or call a live attack target a rival.
- Earned rivals require memory evidence (repeated encounters, kin kills, war/plot opposition).
- Likely succession is omitted unless a game-authored heir API probe succeeds.
- Pack / kin lenses fill remaining Cast slots with live family peers (`Packmate` / `Kin`) after lover/friend/parents/children/heir/rival.
## Soft-fill quiet (AFK hygiene)
After hard story/crisis closers, soft-fill quiet suppresses soft-life crumbs and short interrupt FX (`stunned` / `invincible`).
`FamilyPack` does not break quiet (ambient cluster fill).
Combat / war / plot / outbreak / earthquake / story beats / epic strength still cut in.
Quiet can refresh briefly when a soft crumb ends (bounded).
## Session
- `StoryPlanner.Clear` does not wipe the roster or saga memory.
- Lore browse / brief idle-off keeps the roster and memory.
- World replacement, harness batch end, and `interest_saga_clear` wipe roster + memory.
## Harness
- `saga_roster_diversity`
- `saga_camera_prefers_mc`
- `saga_prefer_soft_bias`
- `saga_overview_has_mc`
- `saga_adaptive_dossier`
- `saga_memory_survives`
- `saga_hover_read_pause`
- `saga_replace_on_death`
- `saga_admit_roles`
- `saga_replace_weaker`
- `saga_rail_active_highlight`
- `saga_rail_prefer_click`
- `saga_soft_fill_no_pack`
See also: [story-planner.md](story-planner.md).

View file

@ -5,7 +5,36 @@ IdleSpectator commits to short multi-beat stories instead of channel-surfing ran
Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
`StoryPlanner` sits above the director and owns *which story* the next 3090s belongs to.
See also: [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
## Short-arc board (not the dossier rail)
Hard storylines (notable duel / mass / war / plot / love / grief) still form a small
watching + parked board (cap 4) for short-chapter resume.
Cutting away **parks** a hard arc; soft anonymous scraps still end.
There is **no** rail Commit / hard pin anymore.
- Lore browse uses `browsePause` - freezes idle without `StoryPlanner.Clear()`.
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
Click toggles Prefer (soft favorite).
Dossier/Saga tabs and hover preview are owned by `LifeSagaViewController` + `LifeSagaPanel`.
Durable observed facts live in `LifeSagaMemory` (independent of ChronicleEnabled).
Hover temporarily pauses camera switching without disabling Idle Spectator.
See [life-saga.md](life-saga.md).
Crisis camera hold: WarFront always blocks Mass/Battle cut-ins (`war_theater_hold`).
Disaster / outbreak signals use the same class rule while their chapter is live
(`crisis_theater_hold`), classified via live `AssetManager.disasters` + catalog categories
(not tip-string `IndexOf`).
`InterestDirector` is a partial class: session orchestrator + `StickyMaintain` + `SwitchPolicy`.
Harness: `story_lore_pause_keeps`, `story_board_park_resume` (short-arc park/resume via
`story_resume_parked`), `story_crisis_war`, `story_crisis_disaster`, `war_front_sticky`,
plus `saga_*` scenarios.
See also: [life-saga.md](life-saga.md), [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
## Pipeline
@ -106,23 +135,61 @@ Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_i
/ `combat_wiped_side` / `reason_life_principal`
(`story_arc_combat`, `story_spine_scoped`, `story_aftermath_combat`, `story_family_variety`).
## Crisis chapters (Phase 4)
Parallel overlay beside short `StoryArc` - war / disaster / outbreak thresholds open a
multi-minute chapter without thrashing when unrelated tips cut in.
| Signal | Enter when |
|--------|------------|
| WarFront | **live** participants ≥ `crisisWarParticipantMin` (default 8), both sides present (peak lock alone does not enter) |
| EarthquakeActive / Disaster category | live or EventStrength ≥ `crisisDisasterStrengthMin` (95) |
| StatusOutbreak | EventStrength ≥ disaster floor |
While live:
- `crisisOwnershipBoost` + `crisisHoldMargin` on matching tips
- Character-fill ambient is suppressed (`SuppressAmbientFill`)
- Epic peers (≥ disaster floor) may still cut
On exit (signal linger / max duration / harness `interest_crisis_end`): inject
`epilogue_crisis` (`{a} surveys the aftermath of the crisis`), then cooldown.
Disaster / outbreak chapters use a shared family anchor (`disaster` / `outbreak`), a longer
linger after the last WorldLog pulse, and `crisisDisasterCooldownSeconds` (default 120)
armed when the closer injects so stacked tornado tips cannot double-close.
War chapters stamp a kingdom theater pair. Local Mass/Battle on that pair keeps the chapter
warm and cannot steal the War tip (`war_theater_hold`). Crisis closers clear short-arc spine
and suppress combat task detail crumbs under the orange reason.
Short-arc related epilogue is skipped when a crisis closer owns the exit.
When a short-arc war linger injects while a war crisis is live, the crisis quiet-closes
(no second `epilogue_crisis`). Harness batch end and `interest_story_purge_leftovers`
drop leftover crisis closers so free AFK does not inherit a fake ending.
After hard story/crisis ends, `softFillQuietSeconds` suppresses soft-life crumbs,
short interrupt FX (`stunned` / `invincible`), and character-fill ambient briefly.
`FamilyPack` does not break that quiet window (see [life-saga.md](life-saga.md)).
## Selection discipline
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
- Near-ties still roll among top-3.
- Causal heat + Character Ledger heat add into `RecalcTotal`.
- Causal heat + life-saga Prefer/MC soft bias add into `RecalcTotal`.
## Knobs
`scoring-model.json``story` block (loaded by `InterestScoringConfig.Story`).
Catalog policy ids live under `event-catalog.json``story`
(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, …).
(`aftermath_survivor`, `aftermath_mourner`, `aftermath_war_linger`, `epilogue_crisis`, …).
## Harness
| Scenario | Proves |
|----------|--------|
| `story_crisis_war` | WarFront → crisis active → `epilogue_crisis` |
| `story_crisis_disaster` | EarthquakeActive → crisis → `epilogue_crisis`; cool blocks reopen |
| `story_aftermath_combat` | duel cold → aftermath tip |
| `story_aftermath_partner_truth` | aftermath never names living lover as fallen |
| `story_aftermath_cast_noise` | hatch noise pending still gets aftermath |
@ -140,13 +207,18 @@ Catalog policy ids live under `event-catalog.json` → `story`
| `story_resume_cooled` | cooled soft tip does not resume after cut-in |
| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick |
| `combat_wiped_side` | wiped camp never prints vs (0) |
| `story_aftermath_war` | war sticky cold → aftermath |
| `story_aftermath_war` | war sticky cold → aftermath; no dual crisis closer |
| `story_crisis_hygiene` | auto-opened war crisis + short-arc linger; purge leaves no epilogue leak |
| `story_near_tie` | large gap always picks #1 |
| `story_arc_combat` | climax → aftermath ownership |
| `story_arc_preempt_resume` | epic cut then fresh scrap aftermath |
| `story_ledger_revisit` | featured unit wins near-tie |
```bash
./scripts/harness-run.sh --repeat 3 story_crisis_war
./scripts/harness-run.sh --repeat 3 story_crisis_disaster
./scripts/harness-run.sh --repeat 3 story_aftermath_war
./scripts/harness-run.sh --repeat 3 war_front_sticky
./scripts/harness-run.sh --repeat 3 story_aftermath_combat
./scripts/harness-run.sh --repeat 3 story_aftermath_partner_truth
./scripts/harness-run.sh --repeat 3 story_aftermath_cast_noise
@ -168,8 +240,10 @@ Catalog policy ids live under `event-catalog.json` → `story`
## Files
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - saga hover + rail
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row