Fighting upgrade

This commit is contained in:
DazedAnon 2026-07-16 23:10:54 -05:00
parent e37cbf7890
commit 7dda02bcea
9 changed files with 1199 additions and 80 deletions

View file

@ -2017,6 +2017,10 @@ public static class AgentHarness
DoCombatEnsembleEscalate(cmd);
break;
case "combat_wire_attack_sides":
DoCombatWireAttackSides(cmd);
break;
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
@ -3578,6 +3582,7 @@ public static class AgentHarness
{
c.Category = "Combat";
c.Verb = "fighting";
c.ClearCombatSticky();
if (related != null)
{
c.ParticipantCount = 2;
@ -3623,6 +3628,18 @@ public static class AgentHarness
}
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())
{
_cmdFail++;
@ -3630,6 +3647,7 @@ public static class AgentHarness
return;
}
scene.ClearCombatSticky();
int want = 6;
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}");
}
/// <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)
{
try
@ -3938,7 +4139,24 @@ public static class AgentHarness
Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string unitAsset = unit?.asset != null ? unit.asset.id : "";
pass = !string.IsNullOrEmpty(wantAsset) && wantAsset == unitAsset;
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;
}
detail = $"unitAsset={unitAsset} want={wantAsset}";
break;
}

View file

@ -44,7 +44,8 @@ public static class EventReason
/// <summary>
/// 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>
public static string Combat(LiveEnsemble ensemble)
{
@ -53,8 +54,8 @@ public static class EventReason
return "";
}
if (ensemble.Frame == EnsembleFrame.NamedPair
|| ensemble.Scale == EnsembleScale.Pair)
// True 1v1 only when we do not already have sticky opposing camps.
if (ensemble.Scale == EnsembleScale.Pair && !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
string a = Name(ensemble.Focus);
string b = Name(ensemble.Related);
@ -65,31 +66,67 @@ public static class EventReason
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;
}
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);
string tier = ScaleTierLabel(
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
if (sided)
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
return tier + " - " + sideA + " vs " + sideB;
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
{
return tier + " - " + sideA + " vs " + sideB;
}
}
int n = Math.Max(1, ensemble.ParticipantCount);
if (!string.IsNullOrEmpty(sideA))
// Same-species pack with no opposing sticky camps.
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key))
{
return tier + " - " + sideA + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies);
if (!string.IsNullOrEmpty(pack))
{
return tier + " - " + pack;
}
}
return tier + " - fight of " + n.ToString(CultureInfo.InvariantCulture);
// 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)
@ -114,6 +151,12 @@ public static class EventReason
return "";
}
// Mass/skirmish never print unit proper names as a side.
if (frame == EnsembleFrame.NamedLeaders || frame == EnsembleFrame.NamedPair)
{
return "";
}
string label = side.Display ?? "";
if (string.IsNullOrEmpty(label))
{
@ -122,7 +165,7 @@ public static class EventReason
if (frame == EnsembleFrame.KingdomVsKingdom)
{
label = LiveEnsemble.KingdomDisplay(label);
label = LiveEnsemble.KingdomDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
}
else if (frame == EnsembleFrame.SpeciesVsSpecies)
{
@ -130,7 +173,6 @@ public static class EventReason
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)
@ -139,12 +181,10 @@ public static class EventReason
}
}
}
else if (frame == EnsembleFrame.NamedLeaders)
else
{
if (side.Best != null)
{
label = Name(side.Best);
}
// CountOnly / unknown - refuse unit-looking labels.
return "";
}
if (string.IsNullOrEmpty(label))
@ -152,14 +192,9 @@ public static class EventReason
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);
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);
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
}

View file

@ -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)
{
ensemble.Frame = EnsembleFrame.NamedPair;
@ -500,12 +677,7 @@ public sealed class LiveEnsemble
if (foe != null)
{
ensemble.SideB = SideFromActor(foe, "foe");
if (ensemble.NotableParticipantCount >= 2
&& InterestScoring.IsNotable(ensemble.Focus)
&& InterestScoring.IsNotable(foe))
{
ensemble.Frame = EnsembleFrame.NamedLeaders;
}
// Multi-fighter: never NamedLeaders (unit-name sides). Species promotion happens in EventReason.
}
}
@ -648,9 +820,28 @@ public sealed class LiveEnsemble
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);
// Prefer species framing over unit-named camps whenever species differ.
string spA = SpeciesKeyOf(focus);
string spB = SpeciesKeyOf(foe);
if (!string.IsNullOrEmpty(spA)
&& !string.IsNullOrEmpty(spB)
&& !spA.Equals(spB, StringComparison.OrdinalIgnoreCase))
{
ensemble.Frame = EnsembleFrame.SpeciesVsSpecies;
ensemble.SideA = BuildSide(spA, SpeciesDisplay(spA), sideA, prior, newcomerBonus, EnsembleFrame.SpeciesVsSpecies);
ensemble.SideB = BuildSide(spB, SpeciesDisplay(spB), sideB, prior, newcomerBonus, EnsembleFrame.SpeciesVsSpecies);
}
else
{
// Same species camps → count-only melee (EventReason prints "melee (n)").
ensemble.Frame = EnsembleFrame.CountOnly;
ensemble.SideA = null;
ensemble.SideB = null;
PreferFocusSide(ensemble);
ensemble.Related = foe;
return true;
}
PreferFocusSide(ensemble);
ensemble.Related = ensemble.SideB?.Best ?? foe;
return true;

View file

@ -53,6 +53,9 @@ internal static class HarnessScenarios
case "combat_focus":
case "combat_tip_align":
return CombatFocusAlign();
case "combat_stability_live":
case "combat_live_proof":
return CombatStabilityLive();
case "director_action_priority":
case "action_priority":
return DirectorActionPriority();
@ -1065,10 +1068,11 @@ internal static class HarnessScenarios
Step("cf25b", "assert", expect: "dossier_matches_focus"),
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("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
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("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
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>
/// 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.

View file

@ -44,6 +44,21 @@ public sealed class InterestCandidate
public float TotalScore;
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
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>
public int NotableParticipantCount;
public Vector3 Position;
@ -78,6 +93,28 @@ public sealed class InterestCandidate
public bool HasValidPosition =>
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()
{
return new InterestEvent
@ -109,6 +146,16 @@ public sealed class InterestCandidate
VisualConfidence = VisualConfidence,
TotalScore = TotalScore,
ParticipantCount = ParticipantCount,
CombatPeakParticipants = CombatPeakParticipants,
CombatSideFrame = CombatSideFrame,
CombatSideAKey = CombatSideAKey,
CombatSideADisplay = CombatSideADisplay,
CombatSideAKingdom = CombatSideAKingdom,
CombatSideACount = CombatSideACount,
CombatSideBKey = CombatSideBKey,
CombatSideBDisplay = CombatSideBDisplay,
CombatSideBKingdom = CombatSideBKingdom,
CombatSideBCount = CombatSideBCount,
NotableParticipantCount = NotableParticipantCount,
Position = Position,
FollowUnit = FollowUnit,
@ -141,6 +188,16 @@ public sealed class InterestCandidate
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;
}
}

View file

@ -48,6 +48,9 @@ public static class InterestDirector
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;
/// <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);
public static string CurrentTierName => CurrentScoreLabel;
@ -201,6 +204,11 @@ public static class InterestDirector
return false;
}
if (candidate.Completion == InterestCompletionKind.CombatActive)
{
candidate.ClearCombatSticky();
}
EventFeedUtil.RegisterCandidate(candidate);
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
return true;
@ -422,10 +430,17 @@ public static class InterestDirector
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
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (_current.Completion == InterestCompletionKind.CombatActive
&& TryPromoteStickyFollow(_current))
{
MaintainCombatFocus(now, force: true);
return;
}
EndCurrent(now, reason: "follow_lost");
return;
}
@ -477,6 +492,12 @@ public static class InterestDirector
if (!_current.HasFollowUnit
&& (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive()))
{
if (TryPromoteStickyFollow(_current))
{
MaintainCombatFocus(Time.unscaledTime, force: true);
return;
}
EndCurrent(Time.unscaledTime, reason: "follow_lost");
}
@ -593,14 +614,18 @@ public static class InterestDirector
newcomerBonus: 8f);
if (handoffEnsemble != null)
{
StabilizeCombatScale(_current, handoffEnsemble, Time.unscaledTime);
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.
string handoffLabel;
if (handoffEnsemble != null
&& handoffEnsemble.ParticipantCount > 2
&& handoffEnsemble.Scale != EnsembleScale.Pair)
&& (handoffEnsemble.ParticipantCount > 2
|| HasStickyCombatSides(_current)
|| handoffEnsemble.Scale != EnsembleScale.Pair))
{
handoffEnsemble.Focus = handoff;
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
@ -657,11 +682,13 @@ public static class InterestDirector
string label;
if (ensemble != null && ensemble.HasFocus)
{
StabilizeCombatScale(_current, ensemble, Time.unscaledTime);
best = ensemble.Focus;
foe = ensemble.Related;
fighters = Mathf.Max(1, ensemble.ParticipantCount);
label = EventReason.Combat(ensemble);
fighters = Mathf.Max(0, ensemble.ParticipantCount);
ApplyEnsembleFields(_current, ensemble);
RefreshStickyCombatSides(_current, ensemble, pos, radius);
label = EventReason.Combat(ensemble);
}
else if (WorldActivityScanner.TryPickBestCombatFocus(
_current, out best, out foe, out fighters))
@ -675,48 +702,133 @@ public static class InterestDirector
Focus = best,
Related = foe
};
StabilizeCombatScale(_current, fallback, Time.unscaledTime);
ApplyEnsembleFields(_current, fallback);
RefreshStickyCombatSides(_current, fallback, pos, radius);
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
{
return false;
}
// Hysteresis: do not thrash the camera between near-equal fighters.
if (_current.FollowUnit != null
&& _current.FollowUnit.isAlive()
&& best != null
&& best != _current.FollowUnit)
// Never stick on a sleeper / bystander unless they are on the sticky combat roster.
Actor curFollow = _current.FollowUnit;
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
bool curRostered = IsOnStickyRoster(_current, curFollow);
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(
_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);
if (newW < curW + CombatFocusSwitchMargin)
{
best = _current.FollowUnit;
best = curFollow;
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
}
foe = ResolveAttackFoe(best) ?? 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 labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal);
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
_current.FollowUnit = best;
_current.SubjectId = EventFeedUtil.SafeId(best);
_current.RelatedUnit = foe;
@ -731,11 +843,12 @@ public static class InterestDirector
// keep prior position
}
if (forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best)
bool needWatch = forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best;
if (needWatch)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
@ -743,6 +856,400 @@ public static class InterestDirector
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)
{
if (scene == null || ensemble == null)
@ -750,7 +1257,12 @@ public static class InterestDirector
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;
WorldActivityScanner.StampParticipantIds(scene, ensemble);
if (ensemble.Scale >= EnsembleScale.Skirmish)
@ -771,6 +1283,24 @@ public static class InterestDirector
{
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>
@ -842,6 +1372,11 @@ public static class InterestDirector
}
ApplyEnsembleFields(_current, ensemble);
RefreshStickyCombatSides(
_current,
ensemble,
ensemble.Focus.current_position,
14f);
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
@ -882,6 +1417,8 @@ public static class InterestDirector
}
_current.Label = "";
_current.ClearCombatSticky();
_combatScaleDropSince = -999f;
_lastCombatFocusAt = now;
CameraDirector.Watch(_current.ToInterestEvent());
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.54",
"description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.",
"version": "0.25.64",
"description": "AFK Idle Spectator (I) + Lore (L). Sticky combat handoff - no thin fighting fallback.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -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;
`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`).

View file

@ -40,8 +40,16 @@ The director refreshes counts, sides, focus, and `EventReason.Combat` while `Com
| 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.
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).
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)