From 45a37d74712c762d3c88c68cca65bcaa52b96a04 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Fri, 17 Jul 2026 17:44:14 -0500 Subject: [PATCH] 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 --- IdleSpectator/AgentHarness.cs | 220 ++++++++++++++++++++++- IdleSpectator/CameraDirector.cs | 4 +- IdleSpectator/HarnessScenarios.cs | 26 ++- IdleSpectator/InterestCandidate.cs | 28 ++- IdleSpectator/InterestDirector.cs | 269 ++++++++++++++++++++++++++--- IdleSpectator/InterestRegistry.cs | 18 +- IdleSpectator/mod.json | 2 +- 7 files changed, 520 insertions(+), 47 deletions(-) diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index a404d49..aeac97c 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -32,6 +32,7 @@ public static class AgentHarness private static bool _directorRunActive; private static string _rememberedFocusKey = ""; private static string _rememberedTip = ""; + private static long _rememberedRelatedId; private static string LastScreenshotPath = ""; private static readonly string[] PreferredSpawnAssets = { @@ -259,6 +260,28 @@ public static class AgentHarness Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}"); 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": { @@ -2691,6 +2714,14 @@ public static class AgentHarness DoCombatWireAttackSides(cmd); break; + case "combat_swap_attack_target": + DoCombatSwapAttackTarget(cmd); + break; + + case "combat_probe_fighters": + DoCombatProbeFighters(cmd); + break; + case "combat_park_bystander_follow": DoCombatParkBystanderFollow(cmd); 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(); bool ok = wired > 0; if (ok) @@ -6014,7 +6052,170 @@ public static class AgentHarness Emit( cmd, 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 campA, + List 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; + } + } + + /// Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus. + 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}'"); + } + + /// + /// 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. + /// + 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) @@ -6476,6 +6677,22 @@ public static class AgentHarness detail = $"remembered='{_rememberedTip}' now='{nowTip}'"; 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 "relationship_arrows": { @@ -10257,6 +10474,7 @@ public static class AgentHarness _lastSpawnedAssetId = ""; _rememberedFocusKey = ""; _rememberedTip = ""; + _rememberedRelatedId = 0; InterestDirector.SetHarnessFastTiming(false); SpeciesDiscovery.SetHarnessFastTiming(false); try diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index dcac631..160de90 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -37,9 +37,9 @@ public static class CameraDirector string tip = FormatWatchTip(interest); string label = interest.Label ?? ""; 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) - || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal) - || !string.Equals(assetId, LastWatchAssetId, StringComparison.Ordinal); + || !string.Equals(label, LastWatchLabel, StringComparison.Ordinal); bool headcountOnly = tipChanged && EventReason.IsHeadcountOnlyChange(LastWatchLabel, label); LastFormattedWatchTip = tip; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 57eb79b..3ee6dc7 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -1122,6 +1122,15 @@ internal static class HarnessScenarios Step("cf21e", "assert", expect: "tip_same"), Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), 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("cf23", "combat_maintain_focus"), Step("cf24", "assert", expect: "has_focus", value: "true"), @@ -1144,14 +1153,15 @@ internal static class HarnessScenarios Step("cf45", "assert", expect: "tip_matches_focus"), Step("cf46", "assert", expect: "dossier_matches_focus"), Step("cf47", "assert", expect: "has_focus", value: "true"), - // Theater lead: collective maintain must not thrash Follow across the mob. - Step("cf47a", "remember_focus"), - Step("cf47b", "combat_maintain_focus"), - Step("cf47c", "wait", value: "0.35"), - Step("cf47d", "combat_maintain_focus"), - Step("cf47e", "wait", value: "0.35"), - Step("cf47f", "combat_maintain_focus"), - Step("cf47g", "assert", expect: "focus_same"), + // Theater lead: settle once, then maintain must not thrash Follow across the mob. + Step("cf47a", "combat_maintain_focus"), + Step("cf47b", "remember_focus"), + Step("cf47c", "combat_maintain_focus"), + Step("cf47d", "wait", value: "0.35"), + Step("cf47e", "combat_maintain_focus"), + Step("cf47f", "wait", value: "0.35"), + 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. Step("cf48", "interest_end_session"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 06d4245..7db0667 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -176,8 +176,12 @@ public sealed class InterestCandidate ClearTheaterLead(); } - /// Lock durable 1v1 owner/partner ids (no-op when owner missing). - 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 ). + /// + public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false) { long oid = EventFeedUtil.SafeId(owner); if (oid == 0) @@ -191,7 +195,25 @@ public sealed class InterestCandidate } 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; } diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 86d454f..1fc3467 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -724,13 +724,20 @@ public static class InterestDirector int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2; bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus); 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. // Keep durable A vs B order while either still fights (no tip flip on attack gaps). if (!focusFighting && !relatedFighting) { // Fall through to sticky rebuild / live fighter pick. } - else if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) + else if (!stickyPackEscalate + && !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters)) { string pairLabel = ""; 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) && (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 { @@ -1134,7 +1147,7 @@ public static class InterestDirector Count = _current.CombatSideBCount, Best = foe }, - ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount + ParticipantCount = Math.Max(stickyFighters, _current.CombatSideACount + _current.CombatSideBCount) }; label = EventReason.Combat(stickyEns); } @@ -2144,19 +2157,6 @@ public static class InterestDirector string tip = scene.Label ?? ""; 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); Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit; Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit; @@ -2165,8 +2165,40 @@ public static class InterestDirector return false; } - return LiveEnsemble.IsCombatParticipant(focus) - && LiveEnsemble.IsCombatParticipant(related); + bool pairEngaged = LiveEnsemble.IsCombatParticipant(focus) + || 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( @@ -2183,8 +2215,11 @@ public static class InterestDirector 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) + && enrolled < 3 && (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies || scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom)) { @@ -3487,6 +3522,15 @@ public static class InterestDirector 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; bool inGrace = InQuietGrace; if (inGrace) @@ -3826,14 +3870,21 @@ public static class InterestDirector return true; } - // Combat / hatch share key prefixes per subject. - if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key)) + // Hatch: same subject beat may refresh without full margin. + if (!string.IsNullOrEmpty(current.Key) + && !string.IsNullOrEmpty(next.Key) + && SameKeyPrefix(current.Key, next.Key, "hatch:")) { - if (SameKeyPrefix(current.Key, next.Key, "combat:") - || SameKeyPrefix(current.Key, next.Key, "hatch:")) - { - return true; - } + 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 @@ -3847,6 +3898,164 @@ public static class InterestDirector return false; } + /// + /// True when next is the same 1v1 pair or a multi escalate of the current scrap. + /// + 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); + } + + /// + /// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold. + /// + 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) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix)) @@ -3980,6 +4189,12 @@ public static class InterestDirector return; } + // Same scene object: maintain already owns focus - do not re-Watch / re-log. + if (next == _current) + { + return; + } + if (!EventPresentability.WouldShow(next) && !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase)) { diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 79ef5ea..88eec2f 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -180,13 +180,21 @@ public static class InterestRegistry existing.Position = incoming.Position; } - if (incoming.RelatedUnit != null) + if (incoming.RelatedUnit != null && incoming.RelatedUnit.isAlive()) { - existing.RelatedUnit = incoming.RelatedUnit; - existing.RelatedId = incoming.RelatedId; - if (existing.PairPartnerId == 0) + long incomingRelatedId = EventFeedUtil.SafeId(incoming.RelatedUnit); + // Pair-locked Duels keep the locked partner across attack_target upserts. + 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; + } } } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 745e2df..771ca5b 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.18", + "version": "0.28.21", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" }