feat(saga): deepen session cast stakes and cut idle hitch spikes

- Keep LifeSagaSession across Clear; wipe on map load/clear with bind key
- Soft-bias Prefer/cross-MC/cast and prefer roster MCs in aftermath/recover/park
- Resolve war/plot principals plus discrete/WorldLog labels from live libraries
- Amortize roster world scans, gate Relayout, and add hitch probe/save-slot helpers
This commit is contained in:
DazedAnon 2026-07-22 14:37:36 -05:00
parent dafe79dfa2
commit ba58a9ff38
30 changed files with 2955 additions and 321 deletions

View file

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using HarmonyLib;
using NeoModLoader.services;
using UnityEngine;
@ -2899,6 +2900,16 @@ public static class AgentHarness
Emit(cmd, ok: true, detail: "saga_cleared");
break;
case "saga_session_reset":
LifeSagaSession.ResetForWorldChange("harness");
_sagaPanelSizeAnchor = Vector2.zero;
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"saga_session_reset key='{LifeSagaSession.BoundKey}' epoch={LifeSagaSession.Epoch}");
break;
case "interest_story_purge_leftovers":
StoryPlanner.PurgeCloserLeftovers();
_cmdOk++;
@ -3096,6 +3107,40 @@ public static class AgentHarness
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
Actor partner = _happinessPartner;
string needle = (cmd.expect ?? "").Trim();
if (string.IsNullOrEmpty(needle))
{
needle = (cmd.label ?? "").Trim();
}
if (string.IsNullOrEmpty(needle))
{
needle = (cmd.value ?? "").Trim();
}
if (!string.IsNullOrEmpty(needle)
&& (cur == null
|| string.IsNullOrEmpty(cur.Label)
|| cur.Label.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0))
{
var pending = new System.Collections.Generic.List<InterestCandidate>(64);
InterestRegistry.CopyPending(pending);
for (int i = 0; i < pending.Count; i++)
{
InterestCandidate p = pending[i];
if (p == null || string.IsNullOrEmpty(p.Label))
{
continue;
}
if (p.Label.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
{
cur = p;
break;
}
}
}
bool ok = cur != null && partner != null && partner.isAlive();
if (ok)
{
@ -3110,7 +3155,7 @@ public static class AgentHarness
}
Emit(cmd, ok,
detail: $"bind related={SafeName(partner)} tip={cur?.Label} score={cur?.TotalScore:0.#}");
detail: $"bind related={SafeName(partner)} tip={cur?.Label} score={cur?.TotalScore:0.#} needle='{needle}'");
break;
}
@ -3384,18 +3429,120 @@ public static class AgentHarness
{
kind = LifeSagaFactKind.RoleChange;
}
else if (kindRaw.IndexOf("combat", StringComparison.OrdinalIgnoreCase) >= 0
|| kindRaw.IndexOf("hardarc", StringComparison.OrdinalIgnoreCase) >= 0)
{
kind = LifeSagaFactKind.HardArcCombat;
}
LifeSagaFact fact = LifeSagaMemory.Record(
kind,
focus,
null,
correlationKey: "harness:" + EventFeedUtil.SafeId(focus) + ":" + kind,
strength: 80f,
provenance: "harness",
note: cmd.value ?? "");
bool ok = fact != null;
Actor other = _happinessPartner;
if (other == focus)
{
other = null;
}
LifeSagaFact fact;
if (kind == LifeSagaFactKind.WarJoin)
{
string warKey = string.IsNullOrEmpty(cmd.expect) ? "harness_war" : cmd.expect.Trim();
LifeSagaMemory.RecordWar(
focus,
warKey,
cmd.label ?? "North",
ended: false,
provenance: "harness",
other: other,
note: string.IsNullOrEmpty(cmd.value) ? "North vs South" : cmd.value);
if (other != null && other.isAlive())
{
LifeSagaMemory.RecordWar(
other,
warKey,
"South",
ended: false,
provenance: "harness",
other: focus,
note: string.IsNullOrEmpty(cmd.value) ? "North vs South" : cmd.value);
}
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
}
else if (kind == LifeSagaFactKind.PlotJoin)
{
string plotKey = string.IsNullOrEmpty(cmd.expect) ? "harness_plot" : cmd.expect.Trim();
LifeSagaMemory.RecordPlot(
focus,
plotKey,
string.IsNullOrEmpty(cmd.value) ? "coup" : cmd.value,
finished: false,
provenance: "harness",
other: other);
if (other != null && other.isAlive())
{
LifeSagaMemory.RecordPlot(
other,
plotKey,
string.IsNullOrEmpty(cmd.value) ? "coup" : cmd.value,
finished: false,
provenance: "harness",
other: focus);
}
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
}
else if (kind == LifeSagaFactKind.HardArcCombat)
{
LifeSagaMemory.RecordHardArc(
EventFeedUtil.SafeId(focus),
StoryArcKind.CombatDuel,
string.IsNullOrEmpty(cmd.value) ? "A fight marked them" : cmd.value,
relatedId: EventFeedUtil.SafeId(other),
provenance: "harness");
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
}
else
{
fact = LifeSagaMemory.Record(
kind,
focus,
other,
correlationKey: "harness:" + EventFeedUtil.SafeId(focus) + ":" + kind,
strength: 80f,
provenance: "harness",
note: cmd.value ?? "");
}
bool ok = fact != null || LifeSagaMemory.HasFacts(EventFeedUtil.SafeId(focus));
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: $"kind={kind} facts={LifeSagaMemory.FactsFor(EventFeedUtil.SafeId(focus)).Count}");
Emit(cmd, ok, detail: $"kind={kind} other={SafeName(other)} facts={LifeSagaMemory.FactsFor(EventFeedUtil.SafeId(focus)).Count}");
break;
}
case "saga_memory_resolve_war":
{
string warKey = string.IsNullOrEmpty(cmd.value) ? "harness_war" : cmd.value.Trim();
LifeSagaMemory.ResolveWarJoins(warKey);
_cmdOk++;
Emit(cmd, ok: true, detail: $"resolved_war={warKey}");
break;
}
case "saga_memory_resolve_plot":
{
string plotKey = string.IsNullOrEmpty(cmd.value) ? "harness_plot" : cmd.value.Trim();
LifeSagaMemory.ResolvePlotJoins(plotKey);
_cmdOk++;
Emit(cmd, ok: true, detail: $"resolved_plot={plotKey}");
break;
}
case "saga_role_rise_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string role = string.IsNullOrEmpty(cmd.value) ? "become_king" : cmd.value.Trim();
bool ok = LifeSagaRoster.HarnessSimulateRoleRise(focus, role);
if (ok) _cmdOk++; else _cmdFail++;
Emit(cmd, ok, detail: $"role={role} id={EventFeedUtil.SafeId(focus)}");
break;
}
@ -3428,6 +3575,118 @@ public static class AgentHarness
break;
}
case "hitch_probe":
{
bool on = ParseBool(cmd.value, defaultValue: true);
IdleHitchProbe.SetEnabled(on);
IdleHitchProbe.ResetStats();
_cmdOk++;
Emit(cmd, ok: true, detail: $"hitch_probe={on}");
break;
}
case "load_save_slot":
{
int slot = Mathf.Clamp(ParseInt(cmd.value, 2), 1, 5);
string detail;
bool ok = TryLoadSaveSlot(slot, out detail);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, ok, detail: detail);
break;
}
case "reflect_save_manager":
{
Type sm = AccessTools.TypeByName("SaveManager");
var sb = new StringBuilder();
sb.Append("type=").Append(sm != null ? sm.FullName : "null");
if (sm != null)
{
sb.Append(" methods=");
foreach (var mi in sm.GetMethods(
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.Static
| System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.DeclaredOnly))
{
if (mi.Name.IndexOf("load", StringComparison.OrdinalIgnoreCase) < 0
&& mi.Name.IndexOf("save", StringComparison.OrdinalIgnoreCase) < 0
&& mi.Name.IndexOf("slot", StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
sb.Append(mi.IsStatic ? "S:" : "I:");
sb.Append(mi.Name).Append('(');
var ps = mi.GetParameters();
for (int i = 0; i < ps.Length; i++)
{
if (i > 0)
{
sb.Append(',');
}
sb.Append(ps[i].ParameterType.Name);
}
sb.Append(");");
}
sb.Append(" fields=");
foreach (var fi in sm.GetFields(
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.Static
| System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic))
{
sb.Append(fi.IsStatic ? "S:" : "I:");
sb.Append(fi.Name).Append(':').Append(fi.FieldType.Name).Append(';');
}
}
// Host owners of saveManager
foreach (string hostName in new[] { "MapBox", "World", "Config", "SaveSlotManager" })
{
Type host = AccessTools.TypeByName(hostName);
if (host == null)
{
continue;
}
sb.Append(" host=").Append(hostName).Append('{');
foreach (var fi in host.GetFields(
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.Static
| System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic))
{
if (fi.Name.IndexOf("save", StringComparison.OrdinalIgnoreCase) < 0
&& fi.Name.IndexOf("Save", StringComparison.Ordinal) < 0)
{
continue;
}
sb.Append(fi.IsStatic ? "S:" : "I:");
sb.Append(fi.Name).Append(':').Append(fi.FieldType.Name).Append(';');
}
sb.Append('}');
}
_cmdOk++;
Emit(cmd, ok: true, detail: sb.ToString());
break;
}
case "interest_variety_note":
{
// Stamp the current (or pending needle in value) as the last shown variety arc.
@ -4971,6 +5230,16 @@ public static class AgentHarness
return;
}
if (!string.IsNullOrEmpty(cmd.value)
&& cmd.value.IndexOf("related=partner", StringComparison.OrdinalIgnoreCase) >= 0
&& _happinessPartner != null
&& _happinessPartner.isAlive())
{
c.RelatedUnit = _happinessPartner;
c.RelatedId = EventFeedUtil.SafeId(_happinessPartner);
InterestScoring.RecalcTotal(c);
}
if (participants > 0 || notables > 0)
{
c.ParticipantCount = participants;
@ -8725,6 +8994,26 @@ public static class AgentHarness
$"involvedLit={LifeSagaRail.LastInvolvedHighlightCount} want>={want} active={LifeSagaRail.LastActiveHighlight}";
break;
}
case "saga_soft_bias_rank":
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
int want = ParseInt(cmd.value, 3);
int have = LifeSagaRoster.SoftBiasRank(cur);
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
pass = cmp == "min" ? have >= want : have == want;
detail =
$"softBias={have} want={want} cmp={cmp} touched={LifeSagaRoster.CountTouchedMcs(cur)} tip={cur?.Label}";
break;
}
case "saga_score_bonus_min":
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
float want = ParseFloat(cmd.value, 20f);
float have = LifeSagaRoster.ScoreBonus(cur);
pass = have + 0.05f >= want;
detail = $"sagaBonus={have:0.#} want>={want:0.#} tip={cur?.Label}";
break;
}
case "saga_rival_focus":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
@ -8918,6 +9207,181 @@ public static class AgentHarness
detail = $"id={id} wantKind={want} facts={facts.Count} pass={pass}";
break;
}
case "saga_memory_fact_other":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
long partnerId = EventFeedUtil.SafeId(_happinessPartner);
string wantKind = (cmd.value ?? "WarJoin").Trim();
string mode = (cmd.label ?? "").Trim().ToLowerInvariant();
bool wantNonzero = mode == "nonzero" || mode == "any" || partnerId == 0 || partnerId == id;
pass = false;
var facts = LifeSagaMemory.FactsFor(id);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact f = facts[i];
if (f == null)
{
continue;
}
if (f.Kind.ToString().IndexOf(wantKind, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
if (wantNonzero)
{
if (f.OtherId != 0 && f.OtherId != id)
{
pass = true;
break;
}
}
else if (f.OtherId == partnerId)
{
pass = true;
break;
}
}
detail =
$"id={id} partner={partnerId} wantKind={wantKind} mode={(wantNonzero ? "nonzero" : "partner")} pass={pass}";
break;
}
case "saga_memory_fact_resolved":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
string wantKind = (cmd.value ?? "War").Trim();
bool wantResolved = ParseBool(cmd.label, defaultValue: true);
pass = false;
var facts = LifeSagaMemory.FactsFor(id);
for (int i = 0; i < facts.Count; i++)
{
LifeSagaFact f = facts[i];
if (f == null)
{
continue;
}
if (f.Kind.ToString().IndexOf(wantKind, StringComparison.OrdinalIgnoreCase) < 0
&& (f.WarKey ?? "").IndexOf(wantKind, StringComparison.OrdinalIgnoreCase) < 0
&& (f.PlotKey ?? "").IndexOf(wantKind, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
pass = f.Resolved == wantResolved;
if (pass)
{
break;
}
}
detail = $"id={id} wantKind={wantKind} wantResolved={wantResolved} pass={pass}";
break;
}
case "saga_stake_contains":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
if (id == 0)
{
var board = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(board);
id = board.Count > 0 && board[0] != null ? board[0].UnitId : 0;
}
string tip = LifeSagaPresentation.BuildPlainText(id) ?? "";
string any = (cmd.value ?? "").Trim();
pass = false;
if (!string.IsNullOrEmpty(tip) && !string.IsNullOrEmpty(any))
{
string[] parts = any.Split('|');
for (int i = 0; i < parts.Length; i++)
{
string needle = (parts[i] ?? "").Trim();
if (needle.Length > 0
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
break;
}
}
}
detail = $"stakeTip='{tip}' any='{any}' pass={pass}";
break;
}
case "saga_cast_contains":
{
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
if (id == 0)
{
var board = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(board);
id = board.Count > 0 && board[0] != null ? board[0].UnitId : 0;
}
LifeSagaViewModel model = LifeSagaPresentation.Build(id);
string any = (cmd.value ?? "").Trim();
pass = false;
string sample = "";
if (model != null)
{
for (int i = 0; i < model.Cast.Count; i++)
{
LifeSagaCastMember m = model.Cast[i];
if (m == null)
{
continue;
}
string row = (m.Relation ?? "") + " " + (m.Name ?? "");
if (i > 0)
{
sample += " · ";
}
sample += row;
if (!string.IsNullOrEmpty(any)
&& row.IndexOf(any, StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
}
}
if (!pass && !string.IsNullOrEmpty(any))
{
string[] parts = any.Split('|');
for (int i = 0; i < parts.Length && !pass; i++)
{
string needle = (parts[i] ?? "").Trim();
if (needle.Length == 0)
{
continue;
}
for (int j = 0; j < model.Cast.Count; j++)
{
LifeSagaCastMember m = model.Cast[j];
if (m == null)
{
continue;
}
string row = (m.Relation ?? "") + " " + (m.Name ?? "");
if (row.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
break;
}
}
}
}
}
detail = $"cast='{sample}' any='{any}' pass={pass}";
break;
}
case "saga_memory_life_count":
{
int want = ParseInt(cmd.value, 1);
@ -13000,6 +13464,11 @@ public static class AgentHarness
private static string SafeName(Actor actor)
{
if (actor == null)
{
return "-";
}
try
{
string name = actor.getName();
@ -13205,6 +13674,79 @@ public static class AgentHarness
return defaultValue;
}
/// <summary>
/// Load a numbered save slot (1-5). Hitch soaks use slot 2 (Box of Magic).
/// </summary>
private static bool TryLoadSaveSlot(int slot, out string detail)
{
detail = "";
try
{
Type smType = AccessTools.TypeByName("SaveManager");
if (smType == null)
{
detail = "SaveManager type missing";
return false;
}
var setSlot = AccessTools.Method(smType, "setCurrentSlot", new[] { typeof(int) });
var getPath = AccessTools.Method(smType, "getSlotSavePath", new[] { typeof(int) });
if (setSlot == null)
{
detail = "setCurrentSlot missing";
return false;
}
setSlot.Invoke(null, new object[] { slot });
string path = getPath?.Invoke(null, new object[] { slot }) as string ?? "";
object instance = null;
object map = World.world;
if (map == null)
{
var instFi = AccessTools.Field(typeof(MapBox), "instance");
map = instFi?.GetValue(null);
}
if (map != null)
{
var smFi = AccessTools.Field(map.GetType(), "save_manager")
?? AccessTools.Field(typeof(MapBox), "save_manager");
instance = smFi?.GetValue(map);
}
if (instance == null)
{
detail = $"save_manager missing after setCurrentSlot={slot} path={path}";
return false;
}
var loadWorld = AccessTools.Method(smType, "loadWorld", new[] { typeof(string), typeof(bool) });
if (loadWorld != null && !string.IsNullOrEmpty(path))
{
loadWorld.Invoke(instance, new object[] { path, true });
detail = $"loadWorld slot={slot} path={path}";
return true;
}
var start = AccessTools.Method(smType, "startLoadSlot", Type.EmptyTypes);
if (start != null)
{
start.Invoke(instance, Array.Empty<object>());
detail = $"startLoadSlot slot={slot} path={path}";
return true;
}
detail = $"no loadWorld/startLoadSlot path={path}";
return false;
}
catch (Exception ex)
{
detail = "load_failed: " + (ex.InnerException?.Message ?? ex.Message);
return false;
}
}
private static string Escape(string s)
{
if (string.IsNullOrEmpty(s))

View file

@ -176,11 +176,12 @@ public static class Chronicle
{
ClearSession();
ActivityLog.ClearSession();
LifeSagaMemory.ClearSession();
_boundWorld = world;
LogService.LogInfo("[IdleSpectator] Chronicle cleared for new world session");
}
// Roster + saga memory use a richer bind key than world reference alone.
LifeSagaSession.EnsureBound();
RefreshAgeChapter(force: true);
}

View file

@ -71,7 +71,8 @@ public static partial class EventCatalog
signals,
d.SignalStrength,
signalPredicate: IsSpectacleSpell,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "spell");
}
/// <summary>
@ -118,7 +119,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 55f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 55f, libraryKind: "spell");
}
}
@ -214,7 +215,8 @@ public static partial class EventCatalog
signalIds: null,
d.SignalStrength,
signalPredicate: IsSpectaclePower,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "power");
}
public static IEnumerable<string> AuthoredIds
@ -230,7 +232,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("power", 28f, 92f, "Spectacle", "God power: {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f, libraryKind: "power");
}
}
@ -278,7 +280,8 @@ public static partial class EventCatalog
signalIds: null,
d.SignalStrength,
signalPredicate: IsLegendaryItem,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "item");
}
public static IEnumerable<string> AuthoredIds
@ -294,7 +297,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f, libraryKind: "item");
}
}
@ -359,7 +362,8 @@ public static partial class EventCatalog
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramatic,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "subspecies_trait");
}
public static IEnumerable<string> AuthoredIds
@ -375,7 +379,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 36f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 36f, libraryKind: "subspecies_trait");
}
}
@ -411,7 +415,8 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "gene");
}
public static IEnumerable<string> AuthoredIds
@ -427,7 +432,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f, libraryKind: "gene");
}
}
@ -463,7 +468,8 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "phenotype");
}
public static IEnumerable<string> AuthoredIds
@ -479,7 +485,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 28f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 28f, libraryKind: "phenotype");
}
}
@ -515,7 +521,8 @@ public static partial class EventCatalog
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticWorldLaw,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "worldlaw");
}
private static bool IsDramaticWorldLaw(string id)
@ -546,7 +553,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f, libraryKind: "worldlaw");
}
}
@ -581,7 +588,8 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "biome");
}
public static IEnumerable<string> AuthoredIds
@ -597,7 +605,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("biome", 26f, 50f, "Biome", "Biome shifts: {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 26f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 26f, libraryKind: "biome");
}
}
@ -703,7 +711,8 @@ public static partial class EventCatalog
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticCultureTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "culture_trait");
}
public static IEnumerable<string> AuthoredIds
@ -719,7 +728,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f, libraryKind: "culture_trait");
}
}
@ -755,7 +764,8 @@ public static partial class EventCatalog
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramaticMetaTrait,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "religion_trait");
}
public static IEnumerable<string> AuthoredIds
@ -771,7 +781,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f, libraryKind: "religion_trait");
}
}
@ -807,7 +817,8 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "clan_trait");
}
public static IEnumerable<string> AuthoredIds
@ -823,7 +834,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 32f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 32f, libraryKind: "clan_trait");
}
}
@ -859,7 +870,8 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
ambientCreatesInterest: d.AmbientCreatesInterest,
libraryKind: "language_trait");
}
public static IEnumerable<string> AuthoredIds
@ -875,7 +887,7 @@ public static partial class EventCatalog
{
Ensure();
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f, libraryKind: "language_trait");
}
}
}

View file

@ -20,7 +20,7 @@ public sealed class WorldLogEventEntry
string a = (special1 ?? "").Trim();
string b = (special2 ?? "").Trim();
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
string displayId = EventReason.HumanizeId(Id);
string displayId = ResolveWorldLogDisplayId(Id);
if (string.IsNullOrEmpty(displayId))
{
displayId = (Id ?? "").Trim();
@ -35,6 +35,52 @@ public sealed class WorldLogEventEntry
return CollapseSparseLabel(label, displayId);
}
private static string ResolveWorldLogDisplayId(string id)
{
if (string.IsNullOrEmpty(id))
{
return "";
}
try
{
WorldLogAsset asset = AssetManager.world_log_library?.get(id.Trim());
if (asset != null)
{
// Prefer live locale helpers when present (shape varies by WorldBox build).
string translated = "";
try
{
var mi = asset.GetType().GetMethod(
"getTranslatedName",
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic);
if (mi != null)
{
translated = mi.Invoke(asset, null) as string ?? "";
}
}
catch
{
translated = "";
}
if (!string.IsNullOrWhiteSpace(translated)
&& !string.Equals(translated.Trim(), id.Trim(), StringComparison.OrdinalIgnoreCase))
{
return translated.Trim();
}
}
}
catch
{
// fall through to humanize
}
return EventReason.HumanizeId(id);
}
public string MakeLabel(WorldLogMessage message)
{
if (message == null)

View file

@ -18,7 +18,8 @@ public static class LiveLibraryInterest
HashSet<string> signalIds,
float signalStrength,
Func<string, bool> signalPredicate = null,
bool ambientCreatesInterest = true)
bool ambientCreatesInterest = true,
string libraryKind = "")
{
if (entries == null || enumerateLive == null)
{
@ -51,7 +52,8 @@ public static class LiveLibraryInterest
EventStrength = signal ? signalStrength : ambientStrength,
Category = category,
CreatesInterest = signal || ambientCreatesInterest,
LabelTemplate = labelTemplate
LabelTemplate = labelTemplate,
LibraryKind = libraryKind ?? ""
};
}
}
@ -62,7 +64,8 @@ public static class LiveLibraryInterest
string category,
string labelTemplate,
float fallbackStrength = 40f,
bool fallbackCreatesInterest = false)
bool fallbackCreatesInterest = false,
string libraryKind = "")
{
if (!string.IsNullOrEmpty(id) && entries != null && entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
@ -77,6 +80,7 @@ public static class LiveLibraryInterest
Category = category,
LabelTemplate = labelTemplate,
CreatesInterest = fallbackCreatesInterest,
LibraryKind = libraryKind ?? "",
IsFallback = true
};
}

View file

@ -16,6 +16,10 @@ public sealed class DiscreteEventEntry
/// (e.g. <c>change the kingdom's culture</c> → <c>{a} decides to …</c>).
/// </summary>
public string ActionPhrase = "";
/// <summary>
/// When set, <c>{id}</c> resolves via <see cref="LibraryAssetNames"/> for this domain.
/// </summary>
public string LibraryKind = "";
public bool IsFallback;
public string MakeLabel(Actor a, Actor b = null)
@ -29,7 +33,12 @@ public sealed class DiscreteEventEntry
return (string.IsNullOrEmpty(an) ? "Someone" : an) + " casts " + obj;
}
return EventReason.Apply(LabelTemplate, a, b, Id);
string displayId = ResolveIdDisplay();
string t = string.IsNullOrEmpty(template) ? "{a}" : template;
return t
.Replace("{id}", displayId)
.Replace("{a}", EventFeedUtil.SafeName(a) ?? "")
.Replace("{b}", EventFeedUtil.SafeName(b) ?? "");
}
public string MakeLabel(string a, string b)
@ -41,10 +50,30 @@ public sealed class DiscreteEventEntry
return an + " casts " + EventReason.SpellCastObject(Id);
}
string displayId = string.IsNullOrEmpty(Id) ? "" : EventReason.HumanizeId(Id);
string displayId = ResolveIdDisplay();
return template
.Replace("{id}", displayId)
.Replace("{a}", a ?? "")
.Replace("{b}", b ?? "");
}
/// <summary>Localized library name when <see cref="LibraryKind"/> is set; else humanized id.</summary>
public string ResolveIdDisplay()
{
if (string.IsNullOrEmpty(Id))
{
return "";
}
if (!string.IsNullOrEmpty(LibraryKind))
{
string named = LibraryAssetNames.DisplayName(LibraryKind, Id);
if (!string.IsNullOrEmpty(named))
{
return named;
}
}
return EventReason.HumanizeId(Id);
}
}

View file

@ -13,7 +13,8 @@ public static class InterestFeeds
private static ulong _happinessCursor;
private static readonly List<HappinessOccurrence> HappinessDrain = new List<HappinessOccurrence>(64);
private static float _lastScannerAt = -999f;
private const float ScannerInterval = 0.75f;
private const float ScannerInterval = 1.25f;
private static int _tickParity;
// Civic aggregate boosts keyed by kingdom+effect.
private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32);
@ -107,8 +108,12 @@ public static class InterestFeeds
public static void Tick()
{
DrainHappiness();
TickScanner();
PruneCivicBoosts();
// Stagger battle/character scanner off the happiness drain frame.
if ((_tickParity++ & 1) == 0)
{
TickScanner();
}
}
public static void OnWorldLogMessage(WorldLogMessage message)

View file

@ -10,7 +10,7 @@ public static class PlotEventPatches
[HarmonyPostfix]
public static void PostfixNewPlot(Plot __result)
{
if (__result == null)
if (__result == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -18,13 +18,7 @@ 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");
}
LifeSagaMemory.RecordPlotMembers(__result, finished: false, provenance: "newPlot");
}
catch
{
@ -36,19 +30,35 @@ public static class PlotEventPatches
[HarmonyPostfix]
public static void PostfixCancelPlot(Plot pPlot)
{
if (pPlot == null)
if (pPlot == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
PlotInterestFeed.EmitFromPlot(pPlot, "cancel");
try
{
LifeSagaMemory.RecordPlotMembers(pPlot, finished: true, provenance: "cancelPlot");
}
catch
{
try
{
string plotKey = pPlot.getID().ToString();
LifeSagaMemory.ResolvePlotJoins(plotKey);
}
catch
{
// ignore
}
}
}
[HarmonyPatch(typeof(Actor), nameof(Actor.setPlot))]
[HarmonyPostfix]
public static void PostfixSetPlot(Actor __instance, Plot pObject)
{
if (__instance == null || !__instance.isAlive() || pObject == null)
if (__instance == null || !__instance.isAlive() || pObject == null || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -56,9 +66,28 @@ public static class PlotEventPatches
PlotInterestFeed.EmitFromPlot(pObject, "join");
try
{
// Joiner only - full member snapshot on new/finish/cancel.
// Re-recording every co-member on each setPlot was O(joins^2) on busy plots.
string plotKey = pObject.getID().ToString();
string assetId = ReadPlotAssetId(pObject);
LifeSagaMemory.RecordPlot(__instance, plotKey, assetId, finished: false, provenance: "setPlot");
Actor author = null;
try
{
author = pObject.getAuthor();
}
catch
{
author = null;
}
Actor other = author != null && author != __instance ? author : null;
LifeSagaMemory.RecordPlot(
__instance,
plotKey,
assetId,
finished: false,
provenance: "setPlot",
other: other);
}
catch
{
@ -71,6 +100,11 @@ public static class PlotEventPatches
public static void PrefixLeavePlot(Actor __instance, out string __state)
{
__state = "";
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
try
{
object plot = __instance?.GetType().GetField("plot")?.GetValue(__instance)
@ -78,6 +112,29 @@ public static class PlotEventPatches
if (plot != null)
{
__state = ReadPlotAssetId(plot);
try
{
string plotKey = "";
if (plot is Plot typed)
{
plotKey = typed.getID().ToString();
}
if (!string.IsNullOrEmpty(plotKey))
{
// Leaving resolves this actor's join; full cancel/finish still resolves all.
LifeSagaMemory.RecordPlot(
__instance,
plotKey,
__state,
finished: true,
provenance: "leavePlot");
}
}
catch
{
// ignore
}
}
}
catch
@ -90,7 +147,8 @@ public static class PlotEventPatches
[HarmonyPostfix]
public static void PostfixLeavePlot(Actor __instance, string __state)
{
if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(__state))
if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(__state)
|| !AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -102,7 +160,7 @@ public static class PlotEventPatches
[HarmonyPostfix]
public static void PostfixFinishPlot(Plot __instance, PlotState pState, Actor pActor)
{
if (__instance == null || pState != PlotState.Finished)
if (__instance == null || pState != PlotState.Finished || !AgentHarness.LiveFeedsAllowed)
{
return;
}
@ -114,37 +172,20 @@ public static class PlotEventPatches
if (string.IsNullOrEmpty(assetId))
{
PlotInterestFeed.EmitFromPlot(__instance, "complete");
return;
}
PlotInterestFeed.Emit(assetId, pActor, "complete");
try
else
{
string plotKey = __instance.getID().ToString();
LifeSagaMemory.RecordPlot(pActor, plotKey, assetId, finished: true, provenance: "finishPlot");
PlotInterestFeed.Emit(assetId, pActor, "complete");
}
catch
{
// ignore
}
return;
}
else
{
PlotInterestFeed.EmitFromPlot(__instance, "complete");
}
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");
}
LifeSagaMemory.RecordPlotMembers(__instance, finished: true, provenance: "finishPlot");
}
catch
{

View file

@ -18,40 +18,7 @@ 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");
}
NoteWarPrincipals(__result, ended: false, provenance: "newWar");
}
catch
{
@ -71,37 +38,9 @@ public static class WarEventPatches
try
{
WarInterestFeed.EmitFromWarObject(__args[0], phase: "end");
object war = __args[0];
string warKey = "";
try
if (__args[0] is War endedWar)
{
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");
}
NoteWarPrincipals(endedWar, ended: true, provenance: "endWar");
}
}
catch
@ -109,4 +48,94 @@ public static class WarEventPatches
// ignore
}
}
private static void NoteWarPrincipals(War war, bool ended, string provenance)
{
if (war == null)
{
return;
}
string warKey = "";
try
{
warKey = war.getID().ToString();
}
catch
{
warKey = "";
}
if (string.IsNullOrEmpty(warKey))
{
return;
}
Kingdom attacker = null;
Kingdom defender = null;
try
{
attacker = war.main_attacker;
defender = war.main_defender;
}
catch
{
attacker = null;
defender = null;
}
Actor attackKing = LiveEnsemble.PreferKingdomLeader(attacker);
Actor defendKing = LiveEnsemble.PreferKingdomLeader(defender);
if (attackKing == null)
{
try
{
attackKing = attacker?.king;
}
catch
{
attackKing = null;
}
}
if (defendKing == null)
{
try
{
defendKing = defender?.king;
}
catch
{
defendKing = null;
}
}
string attackName = KingdomName(attacker);
string defendName = KingdomName(defender);
LifeSagaMemory.RecordWarPrincipals(
attackKing,
defendKing,
warKey,
attackName,
defendName,
ended,
provenance);
}
private static string KingdomName(Kingdom kingdom)
{
if (kingdom == null)
{
return "";
}
try
{
return kingdom.name ?? "";
}
catch
{
return "";
}
}
}

View file

@ -71,6 +71,12 @@ internal static class HarnessScenarios
case "story_aftermath_combat":
case "story_aftermath":
return StoryAftermathCombat();
case "story_aftermath_prefers_mc":
return StoryAftermathPrefersMc();
case "story_recover_follow_mc":
return StoryRecoverFollowMc();
case "story_park_trim_keeps_mc":
return StoryParkTrimKeepsMc();
case "story_aftermath_cast_noise":
case "soft_duel_aftermath_noise":
return StoryAftermathCastNoise();
@ -163,6 +169,8 @@ internal static class HarnessScenarios
return SagaCastSoftBias();
case "saga_cross_mc_light":
return SagaCrossMcLight();
case "saga_cross_mc_wins_gap":
return SagaCrossMcWinsGap();
case "saga_cross_mc_kill":
return SagaCrossMcKill();
case "saga_rival_not_single_kill":
@ -180,6 +188,16 @@ internal static class HarnessScenarios
return SagaAdaptiveDossier();
case "saga_memory_survives":
return SagaMemorySurvives();
case "saga_war_memory_both_sides":
return SagaWarMemoryBothSides();
case "saga_plot_cast_members":
return SagaPlotCastMembers();
case "saga_cast_kin_non_pack_lens":
return SagaCastKinNonPackLens();
case "saga_stake_from_role_live":
return SagaStakeFromRoleLive();
case "saga_session_reset":
return SagaSessionReset();
case "saga_hover_read_pause":
return SagaHoverReadPause();
case "saga_admit_roles":
@ -355,6 +373,9 @@ internal static class HarnessScenarios
Nested("reg_combat_hold", "combat_hold_until_end"),
Nested("reg_combat_stability", "combat_stability_live"),
Nested("reg_story_aftermath", "story_aftermath_combat"),
Nested("reg_story_aftermath_mc", "story_aftermath_prefers_mc"),
Nested("reg_story_recover_mc", "story_recover_follow_mc"),
Nested("reg_story_park_trim_mc", "story_park_trim_keeps_mc"),
Nested("reg_story_aftermath_noise", "story_aftermath_cast_noise"),
Nested("reg_story_aftermath_partner_truth", "story_aftermath_partner_truth"),
Nested("reg_reason_duel_principal", "reason_duel_principal"),
@ -385,6 +406,7 @@ internal static class HarnessScenarios
Nested("reg_saga_combat_follow", "saga_combat_follow_mc"),
Nested("reg_saga_cast_bias", "saga_cast_soft_bias"),
Nested("reg_saga_cross_light", "saga_cross_mc_light"),
Nested("reg_saga_cross_gap", "saga_cross_mc_wins_gap"),
Nested("reg_saga_cross_kill", "saga_cross_mc_kill"),
Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
@ -393,6 +415,11 @@ internal static class HarnessScenarios
Nested("reg_saga_overview", "saga_overview_has_mc"),
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
Nested("reg_saga_memory", "saga_memory_survives"),
Nested("reg_saga_war_memory", "saga_war_memory_both_sides"),
Nested("reg_saga_plot_cast", "saga_plot_cast_members"),
Nested("reg_saga_cast_kin", "saga_cast_kin_non_pack_lens"),
Nested("reg_saga_stake_role", "saga_stake_from_role_live"),
Nested("reg_saga_session_reset", "saga_session_reset"),
Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
Nested("reg_saga_diversity", "saga_roster_diversity"),
Nested("reg_saga_death", "saga_replace_on_death"),
@ -1761,6 +1788,114 @@ internal static class HarnessScenarios
};
}
/// <summary>Aftermath follow prefers a roster MC among living theater candidates.</summary>
private static List<HarnessCommand> StoryAftermathPrefersMc()
{
return new List<HarnessCommand>
{
Step("sam0", "dismiss_windows"),
Step("sam1", "wait_world"),
Step("sam2", "set_setting", expect: "enabled", value: "true"),
Step("sam3", "fast_timing", value: "true"),
Step("sam4", "spawn", asset: "human", count: 1),
Step("sam5", "spawn", asset: "wolf", count: 1),
Step("sam6", "spectator", value: "off"),
Step("sam7", "spectator", value: "on"),
Step("sam8", "pick_unit", asset: "human"),
Step("sam9", "focus", asset: "human"),
Step("sam10", "interest_end_session"),
Step("sam11", "interest_story_clear"),
Step("sam12", "interest_saga_clear"),
Step("sam13", "saga_force_admit_focus"),
Step("sam14", "assert", expect: "saga_has_focus"),
Step("sam15", "interest_expire_pending", value: ""),
Step("sam20", "interest_combat_session", asset: "human", value: "wolf", expect: "sam_pack"),
Step("sam21", "combat_isolate_pair", value: "20"),
Step("sam22", "status_apply", asset: "human", value: "invincible"),
Step("sam23", "assert", expect: "tip_matches_any", value: "Duel -"),
Step("sam24", "combat_kill_related"),
Step("sam25", "age_current", wait: 10f),
Step("sam26", "interest_mark_combat_cold"),
Step("sam27", "director_run", wait: 1.5f),
Step("sam28", "assert", expect: "tip_asset", value: "aftermath_"),
Step("sam29", "assert", expect: "story_phase", value: "Aftermath"),
Step("sam30", "assert", expect: "saga_has_focus"),
Step("sam90", "fast_timing", value: "false"),
Step("sam99", "snapshot"),
};
}
/// <summary>Cast recovery after follow drop prefers a living roster MC on the arc.</summary>
private static List<HarnessCommand> StoryRecoverFollowMc()
{
return new List<HarnessCommand>
{
Step("srm0", "dismiss_windows"),
Step("srm1", "wait_world"),
Step("srm2", "set_setting", expect: "enabled", value: "true"),
Step("srm3", "fast_timing", value: "true"),
Step("srm4", "spawn", asset: "human", count: 1),
Step("srm5", "spawn", asset: "wolf", count: 1),
Step("srm6", "spectator", value: "off"),
Step("srm7", "spectator", value: "on"),
Step("srm8", "pick_unit", asset: "human"),
Step("srm9", "focus", asset: "human"),
Step("srm10", "interest_end_session"),
Step("srm11", "interest_story_clear"),
Step("srm12", "interest_saga_clear"),
Step("srm13", "saga_force_admit_focus"),
Step("srm14", "interest_expire_pending", value: ""),
Step("srm20", "interest_combat_session", asset: "human", value: "wolf", expect: "srm_pack"),
Step("srm21", "combat_isolate_pair", value: "20"),
Step("srm22", "status_apply", asset: "human", value: "invincible"),
Step("srm23", "combat_kill_related"),
Step("srm24", "age_current", wait: 10f),
Step("srm25", "interest_mark_combat_cold"),
Step("srm26", "director_run", wait: 1.5f),
Step("srm27", "assert", expect: "tip_asset", value: "aftermath_"),
Step("srm28", "interest_drop_follow"),
Step("srm29", "director_run", wait: 0.8f),
Step("srm30", "assert", expect: "tip_asset", value: "aftermath_"),
Step("srm31", "assert", expect: "story_phase", value: "Aftermath"),
Step("srm32", "assert", expect: "saga_has_focus"),
Step("srm90", "fast_timing", value: "false"),
Step("srm99", "snapshot"),
};
}
/// <summary>
/// Parked-board trim keeps Prefer/MC arcs when over cap (anonymous scraps drop first).
/// </summary>
private static List<HarnessCommand> StoryParkTrimKeepsMc()
{
return new List<HarnessCommand>
{
Step("spt0", "dismiss_windows"),
Step("spt1", "wait_world"),
Step("spt2", "set_setting", expect: "enabled", value: "true"),
Step("spt3", "fast_timing", value: "true"),
Step("spt4", "spawn", asset: "human", count: 2),
Step("spt5", "spectator", value: "off"),
Step("spt6", "spectator", value: "on"),
Step("spt7", "pick_unit", asset: "human"),
Step("spt8", "focus", asset: "human"),
Step("spt9", "interest_end_session"),
Step("spt10", "interest_story_clear"),
Step("spt11", "interest_saga_clear"),
Step("spt12", "saga_force_admit_focus"),
Step("spt20", "interest_war_session", asset: "human", expect: "spt_war_mc"),
Step("spt21", "director_run", wait: 0.6f),
Step("spt22", "assert", expect: "story_phase", value: "Climax"),
Step("spt23", "interest_force_session", asset: "human",
label: "ParkAway", tier: "Epic", expect: "spt_park",
value: "lead=event;evt=150"),
Step("spt24", "assert", expect: "story_parked_count", value: "1", label: "min"),
Step("spt25", "assert", expect: "saga_has_focus", value: "true"),
Step("spt90", "fast_timing", value: "false"),
Step("spt99", "snapshot"),
};
}
private static List<HarnessCommand> StoryAftermathWar()
{
return new List<HarnessCommand>
@ -3112,6 +3247,43 @@ internal static class HarnessScenarios
};
}
/// <summary>
/// Two roster MCs on one tip get CrossMC SoftBias + cross bonus and Involved chrome;
/// a hotter stranger does not displace the live cross tip.
/// </summary>
private static List<HarnessCommand> SagaCrossMcWinsGap()
{
return new List<HarnessCommand>
{
Step("scg0", "dismiss_windows"),
Step("scg1", "wait_world"),
Step("scg2", "set_setting", expect: "enabled", value: "true"),
Step("scg3", "fast_timing", value: "true"),
Step("scg4", "spawn", asset: "human", count: 3),
Step("scg5", "spectator", value: "off"),
Step("scg6", "spectator", value: "on"),
Step("scg7", "pick_unit", asset: "human"),
Step("scg8", "happiness_remember_partner"),
Step("scg9", "pick_unit", asset: "human", value: "other"),
Step("scg10", "focus", asset: "human"),
Step("scg11", "interest_end_session"),
Step("scg12", "interest_saga_clear"),
Step("scg13", "saga_force_admit_focus"),
Step("scg14", "saga_force_admit_partner"),
Step("scg15", "interest_expire_pending", value: ""),
Step("scg16", "interest_story_purge_leftovers"),
Step("scg20", "interest_inject", asset: "human", label: "CrossGapWin", tier: "Action",
expect: "scg_cross",
value: "lead=event;evt=55;char=5;force=true;ttl=60;related=partner"),
Step("scg21", "assert", expect: "tip_contains", value: "CrossGapWin"),
Step("scg22", "assert", expect: "saga_soft_bias_rank", value: "3", label: "min"),
Step("scg23", "assert", expect: "saga_score_bonus_min", value: "20"),
Step("scg24", "assert", expect: "saga_rail_involved_count", value: "1", label: "min"),
Step("scg90", "fast_timing", value: "false"),
Step("scg99", "snapshot"),
};
}
/// <summary>MC kills MC → both memories hold McCrossover facts.</summary>
private static List<HarnessCommand> SagaCrossMcKill()
{
@ -3408,6 +3580,161 @@ internal static class HarnessScenarios
};
}
/// <summary>WarJoin snapshots both principals with Other ids and resolves on end.</summary>
private static List<HarnessCommand> SagaWarMemoryBothSides()
{
return new List<HarnessCommand>
{
Step("swm0", "dismiss_windows"),
Step("swm1", "wait_world"),
Step("swm2", "set_setting", expect: "enabled", value: "true"),
Step("swm3", "fast_timing", value: "true"),
Step("swm4", "spawn", asset: "human", count: 2),
Step("swm5", "spectator", value: "off"),
Step("swm6", "spectator", value: "on"),
Step("swm7", "interest_saga_clear"),
Step("swm8", "pick_unit", asset: "human"),
Step("swm9", "happiness_remember_partner"),
Step("swm10", "pick_unit", asset: "human", value: "other"),
Step("swm11", "focus", asset: "human"),
Step("swm12", "saga_force_admit_focus"),
Step("swm13", "saga_force_admit_partner"),
Step("swm14", "saga_memory_record_focus", asset: "War", value: "North vs South",
expect: "harness_war", label: "North"),
Step("swm15", "assert", expect: "saga_memory_fact_focus", value: "WarJoin"),
Step("swm16", "assert", expect: "saga_memory_fact_other", value: "WarJoin"),
Step("swm17", "assert", expect: "saga_stake_contains", value: "War with|North vs South"),
Step("swm18", "saga_memory_resolve_war", value: "harness_war"),
Step("swm19", "assert", expect: "saga_memory_fact_resolved", value: "harness_war", label: "true"),
// Original partner from swm9 is the other principal - focus them directly.
Step("swm20", "focus_partner"),
Step("swm21", "assert", expect: "saga_memory_fact_other", value: "War", label: "nonzero"),
Step("swm22", "assert", expect: "saga_memory_fact_resolved", value: "harness_war", label: "true"),
Step("swm90", "fast_timing", value: "false"),
Step("swm99", "snapshot"),
};
}
/// <summary>Unresolved PlotJoin Other lands in Cast as Plot ally.</summary>
private static List<HarnessCommand> SagaPlotCastMembers()
{
return new List<HarnessCommand>
{
Step("spc0", "dismiss_windows"),
Step("spc1", "wait_world"),
Step("spc2", "set_setting", expect: "enabled", value: "true"),
Step("spc3", "fast_timing", value: "true"),
Step("spc4", "spawn", asset: "human", count: 2),
Step("spc5", "spectator", value: "off"),
Step("spc6", "spectator", value: "on"),
Step("spc7", "interest_saga_clear"),
Step("spc8", "pick_unit", asset: "human"),
Step("spc9", "happiness_remember_partner"),
Step("spc10", "pick_unit", asset: "human", value: "other"),
Step("spc11", "focus", asset: "human"),
Step("spc12", "saga_force_admit_focus"),
Step("spc13", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_plot"),
Step("spc14", "assert", expect: "saga_memory_fact_other", value: "PlotJoin"),
Step("spc15", "saga_select_tab", value: "saga"),
Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot ally"),
Step("spc17", "assert", expect: "saga_stake_contains", value: "Plot with|Joined"),
Step("spc90", "fast_timing", value: "false"),
Step("spc99", "snapshot"),
};
}
/// <summary>Crown lens still shows family Parent in Cast (peer fill not Pack-only).</summary>
private static List<HarnessCommand> SagaCastKinNonPackLens()
{
return new List<HarnessCommand>
{
Step("sck0", "dismiss_windows"),
Step("sck1", "wait_world"),
Step("sck2", "set_setting", expect: "enabled", value: "true"),
Step("sck3", "fast_timing", value: "true"),
Step("sck4", "spawn", asset: "human", count: 2),
Step("sck5", "spectator", value: "off"),
Step("sck6", "spectator", value: "on"),
Step("sck7", "interest_saga_clear"),
Step("sck8", "pick_unit", asset: "human"),
Step("sck9", "happiness_remember_partner"),
Step("sck10", "pick_unit", asset: "human", value: "other"),
Step("sck11", "focus", asset: "human"),
Step("sck12", "saga_force_admit_focus"),
Step("sck13", "saga_role_rise_focus", value: "become_king"),
Step("sck14", "relationship_set_parent"),
Step("sck15", "saga_select_tab", value: "saga"),
Step("sck16", "assert", expect: "saga_panel_lens", value: "true"),
Step("sck17", "assert", expect: "saga_stake_contains", value: "Crown|Became king"),
Step("sck18", "assert", expect: "saga_cast_contains", value: "Parent"),
Step("sck19", "saga_memory_record_focus", asset: "War", value: "North vs South",
expect: "harness_war_crown", label: "North"),
Step("sck20", "assert", expect: "saga_cast_contains", value: "War foe|Parent"),
Step("sck90", "fast_timing", value: "false"),
Step("sck99", "snapshot"),
};
}
/// <summary>Live roster role rise becomes RoleChange stake; combat is fallback when alone.</summary>
private static List<HarnessCommand> SagaStakeFromRoleLive()
{
return new List<HarnessCommand>
{
Step("srl0", "dismiss_windows"),
Step("srl1", "wait_world"),
Step("srl2", "set_setting", expect: "enabled", value: "true"),
Step("srl3", "fast_timing", value: "true"),
Step("srl4", "spawn", asset: "human", count: 1),
Step("srl5", "spectator", value: "off"),
Step("srl6", "spectator", value: "on"),
Step("srl7", "interest_saga_clear"),
Step("srl8", "pick_unit", asset: "human"),
Step("srl9", "focus", asset: "human"),
Step("srl10", "saga_force_admit_focus"),
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "A fight marked them"),
Step("srl12", "assert", expect: "saga_stake_contains", value: "fight marked|A fight"),
Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
Step("srl15", "assert", expect: "saga_stake_contains", value: "Became clan chief"),
Step("srl90", "fast_timing", value: "false"),
Step("srl99", "snapshot"),
};
}
/// <summary>
/// Session reset clears roster + memory even when World.world reference is unchanged.
/// Same-map spectator toggle must not wipe.
/// </summary>
private static List<HarnessCommand> SagaSessionReset()
{
return new List<HarnessCommand>
{
Step("ssr0", "dismiss_windows"),
Step("ssr1", "wait_world"),
Step("ssr2", "set_setting", expect: "enabled", value: "true"),
Step("ssr3", "fast_timing", value: "true"),
Step("ssr4", "spawn", asset: "human", count: 2),
Step("ssr5", "spectator", value: "off"),
Step("ssr6", "spectator", value: "on"),
Step("ssr7", "interest_saga_clear"),
Step("ssr8", "pick_unit", asset: "human"),
Step("ssr9", "focus", asset: "human"),
Step("ssr10", "saga_force_admit_focus"),
Step("ssr11", "saga_memory_record_focus", asset: "Kill", value: "Session foe"),
Step("ssr12", "assert", expect: "saga_roster_count", value: "1", label: "min"),
Step("ssr13", "assert", expect: "saga_memory_life_count", value: "1", label: "min"),
Step("ssr14", "spectator", value: "off"),
Step("ssr15", "spectator", value: "on"),
Step("ssr16", "assert", expect: "saga_roster_count", value: "1", label: "min"),
Step("ssr17", "assert", expect: "saga_memory_life_count", value: "1", label: "min"),
Step("ssr18", "saga_session_reset"),
Step("ssr19", "assert", expect: "saga_roster_count", value: "0"),
Step("ssr20", "assert", expect: "saga_memory_life_count", value: "0"),
Step("ssr90", "fast_timing", value: "false"),
Step("ssr99", "snapshot"),
};
}
/// <summary>Hover acquires read-pause; camera owner stays fixed until release.</summary>
private static List<HarnessCommand> SagaHoverReadPause()
{

View file

@ -0,0 +1,191 @@
using System.Diagnostics;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Lightweight idle hitch sampler. Logs when a frame or idle subsystem exceeds thresholds.
/// Enable via harness <c>hitch_probe</c> or temporary always-on while diagnosing FPS hitching.
/// </summary>
public static class IdleHitchProbe
{
public static bool Enabled { get; private set; }
/// <summary>Frame delta (unscaled) above this counts as a hitch candidate.</summary>
public static float FrameSpikeSeconds { get; set; } = 0.028f;
/// <summary>Idle subsystem slice above this is attributed in the log.</summary>
public static float SliceSpikeMs { get; set; } = 4f;
private static readonly Stopwatch Sw = new Stopwatch();
private static float _directorMs;
private static float _discoveryMs;
private static float _captionMs;
private static float _otherMs;
private static string _hotMark = "";
private static float _hotMarkMs;
private static float _markStartedAt;
private static string _markName = "";
private static float _nextSummaryAt;
private static int _spikes;
private static float _maxDt;
private static float _maxDirectorMs;
private static float _maxCaptionMs;
private static float _maxDiscoveryMs;
public static void SetEnabled(bool on)
{
Enabled = on;
if (on)
{
ResetStats();
LogService.LogInfo(
$"[IdleSpectator][HITCH] probe on frame>={FrameSpikeSeconds * 1000f:0}ms slice>={SliceSpikeMs:0.#}ms");
}
else
{
LogService.LogInfo($"[IdleSpectator][HITCH] probe off spikes={_spikes}");
}
}
public static void ResetStats()
{
_spikes = 0;
_maxDt = 0f;
_maxDirectorMs = 0f;
_maxCaptionMs = 0f;
_maxDiscoveryMs = 0f;
_nextSummaryAt = Time.unscaledTime + 5f;
}
public static void BeginSlice()
{
if (!Enabled)
{
return;
}
Sw.Restart();
}
public static void Mark(string name)
{
if (!Enabled || string.IsNullOrEmpty(name))
{
return;
}
float nowMs = Time.realtimeSinceStartup * 1000f;
if (!string.IsNullOrEmpty(_markName))
{
float elapsed = nowMs - _markStartedAt;
if (elapsed > _hotMarkMs)
{
_hotMarkMs = elapsed;
_hotMark = _markName + "->" + name;
}
}
_markName = name;
_markStartedAt = nowMs;
}
public static void EndDirector()
{
if (!Enabled)
{
return;
}
Sw.Stop();
_directorMs = (float)Sw.Elapsed.TotalMilliseconds;
_markName = "";
}
public static void EndDiscovery()
{
if (!Enabled)
{
return;
}
Sw.Stop();
_discoveryMs = (float)Sw.Elapsed.TotalMilliseconds;
}
public static void EndCaption()
{
if (!Enabled)
{
return;
}
Sw.Stop();
_captionMs = (float)Sw.Elapsed.TotalMilliseconds;
}
public static void EndOther()
{
if (!Enabled)
{
return;
}
Sw.Stop();
_otherMs = (float)Sw.Elapsed.TotalMilliseconds;
}
public static void AfterFrame()
{
if (!Enabled)
{
return;
}
float dt = Time.unscaledDeltaTime;
_maxDt = Mathf.Max(_maxDt, dt);
_maxDirectorMs = Mathf.Max(_maxDirectorMs, _directorMs);
_maxCaptionMs = Mathf.Max(_maxCaptionMs, _captionMs);
_maxDiscoveryMs = Mathf.Max(_maxDiscoveryMs, _discoveryMs);
bool frameSpike = dt >= FrameSpikeSeconds;
bool sliceSpike = _directorMs >= SliceSpikeMs
|| _captionMs >= SliceSpikeMs
|| _discoveryMs >= SliceSpikeMs;
if (frameSpike || sliceSpike)
{
_spikes++;
string idle = SpectatorMode.Active ? "on" : "off";
LogService.LogInfo(
$"[IdleSpectator][HITCH] spike idle={idle} dt={dt * 1000f:0.0}ms "
+ $"dir={_directorMs:0.0}ms disc={_discoveryMs:0.0}ms cap={_captionMs:0.0}ms "
+ $"other={_otherMs:0.0}ms roster={LifeSagaRoster.Count} pending={InterestRegistry.PendingCount} "
+ $"scanMs={LifeSagaRoster.LastWorldScanMs:0.0} softMs={LifeSagaRoster.LastSoftRefillMs:0.0} "
+ $"hot={_hotMark}({_hotMarkMs:0.0}ms)");
_hotMark = "";
_hotMarkMs = 0f;
}
float now = Time.unscaledTime;
if (now >= _nextSummaryAt)
{
_nextSummaryAt = now + 5f;
LogService.LogInfo(
$"[IdleSpectator][HITCH] summary idle={(SpectatorMode.Active ? "on" : "off")} "
+ $"spikes={_spikes} maxDt={_maxDt * 1000f:0.0}ms "
+ $"maxDir={_maxDirectorMs:0.0}ms maxDisc={_maxDiscoveryMs:0.0}ms maxCap={_maxCaptionMs:0.0}ms "
+ $"roster={LifeSagaRoster.Count}");
_spikes = 0;
_maxDt = 0f;
_maxDirectorMs = 0f;
_maxCaptionMs = 0f;
_maxDiscoveryMs = 0f;
}
_directorMs = 0f;
_discoveryMs = 0f;
_captionMs = 0f;
_otherMs = 0f;
}
}

View file

@ -593,6 +593,7 @@ public static partial class InterestDirector
LifeSagaMemory.NoteCombatEncounter(best, foe, "sticky_combat");
}
LifeSagaRoster.NoteFeaturedCrossMcPrincipals(_current);
return true;
}
@ -688,6 +689,8 @@ public static partial class InterestDirector
"war_front_maintain");
}
LifeSagaRoster.NoteFeaturedCrossMcPrincipals(_current);
string label = EventReason.WarFront(held);
if (string.IsNullOrEmpty(label))
{
@ -823,6 +826,8 @@ public static partial class InterestDirector
"plot_cell_maintain");
}
LifeSagaRoster.NoteFeaturedCrossMcPrincipals(_current);
// Plot stickies are groups only - never "Plot - Plotters (1)" / (0).
int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
if (plotters < 2)

View file

@ -632,16 +632,20 @@ public static partial class InterestDirector
if (now - _lastFeedsAt >= _feedsTick)
{
_lastFeedsAt = now;
IdleHitchProbe.Mark("feeds");
InterestFeeds.Tick();
InterestRegistry.ExpireStale(now);
IdleHitchProbe.Mark("feeds_done");
}
float onCurrent = now - _currentStartedAt;
float sinceSwitch = now - _lastSwitchAt;
IdleHitchProbe.Mark("story");
StoryPlanner.Tick(now);
LifeSagaRoster.Tick(now);
LifeSagaMemory.EnsureWorldBound();
LifeSagaSession.EnsureBound();
IdleHitchProbe.Mark("story_done");
if (ReadPauseActive)
{
@ -662,7 +666,9 @@ public static partial class InterestDirector
MaintainFamilyPack(now, force: false);
MaintainStatusOutbreak(now, force: false);
IdleHitchProbe.Mark("select");
InterestCandidate next = SelectNext(now);
IdleHitchProbe.Mark("select_done");
if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch))
{
if (!InterestScoring.IsFillScore(next.TotalScore))
@ -2022,7 +2028,7 @@ public static partial class InterestDirector
return null;
}
InterestScoring.EnrichTopK(PendingScratch);
// EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here.
return InterestVariety.Pick(PendingScratch, _current);
}

View file

@ -54,7 +54,7 @@ public class StoryWeights
/// <summary>Soft bonus when tip touches an MC's story cast (lover/friend/kin/rival).</summary>
public float sagaCastWeight = 5f;
/// <summary>Extra tip bonus when subject/related/follow touch two or more roster MCs.</summary>
public float sagaCrossMcBonus = 6f;
public float sagaCrossMcBonus = 11f;
/// <summary>Living rematch count before EarnRival (kill/death do not count).</summary>
public int sagaRivalEncounterThreshold = 3;
/// <summary>Chapter starvation window before dull non-favorite MCs lose rank.</summary>

