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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "combat_ensemble_escalate":
|
||||||
|
DoCombatEnsembleEscalate(cmd);
|
||||||
|
break;
|
||||||
|
|
||||||
case "interest_variety_clear":
|
case "interest_variety_clear":
|
||||||
InterestVariety.Clear();
|
InterestVariety.Clear();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
|
|
@ -3542,7 +3546,19 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
string label = string.IsNullOrEmpty(cmd.label)
|
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));
|
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
|
||||||
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
|
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
|
||||||
InterestCandidate c = InterestFeeds.RegisterHarness(
|
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}'");
|
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)
|
private static void DoSetSetting(HarnessCommand cmd)
|
||||||
{
|
{
|
||||||
string key = cmd.expect;
|
string key = cmd.expect;
|
||||||
|
|
@ -3711,8 +3874,9 @@ public static class AgentHarness
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|
||||||
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
|
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
|
||||||
// Also accept Battle-style where name leads.
|
|| IsEnsembleCombatTip(tip);
|
||||||
|
// Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
|
||||||
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
detail = $"tip='{tip}' focus='{name}' pass={pass}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3727,6 +3891,43 @@ public static class AgentHarness
|
||||||
detail = $"tip='{tip}' not_contains='{needle}'";
|
detail = $"tip='{tip}' not_contains='{needle}'";
|
||||||
break;
|
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":
|
case "unit_asset":
|
||||||
{
|
{
|
||||||
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
|
||||||
|
|
@ -6688,6 +6889,20 @@ public static class AgentHarness
|
||||||
return SafeName(MoveCamera._focus_unit);
|
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()
|
private static long ResolveActivitySubjectId()
|
||||||
{
|
{
|
||||||
// Prefer the camera focus (harness apply/assert target) over watch caption,
|
// 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)
|
public static string Battle(Actor focus, int fighters)
|
||||||
{
|
{
|
||||||
string name = Name(focus);
|
// Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing.
|
||||||
if (string.IsNullOrEmpty(name))
|
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 (ensemble.Frame == EnsembleFrame.NamedPair
|
||||||
if (n <= 2)
|
|| 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)
|
public static string SeekingLover(Actor a)
|
||||||
|
|
|
||||||
|
|
@ -353,8 +353,19 @@ public static class InterestFeeds
|
||||||
|
|
||||||
string verb = taskOrBeat ?? "fighting";
|
string verb = taskOrBeat ?? "fighting";
|
||||||
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
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);
|
long followId = SafeId(follow ?? actor);
|
||||||
string key = EventCatalog.Combat.Key(followId);
|
string key = EventCatalog.Combat.Key(followId);
|
||||||
|
string label = ensemble != null
|
||||||
|
? EventReason.Combat(ensemble)
|
||||||
|
: EventReason.Fight(follow ?? actor, related);
|
||||||
var candidate = new InterestCandidate
|
var candidate = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = key,
|
Key = key,
|
||||||
|
|
@ -368,12 +379,14 @@ public static class InterestFeeds
|
||||||
RelatedUnit = related,
|
RelatedUnit = related,
|
||||||
SubjectId = followId,
|
SubjectId = followId,
|
||||||
RelatedId = related != null ? SafeId(related) : 0,
|
RelatedId = related != null ? SafeId(related) : 0,
|
||||||
Label = EventReason.Fight(follow ?? actor, related),
|
Label = label,
|
||||||
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
||||||
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
||||||
Verb = verb,
|
Verb = verb,
|
||||||
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
|
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
|
||||||
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.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,
|
CreatedAt = Time.unscaledTime,
|
||||||
LastSeenAt = Time.unscaledTime,
|
LastSeenAt = Time.unscaledTime,
|
||||||
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
||||||
|
|
@ -381,15 +394,34 @@ public static class InterestFeeds
|
||||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||||
Completion = InterestCompletionKind.CombatActive
|
Completion = InterestCompletionKind.CombatActive
|
||||||
};
|
};
|
||||||
candidate.ParticipantIds.Add(followId);
|
if (ensemble != null)
|
||||||
if (related != null)
|
|
||||||
{
|
{
|
||||||
long rid = SafeId(related);
|
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
|
||||||
if (rid != 0)
|
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);
|
InterestScoring.ScoreCheap(candidate);
|
||||||
EventFeedUtil.RegisterCandidate(candidate);
|
EventFeedUtil.RegisterCandidate(candidate);
|
||||||
}
|
}
|
||||||
|
|
@ -1031,7 +1063,15 @@ public static class InterestFeeds
|
||||||
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
|
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
|
||||||
if (battle != null)
|
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).
|
// 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.
|
// 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("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("cf22", "interest_clear_follow", asset: "wolf"),
|
||||||
Step("cf23", "combat_maintain_focus"),
|
Step("cf23", "combat_maintain_focus"),
|
||||||
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
||||||
|
|
@ -1065,13 +1065,25 @@ internal static class HarnessScenarios
|
||||||
Step("cf25b", "assert", expect: "dossier_matches_focus"),
|
Step("cf25b", "assert", expect: "dossier_matches_focus"),
|
||||||
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
|
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("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("cf32", "interest_release_force"),
|
||||||
Step("cf33", "interest_mark_inactive", value: "1"),
|
Step("cf33", "interest_mark_inactive", value: "1"),
|
||||||
Step("cf34", "combat_maintain_focus"),
|
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("cf90", "fast_timing", value: "false"),
|
||||||
Step("cf99", "snapshot"),
|
Step("cf99", "snapshot"),
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ public static class InterestDirector
|
||||||
private static float _enabledAt = -999f;
|
private static float _enabledAt = -999f;
|
||||||
private static float _inactiveSince = -999f;
|
private static float _inactiveSince = -999f;
|
||||||
private static float _lastCombatFocusAt = -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);
|
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
|
||||||
|
|
||||||
public static string CurrentTierName => CurrentScoreLabel;
|
public static string CurrentTierName => CurrentScoreLabel;
|
||||||
|
|
@ -506,8 +508,9 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
|
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
|
||||||
/// so the subject always matches focus. Clears fight Labels once combat goes cold.
|
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
|
||||||
|
/// Clears fight Labels once combat goes cold.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void MaintainCombatFocus(float now, bool force)
|
private static void MaintainCombatFocus(float now, bool force)
|
||||||
{
|
{
|
||||||
|
|
@ -529,18 +532,188 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastCombatFocusAt = now;
|
_lastCombatFocusAt = now;
|
||||||
if (!WorldActivityScanner.TryPickBestCombatFocus(
|
ApplyCombatEnsembleToCurrent(forceWatch: false);
|
||||||
_current, out Actor best, out Actor foe, out int fighters))
|
}
|
||||||
|
|
||||||
|
/// <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)
|
Vector3 pos = _current.Position;
|
||||||
|| fighters > 2
|
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
|
||||||
|| _current.ParticipantCount > 2;
|
{
|
||||||
string label = mass
|
pos = _current.FollowUnit.current_position;
|
||||||
? EventReason.Battle(best, fighters)
|
}
|
||||||
: EventReason.Fight(best, foe);
|
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 followChanged = _current.FollowUnit != best;
|
||||||
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
|
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
|
||||||
|
|
@ -558,13 +731,133 @@ public static class InterestDirector
|
||||||
// keep prior position
|
// keep prior position
|
||||||
}
|
}
|
||||||
|
|
||||||
if (followChanged
|
if (forceWatch
|
||||||
|
|| followChanged
|
||||||
|| labelChanged
|
|| labelChanged
|
||||||
|| !HasLivingCameraFocus()
|
|| !HasLivingCameraFocus()
|
||||||
|| MoveCamera._focus_unit != best)
|
|| MoveCamera._focus_unit != best)
|
||||||
{
|
{
|
||||||
CameraDirector.Watch(_current.ToInterestEvent());
|
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)
|
private static void ClearStaleCombatLabel(float now)
|
||||||
|
|
@ -574,8 +867,16 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
|
string label = _current.Label;
|
||||||
&& _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,16 @@ public static class InterestRegistry
|
||||||
existing.RelatedId = incoming.RelatedId;
|
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)
|
if (incoming.ForceActive)
|
||||||
{
|
{
|
||||||
existing.ForceActive = true;
|
existing.ForceActive = true;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ public class ScoringWeights
|
||||||
public float massBaseBonus = 28f;
|
public float massBaseBonus = 28f;
|
||||||
public float massPerExtraFighter = 4f;
|
public float massPerExtraFighter = 4f;
|
||||||
public float massExtraCap = 24f;
|
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 int duelMaxFighters = 2;
|
||||||
public float duelNotablePairBonus = 55f;
|
public float duelNotablePairBonus = 55f;
|
||||||
public float duelSingleNotableBonus = 15f;
|
public float duelSingleNotableBonus = 15f;
|
||||||
|
|
|
||||||
|
|
@ -348,12 +348,16 @@ public sealed class UnitDossier
|
||||||
// Ownership: never let the reason name a living stranger (not subject/related).
|
// Ownership: never let the reason name a living stranger (not subject/related).
|
||||||
if (ReasonNamesStranger(beat, scene))
|
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.
|
// Ownership: reject beats that lead with someone else's proper name.
|
||||||
|
|
@ -472,6 +476,9 @@ public sealed class UnitDossier
|
||||||
case "fighting":
|
case "fighting":
|
||||||
case "war":
|
case "war":
|
||||||
case "battle":
|
case "battle":
|
||||||
|
case "duel":
|
||||||
|
case "skirmish":
|
||||||
|
case "mass":
|
||||||
case "clash":
|
case "clash":
|
||||||
case "grief":
|
case "grief":
|
||||||
case "mourning":
|
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)
|
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
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(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|| t.IndexOf(" from ", 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("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|| t.IndexOf(':') >= 0;
|
|| t.IndexOf(':') >= 0;
|
||||||
|
|
@ -998,7 +1024,7 @@ public sealed class UnitDossier
|
||||||
sb.Append(" · ");
|
sb.Append(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.Append(d.KingdomName);
|
sb.Append(LiveEnsemble.KingdomDisplay(d.KingdomName));
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(d.CityName)
|
else if (!string.IsNullOrEmpty(d.CityName)
|
||||||
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)
|
&& !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);
|
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);
|
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
|
var candidate = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = EventCatalog.Combat.Key(id),
|
Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id),
|
||||||
LeadKind = InterestLeadKind.EventLed,
|
LeadKind = InterestLeadKind.EventLed,
|
||||||
Category = "Combat",
|
Category = "Combat",
|
||||||
Source = "scanner",
|
Source = "scanner",
|
||||||
|
|
@ -155,6 +165,21 @@ public static class WorldActivityScanner
|
||||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||||
Completion = InterestCompletionKind.CombatActive
|
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);
|
InterestScoring.ScoreCheap(candidate);
|
||||||
EventFeedUtil.RegisterCandidate(candidate);
|
EventFeedUtil.RegisterCandidate(candidate);
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +255,10 @@ public static class WorldActivityScanner
|
||||||
|
|
||||||
int deaths = battle.getDeathsTotal();
|
int deaths = battle.getDeathsTotal();
|
||||||
Vector3 pos = battle.tile.posV3;
|
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)
|
if (deaths < 1 && fighters < 2)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -241,12 +269,16 @@ public static class WorldActivityScanner
|
||||||
if (score > bestScore)
|
if (score > bestScore)
|
||||||
{
|
{
|
||||||
bestScore = score;
|
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
|
best = new InterestEvent
|
||||||
{
|
{
|
||||||
Score = score,
|
Score = score,
|
||||||
Position = pos,
|
Position = pos,
|
||||||
FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
|
FollowUnit = focus,
|
||||||
|
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||||||
Label = label,
|
Label = label,
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
AssetId = "live_battle",
|
AssetId = "live_battle",
|
||||||
|
|
@ -289,7 +321,10 @@ public static class WorldActivityScanner
|
||||||
continue;
|
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)
|
if (fighters < 1)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -302,12 +337,16 @@ public static class WorldActivityScanner
|
||||||
{
|
{
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
Actor focus = follow ?? actor;
|
Actor focus = follow ?? actor;
|
||||||
|
string label = ensemble != null && ensemble.HasFocus
|
||||||
|
? EventReason.Combat(ensemble)
|
||||||
|
: EventReason.Battle(focus, fighters);
|
||||||
best = new InterestEvent
|
best = new InterestEvent
|
||||||
{
|
{
|
||||||
Score = score,
|
Score = score,
|
||||||
Position = actor.current_position,
|
Position = actor.current_position,
|
||||||
FollowUnit = focus,
|
FollowUnit = focus,
|
||||||
Label = EventReason.Battle(focus, fighters),
|
RelatedUnit = ensemble != null ? ensemble.Related : null,
|
||||||
|
Label = label,
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
AssetId = "live_battle",
|
AssetId = "live_battle",
|
||||||
ParticipantCount = fighters,
|
ParticipantCount = fighters,
|
||||||
|
|
@ -370,7 +409,7 @@ public static class WorldActivityScanner
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Highest-scored living participant for an active combat scene.
|
/// 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>
|
/// </summary>
|
||||||
public static bool TryPickBestCombatFocus(
|
public static bool TryPickBestCombatFocus(
|
||||||
InterestCandidate scene,
|
InterestCandidate scene,
|
||||||
|
|
@ -386,6 +425,35 @@ public static class WorldActivityScanner
|
||||||
return false;
|
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);
|
fighters = Mathf.Max(1, scene.ParticipantCount);
|
||||||
float bestWeight = -1f;
|
float bestWeight = -1f;
|
||||||
Actor bestLocal = null;
|
Actor bestLocal = null;
|
||||||
|
|
@ -397,7 +465,7 @@ public static class WorldActivityScanner
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
float w = CombatFocusWeight(unit);
|
float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
|
||||||
if (w > bestWeight)
|
if (w > bestWeight)
|
||||||
{
|
{
|
||||||
bestWeight = w;
|
bestWeight = w;
|
||||||
|
|
@ -407,37 +475,13 @@ public static class WorldActivityScanner
|
||||||
|
|
||||||
Consider(scene.FollowUnit);
|
Consider(scene.FollowUnit);
|
||||||
Consider(scene.RelatedUnit);
|
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;
|
best = bestLocal;
|
||||||
if (best == null)
|
if (best == null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foe = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (best.has_attack_target
|
if (best.has_attack_target
|
||||||
|
|
@ -474,6 +518,39 @@ public static class WorldActivityScanner
|
||||||
return true;
|
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)
|
private static float ScoreFightCluster(int deaths, int fighters, int notables)
|
||||||
{
|
{
|
||||||
ScoringWeights w = InterestScoringConfig.W;
|
ScoringWeights w = InterestScoringConfig.W;
|
||||||
|
|
@ -512,54 +589,14 @@ public static class WorldActivityScanner
|
||||||
fighters = 0;
|
fighters = 0;
|
||||||
notables = 0;
|
notables = 0;
|
||||||
bestFollow = null;
|
bestFollow = null;
|
||||||
float bestChar = -1f;
|
if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
|
||||||
float r2 = radius * radius;
|
|
||||||
foreach (Actor actor in EnumerateAliveUnits())
|
|
||||||
{
|
{
|
||||||
if (actor == null || !actor.isAlive())
|
return;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fighters = ensemble.ParticipantCount;
|
||||||
|
notables = ensemble.NotableParticipantCount;
|
||||||
|
bestFollow = ensemble.Focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)
|
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "IdleSpectator",
|
"name": "IdleSpectator",
|
||||||
"author": "dazed",
|
"author": "dazed",
|
||||||
"version": "0.25.52",
|
"version": "0.25.54",
|
||||||
"description": "AFK Idle Spectator (I) + Lore (L). Dossier nametag tracks focus on handoff.",
|
"description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.",
|
||||||
"GUID": "com.dazed.idlespectator"
|
"GUID": "com.dazed.idlespectator"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"massBaseBonus": 28,
|
"massBaseBonus": 28,
|
||||||
"massPerExtraFighter": 4,
|
"massPerExtraFighter": 4,
|
||||||
"massExtraCap": 24,
|
"massExtraCap": 24,
|
||||||
|
"massCrowdThreshold": 8,
|
||||||
"duelMaxFighters": 2,
|
"duelMaxFighters": 2,
|
||||||
"duelNotablePairBonus": 55,
|
"duelNotablePairBonus": 55,
|
||||||
"duelSingleNotableBonus": 15,
|
"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;
|
Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption;
|
||||||
`WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`).
|
`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`).
|
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` |
|
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
|
||||||
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
|
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
|
||||||
| Combat dossier nametag matches focus | `combat_focus` / `dossier_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 |
|
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
|
||||||
|
|
||||||
Live idle soak helper (Player.log window):
|
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`.
|
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
|
## EventReason
|
||||||
|
|
||||||
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue