diff --git a/.tmp-logs/README.md b/.tmp-logs/README.md
new file mode 100644
index 0000000..8a81bff
--- /dev/null
+++ b/.tmp-logs/README.md
@@ -0,0 +1,10 @@
+# Saved Player.log archives
+
+## Player-pre-theater-lead-20260717-172418.log
+
+Captured before the Mass/Battle theater-lead focus hold fix (mod 0.28.17 soak).
+
+Use this to evaluate whether the director was showing interesting events overall,
+separate from the combat camera thrash that was fixed in 0.28.18.
+
+Key stretch: Evil Mage vs The Godo humans (mass fight + duel partner churn).
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index 8603373..57eb79b 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -1144,6 +1144,35 @@ 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"),
+
+ // Spectacle theater lead: evil mage vs human mob holds the mage, not a random human.
+ Step("cf48", "interest_end_session"),
+ Step("cf48b", "spawn", asset: "evil_mage", count: 1),
+ Step("cf48c", "spawn", asset: "human", count: 1),
+ Step("cf48d", "pick_unit", asset: "evil_mage"),
+ Step("cf48e", "interest_combat_session", asset: "evil_mage", value: "human", expect: "cf_mage"),
+ Step("cf48f", "combat_ensemble_escalate", value: "8"),
+ Step("cf48g", "combat_wire_attack_sides", asset: "evil_mage", value: "human"),
+ Step("cf48h", "wait", value: "0.35"),
+ Step("cf48i", "combat_maintain_focus"),
+ Step("cf48j", "assert", expect: "unit_asset", asset: "evil_mage"),
+ Step("cf48k", "remember_focus"),
+ Step("cf48l", "combat_maintain_focus"),
+ Step("cf48m", "wait", value: "0.35"),
+ Step("cf48n", "combat_maintain_focus"),
+ Step("cf48o", "wait", value: "0.35"),
+ Step("cf48p", "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 -"),
// Natural Duel → multi: seed packs without tip rewrite; maintain must escalate.
Step("cf50", "interest_end_session"),
@@ -1226,6 +1255,14 @@ internal static class HarnessScenarios
Step("csl45b", "assert", expect: "tip_not_contains", value: "melee"),
Step("csl46", "assert", expect: "unit_asset", value: "human|wolf"),
Step("csl47", "assert", expect: "dossier_matches_focus"),
+ // Theater lead hold across maintain ticks (no mob Follow thrash).
+ Step("csl47a", "remember_focus"),
+ Step("csl47b", "combat_maintain_focus"),
+ Step("csl47c", "wait", value: "0.35"),
+ Step("csl47d", "combat_maintain_focus"),
+ Step("csl47e", "wait", value: "0.35"),
+ Step("csl47f", "combat_maintain_focus"),
+ Step("csl47g", "assert", expect: "focus_same"),
// Chore bystander: park Follow on a sleeping rostered unit; maintain must retarget.
Step("csl48", "combat_park_bystander_follow", asset: "human", value: "wolf"),
diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs
index 1957c26..06d4245 100644
--- a/IdleSpectator/InterestCandidate.cs
+++ b/IdleSpectator/InterestCandidate.cs
@@ -70,6 +70,13 @@ public sealed class InterestCandidate
///
public long PairOwnerId;
public long PairPartnerId;
+ ///
+ /// Collective Mass/Battle camera owner: best fighter across both sticky sides.
+ /// Held across attack-target gaps so maintain ticks cannot thrash the follow.
+ ///
+ public long TheaterLeadId;
+ /// Unscaled time the theater lead was last a live combat participant.
+ public float TheaterLeadLastCombatAt = -999f;
public string Label = "";
public string AssetId = "";
public string SpeciesId = "";
@@ -163,7 +170,11 @@ public sealed class InterestCandidate
public bool HasStickyCombatSides => Sticky.HasOpposingSides;
- public void ClearCombatSticky() => Sticky.Clear();
+ public void ClearCombatSticky()
+ {
+ Sticky.Clear();
+ ClearTheaterLead();
+ }
/// Lock durable 1v1 owner/partner ids (no-op when owner missing).
public void StampCombatPair(Actor owner, Actor partner)
@@ -188,6 +199,23 @@ public sealed class InterestCandidate
public bool HasCombatPairLock => PairOwnerId != 0;
+ public void StampTheaterLead(Actor lead)
+ {
+ long id = EventFeedUtil.SafeId(lead);
+ if (id == 0)
+ {
+ return;
+ }
+
+ TheaterLeadId = id;
+ }
+
+ public void ClearTheaterLead()
+ {
+ TheaterLeadId = 0;
+ TheaterLeadLastCombatAt = -999f;
+ }
+
public InterestEvent ToInterestEvent()
{
return new InterestEvent
@@ -227,6 +255,8 @@ public sealed class InterestCandidate
RelatedId = RelatedId,
PairOwnerId = PairOwnerId,
PairPartnerId = PairPartnerId,
+ TheaterLeadId = TheaterLeadId,
+ TheaterLeadLastCombatAt = TheaterLeadLastCombatAt,
Label = Label,
AssetId = AssetId,
SpeciesId = SpeciesId,
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index dae3262..86d454f 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -53,6 +53,15 @@ public static class InterestDirector
private const int CombatFocusLargeParticipantThreshold = 16;
/// Require this much extra focus weight before stealing the camera mid-fight.
private const float CombatFocusSwitchMargin = 12f;
+ ///
+ /// Collective Mass/Battle: hold theater lead through brief attack_target gaps
+ /// so maintain ticks cannot hop across equally-scored mob fighters.
+ ///
+ private const float CombatTheaterLeadGraceSeconds = 4f;
+ /// Only a clearly hotter fighter may steal theater lead mid-hold.
+ private const float CombatTheaterLeadSwitchMargin = 40f;
+ /// Boost the thinner sticky side's best (1-vs-mob spectacles).
+ private const float CombatTheaterLeadOutnumberedBonus = 28f;
private static readonly List PendingScratch = new List(96);
public static string CurrentTierName => CurrentScoreLabel;
@@ -1021,70 +1030,79 @@ public static class InterestDirector
_current.PairPartnerId = 0;
}
- // Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores).
- bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
- bool bestFighting = LiveEnsemble.IsCombatParticipant(best);
- if (!curFighting)
+ // Collective Mass/Battle: hold the theater lead (best across both sides).
+ // Attack-target gaps used to hop the camera across the mob every maintain tick.
+ if (TryApplyTheaterLeadHold(_current, ref best, ref foe, fighters))
{
- if (bestFighting)
- {
- foe = ResolveAttackFoe(best) ?? foe;
- }
- else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe))
- {
- best = fighter;
- foe = fighterFoe ?? foe;
- if (ensemble != null)
- {
- label = EventReason.Combat(ensemble);
- }
- }
- else if (curFollow != null && curFollow.isAlive())
- {
- // Nobody fighting - keep ownership rather than jump to a random sleeper.
- best = curFollow;
- foe = ResolveAttackFoe(best) ?? curRelated ?? foe;
- }
+ // Theater lead owns Follow/Related for this maintain.
}
- else if (!bestFighting)
+ else
{
- best = curFollow;
- foe = ResolveAttackFoe(best) ?? foe;
- }
- else if (best != curFollow
- && curFollow != null
- && curFollow.isAlive()
- && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
- {
- float curW = LiveEnsemble.FocusWeight(
- curFollow, _current.ParticipantIds, newcomerBonus: 0f);
- float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
- if (newW < curW + CombatFocusSwitchMargin)
+ // Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores).
+ bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
+ bool bestFighting = LiveEnsemble.IsCombatParticipant(best);
+ if (!curFighting)
{
- best = curFollow;
- if (foe == null || foe == best)
+ if (bestFighting)
{
foe = ResolveAttackFoe(best) ?? foe;
}
+ else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe))
+ {
+ best = fighter;
+ foe = fighterFoe ?? foe;
+ if (ensemble != null)
+ {
+ label = EventReason.Combat(ensemble);
+ }
+ }
+ else if (curFollow != null && curFollow.isAlive())
+ {
+ // Nobody fighting - keep ownership rather than jump to a random sleeper.
+ best = curFollow;
+ foe = ResolveAttackFoe(best) ?? curRelated ?? foe;
+ }
+ }
+ else if (!bestFighting)
+ {
+ best = curFollow;
+ foe = ResolveAttackFoe(best) ?? foe;
+ }
+ else if (best != curFollow
+ && curFollow != null
+ && curFollow.isAlive()
+ && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
+ {
+ float curW = LiveEnsemble.FocusWeight(
+ curFollow, _current.ParticipantIds, newcomerBonus: 0f);
+ float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
+ if (newW < curW + CombatFocusSwitchMargin)
+ {
+ best = curFollow;
+ if (foe == null || foe == best)
+ {
+ foe = ResolveAttackFoe(best) ?? foe;
+ }
+ }
}
- }
- // Preserve duel partner when rebuild briefly loses Related (attack_target gaps).
- bool duelLabeled = !string.IsNullOrEmpty(_current.Label)
- && _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
- if (foe == null
- && curRelated != null
- && curRelated.isAlive()
- && curRelated != best
- && (duelLabeled || _current.ForceActive || fighters <= 2))
- {
- foe = curRelated;
- }
+ // Preserve duel partner when rebuild briefly loses Related (attack_target gaps).
+ bool duelLabeled = !string.IsNullOrEmpty(_current.Label)
+ && _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
+ if (foe == null
+ && curRelated != null
+ && curRelated.isAlive()
+ && curRelated != best
+ && (duelLabeled || _current.ForceActive || fighters <= 2))
+ {
+ foe = curRelated;
+ }
- // Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks.
- if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
- {
- ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
+ // Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks.
+ if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
+ {
+ ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
+ }
}
}
@@ -1789,6 +1807,272 @@ public static class InterestDirector
return false;
}
+ ///
+ /// Collective sticky Mass/Battle/Skirmish (not NamedPair duel ownership).
+ ///
+ private static bool IsCollectiveCombatTheater(InterestCandidate scene)
+ {
+ if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
+ {
+ return false;
+ }
+
+ if (IsNamedPairCombatOwnership(scene))
+ {
+ return false;
+ }
+
+ if (!HasStickyCombatSides(scene))
+ {
+ return false;
+ }
+
+ return HasStickyCollectiveMulti(scene)
+ || scene.CombatPeakParticipants >= 3
+ || scene.ParticipantCount >= 3;
+ }
+
+ ///
+ /// Score a sticky-side champion for theater-lead selection (stable, no newcomer jitter).
+ ///
+ private static float TheaterLeadScore(InterestCandidate scene, Actor actor, int ownSideCount, int foeSideCount)
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ return -1f;
+ }
+
+ float weight = LiveEnsemble.FocusWeight(actor, scene?.ParticipantIds, newcomerBonus: 0f);
+ if (weight < 0f)
+ {
+ return weight;
+ }
+
+ // 1-vs-mob: prefer the thinner side's champion (evil mage vs humans).
+ if (ownSideCount > 0
+ && foeSideCount > 0
+ && ownSideCount < foeSideCount
+ && foeSideCount >= 3)
+ {
+ weight += CombatTheaterLeadOutnumberedBonus;
+ }
+
+ return weight;
+ }
+
+ ///
+ /// Pick the highest-scoring living fighter across both sticky sides.
+ ///
+ private static bool TryPickTheaterLead(
+ InterestCandidate scene,
+ out Actor lead,
+ out Actor foe)
+ {
+ lead = null;
+ foe = null;
+ if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
+ {
+ return false;
+ }
+
+ LiveSceneStickyState sticky = scene.Sticky;
+ Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true);
+ Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true);
+ if ((pickA == null || !pickA.isAlive()) && (pickB == null || !pickB.isAlive()))
+ {
+ return false;
+ }
+
+ int countA = Mathf.Max(1, sticky.SideACount);
+ int countB = Mathf.Max(1, sticky.SideBCount);
+ float wA = TheaterLeadScore(scene, pickA, countA, countB);
+ float wB = TheaterLeadScore(scene, pickB, countB, countA);
+ if (wB > wA)
+ {
+ lead = pickB;
+ foe = pickA;
+ }
+ else
+ {
+ lead = pickA;
+ foe = pickB;
+ }
+
+ return lead != null && lead.isAlive();
+ }
+
+ ///
+ /// Hold the collective theater lead through attack gaps; retarget only on death,
+ /// leaving the scrap past grace, or a clearly hotter fighter.
+ ///
+ private static bool TryApplyTheaterLeadHold(
+ InterestCandidate scene,
+ ref Actor best,
+ ref Actor foe,
+ int fighters)
+ {
+ if (scene == null || !IsCollectiveCombatTheater(scene))
+ {
+ if (fighters < 3 || scene == null || !HasStickyCombatSides(scene))
+ {
+ return false;
+ }
+
+ if (IsNamedPairCombatOwnership(scene))
+ {
+ return false;
+ }
+ }
+
+ float now = Time.unscaledTime;
+ Actor held = null;
+ if (scene.TheaterLeadId != 0)
+ {
+ held = LiveEnsemble.FindTrackedActor(scene.TheaterLeadId);
+ }
+
+ if (held == null || !held.isAlive())
+ {
+ held = scene.FollowUnit != null && scene.FollowUnit.isAlive()
+ ? scene.FollowUnit
+ : null;
+ }
+
+ if (held != null && LiveEnsemble.IsCombatParticipant(held))
+ {
+ scene.TheaterLeadLastCombatAt = now;
+ }
+
+ bool onRoster = held != null && StickyScoreboard.IsOnRoster(scene, held);
+ bool inGrace = held != null
+ && held.isAlive()
+ && (LiveEnsemble.IsCombatParticipant(held)
+ || now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds);
+ bool holdOk = held != null && held.isAlive() && onRoster && inGrace;
+
+ if (!TryPickTheaterLead(scene, out Actor pick, out Actor pickFoe))
+ {
+ if (!holdOk)
+ {
+ return false;
+ }
+
+ best = held;
+ foe = ResolveAttackFoe(held) ?? scene.RelatedUnit ?? foe;
+ scene.StampTheaterLead(held);
+ return true;
+ }
+
+ if (holdOk)
+ {
+ float heldW = TheaterLeadScore(
+ scene,
+ held,
+ SideCountForActor(scene, held),
+ SideCountForOpponent(scene, held));
+ float pickW = TheaterLeadScore(
+ scene,
+ pick,
+ SideCountForActor(scene, pick),
+ SideCountForOpponent(scene, pick));
+ bool steal = pick != held
+ && LiveEnsemble.IsCombatParticipant(pick)
+ && pickW >= heldW + CombatTheaterLeadSwitchMargin;
+ if (!steal)
+ {
+ best = held;
+ foe = ResolveAttackFoe(held)
+ ?? OppositeSideBest(scene, held)
+ ?? pickFoe
+ ?? foe;
+ scene.StampTheaterLead(held);
+ return true;
+ }
+ }
+
+ best = pick;
+ foe = ResolveAttackFoe(pick) ?? pickFoe ?? foe;
+ scene.StampTheaterLead(pick);
+ if (LiveEnsemble.IsCombatParticipant(pick))
+ {
+ scene.TheaterLeadLastCombatAt = now;
+ }
+
+ return true;
+ }
+
+ private static int SideCountForActor(InterestCandidate scene, Actor actor)
+ {
+ if (scene?.Sticky == null || actor == null)
+ {
+ return 1;
+ }
+
+ long id = EventFeedUtil.SafeId(actor);
+ LiveSceneStickyState sticky = scene.Sticky;
+ for (int i = 0; i < sticky.SideAIds.Count; i++)
+ {
+ if (sticky.SideAIds[i] == id)
+ {
+ return Mathf.Max(1, sticky.SideACount);
+ }
+ }
+
+ for (int i = 0; i < sticky.SideBIds.Count; i++)
+ {
+ if (sticky.SideBIds[i] == id)
+ {
+ return Mathf.Max(1, sticky.SideBCount);
+ }
+ }
+
+ return Mathf.Max(1, sticky.SideACount);
+ }
+
+ private static int SideCountForOpponent(InterestCandidate scene, Actor actor)
+ {
+ if (scene?.Sticky == null || actor == null)
+ {
+ return 1;
+ }
+
+ long id = EventFeedUtil.SafeId(actor);
+ LiveSceneStickyState sticky = scene.Sticky;
+ for (int i = 0; i < sticky.SideAIds.Count; i++)
+ {
+ if (sticky.SideAIds[i] == id)
+ {
+ return Mathf.Max(1, sticky.SideBCount);
+ }
+ }
+
+ return Mathf.Max(1, sticky.SideACount);
+ }
+
+ private static Actor OppositeSideBest(InterestCandidate scene, Actor actor)
+ {
+ if (scene?.Sticky == null || actor == null)
+ {
+ return null;
+ }
+
+ long id = EventFeedUtil.SafeId(actor);
+ LiveSceneStickyState sticky = scene.Sticky;
+ bool onA = false;
+ for (int i = 0; i < sticky.SideAIds.Count; i++)
+ {
+ if (sticky.SideAIds[i] == id)
+ {
+ onA = true;
+ break;
+ }
+ }
+
+ return LiveEnsemble.FindBestAliveTracked(
+ onA ? sticky.SideBIds : sticky.SideAIds,
+ preferCombat: true);
+ }
+
///
/// Pick a living sticky-roster fighter when the current follow left the scrap.
///
@@ -2253,16 +2537,37 @@ public static class InterestDirector
{
_current.PairOwnerId = 0;
_current.PairPartnerId = 0;
+ _current.ClearTheaterLead();
}
- _current.FollowUnit = ensemble.Focus;
- _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
- _current.RelatedUnit = ensemble.Related;
- _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
+ Actor focus = ensemble.Focus;
+ Actor related = ensemble.Related;
+ if (IsCollectiveCombatTheater(_current)
+ || (authoredN >= 3 && HasStickyCombatSides(_current)))
+ {
+ if (TryPickTheaterLead(_current, out Actor lead, out Actor leadFoe))
+ {
+ focus = lead;
+ related = ResolveAttackFoe(lead) ?? leadFoe ?? related;
+ _current.StampTheaterLead(lead);
+ if (LiveEnsemble.IsCombatParticipant(lead))
+ {
+ _current.TheaterLeadLastCombatAt = Time.unscaledTime;
+ }
+
+ ensemble.Focus = focus;
+ ensemble.Related = related;
+ }
+ }
+
+ _current.FollowUnit = focus;
+ _current.SubjectId = EventFeedUtil.SafeId(focus);
+ _current.RelatedUnit = related;
+ _current.RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0;
_current.Label = EventReason.Combat(ensemble);
try
{
- _current.Position = ensemble.Focus.current_position;
+ _current.Position = focus.current_position;
}
catch
{
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index f575cbc..8874d41 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -597,6 +597,15 @@ public static class WorldActivityScanner
weight += 30f;
}
+ // Spectacle creatures (evil mage, dragon, …) win theater-lead picks in mob fights.
+ // Use a dedicated floor so char-only weights among civs cannot outrank them.
+ if (IsSpectacle(actor))
+ {
+ ScoringWeights w = InterestScoringConfig.W;
+ float spectacle = w.scannerSpectacleBonus > 0f ? w.scannerSpectacleBonus : 12f;
+ weight += Mathf.Max(35f, spectacle);
+ }
+
return weight;
}
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index d31f687..745e2df 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.28.17",
+ "version": "0.28.18",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}