fights
This commit is contained in:
parent
3ed85d01d1
commit
e37cbf7890
15 changed files with 1812 additions and 125 deletions
|
|
@ -2013,6 +2013,10 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "combat_ensemble_escalate":
|
||||
DoCombatEnsembleEscalate(cmd);
|
||||
break;
|
||||
|
||||
case "interest_variety_clear":
|
||||
InterestVariety.Clear();
|
||||
_cmdOk++;
|
||||
|
|
@ -3542,7 +3546,19 @@ public static class AgentHarness
|
|||
}
|
||||
|
||||
string label = string.IsNullOrEmpty(cmd.label)
|
||||
? EventReason.Fight(follow, related)
|
||||
? EventReason.Combat(new LiveEnsemble
|
||||
{
|
||||
Kind = EnsembleKind.Combat,
|
||||
Scale = EnsembleScale.Pair,
|
||||
Frame = EnsembleFrame.NamedPair,
|
||||
ParticipantCount = related != null ? 2 : 1,
|
||||
Focus = follow,
|
||||
Related = related,
|
||||
SideA = new EnsembleSide { Display = SafeName(follow), Count = 1, Best = follow },
|
||||
SideB = related != null
|
||||
? new EnsembleSide { Display = SafeName(related), Count = 1, Best = related }
|
||||
: null
|
||||
})
|
||||
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
|
||||
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
|
||||
InterestCandidate c = InterestFeeds.RegisterHarness(
|
||||
|
|
@ -3586,6 +3602,153 @@ public static class AgentHarness
|
|||
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grow the current CombatActive scene into a sided multi-fighter ensemble reason.
|
||||
/// value = participant count (default 6). Spawns extra follow/related species near the focus.
|
||||
/// </summary>
|
||||
private static void DoCombatEnsembleEscalate(HarnessCommand cmd)
|
||||
{
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no_combat_scene");
|
||||
return;
|
||||
}
|
||||
|
||||
Actor focus = scene.FollowUnit;
|
||||
if (focus == null || !focus.isAlive())
|
||||
{
|
||||
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
}
|
||||
|
||||
Actor related = scene.RelatedUnit;
|
||||
if (focus == null || !focus.isAlive())
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no_focus");
|
||||
return;
|
||||
}
|
||||
|
||||
int want = 6;
|
||||
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
|
||||
{
|
||||
want = 6;
|
||||
}
|
||||
|
||||
want = Mathf.Clamp(want, 3, 24);
|
||||
string focusSpecies = focus.asset != null ? focus.asset.id : "human";
|
||||
string relatedSpecies = related?.asset != null ? related.asset.id : "wolf";
|
||||
if (string.Equals(relatedSpecies, focusSpecies, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relatedSpecies = focusSpecies == "human" ? "wolf" : "human";
|
||||
}
|
||||
|
||||
int sideA = want / 2;
|
||||
int sideB = want - sideA;
|
||||
// Already have focus (+ optional related); spawn the rest near the fight.
|
||||
for (int i = 1; i < sideA; i++)
|
||||
{
|
||||
SpawnAssetNear(focusSpecies, focus.current_position, 2f + i * 0.4f);
|
||||
}
|
||||
|
||||
if (related == null || !related.isAlive())
|
||||
{
|
||||
related = SpawnAssetNear(relatedSpecies, focus.current_position, 2.5f);
|
||||
}
|
||||
|
||||
for (int i = 1; i < sideB; i++)
|
||||
{
|
||||
SpawnAssetNear(relatedSpecies, focus.current_position, 3f + i * 0.4f);
|
||||
}
|
||||
|
||||
var ensemble = new LiveEnsemble
|
||||
{
|
||||
Kind = EnsembleKind.Combat,
|
||||
Scale = LiveEnsemble.ScaleForCount(want),
|
||||
Frame = EnsembleFrame.SpeciesVsSpecies,
|
||||
ParticipantCount = want,
|
||||
NotableParticipantCount = Mathf.Max(scene.NotableParticipantCount, 0),
|
||||
Focus = focus,
|
||||
Related = related,
|
||||
Anchor = focus.current_position,
|
||||
SideA = new EnsembleSide
|
||||
{
|
||||
Key = focusSpecies,
|
||||
Display = LiveEnsemble.SpeciesDisplay(focusSpecies),
|
||||
Count = sideA,
|
||||
Best = focus
|
||||
},
|
||||
SideB = new EnsembleSide
|
||||
{
|
||||
Key = relatedSpecies,
|
||||
Display = LiveEnsemble.SpeciesDisplay(relatedSpecies),
|
||||
Count = sideB,
|
||||
Best = related
|
||||
}
|
||||
};
|
||||
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(focus));
|
||||
if (related != null)
|
||||
{
|
||||
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(related));
|
||||
}
|
||||
|
||||
bool ok = InterestDirector.HarnessApplyCombatEnsemble(ensemble);
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
|
||||
}
|
||||
|
||||
private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (AssetManager.actor_library?.get(assetId) == null || World.world == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WorldTile tile = World.world.GetTile(
|
||||
Mathf.RoundToInt(near.x + jitter),
|
||||
Mathf.RoundToInt(near.y));
|
||||
if (tile == null)
|
||||
{
|
||||
tile = World.world.GetTile(
|
||||
Mathf.RoundToInt(near.x),
|
||||
Mathf.RoundToInt(near.y));
|
||||
}
|
||||
|
||||
if (tile == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor spawned = World.world.units.spawnNewUnit(
|
||||
assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
|
||||
if (spawned != null && spawned.isAlive())
|
||||
{
|
||||
_lastSpawned = spawned;
|
||||
_lastSpawnedAssetId = assetId;
|
||||
}
|
||||
|
||||
return spawned;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DoSetSetting(HarnessCommand cmd)
|
||||
{
|
||||
string key = cmd.expect;
|
||||
|
|
@ -3711,8 +3874,9 @@ public static class AgentHarness
|
|||
else
|
||||
{
|
||||
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
|
||||
// Also accept Battle-style where name leads.
|
||||
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
|
||||
|| IsEnsembleCombatTip(tip);
|
||||
// Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
|
||||
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
||||
}
|
||||
|
||||
|
|
@ -3727,6 +3891,43 @@ public static class AgentHarness
|
|||
detail = $"tip='{tip}' not_contains='{needle}'";
|
||||
break;
|
||||
}
|
||||
case "tip_matches_any":
|
||||
{
|
||||
// value = pipe-separated needles; tip must contain at least one.
|
||||
string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
||||
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||
pass = false;
|
||||
if (!string.IsNullOrEmpty(raw))
|
||||
{
|
||||
string[] parts = raw.Split('|');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string needle = (parts[i] ?? "").Trim();
|
||||
if (needle.Length > 0
|
||||
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"tip='{tip}' any='{raw}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "participant_count_min":
|
||||
{
|
||||
int want = 1;
|
||||
if (!int.TryParse(string.IsNullOrEmpty(cmd.value) ? "1" : cmd.value, out want))
|
||||
{
|
||||
want = 1;
|
||||
}
|
||||
|
||||
int have = InterestDirector.CurrentCandidate?.ParticipantCount ?? 0;
|
||||
pass = have >= want;
|
||||
detail = $"participants={have} want>={want}";
|
||||
break;
|
||||
}
|
||||
case "unit_asset":
|
||||
{
|
||||
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
||||
|
|
@ -6688,6 +6889,20 @@ public static class AgentHarness
|
|||
return SafeName(MoveCamera._focus_unit);
|
||||
}
|
||||
|
||||
private static bool IsEnsembleCombatTip(string tip)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tip))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static long ResolveActivitySubjectId()
|
||||
{
|
||||
// Prefer the camera focus (harness apply/assert target) over watch caption,
|
||||
|
|
|
|||
|
|
@ -30,19 +30,154 @@ public static class EventReason
|
|||
|
||||
public static string Battle(Actor focus, int fighters)
|
||||
{
|
||||
string name = Name(focus);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
// Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing.
|
||||
var e = new LiveEnsemble
|
||||
{
|
||||
name = "Someone";
|
||||
Kind = EnsembleKind.Combat,
|
||||
Scale = LiveEnsemble.ScaleForCount(fighters),
|
||||
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
|
||||
ParticipantCount = Math.Max(1, fighters),
|
||||
Focus = focus
|
||||
};
|
||||
return Combat(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
|
||||
/// Multi-fighter form: <c>Battle - [Essiona] humans (10) vs wolves (3)</c>.
|
||||
/// </summary>
|
||||
public static string Combat(LiveEnsemble ensemble)
|
||||
{
|
||||
if (ensemble == null || !ensemble.HasFocus)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
int n = Math.Max(1, fighters);
|
||||
if (n <= 2)
|
||||
if (ensemble.Frame == EnsembleFrame.NamedPair
|
||||
|| ensemble.Scale == EnsembleScale.Pair)
|
||||
{
|
||||
return name + " is in a duel";
|
||||
string a = Name(ensemble.Focus);
|
||||
string b = Name(ensemble.Related);
|
||||
if (string.IsNullOrEmpty(a))
|
||||
{
|
||||
a = "Someone";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(b))
|
||||
{
|
||||
return "Duel - " + a;
|
||||
}
|
||||
|
||||
return "Duel - " + a + " vs " + b;
|
||||
}
|
||||
|
||||
return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
|
||||
string tier = ScaleTierLabel(ensemble.Scale);
|
||||
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
|
||||
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
|
||||
bool sided = !string.IsNullOrEmpty(sideA)
|
||||
&& !string.IsNullOrEmpty(sideB)
|
||||
&& !sideA.Equals(sideB, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (sided)
|
||||
{
|
||||
return tier + " - " + sideA + " vs " + sideB;
|
||||
}
|
||||
|
||||
int n = Math.Max(1, ensemble.ParticipantCount);
|
||||
if (!string.IsNullOrEmpty(sideA))
|
||||
{
|
||||
return tier + " - " + sideA + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
||||
}
|
||||
|
||||
return tier + " - fight of " + n.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ScaleTierLabel(EnsembleScale scale)
|
||||
{
|
||||
switch (scale)
|
||||
{
|
||||
case EnsembleScale.Pair:
|
||||
return "Duel";
|
||||
case EnsembleScale.Skirmish:
|
||||
return "Skirmish";
|
||||
case EnsembleScale.Mass:
|
||||
return "Mass";
|
||||
default:
|
||||
return "Battle";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatCombatSide(EnsembleSide side, EnsembleFrame frame)
|
||||
{
|
||||
if (side == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string label = side.Display ?? "";
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
label = side.Key ?? "";
|
||||
}
|
||||
|
||||
if (frame == EnsembleFrame.KingdomVsKingdom)
|
||||
{
|
||||
label = LiveEnsemble.KingdomDisplay(label);
|
||||
}
|
||||
else if (frame == EnsembleFrame.SpeciesVsSpecies)
|
||||
{
|
||||
label = LiveEnsemble.SpeciesDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
|
||||
if (!string.IsNullOrEmpty(side.KingdomDisplay))
|
||||
{
|
||||
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
|
||||
// Avoid "[Humans] humans" when kingdom label is just the species.
|
||||
if (!string.IsNullOrEmpty(kingdom)
|
||||
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
|
||||
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
label = "[" + kingdom + "] " + label;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (frame == EnsembleFrame.NamedLeaders)
|
||||
{
|
||||
if (side.Best != null)
|
||||
{
|
||||
label = Name(side.Best);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// Consistent side casing for collective labels.
|
||||
if (frame == EnsembleFrame.SpeciesVsSpecies
|
||||
|| frame == EnsembleFrame.KingdomVsKingdom)
|
||||
{
|
||||
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||
}
|
||||
|
||||
int n = Math.Max(1, side.Count);
|
||||
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
||||
}
|
||||
|
||||
/// <summary>Dispatch by ensemble kind (combat first; other kinds plug in later).</summary>
|
||||
public static string Ensemble(LiveEnsemble ensemble)
|
||||
{
|
||||
if (ensemble == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (ensemble.Kind)
|
||||
{
|
||||
case EnsembleKind.Combat:
|
||||
return Combat(ensemble);
|
||||
default:
|
||||
return Combat(ensemble);
|
||||
}
|
||||
}
|
||||
|
||||
public static string SeekingLover(Actor a)
|
||||
|
|
|
|||
|
|
@ -353,8 +353,19 @@ public static class InterestFeeds
|
|||
|
||||
string verb = taskOrBeat ?? "fighting";
|
||||
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
||||
Vector3 pos = follow != null ? follow.current_position : actor.current_position;
|
||||
LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
|
||||
if (ensemble != null && ensemble.HasFocus)
|
||||
{
|
||||
follow = ensemble.Focus;
|
||||
related = ensemble.Related ?? related;
|
||||
}
|
||||
|
||||
long followId = SafeId(follow ?? actor);
|
||||
string key = EventCatalog.Combat.Key(followId);
|
||||
string label = ensemble != null
|
||||
? EventReason.Combat(ensemble)
|
||||
: EventReason.Fight(follow ?? actor, related);
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
|
|
@ -368,12 +379,14 @@ public static class InterestFeeds
|
|||
RelatedUnit = related,
|
||||
SubjectId = followId,
|
||||
RelatedId = related != null ? SafeId(related) : 0,
|
||||
Label = EventReason.Fight(follow ?? actor, related),
|
||||
Label = label,
|
||||
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
||||
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
||||
Verb = verb,
|
||||
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
|
||||
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
|
||||
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
|
||||
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
||||
|
|
@ -381,15 +394,34 @@ public static class InterestFeeds
|
|||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||
Completion = InterestCompletionKind.CombatActive
|
||||
};
|
||||
candidate.ParticipantIds.Add(followId);
|
||||
if (related != null)
|
||||
if (ensemble != null)
|
||||
{
|
||||
long rid = SafeId(related);
|
||||
if (rid != 0)
|
||||
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
|
||||
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||
{
|
||||
candidate.ParticipantIds.Add(rid);
|
||||
candidate.AssetId = "live_battle";
|
||||
}
|
||||
|
||||
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
||||
&& ensemble.SideA != null
|
||||
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||
{
|
||||
candidate.KingdomKey = ensemble.SideA.Key;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.ParticipantIds.Add(followId);
|
||||
if (related != null)
|
||||
{
|
||||
long rid = SafeId(related);
|
||||
if (rid != 0)
|
||||
{
|
||||
candidate.ParticipantIds.Add(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
|
@ -1031,7 +1063,15 @@ public static class InterestFeeds
|
|||
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
|
||||
if (battle != null)
|
||||
{
|
||||
RegisterDirect(battle);
|
||||
if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
&& InterestDirector.TryAbsorbCombatBattle(battle))
|
||||
{
|
||||
// Active combat scene refreshed in place.
|
||||
}
|
||||
else
|
||||
{
|
||||
RegisterDirect(battle);
|
||||
}
|
||||
}
|
||||
|
||||
// Ongoing wars from WarManager (complements WorldLog war start/end).
|
||||
|
|
|
|||
877
IdleSpectator/Events/LiveEnsemble.cs
Normal file
877
IdleSpectator/Events/LiveEnsemble.cs
Normal file
|
|
@ -0,0 +1,877 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
if (ensemble.NotableParticipantCount >= 2
|
||||
&& InterestScoring.IsNotable(ensemble.Focus)
|
||||
&& InterestScoring.IsNotable(foe))
|
||||
{
|
||||
ensemble.Frame = EnsembleFrame.NamedLeaders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
ensemble.Frame = EnsembleFrame.NamedLeaders;
|
||||
ensemble.SideA = BuildSide("camp:a", EventFeedUtil.SafeName(focus), sideA, prior, newcomerBonus, EnsembleFrame.NamedLeaders);
|
||||
ensemble.SideB = BuildSide("camp:b", EventFeedUtil.SafeName(foe), sideB, prior, newcomerBonus, EnsembleFrame.NamedLeaders);
|
||||
PreferFocusSide(ensemble);
|
||||
ensemble.Related = ensemble.SideB?.Best ?? foe;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SameCampHint(Actor a, Actor camp)
|
||||
{
|
||||
if (a == null || camp == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string ka = KingdomKeyOf(a);
|
||||
string kc = KingdomKeyOf(camp);
|
||||
if (!string.IsNullOrEmpty(ka) && string.Equals(ka, kc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string sa = SpeciesKeyOf(a);
|
||||
string sc = SpeciesKeyOf(camp);
|
||||
return !string.IsNullOrEmpty(sa) && string.Equals(sa, sc, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool FinishTwoSides(
|
||||
LiveEnsemble ensemble,
|
||||
Dictionary<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1057,7 +1057,7 @@ internal static class HarnessScenarios
|
|||
|
||||
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
|
||||
Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
|
||||
Step("cf21", "assert", expect: "tip_contains", value: "fighting"),
|
||||
Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||
Step("cf22", "interest_clear_follow", asset: "wolf"),
|
||||
Step("cf23", "combat_maintain_focus"),
|
||||
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
||||
|
|
@ -1065,13 +1065,25 @@ internal static class HarnessScenarios
|
|||
Step("cf25b", "assert", expect: "dossier_matches_focus"),
|
||||
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
|
||||
|
||||
// Cold combat must not keep "is fighting".
|
||||
// 1v1 → multi: sided species framing + participant write-back.
|
||||
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
||||
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
||||
Step("cf43", "assert", expect: "participant_count_min", value: "6"),
|
||||
Step("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
|
||||
Step("cf45", "assert", expect: "tip_matches_focus"),
|
||||
Step("cf46", "assert", expect: "dossier_matches_focus"),
|
||||
Step("cf47", "assert", expect: "has_focus", value: "true"),
|
||||
|
||||
// Cold combat must not keep fight / ensemble tips.
|
||||
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
|
||||
Step("cf31", "assert", expect: "tip_contains", value: "fighting"),
|
||||
Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||
Step("cf32", "interest_release_force"),
|
||||
Step("cf33", "interest_mark_inactive", value: "1"),
|
||||
Step("cf34", "combat_maintain_focus"),
|
||||
Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"),
|
||||
Step("cf35", "assert", expect: "tip_not_contains", value: " vs "),
|
||||
Step("cf35b", "assert", expect: "tip_not_contains", value: "Duel"),
|
||||
Step("cf35c", "assert", expect: "tip_not_contains", value: "fighting"),
|
||||
|
||||
Step("cf90", "fast_timing", value: "false"),
|
||||
Step("cf99", "snapshot"),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ public static class InterestDirector
|
|||
private static float _enabledAt = -999f;
|
||||
private static float _inactiveSince = -999f;
|
||||
private static float _lastCombatFocusAt = -999f;
|
||||
private const float CombatFocusThrottleSeconds = 0.4f;
|
||||
private const float CombatFocusThrottleSeconds = 1.25f;
|
||||
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
|
||||
private const float CombatFocusSwitchMargin = 12f;
|
||||
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
|
||||
|
||||
public static string CurrentTierName => CurrentScoreLabel;
|
||||
|
|
@ -506,8 +508,9 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
|
||||
/// so the subject always matches focus. Clears fight Labels once combat goes cold.
|
||||
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
|
||||
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
|
||||
/// Clears fight Labels once combat goes cold.
|
||||
/// </summary>
|
||||
private static void MaintainCombatFocus(float now, bool force)
|
||||
{
|
||||
|
|
@ -529,18 +532,188 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
_lastCombatFocusAt = now;
|
||||
if (!WorldActivityScanner.TryPickBestCombatFocus(
|
||||
_current, out Actor best, out Actor foe, out int fighters))
|
||||
ApplyCombatEnsembleToCurrent(forceWatch: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh active combat scene from the world ensemble. Returns false if no focus.
|
||||
/// </summary>
|
||||
private static bool ApplyCombatEnsembleToCurrent(bool forceWatch)
|
||||
{
|
||||
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| fighters > 2
|
||||
|| _current.ParticipantCount > 2;
|
||||
string label = mass
|
||||
? EventReason.Battle(best, fighters)
|
||||
: EventReason.Fight(best, foe);
|
||||
Vector3 pos = _current.Position;
|
||||
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
||||
{
|
||||
pos = _current.FollowUnit.current_position;
|
||||
}
|
||||
else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
||||
{
|
||||
pos = _current.RelatedUnit.current_position;
|
||||
}
|
||||
|
||||
bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| _current.ParticipantCount > 2;
|
||||
float radius = massHint ? 14f : 10f;
|
||||
|
||||
// Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
|
||||
if (!_current.HasFollowUnit
|
||||
&& _current.RelatedUnit != null
|
||||
&& _current.RelatedUnit.isAlive())
|
||||
{
|
||||
Actor handoff = _current.RelatedUnit;
|
||||
Actor handoffFoe = null;
|
||||
try
|
||||
{
|
||||
if (handoff.has_attack_target
|
||||
&& handoff.attack_target != null
|
||||
&& handoff.attack_target.isAlive()
|
||||
&& handoff.attack_target.isActor())
|
||||
{
|
||||
handoffFoe = handoff.attack_target.a;
|
||||
if (handoffFoe != null && !handoffFoe.isAlive())
|
||||
{
|
||||
handoffFoe = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
handoffFoe = null;
|
||||
}
|
||||
|
||||
LiveEnsemble.TryBuildCombat(
|
||||
handoff.current_position,
|
||||
radius,
|
||||
out LiveEnsemble handoffEnsemble,
|
||||
priorParticipantIds: _current.ParticipantIds,
|
||||
newcomerBonus: 8f);
|
||||
if (handoffEnsemble != null)
|
||||
{
|
||||
ApplyEnsembleFields(_current, handoffEnsemble);
|
||||
}
|
||||
|
||||
// Keep ownership on the handoff unit; only use ensemble framing when the melee is large.
|
||||
string handoffLabel;
|
||||
if (handoffEnsemble != null
|
||||
&& handoffEnsemble.ParticipantCount > 2
|
||||
&& handoffEnsemble.Scale != EnsembleScale.Pair)
|
||||
{
|
||||
handoffEnsemble.Focus = handoff;
|
||||
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
|
||||
{
|
||||
handoffEnsemble.Related = handoffFoe;
|
||||
}
|
||||
|
||||
handoffLabel = EventReason.Combat(handoffEnsemble);
|
||||
}
|
||||
else
|
||||
{
|
||||
handoffLabel = EventReason.Fight(handoff, handoffFoe);
|
||||
_current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount);
|
||||
}
|
||||
|
||||
bool handoffFollowChanged = _current.FollowUnit != handoff;
|
||||
bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal);
|
||||
_current.FollowUnit = handoff;
|
||||
_current.SubjectId = EventFeedUtil.SafeId(handoff);
|
||||
_current.RelatedUnit = handoffFoe;
|
||||
_current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
|
||||
_current.Label = handoffLabel;
|
||||
try
|
||||
{
|
||||
_current.Position = handoff.current_position;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep
|
||||
}
|
||||
|
||||
if (forceWatch
|
||||
|| handoffFollowChanged
|
||||
|| handoffLabelChanged
|
||||
|| !HasLivingCameraFocus()
|
||||
|| MoveCamera._focus_unit != handoff)
|
||||
{
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
LiveEnsemble.TryBuildCombat(
|
||||
pos,
|
||||
radius,
|
||||
out LiveEnsemble ensemble,
|
||||
priorParticipantIds: _current.ParticipantIds,
|
||||
newcomerBonus: 8f);
|
||||
|
||||
Actor best;
|
||||
Actor foe;
|
||||
int fighters;
|
||||
string label;
|
||||
if (ensemble != null && ensemble.HasFocus)
|
||||
{
|
||||
best = ensemble.Focus;
|
||||
foe = ensemble.Related;
|
||||
fighters = Mathf.Max(1, ensemble.ParticipantCount);
|
||||
label = EventReason.Combat(ensemble);
|
||||
ApplyEnsembleFields(_current, ensemble);
|
||||
}
|
||||
else if (WorldActivityScanner.TryPickBestCombatFocus(
|
||||
_current, out best, out foe, out fighters))
|
||||
{
|
||||
var fallback = new LiveEnsemble
|
||||
{
|
||||
Kind = EnsembleKind.Combat,
|
||||
Scale = LiveEnsemble.ScaleForCount(fighters),
|
||||
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
|
||||
ParticipantCount = fighters,
|
||||
Focus = best,
|
||||
Related = foe
|
||||
};
|
||||
label = EventReason.Combat(fallback);
|
||||
_current.ParticipantCount = fighters;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hysteresis: do not thrash the camera between near-equal fighters.
|
||||
if (_current.FollowUnit != null
|
||||
&& _current.FollowUnit.isAlive()
|
||||
&& best != null
|
||||
&& best != _current.FollowUnit)
|
||||
{
|
||||
float curW = LiveEnsemble.FocusWeight(
|
||||
_current.FollowUnit, _current.ParticipantIds, newcomerBonus: 0f);
|
||||
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
|
||||
if (newW < curW + CombatFocusSwitchMargin)
|
||||
{
|
||||
best = _current.FollowUnit;
|
||||
if (foe == null || foe == best)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (best.has_attack_target
|
||||
&& best.attack_target != null
|
||||
&& best.attack_target.isAlive()
|
||||
&& best.attack_target.isActor())
|
||||
{
|
||||
foe = best.attack_target.a;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep foe
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool followChanged = _current.FollowUnit != best;
|
||||
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
|
||||
|
|
@ -558,13 +731,133 @@ public static class InterestDirector
|
|||
// keep prior position
|
||||
}
|
||||
|
||||
if (followChanged
|
||||
if (forceWatch
|
||||
|| followChanged
|
||||
|| labelChanged
|
||||
|| !HasLivingCameraFocus()
|
||||
|| MoveCamera._focus_unit != best)
|
||||
{
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
|
||||
{
|
||||
if (scene == null || ensemble == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
scene.ParticipantCount = Mathf.Max(1, ensemble.ParticipantCount);
|
||||
scene.NotableParticipantCount = ensemble.NotableParticipantCount;
|
||||
WorldActivityScanner.StampParticipantIds(scene, ensemble);
|
||||
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||
{
|
||||
scene.AssetId = "live_battle";
|
||||
}
|
||||
|
||||
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
||||
&& ensemble.SideA != null
|
||||
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||
{
|
||||
scene.KingdomKey = ensemble.SideA.Key;
|
||||
}
|
||||
|
||||
if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies
|
||||
&& ensemble.SideA != null
|
||||
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||
{
|
||||
scene.SpeciesId = ensemble.SideA.Key;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the active combat scene already covers this battle anchor, refresh it in place
|
||||
/// instead of registering a second live_battle candidate.
|
||||
/// </summary>
|
||||
public static bool TryAbsorbCombatBattle(InterestEvent battle)
|
||||
{
|
||||
if (battle == null
|
||||
|| _current == null
|
||||
|| _current.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Vector3 anchor = battle.Position;
|
||||
Vector3 cur = _current.Position;
|
||||
try
|
||||
{
|
||||
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
||||
{
|
||||
cur = _current.FollowUnit.current_position;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep
|
||||
}
|
||||
|
||||
float dx = anchor.x - cur.x;
|
||||
float dy = anchor.y - cur.y;
|
||||
float mergeR = 16f;
|
||||
bool near = dx * dx + dy * dy <= mergeR * mergeR;
|
||||
bool shared = false;
|
||||
if (!near && battle.FollowUnit != null)
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(battle.FollowUnit);
|
||||
shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id);
|
||||
}
|
||||
|
||||
if (!near && !shared)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (battle.ParticipantCount > _current.ParticipantCount)
|
||||
{
|
||||
_current.ParticipantCount = battle.ParticipantCount;
|
||||
}
|
||||
|
||||
if (battle.NotableParticipantCount > _current.NotableParticipantCount)
|
||||
{
|
||||
_current.NotableParticipantCount = battle.NotableParticipantCount;
|
||||
}
|
||||
|
||||
ApplyCombatEnsembleToCurrent(forceWatch: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Harness: apply a synthetic combat ensemble onto the current scene.</summary>
|
||||
public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble)
|
||||
{
|
||||
if (_current == null
|
||||
|| ensemble == null
|
||||
|| !ensemble.HasFocus
|
||||
|| _current.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyEnsembleFields(_current, ensemble);
|
||||
_current.FollowUnit = ensemble.Focus;
|
||||
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
||||
_current.RelatedUnit = ensemble.Related;
|
||||
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
|
||||
_current.Label = EventReason.Combat(ensemble);
|
||||
try
|
||||
{
|
||||
_current.Position = ensemble.Focus.current_position;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep
|
||||
}
|
||||
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ClearStaleCombatLabel(float now)
|
||||
|
|
@ -574,8 +867,16 @@ public static class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
string label = _current.Label;
|
||||
if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ public static class InterestRegistry
|
|||
existing.RelatedId = incoming.RelatedId;
|
||||
}
|
||||
|
||||
if (incoming.ParticipantCount > existing.ParticipantCount)
|
||||
{
|
||||
existing.ParticipantCount = incoming.ParticipantCount;
|
||||
}
|
||||
|
||||
if (incoming.NotableParticipantCount > existing.NotableParticipantCount)
|
||||
{
|
||||
existing.NotableParticipantCount = incoming.NotableParticipantCount;
|
||||
}
|
||||
|
||||
if (incoming.ForceActive)
|
||||
{
|
||||
existing.ForceActive = true;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ public class ScoringWeights
|
|||
public float massBaseBonus = 28f;
|
||||
public float massPerExtraFighter = 4f;
|
||||
public float massExtraCap = 24f;
|
||||
/// <summary>Fighter count at which combat ensembles use Mass scale framing.</summary>
|
||||
public int massCrowdThreshold = 8;
|
||||
public int duelMaxFighters = 2;
|
||||
public float duelNotablePairBonus = 55f;
|
||||
public float duelSingleNotableBonus = 15f;
|
||||
|
|
|
|||
|
|
@ -348,12 +348,16 @@ public sealed class UnitDossier
|
|||
// Ownership: never let the reason name a living stranger (not subject/related).
|
||||
if (ReasonNamesStranger(beat, scene))
|
||||
{
|
||||
if (recordDrops)
|
||||
// Tiered combat tips name sides / duelists on purpose.
|
||||
if (!IsEnsembleCombatReason(beat))
|
||||
{
|
||||
InterestDropLog.Record("identity_filter", "stranger:" + beat);
|
||||
}
|
||||
if (recordDrops)
|
||||
{
|
||||
InterestDropLog.Record("identity_filter", "stranger:" + beat);
|
||||
}
|
||||
|
||||
return "";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership: reject beats that lead with someone else's proper name.
|
||||
|
|
@ -472,6 +476,9 @@ public sealed class UnitDossier
|
|||
case "fighting":
|
||||
case "war":
|
||||
case "battle":
|
||||
case "duel":
|
||||
case "skirmish":
|
||||
case "mass":
|
||||
case "clash":
|
||||
case "grief":
|
||||
case "mourning":
|
||||
|
|
@ -501,6 +508,20 @@ public sealed class UnitDossier
|
|||
}
|
||||
}
|
||||
|
||||
private static bool IsEnsembleCombatReason(string reason)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return reason.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| reason.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||
|| reason.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| reason.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
||||
|| reason.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
||||
|
|
@ -768,6 +789,11 @@ public sealed class UnitDossier
|
|||
|| t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" vs ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("Skirmish", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("Mass", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| t.IndexOf(':') >= 0;
|
||||
|
|
@ -998,7 +1024,7 @@ public sealed class UnitDossier
|
|||
sb.Append(" · ");
|
||||
}
|
||||
|
||||
sb.Append(d.KingdomName);
|
||||
sb.Append(LiveEnsemble.KingdomDisplay(d.KingdomName));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(d.CityName)
|
||||
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
|
||||
|
|
|
|||
|
|
@ -121,14 +121,24 @@ public static class WorldActivityScanner
|
|||
}
|
||||
|
||||
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
||||
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
|
||||
fighters = Mathf.Max(1, fighters);
|
||||
|
||||
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
||||
string label = EventReason.Fight(follow, related);
|
||||
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
|
||||
int fighters = ensemble != null ? ensemble.ParticipantCount : 1;
|
||||
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||||
fighters = Mathf.Max(1, fighters);
|
||||
if (ensemble != null && ensemble.HasFocus)
|
||||
{
|
||||
follow = ensemble.Focus;
|
||||
related = ensemble.Related ?? related;
|
||||
}
|
||||
|
||||
string label = ensemble != null
|
||||
? EventReason.Combat(ensemble)
|
||||
: EventReason.Fight(follow, related);
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = EventCatalog.Combat.Key(id),
|
||||
Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id),
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "Combat",
|
||||
Source = "scanner",
|
||||
|
|
@ -155,6 +165,21 @@ public static class WorldActivityScanner
|
|||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||
Completion = InterestCompletionKind.CombatActive
|
||||
};
|
||||
if (ensemble != null)
|
||||
{
|
||||
StampParticipantIds(candidate, ensemble);
|
||||
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||
{
|
||||
candidate.AssetId = "live_battle";
|
||||
}
|
||||
|
||||
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)
|
||||
&& ensemble.Frame == EnsembleFrame.KingdomVsKingdom)
|
||||
{
|
||||
candidate.KingdomKey = ensemble.SideA.Key;
|
||||
}
|
||||
}
|
||||
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
|
@ -230,7 +255,10 @@ public static class WorldActivityScanner
|
|||
|
||||
int deaths = battle.getDeathsTotal();
|
||||
Vector3 pos = battle.tile.posV3;
|
||||
CountFightCluster(pos, 14f, out int fighters, out int notables, out Actor follow);
|
||||
LiveEnsemble.TryBuildCombat(pos, 14f, out LiveEnsemble ensemble);
|
||||
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
|
||||
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||||
Actor follow = ensemble != null ? ensemble.Focus : null;
|
||||
if (deaths < 1 && fighters < 2)
|
||||
{
|
||||
continue;
|
||||
|
|
@ -241,12 +269,16 @@ public static class WorldActivityScanner
|
|||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
|
||||
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
|
||||
string label = ensemble != null && ensemble.HasFocus
|
||||
? EventReason.Combat(ensemble)
|
||||
: EventReason.Battle(focus, fighters);
|
||||
best = new InterestEvent
|
||||
{
|
||||
Score = score,
|
||||
Position = pos,
|
||||
FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
|
||||
FollowUnit = focus,
|
||||
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||||
Label = label,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
AssetId = "live_battle",
|
||||
|
|
@ -289,7 +321,10 @@ public static class WorldActivityScanner
|
|||
continue;
|
||||
}
|
||||
|
||||
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out Actor follow);
|
||||
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
|
||||
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
|
||||
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
|
||||
Actor follow = ensemble != null ? ensemble.Focus : null;
|
||||
if (fighters < 1)
|
||||
{
|
||||
continue;
|
||||
|
|
@ -302,12 +337,16 @@ public static class WorldActivityScanner
|
|||
{
|
||||
bestScore = score;
|
||||
Actor focus = follow ?? actor;
|
||||
string label = ensemble != null && ensemble.HasFocus
|
||||
? EventReason.Combat(ensemble)
|
||||
: EventReason.Battle(focus, fighters);
|
||||
best = new InterestEvent
|
||||
{
|
||||
Score = score,
|
||||
Position = actor.current_position,
|
||||
FollowUnit = focus,
|
||||
Label = EventReason.Battle(focus, fighters),
|
||||
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||||
Label = label,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
AssetId = "live_battle",
|
||||
ParticipantCount = fighters,
|
||||
|
|
@ -370,7 +409,7 @@ public static class WorldActivityScanner
|
|||
|
||||
/// <summary>
|
||||
/// Highest-scored living participant for an active combat scene.
|
||||
/// Considers Follow, Related, and (for mass fights) the nearby fight cluster.
|
||||
/// Considers Follow, Related, and (for mass fights) the nearby fight ensemble.
|
||||
/// </summary>
|
||||
public static bool TryPickBestCombatFocus(
|
||||
InterestCandidate scene,
|
||||
|
|
@ -386,6 +425,35 @@ public static class WorldActivityScanner
|
|||
return false;
|
||||
}
|
||||
|
||||
LiveEnsemble ensemble = null;
|
||||
Vector3 pos = scene.Position;
|
||||
if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
|
||||
{
|
||||
pos = scene.FollowUnit.current_position;
|
||||
}
|
||||
else if (scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
|
||||
{
|
||||
pos = scene.RelatedUnit.current_position;
|
||||
}
|
||||
|
||||
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| scene.ParticipantCount > 2;
|
||||
float radius = mass ? 14f : 10f;
|
||||
LiveEnsemble.TryBuildCombat(
|
||||
pos,
|
||||
radius,
|
||||
out ensemble,
|
||||
priorParticipantIds: scene.ParticipantIds,
|
||||
newcomerBonus: 8f);
|
||||
|
||||
if (ensemble != null && ensemble.HasFocus)
|
||||
{
|
||||
best = ensemble.Focus;
|
||||
foe = ensemble.Related;
|
||||
fighters = Mathf.Max(1, ensemble.ParticipantCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
fighters = Mathf.Max(1, scene.ParticipantCount);
|
||||
float bestWeight = -1f;
|
||||
Actor bestLocal = null;
|
||||
|
|
@ -397,7 +465,7 @@ public static class WorldActivityScanner
|
|||
return;
|
||||
}
|
||||
|
||||
float w = CombatFocusWeight(unit);
|
||||
float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
|
||||
if (w > bestWeight)
|
||||
{
|
||||
bestWeight = w;
|
||||
|
|
@ -407,37 +475,13 @@ public static class WorldActivityScanner
|
|||
|
||||
Consider(scene.FollowUnit);
|
||||
Consider(scene.RelatedUnit);
|
||||
|
||||
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| scene.ParticipantCount > 2;
|
||||
Vector3 pos = scene.Position;
|
||||
if (bestLocal != null)
|
||||
{
|
||||
pos = bestLocal.current_position;
|
||||
}
|
||||
else if (scene.FollowUnit != null)
|
||||
{
|
||||
pos = scene.FollowUnit.current_position;
|
||||
}
|
||||
else if (scene.RelatedUnit != null)
|
||||
{
|
||||
pos = scene.RelatedUnit.current_position;
|
||||
}
|
||||
|
||||
if (mass || bestLocal == null)
|
||||
{
|
||||
float radius = mass ? 14f : 10f;
|
||||
CountFightCluster(pos, radius, out int clusterFighters, out _, out Actor clusterBest);
|
||||
fighters = Mathf.Max(fighters, clusterFighters);
|
||||
Consider(clusterBest);
|
||||
}
|
||||
|
||||
best = bestLocal;
|
||||
if (best == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foe = null;
|
||||
try
|
||||
{
|
||||
if (best.has_attack_target
|
||||
|
|
@ -474,6 +518,39 @@ public static class WorldActivityScanner
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Build a combat ensemble snapshot at a world position.</summary>
|
||||
public static bool TryBuildCombatEnsemble(
|
||||
Vector2 pos,
|
||||
float radius,
|
||||
out LiveEnsemble ensemble,
|
||||
InterestCandidate prior = null)
|
||||
{
|
||||
return LiveEnsemble.TryBuildCombat(
|
||||
pos,
|
||||
radius,
|
||||
out ensemble,
|
||||
priorParticipantIds: prior?.ParticipantIds,
|
||||
newcomerBonus: 8f);
|
||||
}
|
||||
|
||||
public static void StampParticipantIds(InterestCandidate candidate, LiveEnsemble ensemble)
|
||||
{
|
||||
if (candidate == null || ensemble == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
candidate.ParticipantIds.Clear();
|
||||
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
|
||||
{
|
||||
long id = ensemble.ParticipantIds[i];
|
||||
if (id != 0 && !candidate.ParticipantIds.Contains(id))
|
||||
{
|
||||
candidate.ParticipantIds.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static float ScoreFightCluster(int deaths, int fighters, int notables)
|
||||
{
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
|
|
@ -512,54 +589,14 @@ public static class WorldActivityScanner
|
|||
fighters = 0;
|
||||
notables = 0;
|
||||
bestFollow = null;
|
||||
float bestChar = -1f;
|
||||
float r2 = radius * radius;
|
||||
foreach (Actor actor in EnumerateAliveUnits())
|
||||
if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
|
||||
{
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float dx = actor.current_position.x - pos.x;
|
||||
float dy = actor.current_position.y - pos.y;
|
||||
if (dx * dx + dy * dy > r2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool fighting = false;
|
||||
try
|
||||
{
|
||||
fighting = actor.has_attack_target
|
||||
|| (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat);
|
||||
}
|
||||
catch
|
||||
{
|
||||
fighting = false;
|
||||
}
|
||||
|
||||
if (!fighting)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fighters++;
|
||||
bool notable = InterestScoring.IsNotable(actor);
|
||||
bool extreme = InterestScoring.IsExtremelyNotable(actor);
|
||||
if (notable)
|
||||
{
|
||||
notables++;
|
||||
}
|
||||
|
||||
SplitActorScore(actor, null, out _, out float charScore);
|
||||
float weight = charScore + (extreme ? 30f : 0f);
|
||||
if (weight > bestChar)
|
||||
{
|
||||
bestChar = weight;
|
||||
bestFollow = actor;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fighters = ensemble.ParticipantCount;
|
||||
notables = ensemble.NotableParticipantCount;
|
||||
bestFollow = ensemble.Focus;
|
||||
}
|
||||
|
||||
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.25.52",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Dossier nametag tracks focus on handoff.",
|
||||
"version": "0.25.54",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"massBaseBonus": 28,
|
||||
"massPerExtraFighter": 4,
|
||||
"massExtraCap": 24,
|
||||
"massCrowdThreshold": 8,
|
||||
"duelMaxFighters": 2,
|
||||
"duelNotablePairBonus": 55,
|
||||
"duelSingleNotableBonus": 15,
|
||||
|
|
|
|||
|
|
@ -88,4 +88,6 @@ Done (see [camera-presentability-plan.md](camera-presentability-plan.md) and `co
|
|||
Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption;
|
||||
`WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`).
|
||||
|
||||
Done (0.25.54): tiered battle reasons (`Battle - [Kingdom] side (n) vs …`) + combat focus hysteresis.
|
||||
|
||||
Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`).
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ See [`camera-presentability-plan.md`](camera-presentability-plan.md).
|
|||
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
|
||||
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
|
||||
| Combat dossier nametag matches focus | `combat_focus` / `dossier_matches_focus` |
|
||||
| Combat 1v1 escalates to sided ensemble | `combat_focus` / `combat_ensemble_escalate` + `tip_matches_any` (`Battle -` / ` vs `) |
|
||||
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
|
||||
|
||||
Live idle soak helper (Player.log window):
|
||||
|
|
|
|||
|
|
@ -28,6 +28,34 @@ Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSu
|
|||
|
||||
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
|
||||
|
||||
## Live ensembles (escalating multi-actor)
|
||||
|
||||
Combat is the first consumer of `LiveEnsemble` ([`Events/LiveEnsemble.cs`](../IdleSpectator/Events/LiveEnsemble.cs)).
|
||||
The director refreshes counts, sides, focus, and `EventReason.Combat` while `CombatActive` stays sticky.
|
||||
|
||||
| Scale | Fighters | Typical reason |
|
||||
|-------|----------|----------------|
|
||||
| Pair | ≤2 | `Duel - Rosado vs Waaf` |
|
||||
| Skirmish / Battle | 3+ | `Battle - Essiona (10) vs Northreach (3)` or `Battle - humans (10) vs wolves (3)` |
|
||||
| Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` |
|
||||
|
||||
Optional kingdom brackets on species sides: `Battle - [Essiona] humans (10) vs wolves (3)`.
|
||||
Side framing priority: kingdom vs kingdom → species vs species → attack camps / named leaders → count-only.
|
||||
Names are humanized (no raw `nomads_human` ids).
|
||||
|
||||
### Other families (same snapshot later)
|
||||
|
||||
| Kind | Live signal | Side framing |
|
||||
|------|-------------|--------------|
|
||||
| Combat | attack_target / in_combat cluster | kingdom → species → camps |
|
||||
| WarFront | `WarInterestFeed` + nearby battles | kingdom vs kingdom |
|
||||
| StatusOutbreak | same status id in radius | species / city |
|
||||
| FamilyPack | family_group peers | family / species |
|
||||
| PlotCell | plot members / phase | kingdom / plot type |
|
||||
| DiscoveryCluster | pending first-seens near anchor | species |
|
||||
|
||||
Shared pieces: `LiveEnsemble`, side helpers, `EventReason.Ensemble`, maintain/write-back pattern in `InterestDirector`.
|
||||
|
||||
## EventReason
|
||||
|
||||
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
||||
|
|
|
|||
Loading…
Reference in a new issue