View file

@ -754,7 +754,7 @@ public static class InterestVariety
}
/// <summary>
/// Prefer (3) > MC (2) > MC cast (1) among candidates within <paramref name="band"/> of #1.
/// Prefer (4) > CrossMC (3) > MC (2) > MC cast (1) among candidates within <paramref name="band"/> of #1.
/// </summary>
private static InterestCandidate PickBestSagaInBand(
List<InterestCandidate> ranked,

View file

@ -56,6 +56,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
_config = ModSettings.Load(pModDecl);
InterestScoringConfig.LoadFromModFolder(ModFolder);
EventCatalogConfig.LoadFromModFolder(ModFolder);
TryArmAutoloadSlot(pModDecl);
_harmony = new Harmony(pModDecl.UID);
ApplyHarmonyPatches(pModDecl.Name);
@ -94,23 +95,73 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
}
}
/// <summary>
/// Dev/agent helper: <c>.harness/autoload_slot</c> containing a slot number (1-5)
/// sets Config.load_save_on_start so Box of Magic (slot 2) can come up after reboot.
/// </summary>
private static void TryArmAutoloadSlot(ModDeclare declare)
{
try
{
string path = Path.Combine(declare.FolderPath, ".harness", "autoload_slot");
if (!File.Exists(path))
{
return;
}
string raw = File.ReadAllText(path).Trim();
if (!int.TryParse(raw, out int slot) || slot < 1 || slot > 5)
{
return;
}
var slotFi = AccessTools.Field(typeof(Config), "load_save_on_start_slot");
var onFi = AccessTools.Field(typeof(Config), "load_save_on_start");
if (slotFi == null || onFi == null)
{
return;
}
slotFi.SetValue(null, slot);
onFi.SetValue(null, true);
LogService.LogInfo($"[{declare.Name}]: autoload_slot armed slot={slot}");
}
catch (Exception ex)
{
LogService.LogInfo($"[{declare.Name}]: autoload_slot failed: {ex.Message}");
}
}
private void Update()
{
AgentHarness.Update();
SpectatorMode.PollInput();
Chronicle.PollInput();
IdleHitchProbe.BeginSlice();
InterestDirector.Update();
IdleHitchProbe.EndDirector();
IdleHitchProbe.BeginSlice();
if (!AgentHarness.FreezeDirector)
{
SpeciesDiscovery.Update();
}
IdleHitchProbe.EndDiscovery();
IdleHitchProbe.BeginSlice();
WatchCaption.Update();
IdleHitchProbe.EndCaption();
IdleHitchProbe.BeginSlice();
Chronicle.Update();
GraveMarkers.Update();
InspectUi.Update();
FocusRelationshipArrows.PinFocusActor();
StateProbe.Update();
AutoTest.Update();
IdleHitchProbe.EndOther();
IdleHitchProbe.AfterFrame();
}
}

View file

@ -40,24 +40,13 @@ public static class LifeSagaMemory
public static void EnsureWorldBound()
{
object world = null;
try
{
world = World.world;
}
catch
{
world = null;
}
LifeSagaSession.EnsureBound();
}
if (ReferenceEquals(world, _boundWorld))
{
return;
}
ClearSession();
/// <summary>Called by <see cref="LifeSagaSession"/> after a session reset.</summary>
public static void NoteBoundWorld(object world)
{
_boundWorld = world;
_worldEpoch++;
}
public static void ClearSession()
@ -66,6 +55,7 @@ public static class LifeSagaMemory
PairEncounters.Clear();
PairEncounterCooldown.Clear();
_boundWorld = null;
_worldEpoch++;
}
public static void Clear() => ClearSession();
@ -217,6 +207,14 @@ public static class LifeSagaMemory
if (other.Id != 0 && existing.Other.Id == 0)
{
existing.Other = other;
existing.OtherId = other.Id;
}
// Keep join/end kinds aligned when the same correlation key is reused.
if (kind == LifeSagaFactKind.WarEnd || kind == LifeSagaFactKind.PlotEnd)
{
existing.Kind = kind;
existing.Resolved = true;
}
life.TouchedAt = now;
@ -614,7 +612,9 @@ public static class LifeSagaMemory
string warKey,
string kingdomKey,
bool ended,
string provenance = "war")
string provenance = "war",
Actor other = null,
string note = "")
{
if (subject == null || string.IsNullOrEmpty(warKey))
{
@ -624,13 +624,82 @@ public static class LifeSagaMemory
Record(
ended ? LifeSagaFactKind.WarEnd : LifeSagaFactKind.WarJoin,
subject,
null,
other,
correlationKey: "war:" + warKey + ":" + EventFeedUtil.SafeId(subject),
strength: ended ? 70f : 78f,
provenance: provenance,
kingdomKey: kingdomKey,
warKey: warKey,
resolved: ended);
resolved: ended,
note: note ?? "");
}
/// <summary>
/// Snapshot both kingdom principals for a war tip/object. On end, resolve all open joins for the war key.
/// </summary>
public static void RecordWarPrincipals(
Actor attackerPrincipal,
Actor defenderPrincipal,
string warKey,
string attackerKingdom,
string defenderKingdom,
bool ended,
string provenance = "war")
{
if (string.IsNullOrEmpty(warKey))
{
return;
}
string pairNote = FormatWarPairNote(attackerKingdom, defenderKingdom);
if (attackerPrincipal != null)
{
try
{
if (attackerPrincipal.isAlive())
{
RecordWar(
attackerPrincipal,
warKey,
attackerKingdom ?? "",
ended,
provenance,
other: defenderPrincipal,
note: pairNote);
}
}
catch
{
// skip dead/invalid
}
}
if (defenderPrincipal != null)
{
try
{
if (defenderPrincipal.isAlive())
{
RecordWar(
defenderPrincipal,
warKey,
defenderKingdom ?? "",
ended,
provenance,
other: attackerPrincipal,
note: pairNote);
}
}
catch
{
// skip dead/invalid
}
}
if (ended)
{
ResolveWarJoins(warKey);
}
}
public static void RecordPlot(
@ -638,25 +707,237 @@ public static class LifeSagaMemory
string plotKey,
string plotAsset,
bool finished,
string provenance = "plot")
string provenance = "plot",
Actor other = null)
{
if (subject == null || string.IsNullOrEmpty(plotKey))
{
return;
}
string display = ResolvePlotDisplay(plotAsset);
Record(
finished ? LifeSagaFactKind.PlotEnd : LifeSagaFactKind.PlotJoin,
subject,
null,
other,
correlationKey: "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject),
strength: finished ? 72f : 76f,
provenance: provenance,
plotKey: plotKey,
note: plotAsset ?? "",
note: display,
resolved: finished);
}
/// <summary>
/// Snapshot author + living co-members. Joiners point Other at the author; author points at first peer.
/// Finish/cancel resolves every open join for the plot key.
/// </summary>
public static void RecordPlotMembers(
Plot plot,
bool finished,
string provenance = "plot")
{
if (plot == null)
{
return;
}
string plotKey = "";
try
{
plotKey = plot.getID().ToString();
}
catch
{
plotKey = "";
}
if (string.IsNullOrEmpty(plotKey))
{
return;
}
string assetId = "";
try
{
object asset = plot.GetType().GetField("asset")?.GetValue(plot)
?? plot.GetType().GetProperty("asset")?.GetValue(plot, null);
if (asset != null)
{
object id = asset.GetType().GetField("id")?.GetValue(asset)
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
assetId = id != null ? id.ToString() : "";
}
}
catch
{
assetId = "";
}
Actor author = null;
try
{
author = plot.getAuthor();
}
catch
{
author = null;
}
var members = new List<Actor>(8);
LiveEnsemble.CollectPlotUnits(plot, members);
if (author != null && author.isAlive() && !members.Contains(author))
{
members.Insert(0, author);
}
Actor peerForAuthor = null;
for (int i = 0; i < members.Count; i++)
{
Actor m = members[i];
if (m == null || m == author)
{
continue;
}
try
{
if (m.isAlive())
{
peerForAuthor = m;
break;
}
}
catch
{
// skip
}
}
for (int i = 0; i < members.Count; i++)
{
Actor member = members[i];
if (member == null)
{
continue;
}
try
{
if (!member.isAlive())
{
continue;
}
}
catch
{
continue;
}
Actor other = member == author ? peerForAuthor : author;
RecordPlot(member, plotKey, assetId, finished, provenance, other);
}
if (finished)
{
ResolvePlotJoins(plotKey);
}
}
public static void ResolveWarJoins(string warKey)
{
ResolveJoinsByKey(warKey, plot: false);
}
public static void ResolvePlotJoins(string plotKey)
{
ResolveJoinsByKey(plotKey, plot: true);
}
private static void ResolveJoinsByKey(string key, bool plot)
{
if (string.IsNullOrEmpty(key))
{
return;
}
EnsureWorldBound();
foreach (KeyValuePair<long, LifeSagaLifeMemory> kv in Lives)
{
LifeSagaLifeMemory life = kv.Value;
if (life?.Facts == null)
{
continue;
}
for (int i = 0; i < life.Facts.Count; i++)
{
LifeSagaFact f = life.Facts[i];
if (f == null || f.Resolved)
{
continue;
}
bool match = plot
? string.Equals(f.PlotKey, key, StringComparison.Ordinal)
: string.Equals(f.WarKey, key, StringComparison.Ordinal);
if (!match)
{
continue;
}
if (plot)
{
if (f.Kind == LifeSagaFactKind.PlotJoin || f.Kind == LifeSagaFactKind.PlotEnd)
{
f.Resolved = true;
if (f.Kind == LifeSagaFactKind.PlotJoin)
{
f.Kind = LifeSagaFactKind.PlotEnd;
}
}
}
else if (f.Kind == LifeSagaFactKind.WarJoin || f.Kind == LifeSagaFactKind.WarEnd)
{
f.Resolved = true;
if (f.Kind == LifeSagaFactKind.WarJoin)
{
f.Kind = LifeSagaFactKind.WarEnd;
}
}
}
}
}
private static string FormatWarPairNote(string attackerKingdom, string defenderKingdom)
{
string a = (attackerKingdom ?? "").Trim();
string b = (defenderKingdom ?? "").Trim();
if (!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b))
{
return a + " vs " + b;
}
return FirstNonEmpty(a, b);
}
private static string ResolvePlotDisplay(string plotAsset)
{
if (string.IsNullOrEmpty(plotAsset))
{
return "";
}
string display = LibraryAssetNames.DisplayName("plots", plotAsset);
if (!string.IsNullOrWhiteSpace(display)
&& !string.Equals(display.Trim(), plotAsset.Trim(), StringComparison.OrdinalIgnoreCase))
{
return display.Trim();
}
return EventReason.HumanizeId(plotAsset);
}
public static void RecordFounding(Actor subject, string metaKind, string metaKey, string provenance)
{
if (subject == null)
@ -916,6 +1197,18 @@ public static class LifeSagaMemory
return null;
}
LifeSagaFact best = PickStrongest(life, strongOnly: true);
if (best != null)
{
return best;
}
// Fallback stakes when no war/plot/role/rival stake exists yet.
return PickStrongest(life, strongOnly: false);
}
private static LifeSagaFact PickStrongest(LifeSagaLifeMemory life, bool strongOnly)
{
LifeSagaFact best = null;
for (int i = 0; i < life.Facts.Count; i++)
{
@ -925,7 +1218,14 @@ public static class LifeSagaMemory
continue;
}
if (!IsStakeWorthy(f))
if (strongOnly)
{
if (!IsStakeWorthy(f))
{
continue;
}
}
else if (!IsStakeFallbackWorthy(f))
{
continue;
}
@ -1077,13 +1377,45 @@ public static class LifeSagaMemory
return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
case LifeSagaFactKind.WarJoin:
return "Entered a war";
{
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm))
{
return "War with " + other + " (" + realm + ")";
}
if (!string.IsNullOrEmpty(other))
{
return "War with " + other;
}
return string.IsNullOrEmpty(realm) ? "Entered a war" : "War: " + realm;
}
case LifeSagaFactKind.WarEnd:
return "War ended";
{
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
return string.IsNullOrEmpty(realm) ? "War ended" : "War ended (" + realm + ")";
}
case LifeSagaFactKind.PlotJoin:
return "Joined a plot";
{
string plot = fact.Note ?? "";
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot))
{
return "Plot with " + other + " (" + plot + ")";
}
if (!string.IsNullOrEmpty(other))
{
return "Plot with " + other;
}
return string.IsNullOrEmpty(plot) ? "Joined a plot" : "Joined " + plot;
}
case LifeSagaFactKind.PlotEnd:
return "Plot resolved";
{
string plot = fact.Note ?? "";
return string.IsNullOrEmpty(plot) ? "Plot resolved" : "Plot resolved (" + plot + ")";
}
case LifeSagaFactKind.Founding:
return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home";
case LifeSagaFactKind.RoleChange:
@ -1389,6 +1721,32 @@ public static class LifeSagaMemory
}
}
/// <summary>
/// Weaker unresolved facts used only when no primary stake exists.
/// Kind-gated: HardArcCombat, open BondFormed, and McCrossover Kill (Kill already via McCrossover path).
/// </summary>
private static bool IsStakeFallbackWorthy(LifeSagaFact fact)
{
if (fact == null)
{
return false;
}
if (fact.McCrossover && fact.Kind == LifeSagaFactKind.Kill)
{
return true;
}
switch (fact.Kind)
{
case LifeSagaFactKind.HardArcCombat:
case LifeSagaFactKind.BondFormed:
return true;
default:
return false;
}
}
private static void TryMarkMcCrossover(LifeSagaFact fact)
{
if (fact == null || fact.OtherId == 0 || fact.SubjectId == 0)
@ -1579,6 +1937,18 @@ public static class LifeSagaMemory
return "Became alpha";
}
if (roleId.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0
|| roleId.IndexOf("clan", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became clan chief";
}
if (roleId.IndexOf("captain", StringComparison.OrdinalIgnoreCase) >= 0
|| roleId.IndexOf("army", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Became army captain";
}
return "Role: " + roleId;
}

View file

@ -538,10 +538,42 @@ public static class LifeSagaPresentation
}
}
// Pack / kin peers fill remaining slots for pack-shaped and family-shaped lenses.
// Living Others from unresolved war/plot joins.
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.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId))
{
continue;
}
Actor liveOther = EventFeedUtil.FindAliveById(f.OtherId);
if (liveOther == null)
{
continue;
}
if (f.Kind == LifeSagaFactKind.WarJoin)
{
AddLive(liveOther, "War foe", "observed");
}
else if (f.Kind == LifeSagaFactKind.PlotJoin)
{
AddLive(liveOther, "Plot ally", "observed");
}
}
}
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
if (actor != null
&& model.Cast.Count < 4
&& (primary == LifeSagaLens.PackAlpha || primary == LifeSagaLens.LoveGrief))
&& (primary == LifeSagaLens.PackAlpha
|| primary == LifeSagaLens.LoveGrief
|| primary == LifeSagaLens.CrownClan
|| primary == LifeSagaLens.WarriorKiller
|| primary == LifeSagaLens.Plotter))
{
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))

