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