1068 lines
29 KiB
C#
1068 lines
29 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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
private static void CollectCombatFighters(Vector2 pos, float radius, List<Actor> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sticky scoreboard roster: add units currently fighting for each camp, then count every
|
|
/// tracked member that is still alive (combat or not) until the combat scene grace ends.
|
|
/// Dead ids are pruned. New combatants matching a sticky key are enrolled.
|
|
/// </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)
|
|
{
|
|
countA = 0;
|
|
countB = 0;
|
|
bestA = null;
|
|
bestB = null;
|
|
if (sideAIds == null || sideBIds == null
|
|
|| string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB))
|
|
{
|
|
return;
|
|
}
|
|
|
|
float r2 = radius * radius;
|
|
// Enroll anyone currently fighting for a sticky camp.
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive() || !IsCombatParticipant(actor))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float dx = actor.current_position.x - pos.x;
|
|
float dy = actor.current_position.y - pos.y;
|
|
if (dx * dx + dy * dy > r2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string key = frame == EnsembleFrame.KingdomVsKingdom
|
|
? KingdomKeyOf(actor)
|
|
: SpeciesKeyOf(actor);
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
long id = EventFeedUtil.SafeId(actor);
|
|
if (id == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AddUniqueId(sideAIds, id);
|
|
}
|
|
else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AddUniqueId(sideBIds, id);
|
|
}
|
|
}
|
|
|
|
countA = CountAliveTracked(sideAIds, out bestA, preferCombat: true);
|
|
countB = CountAliveTracked(sideBIds, out bestB, preferCombat: true);
|
|
}
|
|
|
|
public static Actor FindBestAliveTracked(List<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 = FindAliveById(id);
|
|
if (actor == null)
|
|
{
|
|
ids.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
alive++;
|
|
float w = FocusWeight(actor, priorParticipantIds: null, newcomerBonus: 0f);
|
|
if (preferCombat && IsCombatParticipant(actor))
|
|
{
|
|
w += 40f;
|
|
}
|
|
|
|
if (w > bestW)
|
|
{
|
|
bestW = w;
|
|
best = actor;
|
|
}
|
|
}
|
|
|
|
return alive;
|
|
}
|
|
|
|
private static Actor FindAliveById(long id)
|
|
{
|
|
if (id == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (EventFeedUtil.SafeId(actor) == id)
|
|
{
|
|
return actor;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble)
|
|
{
|
|
if (ensemble?.SideA == null || ensemble.SideB == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (ensemble.Frame != EnsembleFrame.SpeciesVsSpecies
|
|
&& ensemble.Frame != EnsembleFrame.KingdomVsKingdom)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(ensemble.SideA.Key) || string.IsNullOrEmpty(ensemble.SideB.Key))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return !ensemble.SideA.Key.Equals(ensemble.SideB.Key, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static void ApplyNamedPair(LiveEnsemble ensemble, List<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;
|
|
}
|
|
}
|