Enhance combat handling and introduce related unit management.

- Add functionality to remember related units during combat scenarios
- Implement combat target swapping while maintaining pair partner integrity
- Update InterestCandidate to support durable combat pair ownership
- Refactor InterestDirector to prioritize sticky collective tips in combat
- Increment version to 0.28.21 in mod.json
This commit is contained in:
DazedAnon 2026-07-17 17:44:14 -05:00
parent 04d5bcb381
commit 45a37d7471
7 changed files with 520 additions and 47 deletions

View file

@ -32,6 +32,7 @@ public static class AgentHarness
private static bool _directorRunActive; private static bool _directorRunActive;
private static string _rememberedFocusKey = ""; private static string _rememberedFocusKey = "";
private static string _rememberedTip = ""; private static string _rememberedTip = "";
private static long _rememberedRelatedId;
private static string LastScreenshotPath = ""; private static string LastScreenshotPath = "";
private static readonly string[] PreferredSpawnAssets = private static readonly string[] PreferredSpawnAssets =
{ {
@ -259,6 +260,28 @@ public static class AgentHarness
Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}"); Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}");
break; break;
} }
case "remember_related":
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
long id = scene != null
? (scene.PairPartnerId != 0
? scene.PairPartnerId
: EventFeedUtil.SafeId(scene.RelatedUnit))
: 0;
_rememberedRelatedId = id;
bool okRel = id != 0;
if (okRel)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(cmd, okRel, detail: $"related={id}");
break;
}
case "spectator": case "spectator":
{ {
@ -2691,6 +2714,14 @@ public static class AgentHarness
DoCombatWireAttackSides(cmd); DoCombatWireAttackSides(cmd);
break; break;
case "combat_swap_attack_target":
DoCombatSwapAttackTarget(cmd);
break;
case "combat_probe_fighters":
DoCombatProbeFighters(cmd);
break;
case "combat_park_bystander_follow": case "combat_park_bystander_follow":
DoCombatParkBystanderFollow(cmd); DoCombatParkBystanderFollow(cmd);
break; break;
@ -6000,6 +6031,13 @@ public static class AgentHarness
} }
} }
// Enroll camps on the sticky scoreboard. AI often clears attack_target on pack
// extras within a tick; roster membership still lets Duel → Mass escalate.
if (scene != null && campA.Count > 0 && campB.Count > 0)
{
StampCombatCampsOntoScene(scene, sideA, sideB, campA, campB);
}
InterestDirector.HarnessMaintainCombatFocus(); InterestDirector.HarnessMaintainCombatFocus();
bool ok = wired > 0; bool ok = wired > 0;
if (ok) if (ok)
@ -6014,7 +6052,170 @@ public static class AgentHarness
Emit( Emit(
cmd, cmd,
ok, ok,
detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}"); detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} sticky={scene?.CombatSideACount ?? 0}:{scene?.CombatSideBCount ?? 0} peak={scene?.CombatPeakParticipants ?? 0} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
}
private static void StampCombatCampsOntoScene(
InterestCandidate scene,
string sideA,
string sideB,
List<Actor> campA,
List<Actor> campB)
{
if (scene?.Sticky == null || campA == null || campB == null)
{
return;
}
LiveSceneStickyState sticky = scene.Sticky;
sticky.Kind = EnsembleKind.Combat;
sticky.Frame = EnsembleFrame.SpeciesVsSpecies;
sticky.SideAKey = sideA;
sticky.SideBKey = sideB;
sticky.SideADisplay = LiveEnsemble.SpeciesDisplay(sideA);
sticky.SideBDisplay = LiveEnsemble.SpeciesDisplay(sideB);
sticky.SideAIds.Clear();
sticky.SideBIds.Clear();
for (int i = 0; i < campA.Count; i++)
{
long id = EventFeedUtil.SafeId(campA[i]);
if (id != 0 && !sticky.SideAIds.Contains(id))
{
sticky.SideAIds.Add(id);
}
}
for (int i = 0; i < campB.Count; i++)
{
long id = EventFeedUtil.SafeId(campB[i]);
if (id != 0 && !sticky.SideBIds.Contains(id))
{
sticky.SideBIds.Add(id);
}
}
sticky.SideACount = sticky.SideAIds.Count;
sticky.SideBCount = sticky.SideBIds.Count;
int total = sticky.SideACount + sticky.SideBCount;
scene.ParticipantCount = Math.Max(scene.ParticipantCount, total);
if (total > scene.CombatPeakParticipants)
{
scene.CombatPeakParticipants = total;
}
}
/// <summary>Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus.</summary>
private static void DoCombatProbeFighters(HarnessCommand cmd)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
Actor anchor = scene?.FollowUnit;
if (anchor == null || !anchor.isAlive())
{
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
if (anchor == null || !anchor.isAlive())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_anchor");
return;
}
float radius = ParseFloat(cmd.value, 18f);
if (radius < 4f)
{
radius = 18f;
}
LiveEnsemble.TryBuildCombat(
anchor.current_position,
radius,
out LiveEnsemble ensemble,
priorParticipantIds: scene?.ParticipantIds,
newcomerBonus: 8f);
int n = ensemble != null ? ensemble.ParticipantCount : 0;
string frame = ensemble != null ? ensemble.Frame.ToString() : "none";
string scale = ensemble != null ? ensemble.Scale.ToString() : "none";
bool sided = ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble);
bool focusFight = LiveEnsemble.IsCombatParticipant(anchor);
bool relatedFight = scene?.RelatedUnit != null
&& LiveEnsemble.IsCombatParticipant(scene.RelatedUnit);
_cmdOk++;
Emit(
cmd,
ok: true,
detail: $"n={n} r={radius:0.#} frame={frame} scale={scale} sided={sided} focusFight={focusFight} relatedFight={relatedFight} tip='{CameraDirector.LastWatchLabel}'");
}
/// <summary>
/// Spawn a distractor and point the combat Follow's attack_target at them while the
/// locked pair partner stays alive. Maintain / director must not flip the Duel tip.
/// </summary>
private static void DoCombatSwapAttackTarget(HarnessCommand cmd)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_combat_scene");
return;
}
Actor focus = scene.FollowUnit;
Actor partner = scene.RelatedUnit;
if (focus == null || !focus.isAlive())
{
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
if (focus == null || !focus.isAlive())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_focus");
return;
}
if (partner == null || !partner.isAlive())
{
partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId);
}
string distractorAsset = !string.IsNullOrEmpty(cmd.asset)
? cmd.asset.Trim()
: (partner?.asset != null ? partner.asset.id : "wolf");
Actor distractor = SpawnAssetNear(distractorAsset, focus.current_position, 3.5f);
if (distractor == null || !distractor.isAlive())
{
_cmdFail++;
Emit(cmd, ok: false, detail: "spawn_failed");
return;
}
// Keep the locked partner alive so the hold must prefer them over the new target.
if (partner != null && partner.isAlive())
{
StatusGameApi.TryAddStatus(partner, "invincible", 12f);
}
StatusGameApi.TryAddStatus(distractor, "invincible", 8f);
bool wired = TrySetAttackTarget(focus, distractor);
TrySetAttackTarget(distractor, focus);
// Publish the noisy peer pair the way live activity does (new combat:pair key).
InterestFeeds.OnActivityNote(focus, "fighting", hot: true);
if (wired)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
wired,
detail: $"focus={SafeName(focus)} partner={SafeName(partner)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'");
} }
private static bool TrySetAttackTarget(Actor unit, Actor foe) private static bool TrySetAttackTarget(Actor unit, Actor foe)
@ -6476,6 +6677,22 @@ public static class AgentHarness
detail = $"remembered='{_rememberedTip}' now='{nowTip}'"; detail = $"remembered='{_rememberedTip}' now='{nowTip}'";
break; break;
} }
case "related_same":
case "pair_partner_same":
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
long nowId = 0;
if (scene != null)
{
nowId = scene.PairPartnerId != 0
? scene.PairPartnerId
: EventFeedUtil.SafeId(scene.RelatedUnit);
}
pass = _rememberedRelatedId != 0 && nowId == _rememberedRelatedId;
detail = $"remembered={_rememberedRelatedId} now={nowId}";
break;
}
case "focus_arrows": case "focus_arrows":
case "relationship_arrows": case "relationship_arrows":
{ {
@ -10257,6 +10474,7 @@ public static class AgentHarness
_lastSpawnedAssetId = ""; _lastSpawnedAssetId = "";
_rememberedFocusKey = ""; _rememberedFocusKey = "";
_rememberedTip = ""; _rememberedTip = "";
_rememberedRelatedId = 0;
InterestDirector.SetHarnessFastTiming(false); InterestDirector.SetHarnessFastTiming(false);
SpeciesDiscovery.SetHarnessFastTiming(false); SpeciesDiscovery.SetHarnessFastTiming(false);
try try

View file

@ -37,9 +37,9 @@ public static class CameraDirector
string tip = FormatWatchTip(interest); string tip = FormatWatchTip(interest);
string label = interest.Label ?? ""; string label = interest.Label ?? "";
string assetId = interest.AssetId ?? ""; string assetId = interest.AssetId ?? "";
// AssetId-only churn (live_combat ↔ live_battle) must not re-log the same tip.
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);
bool headcountOnly = tipChanged bool headcountOnly = tipChanged
&& EventReason.IsHeadcountOnlyChange(LastWatchLabel, label); && EventReason.IsHeadcountOnlyChange(LastWatchLabel, label);
LastFormattedWatchTip = tip; LastFormattedWatchTip = tip;

View file

@ -1122,6 +1122,15 @@ internal static class HarnessScenarios
Step("cf21e", "assert", expect: "tip_same"), Step("cf21e", "assert", expect: "tip_same"),
Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf21w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"), Step("cf21w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"),
// Attack-target swap must keep locked pair partner (tip may escalate 1v2→Skirmish).
Step("cf21p", "remember_related"),
Step("cf21q", "combat_swap_attack_target", asset: "wolf"),
Step("cf21r", "wait", value: "0.35"),
Step("cf21s", "director_run", wait: 0.6f),
Step("cf21t", "combat_maintain_focus"),
Step("cf21u", "combat_maintain_focus"),
Step("cf21v", "assert", expect: "related_same"),
Step("cf21x", "assert", expect: "tip_matches_any", value: "Duel|Skirmish|Battle|Mass|fighting| vs "),
Step("cf22", "interest_clear_follow", asset: "wolf"), Step("cf22", "interest_clear_follow", asset: "wolf"),
Step("cf23", "combat_maintain_focus"), Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"), Step("cf24", "assert", expect: "has_focus", value: "true"),
@ -1144,14 +1153,15 @@ internal static class HarnessScenarios
Step("cf45", "assert", expect: "tip_matches_focus"), Step("cf45", "assert", expect: "tip_matches_focus"),
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"),
// Theater lead: collective maintain must not thrash Follow across the mob. // Theater lead: settle once, then maintain must not thrash Follow across the mob.
Step("cf47a", "remember_focus"), Step("cf47a", "combat_maintain_focus"),
Step("cf47b", "combat_maintain_focus"), Step("cf47b", "remember_focus"),
Step("cf47c", "wait", value: "0.35"), Step("cf47c", "combat_maintain_focus"),
Step("cf47d", "combat_maintain_focus"), Step("cf47d", "wait", value: "0.35"),
Step("cf47e", "wait", value: "0.35"), Step("cf47e", "combat_maintain_focus"),
Step("cf47f", "combat_maintain_focus"), Step("cf47f", "wait", value: "0.35"),
Step("cf47g", "assert", expect: "focus_same"), Step("cf47g", "combat_maintain_focus"),
Step("cf47h", "assert", expect: "focus_same"),
// Spectacle theater lead: evil mage vs human mob holds the mage, not a random human. // Spectacle theater lead: evil mage vs human mob holds the mage, not a random human.
Step("cf48", "interest_end_session"), Step("cf48", "interest_end_session"),

View file

@ -176,8 +176,12 @@ public sealed class InterestCandidate
ClearTheaterLead(); ClearTheaterLead();
} }
/// <summary>Lock durable 1v1 owner/partner ids (no-op when owner missing).</summary> /// <summary>
public void StampCombatPair(Actor owner, Actor partner) /// Lock durable 1v1 owner/partner ids (no-op when owner missing).
/// First living partner sticks across attack_target swaps; replace only when the
/// locked partner is dead/missing (or <paramref name="replacePartner"/>).
/// </summary>
public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false)
{ {
long oid = EventFeedUtil.SafeId(owner); long oid = EventFeedUtil.SafeId(owner);
if (oid == 0) if (oid == 0)
@ -191,7 +195,25 @@ public sealed class InterestCandidate
} }
long pid = EventFeedUtil.SafeId(partner); long pid = EventFeedUtil.SafeId(partner);
if (pid != 0 && pid != PairOwnerId) if (pid == 0 || pid == PairOwnerId)
{
return;
}
if (PairPartnerId == 0 || replacePartner)
{
PairPartnerId = pid;
return;
}
if (PairPartnerId == pid)
{
return;
}
// Locked partner still alive: keep them. Dead/missing: adopt the new foe once.
Actor locked = LiveEnsemble.FindTrackedActor(PairPartnerId);
if (locked == null || !locked.isAlive())
{ {
PairPartnerId = pid; PairPartnerId = pid;
} }

