From 366ae45f13e06197cf80149a7f49f72dc3aea9ba Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 17 Jul 2026 14:40:32 -0500 Subject: [PATCH] Implement combat isolation and improve combat event handling. - Add new command for isolating combat pairs to prevent ambient escalation - Enhance combat event logging and participant tracking - Optimize combat focus handling based on participant count - Update harness scenarios to validate new combat interactions - Increment version to 0.26.7 in mod.json --- .tmp-reflect/DumpActorMgr.cs | 47 +++ .tmp-reflect/DumpActorMgr.csproj | 8 + IdleSpectator/AgentHarness.cs | 124 +++++- IdleSpectator/CameraDirector.cs | 16 +- IdleSpectator/Events/EventFeedUtil.cs | 27 ++ IdleSpectator/Events/EventReason.cs | 51 +++ IdleSpectator/Events/Feeds/InterestFeeds.cs | 40 +- .../Events/Feeds/StatusOutbreakFeed.cs | 98 +++-- IdleSpectator/Events/LiveEnsemble.cs | 231 +++++++---- IdleSpectator/HarnessScenarios.cs | 20 + IdleSpectator/InterestCompletion.cs | 17 +- IdleSpectator/InterestDirector.cs | 156 +++++--- IdleSpectator/WatchCaption.cs | 31 +- IdleSpectator/WorldActivityScanner.cs | 368 +++++++++++++----- IdleSpectator/mod.json | 4 +- 15 files changed, 973 insertions(+), 265 deletions(-) create mode 100644 .tmp-reflect/DumpActorMgr.cs create mode 100644 .tmp-reflect/DumpActorMgr.csproj diff --git a/.tmp-reflect/DumpActorMgr.cs b/.tmp-reflect/DumpActorMgr.cs new file mode 100644 index 0000000..79ba445 --- /dev/null +++ b/.tmp-reflect/DumpActorMgr.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +string path = "/home/dazed/.local/share/Steam/steamapps/common/worldbox/worldbox_Data/Managed/Assembly-CSharp.dll"; +using var fs = File.OpenRead(path); +using var pe = new PEReader(fs); +var md = pe.GetMetadataReader(); +foreach (var typeName in new[]{"ActorManager","MapBox","World","Finder","BaseSystemManager`1","NanoObjectManager`2"}) { + Console.WriteLine("== "+typeName+" =="); + foreach (var th in md.TypeDefinitions) { + var t = md.GetTypeDefinition(th); + var n = md.GetString(t.Name); + var ns = md.GetString(t.Namespace); + if (n!=typeName && !(typeName.Contains('`') && n.StartsWith(typeName.Split('`')[0]))) continue; + Console.WriteLine($"TYPE {ns}.{n}"); + // base type + if (!t.BaseType.IsNil) { + try { + var bt = t.BaseType; + if (bt.Kind == HandleKind.TypeReference) { + var tr = md.GetTypeReference((TypeReferenceHandle)bt); + Console.WriteLine(" base="+md.GetString(tr.Namespace)+"."+md.GetString(tr.Name)); + } else if (bt.Kind == HandleKind.TypeSpecification) { + Console.WriteLine(" base=TypeSpec"); + } else if (bt.Kind == HandleKind.TypeDefinition) { + var td = md.GetTypeDefinition((TypeDefinitionHandle)bt); + Console.WriteLine(" base="+md.GetString(td.Namespace)+"."+md.GetString(td.Name)); + } + } catch (Exception ex) { Console.WriteLine(" baseerr "+ex.Message); } + } + foreach (var mh in t.GetMethods()) { + var m = md.GetMethodDefinition(mh); + var mn = md.GetString(m.Name); + if (mn.StartsWith("set_") || mn is ".ctor" or ".cctor") continue; + Console.WriteLine(" "+mn); + } + } +} +Console.WriteLine("== methods named getByID =="); +foreach (var mh in md.MethodDefinitions) { + var m = md.GetMethodDefinition(mh); + var mn = md.GetString(m.Name); + if (mn is "getByID" or "get" or "getActor") { + // find owning type - expensive: skip, just print token + Console.WriteLine(mn+" token="+mh.GetHashCode()); + } +} diff --git a/.tmp-reflect/DumpActorMgr.csproj b/.tmp-reflect/DumpActorMgr.csproj new file mode 100644 index 0000000..64e34a8 --- /dev/null +++ b/.tmp-reflect/DumpActorMgr.csproj @@ -0,0 +1,8 @@ + + + Exe + net8.0 + enable + enable + + diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 307aadc..1334ebf 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2310,6 +2310,10 @@ public static class AgentHarness DoOutbreakEnsembleApply(cmd); break; + case "combat_isolate_pair": + DoCombatIsolatePair(cmd); + break; + case "combat_wire_attack_sides": DoCombatWireAttackSides(cmd); break; @@ -5099,7 +5103,15 @@ public static class AgentHarness return; } - scene.ClearCombatSticky(); + // spawn_only: seed camps near the Duel without rewriting tip/sticky (natural escalate gate). + bool spawnOnly = (cmd.expect ?? "").IndexOf("spawn_only", StringComparison.OrdinalIgnoreCase) >= 0 + || string.Equals(cmd.label, "spawn_only", StringComparison.OrdinalIgnoreCase); + + if (!spawnOnly) + { + scene.ClearCombatSticky(); + } + int want = 6; if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want)) { @@ -5132,6 +5144,16 @@ public static class AgentHarness SpawnAssetNear(relatedSpecies, focus.current_position, 3f + i * 0.4f); } + if (spawnOnly) + { + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"spawn_only n={want} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)}"); + return; + } + var ensemble = new LiveEnsemble { Kind = EnsembleKind.Combat, @@ -5179,6 +5201,106 @@ public static class AgentHarness detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}"); } + /// + /// Kill living units near the combat pair so ambient packs cannot escalate a 1v1 Duel tip. + /// value = radius (default 18). Keeps Follow + Related (+ optional PairOwner/Partner ids). + /// + private static void DoCombatIsolatePair(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + Actor keepA = scene?.FollowUnit; + Actor keepB = scene?.RelatedUnit; + if (keepA == null || !keepA.isAlive()) + { + keepA = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + if ((keepB == null || !keepB.isAlive()) && scene != null) + { + if (scene.PairPartnerId != 0) + { + keepB = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); + } + + if ((keepB == null || !keepB.isAlive()) && scene.PairOwnerId != 0) + { + Actor owner = LiveEnsemble.FindTrackedActor(scene.PairOwnerId); + if (owner != null && owner != keepA && owner.isAlive()) + { + keepB = owner; + } + } + } + + if (keepA == null || !keepA.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_pair"); + return; + } + + float radius = 18f; + if (!string.IsNullOrEmpty(cmd.value) + && float.TryParse(cmd.value, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed) + && parsed > 1f) + { + radius = parsed; + } + + float r2 = radius * radius; + Vector2 origin = keepA.current_position; + int culled = 0; + var doomed = new List(32); + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive() || actor == keepA || actor == keepB) + { + continue; + } + + try + { + Vector2 d = actor.current_position - origin; + if (d.sqrMagnitude > r2) + { + continue; + } + } + catch + { + continue; + } + + doomed.Add(actor); + } + + for (int i = 0; i < doomed.Count; i++) + { + try + { + doomed[i].die(true, AttackType.Divine); + culled++; + } + catch + { + // keep going + } + } + + if (scene != null) + { + scene.ClearCombatSticky(); + scene.ParticipantCount = keepB != null && keepB.isAlive() ? 2 : 1; + scene.CombatPeakParticipants = scene.ParticipantCount; + } + + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"culled={culled} keepA={SafeName(keepA)} keepB={SafeName(keepB)} r={radius:0.#} tip='{CameraDirector.LastWatchLabel}'"); + } + /// /// Best-effort: set attack_target across two species camps near the combat focus /// so sees a real multi-fighter cluster. diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index b20d2a0..dcac631 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -40,12 +40,14 @@ public static class CameraDirector bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal) || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal) || !string.Equals(assetId, LastWatchAssetId, StringComparison.Ordinal); + bool headcountOnly = tipChanged + && EventReason.IsHeadcountOnlyChange(LastWatchLabel, label); LastFormattedWatchTip = tip; // LastWatchLabel is tip/harness telemetry only - never dossier reason truth. LastWatchLabel = label; LastWatchAssetId = assetId; - // Sticky maintain can call Watch every tick - only log when the tip changes. - if (tipChanged) + // Sticky Mass tips tick headcounts often - log structure changes only. + if (tipChanged && !headcountOnly) { LogService.LogInfo($"[IdleSpectator] {tip}"); } @@ -56,7 +58,15 @@ public static class CameraDirector // Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier // never diverge across a handoff frame. RetargetFollow(follow); - WatchCaption.SetFromActor(follow); + // Same subject: skip full UnitDossier.FromActor rebuild (was walking every + // alive unit for species counts on every Mass headcount tip refresh). + // WatchCaption.Update already refreshes reason/task/identity live. + long followId = EventFeedUtil.SafeId(follow); + if (followId == 0 || followId != WatchCaption.CurrentUnitId) + { + WatchCaption.SetFromActor(follow); + } + return; } diff --git a/IdleSpectator/Events/EventFeedUtil.cs b/IdleSpectator/Events/EventFeedUtil.cs index 752f4a3..e2fa521 100644 --- a/IdleSpectator/Events/EventFeedUtil.cs +++ b/IdleSpectator/Events/EventFeedUtil.cs @@ -180,6 +180,33 @@ public static class EventFeedUtil } } + /// + /// O(1) living actor lookup via ActorManager.get. Prefer this over scanning + /// . + /// + public static Actor FindAliveById(long id) + { + if (id == 0 || World.world?.units == null) + { + return null; + } + + try + { + Actor actor = World.world.units.get(id); + if (actor != null && actor.isAlive()) + { + return actor; + } + } + catch + { + // ignore lookup failures + } + + return null; + } + public static string SafeName(Actor actor) { if (actor == null) diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index 5acdf9b..a915279 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -11,6 +11,57 @@ namespace IdleSpectator; /// public static class EventReason { + /// + /// True when two labels differ only by parenthesized headcounts + /// (e.g. Mass - A (12) vs B (3)Mass - A (11) vs B (4)). + /// + public static bool IsHeadcountOnlyChange(string previous, string next) + { + if (string.IsNullOrEmpty(previous) || string.IsNullOrEmpty(next)) + { + return false; + } + + if (string.Equals(previous, next, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(StripParenCounts(previous), StripParenCounts(next), StringComparison.Ordinal); + } + + private static string StripParenCounts(string text) + { + var sb = new StringBuilder(text.Length); + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c != '(') + { + sb.Append(c); + continue; + } + + int j = i + 1; + bool digits = j < text.Length && char.IsDigit(text[j]); + while (j < text.Length && char.IsDigit(text[j])) + { + j++; + } + + if (digits && j < text.Length && text[j] == ')') + { + sb.Append("()"); + i = j; + continue; + } + + sb.Append(c); + } + + return sb.ToString(); + } + public static string Fight(Actor a, Actor b) { string name = Name(a); diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 22a86f3..1fd1df8 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -1073,8 +1073,38 @@ public static class InterestFeeds return; } - // Top battle cluster. - InterestEvent battle = WorldActivityScanner.FindHottestBattle(); + InterestCandidate current = InterestDirector.CurrentCandidate; + bool stickyCombat = current != null + && current.Completion == InterestCompletionKind.CombatActive + && InterestDirector.CurrentIsActive + && current.ParticipantCount >= 2; + + InterestEvent battle; + if (stickyCombat) + { + // Sticky owns the camera - never CollectAllCombatFighters. Chunk/BattleKeeper only. + Vector3 anchor = current.Position; + try + { + if (current.FollowUnit != null && current.FollowUnit.isAlive()) + { + anchor = current.FollowUnit.current_position; + } + } + catch + { + // keep Position + } + + battle = WorldActivityScanner.FindHottestBattleLight(anchor); + } + else + { + // Quiet / non-sticky: full ranking is OK (one fighter pass per ScanInterval). + battle = WorldActivityScanner.FindHottestBattle(); + WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); + } + if (battle != null) { if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) @@ -1082,8 +1112,9 @@ public static class InterestFeeds { // Active combat scene refreshed in place. } - else + else if (!stickyCombat) { + // Do not let a far-away light tip steal sticky ownership via RegisterDirect. RegisterDirect(battle); } } @@ -1099,9 +1130,6 @@ public static class InterestFeeds // Nearby multi-carrier status clusters. StatusOutbreakFeed.Tick(); - - // Character-led vignette seeds (not sole one-best monopoly: register best + a few notables). - WorldActivityScanner.RegisterTopCharacterCandidates(max: 4); } private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId) diff --git a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs index bd4463d..1188e92 100644 --- a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs +++ b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs @@ -65,6 +65,12 @@ public static class StatusOutbreakFeed ApplySticky(registered, ensemble, statusId, pos); } + private static float _lastTickAt = -999f; + private static List _sampleStatusIds; + private static float _sampleStatusIdsAt = -999f; + private const float OutbreakTickSeconds = 2.5f; + private const float StatusIdCacheSeconds = 30f; + public static void Tick() { if (!AgentHarness.LiveFeedsAllowed) @@ -72,35 +78,30 @@ public static class StatusOutbreakFeed return; } + // Sticky combat already owns the shot - do not walk every unit × status id. + InterestCandidate current = InterestDirector.CurrentCandidate; + if (current != null + && current.Completion == InterestCompletionKind.CombatActive + && InterestDirector.CurrentIsActive + && current.ParticipantCount >= 3) + { + return; + } + + float now = Time.unscaledTime; + if (now - _lastTickAt < OutbreakTickSeconds) + { + return; + } + + _lastTickAt = now; + // Sample live affliction statuses from living units (HotStatusIds + discovered ids). int registered = 0; var seenStatus = new HashSet(StringComparer.OrdinalIgnoreCase); - var sampleIds = new List(HotStatusIds.Length + 16); - sampleIds.AddRange(HotStatusIds); - List liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds(); - for (int i = 0; i < liveIds.Count; i++) - { - string id = liveIds[i]; - if (string.IsNullOrEmpty(id)) - { - continue; - } - - bool already = false; - for (int j = 0; j < sampleIds.Count; j++) - { - if (sampleIds[j].Equals(id, StringComparison.OrdinalIgnoreCase)) - { - already = true; - break; - } - } - - if (!already && IsOutbreakWorthy(id)) - { - sampleIds.Add(id); - } - } + List sampleIds = GetSampleStatusIds(now); + int checkedUnits = 0; + const int maxUnitsToProbe = 80; foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { @@ -109,6 +110,12 @@ public static class StatusOutbreakFeed break; } + checkedUnits++; + if (checkedUnits > maxUnitsToProbe) + { + break; + } + for (int s = 0; s < sampleIds.Count; s++) { string statusId = sampleIds[s]; @@ -136,6 +143,45 @@ public static class StatusOutbreakFeed } } + private static List GetSampleStatusIds(float now) + { + if (_sampleStatusIds != null && now - _sampleStatusIdsAt < StatusIdCacheSeconds) + { + return _sampleStatusIds; + } + + var sampleIds = new List(HotStatusIds.Length + 16); + sampleIds.AddRange(HotStatusIds); + List liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds(); + for (int i = 0; i < liveIds.Count; i++) + { + string id = liveIds[i]; + if (string.IsNullOrEmpty(id)) + { + continue; + } + + bool already = false; + for (int j = 0; j < sampleIds.Count; j++) + { + if (sampleIds[j].Equals(id, StringComparison.OrdinalIgnoreCase)) + { + already = true; + break; + } + } + + if (!already && IsOutbreakWorthy(id)) + { + sampleIds.Add(id); + } + } + + _sampleStatusIds = sampleIds; + _sampleStatusIdsAt = now; + return sampleIds; + } + /// /// Outbreak tips are lasting affliction / disease / spectacle clusters (2+ nearby). /// Never love/emotion/ambient crowds, and never brief combat interrupt FX diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs index 613419c..2147a29 100644 --- a/IdleSpectator/Events/LiveEnsemble.cs +++ b/IdleSpectator/Events/LiveEnsemble.cs @@ -891,23 +891,7 @@ public sealed class LiveEnsemble return true; } - public static Actor FindTrackedActor(long id) - { - if (id == 0) - { - return null; - } - - foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) - { - if (actor != null && actor.isAlive() && EventFeedUtil.SafeId(actor) == id) - { - return actor; - } - } - - return null; - } + public static Actor FindTrackedActor(long id) => EventFeedUtil.FindAliveById(id); /// True when Side A is a synthetic plotter camp key. public static bool IsPlotterSideKey(string key) => @@ -1333,11 +1317,42 @@ public sealed class LiveEnsemble return sb.ToString(); } - private static void CollectCombatFighters(Vector2 pos, float radius, List into) + /// One full-world pass of living combat participants (attack target or AI in_combat). + public static void CollectAllCombatFighters(List into) { - float r2 = radius * radius; + if (into == null) + { + return; + } + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { + if (IsCombatParticipant(actor)) + { + into.Add(actor); + } + } + } + + /// + /// Filter a precollected fighter list into by radius. + /// Avoids a second full-world scan when the caller already has all fighters. + /// + public static void CollectCombatFightersNear( + IList source, + Vector2 pos, + float radius, + List into) + { + if (source == null || into == null) + { + return; + } + + float r2 = radius * radius; + for (int i = 0; i < source.Count; i++) + { + Actor actor = source[i]; if (actor == null || !actor.isAlive()) { continue; @@ -1350,12 +1365,123 @@ public sealed class LiveEnsemble continue; } - if (!IsCombatParticipant(actor)) + into.Add(actor); + } + } + + private static void CollectCombatFighters(Vector2 pos, float radius, List into) + { + if (into == null) + { + return; + } + + ForEachNearbyActor(pos, radius, requireCombat: true, actor => into.Add(actor)); + } + + /// + /// Visit living actors near . Prefers chunk-local lookup; + /// falls back to a full alive-unit walk only when chunks yield nothing. + /// + private static void ForEachNearbyActor( + Vector2 pos, + float radius, + bool requireCombat, + Action visit) + { + if (visit == null) + { + return; + } + + float r2 = radius * radius; + if (TryForEachNearbyFromChunks(pos, radius, r2, requireCombat, visit)) + { + return; + } + + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive()) { continue; } - into.Add(actor); + if (requireCombat && !IsCombatParticipant(actor)) + { + continue; + } + + float dx = actor.current_position.x - pos.x; + float dy = actor.current_position.y - pos.y; + if (dx * dx + dy * dy > r2) + { + continue; + } + + visit(actor); + } + } + + private static bool TryForEachNearbyFromChunks( + Vector2 pos, + float radius, + float r2, + bool requireCombat, + Action visit) + { + try + { + if (World.world == null) + { + return false; + } + + WorldTile tile = World.world.GetTile((int)pos.x, (int)pos.y) + ?? World.world.GetTileSimple((int)pos.x, (int)pos.y); + if (tile == null) + { + return false; + } + + // Generous chunk ring so radius queries are not truncated, then fall back + // to a full walk only when the ring is empty (visited == 0). + int chunkRange = Mathf.Clamp(Mathf.CeilToInt(radius / 4f) + 2, 2, 12); + IEnumerable nearby = Finder.getUnitsFromChunk(tile, chunkRange); + if (nearby == null) + { + return false; + } + + int visited = 0; + foreach (Actor actor in nearby) + { + if (actor == null || !actor.isAlive()) + { + continue; + } + + if (requireCombat && !IsCombatParticipant(actor)) + { + continue; + } + + float dx = actor.current_position.x - pos.x; + float dy = actor.current_position.y - pos.y; + if (dx * dx + dy * dy > r2) + { + continue; + } + + visit(actor); + visited++; + } + + return visited > 0; + } + catch + { + return false; } } @@ -1412,49 +1538,39 @@ public sealed class LiveEnsemble return; } - float r2 = radius * radius; - foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + var seenA = new HashSet(sideAIds); + var seenB = new HashSet(sideBIds); + ForEachNearbyActor(pos, radius, requireCombat, actor => { - if (actor == null || !actor.isAlive()) - { - continue; - } - - if (requireCombat && !IsCombatParticipant(actor)) - { - continue; - } - - float dx = actor.current_position.x - pos.x; - float dy = actor.current_position.y - pos.y; - if (dx * dx + dy * dy > r2) - { - continue; - } - string key = frame == EnsembleFrame.KingdomVsKingdom ? KingdomKeyOf(actor) : SpeciesKeyOf(actor); if (string.IsNullOrEmpty(key)) { - continue; + return; } long id = EventFeedUtil.SafeId(actor); if (id == 0) { - continue; + return; } if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase)) { - AddUniqueId(sideAIds, id); + if (seenA.Add(id)) + { + sideAIds.Add(id); + } } else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase)) { - AddUniqueId(sideBIds, id); + if (seenB.Add(id)) + { + sideBIds.Add(id); + } } - } + }); countA = CountAliveTracked(sideAIds, out bestA, preferCombat: requireCombat); countB = CountAliveTracked(sideBIds, out bestB, preferCombat: requireCombat); @@ -1497,7 +1613,7 @@ public sealed class LiveEnsemble for (int i = ids.Count - 1; i >= 0; i--) { long id = ids[i]; - Actor actor = FindAliveById(id); + Actor actor = EventFeedUtil.FindAliveById(id); if (actor == null) { ids.RemoveAt(i); @@ -1521,29 +1637,6 @@ public sealed class LiveEnsemble return alive; } - private static Actor FindAliveById(long id) - { - if (id == 0) - { - return null; - } - - foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) - { - if (actor == null || !actor.isAlive()) - { - continue; - } - - if (EventFeedUtil.SafeId(actor) == id) - { - return actor; - } - } - - return null; - } - public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble) { if (ensemble?.SideA == null) diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 0d705b2..63b5c9c 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -1091,6 +1091,8 @@ internal static class HarnessScenarios // Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus. // expect suffix no_attack: ownership assert must not race a fast-timing kill mid-wait. Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight_no_attack"), + // Drop ambient packs left by prior escalate / --repeat so tip_same stays a true 1v1. + Step("cf20b", "combat_isolate_pair", value: "20"), Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), // Pair ownership: maintain must not flip Duel - A vs B ↔ B vs A. Step("cf21a", "remember_tip"), @@ -1122,6 +1124,24 @@ internal static class HarnessScenarios Step("cf46", "assert", expect: "dossier_matches_focus"), Step("cf47", "assert", expect: "has_focus", value: "true"), + // Natural Duel → multi: seed packs without tip rewrite; maintain must escalate. + Step("cf50", "interest_end_session"), + Step("cf50b", "spawn", asset: "human", count: 1), + Step("cf50c", "spawn", asset: "wolf", count: 1), + Step("cf50d", "pick_unit", asset: "human"), + Step("cf51", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_natural"), + Step("cf51b", "combat_isolate_pair", value: "20"), + Step("cf52", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), + Step("cf53", "combat_ensemble_escalate", value: "6", expect: "spawn_only"), + Step("cf54", "combat_wire_attack_sides", asset: "human", value: "wolf"), + Step("cf55", "wait", value: "0.4"), + Step("cf56", "combat_maintain_focus"), + Step("cf57", "wait", value: "0.4"), + Step("cf58", "combat_maintain_focus"), + Step("cf59", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("cf59b", "assert", expect: "tip_not_contains", value: "Duel"), + Step("cf59c", "assert", expect: "tip_matches_any", value: " vs "), + // Cold combat must not keep fight / ensemble tips. Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"), Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), diff --git a/IdleSpectator/InterestCompletion.cs b/IdleSpectator/InterestCompletion.cs index d464ebd..a77c09a 100644 --- a/IdleSpectator/InterestCompletion.cs +++ b/IdleSpectator/InterestCompletion.cs @@ -104,8 +104,21 @@ public static class InterestCompletion if (!hot && c.AssetId == "live_battle") { - InterestEvent battle = WorldActivityScanner.FindHottestBattle(); - hot = battle != null; + // Chunk-local only - never CollectAllCombatFighters during sticky maintain. + Vector3 pos = c.Position; + try + { + if (c.FollowUnit != null && c.FollowUnit.isAlive()) + { + pos = c.FollowUnit.current_position; + } + } + catch + { + // keep Position + } + + hot = WorldActivityScanner.HasLiveCombatNear(pos, 18f); } if (hot) diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index fbaf4e4..ad3e8ad 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -46,6 +46,9 @@ public static class InterestDirector private static float _inactiveSince = -999f; private static float _lastCombatFocusAt = -999f; private const float CombatFocusThrottleSeconds = 1.25f; + /// Large Mass sticky scenes can maintain less often - roster work is heavier. + private const float CombatFocusThrottleLargeSeconds = 2.0f; + private const int CombatFocusLargeParticipantThreshold = 16; /// Require this much extra focus weight before stealing the camera mid-fight. private const float CombatFocusSwitchMargin = 12f; private static readonly List PendingScratch = new List(96); @@ -654,7 +657,10 @@ public static class InterestDirector return; } - if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold + ? CombatFocusThrottleLargeSeconds + : CombatFocusThrottleSeconds; + if (!force && now - _lastCombatFocusAt < throttle) { return; } @@ -688,55 +694,67 @@ public static class InterestDirector && ownedRelated.isAlive() && !HasStickyCollectiveMulti(_current)); - // Fast path: living NamedPair never runs sticky Refresh (ambient camps were flipping Duels). + // Fast path: living NamedPair skips sticky Refresh (ambient camps were flipping Duels). + // Probe the local scrap first so a real sided multi can escalate Duel → Skirmish/Battle/Mass. // Skip when Follow was cleared (death / harness handoff) so Related can take ownership. if (pairOwned && _current.HasFollowUnit && ownedFocus != null && ownedFocus.isAlive() && ownedRelated != null - && ownedRelated.isAlive() - && !ShouldEscalateNamedPair(_current, null, 2)) + && ownedRelated.isAlive()) { - string pairLabel = ""; - Actor holdFocus = ownedFocus; - Actor holdFoe = ownedRelated; - int holdFighters = 2; - ApplyNamedPairHold( - _current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel); - string priorLabel = _current.Label ?? ""; - if (string.IsNullOrEmpty(pairLabel)) + LiveEnsemble.TryBuildCombat( + ownedFocus.current_position, + 14f, + out LiveEnsemble escalateProbe, + priorParticipantIds: _current.ParticipantIds, + newcomerBonus: 8f); + int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2; + if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) { - pairLabel = priorLabel; + string pairLabel = ""; + Actor holdFocus = ownedFocus; + Actor holdFoe = ownedRelated; + int holdFighters = 2; + ApplyNamedPairHold( + _current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel); + string priorLabel = _current.Label ?? ""; + if (string.IsNullOrEmpty(pairLabel)) + { + pairLabel = priorLabel; + } + + bool holdFollowChanged = _current.FollowUnit != holdFocus; + bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal); + _current.FollowUnit = holdFocus; + _current.SubjectId = EventFeedUtil.SafeId(holdFocus); + _current.RelatedUnit = holdFoe; + _current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0; + _current.Label = pairLabel; + try + { + _current.Position = holdFocus.current_position; + } + catch + { + // keep + } + + bool holdNeedWatch = forceWatch + || holdFollowChanged + || holdLabelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != holdFocus; + if (holdNeedWatch) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return true; } - bool holdFollowChanged = _current.FollowUnit != holdFocus; - bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal); - _current.FollowUnit = holdFocus; - _current.SubjectId = EventFeedUtil.SafeId(holdFocus); - _current.RelatedUnit = holdFoe; - _current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0; - _current.Label = pairLabel; - try - { - _current.Position = holdFocus.current_position; - } - catch - { - // keep - } - - bool holdNeedWatch = forceWatch - || holdFollowChanged - || holdLabelChanged - || !HasLivingCameraFocus() - || MoveCamera._focus_unit != holdFocus; - if (holdNeedWatch) - { - CameraDirector.Watch(_current.ToInterestEvent()); - } - - return true; + // Real multi around the pair: fall through to sticky rebuild / collective tip. } Vector3 pos = _current.Position; @@ -982,6 +1000,17 @@ public static class InterestDirector } else { + // Leaving NamedPair for a sided multi: drop durable pair lock so later maintains + // cannot ApplyNamedPairHold and collapse Mass/Battle back to Duel. + if (pairOwned + && fighters >= 3 + && (ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble) + || HasStickyCollectiveMulti(_current))) + { + _current.PairOwnerId = 0; + _current.PairPartnerId = 0; + } + // Never stick on a sleeper / bystander unless they are on the sticky combat roster. bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow); @@ -1034,7 +1063,7 @@ public static class InterestDirector { ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); } - } + } // Never replace a sticky scoreboard tip with thin Fight prose. if (HasStickyCombatSides(_current) @@ -1089,6 +1118,8 @@ public static class InterestDirector bool followChanged = _current.FollowUnit != best; bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); + bool headcountOnly = labelChanged + && EventReason.IsHeadcountOnlyChange(previousLabel, label ?? ""); _current.FollowUnit = best; _current.SubjectId = EventFeedUtil.SafeId(best); _current.RelatedUnit = foe; @@ -1103,9 +1134,11 @@ public static class InterestDirector // keep prior position } + // Headcount-only Mass tip churn: keep Label for live reason refresh, skip Watch + // (RetargetFollow + tip log) when the camera subject is already correct. bool needWatch = forceWatch || followChanged - || labelChanged + || (labelChanged && !headcountOnly) || !HasLivingCameraFocus() || MoveCamera._focus_unit != best; if (needWatch) @@ -1699,14 +1732,8 @@ public static class InterestDirector if (HasStickyCollectiveMulti(scene)) { string label = scene.Label ?? ""; - // Explicit Duel / durable pair lock wins over ambient sticky pollution. - if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) - || (scene.HasCombatPairLock && pairIdsLive)) - { - return true; - } - - return false; + // Explicit Duel tip wins over ambient sticky pollution; collective tips own the scene. + return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); } if (scene.HasCombatPairLock && pairIdsLive) @@ -1740,8 +1767,8 @@ public static class InterestDirector } /// - /// Allow leaving NamedPair only after an intentional multi frame exists - /// (harness escalate / real sticky Battle), never from ambient radius noise. + /// Allow leaving NamedPair only when a real sided multi owns the scrap + /// (species/kingdom camps with 3+ fighters), never from ambient radius noise. /// private static bool ShouldEscalateNamedPair( InterestCandidate scene, @@ -1754,13 +1781,11 @@ public static class InterestDirector } string tip = scene.Label ?? ""; - if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) - { - // Duel tips never escalate from a scan rebuild; escalate harness rewrites the tip first. - return false; - } + bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); - if (HasStickyCollectiveMulti(scene)) + // Already-framed collective multi may leave NamedPair, unless a Duel tip is still + // holding ownership over ambient sticky pollution (needs a live sided ensemble). + if (HasStickyCollectiveMulti(scene) && !duelLabeled) { return true; } @@ -1771,16 +1796,16 @@ public static class InterestDirector } // Natural growth: both pair members still fighting inside a sided multi cluster. - Actor focus = scene.FollowUnit; - Actor related = scene.RelatedUnit; + ResolveCombatPairActors(scene, out Actor owner, out Actor partner); + Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit; + Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit; if (focus == null || !focus.isAlive() || related == null || !related.isAlive()) { return false; } return LiveEnsemble.IsCombatParticipant(focus) - && LiveEnsemble.IsCombatParticipant(related) - && !scene.ForceActive; + && LiveEnsemble.IsCombatParticipant(related); } private static void ApplyNamedPairHold( @@ -1881,7 +1906,12 @@ public static class InterestDirector return false; } - // Already-escalated collective multi may leave the pair (not ambient Duel pollution). + // Collective multi (including Duel mid-escalate after sticky Refresh) leaves the pair. + if (fighters >= 3 && HasStickyCollectiveMulti(scene)) + { + return false; + } + if (HasStickyCollectiveMulti(scene)) { string tip = scene.Label ?? ""; diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index b60b270..f6eb192 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -98,6 +98,7 @@ public static class WatchCaption private static bool _visible; private static string _statusBanner = ""; private static float _statusBannerUntil; + private static string _lastLoggedCaption = ""; private sealed class TraitSlot { @@ -466,7 +467,7 @@ public static class WatchCaption hasReason: true, hist); SetVisible(true); - LogService.LogInfo("[IdleSpectator][CAPTION] status=" + message); + LogCaptionIfChanged("status=" + message); } public static void SetFromInterest(InterestEvent interest) @@ -522,7 +523,7 @@ public static class WatchCaption EnsureBuilt(); ApplyVisual(actor, dossier); SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); - LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | ")); + LogCaptionIfChanged(LastCaptionText); } /// @@ -548,8 +549,23 @@ public static class WatchCaption EnsureBuilt(); ApplyVisual(null, dossier); SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); - LogService.LogInfo( - "[IdleSpectator][CAPTION] fallen dossier " + LastCaptionText.Replace("\n", " | ")); + LogCaptionIfChanged("fallen dossier " + LastCaptionText); + } + + /// + /// CAPTION logs only when the text changes - busy Mass tip ticks used to flood Player.log + /// by rebuilding the dossier (and logging) on every sticky Watch refresh. + /// + private static void LogCaptionIfChanged(string caption) + { + string text = caption ?? ""; + if (string.Equals(text, _lastLoggedCaption, StringComparison.Ordinal)) + { + return; + } + + _lastLoggedCaption = text; + LogService.LogInfo("[IdleSpectator][CAPTION] " + text.Replace("\n", " | ")); } public static void Update() @@ -809,6 +825,7 @@ public static class WatchCaption return; } + bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next); _current.ReasonLine = next; LastDetail = _current.DetailLine ?? ""; LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine); @@ -819,6 +836,12 @@ public static class WatchCaption ApplyReasonText(hasReason ? next : "", hasReason ? _current.ReasonRelatedId : 0); } + // Mass tip headcount ticks must not Relayout the whole dossier every second. + if (headcountOnly) + { + return; + } + bool hasTask = !string.IsNullOrEmpty(_current.TaskText); Relayout( _current.UnitId != 0, diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index 74ca9f3..f575cbc 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -40,6 +40,11 @@ public static class WorldActivityScanner private static InterestEvent _cachedBattle; private const float ScanInterval = 0.5f; private static float _lastEmptyLogAt = -999f; + private static Dictionary _speciesCountCache; + private static float _speciesCountCachedAt = -999f; + private const float SpeciesCountCacheSeconds = 2.5f; + private static List _lastAllFighters; + private static float _lastAllFightersAt = -999f; public static InterestEvent FindBestLiveTarget() { @@ -59,46 +64,143 @@ public static class WorldActivityScanner return Clone(_cachedBattle); } - /// Register up to character-led vignette seeds into the registry. - public static void RegisterTopCharacterCandidates(int max = 4) + /// + /// Cheap battle probe for sticky combat: BattleKeeper tiles + one chunk cluster at + /// . Never walks every alive unit. + /// + public static InterestEvent FindHottestBattleLight(Vector3 anchor) { - EnsureScanned(); - Dictionary speciesCounts = CountSpeciesPopulations(); - List alive = new List(128); - foreach (Actor actor in EnumerateAliveUnits()) + return Clone(BuildHottestBattleLight(anchor)); + } + + /// True when a combat cluster exists near (chunk-local). + public static bool HasLiveCombatNear(Vector3 pos, float radius = 16f) + { + if (LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble) + && ensemble != null + && ensemble.ParticipantCount >= 1) { - if (actor?.asset != null && actor.asset.is_boat) + return true; + } + + HashSet battles = BattleKeeperManager.get(); + if (battles == null || battles.Count == 0) + { + return false; + } + + float r2 = radius * radius; + foreach (BattleContainer battle in battles) + { + if (battle?.tile == null) { continue; } - if (actor != null && actor.isAlive()) + Vector3 bpos = battle.tile.posV3; + float dx = bpos.x - pos.x; + float dy = bpos.y - pos.y; + if (dx * dx + dy * dy > r2) { - alive.Add(actor); + continue; + } + + if (battle.getDeathsTotal() > 0) + { + return true; + } + + if (LiveEnsemble.TryBuildCombat(bpos, 14f, out LiveEnsemble near) + && near != null + && near.ParticipantCount >= 1) + { + return true; } } - if (alive.Count == 0) + return false; + } + + /// + /// Register up to character-led vignette seeds into the registry. + /// Samples current combatants only - never sorts the full alive unit list. + /// + public static void RegisterTopCharacterCandidates(int max = 4) + { + // Sticky mass combat already owns the camera; skip redundant vignette work. + InterestCandidate current = InterestDirector.CurrentCandidate; + if (current != null + && current.Completion == InterestCompletionKind.CombatActive + && current.ParticipantCount >= 4 + && InterestDirector.CurrentIsActive) { return; } - alive.Sort((a, b) => ScoreActor(b, speciesCounts).CompareTo(ScoreActor(a, speciesCounts))); - int n = Mathf.Min(max, alive.Count); - for (int i = 0; i < n; i++) + EnsureScanned(); + List fighters = _lastAllFighters; + if (fighters == null + || fighters.Count == 0 + || Time.unscaledTime - _lastAllFightersAt > ScanInterval * 2f) { - Actor actor = alive[i]; + fighters = new List(64); + LiveEnsemble.CollectAllCombatFighters(fighters); + _lastAllFighters = fighters; + _lastAllFightersAt = Time.unscaledTime; + } + + if (fighters.Count == 0) + { + return; + } + + Dictionary speciesCounts = CountSpeciesPopulations(); + // Linear top-N over fighters only (no O(n log n) sort of the whole world). + var top = new List(max); + var topScores = new List(max); + for (int i = 0; i < fighters.Count; i++) + { + Actor actor = fighters[i]; if (actor == null || !actor.isAlive() || !actor.has_attack_target) { continue; } - long id = 0; - try + float score = ScoreActor(actor, speciesCounts); + if (top.Count < max) { - id = actor.getID(); + top.Add(actor); + topScores.Add(score); + continue; } - catch + + int worst = 0; + for (int t = 1; t < topScores.Count; t++) + { + if (topScores[t] < topScores[worst]) + { + worst = t; + } + } + + if (score > topScores[worst]) + { + top[worst] = actor; + topScores[worst] = score; + } + } + + var nearby = new List(32); + for (int i = 0; i < top.Count; i++) + { + Actor actor = top[i]; + if (actor == null || !actor.isAlive() || !actor.has_attack_target) + { + continue; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) { continue; } @@ -121,12 +223,13 @@ public static class WorldActivityScanner } SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore); - PreferCombatFollow(actor, foe, out Actor follow, out Actor related); - LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble); - int fighters = ensemble != null ? ensemble.ParticipantCount : 1; + nearby.Clear(); + LiveEnsemble.CollectCombatFightersNear(fighters, actor.current_position, 10f, nearby); + LiveEnsemble ensemble = LiveEnsemble.FromCombatFighters(nearby, actor.current_position); + int participantCount = ensemble != null ? ensemble.ParticipantCount : 1; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0; - fighters = Mathf.Max(1, fighters); + participantCount = Mathf.Max(1, participantCount); if (ensemble != null && ensemble.HasFocus) { if (ensemble.ParticipantCount > 2 @@ -165,7 +268,7 @@ public static class WorldActivityScanner SpeciesId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : ""), CityKey = follow?.city != null ? (follow.city.name ?? "") : (actor.city != null ? (actor.city.name ?? "") : ""), KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""), - ParticipantCount = fighters, + ParticipantCount = participantCount, NotableParticipantCount = notables, CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, @@ -209,6 +312,13 @@ public static class WorldActivityScanner private static void ScanNow() { _cachedBattle = BuildHottestBattle(); + // Busy war maps: skip O(units) character scoring when a real fight already won. + if (_cachedBattle != null && _cachedBattle.ParticipantCount >= 3) + { + _cachedBest = _cachedBattle; + return; + } + InterestEvent bestUnit = BuildBestScoredUnit(); _cachedBest = PreferRicherAction(_cachedBattle, bestUnit); } @@ -246,16 +356,49 @@ public static class WorldActivityScanner } private static InterestEvent BuildHottestBattle() + { + // One full-world fighter pass shared by BattleKeeper + live cluster ranking. + // Avoids the old O(fighters × units) TryBuildCombat-per-seed path. + // Sticky combat must not call this - use BuildHottestBattleLight instead. + var allFighters = new List(256); + LiveEnsemble.CollectAllCombatFighters(allFighters); + _lastAllFighters = allFighters; + _lastAllFightersAt = Time.unscaledTime; + + InterestEvent best = RankBattleKeeper(allFighters); + InterestEvent live = BuildHottestFightCluster(allFighters); + return PreferRicherAction(best, live); + } + + /// + /// Sticky-safe battle ranking: BattleKeeper + chunk cluster at . + /// No CollectAllCombatFighters. + /// + private static InterestEvent BuildHottestBattleLight(Vector3 anchor) + { + InterestEvent best = RankBattleKeeper(allFighters: null); + if (LiveEnsemble.TryBuildCombat(anchor, 14f, out LiveEnsemble local) + && local != null + && local.ParticipantCount >= 1) + { + InterestEvent localEvent = FromEnsembleBattle(local, anchor, deaths: 0); + best = PreferRicherAction(best, localEvent); + } + + return best; + } + + private static InterestEvent RankBattleKeeper(List allFighters) { HashSet battles = BattleKeeperManager.get(); if (battles == null || battles.Count == 0) { - // Fall through to live fight clusters with no BattleKeeper entry yet. - return BuildHottestFightCluster(); + return null; } InterestEvent best = null; float bestScore = float.MinValue; + var nearby = allFighters != null ? new List(32) : null; foreach (BattleContainer battle in battles) { if (battle?.tile == null) @@ -265,7 +408,18 @@ public static class WorldActivityScanner int deaths = battle.getDeathsTotal(); Vector3 pos = battle.tile.posV3; - LiveEnsemble.TryBuildCombat(pos, 14f, out LiveEnsemble ensemble); + LiveEnsemble ensemble; + if (allFighters != null) + { + nearby.Clear(); + LiveEnsemble.CollectCombatFightersNear(allFighters, pos, 14f, nearby); + ensemble = LiveEnsemble.FromCombatFighters(nearby, pos); + } + else if (!LiveEnsemble.TryBuildCombat(pos, 14f, out ensemble)) + { + ensemble = null; + } + int fighters = ensemble != null ? ensemble.ParticipantCount : 0; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0; Actor follow = ensemble != null ? ensemble.Focus : null; @@ -276,62 +430,120 @@ public static class WorldActivityScanner fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0); float score = ScoreFightCluster(deaths, fighters, notables); - if (score > bestScore) + if (score <= bestScore) { - bestScore = score; - Actor focus = follow ?? FindNearestAliveUnit(pos, 14f); - string label = ensemble != null && ensemble.HasFocus - ? EventReason.Combat(ensemble) - : EventReason.Battle(focus, fighters); - best = new InterestEvent - { - Score = score, - Position = pos, - FollowUnit = focus, - RelatedUnit = ensemble != null ? ensemble.Related : null, - Label = label, - CreatedAt = Time.unscaledTime, - AssetId = "live_battle", - ParticipantCount = fighters, - NotableParticipantCount = notables, - CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable - }; + continue; + } + + bestScore = score; + best = FromEnsembleBattle(ensemble, pos, deaths, follow, fighters, notables, score); + } + + return best; + } + + private static InterestEvent FromEnsembleBattle( + LiveEnsemble ensemble, + Vector3 pos, + int deaths, + Actor follow = null, + int fighters = -1, + int notables = -1, + float score = -1f) + { + if (ensemble != null) + { + if (fighters < 0) + { + fighters = ensemble.ParticipantCount; + } + + if (notables < 0) + { + notables = ensemble.NotableParticipantCount; + } + + if (follow == null) + { + follow = ensemble.Focus; } } - InterestEvent live = BuildHottestFightCluster(); - return PreferRicherAction(best, live); + fighters = Mathf.Max(0, fighters); + notables = Mathf.Max(0, notables); + if (score < 0f) + { + score = ScoreFightCluster(deaths, fighters, notables); + } + + Actor focus = follow ?? FindNearestAliveUnit(pos, 14f); + string label = ensemble != null && ensemble.HasFocus + ? EventReason.Combat(ensemble) + : EventReason.Battle(focus, fighters); + return new InterestEvent + { + Score = score, + Position = pos, + FollowUnit = focus, + RelatedUnit = ensemble != null ? ensemble.Related : null, + Label = label, + CreatedAt = Time.unscaledTime, + AssetId = "live_battle", + ParticipantCount = fighters, + NotableParticipantCount = notables, + CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable + }; } - /// Cluster of units currently in combat (even before BattleKeeper records deaths). - private static InterestEvent BuildHottestFightCluster() + /// + /// Cluster of units currently in combat (even before BattleKeeper records deaths). + /// One pass over a precollected fighter list - no per-seed world scans. + /// + private static InterestEvent BuildHottestFightCluster(List allFighters) { + if (allFighters == null || allFighters.Count == 0) + { + return null; + } + InterestEvent best = null; float bestScore = float.MinValue; - var seen = new HashSet(); - foreach (Actor actor in EnumerateAliveUnits()) + var claimed = new HashSet(); + var cluster = new List(32); + const float clusterRadius = 10f; + + for (int i = 0; i < allFighters.Count; i++) { + Actor actor = allFighters[i]; if (actor == null || !actor.isAlive() || !actor.has_attack_target) { continue; } - long id = 0; - try - { - id = actor.getID(); - } - catch + long id = EventFeedUtil.SafeId(actor); + if (id == 0 || claimed.Contains(id)) { continue; } - if (seen.Contains(id)) + Vector2 seedPos = actor.current_position; + cluster.Clear(); + LiveEnsemble.CollectCombatFightersNear(allFighters, seedPos, clusterRadius, cluster); + if (cluster.Count < 1) { continue; } - LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble); + for (int c = 0; c < cluster.Count; c++) + { + long cid = EventFeedUtil.SafeId(cluster[c]); + if (cid != 0) + { + claimed.Add(cid); + } + } + + LiveEnsemble ensemble = LiveEnsemble.FromCombatFighters(cluster, seedPos); int fighters = ensemble != null ? ensemble.ParticipantCount : 0; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0; Actor follow = ensemble != null ? ensemble.Focus : null; @@ -340,8 +552,6 @@ public static class WorldActivityScanner continue; } - // Mark cluster members so we don't emit N duplicates. - MarkClusterSeen(actor.current_position, 10f, seen); float score = ScoreFightCluster(deaths: 0, fighters, notables); if (score > bestScore) { @@ -604,34 +814,6 @@ public static class WorldActivityScanner bestFollow = ensemble.Focus; } - private static void MarkClusterSeen(Vector2 pos, float radius, HashSet seen) - { - float r2 = radius * radius; - foreach (Actor actor in EnumerateAliveUnits()) - { - if (actor == null || !actor.isAlive() || !actor.has_attack_target) - { - continue; - } - - float dx = actor.current_position.x - pos.x; - float dy = actor.current_position.y - pos.y; - if (dx * dx + dy * dy > r2) - { - continue; - } - - try - { - seen.Add(actor.getID()); - } - catch - { - // ignore - } - } - } - private static InterestEvent BuildBestScoredUnit() { Dictionary speciesCounts = new Dictionary(); @@ -743,6 +925,12 @@ public static class WorldActivityScanner /// Public wrappers for dossier / harness (same logic as private scorers). public static Dictionary CountSpeciesPopulations() { + float now = Time.unscaledTime; + if (_speciesCountCache != null && now - _speciesCountCachedAt < SpeciesCountCacheSeconds) + { + return _speciesCountCache; + } + Dictionary speciesCounts = new Dictionary(); foreach (Actor actor in EnumerateAliveUnits()) { @@ -760,6 +948,8 @@ public static class WorldActivityScanner speciesCounts[id]++; } + _speciesCountCache = speciesCounts; + _speciesCountCachedAt = now; return speciesCounts; } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index f35d513..c8e4400 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.26.1", - "description": "AFK Idle Spectator (I) + Lore (L). Fix Lore click camera follow (pause before focus).", + "version": "0.26.7", + "description": "AFK Idle Spectator (I) + Lore (L). Fix Duel→Battle escalate when packs join.", "GUID": "com.dazed.idlespectator" }