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:
parent
dafe79dfa2
commit
ba58a9ff38
30 changed files with 2955 additions and 321 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
191
IdleSpectator/IdleHitchProbe.cs
Normal file
191
IdleSpectator/IdleHitchProbe.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
172
IdleSpectator/Story/LifeSagaSession.cs
Normal file
172
IdleSpectator/Story/LifeSagaSession.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
IdleSpectator/Story/LifeSagaSessionPatches.cs
Normal file
36
IdleSpectator/Story/LifeSagaSessionPatches.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
"sagaPreferWeight": 16,
|
||||
"sagaGameFavoriteWeight": 8,
|
||||
"sagaCastWeight": 5,
|
||||
"sagaCrossMcBonus": 6,
|
||||
"sagaCrossMcBonus": 11,
|
||||
"sagaRivalEncounterThreshold": 3,
|
||||
"sagaDullSeconds": 300,
|
||||
"familyLifeBeatCooldownSeconds": 90,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue