diff --git a/IdleSpectator/CameraDirector.cs b/IdleSpectator/CameraDirector.cs index 160de90..7a2e7ae 100644 --- a/IdleSpectator/CameraDirector.cs +++ b/IdleSpectator/CameraDirector.cs @@ -40,14 +40,15 @@ public static class CameraDirector // 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); - bool headcountOnly = tipChanged - && EventReason.IsHeadcountOnlyChange(LastWatchLabel, label); + bool framingOnly = tipChanged + && (EventReason.IsHeadcountOnlyChange(LastWatchLabel, label) + || EventReason.IsCombatFramingOnlyChange(LastWatchLabel, label)); LastFormattedWatchTip = tip; // LastWatchLabel is tip/harness telemetry only - never dossier reason truth. LastWatchLabel = label; LastWatchAssetId = assetId; - // Sticky Mass tips tick headcounts often - log structure changes only. - if (tipChanged && !headcountOnly) + // Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only. + if (tipChanged && !framingOnly) { LogService.LogInfo($"[IdleSpectator] {tip}"); } diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index 7829774..2ded309 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -30,6 +30,83 @@ public static class EventReason return string.Equals(StripParenCounts(previous), StripParenCounts(next), StringComparison.Ordinal); } + /// + /// True when two combat tips describe the same camps but differ by tier + /// (Battle↔Mass) and/or side order (A vs B ↔ B vs A), optionally with counts. + /// + public static bool IsCombatFramingOnlyChange(string previous, string next) + { + if (string.IsNullOrEmpty(previous) || string.IsNullOrEmpty(next)) + { + return false; + } + + if (string.Equals(previous, next, StringComparison.Ordinal)) + { + return false; + } + + if (!TryCombatCampKey(previous, out string prevKey) + || !TryCombatCampKey(next, out string nextKey)) + { + return false; + } + + return string.Equals(prevKey, nextKey, StringComparison.Ordinal); + } + + private static bool TryCombatCampKey(string label, out string key) + { + key = ""; + if (string.IsNullOrEmpty(label)) + { + return false; + } + + string t = label.Trim(); + int dash = t.IndexOf(" - ", StringComparison.Ordinal); + if (dash < 0) + { + return false; + } + + string tier = t.Substring(0, dash).Trim(); + if (!tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase) + && !tier.Equals("Battle", StringComparison.OrdinalIgnoreCase) + && !tier.Equals("Mass", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string rest = StripParenCounts(t.Substring(dash + 3).Trim()); + int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase); + if (vs < 0) + { + // Same-side pack tip: Mass - Wolves () + key = "pack:" + rest.ToLowerInvariant(); + return !string.IsNullOrEmpty(rest); + } + + string a = rest.Substring(0, vs).Trim().ToLowerInvariant(); + string b = rest.Substring(vs + 4).Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) + { + return false; + } + + // Unordered camp pair so A vs B and B vs A collapse. + if (string.CompareOrdinal(a, b) <= 0) + { + key = a + "|" + b; + } + else + { + key = b + "|" + a; + } + + return true; + } + private static string StripParenCounts(string text) { var sb = new StringBuilder(text.Length); diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 3ee6dc7..61b29c2 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -1180,6 +1180,11 @@ internal static class HarnessScenarios Step("cf48n", "combat_maintain_focus"), Step("cf48o", "wait", value: "0.35"), Step("cf48p", "combat_maintain_focus"), + // Re-wire / refresh camps mid-hold - tip may reframe; focus must stay on the mage. + Step("cf48p2", "combat_wire_attack_sides", asset: "evil_mage", value: "human"), + Step("cf48p3", "combat_maintain_focus"), + Step("cf48p4", "wait", value: "0.35"), + Step("cf48p5", "combat_maintain_focus"), Step("cf48q", "assert", expect: "focus_same"), Step("cf48r", "assert", expect: "unit_asset", asset: "evil_mage"), Step("cf48s", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 7db0667..54d5b33 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -172,8 +172,9 @@ public sealed class InterestCandidate public void ClearCombatSticky() { + // Keep TheaterLeadId across sticky rebuilds / tip reframes so Mass focus + // does not hop when camps briefly lose opposing-side identity. Sticky.Clear(); - ClearTheaterLead(); } /// @@ -229,6 +230,12 @@ public sealed class InterestCandidate return; } + // New lead must not inherit the previous unit's attack-gap grace. + if (TheaterLeadId != 0 && TheaterLeadId != id) + { + TheaterLeadLastCombatAt = -999f; + } + TheaterLeadId = id; } diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 1fc3467..6c04375 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -60,8 +60,11 @@ public static class InterestDirector private const float CombatTheaterLeadGraceSeconds = 4f; /// Only a clearly hotter fighter may steal theater lead mid-hold. private const float CombatTheaterLeadSwitchMargin = 40f; + /// Same sticky side: never hop across a mob for a near-tie. + private const float CombatTheaterLeadSameSideSwitchMargin = 90f; /// Boost the thinner sticky side's best (1-vs-mob spectacles). private const float CombatTheaterLeadOutnumberedBonus = 28f; + private const float CombatTheaterLeadNearRadius = 20f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => CurrentScoreLabel; @@ -1124,31 +1127,8 @@ public static class InterestDirector || (stickyCollectiveTip && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)))) { - var stickyEns = new LiveEnsemble - { - Kind = EnsembleKind.Combat, - Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), - Focus = best, - Related = foe, - Frame = _current.CombatSideFrame, - SideA = new EnsembleSide - { - Key = _current.CombatSideAKey, - Display = _current.CombatSideADisplay, - KingdomDisplay = _current.CombatSideAKingdom, - Count = _current.CombatSideACount, - Best = best - }, - SideB = new EnsembleSide - { - Key = _current.CombatSideBKey, - Display = _current.CombatSideBDisplay, - KingdomDisplay = _current.CombatSideBKingdom, - Count = _current.CombatSideBCount, - Best = foe - }, - ParticipantCount = Math.Max(stickyFighters, _current.CombatSideACount + _current.CombatSideBCount) - }; + // Keep theater-lead side first so Mass tip order does not flip every reframe. + BuildStickyCombatTipEnsemble(_current, best, foe, out LiveEnsemble stickyEns); label = EventReason.Combat(stickyEns); } @@ -1172,8 +1152,9 @@ public static class InterestDirector bool followChanged = _current.FollowUnit != best; bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); - bool headcountOnly = labelChanged - && EventReason.IsHeadcountOnlyChange(previousLabel, label ?? ""); + bool framingOnly = labelChanged + && (EventReason.IsHeadcountOnlyChange(previousLabel, label ?? "") + || EventReason.IsCombatFramingOnlyChange(previousLabel, label ?? "")); _current.FollowUnit = best; _current.SubjectId = EventFeedUtil.SafeId(best); _current.RelatedUnit = foe; @@ -1188,11 +1169,11 @@ public static class InterestDirector // keep prior position } - // Headcount-only Mass tip churn: keep Label for live reason refresh, skip Watch - // (RetargetFollow + tip log) when the camera subject is already correct. + // Headcount / side-order / Battle↔Mass reframes: keep Label live, skip Watch + // when the camera subject is already correct (avoids tip-log + caption churn). bool needWatch = forceWatch || followChanged - || (labelChanged && !headcountOnly) + || (labelChanged && !framingOnly) || !HasLivingCameraFocus() || MoveCamera._focus_unit != best; if (needWatch) @@ -1915,8 +1896,8 @@ public static class InterestDirector } /// - /// Hold the collective theater lead through attack gaps; retarget only on death, - /// leaving the scrap past grace, or a clearly hotter fighter. + /// Hold the collective theater lead through attack gaps, sticky rebuilds, and tip + /// reframes; retarget only on death, leaving the theater, or a clearly hotter fighter. /// private static bool TryApplyTheaterLeadHold( InterestCandidate scene, @@ -1924,17 +1905,9 @@ public static class InterestDirector ref Actor foe, int fighters) { - if (scene == null || !IsCollectiveCombatTheater(scene)) + if (scene == null) { - if (fighters < 3 || scene == null || !HasStickyCombatSides(scene)) - { - return false; - } - - if (IsNamedPairCombatOwnership(scene)) - { - return false; - } + return false; } float now = Time.unscaledTime; @@ -1951,17 +1924,44 @@ public static class InterestDirector : null; } + bool hasDurableLead = held != null && held.isAlive() && scene.TheaterLeadId != 0; + if (!IsCollectiveCombatTheater(scene) && !hasDurableLead) + { + if (fighters < 3 || !HasStickyCombatSides(scene)) + { + return false; + } + + if (IsNamedPairCombatOwnership(scene)) + { + return false; + } + } + + if (IsNamedPairCombatOwnership(scene) && !hasDurableLead) + { + return false; + } + if (held != null && LiveEnsemble.IsCombatParticipant(held)) { scene.TheaterLeadLastCombatAt = now; } bool onRoster = held != null && StickyScoreboard.IsOnRoster(scene, held); + bool nearTheater = held != null && IsNearCombatTheater(scene, held); + bool fighting = held != null && LiveEnsemble.IsCombatParticipant(held); bool inGrace = held != null && held.isAlive() - && (LiveEnsemble.IsCombatParticipant(held) - || now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds); - bool holdOk = held != null && held.isAlive() && onRoster && inGrace; + && scene.TheaterLeadId != 0 + && EventFeedUtil.SafeId(held) == scene.TheaterLeadId + && now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds; + // Must be fighting or within post-fight grace. Roster/near only keep a real lead + // (chore bystanders parked on Follow must not inherit grace and stick the camera). + bool holdOk = held != null + && held.isAlive() + && (fighting || inGrace) + && (onRoster || nearTheater || fighting); if (!TryPickTheaterLead(scene, out Actor pick, out Actor pickFoe)) { @@ -1971,7 +1971,7 @@ public static class InterestDirector } best = held; - foe = ResolveAttackFoe(held) ?? scene.RelatedUnit ?? foe; + foe = ResolveAttackFoe(held) ?? OppositeSideBest(scene, held) ?? scene.RelatedUnit ?? foe; scene.StampTheaterLead(held); return true; } @@ -1988,9 +1988,19 @@ public static class InterestDirector pick, SideCountForActor(scene, pick), SideCountForOpponent(scene, pick)); + bool sameSide = SameStickySide(scene, held, pick); + float margin = sameSide + ? CombatTheaterLeadSameSideSwitchMargin + : CombatTheaterLeadSwitchMargin; + // Spectacle theater leads (evil mage, dragon, …) keep the camera harder. + if (WorldActivityScanner.IsSpectaclePublic(held)) + { + margin = Mathf.Max(margin, CombatTheaterLeadSameSideSwitchMargin); + } + bool steal = pick != held && LiveEnsemble.IsCombatParticipant(pick) - && pickW >= heldW + CombatTheaterLeadSwitchMargin; + && pickW >= heldW + margin; if (!steal) { best = held; @@ -2014,6 +2024,80 @@ public static class InterestDirector return true; } + private static bool IsNearCombatTheater(InterestCandidate scene, Actor actor) + { + if (scene == null || actor == null || !actor.isAlive()) + { + return false; + } + + try + { + Vector3 anchor = scene.Position; + if (scene.FollowUnit != null && scene.FollowUnit.isAlive() && scene.FollowUnit != actor) + { + // Prefer sticky scrap anchor when follow already hopped. + } + + float dx = actor.current_position.x - anchor.x; + float dy = actor.current_position.y - anchor.y; + float r = CombatTheaterLeadNearRadius; + return dx * dx + dy * dy <= r * r; + } + catch + { + return false; + } + } + + private static bool SameStickySide(InterestCandidate scene, Actor a, Actor b) + { + if (scene?.Sticky == null || a == null || b == null) + { + return false; + } + + long idA = EventFeedUtil.SafeId(a); + long idB = EventFeedUtil.SafeId(b); + if (idA == 0 || idB == 0) + { + return false; + } + + LiveSceneStickyState sticky = scene.Sticky; + bool aOnA = false; + bool aOnB = false; + bool bOnA = false; + bool bOnB = false; + for (int i = 0; i < sticky.SideAIds.Count; i++) + { + if (sticky.SideAIds[i] == idA) + { + aOnA = true; + } + + if (sticky.SideAIds[i] == idB) + { + bOnA = true; + } + } + + for (int i = 0; i < sticky.SideBIds.Count; i++) + { + if (sticky.SideBIds[i] == idA) + { + aOnB = true; + } + + if (sticky.SideBIds[i] == idB) + { + bOnB = true; + } + } + + return (aOnA && bOnA) || (aOnB && bOnB); + } + private static int SideCountForActor(InterestCandidate scene, Actor actor) { if (scene?.Sticky == null || actor == null) @@ -2196,6 +2280,15 @@ public static class InterestDirector return false; } + // Probe-only escalate needs a real pack on both sides. A single attack_target + // distractor (Skirmish 2v1) is partner-swap noise and must keep the Duel lock. + int sideA = ensemble.SideA != null ? ensemble.SideA.Count : 0; + int sideB = ensemble.SideB != null ? ensemble.SideB.Count : 0; + if (sideA < 2 || sideB < 2) + { + 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; @@ -2636,10 +2729,76 @@ public static class InterestDirector _current.Label = ""; _current.ClearCombatSticky(); + _current.ClearTheaterLead(); _lastCombatFocusAt = now; CameraDirector.Watch(_current.ToInterestEvent()); } + /// + /// Build a sticky combat tip ensemble with the theater-lead side listed first so + /// Mass/Battle wording does not flip A↔B across maintain ticks. + /// + private static void BuildStickyCombatTipEnsemble( + InterestCandidate scene, + Actor focus, + Actor related, + out LiveEnsemble stickyEns) + { + stickyEns = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants)), + Focus = focus, + Related = related, + Frame = scene.CombatSideFrame, + ParticipantCount = scene.CombatSideACount + scene.CombatSideBCount + }; + + var sideA = new EnsembleSide + { + Key = scene.CombatSideAKey, + Display = scene.CombatSideADisplay, + KingdomDisplay = scene.CombatSideAKingdom, + Count = scene.CombatSideACount, + Best = focus + }; + var sideB = new EnsembleSide + { + Key = scene.CombatSideBKey, + Display = scene.CombatSideBDisplay, + KingdomDisplay = scene.CombatSideBKingdom, + Count = scene.CombatSideBCount, + Best = related + }; + + bool focusOnB = false; + if (focus != null && scene.Sticky != null) + { + long id = EventFeedUtil.SafeId(focus); + for (int i = 0; i < scene.Sticky.SideBIds.Count; i++) + { + if (scene.Sticky.SideBIds[i] == id) + { + focusOnB = true; + break; + } + } + } + + if (focusOnB) + { + stickyEns.SideA = sideB; + stickyEns.SideA.Best = focus; + stickyEns.SideB = sideA; + stickyEns.SideB.Best = related; + } + else + { + stickyEns.SideA = sideA; + stickyEns.SideB = sideB; + } + } + /// Harness: force one combat focus maintenance pass. public static void HarnessMaintainCombatFocus() { diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 771ca5b..1f48600 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.28.21", + "version": "0.28.24", "description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.", "GUID": "com.dazed.idlespectator" }