diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index 78d3373..6f48447 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -2013,6 +2013,10 @@ public static class AgentHarness
break;
}
+ case "combat_ensemble_escalate":
+ DoCombatEnsembleEscalate(cmd);
+ break;
+
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
@@ -3542,7 +3546,19 @@ public static class AgentHarness
}
string label = string.IsNullOrEmpty(cmd.label)
- ? EventReason.Fight(follow, related)
+ ? EventReason.Combat(new LiveEnsemble
+ {
+ Kind = EnsembleKind.Combat,
+ Scale = EnsembleScale.Pair,
+ Frame = EnsembleFrame.NamedPair,
+ ParticipantCount = related != null ? 2 : 1,
+ Focus = follow,
+ Related = related,
+ SideA = new EnsembleSide { Display = SafeName(follow), Count = 1, Best = follow },
+ SideB = related != null
+ ? new EnsembleSide { Display = SafeName(related), Count = 1, Best = related }
+ : null
+ })
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
@@ -3586,6 +3602,153 @@ public static class AgentHarness
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
}
+ ///
+ /// Grow the current CombatActive scene into a sided multi-fighter ensemble reason.
+ /// value = participant count (default 6). Spawns extra follow/related species near the focus.
+ ///
+ private static void DoCombatEnsembleEscalate(HarnessCommand cmd)
+ {
+ InterestCandidate scene = InterestDirector.CurrentCandidate;
+ if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no_combat_scene");
+ return;
+ }
+
+ Actor focus = scene.FollowUnit;
+ if (focus == null || !focus.isAlive())
+ {
+ focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ }
+
+ Actor related = scene.RelatedUnit;
+ if (focus == null || !focus.isAlive())
+ {
+ _cmdFail++;
+ Emit(cmd, ok: false, detail: "no_focus");
+ return;
+ }
+
+ int want = 6;
+ if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
+ {
+ want = 6;
+ }
+
+ want = Mathf.Clamp(want, 3, 24);
+ string focusSpecies = focus.asset != null ? focus.asset.id : "human";
+ string relatedSpecies = related?.asset != null ? related.asset.id : "wolf";
+ if (string.Equals(relatedSpecies, focusSpecies, StringComparison.OrdinalIgnoreCase))
+ {
+ relatedSpecies = focusSpecies == "human" ? "wolf" : "human";
+ }
+
+ int sideA = want / 2;
+ int sideB = want - sideA;
+ // Already have focus (+ optional related); spawn the rest near the fight.
+ for (int i = 1; i < sideA; i++)
+ {
+ SpawnAssetNear(focusSpecies, focus.current_position, 2f + i * 0.4f);
+ }
+
+ if (related == null || !related.isAlive())
+ {
+ related = SpawnAssetNear(relatedSpecies, focus.current_position, 2.5f);
+ }
+
+ for (int i = 1; i < sideB; i++)
+ {
+ SpawnAssetNear(relatedSpecies, focus.current_position, 3f + i * 0.4f);
+ }
+
+ var ensemble = new LiveEnsemble
+ {
+ Kind = EnsembleKind.Combat,
+ Scale = LiveEnsemble.ScaleForCount(want),
+ Frame = EnsembleFrame.SpeciesVsSpecies,
+ ParticipantCount = want,
+ NotableParticipantCount = Mathf.Max(scene.NotableParticipantCount, 0),
+ Focus = focus,
+ Related = related,
+ Anchor = focus.current_position,
+ SideA = new EnsembleSide
+ {
+ Key = focusSpecies,
+ Display = LiveEnsemble.SpeciesDisplay(focusSpecies),
+ Count = sideA,
+ Best = focus
+ },
+ SideB = new EnsembleSide
+ {
+ Key = relatedSpecies,
+ Display = LiveEnsemble.SpeciesDisplay(relatedSpecies),
+ Count = sideB,
+ Best = related
+ }
+ };
+ ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(focus));
+ if (related != null)
+ {
+ ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(related));
+ }
+
+ bool ok = InterestDirector.HarnessApplyCombatEnsemble(ensemble);
+ if (ok)
+ {
+ _cmdOk++;
+ }
+ else
+ {
+ _cmdFail++;
+ }
+
+ Emit(
+ cmd,
+ ok,
+ detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
+ }
+
+ private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter)
+ {
+ try
+ {
+ if (AssetManager.actor_library?.get(assetId) == null || World.world == null)
+ {
+ return null;
+ }
+
+ WorldTile tile = World.world.GetTile(
+ Mathf.RoundToInt(near.x + jitter),
+ Mathf.RoundToInt(near.y));
+ if (tile == null)
+ {
+ tile = World.world.GetTile(
+ Mathf.RoundToInt(near.x),
+ Mathf.RoundToInt(near.y));
+ }
+
+ if (tile == null)
+ {
+ return null;
+ }
+
+ Actor spawned = World.world.units.spawnNewUnit(
+ assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
+ if (spawned != null && spawned.isAlive())
+ {
+ _lastSpawned = spawned;
+ _lastSpawnedAssetId = assetId;
+ }
+
+ return spawned;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
private static void DoSetSetting(HarnessCommand cmd)
{
string key = cmd.expect;
@@ -3711,8 +3874,9 @@ public static class AgentHarness
else
{
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
- || tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
- // Also accept Battle-style where name leads.
+ || tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
+ || IsEnsembleCombatTip(tip);
+ // Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
detail = $"tip='{tip}' focus='{name}' pass={pass}";
}
@@ -3727,6 +3891,43 @@ public static class AgentHarness
detail = $"tip='{tip}' not_contains='{needle}'";
break;
}
+ case "tip_matches_any":
+ {
+ // value = pipe-separated needles; tip must contain at least one.
+ string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
+ string tip = CameraDirector.LastWatchLabel ?? "";
+ pass = false;
+ if (!string.IsNullOrEmpty(raw))
+ {
+ string[] parts = raw.Split('|');
+ for (int i = 0; i < parts.Length; i++)
+ {
+ string needle = (parts[i] ?? "").Trim();
+ if (needle.Length > 0
+ && tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ pass = true;
+ break;
+ }
+ }
+ }
+
+ detail = $"tip='{tip}' any='{raw}' pass={pass}";
+ break;
+ }
+ case "participant_count_min":
+ {
+ int want = 1;
+ if (!int.TryParse(string.IsNullOrEmpty(cmd.value) ? "1" : cmd.value, out want))
+ {
+ want = 1;
+ }
+
+ int have = InterestDirector.CurrentCandidate?.ParticipantCount ?? 0;
+ pass = have >= want;
+ detail = $"participants={have} want>={want}";
+ break;
+ }
case "unit_asset":
{
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
@@ -6688,6 +6889,20 @@ public static class AgentHarness
return SafeName(MoveCamera._focus_unit);
}
+ private static bool IsEnsembleCombatTip(string tip)
+ {
+ if (string.IsNullOrEmpty(tip))
+ {
+ return false;
+ }
+
+ return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
+ || tip.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
+ || tip.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
+ || tip.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
+ || tip.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
private static long ResolveActivitySubjectId()
{
// Prefer the camera focus (harness apply/assert target) over watch caption,
diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs
index f02ffd7..bbc2344 100644
--- a/IdleSpectator/Events/EventReason.cs
+++ b/IdleSpectator/Events/EventReason.cs
@@ -30,19 +30,154 @@ public static class EventReason
public static string Battle(Actor focus, int fighters)
{
- string name = Name(focus);
- if (string.IsNullOrEmpty(name))
+ // Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing.
+ var e = new LiveEnsemble
{
- name = "Someone";
+ Kind = EnsembleKind.Combat,
+ Scale = LiveEnsemble.ScaleForCount(fighters),
+ Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
+ ParticipantCount = Math.Max(1, fighters),
+ Focus = focus
+ };
+ return Combat(e);
+ }
+
+ ///
+ /// Context-aware combat orange reason from a snapshot.
+ /// Multi-fighter form: Battle - [Essiona] humans (10) vs wolves (3).
+ ///
+ public static string Combat(LiveEnsemble ensemble)
+ {
+ if (ensemble == null || !ensemble.HasFocus)
+ {
+ return "";
}
- int n = Math.Max(1, fighters);
- if (n <= 2)
+ if (ensemble.Frame == EnsembleFrame.NamedPair
+ || ensemble.Scale == EnsembleScale.Pair)
{
- return name + " is in a duel";
+ string a = Name(ensemble.Focus);
+ string b = Name(ensemble.Related);
+ if (string.IsNullOrEmpty(a))
+ {
+ a = "Someone";
+ }
+
+ if (string.IsNullOrEmpty(b))
+ {
+ return "Duel - " + a;
+ }
+
+ return "Duel - " + a + " vs " + b;
}
- return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
+ 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);
+
+ if (sided)
+ {
+ return tier + " - " + sideA + " vs " + sideB;
+ }
+
+ int n = Math.Max(1, ensemble.ParticipantCount);
+ if (!string.IsNullOrEmpty(sideA))
+ {
+ return tier + " - " + sideA + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+
+ return tier + " - fight of " + n.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private static string ScaleTierLabel(EnsembleScale scale)
+ {
+ switch (scale)
+ {
+ case EnsembleScale.Pair:
+ return "Duel";
+ case EnsembleScale.Skirmish:
+ return "Skirmish";
+ case EnsembleScale.Mass:
+ return "Mass";
+ default:
+ return "Battle";
+ }
+ }
+
+ private static string FormatCombatSide(EnsembleSide side, EnsembleFrame frame)
+ {
+ if (side == null)
+ {
+ return "";
+ }
+
+ string label = side.Display ?? "";
+ if (string.IsNullOrEmpty(label))
+ {
+ label = side.Key ?? "";
+ }
+
+ if (frame == EnsembleFrame.KingdomVsKingdom)
+ {
+ label = LiveEnsemble.KingdomDisplay(label);
+ }
+ else if (frame == EnsembleFrame.SpeciesVsSpecies)
+ {
+ label = LiveEnsemble.SpeciesDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
+ 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)
+ {
+ label = "[" + kingdom + "] " + label;
+ }
+ }
+ }
+ else if (frame == EnsembleFrame.NamedLeaders)
+ {
+ if (side.Best != null)
+ {
+ label = Name(side.Best);
+ }
+ }
+
+ if (string.IsNullOrEmpty(label))
+ {
+ 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);
+ return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+
+ /// Dispatch by ensemble kind (combat first; other kinds plug in later).
+ public static string Ensemble(LiveEnsemble ensemble)
+ {
+ if (ensemble == null)
+ {
+ return "";
+ }
+
+ switch (ensemble.Kind)
+ {
+ case EnsembleKind.Combat:
+ return Combat(ensemble);
+ default:
+ return Combat(ensemble);
+ }
}
public static string SeekingLover(Actor a)
diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs
index d2b41e6..18aec05 100644
--- a/IdleSpectator/Events/Feeds/InterestFeeds.cs
+++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs
@@ -353,8 +353,19 @@ public static class InterestFeeds
string verb = taskOrBeat ?? "fighting";
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
+ Vector3 pos = follow != null ? follow.current_position : actor.current_position;
+ LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
+ if (ensemble != null && ensemble.HasFocus)
+ {
+ follow = ensemble.Focus;
+ related = ensemble.Related ?? related;
+ }
+
long followId = SafeId(follow ?? actor);
string key = EventCatalog.Combat.Key(followId);
+ string label = ensemble != null
+ ? EventReason.Combat(ensemble)
+ : EventReason.Fight(follow ?? actor, related);
var candidate = new InterestCandidate
{
Key = key,
@@ -368,12 +379,14 @@ public static class InterestFeeds
RelatedUnit = related,
SubjectId = followId,
RelatedId = related != null ? SafeId(related) : 0,
- Label = EventReason.Fight(follow ?? actor, related),
+ Label = label,
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
Verb = verb,
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
+ ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
+ NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
@@ -381,15 +394,34 @@ public static class InterestFeeds
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
- candidate.ParticipantIds.Add(followId);
- if (related != null)
+ if (ensemble != null)
{
- long rid = SafeId(related);
- if (rid != 0)
+ WorldActivityScanner.StampParticipantIds(candidate, ensemble);
+ if (ensemble.Scale >= EnsembleScale.Skirmish)
{
- candidate.ParticipantIds.Add(rid);
+ candidate.AssetId = "live_battle";
+ }
+
+ if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
+ && ensemble.SideA != null
+ && !string.IsNullOrEmpty(ensemble.SideA.Key))
+ {
+ candidate.KingdomKey = ensemble.SideA.Key;
}
}
+ else
+ {
+ candidate.ParticipantIds.Add(followId);
+ if (related != null)
+ {
+ long rid = SafeId(related);
+ if (rid != 0)
+ {
+ candidate.ParticipantIds.Add(rid);
+ }
+ }
+ }
+
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
@@ -1031,7 +1063,15 @@ public static class InterestFeeds
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
if (battle != null)
{
- RegisterDirect(battle);
+ if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
+ && InterestDirector.TryAbsorbCombatBattle(battle))
+ {
+ // Active combat scene refreshed in place.
+ }
+ else
+ {
+ RegisterDirect(battle);
+ }
}
// Ongoing wars from WarManager (complements WorldLog war start/end).
diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs
new file mode 100644
index 0000000..f9d4ec7
--- /dev/null
+++ b/IdleSpectator/Events/LiveEnsemble.cs
@@ -0,0 +1,877 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace IdleSpectator;
+
+/// Families that can escalate into multi-actor live scenes.
+public enum EnsembleKind
+{
+ Combat = 0,
+ StatusOutbreak = 1,
+ FamilyPack = 2,
+ WarFront = 3,
+ PlotCell = 4,
+ DiscoveryCluster = 5
+}
+
+/// Size band for reason + scoring alignment.
+public enum EnsembleScale
+{
+ Pair = 0,
+ Skirmish = 1,
+ Battle = 2,
+ Mass = 3
+}
+
+/// How opposing camps are named in the orange reason.
+public enum EnsembleFrame
+{
+ NamedPair = 0,
+ KingdomVsKingdom = 1,
+ SpeciesVsSpecies = 2,
+ NamedLeaders = 3,
+ CountOnly = 4
+}
+
+/// One camp inside a .
+public sealed class EnsembleSide
+{
+ public string Key = "";
+ public string Display = "";
+ /// Optional kingdom pretty-name when the side is species-framed.
+ public string KingdomDisplay = "";
+ public int Count;
+ public int NotableCount;
+ public Actor Best;
+}
+
+///
+/// Snapshot of a live multi-actor scene. Combat is the first consumer;
+/// wars / status / family / plots can reuse the same shape later.
+///
+public sealed class LiveEnsemble
+{
+ public EnsembleKind Kind = EnsembleKind.Combat;
+ public EnsembleScale Scale = EnsembleScale.Pair;
+ public EnsembleFrame Frame = EnsembleFrame.CountOnly;
+ public int ParticipantCount;
+ public int NotableParticipantCount;
+ public Actor Focus;
+ public Actor Related;
+ public EnsembleSide SideA;
+ public EnsembleSide SideB;
+ public readonly List ParticipantIds = new List(8);
+ public Vector3 Anchor;
+
+ public bool HasFocus => Focus != null && Focus.isAlive();
+
+ public static EnsembleScale ScaleForCount(int count)
+ {
+ ScoringWeights w = InterestScoringConfig.W;
+ int n = Math.Max(0, count);
+ if (n <= Math.Max(1, w.duelMaxFighters))
+ {
+ return EnsembleScale.Pair;
+ }
+
+ int crowd = Math.Max(w.massFighterThreshold + 1, w.massCrowdThreshold);
+ if (n >= crowd)
+ {
+ return EnsembleScale.Mass;
+ }
+
+ if (n >= Math.Max(3, w.massFighterThreshold))
+ {
+ return EnsembleScale.Battle;
+ }
+
+ return EnsembleScale.Skirmish;
+ }
+
+ ///
+ /// Collect living fighters near and partition into sides.
+ ///
+ public static bool TryBuildCombat(
+ Vector2 pos,
+ float radius,
+ out LiveEnsemble ensemble,
+ ICollection priorParticipantIds = null,
+ float newcomerBonus = 8f)
+ {
+ ensemble = null;
+ var fighters = new List(16);
+ CollectCombatFighters(pos, radius, fighters);
+ if (fighters.Count == 0)
+ {
+ return false;
+ }
+
+ ensemble = FromCombatFighters(fighters, pos, priorParticipantIds, newcomerBonus);
+ return ensemble != null && ensemble.HasFocus;
+ }
+
+ public static LiveEnsemble FromCombatFighters(
+ List fighters,
+ Vector2 anchor,
+ ICollection priorParticipantIds = null,
+ float newcomerBonus = 8f)
+ {
+ if (fighters == null || fighters.Count == 0)
+ {
+ return null;
+ }
+
+ var ensemble = new LiveEnsemble
+ {
+ Kind = EnsembleKind.Combat,
+ Anchor = new Vector3(anchor.x, anchor.y, 0f)
+ };
+
+ int notables = 0;
+ float bestWeight = -1f;
+ Actor best = null;
+ for (int i = 0; i < fighters.Count; i++)
+ {
+ Actor actor = fighters[i];
+ if (actor == null || !actor.isAlive())
+ {
+ continue;
+ }
+
+ long id = EventFeedUtil.SafeId(actor);
+ if (id != 0)
+ {
+ ensemble.ParticipantIds.Add(id);
+ }
+
+ if (InterestScoring.IsNotable(actor))
+ {
+ notables++;
+ }
+
+ float weight = FocusWeight(actor, priorParticipantIds, newcomerBonus);
+ if (weight > bestWeight)
+ {
+ bestWeight = weight;
+ best = actor;
+ }
+ }
+
+ ensemble.ParticipantCount = ensemble.ParticipantIds.Count > 0
+ ? ensemble.ParticipantIds.Count
+ : fighters.Count;
+ ensemble.NotableParticipantCount = notables;
+ ensemble.Focus = best;
+ ensemble.Scale = ScaleForCount(ensemble.ParticipantCount);
+ if (ensemble.Focus == null)
+ {
+ return null;
+ }
+
+ try
+ {
+ ensemble.Anchor = ensemble.Focus.current_position;
+ }
+ catch
+ {
+ // keep anchor
+ }
+
+ if (ensemble.ParticipantCount <= 2)
+ {
+ ApplyNamedPair(ensemble, fighters);
+ return ensemble;
+ }
+
+ if (TryPartitionByKingdom(fighters, ensemble, priorParticipantIds, newcomerBonus))
+ {
+ return ensemble;
+ }
+
+ if (TryPartitionBySpecies(fighters, ensemble, priorParticipantIds, newcomerBonus))
+ {
+ return ensemble;
+ }
+
+ if (TryPartitionByAttackCamps(fighters, ensemble, priorParticipantIds, newcomerBonus))
+ {
+ return ensemble;
+ }
+
+ ApplyCountOnly(ensemble, fighters, priorParticipantIds, newcomerBonus);
+ return ensemble;
+ }
+
+ public static float FocusWeight(
+ Actor actor,
+ ICollection priorParticipantIds,
+ float newcomerBonus)
+ {
+ float weight = WorldActivityScanner.CombatFocusWeight(actor);
+ if (weight < 0f)
+ {
+ return weight;
+ }
+
+ if (priorParticipantIds != null
+ && priorParticipantIds.Count > 0
+ && newcomerBonus > 0f)
+ {
+ long id = EventFeedUtil.SafeId(actor);
+ if (id != 0 && !priorParticipantIds.Contains(id))
+ {
+ weight += newcomerBonus;
+ }
+ }
+
+ return weight;
+ }
+
+ public static string KingdomKeyOf(Actor actor)
+ {
+ if (actor?.kingdom == null)
+ {
+ return "";
+ }
+
+ string name = actor.kingdom.name ?? "";
+ return string.IsNullOrEmpty(name) ? "" : name.Trim();
+ }
+
+ /// Human-readable kingdom / civ label (never raw snake_case ids).
+ public static string KingdomDisplay(string kingdomKey)
+ {
+ if (string.IsNullOrEmpty(kingdomKey))
+ {
+ return "";
+ }
+
+ string key = kingdomKey.Trim();
+ // Some worlds use a bare species asset id as the kingdom name.
+ if (key.IndexOf(' ') < 0
+ && key.IndexOf('_') < 0
+ && ActivityAssetCatalog.IsLiveActorAssetId(key))
+ {
+ return SpeciesDisplay(key);
+ }
+
+ return HumanizeWorldLabel(key);
+ }
+
+ public static string SpeciesKeyOf(Actor actor)
+ {
+ return actor?.asset != null ? (actor.asset.id ?? "") : "";
+ }
+
+ public static string SpeciesDisplay(string speciesId)
+ {
+ if (string.IsNullOrEmpty(speciesId))
+ {
+ return "creatures";
+ }
+
+ string fromAsset = ActivityAssetCatalog.SpeciesDisplayLabel(speciesId);
+ string s = !string.IsNullOrEmpty(fromAsset) ? fromAsset : speciesId.Replace('_', ' ').Trim();
+ if (string.IsNullOrEmpty(s))
+ {
+ return "creatures";
+ }
+
+ string lower = s.ToLowerInvariant();
+ if (lower.StartsWith("nomads ", StringComparison.Ordinal))
+ {
+ s = s.Substring(7).Trim();
+ lower = s.ToLowerInvariant();
+ }
+
+ switch (lower)
+ {
+ case "wolf":
+ return "wolves";
+ case "human":
+ case "humans":
+ return "humans";
+ case "sheep":
+ return "sheep";
+ case "fish":
+ return "fish";
+ case "goose":
+ return "geese";
+ case "mouse":
+ return "mice";
+ case "ox":
+ return "oxen";
+ case "dwarf":
+ return "dwarves";
+ case "elf":
+ return "elves";
+ }
+
+ if (lower.EndsWith("s", StringComparison.Ordinal)
+ || lower.EndsWith("x", StringComparison.Ordinal)
+ || lower.EndsWith("ch", StringComparison.Ordinal)
+ || lower.EndsWith("sh", StringComparison.Ordinal))
+ {
+ return lower;
+ }
+
+ if (lower.EndsWith("y", StringComparison.Ordinal) && lower.Length > 1)
+ {
+ char prev = lower[lower.Length - 2];
+ if (prev != 'a' && prev != 'e' && prev != 'i' && prev != 'o' && prev != 'u')
+ {
+ return lower.Substring(0, lower.Length - 1) + "ies";
+ }
+ }
+
+ return lower + "s";
+ }
+
+ /// Title-case / underscore cleanup for kingdom and civ-like ids.
+ public static string HumanizeWorldLabel(string raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ {
+ return "";
+ }
+
+ string s = raw.Trim().Replace('_', ' ');
+ while (s.IndexOf(" ", StringComparison.Ordinal) >= 0)
+ {
+ s = s.Replace(" ", " ");
+ }
+
+ if (s.Length == 0)
+ {
+ return "";
+ }
+
+ // Strip civ prefixes that leak into labels ("nomads human" → species when match).
+ string lower = s.ToLowerInvariant();
+ if (lower.StartsWith("nomads ", StringComparison.Ordinal))
+ {
+ string rest = s.Substring(7).Trim();
+ string restId = rest.Replace(' ', '_');
+ if (ActivityAssetCatalog.IsLiveActorAssetId(restId)
+ || ActivityAssetCatalog.IsLiveActorAssetId(rest))
+ {
+ return SpeciesDisplay(
+ ActivityAssetCatalog.IsLiveActorAssetId(restId) ? restId : rest);
+ }
+ }
+
+ var sb = new System.Text.StringBuilder(s.Length);
+ bool cap = true;
+ for (int i = 0; i < s.Length; i++)
+ {
+ char c = s[i];
+ if (char.IsWhiteSpace(c))
+ {
+ sb.Append(' ');
+ cap = true;
+ continue;
+ }
+
+ if (cap)
+ {
+ sb.Append(char.ToUpperInvariant(c));
+ cap = false;
+ }
+ else
+ {
+ sb.Append(char.ToLowerInvariant(c));
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static void CollectCombatFighters(Vector2 pos, float radius, List into)
+ {
+ float r2 = radius * radius;
+ foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ continue;
+ }
+
+ float dx = actor.current_position.x - pos.x;
+ float dy = actor.current_position.y - pos.y;
+ if (dx * dx + dy * dy > r2)
+ {
+ continue;
+ }
+
+ if (!IsCombatParticipant(actor))
+ {
+ continue;
+ }
+
+ into.Add(actor);
+ }
+ }
+
+ public static bool IsCombatParticipant(Actor actor)
+ {
+ if (actor == null || !actor.isAlive())
+ {
+ return false;
+ }
+
+ try
+ {
+ if (actor.has_attack_target)
+ {
+ return true;
+ }
+
+ return actor.hasTask()
+ && actor.ai?.task != null
+ && actor.ai.task.in_combat;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void ApplyNamedPair(LiveEnsemble ensemble, List fighters)
+ {
+ ensemble.Frame = EnsembleFrame.NamedPair;
+ Actor other = null;
+ for (int i = 0; i < fighters.Count; i++)
+ {
+ Actor a = fighters[i];
+ if (a != null && a.isAlive() && a != ensemble.Focus)
+ {
+ other = a;
+ break;
+ }
+ }
+
+ if (other == null)
+ {
+ other = AttackTargetOf(ensemble.Focus);
+ }
+
+ ensemble.Related = other;
+ ensemble.SideA = SideFromActor(ensemble.Focus, "focus");
+ if (other != null)
+ {
+ ensemble.SideB = SideFromActor(other, "foe");
+ }
+ }
+
+ private static void ApplyCountOnly(
+ LiveEnsemble ensemble,
+ List fighters,
+ ICollection prior,
+ float newcomerBonus)
+ {
+ ensemble.Frame = EnsembleFrame.CountOnly;
+ ensemble.SideA = SideFromActor(ensemble.Focus, "focus");
+ ensemble.SideA.Count = ensemble.ParticipantCount;
+ ensemble.SideA.NotableCount = ensemble.NotableParticipantCount;
+ Actor foe = AttackTargetOf(ensemble.Focus);
+ if (foe == null || !foe.isAlive())
+ {
+ // Second-best by weight as a soft related.
+ float bestW = -1f;
+ for (int i = 0; i < fighters.Count; i++)
+ {
+ Actor a = fighters[i];
+ if (a == null || a == ensemble.Focus || !a.isAlive())
+ {
+ continue;
+ }
+
+ float w = FocusWeight(a, prior, newcomerBonus);
+ if (w > bestW)
+ {
+ bestW = w;
+ foe = a;
+ }
+ }
+ }
+
+ ensemble.Related = foe;
+ if (foe != null)
+ {
+ ensemble.SideB = SideFromActor(foe, "foe");
+ if (ensemble.NotableParticipantCount >= 2
+ && InterestScoring.IsNotable(ensemble.Focus)
+ && InterestScoring.IsNotable(foe))
+ {
+ ensemble.Frame = EnsembleFrame.NamedLeaders;
+ }
+ }
+ }
+
+ private static bool TryPartitionByKingdom(
+ List fighters,
+ LiveEnsemble ensemble,
+ ICollection prior,
+ float newcomerBonus)
+ {
+ var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < fighters.Count; i++)
+ {
+ Actor a = fighters[i];
+ string key = KingdomKeyOf(a);
+ if (string.IsNullOrEmpty(key))
+ {
+ continue;
+ }
+
+ if (!groups.TryGetValue(key, out List list))
+ {
+ list = new List(4);
+ groups[key] = list;
+ }
+
+ list.Add(a);
+ }
+
+ if (groups.Count < 2)
+ {
+ return false;
+ }
+
+ return FinishTwoSides(
+ ensemble,
+ groups,
+ EnsembleFrame.KingdomVsKingdom,
+ prior,
+ newcomerBonus,
+ displayFromKey: KingdomDisplay);
+ }
+
+ private static bool TryPartitionBySpecies(
+ List fighters,
+ LiveEnsemble ensemble,
+ ICollection prior,
+ float newcomerBonus)
+ {
+ var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < fighters.Count; i++)
+ {
+ Actor a = fighters[i];
+ string key = SpeciesKeyOf(a);
+ if (string.IsNullOrEmpty(key))
+ {
+ continue;
+ }
+
+ if (!groups.TryGetValue(key, out List list))
+ {
+ list = new List(4);
+ groups[key] = list;
+ }
+
+ list.Add(a);
+ }
+
+ if (groups.Count < 2)
+ {
+ return false;
+ }
+
+ return FinishTwoSides(
+ ensemble,
+ groups,
+ EnsembleFrame.SpeciesVsSpecies,
+ prior,
+ newcomerBonus,
+ displayFromKey: SpeciesDisplay);
+ }
+
+ private static bool TryPartitionByAttackCamps(
+ List fighters,
+ LiveEnsemble ensemble,
+ ICollection prior,
+ float newcomerBonus)
+ {
+ Actor focus = ensemble.Focus;
+ Actor foe = AttackTargetOf(focus);
+ if (foe == null || !foe.isAlive())
+ {
+ return false;
+ }
+
+ var sideA = new List { focus };
+ var sideB = new List { foe };
+ for (int i = 0; i < fighters.Count; i++)
+ {
+ Actor a = fighters[i];
+ if (a == null || a == focus || a == foe || !a.isAlive())
+ {
+ continue;
+ }
+
+ Actor target = AttackTargetOf(a);
+ if (target == foe || target == null && SameCampHint(a, focus))
+ {
+ sideA.Add(a);
+ }
+ else if (target == focus || SameCampHint(a, foe))
+ {
+ sideB.Add(a);
+ }
+ else if (AttackTargetOf(foe) == a)
+ {
+ sideB.Add(a);
+ }
+ else
+ {
+ // Default: share focus's kingdom/species if any, else side A.
+ if (!string.IsNullOrEmpty(KingdomKeyOf(focus))
+ && string.Equals(KingdomKeyOf(a), KingdomKeyOf(focus), StringComparison.OrdinalIgnoreCase))
+ {
+ sideA.Add(a);
+ }
+ else if (!string.IsNullOrEmpty(SpeciesKeyOf(focus))
+ && string.Equals(SpeciesKeyOf(a), SpeciesKeyOf(focus), StringComparison.OrdinalIgnoreCase))
+ {
+ sideA.Add(a);
+ }
+ else
+ {
+ sideB.Add(a);
+ }
+ }
+ }
+
+ if (sideA.Count == 0 || sideB.Count == 0)
+ {
+ 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);
+ PreferFocusSide(ensemble);
+ ensemble.Related = ensemble.SideB?.Best ?? foe;
+ return true;
+ }
+
+ private static bool SameCampHint(Actor a, Actor camp)
+ {
+ if (a == null || camp == null)
+ {
+ return false;
+ }
+
+ string ka = KingdomKeyOf(a);
+ string kc = KingdomKeyOf(camp);
+ if (!string.IsNullOrEmpty(ka) && string.Equals(ka, kc, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ string sa = SpeciesKeyOf(a);
+ string sc = SpeciesKeyOf(camp);
+ return !string.IsNullOrEmpty(sa) && string.Equals(sa, sc, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool FinishTwoSides(
+ LiveEnsemble ensemble,
+ Dictionary> groups,
+ EnsembleFrame frame,
+ ICollection prior,
+ float newcomerBonus,
+ Func displayFromKey)
+ {
+ string keyA = null;
+ string keyB = null;
+ int countA = -1;
+ int countB = -1;
+ foreach (KeyValuePair> kv in groups)
+ {
+ int n = kv.Value?.Count ?? 0;
+ if (n > countA)
+ {
+ keyB = keyA;
+ countB = countA;
+ keyA = kv.Key;
+ countA = n;
+ }
+ else if (n > countB)
+ {
+ keyB = kv.Key;
+ countB = n;
+ }
+ }
+
+ if (string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB))
+ {
+ return false;
+ }
+
+ ensemble.Frame = frame;
+ ensemble.SideA = BuildSide(keyA, displayFromKey(keyA), groups[keyA], prior, newcomerBonus, frame);
+ ensemble.SideB = BuildSide(keyB, displayFromKey(keyB), groups[keyB], prior, newcomerBonus, frame);
+ PreferFocusSide(ensemble);
+ ensemble.Related = ensemble.SideB?.Best;
+ if (ensemble.Related == null)
+ {
+ ensemble.Related = AttackTargetOf(ensemble.Focus);
+ }
+
+ return ensemble.SideA != null && ensemble.SideB != null;
+ }
+
+ private static void PreferFocusSide(LiveEnsemble ensemble)
+ {
+ if (ensemble?.Focus == null || ensemble.SideA == null || ensemble.SideB == null)
+ {
+ return;
+ }
+
+ bool onA = ActorMatchesSide(ensemble.Focus, ensemble.SideA);
+ bool onB = ActorMatchesSide(ensemble.Focus, ensemble.SideB);
+ if (onB && !onA)
+ {
+ EnsembleSide tmp = ensemble.SideA;
+ ensemble.SideA = ensemble.SideB;
+ ensemble.SideB = tmp;
+ }
+ }
+
+ private static bool ActorMatchesSide(Actor actor, EnsembleSide side)
+ {
+ if (actor == null || side == null || string.IsNullOrEmpty(side.Key))
+ {
+ return false;
+ }
+
+ if (side.Key.StartsWith("camp:", StringComparison.OrdinalIgnoreCase))
+ {
+ return side.Best == actor;
+ }
+
+ string kingdom = KingdomKeyOf(actor);
+ if (!string.IsNullOrEmpty(kingdom)
+ && kingdom.Equals(side.Key, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ string species = SpeciesKeyOf(actor);
+ return !string.IsNullOrEmpty(species)
+ && species.Equals(side.Key, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static EnsembleSide BuildSide(
+ string key,
+ string display,
+ List members,
+ ICollection prior,
+ float newcomerBonus,
+ EnsembleFrame frame)
+ {
+ string pretty = string.IsNullOrEmpty(display) ? (key ?? "") : display;
+ if (frame == EnsembleFrame.KingdomVsKingdom)
+ {
+ pretty = KingdomDisplay(pretty);
+ }
+ else if (frame == EnsembleFrame.SpeciesVsSpecies)
+ {
+ pretty = SpeciesDisplay(key);
+ }
+
+ var side = new EnsembleSide
+ {
+ Key = key ?? "",
+ Display = pretty,
+ Count = members?.Count ?? 0
+ };
+ if (members == null)
+ {
+ return side;
+ }
+
+ float bestW = -1f;
+ for (int i = 0; i < members.Count; i++)
+ {
+ Actor a = members[i];
+ if (a == null || !a.isAlive())
+ {
+ continue;
+ }
+
+ if (InterestScoring.IsNotable(a))
+ {
+ side.NotableCount++;
+ }
+
+ float w = FocusWeight(a, prior, newcomerBonus);
+ if (w > bestW)
+ {
+ bestW = w;
+ side.Best = a;
+ }
+ }
+
+ if (frame == EnsembleFrame.SpeciesVsSpecies && side.Best != null)
+ {
+ string kingdom = KingdomKeyOf(side.Best);
+ if (!string.IsNullOrEmpty(kingdom))
+ {
+ side.KingdomDisplay = KingdomDisplay(kingdom);
+ }
+ }
+
+ return side;
+ }
+
+ private static EnsembleSide SideFromActor(Actor actor, string fallbackKey)
+ {
+ if (actor == null)
+ {
+ return null;
+ }
+
+ string kingdom = KingdomKeyOf(actor);
+ string species = SpeciesKeyOf(actor);
+ return new EnsembleSide
+ {
+ Key = !string.IsNullOrEmpty(kingdom) ? kingdom
+ : (!string.IsNullOrEmpty(species) ? species : fallbackKey),
+ Display = !string.IsNullOrEmpty(kingdom)
+ ? KingdomDisplay(kingdom)
+ : (!string.IsNullOrEmpty(species) ? SpeciesDisplay(species) : EventFeedUtil.SafeName(actor)),
+ KingdomDisplay = !string.IsNullOrEmpty(kingdom) ? KingdomDisplay(kingdom) : "",
+ Count = 1,
+ NotableCount = InterestScoring.IsNotable(actor) ? 1 : 0,
+ Best = actor
+ };
+ }
+
+ private static Actor AttackTargetOf(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;
+ }
+}
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index b5d7d7c..fa940bc 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -1057,7 +1057,7 @@ internal static class HarnessScenarios
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
- Step("cf21", "assert", expect: "tip_contains", value: "fighting"),
+ Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf22", "interest_clear_follow", asset: "wolf"),
Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"),
@@ -1065,13 +1065,25 @@ internal static class HarnessScenarios
Step("cf25b", "assert", expect: "dossier_matches_focus"),
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
- // Cold combat must not keep "is fighting".
+ // 1v1 → multi: sided species framing + participant write-back.
+ 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("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"),
+ Step("cf46", "assert", expect: "dossier_matches_focus"),
+ Step("cf47", "assert", expect: "has_focus", value: "true"),
+
+ // Cold combat must not keep fight / ensemble tips.
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
- Step("cf31", "assert", expect: "tip_contains", value: "fighting"),
+ Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf32", "interest_release_force"),
Step("cf33", "interest_mark_inactive", value: "1"),
Step("cf34", "combat_maintain_focus"),
- Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"),
+ Step("cf35", "assert", expect: "tip_not_contains", value: " vs "),
+ Step("cf35b", "assert", expect: "tip_not_contains", value: "Duel"),
+ Step("cf35c", "assert", expect: "tip_not_contains", value: "fighting"),
Step("cf90", "fast_timing", value: "false"),
Step("cf99", "snapshot"),
diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs
index 29610db..d5e5784 100644
--- a/IdleSpectator/InterestDirector.cs
+++ b/IdleSpectator/InterestDirector.cs
@@ -45,7 +45,9 @@ public static class InterestDirector
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
- private const float CombatFocusThrottleSeconds = 0.4f;
+ private const float CombatFocusThrottleSeconds = 1.25f;
+ /// Require this much extra focus weight before stealing the camera mid-fight.
+ private const float CombatFocusSwitchMargin = 12f;
private static readonly List PendingScratch = new List(96);
public static string CurrentTierName => CurrentScoreLabel;
@@ -506,8 +508,9 @@ public static class InterestDirector
}
///
- /// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
- /// so the subject always matches focus. Clears fight Labels once combat goes cold.
+ /// Keep camera on the highest-scored combat participant and rewrite tip/counts
+ /// from a live so subject + scale always match focus.
+ /// Clears fight Labels once combat goes cold.
///
private static void MaintainCombatFocus(float now, bool force)
{
@@ -529,18 +532,188 @@ public static class InterestDirector
}
_lastCombatFocusAt = now;
- if (!WorldActivityScanner.TryPickBestCombatFocus(
- _current, out Actor best, out Actor foe, out int fighters))
+ ApplyCombatEnsembleToCurrent(forceWatch: false);
+ }
+
+ ///
+ /// Refresh active combat scene from the world ensemble. Returns false if no focus.
+ ///
+ private static bool ApplyCombatEnsembleToCurrent(bool forceWatch)
+ {
+ if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
- return;
+ return false;
}
- bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
- || fighters > 2
- || _current.ParticipantCount > 2;
- string label = mass
- ? EventReason.Battle(best, fighters)
- : EventReason.Fight(best, foe);
+ Vector3 pos = _current.Position;
+ if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
+ {
+ pos = _current.FollowUnit.current_position;
+ }
+ else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
+ {
+ pos = _current.RelatedUnit.current_position;
+ }
+
+ bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
+ || _current.ParticipantCount > 2;
+ float radius = massHint ? 14f : 10f;
+
+ // Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
+ if (!_current.HasFollowUnit
+ && _current.RelatedUnit != null
+ && _current.RelatedUnit.isAlive())
+ {
+ Actor handoff = _current.RelatedUnit;
+ Actor handoffFoe = null;
+ try
+ {
+ if (handoff.has_attack_target
+ && handoff.attack_target != null
+ && handoff.attack_target.isAlive()
+ && handoff.attack_target.isActor())
+ {
+ handoffFoe = handoff.attack_target.a;
+ if (handoffFoe != null && !handoffFoe.isAlive())
+ {
+ handoffFoe = null;
+ }
+ }
+ }
+ catch
+ {
+ handoffFoe = null;
+ }
+
+ LiveEnsemble.TryBuildCombat(
+ handoff.current_position,
+ radius,
+ out LiveEnsemble handoffEnsemble,
+ priorParticipantIds: _current.ParticipantIds,
+ newcomerBonus: 8f);
+ if (handoffEnsemble != null)
+ {
+ ApplyEnsembleFields(_current, handoffEnsemble);
+ }
+
+ // 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.Focus = handoff;
+ if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
+ {
+ handoffEnsemble.Related = handoffFoe;
+ }
+
+ handoffLabel = EventReason.Combat(handoffEnsemble);
+ }
+ else
+ {
+ handoffLabel = EventReason.Fight(handoff, handoffFoe);
+ _current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount);
+ }
+
+ bool handoffFollowChanged = _current.FollowUnit != handoff;
+ bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal);
+ _current.FollowUnit = handoff;
+ _current.SubjectId = EventFeedUtil.SafeId(handoff);
+ _current.RelatedUnit = handoffFoe;
+ _current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
+ _current.Label = handoffLabel;
+ try
+ {
+ _current.Position = handoff.current_position;
+ }
+ catch
+ {
+ // keep
+ }
+
+ if (forceWatch
+ || handoffFollowChanged
+ || handoffLabelChanged
+ || !HasLivingCameraFocus()
+ || MoveCamera._focus_unit != handoff)
+ {
+ CameraDirector.Watch(_current.ToInterestEvent());
+ }
+
+ return true;
+ }
+
+ LiveEnsemble.TryBuildCombat(
+ pos,
+ radius,
+ out LiveEnsemble ensemble,
+ priorParticipantIds: _current.ParticipantIds,
+ newcomerBonus: 8f);
+
+ Actor best;
+ Actor foe;
+ int fighters;
+ string label;
+ if (ensemble != null && ensemble.HasFocus)
+ {
+ best = ensemble.Focus;
+ foe = ensemble.Related;
+ fighters = Mathf.Max(1, ensemble.ParticipantCount);
+ label = EventReason.Combat(ensemble);
+ ApplyEnsembleFields(_current, ensemble);
+ }
+ else if (WorldActivityScanner.TryPickBestCombatFocus(
+ _current, out best, out foe, out fighters))
+ {
+ var fallback = new LiveEnsemble
+ {
+ Kind = EnsembleKind.Combat,
+ Scale = LiveEnsemble.ScaleForCount(fighters),
+ Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
+ ParticipantCount = fighters,
+ Focus = best,
+ Related = foe
+ };
+ label = EventReason.Combat(fallback);
+ _current.ParticipantCount = fighters;
+ }
+ 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)
+ {
+ float curW = LiveEnsemble.FocusWeight(
+ _current.FollowUnit, _current.ParticipantIds, newcomerBonus: 0f);
+ float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
+ if (newW < curW + CombatFocusSwitchMargin)
+ {
+ best = _current.FollowUnit;
+ 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
+ }
+ }
+ }
+ }
bool followChanged = _current.FollowUnit != best;
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
@@ -558,13 +731,133 @@ public static class InterestDirector
// keep prior position
}
- if (followChanged
+ if (forceWatch
+ || followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
+
+ return true;
+ }
+
+ private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
+ {
+ if (scene == null || ensemble == null)
+ {
+ return;
+ }
+
+ scene.ParticipantCount = Mathf.Max(1, ensemble.ParticipantCount);
+ scene.NotableParticipantCount = ensemble.NotableParticipantCount;
+ WorldActivityScanner.StampParticipantIds(scene, ensemble);
+ if (ensemble.Scale >= EnsembleScale.Skirmish)
+ {
+ scene.AssetId = "live_battle";
+ }
+
+ if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
+ && ensemble.SideA != null
+ && !string.IsNullOrEmpty(ensemble.SideA.Key))
+ {
+ scene.KingdomKey = ensemble.SideA.Key;
+ }
+
+ if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies
+ && ensemble.SideA != null
+ && !string.IsNullOrEmpty(ensemble.SideA.Key))
+ {
+ scene.SpeciesId = ensemble.SideA.Key;
+ }
+ }
+
+ ///
+ /// If the active combat scene already covers this battle anchor, refresh it in place
+ /// instead of registering a second live_battle candidate.
+ ///
+ public static bool TryAbsorbCombatBattle(InterestEvent battle)
+ {
+ if (battle == null
+ || _current == null
+ || _current.Completion != InterestCompletionKind.CombatActive)
+ {
+ return false;
+ }
+
+ Vector3 anchor = battle.Position;
+ Vector3 cur = _current.Position;
+ try
+ {
+ if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
+ {
+ cur = _current.FollowUnit.current_position;
+ }
+ }
+ catch
+ {
+ // keep
+ }
+
+ float dx = anchor.x - cur.x;
+ float dy = anchor.y - cur.y;
+ float mergeR = 16f;
+ bool near = dx * dx + dy * dy <= mergeR * mergeR;
+ bool shared = false;
+ if (!near && battle.FollowUnit != null)
+ {
+ long id = EventFeedUtil.SafeId(battle.FollowUnit);
+ shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id);
+ }
+
+ if (!near && !shared)
+ {
+ return false;
+ }
+
+ if (battle.ParticipantCount > _current.ParticipantCount)
+ {
+ _current.ParticipantCount = battle.ParticipantCount;
+ }
+
+ if (battle.NotableParticipantCount > _current.NotableParticipantCount)
+ {
+ _current.NotableParticipantCount = battle.NotableParticipantCount;
+ }
+
+ ApplyCombatEnsembleToCurrent(forceWatch: true);
+ return true;
+ }
+
+ /// Harness: apply a synthetic combat ensemble onto the current scene.
+ public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble)
+ {
+ if (_current == null
+ || ensemble == null
+ || !ensemble.HasFocus
+ || _current.Completion != InterestCompletionKind.CombatActive)
+ {
+ return false;
+ }
+
+ ApplyEnsembleFields(_current, ensemble);
+ _current.FollowUnit = ensemble.Focus;
+ _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
+ _current.RelatedUnit = ensemble.Related;
+ _current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
+ _current.Label = EventReason.Combat(ensemble);
+ try
+ {
+ _current.Position = ensemble.Focus.current_position;
+ }
+ catch
+ {
+ // keep
+ }
+
+ CameraDirector.Watch(_current.ToInterestEvent());
+ return true;
}
private static void ClearStaleCombatLabel(float now)
@@ -574,8 +867,16 @@ public static class InterestDirector
return;
}
- if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
- && _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
+ string label = _current.Label;
+ if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0
+ && label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs
index 0b500c7..899ee6c 100644
--- a/IdleSpectator/InterestRegistry.cs
+++ b/IdleSpectator/InterestRegistry.cs
@@ -131,6 +131,16 @@ public static class InterestRegistry
existing.RelatedId = incoming.RelatedId;
}
+ if (incoming.ParticipantCount > existing.ParticipantCount)
+ {
+ existing.ParticipantCount = incoming.ParticipantCount;
+ }
+
+ if (incoming.NotableParticipantCount > existing.NotableParticipantCount)
+ {
+ existing.NotableParticipantCount = incoming.NotableParticipantCount;
+ }
+
if (incoming.ForceActive)
{
existing.ForceActive = true;
diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs
index 1b642c9..7275c7c 100644
--- a/IdleSpectator/InterestScoringConfig.cs
+++ b/IdleSpectator/InterestScoringConfig.cs
@@ -52,6 +52,8 @@ public class ScoringWeights
public float massBaseBonus = 28f;
public float massPerExtraFighter = 4f;
public float massExtraCap = 24f;
+ /// Fighter count at which combat ensembles use Mass scale framing.
+ public int massCrowdThreshold = 8;
public int duelMaxFighters = 2;
public float duelNotablePairBonus = 55f;
public float duelSingleNotableBonus = 15f;
diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs
index df8921f..29821d8 100644
--- a/IdleSpectator/UnitDossier.cs
+++ b/IdleSpectator/UnitDossier.cs
@@ -348,12 +348,16 @@ public sealed class UnitDossier
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
- if (recordDrops)
+ // Tiered combat tips name sides / duelists on purpose.
+ if (!IsEnsembleCombatReason(beat))
{
- InterestDropLog.Record("identity_filter", "stranger:" + beat);
- }
+ if (recordDrops)
+ {
+ InterestDropLog.Record("identity_filter", "stranger:" + beat);
+ }
- return "";
+ return "";
+ }
}
// Ownership: reject beats that lead with someone else's proper name.
@@ -472,6 +476,9 @@ public sealed class UnitDossier
case "fighting":
case "war":
case "battle":
+ case "duel":
+ case "skirmish":
+ case "mass":
case "clash":
case "grief":
case "mourning":
@@ -501,6 +508,20 @@ public sealed class UnitDossier
}
}
+ private static bool IsEnsembleCombatReason(string reason)
+ {
+ if (string.IsNullOrEmpty(reason))
+ {
+ return false;
+ }
+
+ return reason.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
+ || reason.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
+ || reason.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
+ || reason.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
+ || reason.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
@@ -768,6 +789,11 @@ public sealed class UnitDossier
|| t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.IndexOf(" vs ", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || t.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("Skirmish", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
+ || t.StartsWith("Mass", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|| t.IndexOf(':') >= 0;
@@ -998,7 +1024,7 @@ public sealed class UnitDossier
sb.Append(" · ");
}
- sb.Append(d.KingdomName);
+ sb.Append(LiveEnsemble.KingdomDisplay(d.KingdomName));
}
else if (!string.IsNullOrEmpty(d.CityName)
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs
index fc765eb..6a52bf2 100644
--- a/IdleSpectator/WorldActivityScanner.cs
+++ b/IdleSpectator/WorldActivityScanner.cs
@@ -121,14 +121,24 @@ public static class WorldActivityScanner
}
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
- CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
- fighters = Mathf.Max(1, fighters);
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
- string label = EventReason.Fight(follow, related);
+ LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
+ int fighters = ensemble != null ? ensemble.ParticipantCount : 1;
+ int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
+ fighters = Mathf.Max(1, fighters);
+ if (ensemble != null && ensemble.HasFocus)
+ {
+ follow = ensemble.Focus;
+ related = ensemble.Related ?? related;
+ }
+
+ string label = ensemble != null
+ ? EventReason.Combat(ensemble)
+ : EventReason.Fight(follow, related);
var candidate = new InterestCandidate
{
- Key = EventCatalog.Combat.Key(id),
+ Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id),
LeadKind = InterestLeadKind.EventLed,
Category = "Combat",
Source = "scanner",
@@ -155,6 +165,21 @@ public static class WorldActivityScanner
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
+ if (ensemble != null)
+ {
+ StampParticipantIds(candidate, ensemble);
+ if (ensemble.Scale >= EnsembleScale.Skirmish)
+ {
+ candidate.AssetId = "live_battle";
+ }
+
+ if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)
+ && ensemble.Frame == EnsembleFrame.KingdomVsKingdom)
+ {
+ candidate.KingdomKey = ensemble.SideA.Key;
+ }
+ }
+
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
@@ -230,7 +255,10 @@ public static class WorldActivityScanner
int deaths = battle.getDeathsTotal();
Vector3 pos = battle.tile.posV3;
- CountFightCluster(pos, 14f, out int fighters, out int notables, out Actor follow);
+ LiveEnsemble.TryBuildCombat(pos, 14f, out LiveEnsemble ensemble);
+ int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
+ int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
+ Actor follow = ensemble != null ? ensemble.Focus : null;
if (deaths < 1 && fighters < 2)
{
continue;
@@ -241,12 +269,16 @@ public static class WorldActivityScanner
if (score > bestScore)
{
bestScore = score;
- string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
+ Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
+ string label = ensemble != null && ensemble.HasFocus
+ ? EventReason.Combat(ensemble)
+ : EventReason.Battle(focus, fighters);
best = new InterestEvent
{
Score = score,
Position = pos,
- FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
+ FollowUnit = focus,
+ RelatedUnit = ensemble != null ? ensemble.Related : null,
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
@@ -289,7 +321,10 @@ public static class WorldActivityScanner
continue;
}
- CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out Actor follow);
+ LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
+ int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
+ int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
+ Actor follow = ensemble != null ? ensemble.Focus : null;
if (fighters < 1)
{
continue;
@@ -302,12 +337,16 @@ public static class WorldActivityScanner
{
bestScore = score;
Actor focus = follow ?? actor;
+ string label = ensemble != null && ensemble.HasFocus
+ ? EventReason.Combat(ensemble)
+ : EventReason.Battle(focus, fighters);
best = new InterestEvent
{
Score = score,
Position = actor.current_position,
FollowUnit = focus,
- Label = EventReason.Battle(focus, fighters),
+ RelatedUnit = ensemble != null ? ensemble.Related : null,
+ Label = label,
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
ParticipantCount = fighters,
@@ -370,7 +409,7 @@ public static class WorldActivityScanner
///
/// Highest-scored living participant for an active combat scene.
- /// Considers Follow, Related, and (for mass fights) the nearby fight cluster.
+ /// Considers Follow, Related, and (for mass fights) the nearby fight ensemble.
///
public static bool TryPickBestCombatFocus(
InterestCandidate scene,
@@ -386,6 +425,35 @@ public static class WorldActivityScanner
return false;
}
+ LiveEnsemble ensemble = null;
+ Vector3 pos = scene.Position;
+ if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
+ {
+ pos = scene.FollowUnit.current_position;
+ }
+ else if (scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
+ {
+ pos = scene.RelatedUnit.current_position;
+ }
+
+ bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
+ || scene.ParticipantCount > 2;
+ float radius = mass ? 14f : 10f;
+ LiveEnsemble.TryBuildCombat(
+ pos,
+ radius,
+ out ensemble,
+ priorParticipantIds: scene.ParticipantIds,
+ newcomerBonus: 8f);
+
+ if (ensemble != null && ensemble.HasFocus)
+ {
+ best = ensemble.Focus;
+ foe = ensemble.Related;
+ fighters = Mathf.Max(1, ensemble.ParticipantCount);
+ return true;
+ }
+
fighters = Mathf.Max(1, scene.ParticipantCount);
float bestWeight = -1f;
Actor bestLocal = null;
@@ -397,7 +465,7 @@ public static class WorldActivityScanner
return;
}
- float w = CombatFocusWeight(unit);
+ float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
if (w > bestWeight)
{
bestWeight = w;
@@ -407,37 +475,13 @@ public static class WorldActivityScanner
Consider(scene.FollowUnit);
Consider(scene.RelatedUnit);
-
- bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
- || scene.ParticipantCount > 2;
- Vector3 pos = scene.Position;
- if (bestLocal != null)
- {
- pos = bestLocal.current_position;
- }
- else if (scene.FollowUnit != null)
- {
- pos = scene.FollowUnit.current_position;
- }
- else if (scene.RelatedUnit != null)
- {
- pos = scene.RelatedUnit.current_position;
- }
-
- if (mass || bestLocal == null)
- {
- float radius = mass ? 14f : 10f;
- CountFightCluster(pos, radius, out int clusterFighters, out _, out Actor clusterBest);
- fighters = Mathf.Max(fighters, clusterFighters);
- Consider(clusterBest);
- }
-
best = bestLocal;
if (best == null)
{
return false;
}
+ foe = null;
try
{
if (best.has_attack_target
@@ -474,6 +518,39 @@ public static class WorldActivityScanner
return true;
}
+ /// Build a combat ensemble snapshot at a world position.
+ public static bool TryBuildCombatEnsemble(
+ Vector2 pos,
+ float radius,
+ out LiveEnsemble ensemble,
+ InterestCandidate prior = null)
+ {
+ return LiveEnsemble.TryBuildCombat(
+ pos,
+ radius,
+ out ensemble,
+ priorParticipantIds: prior?.ParticipantIds,
+ newcomerBonus: 8f);
+ }
+
+ public static void StampParticipantIds(InterestCandidate candidate, LiveEnsemble ensemble)
+ {
+ if (candidate == null || ensemble == null)
+ {
+ return;
+ }
+
+ candidate.ParticipantIds.Clear();
+ for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
+ {
+ long id = ensemble.ParticipantIds[i];
+ if (id != 0 && !candidate.ParticipantIds.Contains(id))
+ {
+ candidate.ParticipantIds.Add(id);
+ }
+ }
+ }
+
private static float ScoreFightCluster(int deaths, int fighters, int notables)
{
ScoringWeights w = InterestScoringConfig.W;
@@ -512,54 +589,14 @@ public static class WorldActivityScanner
fighters = 0;
notables = 0;
bestFollow = null;
- float bestChar = -1f;
- float r2 = radius * radius;
- foreach (Actor actor in EnumerateAliveUnits())
+ if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
{
- if (actor == null || !actor.isAlive())
- {
- continue;
- }
-
- float dx = actor.current_position.x - pos.x;
- float dy = actor.current_position.y - pos.y;
- if (dx * dx + dy * dy > r2)
- {
- continue;
- }
-
- bool fighting = false;
- try
- {
- fighting = actor.has_attack_target
- || (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat);
- }
- catch
- {
- fighting = false;
- }
-
- if (!fighting)
- {
- continue;
- }
-
- fighters++;
- bool notable = InterestScoring.IsNotable(actor);
- bool extreme = InterestScoring.IsExtremelyNotable(actor);
- if (notable)
- {
- notables++;
- }
-
- SplitActorScore(actor, null, out _, out float charScore);
- float weight = charScore + (extreme ? 30f : 0f);
- if (weight > bestChar)
- {
- bestChar = weight;
- bestFollow = actor;
- }
+ return;
}
+
+ fighters = ensemble.ParticipantCount;
+ notables = ensemble.NotableParticipantCount;
+ bestFollow = ensemble.Focus;
}
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet seen)
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index 8033faa..390271e 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.25.52",
- "description": "AFK Idle Spectator (I) + Lore (L). Dossier nametag tracks focus on handoff.",
+ "version": "0.25.54",
+ "description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.",
"GUID": "com.dazed.idlespectator"
}
diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json
index bef2ca4..acfbf3f 100644
--- a/IdleSpectator/scoring-model.json
+++ b/IdleSpectator/scoring-model.json
@@ -32,6 +32,7 @@
"massBaseBonus": 28,
"massPerExtraFighter": 4,
"massExtraCap": 24,
+ "massCrowdThreshold": 8,
"duelMaxFighters": 2,
"duelNotablePairBonus": 55,
"duelSingleNotableBonus": 15,
diff --git a/docs/event-audit.md b/docs/event-audit.md
index ecaba80..556134f 100644
--- a/docs/event-audit.md
+++ b/docs/event-audit.md
@@ -88,4 +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.
+
Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`).
diff --git a/docs/event-e2e.md b/docs/event-e2e.md
index 43c8322..a747bdf 100644
--- a/docs/event-e2e.md
+++ b/docs/event-e2e.md
@@ -43,6 +43,7 @@ See [`camera-presentability-plan.md`](camera-presentability-plan.md).
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
| Combat dossier nametag matches focus | `combat_focus` / `dossier_matches_focus` |
+| Combat 1v1 escalates to sided ensemble | `combat_focus` / `combat_ensemble_escalate` + `tip_matches_any` (`Battle -` / ` vs `) |
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
Live idle soak helper (Player.log window):
diff --git a/docs/event-reason.md b/docs/event-reason.md
index 071dfaf..9595c7f 100644
--- a/docs/event-reason.md
+++ b/docs/event-reason.md
@@ -28,6 +28,34 @@ Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSu
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
+## Live ensembles (escalating multi-actor)
+
+Combat is the first consumer of `LiveEnsemble` ([`Events/LiveEnsemble.cs`](../IdleSpectator/Events/LiveEnsemble.cs)).
+The director refreshes counts, sides, focus, and `EventReason.Combat` while `CombatActive` stays sticky.
+
+| Scale | Fighters | Typical reason |
+|-------|----------|----------------|
+| Pair | ≤2 | `Duel - Rosado vs Waaf` |
+| Skirmish / Battle | 3+ | `Battle - Essiona (10) vs Northreach (3)` or `Battle - humans (10) vs wolves (3)` |
+| 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.
+Names are humanized (no raw `nomads_human` ids).
+
+### Other families (same snapshot later)
+
+| Kind | Live signal | Side framing |
+|------|-------------|--------------|
+| Combat | attack_target / in_combat cluster | kingdom → species → camps |
+| WarFront | `WarInterestFeed` + nearby battles | kingdom vs kingdom |
+| StatusOutbreak | same status id in radius | species / city |
+| FamilyPack | family_group peers | family / species |
+| PlotCell | plot members / phase | kingdom / plot type |
+| DiscoveryCluster | pending first-seens near anchor | species |
+
+Shared pieces: `LiveEnsemble`, side helpers, `EventReason.Ensemble`, maintain/write-back pattern in `InterestDirector`.
+
## EventReason
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.