Refactor combat handling and enhance event reasoning for framing changes.

- Update CameraDirector to log only significant tip changes
- Introduce new combat framing change detection in EventReason
- Enhance InterestDirector to manage theater lead and combat focus
- Add new steps in HarnessScenarios for combat wire attack scenarios
- Increment version to 0.28.24 in mod.json
This commit is contained in:
DazedAnon 2026-07-17 17:57:08 -05:00
parent 45a37d7471
commit 1ab58e9d9e
6 changed files with 302 additions and 53 deletions

View file

@ -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}");
}

View file

@ -30,6 +30,83 @@ public static class EventReason
return string.Equals(StripParenCounts(previous), StripParenCounts(next), StringComparison.Ordinal);
}
/// <summary>
/// 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.
/// </summary>
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);

View file

@ -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 -"),

View file

@ -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();
}
/// <summary>
@ -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;
}

View file

@ -60,8 +60,11 @@ public static class InterestDirector
private const float CombatTheaterLeadGraceSeconds = 4f;
/// <summary>Only a clearly hotter fighter may steal theater lead mid-hold.</summary>
private const float CombatTheaterLeadSwitchMargin = 40f;
/// <summary>Same sticky side: never hop across a mob for a near-tie.</summary>
private const float CombatTheaterLeadSameSideSwitchMargin = 90f;
/// <summary>Boost the thinner sticky side's best (1-vs-mob spectacles).</summary>
private const float CombatTheaterLeadOutnumberedBonus = 28f;
private const float CombatTheaterLeadNearRadius = 20f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(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
}
/// <summary>
/// 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.
/// </summary>
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());
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>Harness: force one combat focus maintenance pass.</summary>
public static void HarnessMaintainCombatFocus()
{

View file

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