Enhance theater lead handling and update combat scenarios.

- Introduce theater lead management to maintain focus during combat
- Add new steps for combat scenarios involving spectacle creatures
- Update InterestCandidate to track theater lead and combat participation
- Refactor InterestDirector to improve focus handling across maintain ticks
- Increment version to 0.28.18 in mod.json
This commit is contained in:
DazedAnon 2026-07-17 17:29:41 -05:00
parent 03729664be
commit 04d5bcb381
6 changed files with 452 additions and 61 deletions

10
.tmp-logs/README.md Normal file
View file

@ -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).

View file

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

View file

@ -70,6 +70,13 @@ public sealed class InterestCandidate
/// </summary>
public long PairOwnerId;
public long PairPartnerId;
/// <summary>
/// Collective Mass/Battle camera owner: best fighter across both sticky sides.
/// Held across attack-target gaps so maintain ticks cannot thrash the follow.
/// </summary>
public long TheaterLeadId;
/// <summary>Unscaled time the theater lead was last a live combat participant.</summary>
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();
}
/// <summary>Lock durable 1v1 owner/partner ids (no-op when owner missing).</summary>
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,

View file

@ -53,6 +53,15 @@ public static class InterestDirector
private const int CombatFocusLargeParticipantThreshold = 16;
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
private const float CombatFocusSwitchMargin = 12f;
/// <summary>
/// Collective Mass/Battle: hold theater lead through brief attack_target gaps
/// so maintain ticks cannot hop across equally-scored mob fighters.
/// </summary>
private const float CombatTheaterLeadGraceSeconds = 4f;
/// <summary>Only a clearly hotter fighter may steal theater lead mid-hold.</summary>
private const float CombatTheaterLeadSwitchMargin = 40f;
/// <summary>Boost the thinner sticky side's best (1-vs-mob spectacles).</summary>
private const float CombatTheaterLeadOutnumberedBonus = 28f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(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;
}
/// <summary>
/// Collective sticky Mass/Battle/Skirmish (not NamedPair duel ownership).
/// </summary>
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;
}
/// <summary>
/// Score a sticky-side champion for theater-lead selection (stable, no newcomer jitter).
/// </summary>
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;
}
/// <summary>
/// Pick the highest-scoring living fighter across both sticky sides.
/// </summary>
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();
}
/// <summary>
/// Hold the collective theater lead through attack gaps; retarget only on death,
/// leaving the scrap past grace, or a clearly hotter fighter.
/// </summary>
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);
}
/// <summary>
/// Pick a living sticky-roster fighter when the current follow left the scrap.
/// </summary>
@ -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
{

View file

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

View file

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