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)