diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 06c919f..be93d97 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -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(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(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(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; } + /// + /// Load a numbered save slot (1-5). Hitch soaks use slot 2 (Box of Magic). + /// + 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()); + 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)) diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index 2397b8f..be0ecce 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -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); } diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs index 781f97b..cd0c1ac 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs @@ -71,7 +71,8 @@ public static partial class EventCatalog signals, d.SignalStrength, signalPredicate: IsSpectacleSpell, - ambientCreatesInterest: d.AmbientCreatesInterest); + ambientCreatesInterest: d.AmbientCreatesInterest, + libraryKind: "spell"); } /// @@ -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 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 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 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 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 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 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 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 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 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 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"); } } } diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs b/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs index 86031bb..309b9cb 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs @@ -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) diff --git a/IdleSpectator/Events/Catalogs/LiveLibraryInterest.cs b/IdleSpectator/Events/Catalogs/LiveLibraryInterest.cs index 2701388..2b30cad 100644 --- a/IdleSpectator/Events/Catalogs/LiveLibraryInterest.cs +++ b/IdleSpectator/Events/Catalogs/LiveLibraryInterest.cs @@ -18,7 +18,8 @@ public static class LiveLibraryInterest HashSet signalIds, float signalStrength, Func 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 }; } diff --git a/IdleSpectator/Events/DiscreteEventEntry.cs b/IdleSpectator/Events/DiscreteEventEntry.cs index 17959c6..df77334 100644 --- a/IdleSpectator/Events/DiscreteEventEntry.cs +++ b/IdleSpectator/Events/DiscreteEventEntry.cs @@ -16,6 +16,10 @@ public sealed class DiscreteEventEntry /// (e.g. change the kingdom's culture{a} decides to …). /// public string ActionPhrase = ""; + /// + /// When set, {id} resolves via for this domain. + /// + 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 ?? ""); } + + /// Localized library name when is set; else humanized id. + 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); + } } diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 9bf30ed..d572e94 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -13,7 +13,8 @@ public static class InterestFeeds private static ulong _happinessCursor; private static readonly List HappinessDrain = new List(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 CivicBoostUntil = new Dictionary(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) diff --git a/IdleSpectator/Events/Patches/PlotEventPatches.cs b/IdleSpectator/Events/Patches/PlotEventPatches.cs index a36b94a..18c5722 100644 --- a/IdleSpectator/Events/Patches/PlotEventPatches.cs +++ b/IdleSpectator/Events/Patches/PlotEventPatches.cs @@ -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 { diff --git a/IdleSpectator/Events/Patches/WarEventPatches.cs b/IdleSpectator/Events/Patches/WarEventPatches.cs index 81eec00..9707a02 100644 --- a/IdleSpectator/Events/Patches/WarEventPatches.cs +++ b/IdleSpectator/Events/Patches/WarEventPatches.cs @@ -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 ""; + } + } } diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 3ec0a91..be61c48 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -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 }; } + /// Aftermath follow prefers a roster MC among living theater candidates. + private static List StoryAftermathPrefersMc() + { + return new List + { + 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"), + }; + } + + /// Cast recovery after follow drop prefers a living roster MC on the arc. + private static List StoryRecoverFollowMc() + { + return new List + { + 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"), + }; + } + + /// + /// Parked-board trim keeps Prefer/MC arcs when over cap (anonymous scraps drop first). + /// + private static List StoryParkTrimKeepsMc() + { + return new List + { + 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 StoryAftermathWar() { return new List @@ -3112,6 +3247,43 @@ internal static class HarnessScenarios }; } + /// + /// Two roster MCs on one tip get CrossMC SoftBias + cross bonus and Involved chrome; + /// a hotter stranger does not displace the live cross tip. + /// + private static List SagaCrossMcWinsGap() + { + return new List + { + 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"), + }; + } + /// MC kills MC → both memories hold McCrossover facts. private static List SagaCrossMcKill() { @@ -3408,6 +3580,161 @@ internal static class HarnessScenarios }; } + /// WarJoin snapshots both principals with Other ids and resolves on end. + private static List SagaWarMemoryBothSides() + { + return new List + { + 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"), + }; + } + + /// Unresolved PlotJoin Other lands in Cast as Plot ally. + private static List SagaPlotCastMembers() + { + return new List + { + 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"), + }; + } + + /// Crown lens still shows family Parent in Cast (peer fill not Pack-only). + private static List SagaCastKinNonPackLens() + { + return new List + { + 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"), + }; + } + + /// Live roster role rise becomes RoleChange stake; combat is fallback when alone. + private static List SagaStakeFromRoleLive() + { + return new List + { + 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"), + }; + } + + /// + /// Session reset clears roster + memory even when World.world reference is unchanged. + /// Same-map spectator toggle must not wipe. + /// + private static List SagaSessionReset() + { + return new List + { + 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"), + }; + } + /// Hover acquires read-pause; camera owner stays fixed until release. private static List SagaHoverReadPause() { diff --git a/IdleSpectator/IdleHitchProbe.cs b/IdleSpectator/IdleHitchProbe.cs new file mode 100644 index 0000000..528baa6 --- /dev/null +++ b/IdleSpectator/IdleHitchProbe.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using NeoModLoader.services; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Lightweight idle hitch sampler. Logs when a frame or idle subsystem exceeds thresholds. +/// Enable via harness hitch_probe or temporary always-on while diagnosing FPS hitching. +/// +public static class IdleHitchProbe +{ + public static bool Enabled { get; private set; } + + /// Frame delta (unscaled) above this counts as a hitch candidate. + public static float FrameSpikeSeconds { get; set; } = 0.028f; + + /// Idle subsystem slice above this is attributed in the log. + 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; + } +} diff --git a/IdleSpectator/InterestDirector.StickyMaintain.cs b/IdleSpectator/InterestDirector.StickyMaintain.cs index 2fedb75..a54f42d 100644 --- a/IdleSpectator/InterestDirector.StickyMaintain.cs +++ b/IdleSpectator/InterestDirector.StickyMaintain.cs @@ -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) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index cab7dd5..e89f59f 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -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); } diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index dcd23c1..ead57a4 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -54,7 +54,7 @@ public class StoryWeights /// Soft bonus when tip touches an MC's story cast (lover/friend/kin/rival). public float sagaCastWeight = 5f; /// Extra tip bonus when subject/related/follow touch two or more roster MCs. - public float sagaCrossMcBonus = 6f; + public float sagaCrossMcBonus = 11f; /// Living rematch count before EarnRival (kill/death do not count). public int sagaRivalEncounterThreshold = 3; /// Chapter starvation window before dull non-favorite MCs lose rank. diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index b52dd82..de278f5 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -754,7 +754,7 @@ public static class InterestVariety } /// - /// Prefer (3) > MC (2) > MC cast (1) among candidates within of #1. + /// Prefer (4) > CrossMC (3) > MC (2) > MC cast (1) among candidates within of #1. /// private static InterestCandidate PickBestSagaInBand( List ranked, diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index eec5957..268f35c 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -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 } } + /// + /// Dev/agent helper: .harness/autoload_slot containing a slot number (1-5) + /// sets Config.load_save_on_start so Box of Magic (slot 2) can come up after reboot. + /// + 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(); } } diff --git a/IdleSpectator/Story/LifeSagaMemory.cs b/IdleSpectator/Story/LifeSagaMemory.cs index bd0ee38..a66a4a8 100644 --- a/IdleSpectator/Story/LifeSagaMemory.cs +++ b/IdleSpectator/Story/LifeSagaMemory.cs @@ -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(); + /// Called by after a session reset. + 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 ?? ""); + } + + /// + /// Snapshot both kingdom principals for a war tip/object. On end, resolve all open joins for the war key. + /// + 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); } + /// + /// 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. + /// + 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(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 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 } } + /// + /// Weaker unresolved facts used only when no primary stake exists. + /// Kind-gated: HardArcCombat, open BondFormed, and McCrossover Kill (Kill already via McCrossover path). + /// + 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; } diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index f3d4598..285dd71 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -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)) diff --git a/IdleSpectator/Story/LifeSagaRail.cs b/IdleSpectator/Story/LifeSagaRail.cs index f9b0fe7..568db8b 100644 --- a/IdleSpectator/Story/LifeSagaRail.cs +++ b/IdleSpectator/Story/LifeSagaRail.cs @@ -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 SlotScratch = new List(MaxSlots); private static readonly List InvolvedScratch = new List(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; diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index 5fe18aa..08fb893 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -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 Slots = new List(Cap); private static readonly List Scratch = new List(48); + private static readonly List ChallengerScratch = new List(64); + private static readonly List UnitScanSnapshot = new List(512); + private static readonly HashSet ScanSeen = new HashSet(); private static readonly HashSet PreviousIds = new HashSet(); private static readonly HashSet PlotAuthorCache = new HashSet(); private static readonly Dictionary SpeciesCountCache = new Dictionary(); + private static readonly HashSet McOrbitCache = new HashSet(); + private static readonly List TouchScratch = new List(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 + } + + /// Units probed per frame during amortized world admission. + private const int WorldScanUnitsPerFrame = 260; + + /// Cadence for starting a spread world admission scan. + private const float WorldScanIntervalSeconds = 3.5f; + + /// Last timed world-scan refill cost in ms (hitch probe). + public static float LastWorldScanMs { get; private set; } + + /// Last timed soft refill cost in ms (hitch probe). + 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)); } - /// Prefer=3, MC=2, MC cast=1, else 0. + /// Prefer=4, CrossMC=3, MC=2, MC cast=1, else 0. 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; } + /// Near-tie breaker: Prefer > CrossMC > MC > MC cast > neither. + public static int CompareSoftBias(InterestCandidate a, InterestCandidate b) + { + return SoftBiasRank(b).CompareTo(SoftBiasRank(a)); + } + /// True when unit is in a roster MC's live story orbit (not themselves an MC). 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; - } - - /// Near-tie breaker: Prefer > MC > MC cast > neither. - public static int CompareSoftBias(InterestCandidate a, InterestCandidate b) - { - return SoftBiasRank(b).CompareTo(SoftBiasRank(a)); + return McOrbitCache.Contains(unitId); } /// @@ -523,31 +551,31 @@ public static class LifeSagaRoster public static int CountTouchedMcs(InterestCandidate tip) { - if (tip == null) + CollectTouchedMcIds(tip, TouchScratch); + return TouchScratch.Count; + } + + /// + /// 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. + /// + 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); + } + + /// + /// Spread admission discovery across frames so a 2k-unit map cannot hitch on one Tick. + /// + 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; + } } /// @@ -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 seen = new HashSet(); 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; } + /// + /// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles. + /// + 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 MemberCache = + new Dictionary(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"); + } + } + + /// + /// Harness: simulate a live role rising edge on an existing roster slot (does not mutate game offices). + /// + 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) diff --git a/IdleSpectator/Story/LifeSagaSession.cs b/IdleSpectator/Story/LifeSagaSession.cs new file mode 100644 index 0000000..dfcb557 --- /dev/null +++ b/IdleSpectator/Story/LifeSagaSession.cs @@ -0,0 +1,172 @@ +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using HarmonyLib; +using NeoModLoader.services; + +namespace IdleSpectator; + +/// +/// 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. +/// +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 ?? ""; + + /// Clear roster + memory + saga UI and rebind to the current world. + 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}"); + } + } + + /// + /// Bind or reset when the world identity / bind key changes. + /// No-op when the same map session is still live (spectator toggle safe). + /// + 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"); + } + + /// + /// Map load finished on the live MapBox instance. + /// Always wipe even when the world reference and name look unchanged. + /// + 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; + } + } +} diff --git a/IdleSpectator/Story/LifeSagaSessionPatches.cs b/IdleSpectator/Story/LifeSagaSessionPatches.cs new file mode 100644 index 0000000..deb8f38 --- /dev/null +++ b/IdleSpectator/Story/LifeSagaSessionPatches.cs @@ -0,0 +1,36 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// Reset life-saga session when the live map finishes loading or is cleared. +[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. + } + } +} diff --git a/IdleSpectator/Story/StoryPlanner.cs b/IdleSpectator/Story/StoryPlanner.cs index 22ed1c3..9cd40b4 100644 --- a/IdleSpectator/Story/StoryPlanner.cs +++ b/IdleSpectator/Story/StoryPlanner.cs @@ -23,6 +23,7 @@ public static class StoryPlanner private static string _coldHookClimaxKey = ""; private static string _coldHookAnchor = ""; private static readonly List PendingScratch = new List(64); + private static readonly List ActorScratch = new List(16); /// Currently watching storyline (not parked). 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 } } + /// Prefer=3, MC=2, MC cast=1, else 0 for parked-board trim. + 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; diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 8b386d6..6a7e976 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -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); + } } /// diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index d3293dd..559d139 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -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" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index 7c405c4..6faa90c 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -136,7 +136,7 @@ "sagaPreferWeight": 16, "sagaGameFavoriteWeight": 8, "sagaCastWeight": 5, - "sagaCrossMcBonus": 6, + "sagaCrossMcBonus": 11, "sagaRivalEncounterThreshold": 3, "sagaDullSeconds": 300, "familyLifeBeatCooldownSeconds": 90, diff --git a/docs/event-catalog-discovery.md b/docs/event-catalog-discovery.md index 428587f..ed2b530 100644 --- a/docs/event-catalog-discovery.md +++ b/docs/event-catalog-discovery.md @@ -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: diff --git a/docs/life-saga.md b/docs/life-saga.md index 4db2cec..50836a8 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -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` diff --git a/docs/scoring-model.md b/docs/scoring-model.md index 25fda57..2f8e0b9 100644 --- a/docs/scoring-model.md +++ b/docs/scoring-model.md @@ -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. diff --git a/docs/story-planner.md b/docs/story-planner.md index 33d03e1..748ce15 100644 --- a/docs/story-planner.md +++ b/docs/story-planner.md @@ -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