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"
}