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:
parent
45a37d7471
commit
1ab58e9d9e
6 changed files with 302 additions and 53 deletions
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 -"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue