diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 6f48447..b4d1c60 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2017,6 +2017,10 @@ public static class AgentHarness DoCombatEnsembleEscalate(cmd); break; + case "combat_wire_attack_sides": + DoCombatWireAttackSides(cmd); + break; + case "interest_variety_clear": InterestVariety.Clear(); _cmdOk++; @@ -3578,6 +3582,7 @@ public static class AgentHarness { c.Category = "Combat"; c.Verb = "fighting"; + c.ClearCombatSticky(); if (related != null) { c.ParticipantCount = 2; @@ -3623,6 +3628,18 @@ public static class AgentHarness } Actor related = scene.RelatedUnit; + if ((focus == null || !focus.isAlive()) && related != null && related.isAlive()) + { + focus = related; + related = null; + } + + if (focus == null || !focus.isAlive()) + { + // Last resort: nearest living unit so escalate can still seed a pack. + focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + if (focus == null || !focus.isAlive()) { _cmdFail++; @@ -3630,6 +3647,7 @@ public static class AgentHarness return; } + scene.ClearCombatSticky(); int want = 6; if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want)) { @@ -3709,6 +3727,189 @@ public static class AgentHarness detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}"); } + /// + /// Best-effort: set attack_target across two species camps near the combat focus + /// so sees a real multi-fighter cluster. + /// asset = side A species (default human), value = side B species (default wolf). + /// + private static void DoCombatWireAttackSides(HarnessCommand cmd) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + Actor anchor = scene?.FollowUnit; + if (anchor == null || !anchor.isAlive()) + { + anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + if (anchor == null || !anchor.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no_anchor"); + return; + } + + string sideA = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim(); + string sideB = string.IsNullOrEmpty(cmd.value) ? "wolf" : cmd.value.Trim(); + bool pairOnly = (cmd.expect ?? "").IndexOf("pair", StringComparison.OrdinalIgnoreCase) >= 0 + || string.Equals(cmd.label, "pair", StringComparison.OrdinalIgnoreCase); + + if (pairOnly) + { + Actor a = scene?.FollowUnit ?? anchor; + Actor b = scene?.RelatedUnit; + if (b == null || !b.isAlive()) + { + // Nearest opposite-species unit. + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || actor == a || !actor.isAlive() || actor.asset == null) + { + continue; + } + + if (actor.asset.id.Equals(sideB, StringComparison.OrdinalIgnoreCase)) + { + b = actor; + break; + } + } + } + + int wiredPair = 0; + if (TrySetAttackTarget(a, b)) + { + wiredPair++; + } + + if (TrySetAttackTarget(b, a)) + { + wiredPair++; + } + + InterestDirector.HarnessMaintainCombatFocus(); + bool okPair = wiredPair > 0; + if (okPair) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + okPair, + detail: $"pair wired={wiredPair} a={SafeName(a)} b={SafeName(b)} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}"); + return; + } + + var campA = new List(8); + var campB = new List(8); + Vector2 pos = anchor.current_position; + const float r2 = 16f * 16f; + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive() || actor.asset == null) + { + continue; + } + + float dx = actor.current_position.x - pos.x; + float dy = actor.current_position.y - pos.y; + if (dx * dx + dy * dy > r2) + { + continue; + } + + string id = actor.asset.id ?? ""; + if (id.Equals(sideA, StringComparison.OrdinalIgnoreCase)) + { + campA.Add(actor); + } + else if (id.Equals(sideB, StringComparison.OrdinalIgnoreCase)) + { + campB.Add(actor); + } + } + + if (campA.Count == 0 || campB.Count == 0) + { + _cmdFail++; + Emit(cmd, ok: false, detail: $"empty camps a={campA.Count} b={campB.Count} near={SafeName(anchor)}"); + return; + } + + int wired = 0; + for (int i = 0; i < campA.Count; i++) + { + if (TrySetAttackTarget(campA[i], campB[i % campB.Count])) + { + wired++; + } + } + + for (int i = 0; i < campB.Count; i++) + { + if (TrySetAttackTarget(campB[i], campA[i % campA.Count])) + { + wired++; + } + } + + InterestDirector.HarnessMaintainCombatFocus(); + bool ok = wired > 0; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}"); + } + + private static bool TrySetAttackTarget(Actor unit, Actor foe) + { + if (unit == null || foe == null || !unit.isAlive() || !foe.isAlive() || unit == foe) + { + return false; + } + + try + { + var mi = typeof(Actor).GetMethod( + "setAttackTarget", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic); + if (mi != null) + { + mi.Invoke(unit, new object[] { foe }); + } + else + { + var prop = typeof(Actor).GetProperty( + "attack_target", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic); + prop?.SetValue(unit, foe, null); + } + + return unit.has_attack_target; + } + catch + { + return false; + } + } + private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter) { try @@ -3938,7 +4139,24 @@ public static class AgentHarness Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; string unitAsset = unit?.asset != null ? unit.asset.id : ""; - pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset; + if (!string.IsNullOrEmpty(wantAsset) && wantAsset.IndexOf('|') >= 0) + { + string[] opts = wantAsset.Split('|'); + pass = false; + for (int i = 0; i < opts.Length; i++) + { + if (opts[i].Trim().Equals(unitAsset, StringComparison.OrdinalIgnoreCase)) + { + pass = true; + break; + } + } + } + else + { + pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset; + } + detail = $"unitAsset={unitAsset} want={wantAsset}"; break; } diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index bbc2344..b58620f 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -44,7 +44,8 @@ public static class EventReason /// /// Context-aware combat orange reason from a snapshot. - /// Multi-fighter form: Battle - [Essiona] humans (10) vs wolves (3). + /// Multi form: Battle - Humans (4) vs Wolves (0) - counts may be 0 while sides stay sticky. + /// Never uses raw unit names as mass-side labels. Never prints thin melee fallbacks. /// public static string Combat(LiveEnsemble ensemble) { @@ -53,8 +54,8 @@ public static class EventReason return ""; } - if (ensemble.Frame == EnsembleFrame.NamedPair - || ensemble.Scale == EnsembleScale.Pair) + // True 1v1 only when we do not already have sticky opposing camps. + if (ensemble.Scale == EnsembleScale.Pair && !LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { string a = Name(ensemble.Focus); string b = Name(ensemble.Related); @@ -65,31 +66,67 @@ public static class EventReason if (string.IsNullOrEmpty(b)) { - return "Duel - " + a; + // Orphan duel with no foe - blank so director can keep sticky / clear. + return ""; } return "Duel - " + a + " vs " + b; } - string tier = ScaleTierLabel(ensemble.Scale); - string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame); - string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame); - bool sided = !string.IsNullOrEmpty(sideA) - && !string.IsNullOrEmpty(sideB) - && !sideA.Equals(sideB, StringComparison.OrdinalIgnoreCase); + string tier = ScaleTierLabel( + ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); - if (sided) + if (LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { - return tier + " - " + sideA + " vs " + sideB; + string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame); + string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame); + if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB)) + { + return tier + " - " + sideA + " vs " + sideB; + } } - int n = Math.Max(1, ensemble.ParticipantCount); - if (!string.IsNullOrEmpty(sideA)) + // Same-species pack with no opposing sticky camps. + if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)) { - return tier + " - " + sideA + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; + string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies); + if (!string.IsNullOrEmpty(pack)) + { + return tier + " - " + pack; + } } - return tier + " - fight of " + n.ToString(CultureInfo.InvariantCulture); + // Never emit thin "X is fighting" from the multi path - leave blank for director reuse. + if (ensemble.Scale > EnsembleScale.Pair || LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + return ""; + } + + return Fight(ensemble.Focus, ensemble.Related); + } + + private static bool SidesAreSameCollective(string sideA, string sideB) + { + if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB)) + { + return false; + } + + // Strip counts: "Wolves (6)" → "Wolves" + string a = StripSideCount(sideA); + string b = StripSideCount(sideB); + return a.Equals(b, StringComparison.OrdinalIgnoreCase); + } + + private static string StripSideCount(string side) + { + if (string.IsNullOrEmpty(side)) + { + return ""; + } + + int paren = side.LastIndexOf(" (", StringComparison.Ordinal); + return paren > 0 ? side.Substring(0, paren).Trim() : side.Trim(); } private static string ScaleTierLabel(EnsembleScale scale) @@ -114,6 +151,12 @@ public static class EventReason return ""; } + // Mass/skirmish never print unit proper names as a side. + if (frame == EnsembleFrame.NamedLeaders || frame == EnsembleFrame.NamedPair) + { + return ""; + } + string label = side.Display ?? ""; if (string.IsNullOrEmpty(label)) { @@ -122,7 +165,7 @@ public static class EventReason if (frame == EnsembleFrame.KingdomVsKingdom) { - label = LiveEnsemble.KingdomDisplay(label); + label = LiveEnsemble.KingdomDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key); } else if (frame == EnsembleFrame.SpeciesVsSpecies) { @@ -130,7 +173,6 @@ public static class EventReason if (!string.IsNullOrEmpty(side.KingdomDisplay)) { string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay); - // Avoid "[Humans] humans" when kingdom label is just the species. if (!string.IsNullOrEmpty(kingdom) && !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase) && kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0) @@ -139,12 +181,10 @@ public static class EventReason } } } - else if (frame == EnsembleFrame.NamedLeaders) + else { - if (side.Best != null) - { - label = Name(side.Best); - } + // CountOnly / unknown - refuse unit-looking labels. + return ""; } if (string.IsNullOrEmpty(label)) @@ -152,14 +192,9 @@ public static class EventReason return ""; } - // Consistent side casing for collective labels. - if (frame == EnsembleFrame.SpeciesVsSpecies - || frame == EnsembleFrame.KingdomVsKingdom) - { - label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); - } - - int n = Math.Max(1, side.Count); + label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); + // Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin. + int n = Math.Max(0, side.Count); return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; } diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs index f9d4ec7..7ccd4e9 100644 --- a/IdleSpectator/Events/LiveEnsemble.cs +++ b/IdleSpectator/Events/LiveEnsemble.cs @@ -437,6 +437,183 @@ public sealed class LiveEnsemble } } + /// + /// Sticky scoreboard roster: add units currently fighting for each camp, then count every + /// tracked member that is still alive (combat or not) until the combat scene grace ends. + /// Dead ids are pruned. New combatants matching a sticky key are enrolled. + /// + public static void RefreshStickyMemberRoster( + Vector2 pos, + float radius, + EnsembleFrame frame, + string keyA, + string keyB, + List sideAIds, + List sideBIds, + out int countA, + out int countB, + out Actor bestA, + out Actor bestB) + { + countA = 0; + countB = 0; + bestA = null; + bestB = null; + if (sideAIds == null || sideBIds == null + || string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB)) + { + return; + } + + float r2 = radius * radius; + // Enroll anyone currently fighting for a sticky camp. + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive() || !IsCombatParticipant(actor)) + { + continue; + } + + float dx = actor.current_position.x - pos.x; + float dy = actor.current_position.y - pos.y; + if (dx * dx + dy * dy > r2) + { + continue; + } + + string key = frame == EnsembleFrame.KingdomVsKingdom + ? KingdomKeyOf(actor) + : SpeciesKeyOf(actor); + if (string.IsNullOrEmpty(key)) + { + continue; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) + { + continue; + } + + if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase)) + { + AddUniqueId(sideAIds, id); + } + else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase)) + { + AddUniqueId(sideBIds, id); + } + } + + countA = CountAliveTracked(sideAIds, out bestA, preferCombat: true); + countB = CountAliveTracked(sideBIds, out bestB, preferCombat: true); + } + + public static Actor FindBestAliveTracked(List ids, bool preferCombat = true) + { + CountAliveTracked(ids, out Actor best, preferCombat); + return best; + } + + private static void AddUniqueId(List ids, long id) + { + if (id == 0 || ids == null) + { + return; + } + + for (int i = 0; i < ids.Count; i++) + { + if (ids[i] == id) + { + return; + } + } + + ids.Add(id); + } + + private static int CountAliveTracked(List ids, out Actor best, bool preferCombat) + { + best = null; + if (ids == null || ids.Count == 0) + { + return 0; + } + + float bestW = -1f; + int alive = 0; + for (int i = ids.Count - 1; i >= 0; i--) + { + long id = ids[i]; + Actor actor = FindAliveById(id); + if (actor == null) + { + ids.RemoveAt(i); + continue; + } + + alive++; + float w = FocusWeight(actor, priorParticipantIds: null, newcomerBonus: 0f); + if (preferCombat && IsCombatParticipant(actor)) + { + w += 40f; + } + + if (w > bestW) + { + bestW = w; + best = actor; + } + } + + return alive; + } + + private static Actor FindAliveById(long id) + { + if (id == 0) + { + return null; + } + + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor == null || !actor.isAlive()) + { + continue; + } + + if (EventFeedUtil.SafeId(actor) == id) + { + return actor; + } + } + + return null; + } + + public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble) + { + if (ensemble?.SideA == null || ensemble.SideB == null) + { + return false; + } + + if (ensemble.Frame != EnsembleFrame.SpeciesVsSpecies + && ensemble.Frame != EnsembleFrame.KingdomVsKingdom) + { + return false; + } + + if (string.IsNullOrEmpty(ensemble.SideA.Key) || string.IsNullOrEmpty(ensemble.SideB.Key)) + { + return false; + } + + return !ensemble.SideA.Key.Equals(ensemble.SideB.Key, StringComparison.OrdinalIgnoreCase); + } + private static void ApplyNamedPair(LiveEnsemble ensemble, List fighters) { ensemble.Frame = EnsembleFrame.NamedPair; @@ -500,12 +677,7 @@ public sealed class LiveEnsemble if (foe != null) { ensemble.SideB = SideFromActor(foe, "foe"); - if (ensemble.NotableParticipantCount >= 2 - && InterestScoring.IsNotable(ensemble.Focus) - && InterestScoring.IsNotable(foe)) - { - ensemble.Frame = EnsembleFrame.NamedLeaders; - } + // Multi-fighter: never NamedLeaders (unit-name sides). Species promotion happens in EventReason. } } @@ -648,9 +820,28 @@ public sealed class LiveEnsemble return false; } - ensemble.Frame = EnsembleFrame.NamedLeaders; - ensemble.SideA = BuildSide("camp:a", EventFeedUtil.SafeName(focus), sideA, prior, newcomerBonus, EnsembleFrame.NamedLeaders); - ensemble.SideB = BuildSide("camp:b", EventFeedUtil.SafeName(foe), sideB, prior, newcomerBonus, EnsembleFrame.NamedLeaders); + // Prefer species framing over unit-named camps whenever species differ. + string spA = SpeciesKeyOf(focus); + string spB = SpeciesKeyOf(foe); + if (!string.IsNullOrEmpty(spA) + && !string.IsNullOrEmpty(spB) + && !spA.Equals(spB, StringComparison.OrdinalIgnoreCase)) + { + ensemble.Frame = EnsembleFrame.SpeciesVsSpecies; + ensemble.SideA = BuildSide(spA, SpeciesDisplay(spA), sideA, prior, newcomerBonus, EnsembleFrame.SpeciesVsSpecies); + ensemble.SideB = BuildSide(spB, SpeciesDisplay(spB), sideB, prior, newcomerBonus, EnsembleFrame.SpeciesVsSpecies); + } + else + { + // Same species camps → count-only melee (EventReason prints "melee (n)"). + ensemble.Frame = EnsembleFrame.CountOnly; + ensemble.SideA = null; + ensemble.SideB = null; + PreferFocusSide(ensemble); + ensemble.Related = foe; + return true; + } + PreferFocusSide(ensemble); ensemble.Related = ensemble.SideB?.Best ?? foe; return true; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index fa940bc..e8fdc31 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -53,6 +53,9 @@ internal static class HarnessScenarios case "combat_focus": case "combat_tip_align": return CombatFocusAlign(); + case "combat_stability_live": + case "combat_live_proof": + return CombatStabilityLive(); case "director_action_priority": case "action_priority": return DirectorActionPriority(); @@ -1065,10 +1068,11 @@ internal static class HarnessScenarios Step("cf25b", "assert", expect: "dossier_matches_focus"), Step("cf26", "assert", expect: "unit_asset", asset: "wolf"), - // 1v1 → multi: sided species framing + participant write-back. + // 1v1 → multi: sided species framing + live sticky counts. Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"), Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), Step("cf42", "combat_ensemble_escalate", value: "6"), + Step("cf42b", "combat_wire_attack_sides", asset: "human", value: "wolf"), Step("cf43", "assert", expect: "participant_count_min", value: "6"), Step("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "), Step("cf45", "assert", expect: "tip_matches_focus"), @@ -1090,6 +1094,75 @@ internal static class HarnessScenarios }; } + /// + /// Live pack-fight proof: scale hold vs Duel flap, collective species tip, sleeper cannot steal focus. + /// + private static List CombatStabilityLive() + { + return new List + { + Step("csl0", "dismiss_windows"), + Step("csl1", "wait_world"), + Step("csl2", "set_setting", expect: "enabled", value: "true"), + Step("csl3", "fast_timing", value: "true"), + Step("csl4", "spawn", asset: "human", count: 1), + Step("csl5", "spawn", asset: "wolf", count: 1), + Step("csl6", "spectator", value: "off"), + Step("csl7", "spectator", value: "on"), + Step("csl8", "pick_unit", asset: "human"), + Step("csl9", "focus", asset: "human"), + Step("csl10", "interest_end_session"), + + // Peak at 8 (Mass band). Sticky sides lock now; live counts start at 0 until wired. + Step("csl20", "interest_combat_session", asset: "human", value: "wolf", expect: "csl_pack"), + Step("csl21", "combat_ensemble_escalate", value: "8"), + Step("csl22", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("csl22b", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("), + Step("csl24", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"), + + // Live rebuild: sticky Humans vs Wolves, live counts (may be 1/1 after pair wire). + Step("csl30", "combat_maintain_focus"), + Step("csl31", "wait", value: "0.4"), + Step("csl32", "combat_maintain_focus"), + Step("csl33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("csl34", "assert", expect: "tip_not_contains", value: "Duel"), + Step("csl35", "assert", expect: "tip_not_contains", value: "melee"), + Step("csl36", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("), + Step("csl37", "assert", expect: "tip_matches_any", value: " vs "), + + // Wire both camps → counts climb back up on the same sticky frame. + Step("csl40", "combat_wire_attack_sides", asset: "human", value: "wolf"), + Step("csl41", "wait", value: "0.5"), + Step("csl42", "combat_maintain_focus"), + Step("csl42b", "assert", expect: "participant_count_min", value: "6"), + Step("csl43", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("), + Step("csl44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"), + Step("csl45", "assert", expect: "tip_not_contains", value: "Duel"), + 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"), + + // Sleeper bystander must not stick the camera. + Step("csl50", "spawn", asset: "bear", count: 1), + Step("csl51", "focus", asset: "bear"), + Step("csl52", "status_apply", value: "sleeping", label: "8"), + Step("csl53", "wait", value: "0.35"), + Step("csl54", "combat_maintain_focus"), + Step("csl55", "wait", value: "0.35"), + Step("csl56", "combat_maintain_focus"), + Step("csl57", "assert", expect: "unit_asset", value: "human|wolf"), + Step("csl58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -|fighting| vs "), + Step("csl59", "assert", expect: "tip_not_contains", value: "Duel -"), + Step("csl59b", "assert", expect: "tip_not_contains", value: "melee"), + Step("csl60", "assert", expect: "dossier_matches_focus"), + Step("csl61", "assert", expect: "has_focus", value: "true"), + + Step("csl90", "fast_timing", value: "false"), + Step("csl98", "screenshot", value: "hud-combat-stability-live.png"), + Step("csl99", "snapshot"), + }; + } + /// /// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks. /// Hold while status is live; offset end-pings still register briefly without it. diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index 61bed9b..df21012 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -44,6 +44,21 @@ public sealed class InterestCandidate public float TotalScore; /// Live fighters / participants in a cluster (battles, multi-unit events). public int ParticipantCount; + /// Peak fighters seen this combat scene (scale demotion hysteresis). + public int CombatPeakParticipants; + /// Sticky opposing camps for the orange tip (species/kingdom keys). + public EnsembleFrame CombatSideFrame = EnsembleFrame.CountOnly; + public string CombatSideAKey = ""; + public string CombatSideADisplay = ""; + public string CombatSideAKingdom = ""; + public int CombatSideACount; + public string CombatSideBKey = ""; + public string CombatSideBDisplay = ""; + public string CombatSideBKingdom = ""; + public int CombatSideBCount; + /// Tracked camp members for sticky scoreboard (kept until combat grace ends). + public readonly List CombatSideAIds = new List(8); + public readonly List CombatSideBIds = new List(8); /// Notable (king/favorite/leader/high renown) participants in the cluster. public int NotableParticipantCount; public Vector3 Position; @@ -78,6 +93,28 @@ public sealed class InterestCandidate public bool HasValidPosition => HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x)); + public bool HasStickyCombatSides => + !string.IsNullOrEmpty(CombatSideAKey) + && !string.IsNullOrEmpty(CombatSideBKey) + && (CombatSideFrame == EnsembleFrame.SpeciesVsSpecies + || CombatSideFrame == EnsembleFrame.KingdomVsKingdom); + + public void ClearCombatSticky() + { + CombatPeakParticipants = 0; + CombatSideFrame = EnsembleFrame.CountOnly; + CombatSideAKey = ""; + CombatSideADisplay = ""; + CombatSideAKingdom = ""; + CombatSideACount = 0; + CombatSideBKey = ""; + CombatSideBDisplay = ""; + CombatSideBKingdom = ""; + CombatSideBCount = 0; + CombatSideAIds.Clear(); + CombatSideBIds.Clear(); + } + public InterestEvent ToInterestEvent() { return new InterestEvent @@ -109,6 +146,16 @@ public sealed class InterestCandidate VisualConfidence = VisualConfidence, TotalScore = TotalScore, ParticipantCount = ParticipantCount, + CombatPeakParticipants = CombatPeakParticipants, + CombatSideFrame = CombatSideFrame, + CombatSideAKey = CombatSideAKey, + CombatSideADisplay = CombatSideADisplay, + CombatSideAKingdom = CombatSideAKingdom, + CombatSideACount = CombatSideACount, + CombatSideBKey = CombatSideBKey, + CombatSideBDisplay = CombatSideBDisplay, + CombatSideBKingdom = CombatSideBKingdom, + CombatSideBCount = CombatSideBCount, NotableParticipantCount = NotableParticipantCount, Position = Position, FollowUnit = FollowUnit, @@ -141,6 +188,16 @@ public sealed class InterestCandidate copy.ParticipantIds.Add(ParticipantIds[i]); } + for (int i = 0; i < CombatSideAIds.Count; i++) + { + copy.CombatSideAIds.Add(CombatSideAIds[i]); + } + + for (int i = 0; i < CombatSideBIds.Count; i++) + { + copy.CombatSideBIds.Add(CombatSideBIds[i]); + } + return copy; } } diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index d5e5784..67233b0 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -48,6 +48,9 @@ public static class InterestDirector private const float CombatFocusThrottleSeconds = 1.25f; /// Require this much extra focus weight before stealing the camera mid-fight. private const float CombatFocusSwitchMargin = 12f; + /// Hold elevated combat scale this long after fighter count dips. + private const float CombatScaleHoldSeconds = 2.5f; + private static float _combatScaleDropSince = -999f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => CurrentScoreLabel; @@ -201,6 +204,11 @@ public static class InterestDirector return false; } + if (candidate.Completion == InterestCompletionKind.CombatActive) + { + candidate.ClearCombatSticky(); + } + EventFeedUtil.RegisterCandidate(candidate); SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false); return true; @@ -422,10 +430,17 @@ public static class InterestDirector return; } - // No living subject left: do not sit in quiet_grace with an empty camera. + // No living subject left: prefer sticky combat roster handoff before ending. if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { + if (_current.Completion == InterestCompletionKind.CombatActive + && TryPromoteStickyFollow(_current)) + { + MaintainCombatFocus(now, force: true); + return; + } + EndCurrent(now, reason: "follow_lost"); return; } @@ -477,6 +492,12 @@ public static class InterestDirector if (!_current.HasFollowUnit && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) { + if (TryPromoteStickyFollow(_current)) + { + MaintainCombatFocus(Time.unscaledTime, force: true); + return; + } + EndCurrent(Time.unscaledTime, reason: "follow_lost"); } @@ -593,14 +614,18 @@ public static class InterestDirector newcomerBonus: 8f); if (handoffEnsemble != null) { + StabilizeCombatScale(_current, handoffEnsemble, Time.unscaledTime); ApplyEnsembleFields(_current, handoffEnsemble); + RefreshStickyCombatSides( + _current, handoffEnsemble, handoff.current_position, radius); } // Keep ownership on the handoff unit; only use ensemble framing when the melee is large. string handoffLabel; if (handoffEnsemble != null - && handoffEnsemble.ParticipantCount > 2 - && handoffEnsemble.Scale != EnsembleScale.Pair) + && (handoffEnsemble.ParticipantCount > 2 + || HasStickyCombatSides(_current) + || handoffEnsemble.Scale != EnsembleScale.Pair)) { handoffEnsemble.Focus = handoff; if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff) @@ -657,11 +682,13 @@ public static class InterestDirector string label; if (ensemble != null && ensemble.HasFocus) { + StabilizeCombatScale(_current, ensemble, Time.unscaledTime); best = ensemble.Focus; foe = ensemble.Related; - fighters = Mathf.Max(1, ensemble.ParticipantCount); - label = EventReason.Combat(ensemble); + fighters = Mathf.Max(0, ensemble.ParticipantCount); ApplyEnsembleFields(_current, ensemble); + RefreshStickyCombatSides(_current, ensemble, pos, radius); + label = EventReason.Combat(ensemble); } else if (WorldActivityScanner.TryPickBestCombatFocus( _current, out best, out foe, out fighters)) @@ -675,48 +702,133 @@ public static class InterestDirector Focus = best, Related = foe }; + StabilizeCombatScale(_current, fallback, Time.unscaledTime); + ApplyEnsembleFields(_current, fallback); + RefreshStickyCombatSides(_current, fallback, pos, radius); label = EventReason.Combat(fallback); - _current.ParticipantCount = fighters; + _current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount); + } + else if (HasStickyCombatSides(_current)) + { + // No live fighters briefly - keep framed tip from sticky roster until combat goes cold. + if (!_current.HasFollowUnit) + { + TryPromoteStickyFollow(_current); + } + + var held = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)), + Focus = _current.FollowUnit, + Related = _current.RelatedUnit, + ParticipantCount = 0 + }; + StabilizeCombatScale(_current, held, Time.unscaledTime); + RefreshStickyCombatSides(_current, held, pos, radius); + if (held.Focus == null || !held.Focus.isAlive()) + { + held.Focus = _current.RelatedUnit; + } + + if ((held.Focus == null || !held.Focus.isAlive()) && TryPromoteStickyFollow(_current)) + { + held.Focus = _current.FollowUnit; + held.Related = _current.RelatedUnit; + RefreshStickyCombatSides(_current, held, pos, radius); + } + + if (held.Focus == null || !held.Focus.isAlive()) + { + return false; + } + + best = held.Focus; + foe = held.Related; + label = EventReason.Combat(held); + fighters = _current.CombatSideACount + _current.CombatSideBCount; } else { return false; } - // Hysteresis: do not thrash the camera between near-equal fighters. - if (_current.FollowUnit != null - && _current.FollowUnit.isAlive() - && best != null - && best != _current.FollowUnit) + // Never stick on a sleeper / bystander unless they are on the sticky combat roster. + Actor curFollow = _current.FollowUnit; + bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); + bool curRostered = IsOnStickyRoster(_current, curFollow); + if (!LiveEnsemble.IsCombatParticipant(best)) + { + if (curFighting || curRostered) + { + best = curFollow; + foe = ResolveAttackFoe(best) ?? foe; + } + } + else if ((curFighting || curRostered) + && best != curFollow + && curFollow != null + && curFollow.isAlive()) { float curW = LiveEnsemble.FocusWeight( - _current.FollowUnit, _current.ParticipantIds, newcomerBonus: 0f); + curFollow, _current.ParticipantIds, newcomerBonus: 0f); + if (curRostered && !curFighting) + { + curW += 20f; + } + float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f); if (newW < curW + CombatFocusSwitchMargin) { - best = _current.FollowUnit; + best = curFollow; if (foe == null || foe == best) { - try - { - if (best.has_attack_target - && best.attack_target != null - && best.attack_target.isAlive() - && best.attack_target.isActor()) - { - foe = best.attack_target.a; - } - } - catch - { - // keep foe - } + foe = ResolveAttackFoe(best) ?? foe; } } } + // Never replace a sticky scoreboard tip with thin Fight prose. + if (HasStickyCombatSides(_current) + && (string.IsNullOrEmpty(label) + || label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)) + { + 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 = _current.CombatSideACount + _current.CombatSideBCount + }; + label = EventReason.Combat(stickyEns); + } + + string previousLabel = _current.Label ?? ""; + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel)) + { + label = previousLabel; + } + bool followChanged = _current.FollowUnit != best; - bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); _current.FollowUnit = best; _current.SubjectId = EventFeedUtil.SafeId(best); _current.RelatedUnit = foe; @@ -731,11 +843,12 @@ public static class InterestDirector // keep prior position } - if (forceWatch - || followChanged - || labelChanged - || !HasLivingCameraFocus() - || MoveCamera._focus_unit != best) + bool needWatch = forceWatch + || followChanged + || labelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != best; + if (needWatch) { CameraDirector.Watch(_current.ToInterestEvent()); } @@ -743,6 +856,400 @@ public static class InterestDirector return true; } + private static bool IsOnStickyRoster(InterestCandidate scene, Actor actor) + { + if (scene == null || actor == null || !actor.isAlive()) + { + return false; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) + { + return false; + } + + for (int i = 0; i < scene.CombatSideAIds.Count; i++) + { + if (scene.CombatSideAIds[i] == id) + { + return true; + } + } + + for (int i = 0; i < scene.CombatSideBIds.Count; i++) + { + if (scene.CombatSideBIds[i] == id) + { + return true; + } + } + + return false; + } + + private static bool HasStickyCombatSides(InterestCandidate scene) + { + return scene != null && scene.HasStickyCombatSides; + } + + /// + /// When the camera subject dies mid sticky battle, promote another living roster member + /// instead of ending the scene and falling through to thin "X is fighting" tips. + /// + private static bool TryPromoteStickyFollow(InterestCandidate scene) + { + if (scene == null || !HasStickyCombatSides(scene)) + { + return false; + } + + Actor pickA = LiveEnsemble.FindBestAliveTracked(scene.CombatSideAIds, preferCombat: true); + Actor pickB = LiveEnsemble.FindBestAliveTracked(scene.CombatSideBIds, preferCombat: true); + Actor pick = pickA ?? pickB; + if (pick == null || !pick.isAlive()) + { + return false; + } + + // Prefer the camp that still has more living members when both exist. + if (pickA != null && pickB != null) + { + int nA = scene.CombatSideACount; + int nB = scene.CombatSideBCount; + if (nB > nA) + { + pick = pickB; + } + else if (nA > nB) + { + pick = pickA; + } + } + + Actor other = pick == pickA ? pickB : pickA; + scene.FollowUnit = pick; + scene.SubjectId = EventFeedUtil.SafeId(pick); + scene.RelatedUnit = other; + scene.RelatedId = other != null ? EventFeedUtil.SafeId(other) : 0; + try + { + scene.Position = pick.current_position; + } + catch + { + // keep + } + + return true; + } + + /// + /// Lock opposing camp keys once, then refresh live counts (may be 0) every maintain pass. + /// + private static void RefreshStickyCombatSides( + InterestCandidate scene, + LiveEnsemble ensemble, + Vector3 pos, + float radius) + { + if (scene == null || ensemble == null) + { + return; + } + + if (!HasStickyCombatSides(scene) && LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + // Only lock sticky camps once the live cluster is actually multi-sided. + int liveN = Math.Max(ensemble.ParticipantCount, + (ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0)); + if (liveN <= 2 && ensemble.Scale <= EnsembleScale.Pair) + { + return; + } + + scene.CombatSideFrame = ensemble.Frame; + scene.CombatSideAKey = ensemble.SideA.Key ?? ""; + scene.CombatSideADisplay = ensemble.SideA.Display ?? ""; + scene.CombatSideAKingdom = ensemble.SideA.KingdomDisplay ?? ""; + scene.CombatSideBKey = ensemble.SideB.Key ?? ""; + scene.CombatSideBDisplay = ensemble.SideB.Display ?? ""; + scene.CombatSideBKingdom = ensemble.SideB.KingdomDisplay ?? ""; + } + + if (!HasStickyCombatSides(scene)) + { + return; + } + + // Seed roster from known ensemble participants on first lock / empty lists. + SeedStickyRosterFromEnsemble(scene, ensemble); + + LiveEnsemble.RefreshStickyMemberRoster( + pos, + radius, + scene.CombatSideFrame, + scene.CombatSideAKey, + scene.CombatSideBKey, + scene.CombatSideAIds, + scene.CombatSideBIds, + out int countA, + out int countB, + out Actor bestA, + out Actor bestB); + + scene.CombatSideACount = Math.Max(0, countA); + scene.CombatSideBCount = Math.Max(0, countB); + int live = scene.CombatSideACount + scene.CombatSideBCount; + scene.ParticipantCount = live; + if (live > scene.CombatPeakParticipants) + { + scene.CombatPeakParticipants = live; + } + + ensemble.Frame = scene.CombatSideFrame; + ensemble.SideA = new EnsembleSide + { + Key = scene.CombatSideAKey, + Display = string.IsNullOrEmpty(scene.CombatSideADisplay) + ? scene.CombatSideAKey + : scene.CombatSideADisplay, + KingdomDisplay = scene.CombatSideAKingdom, + Count = scene.CombatSideACount, + Best = bestA ?? ensemble.Focus + }; + ensemble.SideB = new EnsembleSide + { + Key = scene.CombatSideBKey, + Display = string.IsNullOrEmpty(scene.CombatSideBDisplay) + ? scene.CombatSideBKey + : scene.CombatSideBDisplay, + KingdomDisplay = scene.CombatSideBKingdom, + Count = scene.CombatSideBCount, + Best = bestB ?? ensemble.Related + }; + ensemble.ParticipantCount = live; + + // Sticky multi frame stays multi while the scene is hot - do not collapse to Pair on a dip. + if (ensemble.Scale == EnsembleScale.Pair) + { + ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants)); + } + + if (bestA != null && (ensemble.Focus == null || !LiveEnsemble.IsCombatParticipant(ensemble.Focus))) + { + ensemble.Focus = bestA; + } + + if (bestB != null) + { + ensemble.Related = bestB; + } + } + + private static void SeedStickyRosterFromEnsemble(InterestCandidate scene, LiveEnsemble ensemble) + { + if (scene == null || ensemble == null || !HasStickyCombatSides(scene)) + { + return; + } + + void Enroll(Actor actor) + { + if (actor == null || !actor.isAlive()) + { + return; + } + + long id = EventFeedUtil.SafeId(actor); + if (id == 0) + { + return; + } + + string key = scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom + ? LiveEnsemble.KingdomKeyOf(actor) + : LiveEnsemble.SpeciesKeyOf(actor); + if (string.IsNullOrEmpty(key)) + { + return; + } + + if (key.Equals(scene.CombatSideAKey, StringComparison.OrdinalIgnoreCase)) + { + for (int i = 0; i < scene.CombatSideAIds.Count; i++) + { + if (scene.CombatSideAIds[i] == id) + { + return; + } + } + + scene.CombatSideAIds.Add(id); + } + else if (key.Equals(scene.CombatSideBKey, StringComparison.OrdinalIgnoreCase)) + { + for (int i = 0; i < scene.CombatSideBIds.Count; i++) + { + if (scene.CombatSideBIds[i] == id) + { + return; + } + } + + scene.CombatSideBIds.Add(id); + } + } + + Enroll(ensemble.Focus); + Enroll(ensemble.Related); + Enroll(ensemble.SideA?.Best); + Enroll(ensemble.SideB?.Best); + if (ensemble.ParticipantIds != null) + { + for (int i = 0; i < ensemble.ParticipantIds.Count; i++) + { + long id = ensemble.ParticipantIds[i]; + if (id == 0) + { + continue; + } + + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor.isAlive() && EventFeedUtil.SafeId(actor) == id) + { + Enroll(actor); + break; + } + } + } + } + } + + private static Actor ResolveAttackFoe(Actor actor) + { + if (actor == null) + { + return null; + } + + try + { + if (actor.has_attack_target + && actor.attack_target != null + && actor.attack_target.isAlive() + && actor.attack_target.isActor()) + { + Actor foe = actor.attack_target.a; + return foe != null && foe.isAlive() ? foe : null; + } + } + catch + { + // ignore + } + + return null; + } + + /// + /// Hold elevated combat scale briefly after fighter count dips so Mass does not flap to Duel. + /// + private static void StabilizeCombatScale(InterestCandidate scene, LiveEnsemble ensemble, float now) + { + if (scene == null || ensemble == null) + { + return; + } + + int n = Mathf.Max(1, ensemble.ParticipantCount); + if (n > scene.CombatPeakParticipants) + { + scene.CombatPeakParticipants = n; + _combatScaleDropSince = -999f; + } + + int peak = Math.Max(scene.CombatPeakParticipants, n); + EnsembleScale live = LiveEnsemble.ScaleForCount(n); + EnsembleScale held = LiveEnsemble.ScaleForCount(peak); + if (held > live) + { + if (_combatScaleDropSince < 0f) + { + _combatScaleDropSince = now; + } + + if (now - _combatScaleDropSince < CombatScaleHoldSeconds) + { + ensemble.Scale = held; + // Live rebuild may only see a NamedPair; keep mass framing while peak holds. + if (ensemble.Frame == EnsembleFrame.NamedPair) + { + ensemble.Frame = EnsembleFrame.CountOnly; + } + + return; + } + + // Hold expired: ratchet peak down to live count. + scene.CombatPeakParticipants = n; + _combatScaleDropSince = -999f; + ensemble.Scale = live; + return; + } + + _combatScaleDropSince = -999f; + ensemble.Scale = live; + } + + /// + /// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps. + /// + private static bool CombatLabelNeedsRewatch(string oldLabel, string newLabel) + { + if (string.IsNullOrEmpty(oldLabel)) + { + return true; + } + + if (string.IsNullOrEmpty(newLabel)) + { + return true; + } + + string a = StripCombatLabelCounts(oldLabel); + string b = StripCombatLabelCounts(newLabel); + return !a.Equals(b, StringComparison.OrdinalIgnoreCase); + } + + private static string StripCombatLabelCounts(string label) + { + if (string.IsNullOrEmpty(label)) + { + return ""; + } + + var sb = new System.Text.StringBuilder(label.Length); + for (int i = 0; i < label.Length; i++) + { + char c = label[i]; + if (c == '(') + { + int close = label.IndexOf(')', i); + if (close > i) + { + i = close; + continue; + } + } + + sb.Append(c); + } + + return sb.ToString().Trim(); + } + private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble) { if (scene == null || ensemble == null) @@ -750,7 +1257,12 @@ public static class InterestDirector return; } - scene.ParticipantCount = Mathf.Max(1, ensemble.ParticipantCount); + scene.ParticipantCount = Math.Max(0, ensemble.ParticipantCount); + if (scene.ParticipantCount > scene.CombatPeakParticipants) + { + scene.CombatPeakParticipants = scene.ParticipantCount; + } + scene.NotableParticipantCount = ensemble.NotableParticipantCount; WorldActivityScanner.StampParticipantIds(scene, ensemble); if (ensemble.Scale >= EnsembleScale.Skirmish) @@ -771,6 +1283,24 @@ public static class InterestDirector { scene.SpeciesId = ensemble.SideA.Key; } + + if (LiveEnsemble.HasOpposingCollectiveSides(ensemble) && !HasStickyCombatSides(scene)) + { + int liveN = Math.Max(ensemble.ParticipantCount, + ensemble.SideA.Count + ensemble.SideB.Count); + if (liveN > 2 || ensemble.Scale > EnsembleScale.Pair) + { + scene.CombatSideFrame = ensemble.Frame; + scene.CombatSideAKey = ensemble.SideA.Key ?? ""; + scene.CombatSideADisplay = ensemble.SideA.Display ?? ""; + scene.CombatSideAKingdom = ensemble.SideA.KingdomDisplay ?? ""; + scene.CombatSideACount = Math.Max(0, ensemble.SideA.Count); + scene.CombatSideBKey = ensemble.SideB.Key ?? ""; + scene.CombatSideBDisplay = ensemble.SideB.Display ?? ""; + scene.CombatSideBKingdom = ensemble.SideB.KingdomDisplay ?? ""; + scene.CombatSideBCount = Math.Max(0, ensemble.SideB.Count); + } + } } /// @@ -842,6 +1372,11 @@ public static class InterestDirector } ApplyEnsembleFields(_current, ensemble); + RefreshStickyCombatSides( + _current, + ensemble, + ensemble.Focus.current_position, + 14f); _current.FollowUnit = ensemble.Focus; _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); _current.RelatedUnit = ensemble.Related; @@ -882,6 +1417,8 @@ public static class InterestDirector } _current.Label = ""; + _current.ClearCombatSticky(); + _combatScaleDropSince = -999f; _lastCombatFocusAt = now; CameraDirector.Watch(_current.ToInterestEvent()); } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 390271e..e67bf7d 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.54", - "description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.", + "version": "0.25.64", + "description": "AFK Idle Spectator (I) + Lore (L). Sticky combat handoff - no thin fighting fallback.", "GUID": "com.dazed.idlespectator" } diff --git a/docs/event-audit.md b/docs/event-audit.md index 556134f..5e2a229 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -88,6 +88,6 @@ Done (see [camera-presentability-plan.md](camera-presentability-plan.md) and `co Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption; `WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`). -Done (0.25.54): tiered battle reasons (`Battle - [Kingdom] side (n) vs …`) + combat focus hysteresis. +Done (0.25.64): sticky combat handoff on follow_lost + never collapse sticky tips to "X is fighting". Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`). diff --git a/docs/event-reason.md b/docs/event-reason.md index 9595c7f..8207e1c 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -40,8 +40,16 @@ The director refreshes counts, sides, focus, and `EventReason.Combat` while `Com | Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` | Optional kingdom brackets on species sides: `Battle - [Essiona] humans (10) vs wolves (3)`. -Side framing priority: kingdom vs kingdom → species vs species → attack camps / named leaders → count-only. +Side framing priority: kingdom vs kingdom → species vs species → count-only melee. +Never use unit proper names as mass-side labels (no `Battle - Waf (6) vs Eaderbert (1)`). +Same-species camps with no opposing sticky keys use a single pack line (`Mass - Wolves (n)`). +Sticky opposing camps lock once and enroll member ids while they fight. +Counts stay on those tracked members while alive (combat or not) until combat grace clears the scene - so a wiped side can hit `0` while survivors remain counted. +New combatants matching a sticky key are enrolled; dead ids are pruned. +Orphan pair tips use `{name} is fighting` instead of `Duel - Name` with no foe. Names are humanized (no raw `nomads_human` ids). +Scale hold (~2.5s peak hysteresis) keeps Mass/Battle from flapping to Duel on brief count dips. +Combat camera hysteresis only applies between living fighters - sleepers/bystanders never stick. ### Other families (same snapshot later)