View file

@ -724,13 +724,20 @@ public static class InterestDirector
int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2; int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2;
bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus); bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus);
bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated); bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated);
int stickyCampN = _current.CombatSideACount + _current.CombatSideBCount;
// Sticky camps enrolled (pack wire / live growth) outrank NamedPair hold even when
// the live probe still looks like a thin Duel this tick.
bool stickyPackEscalate = HasStickyCollectiveMulti(_current)
&& stickyCampN >= 3
&& (focusFighting || relatedFighting);
// Both left the scrap (chores / idle): do not hold a Duel camera on them. // Both left the scrap (chores / idle): do not hold a Duel camera on them.
// Keep durable A vs B order while either still fights (no tip flip on attack gaps). // Keep durable A vs B order while either still fights (no tip flip on attack gaps).
if (!focusFighting && !relatedFighting) if (!focusFighting && !relatedFighting)
{ {
// Fall through to sticky rebuild / live fighter pick. // Fall through to sticky rebuild / live fighter pick.
} }
else if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) else if (!stickyPackEscalate
&& !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters))
{ {
string pairLabel = ""; string pairLabel = "";
Actor holdFocus = ownedFocus; Actor holdFocus = ownedFocus;
@ -1106,10 +1113,16 @@ public static class InterestDirector
} }
} }
// Never replace a sticky scoreboard tip with thin Fight prose. // Prefer sticky collective tips over thin Fight / leftover Duel prose.
// Live probes often collapse to NamedPair when AI drops pack attack_targets.
int stickyFighters = _current.CombatSideACount + _current.CombatSideBCount;
bool stickyCollectiveTip = HasStickyCombatSides(_current)
&& (HasStickyCollectiveMulti(_current) || stickyFighters >= 3);
if (HasStickyCombatSides(_current) if (HasStickyCombatSides(_current)
&& (string.IsNullOrEmpty(label) && (string.IsNullOrEmpty(label)
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)) || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0
|| (stickyCollectiveTip
&& label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))))
{ {
var stickyEns = new LiveEnsemble var stickyEns = new LiveEnsemble
{ {
@ -1134,7 +1147,7 @@ public static class InterestDirector
Count = _current.CombatSideBCount, Count = _current.CombatSideBCount,
Best = foe Best = foe
}, },
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount ParticipantCount = Math.Max(stickyFighters, _current.CombatSideACount + _current.CombatSideBCount)
}; };
label = EventReason.Combat(stickyEns); label = EventReason.Combat(stickyEns);
} }
@ -2144,19 +2157,6 @@ public static class InterestDirector
string tip = scene.Label ?? ""; string tip = scene.Label ?? "";
bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
// 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;
}
if (fighters < 3 || ensemble == null || !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
return false;
}
// Natural growth: both pair members still fighting inside a sided multi cluster.
ResolveCombatPairActors(scene, out Actor owner, out Actor partner); ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit; Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit;
Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit; Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit;
@ -2165,8 +2165,40 @@ public static class InterestDirector
return false; return false;
} }
return LiveEnsemble.IsCombatParticipant(focus) bool pairEngaged = LiveEnsemble.IsCombatParticipant(focus)
&& LiveEnsemble.IsCombatParticipant(related); || LiveEnsemble.IsCombatParticipant(related);
// Already-framed collective multi may leave NamedPair. Duel tips still escalate when
// sticky camps were enrolled (AI often drops attack_target on pack extras).
if (HasStickyCollectiveMulti(scene))
{
if (!duelLabeled)
{
return true;
}
int stickyN = scene.CombatSideACount + scene.CombatSideBCount;
if (stickyN >= 3 && pairEngaged)
{
return true;
}
}
if (fighters < 3 || ensemble == null)
{
return false;
}
bool sidedMulti = LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|| ensemble.Scale >= EnsembleScale.Skirmish;
if (!sidedMulti)
{
return false;
}
// Natural growth: pair still owns a living scrap inside a sided multi.
// Attack-target gaps on one partner must not block Duel → Mass/Battle.
return pairEngaged;
} }
private static void ApplyNamedPairHold( private static void ApplyNamedPairHold(
@ -2183,8 +2215,11 @@ public static class InterestDirector
return; return;
} }
// Drop ambient species/kingdom sticky that leaked in during cluster Refresh. // Drop ambient species/kingdom sticky that leaked in during cluster Refresh,
// but never wipe a real multi camp roster (3+ enrolled) while holding a Duel tip.
int enrolled = scene.CombatSideACount + scene.CombatSideBCount;
if (HasStickyCombatSides(scene) if (HasStickyCombatSides(scene)
&& enrolled < 3
&& (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies && (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies
|| scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom)) || scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom))
{ {
@ -3487,6 +3522,15 @@ public static class InterestDirector
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
} }
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
if (IsCombatPartnerSwapNoise(_current, candidate))
{
InterestDropLog.Record(
"combat_partner_hold",
$"cur={_current.Key} next={candidate.Key}");
return false;
}
float now = Time.unscaledTime; float now = Time.unscaledTime;
bool inGrace = InQuietGrace; bool inGrace = InQuietGrace;
if (inGrace) if (inGrace)
@ -3826,14 +3870,21 @@ public static class InterestDirector
return true; return true;
} }
// Combat / hatch share key prefixes per subject. // Hatch: same subject beat may refresh without full margin.
if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key)) if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
{ {
if (SameKeyPrefix(current.Key, next.Key, "combat:") return true;
|| SameKeyPrefix(current.Key, next.Key, "hatch:")) }
{
return true; // Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
} // Different combat:pair keys used to soft-cut and thrash Duel partners.
if (current.Completion == InterestCompletionKind.CombatActive
&& next.Completion == InterestCompletionKind.CombatActive
&& IsSameCombatPairArc(current, next))
{
return true;
} }
if (current.Completion == InterestCompletionKind.HappinessGrief if (current.Completion == InterestCompletionKind.HappinessGrief
@ -3847,6 +3898,164 @@ public static class InterestDirector
return false; return false;
} }
/// <summary>
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
/// </summary>
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
{
if (current == null || next == null)
{
return false;
}
if (!string.IsNullOrEmpty(current.Key)
&& !string.IsNullOrEmpty(next.Key)
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
{
return true;
}
CollectCombatActorIds(current, out long curA, out long curB);
CollectCombatActorIds(next, out long nextA, out long nextB);
bool samePair = curA != 0
&& curB != 0
&& nextA != 0
&& nextB != 0
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
if (samePair)
{
return true;
}
// Duel → Mass/Battle escalate: shared fighter + larger theater.
bool nextMulti = next.ParticipantCount >= 3
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| HasStickyCollectiveMulti(next);
if (!nextMulti)
{
return false;
}
return SharesCombatFighterId(current, nextA)
|| SharesCombatFighterId(current, nextB)
|| SharesCombatFighterId(next, curA)
|| SharesCombatFighterId(next, curB);
}
/// <summary>
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
/// </summary>
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
{
if (current == null
|| next == null
|| current.Completion != InterestCompletionKind.CombatActive
|| next.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
// Multi escalate / different theater is not partner-swap noise.
bool nextMulti = next.ParticipantCount >= 3
|| HasStickyCollectiveMulti(next)
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
&& next.ParticipantCount > 2);
if (nextMulti && !IsThinPairCombat(next))
{
return false;
}
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
{
return false;
}
if (!IsThinPairCombat(next))
{
return false;
}
CollectCombatActorIds(current, out long curA, out long curB);
CollectCombatActorIds(next, out long nextA, out long nextB);
if (curA == 0 && curB == 0)
{
return false;
}
// Exact same unordered pair is same scene (registry key should match) - not noise.
if (curA != 0
&& curB != 0
&& nextA != 0
&& nextB != 0
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA)))
{
return false;
}
// Shared fighter + different partner = attack_target thrash.
return SharesCombatFighterId(current, nextA)
|| SharesCombatFighterId(current, nextB);
}
private static bool IsThinPairCombat(InterestCandidate c)
{
if (c == null)
{
return false;
}
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
{
return true;
}
string tip = c.Label ?? "";
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
string key = c.Key ?? "";
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|| (key.StartsWith("combat:", StringComparison.Ordinal)
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
}
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
{
a = 0;
b = 0;
if (c == null)
{
return;
}
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
if (a == 0)
{
a = EventFeedUtil.SafeId(c.FollowUnit);
}
if (b == 0)
{
b = EventFeedUtil.SafeId(c.RelatedUnit);
}
}
private static bool SharesCombatFighterId(InterestCandidate c, long id)
{
if (c == null || id == 0)
{
return false;
}
CollectCombatActorIds(c, out long a, out long b);
return id == a || id == b;
}
private static bool SameKeyPrefix(string a, string b, string prefix) private static bool SameKeyPrefix(string a, string b, string prefix)
{ {
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix)) if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
@ -3980,6 +4189,12 @@ public static class InterestDirector
return; return;
} }
// Same scene object: maintain already owns focus - do not re-Watch / re-log.
if (next == _current)
{
return;
}
if (!EventPresentability.WouldShow(next) if (!EventPresentability.WouldShow(next)
&& !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase)) && !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
{ {

View file

@ -180,13 +180,21 @@ public static class InterestRegistry
existing.Position = incoming.Position; existing.Position = incoming.Position;
} }
if (incoming.RelatedUnit != null) if (incoming.RelatedUnit != null && incoming.RelatedUnit.isAlive())
{ {
existing.RelatedUnit = incoming.RelatedUnit; long incomingRelatedId = EventFeedUtil.SafeId(incoming.RelatedUnit);
existing.RelatedId = incoming.RelatedId; // Pair-locked Duels keep the locked partner across attack_target upserts.
if (existing.PairPartnerId == 0) bool adoptRelated = !pairLocked
|| existing.PairPartnerId == 0
|| incomingRelatedId == existing.PairPartnerId;
if (adoptRelated)
{ {
existing.PairPartnerId = incoming.RelatedId; existing.RelatedUnit = incoming.RelatedUnit;
existing.RelatedId = incoming.RelatedId;
if (existing.PairPartnerId == 0 && incomingRelatedId != 0)
{
existing.PairPartnerId = incomingRelatedId;
}
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.28.18", "version": "0.28.21",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }