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; } /// /// Build a kingdom-vs-kingdom war front from a live . /// Counts use kingdom population; focus prefers living kings then any side unit. /// public static bool TryBuildWar(War war, out LiveEnsemble ensemble) { ensemble = null; if (war == null) { return false; } try { if (!war.isAlive()) { return false; } } catch { // continue - some meta objects omit isAlive } Kingdom attacker; Kingdom defender; try { attacker = war.main_attacker; defender = war.main_defender; } catch { return false; } if (attacker == null || defender == null) { return false; } string keyA = KingdomMetaKey(attacker); string keyB = KingdomMetaKey(defender); if (string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB)) { return false; } int countA = KingdomUnitCount(attacker); int countB = KingdomUnitCount(defender); Actor focus = PreferKingdomLeader(attacker) ?? PreferKingdomLeader(defender); if (focus == null) { return false; } Actor related = null; string focusKey = KingdomKeyOf(focus); if (!string.IsNullOrEmpty(focusKey) && focusKey.Equals(keyA, StringComparison.OrdinalIgnoreCase)) { related = PreferKingdomLeader(defender); } else { related = PreferKingdomLeader(attacker); } if (related == focus) { related = null; } ensemble = new LiveEnsemble { Kind = EnsembleKind.WarFront, Frame = EnsembleFrame.KingdomVsKingdom, Scale = ScaleForCount(Math.Max(3, countA + countB)), ParticipantCount = Math.Max(0, countA) + Math.Max(0, countB), Focus = focus, Related = related, SideA = new EnsembleSide { Key = keyA, Display = KingdomDisplay(keyA), KingdomDisplay = KingdomDisplay(keyA), Count = Math.Max(0, countA), Best = PreferKingdomLeader(attacker) ?? focus }, SideB = new EnsembleSide { Key = keyB, Display = KingdomDisplay(keyB), KingdomDisplay = KingdomDisplay(keyB), Count = Math.Max(0, countB), Best = PreferKingdomLeader(defender) ?? related } }; try { ensemble.Anchor = focus.current_position; } catch { // keep } long idFocus = EventFeedUtil.SafeId(focus); if (idFocus != 0) { ensemble.ParticipantIds.Add(idFocus); } long idRelated = related != null ? EventFeedUtil.SafeId(related) : 0; if (idRelated != 0) { ensemble.ParticipantIds.Add(idRelated); } return ensemble.HasFocus; } /// /// Build a plotters-vs-target-kingdom cell from a live . /// Requires an active plot with units and a resolvable target kingdom (or city→kingdom). /// Side A is a collective plotter camp (never author proper names). /// public static bool TryBuildPlot(Plot plot, out LiveEnsemble ensemble) { ensemble = null; if (plot == null) { return false; } try { if (!plot.isActive()) { return false; } } catch { // continue - treat as active when isActive is unavailable } string assetId = ""; try { PlotAsset asset = plot.getAsset(); if (asset != null && !string.IsNullOrEmpty(asset.id)) { assetId = asset.id; } } catch { assetId = ""; } if (string.IsNullOrEmpty(assetId)) { assetId = "plot"; } Kingdom targetKingdom = null; try { targetKingdom = plot.target_kingdom; } catch { targetKingdom = null; } if (targetKingdom == null) { try { City city = plot.target_city; if (city != null) { targetKingdom = city.kingdom; } } catch { targetKingdom = null; } } if (targetKingdom == null) { try { Actor targetActor = plot.target_actor; if (targetActor != null && targetActor.isAlive()) { string tk = KingdomKeyOf(targetActor); if (!string.IsNullOrEmpty(tk)) { targetKingdom = FindKingdomByKey(tk); } } } catch { // ignore } } string keyB = KingdomMetaKey(targetKingdom); if (string.IsNullOrEmpty(keyB)) { return false; } string keyA = "plot:" + assetId; var plotters = new List(8); CollectPlotUnits(plot, plotters); Actor author = null; try { author = plot.getAuthor(); } catch { author = null; } if (author != null && author.isAlive() && !plotters.Contains(author)) { plotters.Add(author); } // PlotCell sticky is a conspiracy cell - solo authors use unit Plot tips instead. if (plotters.Count < 2) { return false; } Actor focus = author != null && author.isAlive() ? author : plotters[0]; Actor related = PreferKingdomLeader(targetKingdom); if (related == focus) { related = null; } int countA = plotters.Count; int countB = KingdomUnitCount(targetKingdom); ensemble = new LiveEnsemble { Kind = EnsembleKind.PlotCell, Frame = EnsembleFrame.KingdomVsKingdom, Scale = ScaleForCount(Math.Max(3, countA + Math.Min(countB, 20))), ParticipantCount = Math.Max(0, countA) + Math.Max(0, countB), Focus = focus, Related = related, SideA = new EnsembleSide { Key = keyA, Display = "Plotters", KingdomDisplay = "", Count = Math.Max(0, countA), Best = focus }, SideB = new EnsembleSide { Key = keyB, Display = KingdomDisplay(keyB), KingdomDisplay = KingdomDisplay(keyB), Count = Math.Max(0, countB), Best = related } }; try { ensemble.Anchor = focus.current_position; } catch { // keep } for (int i = 0; i < plotters.Count; i++) { long id = EventFeedUtil.SafeId(plotters[i]); if (id != 0) { ensemble.ParticipantIds.Add(id); } } long idRelated = related != null ? EventFeedUtil.SafeId(related) : 0; if (idRelated != 0) { ensemble.ParticipantIds.Add(idRelated); } return ensemble.HasFocus; } /// /// Build a species-framed family pack from a live . /// Requires at least one living member; focus prefers alpha then any member. /// public static bool TryBuildFamily(Family family, out LiveEnsemble ensemble) { ensemble = null; if (family == null) { return false; } var members = new List(8); CollectFamilyUnits(family, members); if (members.Count == 0) { return false; } string familyId = ""; try { familyId = family.getID().ToString(); } catch { familyId = ""; } if (string.IsNullOrEmpty(familyId)) { familyId = "pack"; } Actor alpha = null; try { alpha = family.getAlpha(); if (alpha != null && !alpha.isAlive()) { alpha = null; } } catch { alpha = null; } Actor focus = alpha ?? members[0]; string speciesKey = SpeciesKeyOf(focus); if (string.IsNullOrEmpty(speciesKey)) { speciesKey = "unknown"; } Actor related = null; for (int i = 0; i < members.Count; i++) { if (members[i] != null && members[i] != focus && members[i].isAlive()) { related = members[i]; break; } } int count = members.Count; ensemble = new LiveEnsemble { Kind = EnsembleKind.FamilyPack, Frame = EnsembleFrame.SpeciesVsSpecies, Scale = ScaleForCount(Math.Max(2, count)), ParticipantCount = count, Focus = focus, Related = related, SideA = new EnsembleSide { Key = "family:" + familyId, Display = SpeciesDisplay(speciesKey), KingdomDisplay = "", Count = count, Best = focus }, SideB = null }; try { ensemble.Anchor = focus.current_position; } catch { // keep } for (int i = 0; i < members.Count; i++) { long id = EventFeedUtil.SafeId(members[i]); if (id != 0) { ensemble.ParticipantIds.Add(id); } } return ensemble.HasFocus; } /// /// Build a status outbreak cluster: units near sharing . /// Requires at least 2 carriers for a sticky outbreak scene. /// public static bool TryBuildStatusOutbreak( string statusId, Vector2 pos, float radius, out LiveEnsemble ensemble, Actor preferFocus = null) { ensemble = null; if (string.IsNullOrEmpty(statusId)) { return false; } string id = statusId.Trim(); var carriers = new List(8); CollectStatusCarriers(pos, radius, id, carriers); if (preferFocus != null && preferFocus.isAlive() && HasActorStatus(preferFocus, id) && !carriers.Contains(preferFocus)) { carriers.Insert(0, preferFocus); } if (carriers.Count < 2) { return false; } Actor focus = preferFocus != null && carriers.Contains(preferFocus) ? preferFocus : carriers[0]; Actor related = null; for (int i = 0; i < carriers.Count; i++) { if (carriers[i] != null && carriers[i] != focus && carriers[i].isAlive()) { related = carriers[i]; break; } } int count = carriers.Count; ensemble = new LiveEnsemble { Kind = EnsembleKind.StatusOutbreak, Frame = EnsembleFrame.SpeciesVsSpecies, Scale = ScaleForCount(Math.Max(2, count)), ParticipantCount = count, Focus = focus, Related = related, SideA = new EnsembleSide { Key = "status:" + id, Display = StatusDisplayName(id), KingdomDisplay = "", Count = count, Best = focus }, SideB = null }; try { ensemble.Anchor = focus.current_position; } catch { ensemble.Anchor = new Vector3(pos.x, pos.y, 0f); } for (int i = 0; i < carriers.Count; i++) { long unitId = EventFeedUtil.SafeId(carriers[i]); if (unitId != 0) { ensemble.ParticipantIds.Add(unitId); } } return ensemble.HasFocus; } public static void CollectStatusCarriers( Vector2 pos, float radius, string statusId, List into) { if (into == null || string.IsNullOrEmpty(statusId)) { return; } float r2 = radius * radius; foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { if (actor == null || !actor.isAlive() || !HasActorStatus(actor, statusId)) { continue; } try { Vector3 p = actor.current_position; float dx = p.x - pos.x; float dy = p.y - pos.y; if (dx * dx + dy * dy > r2) { continue; } } catch { continue; } if (!into.Contains(actor)) { into.Add(actor); } } } public static bool HasActorStatus(Actor actor, string statusId) { if (actor == null || string.IsNullOrEmpty(statusId)) { return false; } try { return actor.hasStatus(statusId.Trim()); } catch { return false; } } public static string StatusDisplayName(string statusId) { if (string.IsNullOrEmpty(statusId)) { return "Afflicted"; } string id = statusId.Trim(); // Prefer a short title-cased asset id (cursed → Cursed). string pretty = id.Replace('_', ' ').Trim(); if (string.IsNullOrEmpty(pretty)) { return "Afflicted"; } return char.ToUpperInvariant(pretty[0]) + (pretty.Length > 1 ? pretty.Substring(1) : ""); } /// Refresh sticky outbreak counts from Side A roster still carrying the status. public static bool TryApplyStatusOutbreakStickyCounts(LiveSceneStickyState sticky, string statusId) { if (sticky == null || !sticky.HasOpposingSides || sticky.Kind != EnsembleKind.StatusOutbreak) { return false; } if (string.IsNullOrEmpty(statusId) && IsStatusOutbreakSideKey(sticky.SideAKey)) { statusId = sticky.SideAKey.Substring("status:".Length); } int alive = 0; for (int i = sticky.SideAIds.Count - 1; i >= 0; i--) { Actor unit = FindTrackedActor(sticky.SideAIds[i]); if (unit == null || !unit.isAlive() || !HasActorStatus(unit, statusId)) { sticky.SideAIds.RemoveAt(i); continue; } alive++; } sticky.SideACount = Math.Max(0, alive); sticky.SideBCount = 0; sticky.SideBKey = ""; sticky.SideBDisplay = ""; if (string.IsNullOrEmpty(sticky.SideADisplay) || IsStatusOutbreakSideKey(sticky.SideADisplay)) { sticky.SideADisplay = StatusDisplayName(statusId); } return alive > 0; } /// Collect living members of into . public static void CollectFamilyUnits(Family family, List into) { if (family == null || into == null) { return; } try { foreach (Actor unit in family.getUnits()) { if (unit != null && unit.isAlive() && !into.Contains(unit)) { into.Add(unit); } } } catch { try { foreach (Actor unit in family.units) { if (unit != null && unit.isAlive() && !into.Contains(unit)) { into.Add(unit); } } } catch { // ignore } } } /// Refresh sticky family pack counts from the Side A roster. public static bool TryApplyFamilyStickyCounts(LiveSceneStickyState sticky) { if (sticky == null || !sticky.HasOpposingSides || sticky.Kind != EnsembleKind.FamilyPack) { return false; } int alive = 0; Actor best = null; for (int i = sticky.SideAIds.Count - 1; i >= 0; i--) { Actor unit = FindTrackedActor(sticky.SideAIds[i]); if (unit == null || !unit.isAlive()) { sticky.SideAIds.RemoveAt(i); continue; } alive++; if (best == null) { best = unit; } try { if (unit.hasFamily() && unit.family != null) { Actor alpha = unit.family.getAlpha(); if (alpha != null && alpha.isAlive() && alpha == unit) { best = unit; } } } catch { // keep } } sticky.SideACount = Math.Max(0, alive); sticky.SideBCount = 0; sticky.SideBKey = ""; sticky.SideBDisplay = ""; if (string.IsNullOrEmpty(sticky.SideADisplay) || IsFamilyPackSideKey(sticky.SideADisplay)) { if (best != null) { sticky.SideADisplay = SpeciesDisplay(SpeciesKeyOf(best)); } } return alive > 0; } /// Collect living members of into . public static void CollectPlotUnits(Plot plot, List into) { if (plot == null || into == null) { return; } try { foreach (Actor unit in plot.getUnits()) { if (unit != null && unit.isAlive() && !into.Contains(unit)) { into.Add(unit); } } } catch { try { foreach (Actor unit in plot.units) { if (unit != null && unit.isAlive() && !into.Contains(unit)) { into.Add(unit); } } } catch { // ignore } } } /// /// Refresh sticky plot counts: Side A = alive plotter roster; Side B = target kingdom pop. /// public static bool TryApplyPlotStickyCounts(LiveSceneStickyState sticky) { if (sticky == null || !sticky.HasOpposingSides || sticky.Kind != EnsembleKind.PlotCell) { return false; } int aliveA = 0; for (int i = sticky.SideAIds.Count - 1; i >= 0; i--) { Actor unit = FindTrackedActor(sticky.SideAIds[i]); if (unit == null || !unit.isAlive()) { sticky.SideAIds.RemoveAt(i); continue; } aliveA++; } sticky.SideACount = Math.Max(0, aliveA); if (string.IsNullOrEmpty(sticky.SideADisplay)) { sticky.SideADisplay = "Plotters"; } Kingdom b = FindKingdomByKey(sticky.SideBKey); if (b != null) { sticky.SideBCount = Math.Max(0, KingdomUnitCount(b)); sticky.SideBDisplay = KingdomDisplay(sticky.SideBKey); sticky.SideBKingdom = sticky.SideBDisplay; } return true; } public static Actor FindTrackedActor(long id) => EventFeedUtil.FindAliveById(id); /// True when Side A is a synthetic plotter camp key. public static bool IsPlotterSideKey(string key) => !string.IsNullOrEmpty(key) && key.StartsWith("plot:", StringComparison.OrdinalIgnoreCase); /// Re-read kingdom population onto sticky sides when keys still resolve. public static bool TryApplyKingdomPopulationCounts(LiveSceneStickyState sticky) { if (sticky == null || !sticky.HasOpposingSides || sticky.Frame != EnsembleFrame.KingdomVsKingdom) { return false; } Kingdom a = FindKingdomByKey(sticky.SideAKey); Kingdom b = FindKingdomByKey(sticky.SideBKey); if (a == null && b == null) { return false; } if (a != null && !IsPlotterSideKey(sticky.SideAKey)) { sticky.SideACount = Math.Max(0, KingdomUnitCount(a)); sticky.SideADisplay = KingdomDisplay(sticky.SideAKey); } if (b != null && !IsPlotterSideKey(sticky.SideBKey)) { sticky.SideBCount = Math.Max(0, KingdomUnitCount(b)); sticky.SideBDisplay = KingdomDisplay(sticky.SideBKey); } return true; } public static string KingdomMetaKey(Kingdom kingdom) { if (kingdom == null) { return ""; } try { string name = kingdom.name ?? ""; return string.IsNullOrEmpty(name) ? "" : name.Trim(); } catch { return ""; } } public static Kingdom FindKingdomByKey(string key) { if (string.IsNullOrEmpty(key) || World.world?.kingdoms == null) { return null; } try { foreach (Kingdom kingdom in World.world.kingdoms) { if (kingdom == null) { continue; } string k = KingdomMetaKey(kingdom); if (!string.IsNullOrEmpty(k) && k.Equals(key, StringComparison.OrdinalIgnoreCase)) { return kingdom; } } } catch { // ignore } return null; } public static int KingdomUnitCount(Kingdom kingdom) { if (kingdom == null) { return 0; } try { return Math.Max(0, kingdom.countUnits()); } catch { try { return Math.Max(0, kingdom.getPopulationPeople()); } catch { return 0; } } } public static Actor PreferKingdomLeader(Kingdom kingdom) { if (kingdom == null) { return null; } try { Actor king = kingdom.king; if (king != null && king.isAlive()) { return king; } } catch { // ignore } try { foreach (Actor unit in kingdom.getUnits()) { if (unit != null && unit.isAlive()) { return unit; } } } catch { // ignore } return null; } 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(); } /// One full-world pass of living combat participants (attack target or AI in_combat). public static void CollectAllCombatFighters(List into) { if (into == null) { return; } foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { if (IsCombatParticipant(actor)) { into.Add(actor); } } } /// /// Filter a precollected fighter list into by radius. /// Avoids a second full-world scan when the caller already has all fighters. /// public static void CollectCombatFightersNear( IList source, Vector2 pos, float radius, List into) { if (source == null || into == null) { return; } float r2 = radius * radius; for (int i = 0; i < source.Count; i++) { Actor actor = source[i]; 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; } into.Add(actor); } } private static void CollectCombatFighters(Vector2 pos, float radius, List into) { if (into == null) { return; } ForEachNearbyActor(pos, radius, requireCombat: true, actor => into.Add(actor)); } /// /// Visit living actors near . Prefers chunk-local lookup; /// falls back to a full alive-unit walk only when chunks yield nothing. /// private static void ForEachNearbyActor( Vector2 pos, float radius, bool requireCombat, Action visit) { if (visit == null) { return; } float r2 = radius * radius; if (TryForEachNearbyFromChunks(pos, radius, r2, requireCombat, visit)) { return; } foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { if (actor == null || !actor.isAlive()) { continue; } if (requireCombat && !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; } visit(actor); } } private static bool TryForEachNearbyFromChunks( Vector2 pos, float radius, float r2, bool requireCombat, Action visit) { try { if (World.world == null) { return false; } WorldTile tile = World.world.GetTile((int)pos.x, (int)pos.y) ?? World.world.GetTileSimple((int)pos.x, (int)pos.y); if (tile == null) { return false; } // Generous chunk ring so radius queries are not truncated, then fall back // to a full walk only when the ring is empty (visited == 0). int chunkRange = Mathf.Clamp(Mathf.CeilToInt(radius / 4f) + 2, 2, 12); IEnumerable nearby = Finder.getUnitsFromChunk(tile, chunkRange); if (nearby == null) { return false; } int visited = 0; foreach (Actor actor in nearby) { if (actor == null || !actor.isAlive()) { continue; } if (requireCombat && !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; } visit(actor); visited++; } return visited > 0; } catch { return false; } } 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; } } /// /// Sticky scoreboard roster: enroll matching side keys in radius, then count every /// tracked member that is still alive until the scene grace ends. /// Combat mode enrolls fighters only; war/plot modes enroll any matching unit. /// 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, bool requireCombat = true) { countA = 0; countB = 0; bestA = null; bestB = null; if (sideAIds == null || sideBIds == null || string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB)) { return; } var seenA = new HashSet(sideAIds); var seenB = new HashSet(sideBIds); ForEachNearbyActor(pos, radius, requireCombat, actor => { string key = frame == EnsembleFrame.KingdomVsKingdom ? KingdomKeyOf(actor) : SpeciesKeyOf(actor); if (string.IsNullOrEmpty(key)) { return; } long id = EventFeedUtil.SafeId(actor); if (id == 0) { return; } if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase)) { if (seenA.Add(id)) { sideAIds.Add(id); } } else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase)) { if (seenB.Add(id)) { sideBIds.Add(id); } } }); countA = CountAliveTracked(sideAIds, out bestA, preferCombat: requireCombat); countB = CountAliveTracked(sideBIds, out bestB, preferCombat: requireCombat); } 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 = EventFeedUtil.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; } public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble) { if (ensemble?.SideA == null) { return false; } // Family packs / status outbreaks lock a single collective roster (no opposing B). if (ensemble.Kind == EnsembleKind.FamilyPack || ensemble.Kind == EnsembleKind.StatusOutbreak) { return !string.IsNullOrEmpty(ensemble.SideA.Key) && ensemble.SideA.Count >= 1; } if (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); } /// True when Side A is a synthetic family pack key. public static bool IsFamilyPackSideKey(string key) => !string.IsNullOrEmpty(key) && key.StartsWith("family:", StringComparison.OrdinalIgnoreCase); /// True when Side A is a synthetic status outbreak key. public static bool IsStatusOutbreakSideKey(string key) => !string.IsNullOrEmpty(key) && key.StartsWith("status:", StringComparison.OrdinalIgnoreCase); 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"); // Multi-fighter: never NamedLeaders (unit-name sides). Species promotion happens in EventReason. } } 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; } // 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; } 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; } }