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:
parent
90d5b1604f
commit
366ae45f13
15 changed files with 973 additions and 265 deletions
47
.tmp-reflect/DumpActorMgr.cs
Normal file
47
.tmp-reflect/DumpActorMgr.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.tmp-reflect/DumpActorMgr.csproj
Normal file
8
.tmp-reflect/DumpActorMgr.csproj
Normal 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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 "),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,55 +694,67 @@ 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))
|
|
||||||
{
|
{
|
||||||
string pairLabel = "";
|
LiveEnsemble.TryBuildCombat(
|
||||||
Actor holdFocus = ownedFocus;
|
ownedFocus.current_position,
|
||||||
Actor holdFoe = ownedRelated;
|
14f,
|
||||||
int holdFighters = 2;
|
out LiveEnsemble escalateProbe,
|
||||||
ApplyNamedPairHold(
|
priorParticipantIds: _current.ParticipantIds,
|
||||||
_current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel);
|
newcomerBonus: 8f);
|
||||||
string priorLabel = _current.Label ?? "";
|
int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2;
|
||||||
if (string.IsNullOrEmpty(pairLabel))
|
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;
|
// Real multi around the pair: fall through to sticky rebuild / collective tip.
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Vector3 pos = _current.Position;
|
Vector3 pos = _current.Position;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -1034,7 +1063,7 @@ public static class InterestDirector
|
||||||
{
|
{
|
||||||
ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
|
ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never replace a sticky scoreboard tip with thin Fight prose.
|
// Never replace a sticky scoreboard tip with thin Fight prose.
|
||||||
if (HasStickyCombatSides(_current)
|
if (HasStickyCombatSides(_current)
|
||||||
|
|
@ -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 ?? "";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 (LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble)
|
||||||
|
&& ensemble != null
|
||||||
|
&& ensemble.ParticipantCount >= 1)
|
||||||
{
|
{
|
||||||
if (actor?.asset != null && actor.asset.is_boat)
|
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,62 +430,120 @@ 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)
|
||||||
{
|
{
|
||||||
bestScore = score;
|
continue;
|
||||||
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
|
}
|
||||||
string label = ensemble != null && ensemble.HasFocus
|
|
||||||
? EventReason.Combat(ensemble)
|
bestScore = score;
|
||||||
: EventReason.Battle(focus, fighters);
|
best = FromEnsembleBattle(ensemble, pos, deaths, follow, fighters, notables, score);
|
||||||
best = new InterestEvent
|
}
|
||||||
{
|
|
||||||
Score = score,
|
return best;
|
||||||
Position = pos,
|
}
|
||||||
FollowUnit = focus,
|
|
||||||
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
private static InterestEvent FromEnsembleBattle(
|
||||||
Label = label,
|
LiveEnsemble ensemble,
|
||||||
CreatedAt = Time.unscaledTime,
|
Vector3 pos,
|
||||||
AssetId = "live_battle",
|
int deaths,
|
||||||
ParticipantCount = fighters,
|
Actor follow = null,
|
||||||
NotableParticipantCount = notables,
|
int fighters = -1,
|
||||||
CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
|
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();
|
fighters = Mathf.Max(0, fighters);
|
||||||
return PreferRicherAction(best, live);
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Cluster of units currently in combat (even before BattleKeeper records deaths).</summary>
|
/// <summary>
|
||||||
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.
|
||||||
|
/// </summary>
|
||||||
|
private static InterestEvent BuildHottestFightCluster(List<Actor> allFighters)
|
||||||
{
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue