Fighting upgrade
This commit is contained in:
parent
e37cbf7890
commit
7dda02bcea
9 changed files with 1199 additions and 80 deletions
|
|
@ -2017,6 +2017,10 @@ public static class AgentHarness
|
||||||
DoCombatEnsembleEscalate(cmd);
|
DoCombatEnsembleEscalate(cmd);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "combat_wire_attack_sides":
|
||||||
|
DoCombatWireAttackSides(cmd);
|
||||||
|
break;
|
||||||
|
|
||||||
case "interest_variety_clear":
|
case "interest_variety_clear":
|
||||||
InterestVariety.Clear();
|
InterestVariety.Clear();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
|
|
@ -3578,6 +3582,7 @@ public static class AgentHarness
|
||||||
{
|
{
|
||||||
c.Category = "Combat";
|
c.Category = "Combat";
|
||||||
c.Verb = "fighting";
|
c.Verb = "fighting";
|
||||||
|
c.ClearCombatSticky();
|
||||||
if (related != null)
|
if (related != null)
|
||||||
{
|
{
|
||||||
c.ParticipantCount = 2;
|
c.ParticipantCount = 2;
|
||||||
|
|
@ -3623,6 +3628,18 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
|
|
||||||
Actor related = scene.RelatedUnit;
|
Actor related = scene.RelatedUnit;
|
||||||
|
if ((focus == null || !focus.isAlive()) && related != null && related.isAlive())
|
||||||
|
{
|
||||||
|
focus = related;
|
||||||
|
related = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focus == null || !focus.isAlive())
|
||||||
|
{
|
||||||
|
// Last resort: nearest living unit so escalate can still seed a pack.
|
||||||
|
focus = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
||||||
|
}
|
||||||
|
|
||||||
if (focus == null || !focus.isAlive())
|
if (focus == null || !focus.isAlive())
|
||||||
{
|
{
|
||||||
_cmdFail++;
|
_cmdFail++;
|
||||||
|
|
@ -3630,6 +3647,7 @@ public static class AgentHarness
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scene.ClearCombatSticky();
|
||||||
int want = 6;
|
int want = 6;
|
||||||
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
|
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
|
||||||
{
|
{
|
||||||
|
|
@ -3709,6 +3727,189 @@ public static class AgentHarness
|
||||||
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
|
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort: set attack_target across two species camps near the combat focus
|
||||||
|
/// so <see cref="LiveEnsemble.TryBuildCombat"/> sees a real multi-fighter cluster.
|
||||||
|
/// asset = side A species (default human), value = side B species (default wolf).
|
||||||
|
/// </summary>
|
||||||
|
private static void DoCombatWireAttackSides(HarnessCommand cmd)
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
Actor anchor = scene?.FollowUnit;
|
||||||
|
if (anchor == null || !anchor.isAlive())
|
||||||
|
{
|
||||||
|
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor == null || !anchor.isAlive())
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_anchor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string sideA = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
|
||||||
|
string sideB = string.IsNullOrEmpty(cmd.value) ? "wolf" : cmd.value.Trim();
|
||||||
|
bool pairOnly = (cmd.expect ?? "").IndexOf("pair", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| string.Equals(cmd.label, "pair", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (pairOnly)
|
||||||
|
{
|
||||||
|
Actor a = scene?.FollowUnit ?? anchor;
|
||||||
|
Actor b = scene?.RelatedUnit;
|
||||||
|
if (b == null || !b.isAlive())
|
||||||
|
{
|
||||||
|
// Nearest opposite-species unit.
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor == null || actor == a || !actor.isAlive() || actor.asset == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.asset.id.Equals(sideB, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
b = actor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int wiredPair = 0;
|
||||||
|
if (TrySetAttackTarget(a, b))
|
||||||
|
{
|
||||||
|
wiredPair++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TrySetAttackTarget(b, a))
|
||||||
|
{
|
||||||
|
wiredPair++;
|
||||||
|
}
|
||||||
|
|
||||||
|
InterestDirector.HarnessMaintainCombatFocus();
|
||||||
|
bool okPair = wiredPair > 0;
|
||||||
|
if (okPair)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
okPair,
|
||||||
|
detail: $"pair wired={wiredPair} a={SafeName(a)} b={SafeName(b)} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var campA = new List<Actor>(8);
|
||||||
|
var campB = new List<Actor>(8);
|
||||||
|
Vector2 pos = anchor.current_position;
|
||||||
|
const float r2 = 16f * 16f;
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor == null || !actor.isAlive() || actor.asset == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float dx = actor.current_position.x - pos.x;
|
||||||
|
float dy = actor.current_position.y - pos.y;
|
||||||
|
if (dx * dx + dy * dy > r2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = actor.asset.id ?? "";
|
||||||
|
if (id.Equals(sideA, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
campA.Add(actor);
|
||||||
|
}
|
||||||
|
else if (id.Equals(sideB, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
campB.Add(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campA.Count == 0 || campB.Count == 0)
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: $"empty camps a={campA.Count} b={campB.Count} near={SafeName(anchor)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int wired = 0;
|
||||||
|
for (int i = 0; i < campA.Count; i++)
|
||||||
|
{
|
||||||
|
if (TrySetAttackTarget(campA[i], campB[i % campB.Count]))
|
||||||
|
{
|
||||||
|
wired++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < campB.Count; i++)
|
||||||
|
{
|
||||||
|
if (TrySetAttackTarget(campB[i], campA[i % campA.Count]))
|
||||||
|
{
|
||||||
|
wired++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InterestDirector.HarnessMaintainCombatFocus();
|
||||||
|
bool ok = wired > 0;
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
ok,
|
||||||
|
detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TrySetAttackTarget(Actor unit, Actor foe)
|
||||||
|
{
|
||||||
|
if (unit == null || foe == null || !unit.isAlive() || !foe.isAlive() || unit == foe)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mi = typeof(Actor).GetMethod(
|
||||||
|
"setAttackTarget",
|
||||||
|
System.Reflection.BindingFlags.Instance
|
||||||
|
| System.Reflection.BindingFlags.Public
|
||||||
|
| System.Reflection.BindingFlags.NonPublic);
|
||||||
|
if (mi != null)
|
||||||
|
{
|
||||||
|
mi.Invoke(unit, new object[] { foe });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var prop = typeof(Actor).GetProperty(
|
||||||
|
"attack_target",
|
||||||
|
System.Reflection.BindingFlags.Instance
|
||||||
|
| System.Reflection.BindingFlags.Public
|
||||||
|
| System.Reflection.BindingFlags.NonPublic);
|
||||||
|
prop?.SetValue(unit, foe, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unit.has_attack_target;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter)
|
private static Actor SpawnAssetNear(string assetId, Vector3 near, float jitter)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -3938,7 +4139,24 @@ public static class AgentHarness
|
||||||
|
|
||||||
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
string unitAsset = unit?.asset != null ? unit.asset.id : "";
|
string unitAsset = unit?.asset != null ? unit.asset.id : "";
|
||||||
|
if (!string.IsNullOrEmpty(wantAsset) && wantAsset.IndexOf('|') >= 0)
|
||||||
|
{
|
||||||
|
string[] opts = wantAsset.Split('|');
|
||||||
|
pass = false;
|
||||||
|
for (int i = 0; i < opts.Length; i++)
|
||||||
|
{
|
||||||
|
if (opts[i].Trim().Equals(unitAsset, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
pass = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
|
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
|
||||||
|
}
|
||||||
|
|
||||||
detail = $"unitAsset={unitAsset} want={wantAsset}";
|
detail = $"unitAsset={unitAsset} want={wantAsset}";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ public static class EventReason
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
|
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
|
||||||
/// Multi-fighter form: <c>Battle - [Essiona] humans (10) vs wolves (3)</c>.
|
/// Multi form: <c>Battle - Humans (4) vs Wolves (0)</c> - counts may be 0 while sides stay sticky.
|
||||||
|
/// Never uses raw unit names as mass-side labels. Never prints thin <c>melee</c> fallbacks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string Combat(LiveEnsemble ensemble)
|
public static string Combat(LiveEnsemble ensemble)
|
||||||
{
|
{
|
||||||
|
|
@ -53,8 +54,8 @@ public static class EventReason
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ensemble.Frame == EnsembleFrame.NamedPair
|
// True 1v1 only when we do not already have sticky opposing camps.
|
||||||
|| ensemble.Scale == EnsembleScale.Pair)
|
if (ensemble.Scale == EnsembleScale.Pair && !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
{
|
{
|
||||||
string a = Name(ensemble.Focus);
|
string a = Name(ensemble.Focus);
|
||||||
string b = Name(ensemble.Related);
|
string b = Name(ensemble.Related);
|
||||||
|
|
@ -65,31 +66,67 @@ public static class EventReason
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(b))
|
if (string.IsNullOrEmpty(b))
|
||||||
{
|
{
|
||||||
return "Duel - " + a;
|
// Orphan duel with no foe - blank so director can keep sticky / clear.
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Duel - " + a + " vs " + b;
|
return "Duel - " + a + " vs " + b;
|
||||||
}
|
}
|
||||||
|
|
||||||
string tier = ScaleTierLabel(ensemble.Scale);
|
string tier = ScaleTierLabel(
|
||||||
|
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
|
||||||
|
|
||||||
|
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
|
{
|
||||||
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
|
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
|
||||||
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
|
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
|
||||||
bool sided = !string.IsNullOrEmpty(sideA)
|
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
|
||||||
&& !string.IsNullOrEmpty(sideB)
|
|
||||||
&& !sideA.Equals(sideB, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (sided)
|
|
||||||
{
|
{
|
||||||
return tier + " - " + sideA + " vs " + sideB;
|
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);
|
// Same-species pack with no opposing sticky camps.
|
||||||
|
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||||
|
{
|
||||||
|
string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies);
|
||||||
|
if (!string.IsNullOrEmpty(pack))
|
||||||
|
{
|
||||||
|
return tier + " - " + pack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never emit thin "X is fighting" from the multi path - leave blank for director reuse.
|
||||||
|
if (ensemble.Scale > EnsembleScale.Pair || LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fight(ensemble.Focus, ensemble.Related);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SidesAreSameCollective(string sideA, string sideB)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip counts: "Wolves (6)" → "Wolves"
|
||||||
|
string a = StripSideCount(sideA);
|
||||||
|
string b = StripSideCount(sideB);
|
||||||
|
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripSideCount(string side)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(side))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
int paren = side.LastIndexOf(" (", StringComparison.Ordinal);
|
||||||
|
return paren > 0 ? side.Substring(0, paren).Trim() : side.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ScaleTierLabel(EnsembleScale scale)
|
private static string ScaleTierLabel(EnsembleScale scale)
|
||||||
|
|
@ -114,6 +151,12 @@ public static class EventReason
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mass/skirmish never print unit proper names as a side.
|
||||||
|
if (frame == EnsembleFrame.NamedLeaders || frame == EnsembleFrame.NamedPair)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
string label = side.Display ?? "";
|
string label = side.Display ?? "";
|
||||||
if (string.IsNullOrEmpty(label))
|
if (string.IsNullOrEmpty(label))
|
||||||
{
|
{
|
||||||
|
|
@ -122,7 +165,7 @@ public static class EventReason
|
||||||
|
|
||||||
if (frame == EnsembleFrame.KingdomVsKingdom)
|
if (frame == EnsembleFrame.KingdomVsKingdom)
|
||||||
{
|
{
|
||||||
label = LiveEnsemble.KingdomDisplay(label);
|
label = LiveEnsemble.KingdomDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
|
||||||
}
|
}
|
||||||
else if (frame == EnsembleFrame.SpeciesVsSpecies)
|
else if (frame == EnsembleFrame.SpeciesVsSpecies)
|
||||||
{
|
{
|
||||||
|
|
@ -130,7 +173,6 @@ public static class EventReason
|
||||||
if (!string.IsNullOrEmpty(side.KingdomDisplay))
|
if (!string.IsNullOrEmpty(side.KingdomDisplay))
|
||||||
{
|
{
|
||||||
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
|
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
|
||||||
// Avoid "[Humans] humans" when kingdom label is just the species.
|
|
||||||
if (!string.IsNullOrEmpty(kingdom)
|
if (!string.IsNullOrEmpty(kingdom)
|
||||||
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
|
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
|
||||||
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
|
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
|
||||||
|
|
@ -139,12 +181,10 @@ public static class EventReason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (frame == EnsembleFrame.NamedLeaders)
|
else
|
||||||
{
|
{
|
||||||
if (side.Best != null)
|
// CountOnly / unknown - refuse unit-looking labels.
|
||||||
{
|
return "";
|
||||||
label = Name(side.Best);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(label))
|
if (string.IsNullOrEmpty(label))
|
||||||
|
|
@ -152,14 +192,9 @@ public static class EventReason
|
||||||
return "";
|
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) : "");
|
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||||
}
|
// Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin.
|
||||||
|
int n = Math.Max(0, side.Count);
|
||||||
int n = Math.Max(1, side.Count);
|
|
||||||
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,183 @@ public sealed class LiveEnsemble
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sticky scoreboard roster: add units currently fighting for each camp, then count every
|
||||||
|
/// tracked member that is still alive (combat or not) until the combat scene grace ends.
|
||||||
|
/// Dead ids are pruned. New combatants matching a sticky key are enrolled.
|
||||||
|
/// </summary>
|
||||||
|
public static void RefreshStickyMemberRoster(
|
||||||
|
Vector2 pos,
|
||||||
|
float radius,
|
||||||
|
EnsembleFrame frame,
|
||||||
|
string keyA,
|
||||||
|
string keyB,
|
||||||
|
List<long> sideAIds,
|
||||||
|
List<long> sideBIds,
|
||||||
|
out int countA,
|
||||||
|
out int countB,
|
||||||
|
out Actor bestA,
|
||||||
|
out Actor bestB)
|
||||||
|
{
|
||||||
|
countA = 0;
|
||||||
|
countB = 0;
|
||||||
|
bestA = null;
|
||||||
|
bestB = null;
|
||||||
|
if (sideAIds == null || sideBIds == null
|
||||||
|
|| string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float r2 = radius * radius;
|
||||||
|
// Enroll anyone currently fighting for a sticky camp.
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor == null || !actor.isAlive() || !IsCombatParticipant(actor))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float dx = actor.current_position.x - pos.x;
|
||||||
|
float dy = actor.current_position.y - pos.y;
|
||||||
|
if (dx * dx + dy * dy > r2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = frame == EnsembleFrame.KingdomVsKingdom
|
||||||
|
? KingdomKeyOf(actor)
|
||||||
|
: SpeciesKeyOf(actor);
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.Equals(keyA, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AddUniqueId(sideAIds, id);
|
||||||
|
}
|
||||||
|
else if (key.Equals(keyB, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AddUniqueId(sideBIds, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countA = CountAliveTracked(sideAIds, out bestA, preferCombat: true);
|
||||||
|
countB = CountAliveTracked(sideBIds, out bestB, preferCombat: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Actor FindBestAliveTracked(List<long> ids, bool preferCombat = true)
|
||||||
|
{
|
||||||
|
CountAliveTracked(ids, out Actor best, preferCombat);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddUniqueId(List<long> ids, long id)
|
||||||
|
{
|
||||||
|
if (id == 0 || ids == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < ids.Count; i++)
|
||||||
|
{
|
||||||
|
if (ids[i] == id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountAliveTracked(List<long> ids, out Actor best, bool preferCombat)
|
||||||
|
{
|
||||||
|
best = null;
|
||||||
|
if (ids == null || ids.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
float bestW = -1f;
|
||||||
|
int alive = 0;
|
||||||
|
for (int i = ids.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
long id = ids[i];
|
||||||
|
Actor actor = FindAliveById(id);
|
||||||
|
if (actor == null)
|
||||||
|
{
|
||||||
|
ids.RemoveAt(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
alive++;
|
||||||
|
float w = FocusWeight(actor, priorParticipantIds: null, newcomerBonus: 0f);
|
||||||
|
if (preferCombat && IsCombatParticipant(actor))
|
||||||
|
{
|
||||||
|
w += 40f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (w > bestW)
|
||||||
|
{
|
||||||
|
bestW = w;
|
||||||
|
best = actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Actor FindAliveById(long id)
|
||||||
|
{
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EventFeedUtil.SafeId(actor) == id)
|
||||||
|
{
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasOpposingCollectiveSides(LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (ensemble?.SideA == null || ensemble.SideB == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensemble.Frame != EnsembleFrame.SpeciesVsSpecies
|
||||||
|
&& ensemble.Frame != EnsembleFrame.KingdomVsKingdom)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(ensemble.SideA.Key) || string.IsNullOrEmpty(ensemble.SideB.Key))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ensemble.SideA.Key.Equals(ensemble.SideB.Key, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static void ApplyNamedPair(LiveEnsemble ensemble, List<Actor> fighters)
|
private static void ApplyNamedPair(LiveEnsemble ensemble, List<Actor> fighters)
|
||||||
{
|
{
|
||||||
ensemble.Frame = EnsembleFrame.NamedPair;
|
ensemble.Frame = EnsembleFrame.NamedPair;
|
||||||
|
|
@ -500,12 +677,7 @@ public sealed class LiveEnsemble
|
||||||
if (foe != null)
|
if (foe != null)
|
||||||
{
|
{
|
||||||
ensemble.SideB = SideFromActor(foe, "foe");
|
ensemble.SideB = SideFromActor(foe, "foe");
|
||||||
if (ensemble.NotableParticipantCount >= 2
|
// Multi-fighter: never NamedLeaders (unit-name sides). Species promotion happens in EventReason.
|
||||||
&& InterestScoring.IsNotable(ensemble.Focus)
|
|
||||||
&& InterestScoring.IsNotable(foe))
|
|
||||||
{
|
|
||||||
ensemble.Frame = EnsembleFrame.NamedLeaders;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -648,9 +820,28 @@ public sealed class LiveEnsemble
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensemble.Frame = EnsembleFrame.NamedLeaders;
|
// Prefer species framing over unit-named camps whenever species differ.
|
||||||
ensemble.SideA = BuildSide("camp:a", EventFeedUtil.SafeName(focus), sideA, prior, newcomerBonus, EnsembleFrame.NamedLeaders);
|
string spA = SpeciesKeyOf(focus);
|
||||||
ensemble.SideB = BuildSide("camp:b", EventFeedUtil.SafeName(foe), sideB, prior, newcomerBonus, EnsembleFrame.NamedLeaders);
|
string spB = SpeciesKeyOf(foe);
|
||||||
|
if (!string.IsNullOrEmpty(spA)
|
||||||
|
&& !string.IsNullOrEmpty(spB)
|
||||||
|
&& !spA.Equals(spB, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ensemble.Frame = EnsembleFrame.SpeciesVsSpecies;
|
||||||
|
ensemble.SideA = BuildSide(spA, SpeciesDisplay(spA), sideA, prior, newcomerBonus, EnsembleFrame.SpeciesVsSpecies);
|
||||||
|
ensemble.SideB = BuildSide(spB, SpeciesDisplay(spB), sideB, prior, newcomerBonus, EnsembleFrame.SpeciesVsSpecies);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Same species camps → count-only melee (EventReason prints "melee (n)").
|
||||||
|
ensemble.Frame = EnsembleFrame.CountOnly;
|
||||||
|
ensemble.SideA = null;
|
||||||
|
ensemble.SideB = null;
|
||||||
|
PreferFocusSide(ensemble);
|
||||||
|
ensemble.Related = foe;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
PreferFocusSide(ensemble);
|
PreferFocusSide(ensemble);
|
||||||
ensemble.Related = ensemble.SideB?.Best ?? foe;
|
ensemble.Related = ensemble.SideB?.Best ?? foe;
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ internal static class HarnessScenarios
|
||||||
case "combat_focus":
|
case "combat_focus":
|
||||||
case "combat_tip_align":
|
case "combat_tip_align":
|
||||||
return CombatFocusAlign();
|
return CombatFocusAlign();
|
||||||
|
case "combat_stability_live":
|
||||||
|
case "combat_live_proof":
|
||||||
|
return CombatStabilityLive();
|
||||||
case "director_action_priority":
|
case "director_action_priority":
|
||||||
case "action_priority":
|
case "action_priority":
|
||||||
return DirectorActionPriority();
|
return DirectorActionPriority();
|
||||||
|
|
@ -1065,10 +1068,11 @@ 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"),
|
||||||
|
|
||||||
// 1v1 → multi: sided species framing + participant write-back.
|
// 1v1 → multi: sided species framing + live sticky counts.
|
||||||
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
||||||
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
||||||
|
Step("cf42b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||||
Step("cf43", "assert", expect: "participant_count_min", value: "6"),
|
Step("cf43", "assert", expect: "participant_count_min", value: "6"),
|
||||||
Step("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
|
Step("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
|
||||||
Step("cf45", "assert", expect: "tip_matches_focus"),
|
Step("cf45", "assert", expect: "tip_matches_focus"),
|
||||||
|
|
@ -1090,6 +1094,75 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live pack-fight proof: scale hold vs Duel flap, collective species tip, sleeper cannot steal focus.
|
||||||
|
/// </summary>
|
||||||
|
private static List<HarnessCommand> CombatStabilityLive()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("csl0", "dismiss_windows"),
|
||||||
|
Step("csl1", "wait_world"),
|
||||||
|
Step("csl2", "set_setting", expect: "enabled", value: "true"),
|
||||||
|
Step("csl3", "fast_timing", value: "true"),
|
||||||
|
Step("csl4", "spawn", asset: "human", count: 1),
|
||||||
|
Step("csl5", "spawn", asset: "wolf", count: 1),
|
||||||
|
Step("csl6", "spectator", value: "off"),
|
||||||
|
Step("csl7", "spectator", value: "on"),
|
||||||
|
Step("csl8", "pick_unit", asset: "human"),
|
||||||
|
Step("csl9", "focus", asset: "human"),
|
||||||
|
Step("csl10", "interest_end_session"),
|
||||||
|
|
||||||
|
// Peak at 8 (Mass band). Sticky sides lock now; live counts start at 0 until wired.
|
||||||
|
Step("csl20", "interest_combat_session", asset: "human", value: "wolf", expect: "csl_pack"),
|
||||||
|
Step("csl21", "combat_ensemble_escalate", value: "8"),
|
||||||
|
Step("csl22", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||||
|
Step("csl22b", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("),
|
||||||
|
Step("csl24", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"),
|
||||||
|
|
||||||
|
// Live rebuild: sticky Humans vs Wolves, live counts (may be 1/1 after pair wire).
|
||||||
|
Step("csl30", "combat_maintain_focus"),
|
||||||
|
Step("csl31", "wait", value: "0.4"),
|
||||||
|
Step("csl32", "combat_maintain_focus"),
|
||||||
|
Step("csl33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||||
|
Step("csl34", "assert", expect: "tip_not_contains", value: "Duel"),
|
||||||
|
Step("csl35", "assert", expect: "tip_not_contains", value: "melee"),
|
||||||
|
Step("csl36", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("),
|
||||||
|
Step("csl37", "assert", expect: "tip_matches_any", value: " vs "),
|
||||||
|
|
||||||
|
// Wire both camps → counts climb back up on the same sticky frame.
|
||||||
|
Step("csl40", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||||
|
Step("csl41", "wait", value: "0.5"),
|
||||||
|
Step("csl42", "combat_maintain_focus"),
|
||||||
|
Step("csl42b", "assert", expect: "participant_count_min", value: "6"),
|
||||||
|
Step("csl43", "assert", expect: "tip_matches_any", value: "Humans (|Wolves ("),
|
||||||
|
Step("csl44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||||
|
Step("csl45", "assert", expect: "tip_not_contains", value: "Duel"),
|
||||||
|
Step("csl45b", "assert", expect: "tip_not_contains", value: "melee"),
|
||||||
|
Step("csl46", "assert", expect: "unit_asset", value: "human|wolf"),
|
||||||
|
Step("csl47", "assert", expect: "dossier_matches_focus"),
|
||||||
|
|
||||||
|
// Sleeper bystander must not stick the camera.
|
||||||
|
Step("csl50", "spawn", asset: "bear", count: 1),
|
||||||
|
Step("csl51", "focus", asset: "bear"),
|
||||||
|
Step("csl52", "status_apply", value: "sleeping", label: "8"),
|
||||||
|
Step("csl53", "wait", value: "0.35"),
|
||||||
|
Step("csl54", "combat_maintain_focus"),
|
||||||
|
Step("csl55", "wait", value: "0.35"),
|
||||||
|
Step("csl56", "combat_maintain_focus"),
|
||||||
|
Step("csl57", "assert", expect: "unit_asset", value: "human|wolf"),
|
||||||
|
Step("csl58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -|fighting| vs "),
|
||||||
|
Step("csl59", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||||
|
Step("csl59b", "assert", expect: "tip_not_contains", value: "melee"),
|
||||||
|
Step("csl60", "assert", expect: "dossier_matches_focus"),
|
||||||
|
Step("csl61", "assert", expect: "has_focus", value: "true"),
|
||||||
|
|
||||||
|
Step("csl90", "fast_timing", value: "false"),
|
||||||
|
Step("csl98", "screenshot", value: "hud-combat-stability-live.png"),
|
||||||
|
Step("csl99", "snapshot"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks.
|
/// Status-overlap happiness must not FixedDwell-claim a camera-worthy status the unit lacks.
|
||||||
/// Hold while status is live; offset end-pings still register briefly without it.
|
/// Hold while status is live; offset end-pings still register briefly without it.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,21 @@ public sealed class InterestCandidate
|
||||||
public float TotalScore;
|
public float TotalScore;
|
||||||
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
||||||
public int ParticipantCount;
|
public int ParticipantCount;
|
||||||
|
/// <summary>Peak fighters seen this combat scene (scale demotion hysteresis).</summary>
|
||||||
|
public int CombatPeakParticipants;
|
||||||
|
/// <summary>Sticky opposing camps for the orange tip (species/kingdom keys).</summary>
|
||||||
|
public EnsembleFrame CombatSideFrame = EnsembleFrame.CountOnly;
|
||||||
|
public string CombatSideAKey = "";
|
||||||
|
public string CombatSideADisplay = "";
|
||||||
|
public string CombatSideAKingdom = "";
|
||||||
|
public int CombatSideACount;
|
||||||
|
public string CombatSideBKey = "";
|
||||||
|
public string CombatSideBDisplay = "";
|
||||||
|
public string CombatSideBKingdom = "";
|
||||||
|
public int CombatSideBCount;
|
||||||
|
/// <summary>Tracked camp members for sticky scoreboard (kept until combat grace ends).</summary>
|
||||||
|
public readonly List<long> CombatSideAIds = new List<long>(8);
|
||||||
|
public readonly List<long> CombatSideBIds = new List<long>(8);
|
||||||
/// <summary>Notable (king/favorite/leader/high renown) participants in the cluster.</summary>
|
/// <summary>Notable (king/favorite/leader/high renown) participants in the cluster.</summary>
|
||||||
public int NotableParticipantCount;
|
public int NotableParticipantCount;
|
||||||
public Vector3 Position;
|
public Vector3 Position;
|
||||||
|
|
@ -78,6 +93,28 @@ public sealed class InterestCandidate
|
||||||
public bool HasValidPosition =>
|
public bool HasValidPosition =>
|
||||||
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
|
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
|
||||||
|
|
||||||
|
public bool HasStickyCombatSides =>
|
||||||
|
!string.IsNullOrEmpty(CombatSideAKey)
|
||||||
|
&& !string.IsNullOrEmpty(CombatSideBKey)
|
||||||
|
&& (CombatSideFrame == EnsembleFrame.SpeciesVsSpecies
|
||||||
|
|| CombatSideFrame == EnsembleFrame.KingdomVsKingdom);
|
||||||
|
|
||||||
|
public void ClearCombatSticky()
|
||||||
|
{
|
||||||
|
CombatPeakParticipants = 0;
|
||||||
|
CombatSideFrame = EnsembleFrame.CountOnly;
|
||||||
|
CombatSideAKey = "";
|
||||||
|
CombatSideADisplay = "";
|
||||||
|
CombatSideAKingdom = "";
|
||||||
|
CombatSideACount = 0;
|
||||||
|
CombatSideBKey = "";
|
||||||
|
CombatSideBDisplay = "";
|
||||||
|
CombatSideBKingdom = "";
|
||||||
|
CombatSideBCount = 0;
|
||||||
|
CombatSideAIds.Clear();
|
||||||
|
CombatSideBIds.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public InterestEvent ToInterestEvent()
|
public InterestEvent ToInterestEvent()
|
||||||
{
|
{
|
||||||
return new InterestEvent
|
return new InterestEvent
|
||||||
|
|
@ -109,6 +146,16 @@ public sealed class InterestCandidate
|
||||||
VisualConfidence = VisualConfidence,
|
VisualConfidence = VisualConfidence,
|
||||||
TotalScore = TotalScore,
|
TotalScore = TotalScore,
|
||||||
ParticipantCount = ParticipantCount,
|
ParticipantCount = ParticipantCount,
|
||||||
|
CombatPeakParticipants = CombatPeakParticipants,
|
||||||
|
CombatSideFrame = CombatSideFrame,
|
||||||
|
CombatSideAKey = CombatSideAKey,
|
||||||
|
CombatSideADisplay = CombatSideADisplay,
|
||||||
|
CombatSideAKingdom = CombatSideAKingdom,
|
||||||
|
CombatSideACount = CombatSideACount,
|
||||||
|
CombatSideBKey = CombatSideBKey,
|
||||||
|
CombatSideBDisplay = CombatSideBDisplay,
|
||||||
|
CombatSideBKingdom = CombatSideBKingdom,
|
||||||
|
CombatSideBCount = CombatSideBCount,
|
||||||
NotableParticipantCount = NotableParticipantCount,
|
NotableParticipantCount = NotableParticipantCount,
|
||||||
Position = Position,
|
Position = Position,
|
||||||
FollowUnit = FollowUnit,
|
FollowUnit = FollowUnit,
|
||||||
|
|
@ -141,6 +188,16 @@ public sealed class InterestCandidate
|
||||||
copy.ParticipantIds.Add(ParticipantIds[i]);
|
copy.ParticipantIds.Add(ParticipantIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < CombatSideAIds.Count; i++)
|
||||||
|
{
|
||||||
|
copy.CombatSideAIds.Add(CombatSideAIds[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < CombatSideBIds.Count; i++)
|
||||||
|
{
|
||||||
|
copy.CombatSideBIds.Add(CombatSideBIds[i]);
|
||||||
|
}
|
||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ public static class InterestDirector
|
||||||
private const float CombatFocusThrottleSeconds = 1.25f;
|
private const float CombatFocusThrottleSeconds = 1.25f;
|
||||||
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
|
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
|
||||||
private const float CombatFocusSwitchMargin = 12f;
|
private const float CombatFocusSwitchMargin = 12f;
|
||||||
|
/// <summary>Hold elevated combat scale this long after fighter count dips.</summary>
|
||||||
|
private const float CombatScaleHoldSeconds = 2.5f;
|
||||||
|
private static float _combatScaleDropSince = -999f;
|
||||||
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;
|
||||||
|
|
@ -201,6 +204,11 @@ public static class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate.Completion == InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
candidate.ClearCombatSticky();
|
||||||
|
}
|
||||||
|
|
||||||
EventFeedUtil.RegisterCandidate(candidate);
|
EventFeedUtil.RegisterCandidate(candidate);
|
||||||
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
|
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -422,10 +430,17 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No living subject left: do not sit in quiet_grace with an empty camera.
|
// No living subject left: prefer sticky combat roster handoff before ending.
|
||||||
if (!_current.HasFollowUnit
|
if (!_current.HasFollowUnit
|
||||||
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
||||||
{
|
{
|
||||||
|
if (_current.Completion == InterestCompletionKind.CombatActive
|
||||||
|
&& TryPromoteStickyFollow(_current))
|
||||||
|
{
|
||||||
|
MaintainCombatFocus(now, force: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EndCurrent(now, reason: "follow_lost");
|
EndCurrent(now, reason: "follow_lost");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -477,6 +492,12 @@ public static class InterestDirector
|
||||||
if (!_current.HasFollowUnit
|
if (!_current.HasFollowUnit
|
||||||
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
|
||||||
{
|
{
|
||||||
|
if (TryPromoteStickyFollow(_current))
|
||||||
|
{
|
||||||
|
MaintainCombatFocus(Time.unscaledTime, force: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,14 +614,18 @@ public static class InterestDirector
|
||||||
newcomerBonus: 8f);
|
newcomerBonus: 8f);
|
||||||
if (handoffEnsemble != null)
|
if (handoffEnsemble != null)
|
||||||
{
|
{
|
||||||
|
StabilizeCombatScale(_current, handoffEnsemble, Time.unscaledTime);
|
||||||
ApplyEnsembleFields(_current, handoffEnsemble);
|
ApplyEnsembleFields(_current, handoffEnsemble);
|
||||||
|
RefreshStickyCombatSides(
|
||||||
|
_current, handoffEnsemble, handoff.current_position, radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep ownership on the handoff unit; only use ensemble framing when the melee is large.
|
// Keep ownership on the handoff unit; only use ensemble framing when the melee is large.
|
||||||
string handoffLabel;
|
string handoffLabel;
|
||||||
if (handoffEnsemble != null
|
if (handoffEnsemble != null
|
||||||
&& handoffEnsemble.ParticipantCount > 2
|
&& (handoffEnsemble.ParticipantCount > 2
|
||||||
&& handoffEnsemble.Scale != EnsembleScale.Pair)
|
|| HasStickyCombatSides(_current)
|
||||||
|
|| handoffEnsemble.Scale != EnsembleScale.Pair))
|
||||||
{
|
{
|
||||||
handoffEnsemble.Focus = handoff;
|
handoffEnsemble.Focus = handoff;
|
||||||
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
|
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
|
||||||
|
|
@ -657,11 +682,13 @@ public static class InterestDirector
|
||||||
string label;
|
string label;
|
||||||
if (ensemble != null && ensemble.HasFocus)
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
{
|
{
|
||||||
|
StabilizeCombatScale(_current, ensemble, Time.unscaledTime);
|
||||||
best = ensemble.Focus;
|
best = ensemble.Focus;
|
||||||
foe = ensemble.Related;
|
foe = ensemble.Related;
|
||||||
fighters = Mathf.Max(1, ensemble.ParticipantCount);
|
fighters = Mathf.Max(0, ensemble.ParticipantCount);
|
||||||
label = EventReason.Combat(ensemble);
|
|
||||||
ApplyEnsembleFields(_current, ensemble);
|
ApplyEnsembleFields(_current, ensemble);
|
||||||
|
RefreshStickyCombatSides(_current, ensemble, pos, radius);
|
||||||
|
label = EventReason.Combat(ensemble);
|
||||||
}
|
}
|
||||||
else if (WorldActivityScanner.TryPickBestCombatFocus(
|
else if (WorldActivityScanner.TryPickBestCombatFocus(
|
||||||
_current, out best, out foe, out fighters))
|
_current, out best, out foe, out fighters))
|
||||||
|
|
@ -675,48 +702,133 @@ public static class InterestDirector
|
||||||
Focus = best,
|
Focus = best,
|
||||||
Related = foe
|
Related = foe
|
||||||
};
|
};
|
||||||
|
StabilizeCombatScale(_current, fallback, Time.unscaledTime);
|
||||||
|
ApplyEnsembleFields(_current, fallback);
|
||||||
|
RefreshStickyCombatSides(_current, fallback, pos, radius);
|
||||||
label = EventReason.Combat(fallback);
|
label = EventReason.Combat(fallback);
|
||||||
_current.ParticipantCount = fighters;
|
_current.ParticipantCount = Math.Max(fighters, _current.CombatSideACount + _current.CombatSideBCount);
|
||||||
|
}
|
||||||
|
else if (HasStickyCombatSides(_current))
|
||||||
|
{
|
||||||
|
// No live fighters briefly - keep framed tip from sticky roster until combat goes cold.
|
||||||
|
if (!_current.HasFollowUnit)
|
||||||
|
{
|
||||||
|
TryPromoteStickyFollow(_current);
|
||||||
|
}
|
||||||
|
|
||||||
|
var held = new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
|
||||||
|
Focus = _current.FollowUnit,
|
||||||
|
Related = _current.RelatedUnit,
|
||||||
|
ParticipantCount = 0
|
||||||
|
};
|
||||||
|
StabilizeCombatScale(_current, held, Time.unscaledTime);
|
||||||
|
RefreshStickyCombatSides(_current, held, pos, radius);
|
||||||
|
if (held.Focus == null || !held.Focus.isAlive())
|
||||||
|
{
|
||||||
|
held.Focus = _current.RelatedUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((held.Focus == null || !held.Focus.isAlive()) && TryPromoteStickyFollow(_current))
|
||||||
|
{
|
||||||
|
held.Focus = _current.FollowUnit;
|
||||||
|
held.Related = _current.RelatedUnit;
|
||||||
|
RefreshStickyCombatSides(_current, held, pos, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (held.Focus == null || !held.Focus.isAlive())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
best = held.Focus;
|
||||||
|
foe = held.Related;
|
||||||
|
label = EventReason.Combat(held);
|
||||||
|
fighters = _current.CombatSideACount + _current.CombatSideBCount;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hysteresis: do not thrash the camera between near-equal fighters.
|
// Never stick on a sleeper / bystander unless they are on the sticky combat roster.
|
||||||
if (_current.FollowUnit != null
|
Actor curFollow = _current.FollowUnit;
|
||||||
&& _current.FollowUnit.isAlive()
|
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
|
||||||
&& best != null
|
bool curRostered = IsOnStickyRoster(_current, curFollow);
|
||||||
&& best != _current.FollowUnit)
|
if (!LiveEnsemble.IsCombatParticipant(best))
|
||||||
|
{
|
||||||
|
if (curFighting || curRostered)
|
||||||
|
{
|
||||||
|
best = curFollow;
|
||||||
|
foe = ResolveAttackFoe(best) ?? foe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((curFighting || curRostered)
|
||||||
|
&& best != curFollow
|
||||||
|
&& curFollow != null
|
||||||
|
&& curFollow.isAlive())
|
||||||
{
|
{
|
||||||
float curW = LiveEnsemble.FocusWeight(
|
float curW = LiveEnsemble.FocusWeight(
|
||||||
_current.FollowUnit, _current.ParticipantIds, newcomerBonus: 0f);
|
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
|
||||||
|
if (curRostered && !curFighting)
|
||||||
|
{
|
||||||
|
curW += 20f;
|
||||||
|
}
|
||||||
|
|
||||||
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
|
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
|
||||||
if (newW < curW + CombatFocusSwitchMargin)
|
if (newW < curW + CombatFocusSwitchMargin)
|
||||||
{
|
{
|
||||||
best = _current.FollowUnit;
|
best = curFollow;
|
||||||
if (foe == null || foe == best)
|
if (foe == null || foe == best)
|
||||||
{
|
{
|
||||||
try
|
foe = ResolveAttackFoe(best) ?? foe;
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never replace a sticky scoreboard tip with thin Fight prose.
|
||||||
|
if (HasStickyCombatSides(_current)
|
||||||
|
&& (string.IsNullOrEmpty(label)
|
||||||
|
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||||
|
{
|
||||||
|
var stickyEns = new LiveEnsemble
|
||||||
|
{
|
||||||
|
Kind = EnsembleKind.Combat,
|
||||||
|
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, _current.CombatPeakParticipants)),
|
||||||
|
Focus = best,
|
||||||
|
Related = foe,
|
||||||
|
Frame = _current.CombatSideFrame,
|
||||||
|
SideA = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = _current.CombatSideAKey,
|
||||||
|
Display = _current.CombatSideADisplay,
|
||||||
|
KingdomDisplay = _current.CombatSideAKingdom,
|
||||||
|
Count = _current.CombatSideACount,
|
||||||
|
Best = best
|
||||||
|
},
|
||||||
|
SideB = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = _current.CombatSideBKey,
|
||||||
|
Display = _current.CombatSideBDisplay,
|
||||||
|
KingdomDisplay = _current.CombatSideBKingdom,
|
||||||
|
Count = _current.CombatSideBCount,
|
||||||
|
Best = foe
|
||||||
|
},
|
||||||
|
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount
|
||||||
|
};
|
||||||
|
label = EventReason.Combat(stickyEns);
|
||||||
|
}
|
||||||
|
|
||||||
|
string previousLabel = _current.Label ?? "";
|
||||||
|
if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(previousLabel))
|
||||||
|
{
|
||||||
|
label = previousLabel;
|
||||||
|
}
|
||||||
|
|
||||||
bool followChanged = _current.FollowUnit != best;
|
bool followChanged = _current.FollowUnit != best;
|
||||||
bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
|
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
|
||||||
_current.FollowUnit = best;
|
_current.FollowUnit = best;
|
||||||
_current.SubjectId = EventFeedUtil.SafeId(best);
|
_current.SubjectId = EventFeedUtil.SafeId(best);
|
||||||
_current.RelatedUnit = foe;
|
_current.RelatedUnit = foe;
|
||||||
|
|
@ -731,11 +843,12 @@ public static class InterestDirector
|
||||||
// keep prior position
|
// keep prior position
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceWatch
|
bool needWatch = forceWatch
|
||||||
|| followChanged
|
|| followChanged
|
||||||
|| labelChanged
|
|| labelChanged
|
||||||
|| !HasLivingCameraFocus()
|
|| !HasLivingCameraFocus()
|
||||||
|| MoveCamera._focus_unit != best)
|
|| MoveCamera._focus_unit != best;
|
||||||
|
if (needWatch)
|
||||||
{
|
{
|
||||||
CameraDirector.Watch(_current.ToInterestEvent());
|
CameraDirector.Watch(_current.ToInterestEvent());
|
||||||
}
|
}
|
||||||
|
|
@ -743,6 +856,400 @@ public static class InterestDirector
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsOnStickyRoster(InterestCandidate scene, Actor actor)
|
||||||
|
{
|
||||||
|
if (scene == null || actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < scene.CombatSideAIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideAIds[i] == id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < scene.CombatSideBIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideBIds[i] == id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasStickyCombatSides(InterestCandidate scene)
|
||||||
|
{
|
||||||
|
return scene != null && scene.HasStickyCombatSides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the camera subject dies mid sticky battle, promote another living roster member
|
||||||
|
/// instead of ending the scene and falling through to thin "X is fighting" tips.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryPromoteStickyFollow(InterestCandidate scene)
|
||||||
|
{
|
||||||
|
if (scene == null || !HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor pickA = LiveEnsemble.FindBestAliveTracked(scene.CombatSideAIds, preferCombat: true);
|
||||||
|
Actor pickB = LiveEnsemble.FindBestAliveTracked(scene.CombatSideBIds, preferCombat: true);
|
||||||
|
Actor pick = pickA ?? pickB;
|
||||||
|
if (pick == null || !pick.isAlive())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the camp that still has more living members when both exist.
|
||||||
|
if (pickA != null && pickB != null)
|
||||||
|
{
|
||||||
|
int nA = scene.CombatSideACount;
|
||||||
|
int nB = scene.CombatSideBCount;
|
||||||
|
if (nB > nA)
|
||||||
|
{
|
||||||
|
pick = pickB;
|
||||||
|
}
|
||||||
|
else if (nA > nB)
|
||||||
|
{
|
||||||
|
pick = pickA;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor other = pick == pickA ? pickB : pickA;
|
||||||
|
scene.FollowUnit = pick;
|
||||||
|
scene.SubjectId = EventFeedUtil.SafeId(pick);
|
||||||
|
scene.RelatedUnit = other;
|
||||||
|
scene.RelatedId = other != null ? EventFeedUtil.SafeId(other) : 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scene.Position = pick.current_position;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// keep
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock opposing camp keys once, then refresh live counts (may be 0) every maintain pass.
|
||||||
|
/// </summary>
|
||||||
|
private static void RefreshStickyCombatSides(
|
||||||
|
InterestCandidate scene,
|
||||||
|
LiveEnsemble ensemble,
|
||||||
|
Vector3 pos,
|
||||||
|
float radius)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasStickyCombatSides(scene) && LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||||
|
{
|
||||||
|
// Only lock sticky camps once the live cluster is actually multi-sided.
|
||||||
|
int liveN = Math.Max(ensemble.ParticipantCount,
|
||||||
|
(ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0));
|
||||||
|
if (liveN <= 2 && ensemble.Scale <= EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.CombatSideFrame = ensemble.Frame;
|
||||||
|
scene.CombatSideAKey = ensemble.SideA.Key ?? "";
|
||||||
|
scene.CombatSideADisplay = ensemble.SideA.Display ?? "";
|
||||||
|
scene.CombatSideAKingdom = ensemble.SideA.KingdomDisplay ?? "";
|
||||||
|
scene.CombatSideBKey = ensemble.SideB.Key ?? "";
|
||||||
|
scene.CombatSideBDisplay = ensemble.SideB.Display ?? "";
|
||||||
|
scene.CombatSideBKingdom = ensemble.SideB.KingdomDisplay ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed roster from known ensemble participants on first lock / empty lists.
|
||||||
|
SeedStickyRosterFromEnsemble(scene, ensemble);
|
||||||
|
|
||||||
|
LiveEnsemble.RefreshStickyMemberRoster(
|
||||||
|
pos,
|
||||||
|
radius,
|
||||||
|
scene.CombatSideFrame,
|
||||||
|
scene.CombatSideAKey,
|
||||||
|
scene.CombatSideBKey,
|
||||||
|
scene.CombatSideAIds,
|
||||||
|
scene.CombatSideBIds,
|
||||||
|
out int countA,
|
||||||
|
out int countB,
|
||||||
|
out Actor bestA,
|
||||||
|
out Actor bestB);
|
||||||
|
|
||||||
|
scene.CombatSideACount = Math.Max(0, countA);
|
||||||
|
scene.CombatSideBCount = Math.Max(0, countB);
|
||||||
|
int live = scene.CombatSideACount + scene.CombatSideBCount;
|
||||||
|
scene.ParticipantCount = live;
|
||||||
|
if (live > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensemble.Frame = scene.CombatSideFrame;
|
||||||
|
ensemble.SideA = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = scene.CombatSideAKey,
|
||||||
|
Display = string.IsNullOrEmpty(scene.CombatSideADisplay)
|
||||||
|
? scene.CombatSideAKey
|
||||||
|
: scene.CombatSideADisplay,
|
||||||
|
KingdomDisplay = scene.CombatSideAKingdom,
|
||||||
|
Count = scene.CombatSideACount,
|
||||||
|
Best = bestA ?? ensemble.Focus
|
||||||
|
};
|
||||||
|
ensemble.SideB = new EnsembleSide
|
||||||
|
{
|
||||||
|
Key = scene.CombatSideBKey,
|
||||||
|
Display = string.IsNullOrEmpty(scene.CombatSideBDisplay)
|
||||||
|
? scene.CombatSideBKey
|
||||||
|
: scene.CombatSideBDisplay,
|
||||||
|
KingdomDisplay = scene.CombatSideBKingdom,
|
||||||
|
Count = scene.CombatSideBCount,
|
||||||
|
Best = bestB ?? ensemble.Related
|
||||||
|
};
|
||||||
|
ensemble.ParticipantCount = live;
|
||||||
|
|
||||||
|
// Sticky multi frame stays multi while the scene is hot - do not collapse to Pair on a dip.
|
||||||
|
if (ensemble.Scale == EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
ensemble.Scale = LiveEnsemble.ScaleForCount(Math.Max(3, scene.CombatPeakParticipants));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestA != null && (ensemble.Focus == null || !LiveEnsemble.IsCombatParticipant(ensemble.Focus)))
|
||||||
|
{
|
||||||
|
ensemble.Focus = bestA;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestB != null)
|
||||||
|
{
|
||||||
|
ensemble.Related = bestB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedStickyRosterFromEnsemble(InterestCandidate scene, LiveEnsemble ensemble)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null || !HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Enroll(Actor actor)
|
||||||
|
{
|
||||||
|
if (actor == null || !actor.isAlive())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = EventFeedUtil.SafeId(actor);
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom
|
||||||
|
? LiveEnsemble.KingdomKeyOf(actor)
|
||||||
|
: LiveEnsemble.SpeciesKeyOf(actor);
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.Equals(scene.CombatSideAKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < scene.CombatSideAIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideAIds[i] == id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.CombatSideAIds.Add(id);
|
||||||
|
}
|
||||||
|
else if (key.Equals(scene.CombatSideBKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < scene.CombatSideBIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (scene.CombatSideBIds[i] == id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.CombatSideBIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Enroll(ensemble.Focus);
|
||||||
|
Enroll(ensemble.Related);
|
||||||
|
Enroll(ensemble.SideA?.Best);
|
||||||
|
Enroll(ensemble.SideB?.Best);
|
||||||
|
if (ensemble.ParticipantIds != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
|
||||||
|
{
|
||||||
|
long id = ensemble.ParticipantIds[i];
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor != null && actor.isAlive() && EventFeedUtil.SafeId(actor) == id)
|
||||||
|
{
|
||||||
|
Enroll(actor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Actor ResolveAttackFoe(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hold elevated combat scale briefly after fighter count dips so Mass does not flap to Duel.
|
||||||
|
/// </summary>
|
||||||
|
private static void StabilizeCombatScale(InterestCandidate scene, LiveEnsemble ensemble, float now)
|
||||||
|
{
|
||||||
|
if (scene == null || ensemble == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = Mathf.Max(1, ensemble.ParticipantCount);
|
||||||
|
if (n > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = n;
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
|
}
|
||||||
|
|
||||||
|
int peak = Math.Max(scene.CombatPeakParticipants, n);
|
||||||
|
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
|
||||||
|
EnsembleScale held = LiveEnsemble.ScaleForCount(peak);
|
||||||
|
if (held > live)
|
||||||
|
{
|
||||||
|
if (_combatScaleDropSince < 0f)
|
||||||
|
{
|
||||||
|
_combatScaleDropSince = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - _combatScaleDropSince < CombatScaleHoldSeconds)
|
||||||
|
{
|
||||||
|
ensemble.Scale = held;
|
||||||
|
// Live rebuild may only see a NamedPair; keep mass framing while peak holds.
|
||||||
|
if (ensemble.Frame == EnsembleFrame.NamedPair)
|
||||||
|
{
|
||||||
|
ensemble.Frame = EnsembleFrame.CountOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold expired: ratchet peak down to live count.
|
||||||
|
scene.CombatPeakParticipants = n;
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
|
ensemble.Scale = live;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
|
ensemble.Scale = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rewatch tip only when tier or collective sides change - not on (6)↔(5) count flaps.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CombatLabelNeedsRewatch(string oldLabel, string newLabel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(oldLabel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(newLabel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string a = StripCombatLabelCounts(oldLabel);
|
||||||
|
string b = StripCombatLabelCounts(newLabel);
|
||||||
|
return !a.Equals(b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripCombatLabelCounts(string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder(label.Length);
|
||||||
|
for (int i = 0; i < label.Length; i++)
|
||||||
|
{
|
||||||
|
char c = label[i];
|
||||||
|
if (c == '(')
|
||||||
|
{
|
||||||
|
int close = label.IndexOf(')', i);
|
||||||
|
if (close > i)
|
||||||
|
{
|
||||||
|
i = close;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
|
private static void ApplyEnsembleFields(InterestCandidate scene, LiveEnsemble ensemble)
|
||||||
{
|
{
|
||||||
if (scene == null || ensemble == null)
|
if (scene == null || ensemble == null)
|
||||||
|
|
@ -750,7 +1257,12 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.ParticipantCount = Mathf.Max(1, ensemble.ParticipantCount);
|
scene.ParticipantCount = Math.Max(0, ensemble.ParticipantCount);
|
||||||
|
if (scene.ParticipantCount > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = scene.ParticipantCount;
|
||||||
|
}
|
||||||
|
|
||||||
scene.NotableParticipantCount = ensemble.NotableParticipantCount;
|
scene.NotableParticipantCount = ensemble.NotableParticipantCount;
|
||||||
WorldActivityScanner.StampParticipantIds(scene, ensemble);
|
WorldActivityScanner.StampParticipantIds(scene, ensemble);
|
||||||
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
||||||
|
|
@ -771,6 +1283,24 @@ public static class InterestDirector
|
||||||
{
|
{
|
||||||
scene.SpeciesId = ensemble.SideA.Key;
|
scene.SpeciesId = ensemble.SideA.Key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble) && !HasStickyCombatSides(scene))
|
||||||
|
{
|
||||||
|
int liveN = Math.Max(ensemble.ParticipantCount,
|
||||||
|
ensemble.SideA.Count + ensemble.SideB.Count);
|
||||||
|
if (liveN > 2 || ensemble.Scale > EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
scene.CombatSideFrame = ensemble.Frame;
|
||||||
|
scene.CombatSideAKey = ensemble.SideA.Key ?? "";
|
||||||
|
scene.CombatSideADisplay = ensemble.SideA.Display ?? "";
|
||||||
|
scene.CombatSideAKingdom = ensemble.SideA.KingdomDisplay ?? "";
|
||||||
|
scene.CombatSideACount = Math.Max(0, ensemble.SideA.Count);
|
||||||
|
scene.CombatSideBKey = ensemble.SideB.Key ?? "";
|
||||||
|
scene.CombatSideBDisplay = ensemble.SideB.Display ?? "";
|
||||||
|
scene.CombatSideBKingdom = ensemble.SideB.KingdomDisplay ?? "";
|
||||||
|
scene.CombatSideBCount = Math.Max(0, ensemble.SideB.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -842,6 +1372,11 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyEnsembleFields(_current, ensemble);
|
ApplyEnsembleFields(_current, ensemble);
|
||||||
|
RefreshStickyCombatSides(
|
||||||
|
_current,
|
||||||
|
ensemble,
|
||||||
|
ensemble.Focus.current_position,
|
||||||
|
14f);
|
||||||
_current.FollowUnit = ensemble.Focus;
|
_current.FollowUnit = ensemble.Focus;
|
||||||
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
||||||
_current.RelatedUnit = ensemble.Related;
|
_current.RelatedUnit = ensemble.Related;
|
||||||
|
|
@ -882,6 +1417,8 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
|
|
||||||
_current.Label = "";
|
_current.Label = "";
|
||||||
|
_current.ClearCombatSticky();
|
||||||
|
_combatScaleDropSince = -999f;
|
||||||
_lastCombatFocusAt = now;
|
_lastCombatFocusAt = now;
|
||||||
CameraDirector.Watch(_current.ToInterestEvent());
|
CameraDirector.Watch(_current.ToInterestEvent());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "IdleSpectator",
|
"name": "IdleSpectator",
|
||||||
"author": "dazed",
|
"author": "dazed",
|
||||||
"version": "0.25.54",
|
"version": "0.25.64",
|
||||||
"description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.",
|
"description": "AFK Idle Spectator (I) + Lore (L). Sticky combat handoff - no thin fighting fallback.",
|
||||||
"GUID": "com.dazed.idlespectator"
|
"GUID": "com.dazed.idlespectator"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +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.
|
Done (0.25.64): sticky combat handoff on follow_lost + never collapse sticky tips to "X is fighting".
|
||||||
|
|
||||||
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`).
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,16 @@ The director refreshes counts, sides, focus, and `EventReason.Combat` while `Com
|
||||||
| Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` |
|
| Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` |
|
||||||
|
|
||||||
Optional kingdom brackets on species sides: `Battle - [Essiona] humans (10) vs wolves (3)`.
|
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.
|
Side framing priority: kingdom vs kingdom → species vs species → count-only melee.
|
||||||
|
Never use unit proper names as mass-side labels (no `Battle - Waf (6) vs Eaderbert (1)`).
|
||||||
|
Same-species camps with no opposing sticky keys use a single pack line (`Mass - Wolves (n)`).
|
||||||
|
Sticky opposing camps lock once and enroll member ids while they fight.
|
||||||
|
Counts stay on those tracked members while alive (combat or not) until combat grace clears the scene - so a wiped side can hit `0` while survivors remain counted.
|
||||||
|
New combatants matching a sticky key are enrolled; dead ids are pruned.
|
||||||
|
Orphan pair tips use `{name} is fighting` instead of `Duel - Name` with no foe.
|
||||||
Names are humanized (no raw `nomads_human` ids).
|
Names are humanized (no raw `nomads_human` ids).
|
||||||
|
Scale hold (~2.5s peak hysteresis) keeps Mass/Battle from flapping to Duel on brief count dips.
|
||||||
|
Combat camera hysteresis only applies between living fighters - sleepers/bystanders never stick.
|
||||||
|
|
||||||
### Other families (same snapshot later)
|
### Other families (same snapshot later)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue