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
This commit is contained in:
DazedAnon 2026-07-17 14:40:32 -05:00
parent 90d5b1604f
commit 366ae45f13
15 changed files with 973 additions and 265 deletions

View file

@ -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());
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -2310,6 +2310,10 @@ public static class AgentHarness
DoOutbreakEnsembleApply(cmd); DoOutbreakEnsembleApply(cmd);
break; break;
case "combat_isolate_pair":
DoCombatIsolatePair(cmd);
break;
case "combat_wire_attack_sides": case "combat_wire_attack_sides":
DoCombatWireAttackSides(cmd); DoCombatWireAttackSides(cmd);
break; break;
@ -5099,7 +5103,15 @@ public static class AgentHarness
return; return;
} }
// 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(); scene.ClearCombatSticky();
}
int want = 6; int want = 6;
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want)) 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); 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 var ensemble = new LiveEnsemble
{ {
Kind = EnsembleKind.Combat, 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}"); detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
} }
/// <summary>
/// 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).
/// </summary>
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<Actor>(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}'");
}
/// <summary> /// <summary>
/// Best-effort: set attack_target across two species camps near the combat focus /// Best-effort: set attack_target across two species camps near the combat focus
/// so <see cref="LiveEnsemble.TryBuildCombat"/> sees a real multi-fighter cluster. /// so <see cref="LiveEnsemble.TryBuildCombat"/> sees a real multi-fighter cluster.

View file

@ -40,12 +40,14 @@ public static class CameraDirector
bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal) bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal)
|| !string.Equals(label, LastWatchLabel, StringComparison.Ordinal) || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal)
|| !string.Equals(assetId, LastWatchAssetId, StringComparison.Ordinal); || !string.Equals(assetId, LastWatchAssetId, StringComparison.Ordinal);
bool headcountOnly = tipChanged
&& EventReason.IsHeadcountOnlyChange(LastWatchLabel, label);
LastFormattedWatchTip = tip; LastFormattedWatchTip = tip;
// LastWatchLabel is tip/harness telemetry only - never dossier reason truth. // LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
LastWatchLabel = label; LastWatchLabel = label;
LastWatchAssetId = assetId; LastWatchAssetId = assetId;
// Sticky maintain can call Watch every tick - only log when the tip changes. // Sticky Mass tips tick headcounts often - log structure changes only.
if (tipChanged) if (tipChanged && !headcountOnly)
{ {
LogService.LogInfo($"[IdleSpectator] {tip}"); LogService.LogInfo($"[IdleSpectator] {tip}");
} }
@ -56,7 +58,15 @@ public static class CameraDirector
// Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier // Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier
// never diverge across a handoff frame. // never diverge across a handoff frame.
RetargetFollow(follow); RetargetFollow(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); WatchCaption.SetFromActor(follow);
}
return; return;
} }

View file

@ -180,6 +180,33 @@ public static class EventFeedUtil
} }
} }
/// <summary>
/// O(1) living actor lookup via <c>ActorManager.get</c>. Prefer this over scanning
/// <see cref="WorldActivityScanner.EnumerateAliveUnitsPublic"/>.
/// </summary>
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) public static string SafeName(Actor actor)
{ {
if (actor == null) if (actor == null)

View file

@ -11,6 +11,57 @@ namespace IdleSpectator;
/// </summary> /// </summary>
public static class EventReason public static class EventReason
{ {
/// <summary>
/// True when two labels differ only by parenthesized headcounts
/// (e.g. <c>Mass - A (12) vs B (3)</c> → <c>Mass - A (11) vs B (4)</c>).
/// </summary>
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) public static string Fight(Actor a, Actor b)
{ {
string name = Name(a); string name = Name(a);

View file

@ -1073,8 +1073,38 @@ public static class InterestFeeds
return; return;
} }
// Top battle cluster. InterestCandidate current = InterestDirector.CurrentCandidate;
InterestEvent battle = WorldActivityScanner.FindHottestBattle(); 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 (battle != null)
{ {
if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
@ -1082,8 +1112,9 @@ public static class InterestFeeds
{ {
// Active combat scene refreshed in place. // 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); RegisterDirect(battle);
} }
} }
@ -1099,9 +1130,6 @@ public static class InterestFeeds
// Nearby multi-carrier status clusters. // Nearby multi-carrier status clusters.
StatusOutbreakFeed.Tick(); 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) private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId)

View file

@ -65,6 +65,12 @@ public static class StatusOutbreakFeed
ApplySticky(registered, ensemble, statusId, pos); ApplySticky(registered, ensemble, statusId, pos);
} }
private static float _lastTickAt = -999f;
private static List<string> _sampleStatusIds;
private static float _sampleStatusIdsAt = -999f;
private const float OutbreakTickSeconds = 2.5f;
private const float StatusIdCacheSeconds = 30f;
public static void Tick() public static void Tick()
{ {
if (!AgentHarness.LiveFeedsAllowed) if (!AgentHarness.LiveFeedsAllowed)
@ -72,35 +78,30 @@ public static class StatusOutbreakFeed
return; 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). // Sample live affliction statuses from living units (HotStatusIds + discovered ids).
int registered = 0; int registered = 0;
var seenStatus = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seenStatus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sampleIds = new List<string>(HotStatusIds.Length + 16); List<string> sampleIds = GetSampleStatusIds(now);
sampleIds.AddRange(HotStatusIds); int checkedUnits = 0;
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds(); const int maxUnitsToProbe = 80;
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);
}
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{ {
@ -109,6 +110,12 @@ public static class StatusOutbreakFeed
break; break;
} }
checkedUnits++;
if (checkedUnits > maxUnitsToProbe)
{
break;
}
for (int s = 0; s < sampleIds.Count; s++) for (int s = 0; s < sampleIds.Count; s++)
{ {
string statusId = sampleIds[s]; string statusId = sampleIds[s];
@ -136,6 +143,45 @@ public static class StatusOutbreakFeed
} }
} }
private static List<string> GetSampleStatusIds(float now)
{
if (_sampleStatusIds != null && now - _sampleStatusIdsAt < StatusIdCacheSeconds)
{
return _sampleStatusIds;
}
var sampleIds = new List<string>(HotStatusIds.Length + 16);
sampleIds.AddRange(HotStatusIds);
List<string> 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;
}
/// <summary> /// <summary>
/// Outbreak tips are lasting affliction / disease / spectacle clusters (2+ nearby). /// Outbreak tips are lasting affliction / disease / spectacle clusters (2+ nearby).
/// Never love/emotion/ambient crowds, and never brief combat interrupt FX /// Never love/emotion/ambient crowds, and never brief combat interrupt FX

View file

@ -891,23 +891,7 @@ public sealed class LiveEnsemble
return true; return true;
} }
public static Actor FindTrackedActor(long id) public static Actor FindTrackedActor(long id) => EventFeedUtil.FindAliveById(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;
}
/// <summary>True when Side A is a synthetic plotter camp key.</summary> /// <summary>True when Side A is a synthetic plotter camp key.</summary>
public static bool IsPlotterSideKey(string key) => public static bool IsPlotterSideKey(string key) =>
@ -1333,11 +1317,42 @@ public sealed class LiveEnsemble
return sb.ToString(); return sb.ToString();
} }
private static void CollectCombatFighters(Vector2 pos, float radius, List<Actor> into) /// <summary>One full-world pass of living combat participants (attack target or AI in_combat).</summary>
public static void CollectAllCombatFighters(List<Actor> into)
{ {
float r2 = radius * radius; if (into == null)
{
return;
}
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{ {
if (IsCombatParticipant(actor))
{
into.Add(actor);
}
}
}
/// <summary>
/// Filter a precollected fighter list into <paramref name="into"/> by radius.
/// Avoids a second full-world scan when the caller already has all fighters.
/// </summary>
public static void CollectCombatFightersNear(
IList<Actor> source,
Vector2 pos,
float radius,
List<Actor> 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()) if (actor == null || !actor.isAlive())
{ {
continue; continue;
@ -1350,12 +1365,123 @@ public sealed class LiveEnsemble
continue; continue;
} }
if (!IsCombatParticipant(actor)) into.Add(actor);
}
}
private static void CollectCombatFighters(Vector2 pos, float radius, List<Actor> into)
{
if (into == null)
{
return;
}
ForEachNearbyActor(pos, radius, requireCombat: true, actor => into.Add(actor));
}
/// <summary>
/// Visit living actors near <paramref name="pos"/>. Prefers chunk-local lookup;
/// falls back to a full alive-unit walk only when chunks yield nothing.
/// </summary>
private static void ForEachNearbyActor(
Vector2 pos,
float radius,
bool requireCombat,
Action<Actor> 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; 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<Actor> 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<Actor> 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; return;
} }
float r2 = radius * radius; var seenA = new HashSet<long>(sideAIds);
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) var seenB = new HashSet<long>(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 string key = frame == EnsembleFrame.KingdomVsKingdom
? KingdomKeyOf(actor) ? KingdomKeyOf(actor)
: SpeciesKeyOf(actor); : SpeciesKeyOf(actor);
if (string.IsNullOrEmpty(key)) if (string.IsNullOrEmpty(key))
{ {
continue; return;
} }
long id = EventFeedUtil.SafeId(actor); long id = EventFeedUtil.SafeId(actor);
if (id == 0) if (id == 0)
{ {
continue; return;
} }
if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase)) if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase))
{ {
AddUniqueId(sideAIds, id); if (seenA.Add(id))
{
sideAIds.Add(id);
}
} }
else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase)) else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase))
{ {
AddUniqueId(sideBIds, id); if (seenB.Add(id))
{
sideBIds.Add(id);
} }
} }
});
countA = CountAliveTracked(sideAIds, out bestA, preferCombat: requireCombat); countA = CountAliveTracked(sideAIds, out bestA, preferCombat: requireCombat);
countB = CountAliveTracked(sideBIds, out bestB, 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--) for (int i = ids.Count - 1; i >= 0; i--)
{ {
long id = ids[i]; long id = ids[i];
Actor actor = FindAliveById(id); Actor actor = EventFeedUtil.FindAliveById(id);
if (actor == null) if (actor == null)
{ {
ids.RemoveAt(i); ids.RemoveAt(i);
@ -1521,29 +1637,6 @@ public sealed class LiveEnsemble
return alive; 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) public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble)
{ {
if (ensemble?.SideA == null) if (ensemble?.SideA == null)

View file

@ -1091,6 +1091,8 @@ internal static class HarnessScenarios
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus. // 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. // 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"), 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 "), Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
// Pair ownership: maintain must not flip Duel - A vs B ↔ B vs A. // Pair ownership: maintain must not flip Duel - A vs B ↔ B vs A.
Step("cf21a", "remember_tip"), Step("cf21a", "remember_tip"),
@ -1122,6 +1124,24 @@ internal static class HarnessScenarios
Step("cf46", "assert", expect: "dossier_matches_focus"), Step("cf46", "assert", expect: "dossier_matches_focus"),
Step("cf47", "assert", expect: "has_focus", value: "true"), 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. // Cold combat must not keep fight / ensemble tips.
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"), Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),

View file

@ -104,8 +104,21 @@ public static class InterestCompletion
if (!hot && c.AssetId == "live_battle") if (!hot && c.AssetId == "live_battle")
{ {
InterestEvent battle = WorldActivityScanner.FindHottestBattle(); // Chunk-local only - never CollectAllCombatFighters during sticky maintain.
hot = battle != null; 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) if (hot)

View file

@ -46,6 +46,9 @@ public static class InterestDirector
private static float _inactiveSince = -999f; private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f; private static float _lastCombatFocusAt = -999f;
private const float CombatFocusThrottleSeconds = 1.25f; private const float CombatFocusThrottleSeconds = 1.25f;
/// <summary>Large Mass sticky scenes can maintain less often - roster work is heavier.</summary>
private const float CombatFocusThrottleLargeSeconds = 2.0f;
private const int CombatFocusLargeParticipantThreshold = 16;
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary> /// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
private const float CombatFocusSwitchMargin = 12f; private const float CombatFocusSwitchMargin = 12f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96); private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
@ -654,7 +657,10 @@ public static class InterestDirector
return; return;
} }
if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) float throttle = _current.ParticipantCount >= CombatFocusLargeParticipantThreshold
? CombatFocusThrottleLargeSeconds
: CombatFocusThrottleSeconds;
if (!force && now - _lastCombatFocusAt < throttle)
{ {
return; return;
} }
@ -688,15 +694,24 @@ public static class InterestDirector
&& ownedRelated.isAlive() && ownedRelated.isAlive()
&& !HasStickyCollectiveMulti(_current)); && !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. // Skip when Follow was cleared (death / harness handoff) so Related can take ownership.
if (pairOwned if (pairOwned
&& _current.HasFollowUnit && _current.HasFollowUnit
&& ownedFocus != null && ownedFocus != null
&& ownedFocus.isAlive() && ownedFocus.isAlive()
&& ownedRelated != null && ownedRelated != null
&& ownedRelated.isAlive() && ownedRelated.isAlive())
&& !ShouldEscalateNamedPair(_current, null, 2)) {
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))
{ {
string pairLabel = ""; string pairLabel = "";
Actor holdFocus = ownedFocus; Actor holdFocus = ownedFocus;
@ -739,6 +754,9 @@ public static class InterestDirector
return true; return true;
} }
// Real multi around the pair: fall through to sticky rebuild / collective tip.
}
Vector3 pos = _current.Position; Vector3 pos = _current.Position;
if (ownedFocus != null && ownedFocus.isAlive()) if (ownedFocus != null && ownedFocus.isAlive())
{ {
@ -982,6 +1000,17 @@ public static class InterestDirector
} }
else 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. // Never stick on a sleeper / bystander unless they are on the sticky combat roster.
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow); bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow);
@ -1089,6 +1118,8 @@ public static class InterestDirector
bool followChanged = _current.FollowUnit != best; bool followChanged = _current.FollowUnit != best;
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
bool headcountOnly = labelChanged
&& EventReason.IsHeadcountOnlyChange(previousLabel, label ?? "");
_current.FollowUnit = best; _current.FollowUnit = best;
_current.SubjectId = EventFeedUtil.SafeId(best); _current.SubjectId = EventFeedUtil.SafeId(best);
_current.RelatedUnit = foe; _current.RelatedUnit = foe;
@ -1103,9 +1134,11 @@ public static class InterestDirector
// keep prior position // 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 bool needWatch = forceWatch
|| followChanged || followChanged
|| labelChanged || (labelChanged && !headcountOnly)
|| !HasLivingCameraFocus() || !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best; || MoveCamera._focus_unit != best;
if (needWatch) if (needWatch)
@ -1699,14 +1732,8 @@ public static class InterestDirector
if (HasStickyCollectiveMulti(scene)) if (HasStickyCollectiveMulti(scene))
{ {
string label = scene.Label ?? ""; string label = scene.Label ?? "";
// Explicit Duel / durable pair lock wins over ambient sticky pollution. // Explicit Duel tip wins over ambient sticky pollution; collective tips own the scene.
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) return label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|| (scene.HasCombatPairLock && pairIdsLive))
{
return true;
}
return false;
} }
if (scene.HasCombatPairLock && pairIdsLive) if (scene.HasCombatPairLock && pairIdsLive)
@ -1740,8 +1767,8 @@ public static class InterestDirector
} }
/// <summary> /// <summary>
/// Allow leaving NamedPair only after an intentional multi frame exists /// Allow leaving NamedPair only when a real sided multi owns the scrap
/// (harness escalate / real sticky Battle), never from ambient radius noise. /// (species/kingdom camps with 3+ fighters), never from ambient radius noise.
/// </summary> /// </summary>
private static bool ShouldEscalateNamedPair( private static bool ShouldEscalateNamedPair(
InterestCandidate scene, InterestCandidate scene,
@ -1754,13 +1781,11 @@ public static class InterestDirector
} }
string tip = scene.Label ?? ""; string tip = scene.Label ?? "";
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
{
// Duel tips never escalate from a scan rebuild; escalate harness rewrites the tip first.
return false;
}
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; return true;
} }
@ -1771,16 +1796,16 @@ public static class InterestDirector
} }
// Natural growth: both pair members still fighting inside a sided multi cluster. // Natural growth: both pair members still fighting inside a sided multi cluster.
Actor focus = scene.FollowUnit; ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
Actor related = scene.RelatedUnit; 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()) if (focus == null || !focus.isAlive() || related == null || !related.isAlive())
{ {
return false; return false;
} }
return LiveEnsemble.IsCombatParticipant(focus) return LiveEnsemble.IsCombatParticipant(focus)
&& LiveEnsemble.IsCombatParticipant(related) && LiveEnsemble.IsCombatParticipant(related);
&& !scene.ForceActive;
} }
private static void ApplyNamedPairHold( private static void ApplyNamedPairHold(
@ -1881,7 +1906,12 @@ public static class InterestDirector
return false; 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)) if (HasStickyCollectiveMulti(scene))
{ {
string tip = scene.Label ?? ""; string tip = scene.Label ?? "";

View file

@ -98,6 +98,7 @@ public static class WatchCaption
private static bool _visible; private static bool _visible;
private static string _statusBanner = ""; private static string _statusBanner = "";
private static float _statusBannerUntil; private static float _statusBannerUntil;
private static string _lastLoggedCaption = "";
private sealed class TraitSlot private sealed class TraitSlot
{ {
@ -466,7 +467,7 @@ public static class WatchCaption
hasReason: true, hasReason: true,
hist); hist);
SetVisible(true); SetVisible(true);
LogService.LogInfo("[IdleSpectator][CAPTION] status=" + message); LogCaptionIfChanged("status=" + message);
} }
public static void SetFromInterest(InterestEvent interest) public static void SetFromInterest(InterestEvent interest)
@ -522,7 +523,7 @@ public static class WatchCaption
EnsureBuilt(); EnsureBuilt();
ApplyVisual(actor, dossier); ApplyVisual(actor, dossier);
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogService.LogInfo("[IdleSpectator][CAPTION] " + LastCaptionText.Replace("\n", " | ")); LogCaptionIfChanged(LastCaptionText);
} }
/// <summary> /// <summary>
@ -548,8 +549,23 @@ public static class WatchCaption
EnsureBuilt(); EnsureBuilt();
ApplyVisual(null, dossier); ApplyVisual(null, dossier);
SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused)); SetVisible(ModSettings.ShowDossierCaption && (SpectatorMode.Active || _pinnedWhilePaused));
LogService.LogInfo( LogCaptionIfChanged("fallen dossier " + LastCaptionText);
"[IdleSpectator][CAPTION] fallen dossier " + LastCaptionText.Replace("\n", " | ")); }
/// <summary>
/// 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.
/// </summary>
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() public static void Update()
@ -809,6 +825,7 @@ public static class WatchCaption
return; return;
} }
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
_current.ReasonLine = next; _current.ReasonLine = next;
LastDetail = _current.DetailLine ?? ""; LastDetail = _current.DetailLine ?? "";
LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine); LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine);
@ -819,6 +836,12 @@ public static class WatchCaption
ApplyReasonText(hasReason ? next : "", hasReason ? _current.ReasonRelatedId : 0); 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); bool hasTask = !string.IsNullOrEmpty(_current.TaskText);
Relayout( Relayout(
_current.UnitId != 0, _current.UnitId != 0,

View file

@ -40,6 +40,11 @@ public static class WorldActivityScanner
private static InterestEvent _cachedBattle; private static InterestEvent _cachedBattle;
private const float ScanInterval = 0.5f; private const float ScanInterval = 0.5f;
private static float _lastEmptyLogAt = -999f; private static float _lastEmptyLogAt = -999f;
private static Dictionary<string, int> _speciesCountCache;
private static float _speciesCountCachedAt = -999f;
private const float SpeciesCountCacheSeconds = 2.5f;
private static List<Actor> _lastAllFighters;
private static float _lastAllFightersAt = -999f;
public static InterestEvent FindBestLiveTarget() public static InterestEvent FindBestLiveTarget()
{ {
@ -59,46 +64,143 @@ public static class WorldActivityScanner
return Clone(_cachedBattle); return Clone(_cachedBattle);
} }
/// <summary>Register up to <paramref name="max"/> character-led vignette seeds into the registry.</summary> /// <summary>
public static void RegisterTopCharacterCandidates(int max = 4) /// Cheap battle probe for sticky combat: BattleKeeper tiles + one chunk cluster at
/// <paramref name="anchor"/>. Never walks every alive unit.
/// </summary>
public static InterestEvent FindHottestBattleLight(Vector3 anchor)
{ {
EnsureScanned(); return Clone(BuildHottestBattleLight(anchor));
Dictionary<string, int> speciesCounts = CountSpeciesPopulations(); }
List<Actor> alive = new List<Actor>(128);
foreach (Actor actor in EnumerateAliveUnits()) /// <summary>True when a combat cluster exists near <paramref name="pos"/> (chunk-local).</summary>
public static bool HasLiveCombatNear(Vector3 pos, float radius = 16f)
{ {
if (actor?.asset != null && actor.asset.is_boat) if (LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble)
&& ensemble != null
&& ensemble.ParticipantCount >= 1)
{
return true;
}
HashSet<BattleContainer> 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; 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;
}
/// <summary>
/// Register up to <paramref name="max"/> character-led vignette seeds into the registry.
/// Samples current combatants only - never sorts the full alive unit list.
/// </summary>
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; return;
} }
alive.Sort((a, b) => ScoreActor(b, speciesCounts).CompareTo(ScoreActor(a, speciesCounts))); EnsureScanned();
int n = Mathf.Min(max, alive.Count); List<Actor> fighters = _lastAllFighters;
for (int i = 0; i < n; i++) if (fighters == null
|| fighters.Count == 0
|| Time.unscaledTime - _lastAllFightersAt > ScanInterval * 2f)
{ {
Actor actor = alive[i]; fighters = new List<Actor>(64);
LiveEnsemble.CollectAllCombatFighters(fighters);
_lastAllFighters = fighters;
_lastAllFightersAt = Time.unscaledTime;
}
if (fighters.Count == 0)
{
return;
}
Dictionary<string, int> speciesCounts = CountSpeciesPopulations();
// Linear top-N over fighters only (no O(n log n) sort of the whole world).
var top = new List<Actor>(max);
var topScores = new List<float>(max);
for (int i = 0; i < fighters.Count; i++)
{
Actor actor = fighters[i];
if (actor == null || !actor.isAlive() || !actor.has_attack_target) if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{ {
continue; continue;
} }
long id = 0; float score = ScoreActor(actor, speciesCounts);
try 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<Actor>(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; continue;
} }
@ -121,12 +223,13 @@ public static class WorldActivityScanner
} }
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore); SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
PreferCombatFollow(actor, foe, out Actor follow, out Actor related); PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble); nearby.Clear();
int fighters = ensemble != null ? ensemble.ParticipantCount : 1; 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; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
fighters = Mathf.Max(1, fighters); participantCount = Mathf.Max(1, participantCount);
if (ensemble != null && ensemble.HasFocus) if (ensemble != null && ensemble.HasFocus)
{ {
if (ensemble.ParticipantCount > 2 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 : ""), 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 ?? "") : ""), 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 ?? "") : ""), KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""),
ParticipantCount = fighters, ParticipantCount = participantCount,
NotableParticipantCount = notables, NotableParticipantCount = notables,
CreatedAt = Time.unscaledTime, CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime,
@ -209,6 +312,13 @@ public static class WorldActivityScanner
private static void ScanNow() private static void ScanNow()
{ {
_cachedBattle = BuildHottestBattle(); _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(); InterestEvent bestUnit = BuildBestScoredUnit();
_cachedBest = PreferRicherAction(_cachedBattle, bestUnit); _cachedBest = PreferRicherAction(_cachedBattle, bestUnit);
} }
@ -246,16 +356,49 @@ public static class WorldActivityScanner
} }
private static InterestEvent BuildHottestBattle() 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<Actor>(256);
LiveEnsemble.CollectAllCombatFighters(allFighters);
_lastAllFighters = allFighters;
_lastAllFightersAt = Time.unscaledTime;
InterestEvent best = RankBattleKeeper(allFighters);
InterestEvent live = BuildHottestFightCluster(allFighters);
return PreferRicherAction(best, live);
}
/// <summary>
/// Sticky-safe battle ranking: BattleKeeper + chunk cluster at <paramref name="anchor"/>.
/// No CollectAllCombatFighters.
/// </summary>
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<Actor> allFighters)
{ {
HashSet<BattleContainer> battles = BattleKeeperManager.get(); HashSet<BattleContainer> battles = BattleKeeperManager.get();
if (battles == null || battles.Count == 0) if (battles == null || battles.Count == 0)
{ {
// Fall through to live fight clusters with no BattleKeeper entry yet. return null;
return BuildHottestFightCluster();
} }
InterestEvent best = null; InterestEvent best = null;
float bestScore = float.MinValue; float bestScore = float.MinValue;
var nearby = allFighters != null ? new List<Actor>(32) : null;
foreach (BattleContainer battle in battles) foreach (BattleContainer battle in battles)
{ {
if (battle?.tile == null) if (battle?.tile == null)
@ -265,7 +408,18 @@ public static class WorldActivityScanner
int deaths = battle.getDeathsTotal(); int deaths = battle.getDeathsTotal();
Vector3 pos = battle.tile.posV3; 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 fighters = ensemble != null ? ensemble.ParticipantCount : 0;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
Actor follow = ensemble != null ? ensemble.Focus : null; Actor follow = ensemble != null ? ensemble.Focus : null;
@ -276,14 +430,57 @@ public static class WorldActivityScanner
fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0); fighters = Mathf.Max(fighters, deaths > 0 ? 2 : 0);
float score = ScoreFightCluster(deaths, fighters, notables); float score = ScoreFightCluster(deaths, fighters, notables);
if (score > bestScore) if (score <= bestScore)
{ {
continue;
}
bestScore = score; 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;
}
}
fighters = Mathf.Max(0, fighters);
notables = Mathf.Max(0, notables);
if (score < 0f)
{
score = ScoreFightCluster(deaths, fighters, notables);
}
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f); Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
string label = ensemble != null && ensemble.HasFocus string label = ensemble != null && ensemble.HasFocus
? EventReason.Combat(ensemble) ? EventReason.Combat(ensemble)
: EventReason.Battle(focus, fighters); : EventReason.Battle(focus, fighters);
best = new InterestEvent return new InterestEvent
{ {
Score = score, Score = score,
Position = pos, Position = pos,
@ -297,41 +494,56 @@ public static class WorldActivityScanner
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
}; };
} }
}
InterestEvent live = BuildHottestFightCluster(); /// <summary>
return PreferRicherAction(best, live); /// Cluster of units currently in combat (even before BattleKeeper records deaths).
} /// One pass over a precollected fighter list - no per-seed world scans.
/// </summary>
/// <summary>Cluster of units currently in combat (even before BattleKeeper records deaths).</summary> private static InterestEvent BuildHottestFightCluster(List<Actor> allFighters)
private static InterestEvent BuildHottestFightCluster()
{ {
if (allFighters == null || allFighters.Count == 0)
{
return null;
}
InterestEvent best = null; InterestEvent best = null;
float bestScore = float.MinValue; float bestScore = float.MinValue;
var seen = new HashSet<long>(); var claimed = new HashSet<long>();
foreach (Actor actor in EnumerateAliveUnits()) var cluster = new List<Actor>(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) if (actor == null || !actor.isAlive() || !actor.has_attack_target)
{ {
continue; continue;
} }
long id = 0; long id = EventFeedUtil.SafeId(actor);
try if (id == 0 || claimed.Contains(id))
{
id = actor.getID();
}
catch
{ {
continue; continue;
} }
if (seen.Contains(id)) Vector2 seedPos = actor.current_position;
cluster.Clear();
LiveEnsemble.CollectCombatFightersNear(allFighters, seedPos, clusterRadius, cluster);
if (cluster.Count < 1)
{ {
continue; 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 fighters = ensemble != null ? ensemble.ParticipantCount : 0;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0; int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
Actor follow = ensemble != null ? ensemble.Focus : null; Actor follow = ensemble != null ? ensemble.Focus : null;
@ -340,8 +552,6 @@ public static class WorldActivityScanner
continue; continue;
} }
// Mark cluster members so we don't emit N duplicates.
MarkClusterSeen(actor.current_position, 10f, seen);
float score = ScoreFightCluster(deaths: 0, fighters, notables); float score = ScoreFightCluster(deaths: 0, fighters, notables);
if (score > bestScore) if (score > bestScore)
{ {
@ -604,34 +814,6 @@ public static class WorldActivityScanner
bestFollow = ensemble.Focus; bestFollow = ensemble.Focus;
} }
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> 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() private static InterestEvent BuildBestScoredUnit()
{ {
Dictionary<string, int> speciesCounts = new Dictionary<string, int>(); Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
@ -743,6 +925,12 @@ public static class WorldActivityScanner
/// <summary>Public wrappers for dossier / harness (same logic as private scorers).</summary> /// <summary>Public wrappers for dossier / harness (same logic as private scorers).</summary>
public static Dictionary<string, int> CountSpeciesPopulations() public static Dictionary<string, int> CountSpeciesPopulations()
{ {
float now = Time.unscaledTime;
if (_speciesCountCache != null && now - _speciesCountCachedAt < SpeciesCountCacheSeconds)
{
return _speciesCountCache;
}
Dictionary<string, int> speciesCounts = new Dictionary<string, int>(); Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
foreach (Actor actor in EnumerateAliveUnits()) foreach (Actor actor in EnumerateAliveUnits())
{ {
@ -760,6 +948,8 @@ public static class WorldActivityScanner
speciesCounts[id]++; speciesCounts[id]++;
} }
_speciesCountCache = speciesCounts;
_speciesCountCachedAt = now;
return speciesCounts; return speciesCounts;
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.26.1", "version": "0.26.7",
"description": "AFK Idle Spectator (I) + Lore (L). Fix Lore click camera follow (pause before focus).", "description": "AFK Idle Spectator (I) + Lore (L). Fix Duel→Battle escalate when packs join.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }