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"
}