View file

@ -17,7 +17,7 @@ public static class LifeSagaRail
public const float SlotSize = 18f;
private const float Gap = 2f;
private const float BadgeSize = 7f;
private const float FaceRefreshSeconds = 0.28f;
private const float FaceRefreshSeconds = 1.0f;
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
private static readonly List<long> InvolvedScratch = new List<long>(8);
@ -177,7 +177,7 @@ public static class LifeSagaRail
}
float now = Time.unscaledTime;
LifeSagaRoster.Tick(now);
// Director already Ticks the roster each frame - do not Refill twice.
SlotScratch.Clear();
LifeSagaRoster.CopySlots(SlotScratch);
long watchId = EventFeedUtil.SafeId(
@ -220,7 +220,7 @@ public static class LifeSagaRail
bool involved = !watching && IsInvolved(saga.UnitId);
bool prefer = saga.Prefer || saga.GameFavorite;
slot.UnitId = saga.UnitId;
ApplyFace(slot, saga);
ApplyFace(slot, saga, preferLive: watching);
ApplyBadge(slot, saga);
if (slot.PreferPip != null)
{
@ -268,6 +268,7 @@ public static class LifeSagaRail
}
else
{
// Animate only the Active (watched) glyph; others keep static faces.
for (int i = 0; i < Slots.Length && i < SlotScratch.Count; i++)
{
if (Slots[i] == null || !Slots[i].Root.activeSelf || SlotScratch[i] == null)
@ -275,7 +276,12 @@ public static class LifeSagaRail
continue;
}
ApplyFace(Slots[i], SlotScratch[i]);
if (watchId == 0 || SlotScratch[i].UnitId != watchId)
{
continue;
}
ApplyFace(Slots[i], SlotScratch[i], preferLive: true);
}
}
@ -438,7 +444,7 @@ public static class LifeSagaRail
};
}
private static void ApplyFace(Slot slot, LifeSagaSlot saga)
private static void ApplyFace(Slot slot, LifeSagaSlot saga, bool preferLive)
{
if (slot == null || saga == null)
{
@ -446,7 +452,16 @@ public static class LifeSagaRail
}
Actor actor = saga.Resolve();
Sprite sprite = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
// Live calculateMainSprite is expensive - only the watched glyph animates with it.
// Other rail faces use the species icon (stable between structure changes).
Sprite sprite = preferLive
? HudIcons.FromActorRailFace(actor, saga.SpeciesId)
: HudIcons.FromSpeciesId(saga.SpeciesId);
if (!HudIcons.IsUsableRailFace(sprite) && preferLive)
{
sprite = HudIcons.FromSpeciesId(saga.SpeciesId);
}
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
{
slot.Face.sprite = sprite;

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace IdleSpectator;
@ -17,12 +18,40 @@ public static class LifeSagaRoster
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap);
private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(48);
private static readonly List<Actor> ChallengerScratch = new List<Actor>(64);
private static readonly List<Actor> UnitScanSnapshot = new List<Actor>(512);
private static readonly HashSet<long> ScanSeen = new HashSet<long>();
private static readonly HashSet<long> PreviousIds = new HashSet<long>();
private static readonly HashSet<long> PlotAuthorCache = new HashSet<long>();
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
private static readonly List<long> TouchScratch = new List<long>(8);
private static float _nextScanAt;
private static float _roleCacheAt = -999f;
private static bool _dirty = true;
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
private static int _scanIndex;
private static float _scanStartedAt;
private static int _scanPinCount;
private enum WorldScanPhase
{
None,
Walking,
Commit
}
/// <summary>Units probed per frame during amortized world admission.</summary>
private const int WorldScanUnitsPerFrame = 260;
/// <summary>Cadence for starting a spread world admission scan.</summary>
private const float WorldScanIntervalSeconds = 3.5f;
/// <summary>Last timed world-scan refill cost in ms (hitch probe).</summary>
public static float LastWorldScanMs { get; private set; }
/// <summary>Last timed soft refill cost in ms (hitch probe).</summary>
public static float LastSoftRefillMs { get; private set; }
public static int Count => Slots.Count;
@ -41,11 +70,17 @@ public static class LifeSagaRoster
Slots.Clear();
Scratch.Clear();
ChallengerScratch.Clear();
UnitScanSnapshot.Clear();
ScanSeen.Clear();
PreviousIds.Clear();
PlotAuthorCache.Clear();
SpeciesCountCache.Clear();
McOrbitCache.Clear();
_nextScanAt = 0f;
_roleCacheAt = -999f;
_scanPhase = WorldScanPhase.None;
_scanIndex = 0;
_dirty = true;
}
@ -149,6 +184,7 @@ public static class LifeSagaRoster
existing.Heat = Mathf.Max(existing.Heat, 4f);
existing.TouchedAt = now;
_dirty = true;
RebuildMcOrbitCache();
return true;
}
@ -170,6 +206,7 @@ public static class LifeSagaRoster
Slots.Add(neu);
_dirty = true;
RebuildMcOrbitCache();
return true;
}
@ -277,7 +314,7 @@ public static class LifeSagaRoster
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
float cross = sw.sagaCrossMcBonus > 0f ? sw.sagaCrossMcBonus : 6f;
float cross = sw.sagaCrossMcBonus > 0f ? sw.sagaCrossMcBonus : 11f;
float best = BonusForUnit(c.SubjectId, mc, prefer, fav, cast);
if (c.RelatedId != 0)
@ -417,7 +454,7 @@ public static class LifeSagaRoster
return IsMcCast(EventFeedUtil.SafeId(c.FollowUnit));
}
/// <summary>Prefer=3, MC=2, MC cast=1, else 0.</summary>
/// <summary>Prefer=4, CrossMC=3, MC=2, MC cast=1, else 0.</summary>
public static int SoftBiasRank(InterestCandidate c)
{
if (c == null)
@ -426,6 +463,11 @@ public static class LifeSagaRoster
}
if (TipTouchesPrefer(c))
{
return 4;
}
if (CountTouchedMcs(c) >= 2)
{
return 3;
}
@ -438,6 +480,12 @@ public static class LifeSagaRoster
return TipTouchesMcCast(c) ? 1 : 0;
}
/// <summary>Near-tie breaker: Prefer > CrossMC > MC > MC cast > neither.</summary>
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
}
/// <summary>True when unit is in a roster MC's live story orbit (not themselves an MC).</summary>
public static bool IsMcCast(long unitId)
{
@ -446,27 +494,7 @@ public static class LifeSagaRoster
return false;
}
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0 || slot.UnitId == unitId)
{
continue;
}
if (IsInMcOrbit(slot.UnitId, unitId))
{
return true;
}
}
return false;
}
/// <summary>Near-tie breaker: Prefer > MC > MC cast > neither.</summary>
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
{
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
return McOrbitCache.Contains(unitId);
}
/// <summary>
@ -523,31 +551,31 @@ public static class LifeSagaRoster
public static int CountTouchedMcs(InterestCandidate tip)
{
if (tip == null)
CollectTouchedMcIds(tip, TouchScratch);
return TouchScratch.Count;
}
/// <summary>
/// Heat roster MCs early while a sticky combat/war/plot tip still holds two living principals.
/// Helps Pick land on cross-MC moments before fact recording.
/// </summary>
public static void NoteFeaturedCrossMcPrincipals(InterestCandidate tip, float heat = 0.85f)
{
if (tip == null || heat <= 0f)
{
return 0;
return;
}
int n = 0;
long a = tip.SubjectId;
long b = tip.RelatedId;
long f = EventFeedUtil.SafeId(tip.FollowUnit);
if (IsMc(a))
CollectTouchedMcIds(tip, TouchScratch);
if (TouchScratch.Count < 2)
{
n++;
return;
}
if (b != 0 && b != a && IsMc(b))
for (int i = 0; i < TouchScratch.Count; i++)
{
n++;
NoteFeaturedUnit(TouchScratch[i], heat);
}
if (f != 0 && f != a && f != b && IsMc(f))
{
n++;
}
return n;
}
private static bool IsInMcOrbit(long mcId, long otherId)
@ -821,14 +849,204 @@ public static class LifeSagaRoster
public static void Tick(float now)
{
if (now < _nextScanAt && !_dirty)
if (_scanPhase != WorldScanPhase.None)
{
PruneDead(now);
ContinueAmortizedWorldScan(now);
return;
}
_nextScanAt = now + 2.5f;
Refill(now);
if (now < _nextScanAt)
{
if (_dirty)
{
// Membership / Prefer / heat-driven dirty: re-rank existing slots only.
// Full world admission stays on the timed cadence to avoid hitch spikes.
Refill(now, worldScan: false);
}
else
{
PruneDead(now);
}
return;
}
_nextScanAt = now + WorldScanIntervalSeconds;
if (AgentHarness.Busy)
{
Refill(now, worldScan: false);
return;
}
BeginAmortizedWorldScan(now);
}
/// <summary>
/// Spread admission discovery across frames so a 2k-unit map cannot hitch on one Tick.
/// </summary>
private static void BeginAmortizedWorldScan(float now)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
EnsureRoleCache(now, force: false);
Scratch.Clear();
PreviousIds.Clear();
ScanSeen.Clear();
ChallengerScratch.Clear();
UnitScanSnapshot.Clear();
_scanPinCount = 0;
_scanStartedAt = now;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot s = Slots[i];
if (s == null || s.UnitId == 0)
{
continue;
}
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
if (a == null || !a.isAlive())
{
continue;
}
RefreshSlot(s, a, now);
Scratch.Add(s);
ScanSeen.Add(s.UnitId);
PreviousIds.Add(s.UnitId);
if (IsPin(s))
{
_scanPinCount++;
}
}
if (_scanPinCount >= Cap)
{
FinishRefillFromScratch(now, worldScan: true, sw);
return;
}
try
{
if (World.world?.units != null)
{
foreach (Actor actor in World.world.units)
{
if (actor != null && actor.isAlive())
{
UnitScanSnapshot.Add(actor);
}
}
}
}
catch
{
// ignore
}
_scanIndex = 0;
_scanPhase = WorldScanPhase.Walking;
sw.Stop();
LastWorldScanMs = (float)sw.Elapsed.TotalMilliseconds;
}
private static void ContinueAmortizedWorldScan(float now)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
if (_scanPhase == WorldScanPhase.Walking)
{
int end = Mathf.Min(_scanIndex + WorldScanUnitsPerFrame, UnitScanSnapshot.Count);
for (int i = _scanIndex; i < end; i++)
{
Actor actor = UnitScanSnapshot[i];
if (actor == null || !actor.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0 || ScanSeen.Contains(id))
{
continue;
}
if (!IsAdmissionCandidate(actor))
{
continue;
}
ChallengerScratch.Add(actor);
ScanSeen.Add(id);
}
_scanIndex = end;
if (_scanIndex < UnitScanSnapshot.Count)
{
sw.Stop();
LastWorldScanMs = Mathf.Max(LastWorldScanMs, (float)sw.Elapsed.TotalMilliseconds);
return;
}
_scanPhase = WorldScanPhase.Commit;
}
if (_scanPhase == WorldScanPhase.Commit)
{
const int MaxScanAdmits = Cap * 2;
if (ChallengerScratch.Count > MaxScanAdmits)
{
ChallengerScratch.Sort(CompareAdmitPriorityDesc);
ChallengerScratch.RemoveRange(MaxScanAdmits, ChallengerScratch.Count - MaxScanAdmits);
}
for (int i = 0; i < ChallengerScratch.Count; i++)
{
Actor actor = ChallengerScratch[i];
long id = EventFeedUtil.SafeId(actor);
if (id == 0)
{
continue;
}
Scratch.Add(BuildSlot(actor, now));
}
FinishRefillFromScratch(now, worldScan: true, sw);
ChallengerScratch.Clear();
UnitScanSnapshot.Clear();
ScanSeen.Clear();
_scanPhase = WorldScanPhase.None;
_scanIndex = 0;
return;
}
sw.Stop();
}
private static void FinishRefillFromScratch(
float now,
bool worldScan,
System.Diagnostics.Stopwatch sw)
{
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = false;
RebuildMcOrbitCache();
sw.Stop();
float ms = (float)sw.Elapsed.TotalMilliseconds;
if (worldScan)
{
LastWorldScanMs = Mathf.Max(LastWorldScanMs, ms);
}
else
{
LastSoftRefillMs = ms;
}
}
/// <summary>
@ -891,10 +1109,9 @@ public static class LifeSagaRoster
LifeSagaSlot existing = Get(unitId);
if (existing != null)
{
// Heat-only: do not RefreshSlot or force a world Refill (idle hot path).
existing.Heat = Mathf.Min(HeatCap, existing.Heat + Mathf.Max(0f, heat));
existing.TouchedAt = now;
RefreshSlot(existing, actor, now);
_dirty = true;
return true;
}
@ -941,17 +1158,19 @@ public static class LifeSagaRoster
return;
}
// Soft heat must not mark Dirty - that forced a full world Refill every tip.
s.Heat = Mathf.Min(HeatCap, s.Heat + heat);
s.TouchedAt = now;
_dirty = true;
}
private static void Refill(float now)
private static void Refill(float now, bool worldScan)
{
EnsureRoleCache(now, force: true);
var sw = System.Diagnostics.Stopwatch.StartNew();
// Soft / harness path: never walk the whole world on this call.
// Live AFK discovery uses BeginAmortizedWorldScan instead.
EnsureRoleCache(now, force: false);
Scratch.Clear();
PreviousIds.Clear();
HashSet<long> seen = new HashSet<long>();
for (int i = 0; i < Slots.Count; i++)
{
@ -969,57 +1188,65 @@ public static class LifeSagaRoster
RefreshSlot(s, a, now);
Scratch.Add(s);
seen.Add(s.UnitId);
PreviousIds.Add(s.UnitId);
}
// Harness batches need a stable roster; world scan would re-admit leftovers from prior steps.
// Live AFK (Busy=false) still discovers chiefs/captains/singletons via the scan below.
// Tests use force/consider admit or `saga_world_scan` when natural discovery is required.
if (!AgentHarness.Busy)
_ = worldScan;
FinishRefillFromScratch(now, worldScan: false, sw);
}
private static void RebuildMcOrbitCache()
{
McOrbitCache.Clear();
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0)
{
continue;
}
Actor mc = EventFeedUtil.FindAliveById(slot.UnitId);
if (mc == null)
{
continue;
}
void Add(Actor a)
{
long id = EventFeedUtil.SafeId(a);
if (id != 0 && id != slot.UnitId)
{
McOrbitCache.Add(id);
}
}
Add(ActorRelation.GetLover(mc));
Add(ActorRelation.GetBestFriend(mc));
try
{
if (World.world?.units != null)
foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
{
foreach (Actor actor in World.world.units)
{
if (actor == null || !actor.isAlive())
{
continue;
}
Add(parent);
}
long id = EventFeedUtil.SafeId(actor);
if (id == 0 || seen.Contains(id))
{
continue;
}
if (!IsAdmissionCandidate(actor))
{
continue;
}
LifeSagaSlot neu = BuildSlot(actor, now);
Scratch.Add(neu);
seen.Add(id);
}
foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
{
Add(child);
}
}
catch
{
// ignore scan failures
// ignore
}
if (LifeSagaMemory.TryGetEarnedRival(slot.UnitId, out LifeSagaFact rival)
&& rival != null
&& rival.OtherId != 0)
{
McOrbitCache.Add(rival.OtherId);
}
}
RankAndCap(Scratch, now, PreviousIds);
Slots.Clear();
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
{
Slots.Add(Scratch[i]);
}
_dirty = true;
}
private static void PruneDead(float now)
@ -1040,10 +1267,8 @@ public static class LifeSagaRoster
{
Slots.RemoveAt(i);
changed = true;
continue;
}
RefreshSlot(s, a, now);
// Do not RefreshSlot here - that ran ~20×/frame (Director + Rail) and dominated idle CPU.
}
if (changed)
@ -1420,6 +1645,76 @@ public static class LifeSagaRoster
return false;
}
/// <summary>
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
/// </summary>
private static int CompareAdmitPriorityDesc(Actor a, Actor b)
{
return AdmitPriority(b).CompareTo(AdmitPriority(a));
}
private static float AdmitPriority(Actor actor)
{
if (actor == null)
{
return 0f;
}
float p = 0f;
try
{
if (actor.isFavorite())
{
p += 1000f;
}
if (actor.isKing())
{
p += 400f;
}
if (actor.isCityLeader())
{
p += 300f;
}
}
catch
{
// ignore
}
if (InterestScoring.IsNotable(actor))
{
p += 200f;
}
try
{
if (actor.data != null)
{
p += Mathf.Min(80f, actor.data.kills * 0.5f);
}
p += Mathf.Min(60f, actor.renown * 0.25f);
}
catch
{
// ignore
}
if (IsClanChief(actor) || IsArmyCaptain(actor) || IsPackAlpha(actor))
{
p += 120f;
}
if (IsActivePlotAuthor(actor) || IsFounder(actor) || IsSpeciesSingleton(actor))
{
p += 80f;
}
return p;
}
public static bool IsPackAlpha(Actor actor)
{
if (actor == null)
@ -1532,6 +1827,9 @@ public static class LifeSagaRoster
}
}
private static readonly Dictionary<string, MemberInfo> MemberCache =
new Dictionary<string, MemberInfo>(128);
private static object ReadMember(object target, params string[] names)
{
if (target == null || names == null)
@ -1548,26 +1846,41 @@ public static class LifeSagaRoster
continue;
}
try
string cacheKey = t.FullName + ":" + name;
if (!MemberCache.TryGetValue(cacheKey, out MemberInfo cached))
{
object value = t.GetField(name)?.GetValue(target)
?? t.GetProperty(name)?.GetValue(target, null);
if (value != null)
{
return value;
}
cached = (MemberInfo)t.GetField(name)
?? t.GetProperty(name)
?? (MemberInfo)t.GetMethod(name, Type.EmptyTypes);
MemberCache[cacheKey] = cached; // may be null - negative cache
}
catch
if (cached == null)
{
// try next
continue;
}
try
{
var method = t.GetMethod(name, Type.EmptyTypes);
if (method != null)
if (cached is FieldInfo fi)
{
object value = method.Invoke(target, null);
object value = fi.GetValue(target);
if (value != null)
{
return value;
}
}
else if (cached is PropertyInfo pi)
{
object value = pi.GetValue(target, null);
if (value != null)
{
return value;
}
}
else if (cached is MethodInfo mi)
{
object value = mi.Invoke(target, null);
if (value != null)
{
return value;
@ -1834,6 +2147,13 @@ public static class LifeSagaRoster
private static void RefreshSlot(LifeSagaSlot s, Actor actor, float now)
{
bool existed = s.UnitId != 0 && s.UnitId == EventFeedUtil.SafeId(actor);
bool wasKing = s.IsKing;
bool wasLeader = s.IsLeader;
bool wasAlpha = s.IsAlpha;
bool wasClanChief = s.IsClanChief;
bool wasArmyCaptain = s.IsArmyCaptain;
s.UnitId = EventFeedUtil.SafeId(actor);
s.DisplayName = SafeName(actor);
bool gameFav = false;
@ -1861,7 +2181,8 @@ public static class LifeSagaRoster
s.IsSingleton = IsSpeciesSingleton(actor);
s.Kills = meta?.Kills ?? 0;
s.Renown = meta?.Renown ?? 0f;
s.Standing = BaseInterest(actor);
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
s.Standing = StandingFromFlags(s);
if (s.AdmittedAt <= 0f)
{
s.AdmittedAt = now;
@ -1871,6 +2192,191 @@ public static class LifeSagaRoster
{
s.AdmissionReason = InferAdmissionReason(s);
}
if (existed)
{
NoteRisingRoles(
actor,
wasKing,
wasLeader,
wasAlpha,
wasClanChief,
wasArmyCaptain,
s.IsKing,
s.IsLeader,
s.IsAlpha,
s.IsClanChief,
s.IsArmyCaptain);
}
}
private static float StandingFromFlags(LifeSagaSlot s)
{
float score = 1f;
if (s == null)
{
return score;
}
if (s.GameFavorite)
{
score += 12f;
}
if (s.IsKing)
{
score += 10f;
}
else if (s.IsLeader)
{
score += 6f;
}
if (s.IsClanChief)
{
score += 8f;
}
if (s.IsAlpha)
{
score += 7f;
}
if (s.IsPlotAuthor)
{
score += 7f;
}
if (s.IsSingleton)
{
score += 6f;
}
if (s.IsFounder)
{
score += 5f;
}
if (s.IsArmyCaptain)
{
score += 4f;
}
score += Mathf.Min(8f, s.Kills * 0.05f);
score += Mathf.Min(4f, s.Renown / 100f);
// StandoutTraitScore skipped here - reflection-heavy and ran every Refill×Cap.
return score;
}
private static void NoteRisingRoles(
Actor actor,
bool wasKing,
bool wasLeader,
bool wasAlpha,
bool wasClanChief,
bool wasArmyCaptain,
bool isKing,
bool isLeader,
bool isAlpha,
bool isClanChief,
bool isArmyCaptain)
{
if (actor == null)
{
return;
}
if (!wasKing && isKing)
{
LifeSagaMemory.RecordRole(actor, "become_king", "roster_live");
}
if (!wasLeader && isLeader)
{
LifeSagaMemory.RecordRole(actor, "become_leader", "roster_live");
}
if (!wasAlpha && isAlpha)
{
LifeSagaMemory.RecordRole(actor, "become_alpha", "roster_live");
}
if (!wasClanChief && isClanChief)
{
LifeSagaMemory.RecordRole(actor, "become_clan_chief", "roster_live");
}
if (!wasArmyCaptain && isArmyCaptain)
{
LifeSagaMemory.RecordRole(actor, "become_army_captain", "roster_live");
}
}
/// <summary>
/// Harness: simulate a live role rising edge on an existing roster slot (does not mutate game offices).
/// </summary>
public static bool HarnessSimulateRoleRise(Actor actor, string roleId)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(roleId))
{
return false;
}
long id = EventFeedUtil.SafeId(actor);
LifeSagaSlot s = Get(id);
if (s == null)
{
return false;
}
string role = roleId.Trim().ToLowerInvariant();
bool wasKing = s.IsKing;
bool wasLeader = s.IsLeader;
bool wasAlpha = s.IsAlpha;
bool wasClanChief = s.IsClanChief;
bool wasArmyCaptain = s.IsArmyCaptain;
if (role.IndexOf("king", System.StringComparison.Ordinal) >= 0)
{
s.IsKing = true;
}
else if (role.IndexOf("leader", System.StringComparison.Ordinal) >= 0)
{
s.IsLeader = true;
}
else if (role.IndexOf("alpha", System.StringComparison.Ordinal) >= 0)
{
s.IsAlpha = true;
}
else if (role.IndexOf("chief", System.StringComparison.Ordinal) >= 0
|| role.IndexOf("clan", System.StringComparison.Ordinal) >= 0)
{
s.IsClanChief = true;
}
else if (role.IndexOf("captain", System.StringComparison.Ordinal) >= 0
|| role.IndexOf("army", System.StringComparison.Ordinal) >= 0)
{
s.IsArmyCaptain = true;
}
else
{
return false;
}
NoteRisingRoles(
actor,
wasKing,
wasLeader,
wasAlpha,
wasClanChief,
wasArmyCaptain,
s.IsKing,
s.IsLeader,
s.IsAlpha,
s.IsClanChief,
s.IsArmyCaptain);
_dirty = true;
return true;
}
private static LifeSagaAdmissionReason InferAdmissionReason(LifeSagaSlot s)

View file

@ -0,0 +1,172 @@
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using HarmonyLib;
using NeoModLoader.services;
namespace IdleSpectator;
/// <summary>
/// World-session bind for the life-saga roster and durable memory.
/// Clears on map load-complete and when the live bind key changes; same-map
/// spectator re-enable does not wipe.
/// </summary>
public static class LifeSagaSession
{
private static readonly FieldInfo LoadCounterField =
AccessTools.Field(typeof(MapBox), "_load_counter");
private static object _boundWorld;
private static string _boundKey = "";
private static int _epoch;
private static float _nextBindKeyCheckAt;
public static int Epoch => _epoch;
public static string BoundKey => _boundKey ?? "";
/// <summary>Clear roster + memory + saga UI and rebind to the current world.</summary>
public static void ResetForWorldChange(string reason = null)
{
LifeSagaRoster.Clear();
LifeSagaMemory.ClearSession();
LifeSagaViewController.Clear();
LifeSagaRail.Clear();
LifeSagaPanel.Clear();
_epoch++;
object world = SafeWorld();
_boundWorld = world;
_boundKey = ComputeBindKey(world);
_nextBindKeyCheckAt = 0f;
LifeSagaMemory.NoteBoundWorld(world);
if (!string.IsNullOrEmpty(reason))
{
LogService.LogInfo(
$"[IdleSpectator] Life saga session reset ({reason}) key='{_boundKey}' epoch={_epoch}");
}
}
/// <summary>
/// Bind or reset when the world identity / bind key changes.
/// No-op when the same map session is still live (spectator toggle safe).
/// </summary>
public static void EnsureBound()
{
object world = SafeWorld();
if (ReferenceEquals(world, _boundWorld))
{
// Same world object: throttle key recompute. Load/clear Harmony patches
// still force ResetForWorldChange immediately.
float now = UnityEngine.Time.unscaledTime;
if (now < _nextBindKeyCheckAt)
{
return;
}
_nextBindKeyCheckAt = now + 2f;
}
string key = ComputeBindKey(world);
if (ReferenceEquals(world, _boundWorld)
&& string.Equals(key, _boundKey, StringComparison.Ordinal))
{
return;
}
ResetForWorldChange(world == null ? "world-null" : "bind-key");
}
/// <summary>
/// Map load finished on the live MapBox instance.
/// Always wipe even when the world reference and name look unchanged.
/// </summary>
public static void OnWorldLoadFinished()
{
ResetForWorldChange("load-complete");
}
public static string ComputeBindKey(object worldObj)
{
MapBox world = worldObj as MapBox;
if (world == null)
{
return "";
}
string name = "";
string seed = "";
int statsId = 0;
int loadCounter = 0;
int width = 0;
int height = 0;
try
{
MapStats stats = world.map_stats;
if (stats != null)
{
name = stats.name ?? "";
statsId = RuntimeHelpers.GetHashCode(stats);
}
}
catch
{
name = "";
}
try
{
seed = MapBox.current_world_seed_id.ToString();
}
catch
{
seed = "";
}
try
{
width = MapBox.width;
height = MapBox.height;
}
catch
{
width = 0;
height = 0;
}
try
{
if (LoadCounterField != null)
{
object raw = LoadCounterField.GetValue(world);
if (raw is int i)
{
loadCounter = i;
}
else if (raw != null)
{
loadCounter = Convert.ToInt32(raw);
}
}
}
catch
{
loadCounter = 0;
}
return $"{name}|{seed}|{statsId}|{loadCounter}|{width}x{height}";
}
private static object SafeWorld()
{
try
{
return World.world;
}
catch
{
return null;
}
}
}

View file

@ -0,0 +1,36 @@
using HarmonyLib;
namespace IdleSpectator;
/// <summary>Reset life-saga session when the live map finishes loading or is cleared.</summary>
[HarmonyPatch]
public static class LifeSagaSessionPatches
{
[HarmonyPatch(typeof(MapBox), nameof(MapBox.finishingUpLoading))]
[HarmonyPostfix]
public static void PostfixFinishingUpLoading()
{
try
{
LifeSagaSession.OnWorldLoadFinished();
}
catch
{
// Load path must not throw into the game.
}
}
[HarmonyPatch(typeof(MapBox), nameof(MapBox.clearWorld))]
[HarmonyPrefix]
public static void PrefixClearWorld()
{
try
{
LifeSagaSession.ResetForWorldChange("clear-world");
}
catch
{
// Teardown path must not throw into the game.
}
}
}

View file

@ -23,6 +23,7 @@ public static class StoryPlanner
private static string _coldHookClimaxKey = "";
private static string _coldHookAnchor = "";
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(64);
private static readonly List<Actor> ActorScratch = new List<Actor>(16);
/// <summary>Currently watching storyline (not parked).</summary>
public static StoryArc Active =>
@ -583,13 +584,24 @@ public static class StoryPlanner
private static void TrimParkedBoard()
{
// Watching + parked <= BoardCap
// Watching + parked <= BoardCap. Prefer dropping anonymous scraps over Prefer/MC/cast arcs.
int watching = _active != null && _active.IsActive ? 1 : 0;
while (Parked.Count + watching > BoardCap && Parked.Count > 0)
{
int last = Parked.Count - 1;
StoryArc drop = Parked[last];
Parked.RemoveAt(last);
int dropAt = 0;
int worst = int.MaxValue;
for (int i = 0; i < Parked.Count; i++)
{
int keep = ParkedArcKeepScore(Parked[i]);
if (keep <= worst)
{
worst = keep;
dropAt = i;
}
}
StoryArc drop = Parked[dropAt];
Parked.RemoveAt(dropAt);
if (drop != null)
{
EndArc(drop, Time.unscaledTime);
@ -597,6 +609,40 @@ public static class StoryPlanner
}
}
/// <summary>Prefer=3, MC=2, MC cast=1, else 0 for parked-board trim.</summary>
private static int ParkedArcKeepScore(StoryArc arc)
{
if (arc?.CastIds == null || arc.CastIds.Count == 0)
{
return 0;
}
int best = 0;
for (int i = 0; i < arc.CastIds.Count; i++)
{
long id = arc.CastIds[i];
if (id == 0)
{
continue;
}
if (LifeSagaRoster.IsPrefer(id))
{
best = Mathf.Max(best, 3);
}
else if (LifeSagaRoster.IsMc(id))
{
best = Mathf.Max(best, 2);
}
else if (LifeSagaRoster.IsMcCast(id))
{
best = Mathf.Max(best, 1);
}
}
return best;
}
public static void OnSwitchTo(InterestCandidate next, float now)
{
if (next == null)
@ -1597,15 +1643,22 @@ public static class StoryPlanner
Actor next = null;
if (arc != null && arc.IsActive)
{
ActorScratch.Clear();
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a != null)
{
next = a;
break;
ActorScratch.Add(a);
}
}
// Prefer/MC among living arc cast only - never pull MCs outside the theater.
next = LifeSagaRoster.PreferRosterUnit(ActorScratch);
if (next == null && ActorScratch.Count > 0)
{
next = ActorScratch[0];
}
}
if (next == null && scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
@ -2043,6 +2096,7 @@ public static class StoryPlanner
Actor follow = null;
Actor related = null;
ActorScratch.Clear();
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
@ -2052,24 +2106,55 @@ public static class StoryPlanner
}
Actor lover = ActorRelation.GetLover(a);
if (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
{
follow = a;
related = lover;
break;
}
Actor friend = ActorRelation.GetBestFriend(a);
if (friend != null && friend.isAlive())
bool hasBond = (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
|| (friend != null && friend.isAlive());
if (hasBond)
{
follow = a;
related = friend;
break;
ActorScratch.Add(a);
}
}
if (follow == null)
Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch);
if (preferred != null)
{
follow = preferred;
related = ActorRelation.GetLover(preferred);
if (related == null || !related.isAlive() || EventFeedUtil.SafeId(related) == EventFeedUtil.SafeId(preferred))
{
follow = a;
related = ActorRelation.GetBestFriend(preferred);
}
}
else
{
for (int i = 0; i < arc.CastIds.Count; i++)
{
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
if (a == null)
{
continue;
}
Actor lover = ActorRelation.GetLover(a);
if (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
{
follow = a;
related = lover;
break;
}
Actor friend = ActorRelation.GetBestFriend(a);
if (friend != null && friend.isAlive())
{
follow = a;
related = friend;
break;
}
if (follow == null)
{
follow = a;
}
}
}
@ -2136,6 +2221,61 @@ public static class StoryPlanner
return null;
}
ActorScratch.Clear();
void AddCand(Actor a)
{
if (a == null || !a.isAlive())
{
return;
}
for (int i = 0; i < ActorScratch.Count; i++)
{
if (ActorScratch[i] == a)
{
return;
}
}
ActorScratch.Add(a);
}
AddCand(climax.FollowUnit);
if (climax.TheaterLeadId != 0)
{
AddCand(EventFeedUtil.FindAliveById(climax.TheaterLeadId));
}
if (climax.PairOwnerId != 0)
{
AddCand(EventFeedUtil.FindAliveById(climax.PairOwnerId));
}
AddCand(climax.RelatedUnit);
if (climax.PairPartnerId != 0)
{
AddCand(EventFeedUtil.FindAliveById(climax.PairPartnerId));
}
if (climax.Sticky != null)
{
for (int i = 0; i < climax.Sticky.SideAIds.Count; i++)
{
AddCand(EventFeedUtil.FindAliveById(climax.Sticky.SideAIds[i]));
}
for (int i = 0; i < climax.Sticky.SideBIds.Count; i++)
{
AddCand(EventFeedUtil.FindAliveById(climax.Sticky.SideBIds[i]));
}
}
Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch);
if (preferred != null)
{
return preferred;
}
if (climax.FollowUnit != null && climax.FollowUnit.isAlive())
{
return climax.FollowUnit;

View file

@ -115,6 +115,11 @@ public static class WatchCaption
private static UnitDossier _current;
private static Actor _boundActor;
private static bool _visible;
private static bool _layoutDirty = true;
private static string _layoutKey = "";
private static bool _dossierRowsNeedRestore;
private static float _nextPortraitAt;
private static long _portraitActorId;
private static string _statusBanner = "";
private static float _statusBannerUntil;
private static string _lastLoggedCaption = "";
@ -698,8 +703,8 @@ public static class WatchCaption
}
else
{
// RefreshStorySpine already refreshes Saga body when the Saga tab is effective.
RefreshStorySpine();
RefreshSagaBody();
}
}
@ -759,6 +764,7 @@ public static class WatchCaption
return;
}
_layoutDirty = true;
// Route through spine/tab refresh so Saga body and rail stay consistent.
RefreshStorySpine();
}
@ -838,6 +844,17 @@ public static class WatchCaption
return;
}
long id = EventFeedUtil.SafeId(actor);
float now = Time.unscaledTime;
// Same unit: tile refresh every frame hitchs; keep the live pose at ~5 Hz.
// Focus change always applies immediately.
if (id == _portraitActorId && now < _nextPortraitAt)
{
return;
}
_portraitActorId = id;
_nextPortraitAt = now + 0.2f;
DossierAvatar.Show(actor);
BringHeaderFront();
}
@ -946,6 +963,8 @@ public static class WatchCaption
LifeSagaPanel.Clear();
LifeSagaRail.Clear();
SetTabsVisible(false);
_layoutDirty = true;
_dossierRowsNeedRestore = true;
return;
}
@ -956,6 +975,7 @@ public static class WatchCaption
if (!string.Equals(next, LastStorySpine, StringComparison.Ordinal))
{
ApplyStorySpine(next);
_layoutDirty = true;
}
LifeSagaRail.Refresh();
@ -969,13 +989,20 @@ public static class WatchCaption
if (sagaMode)
{
RefreshSagaBody();
_dossierRowsNeedRestore = true;
}
else
{
LifeSagaPanel.SetVisible(false);
// Saga force-hides dossier rows and deactivates the reason GO. Restore from the
// bound snapshot before Relayout so hover-exit does not wait for a tip change.
RestoreDossierRowsAfterSaga();
// Saga force-hides dossier rows and deactivates the reason GO. Restore once when
// leaving Saga/hover so hover-exit does not wait for a tip change (not every frame).
if (_dossierRowsNeedRestore)
{
RestoreDossierRowsAfterSaga();
_dossierRowsNeedRestore = false;
_layoutDirty = true;
}
traitCount = CountActiveTraitSlots();
statusCount = CountActiveStatusSlots();
historyCount = CountActiveHistorySlots();
@ -988,7 +1015,16 @@ public static class WatchCaption
|| _boundActor != null
|| historyCount > 0
|| _current != null;
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
string layoutKey =
$"{(sagaMode ? 1 : 0)}|{next}|{traitCount}|{statusCount}|{historyCount}"
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}";
if (_layoutDirty || !string.Equals(layoutKey, _layoutKey, StringComparison.Ordinal))
{
_layoutKey = layoutKey;
_layoutDirty = false;
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
}
}
/// <summary>

View file

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

View file

@ -136,7 +136,7 @@
"sagaPreferWeight": 16,
"sagaGameFavoriteWeight": 8,
"sagaCastWeight": 5,
"sagaCrossMcBonus": 6,
"sagaCrossMcBonus": 11,
"sagaRivalEncounterThreshold": 3,
"sagaDullSeconds": 300,
"familyLifeBeatCooldownSeconds": 90,

View file

@ -137,9 +137,17 @@ Unwired ManagerEnd / Beh rows exist in TSVs but are filtered out of `mutation_ga
## Tip quality gap (main expansion lever)
`EventReason` / discrete `MakeLabel` replace `{id}` with the raw asset id.
Status/trait Activity and dossier paths already use locale/`getTranslatedName` helpers (`LiveEnsemble.StatusDisplayName`, trait display names).
Library event Labels do not.
Status (2026-07-22): **class path shipped** - discrete `MakeLabel` resolves `{id}` via
`LibraryAssetNames` when `LibraryKind` is set on live-seeded library rows;
WorldLog `MakeLabel` prefers `world_log_library` translated names, else humanize.
Remaining work is playtest-driven Signal A / weak decision-WorldLog prose only where
soak or camera-A inventory still shows awkward phrases - not mass new JSON libraries.
Previous gap notes (historical):
`EventReason` / discrete `MakeLabel` used to replace `{id}` with the raw asset id.
Status/trait Activity and dossier paths already used locale/`getTranslatedName` helpers.
Examples of weak watch tips today:

View file

@ -47,8 +47,9 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
- 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 > MC cast > CausalHeat.
- `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28).
- Soft ranks: Prefer > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
- Tips that touch two or more roster MCs get `sagaCrossMcBonus`.
- Soft ranks: Prefer > CrossMC (>1 roster MC) > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
- Tips that touch two or more roster MCs get `sagaCrossMcBonus` (~11); scoring touch inventory matches rail Involved ids.
- Sticky combat/war/plot maintain heats both MCs early when two roster principals share the tip.
- Mid-fight follow uses Prefer/MC/cast soft weight in `CombatFocusWeight` (spectacle floors can still win).
- Ambient / character-fill yields immediately to Prefer/MC tips (not vs crisis/combat/short-arc holds).
- Soft-fill quiet still suppresses anonymous crumbs; Prefer/MC tips may break quiet.
@ -88,7 +89,15 @@ Notable only:
- MC↔MC living rematches earn rivalry one encounter sooner; a single MC kill is crossover Legacy, not automatic RivalEarned.
- Cast shows living credible rivals only; weak kill-cycle `repeated_encounters` facts are pruned.
- 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.
- Unresolved `WarJoin` / `PlotJoin` Others fill Cast as `War foe` / `Plot ally` when still living.
- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers (`Packmate` for Pack, `Kin` otherwise) after lover/friend/parents/children/heir/rival.
## Stake
- Primary stake: strongest unresolved war/plot/rival/role/founding/grief (and McCrossover beats of those kinds).
- Fallback stake when nothing stronger exists: unresolved `HardArcCombat`, open `BondFormed`, or McCrossover `Kill`.
- War/plot prose names the opposing principal and kingdom/plot display when known.
- Role rises (king/leader/alpha/clan chief/army captain) emit `RoleChange` from live roster deltas.
## Cross-MC moments
@ -102,11 +111,24 @@ After hard story/crisis closers, soft-fill quiet suppresses soft-life crumbs and
Combat / war / plot / outbreak / earthquake / story beats / epic strength / Prefer/MC still cut in.
Quiet can refresh briefly when a soft crumb ends (bounded).
## Performance (idle)
- Roster role refresh runs on the ~3.5s amortized scan (not every frame). Soft heat does not force a world Refill.
- Dirty Prefer / membership changes re-rank existing slots only; full world admission stays on the timed cadence.
- World admission walks ~260 units/frame, then BuildSlots only the best ~2× Cap challengers (skips when pins fill Cap).
- Dossier `Relayout` runs only when chrome geometry changes; leaving Saga/hover restores dossier rows once.
- Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons).
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
- Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel.
- Hitch probe: harness `hitch_probe` / load save via `load_save_slot` (slot 2 = Box of Magic). Optional `.harness/autoload_slot`.
## 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.
- World replacement, map load-complete (`MapBox.finishingUpLoading`), `clearWorld`, harness batch end, and `interest_saga_clear` / `saga_session_reset` wipe roster + memory.
- Bind key uses live `map_stats.name`, `current_world_seed_id`, `map_stats` identity, `_load_counter`, and map size - not world reference alone.
- Same-map spectator re-enable does not clear when the bind key is unchanged.
- Read-only soak dumps use `soak_probe` (or `snapshot` with `value=preserve`) and skip the batch-end wipe.
## Harness
@ -118,6 +140,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `saga_combat_follow_mc`
- `saga_cast_soft_bias`
- `saga_cross_mc_light`
- `saga_cross_mc_wins_gap`
- `saga_cross_mc_kill`
- `saga_rival_not_single_kill`
- `saga_rival_rematch`
@ -126,6 +149,11 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- `saga_overview_has_mc`
- `saga_adaptive_dossier`
- `saga_memory_survives`
- `saga_war_memory_both_sides`
- `saga_plot_cast_members`
- `saga_cast_kin_non_pack_lens`
- `saga_stake_from_role_live`
- `saga_session_reset`
- `saga_hover_read_pause`
- `saga_replace_on_death`
- `saga_admit_roles`

View file

@ -38,7 +38,7 @@ Edit the `weights` block in scoring-model.json to change live scoring formula kn
Edit the `story` block for StoryPlanner / life-saga soft bias:
- `nearTieEpsilon` / `sagaNearTieEpsilon` - ordinary vs Prefer/MC/cast pick bands
- `sagaMcWeight` / `sagaPreferWeight` / `sagaCastWeight` / `sagaCrossMcBonus` - tip score soft bias
- `sagaMcWeight` / `sagaPreferWeight` / `sagaCastWeight` / `sagaCrossMcBonus` - tip score soft bias (CrossMC bonus ~11; SoftBias Prefer > CrossMC > MC > cast)
- `sagaRivalEncounterThreshold` - living rematches before EarnRival
Edit event-catalog.json to change what each event is worth and what it says.

View file

@ -62,6 +62,8 @@ Phases: `Climax → Aftermath → Epilogue → Done`.
- Soft duel Mass↔Duel key flips still cold-hook once per theater anchor.
- **Epilogue** once after aftermath if a living lover/friend resolves.
- Soft anonymous duels still get aftermath tips; they do not use raised `arcHoldMargin`.
- Aftermath follow, cast recovery, and epilogue pick Prefer/MC among living theater/cast candidates via `LifeSagaRoster.PreferRosterUnit` (never MCs outside the cast).
- Parked-board trim drops anonymous scraps before Prefer/MC/cast arcs when over cap.
## Dossier spine