- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers - Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins - Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight - Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
2184 lines
58 KiB
C#
2184 lines
58 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>Families that can escalate into multi-actor live scenes.</summary>
|
|
public enum EnsembleKind
|
|
{
|
|
Combat = 0,
|
|
StatusOutbreak = 1,
|
|
FamilyPack = 2,
|
|
WarFront = 3,
|
|
PlotCell = 4,
|
|
DiscoveryCluster = 5
|
|
}
|
|
|
|
/// <summary>Size band for reason + scoring alignment.</summary>
|
|
public enum EnsembleScale
|
|
{
|
|
Pair = 0,
|
|
Skirmish = 1,
|
|
Battle = 2,
|
|
Mass = 3
|
|
}
|
|
|
|
/// <summary>How opposing camps are named in the orange reason.</summary>
|
|
public enum EnsembleFrame
|
|
{
|
|
NamedPair = 0,
|
|
KingdomVsKingdom = 1,
|
|
SpeciesVsSpecies = 2,
|
|
NamedLeaders = 3,
|
|
CountOnly = 4
|
|
}
|
|
|
|
/// <summary>One camp inside a <see cref="LiveEnsemble"/>.</summary>
|
|
public sealed class EnsembleSide
|
|
{
|
|
public string Key = "";
|
|
public string Display = "";
|
|
/// <summary>Optional kingdom pretty-name when the side is species-framed.</summary>
|
|
public string KingdomDisplay = "";
|
|
public int Count;
|
|
public int NotableCount;
|
|
public Actor Best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot of a live multi-actor scene. Combat is the first consumer;
|
|
/// wars / status / family / plots can reuse the same shape later.
|
|
/// </summary>
|
|
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<long> ParticipantIds = new List<long>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collect living fighters near <paramref name="pos"/> and partition into sides.
|
|
/// </summary>
|
|
public static bool TryBuildCombat(
|
|
Vector2 pos,
|
|
float radius,
|
|
out LiveEnsemble ensemble,
|
|
ICollection<long> priorParticipantIds = null,
|
|
float newcomerBonus = 8f)
|
|
{
|
|
ensemble = null;
|
|
var fighters = new List<Actor>(16);
|
|
CollectCombatFighters(pos, radius, fighters);
|
|
if (fighters.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ensemble = FromCombatFighters(fighters, pos, priorParticipantIds, newcomerBonus);
|
|
return ensemble != null && ensemble.HasFocus;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a kingdom-vs-kingdom war front from a live <see cref="War"/>.
|
|
/// Counts use kingdom population; focus prefers living kings then any side unit.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a plotters-vs-target-kingdom cell from a live <see cref="Plot"/>.
|
|
/// 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).
|
|
/// </summary>
|
|
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<Actor>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a species-framed family pack from a live <see cref="Family"/>.
|
|
/// Requires at least one living member; focus prefers alpha then any member.
|
|
/// </summary>
|
|
public static bool TryBuildFamily(Family family, out LiveEnsemble ensemble)
|
|
{
|
|
ensemble = null;
|
|
if (family == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var members = new List<Actor>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a status outbreak cluster: units near <paramref name="pos"/> sharing <paramref name="statusId"/>.
|
|
/// Requires at least 2 carriers for a sticky outbreak scene.
|
|
/// </summary>
|
|
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<Actor>(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<Actor> 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) : "");
|
|
}
|
|
|
|
/// <summary>Refresh sticky outbreak counts from Side A roster still carrying the status.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Collect living members of <paramref name="family"/> into <paramref name="into"/>.</summary>
|
|
public static void CollectFamilyUnits(Family family, List<Actor> 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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Refresh sticky family pack counts from the Side A roster.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Collect living members of <paramref name="plot"/> into <paramref name="into"/>.</summary>
|
|
public static void CollectPlotUnits(Plot plot, List<Actor> 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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refresh sticky plot counts: Side A = alive plotter roster; Side B = target kingdom pop.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>True when Side A is a synthetic plotter camp key.</summary>
|
|
public static bool IsPlotterSideKey(string key) =>
|
|
!string.IsNullOrEmpty(key)
|
|
&& key.StartsWith("plot:", StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Re-read kingdom population onto sticky sides when keys still resolve.</summary>
|
|
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<Actor> fighters,
|
|
Vector2 anchor,
|
|
ICollection<long> 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<long> 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();
|
|
}
|
|
|
|
/// <summary>Human-readable kingdom / civ label (never raw snake_case ids).</summary>
|
|
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";
|
|
}
|
|
|
|
/// <summary>Title-case / underscore cleanup for kingdom and civ-like ids.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>One full-world pass of living combat participants (attack target or AI in_combat).</summary>
|
|
public static void CollectAllCombatFighters(List<Actor> into)
|
|
{
|
|
if (into == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (IsCombatParticipant(actor))
|
|
{
|
|
into.Add(actor);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Filter a precollected fighter list into <paramref name="into"/> by radius.
|
|
/// Avoids a second full-world scan when the caller already has all fighters.
|
|
/// </summary>
|
|
public static void CollectCombatFightersNear(
|
|
IList<Actor> source,
|
|
Vector2 pos,
|
|
float radius,
|
|
List<Actor> 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<Actor> into)
|
|
{
|
|
if (into == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ForEachNearbyActor(pos, radius, requireCombat: true, actor => into.Add(actor));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Visit living actors near <paramref name="pos"/>. Prefers chunk-local lookup;
|
|
/// falls back to a full alive-unit walk only when chunks yield nothing.
|
|
/// </summary>
|
|
private static void ForEachNearbyActor(
|
|
Vector2 pos,
|
|
float radius,
|
|
bool requireCombat,
|
|
Action<Actor> 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<Actor> 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<Actor> 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;
|
|
}
|
|
|
|
// Sleeping / stunned / frozen / lying units are not in a live scrap even if an
|
|
// attack_target pointer still lingers (soak + combat_focus cold after sleep).
|
|
if (IsCombatIncapacitated(actor))
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when game state says the actor cannot meaningfully fight right now.
|
|
/// Uses Actor APIs + live sleeping status - not an authored id deny list.
|
|
/// </summary>
|
|
public static bool IsCombatIncapacitated(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Parked only: sleep / freeze / unconscious. Do not treat stun or isLying as out of
|
|
// the scrap - those are normal combat FX and were collapsing Duels to "is fighting".
|
|
try
|
|
{
|
|
var ids = actor.getStatusesIds();
|
|
if (ids != null)
|
|
{
|
|
foreach (string id in ids)
|
|
{
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string s = id.ToLowerInvariant();
|
|
if (s.IndexOf("sleep", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("uncon", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static void RefreshStickyMemberRoster(
|
|
Vector2 pos,
|
|
float radius,
|
|
EnsembleFrame frame,
|
|
string keyA,
|
|
string keyB,
|
|
List<long> sideAIds,
|
|
List<long> 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<long>(sideAIds);
|
|
var seenB = new HashSet<long>(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<long> ids, bool preferCombat = true)
|
|
{
|
|
CountAliveTracked(ids, out Actor best, preferCombat);
|
|
return best;
|
|
}
|
|
|
|
private static void AddUniqueId(List<long> 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<long> 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);
|
|
}
|
|
|
|
/// <summary>True when Side A is a synthetic family pack key.</summary>
|
|
public static bool IsFamilyPackSideKey(string key) =>
|
|
!string.IsNullOrEmpty(key)
|
|
&& key.StartsWith("family:", StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>True when Side A is a synthetic status outbreak key.</summary>
|
|
public static bool IsStatusOutbreakSideKey(string key) =>
|
|
!string.IsNullOrEmpty(key)
|
|
&& key.StartsWith("status:", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static void ApplyNamedPair(LiveEnsemble ensemble, List<Actor> 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<Actor> fighters,
|
|
ICollection<long> 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<Actor> fighters,
|
|
LiveEnsemble ensemble,
|
|
ICollection<long> prior,
|
|
float newcomerBonus)
|
|
{
|
|
var groups = new Dictionary<string, List<Actor>>(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<Actor> list))
|
|
{
|
|
list = new List<Actor>(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<Actor> fighters,
|
|
LiveEnsemble ensemble,
|
|
ICollection<long> prior,
|
|
float newcomerBonus)
|
|
{
|
|
var groups = new Dictionary<string, List<Actor>>(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<Actor> list))
|
|
{
|
|
list = new List<Actor>(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<Actor> fighters,
|
|
LiveEnsemble ensemble,
|
|
ICollection<long> prior,
|
|
float newcomerBonus)
|
|
{
|
|
Actor focus = ensemble.Focus;
|
|
Actor foe = AttackTargetOf(focus);
|
|
if (foe == null || !foe.isAlive())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var sideA = new List<Actor> { focus };
|
|
var sideB = new List<Actor> { 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<string, List<Actor>> groups,
|
|
EnsembleFrame frame,
|
|
ICollection<long> prior,
|
|
float newcomerBonus,
|
|
Func<string, string> displayFromKey)
|
|
{
|
|
string keyA = null;
|
|
string keyB = null;
|
|
int countA = -1;
|
|
int countB = -1;
|
|
foreach (KeyValuePair<string, List<Actor>> 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<Actor> members,
|
|
ICollection<long> 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;
|
|
}
|
|
}
|