Compare commits

...

3 commits

Author SHA1 Message Date
7dda02bcea Fighting upgrade 2026-07-16 23:10:54 -05:00
e37cbf7890 fights 2026-07-16 22:27:59 -05:00
3ed85d01d1 Fix focus handoff 2026-07-16 21:54:44 -05:00
19 changed files with 3049 additions and 169 deletions

View file

@ -2013,6 +2013,14 @@ public static class AgentHarness
break;
}
case "combat_ensemble_escalate":
DoCombatEnsembleEscalate(cmd);
break;
case "combat_wire_attack_sides":
DoCombatWireAttackSides(cmd);
break;
case "interest_variety_clear":
InterestVariety.Clear();
_cmdOk++;
@ -3542,7 +3550,19 @@ public static class AgentHarness
}
string label = string.IsNullOrEmpty(cmd.label)
? EventReason.Fight(follow, related)
? EventReason.Combat(new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = EnsembleScale.Pair,
Frame = EnsembleFrame.NamedPair,
ParticipantCount = related != null ? 2 : 1,
Focus = follow,
Related = related,
SideA = new EnsembleSide { Display = SafeName(follow), Count = 1, Best = follow },
SideB = related != null
? new EnsembleSide { Display = SafeName(related), Count = 1, Best = related }
: null
})
: cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related));
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect;
InterestCandidate c = InterestFeeds.RegisterHarness(
@ -3562,6 +3582,7 @@ public static class AgentHarness
{
c.Category = "Combat";
c.Verb = "fighting";
c.ClearCombatSticky();
if (related != null)
{
c.ParticipantCount = 2;
@ -3586,6 +3607,349 @@ public static class AgentHarness
detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'");
}
/// <summary>
/// Grow the current CombatActive scene into a sided multi-fighter ensemble reason.
/// value = participant count (default 6). Spawns extra follow/related species near the focus.
/// </summary>
private static void DoCombatEnsembleEscalate(HarnessCommand cmd)
{
InterestCandidate scene = InterestDirector.CurrentCandidate;
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
{
_cmdFail++;
Emit(cmd, ok: false, detail: "no_combat_scene");
return;
}
Actor focus = scene.FollowUnit;
if (focus == null || !focus.isAlive())
{
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
}
Actor related = scene.RelatedUnit;
if ((focus == null || !focus.isAlive()) && 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++;
Emit(cmd, ok: false, detail: "no_focus");
return;
}
scene.ClearCombatSticky();
int want = 6;
if (!string.IsNullOrEmpty(cmd.value) && !int.TryParse(cmd.value, out want))
{
want = 6;
}
want = Mathf.Clamp(want, 3, 24);
string focusSpecies = focus.asset != null ? focus.asset.id : "human";
string relatedSpecies = related?.asset != null ? related.asset.id : "wolf";
if (string.Equals(relatedSpecies, focusSpecies, StringComparison.OrdinalIgnoreCase))
{
relatedSpecies = focusSpecies == "human" ? "wolf" : "human";
}
int sideA = want / 2;
int sideB = want - sideA;
// Already have focus (+ optional related); spawn the rest near the fight.
for (int i = 1; i < sideA; i++)
{
SpawnAssetNear(focusSpecies, focus.current_position, 2f + i * 0.4f);
}
if (related == null || !related.isAlive())
{
related = SpawnAssetNear(relatedSpecies, focus.current_position, 2.5f);
}
for (int i = 1; i < sideB; i++)
{
SpawnAssetNear(relatedSpecies, focus.current_position, 3f + i * 0.4f);
}
var ensemble = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(want),
Frame = EnsembleFrame.SpeciesVsSpecies,
ParticipantCount = want,
NotableParticipantCount = Mathf.Max(scene.NotableParticipantCount, 0),
Focus = focus,
Related = related,
Anchor = focus.current_position,
SideA = new EnsembleSide
{
Key = focusSpecies,
Display = LiveEnsemble.SpeciesDisplay(focusSpecies),
Count = sideA,
Best = focus
},
SideB = new EnsembleSide
{
Key = relatedSpecies,
Display = LiveEnsemble.SpeciesDisplay(relatedSpecies),
Count = sideB,
Best = related
}
};
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(focus));
if (related != null)
{
ensemble.ParticipantIds.Add(EventFeedUtil.SafeId(related));
}
bool ok = InterestDirector.HarnessApplyCombatEnsemble(ensemble);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
}
/// <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
{
if (AssetManager.actor_library?.get(assetId) == null || World.world == null)
{
return null;
}
WorldTile tile = World.world.GetTile(
Mathf.RoundToInt(near.x + jitter),
Mathf.RoundToInt(near.y));
if (tile == null)
{
tile = World.world.GetTile(
Mathf.RoundToInt(near.x),
Mathf.RoundToInt(near.y));
}
if (tile == null)
{
return null;
}
Actor spawned = World.world.units.spawnNewUnit(
assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
if (spawned != null && spawned.isAlive())
{
_lastSpawned = spawned;
_lastSpawnedAssetId = assetId;
}
return spawned;
}
catch
{
return null;
}
}
private static void DoSetSetting(HarnessCommand cmd)
{
string key = cmd.expect;
@ -3711,8 +4075,9 @@ public static class AgentHarness
else
{
pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0;
// Also accept Battle-style where name leads.
|| tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0
|| IsEnsembleCombatTip(tip);
// Ensemble tips are tier-led (Battle - …); focus still owns the dossier.
detail = $"tip='{tip}' focus='{name}' pass={pass}";
}
@ -3727,6 +4092,43 @@ public static class AgentHarness
detail = $"tip='{tip}' not_contains='{needle}'";
break;
}
case "tip_matches_any":
{
// value = pipe-separated needles; tip must contain at least one.
string raw = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
string tip = CameraDirector.LastWatchLabel ?? "";
pass = false;
if (!string.IsNullOrEmpty(raw))
{
string[] parts = raw.Split('|');
for (int i = 0; i < parts.Length; i++)
{
string needle = (parts[i] ?? "").Trim();
if (needle.Length > 0
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
{
pass = true;
break;
}
}
}
detail = $"tip='{tip}' any='{raw}' pass={pass}";
break;
}
case "participant_count_min":
{
int want = 1;
if (!int.TryParse(string.IsNullOrEmpty(cmd.value) ? "1" : cmd.value, out want))
{
want = 1;
}
int have = InterestDirector.CurrentCandidate?.ParticipantCount ?? 0;
pass = have >= want;
detail = $"participants={have} want>={want}";
break;
}
case "unit_asset":
{
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset;
@ -3737,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;
}
@ -6688,6 +7107,20 @@ public static class AgentHarness
return SafeName(MoveCamera._focus_unit);
}
private static bool IsEnsembleCombatTip(string tip)
{
if (string.IsNullOrEmpty(tip))
{
return false;
}
return tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| tip.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|| tip.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|| tip.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|| tip.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static long ResolveActivitySubjectId()
{
// Prefer the camera focus (harness apply/assert target) over watch caption,

View file

@ -39,48 +39,67 @@ public static class CameraDirector
LastWatchLabel = interest.Label ?? "";
LastWatchAssetId = interest.AssetId ?? "";
LogService.LogInfo($"[IdleSpectator] {tip}");
WatchCaption.SetFromInterest(interest);
Actor follow = interest.HasFollowUnit
? interest.FollowUnit
: null;
// Species discovery: only attach a species-matched unit, never a random stranger.
if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
&& interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
// Location-only civic: pan only. Do not invent a stranger FollowUnit.
Actor follow = ResolveFollowUnit(interest);
if (follow != null && follow.isAlive())
{
// If this is a species event, refuse mismatched units.
if (interest.Label != null && interest.Label.StartsWith("New species:")
&& !string.IsNullOrEmpty(interest.AssetId)
&& follow.asset != null && follow.asset.id != interest.AssetId)
{
Actor matched = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
if (matched == null)
{
return;
}
follow = matched;
}
// Focus first, then nametag - same order as FocusUnit, so tip/focus/dossier
// never diverge across a handoff frame.
RetargetFollow(follow);
WatchCaption.SetFromActor(follow);
return;
}
// No owned unit: pan only if we are not already following someone.
// Never clear an existing focus unit here - that flashes the power bar.
// No accepted living follow: tip already logged. Do not bind a rejected FollowUnit
// (dead / species mismatch) as the dossier subject.
if (!interest.HasFollowUnit)
{
WatchCaption.SetFromInterest(interest);
}
if (!MoveCamera.hasFocusUnit())
{
PanCamera(interest.Position);
}
}
private static Actor ResolveFollowUnit(InterestEvent interest)
{
if (interest == null)
{
return null;
}
Actor follow = interest.HasFollowUnit ? interest.FollowUnit : null;
// Species discovery: only attach a species-matched unit, never a random stranger.
bool speciesTip = interest.Label != null
&& interest.Label.StartsWith("New species:");
if (follow == null
&& speciesTip
&& !string.IsNullOrEmpty(interest.AssetId)
&& interest.AssetId != "live_battle"
&& interest.AssetId != "scored_unit")
{
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
if (follow == null || !follow.isAlive())
{
return null;
}
if (speciesTip
&& !string.IsNullOrEmpty(interest.AssetId)
&& follow.asset != null
&& follow.asset.id != interest.AssetId)
{
return WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
}
return follow;
}
public static string FormatWatchTip(InterestEvent interest)
{
if (interest == null || string.IsNullOrEmpty(interest.Label))

View file

@ -30,19 +30,189 @@ public static class EventReason
public static string Battle(Actor focus, int fighters)
{
string name = Name(focus);
if (string.IsNullOrEmpty(name))
// Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing.
var e = new LiveEnsemble
{
name = "Someone";
Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(fighters),
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
ParticipantCount = Math.Max(1, fighters),
Focus = focus
};
return Combat(e);
}
/// <summary>
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
/// Multi 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)
{
if (ensemble == null || !ensemble.HasFocus)
{
return "";
}
int n = Math.Max(1, fighters);
if (n <= 2)
// True 1v1 only when we do not already have sticky opposing camps.
if (ensemble.Scale == EnsembleScale.Pair && !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
return name + " is in a duel";
string a = Name(ensemble.Focus);
string b = Name(ensemble.Related);
if (string.IsNullOrEmpty(a))
{
a = "Someone";
}
if (string.IsNullOrEmpty(b))
{
// Orphan duel with no foe - blank so director can keep sticky / clear.
return "";
}
return "Duel - " + a + " vs " + b;
}
return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
string tier = ScaleTierLabel(
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble))
{
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;
}
}
// 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)
{
switch (scale)
{
case EnsembleScale.Pair:
return "Duel";
case EnsembleScale.Skirmish:
return "Skirmish";
case EnsembleScale.Mass:
return "Mass";
default:
return "Battle";
}
}
private static string FormatCombatSide(EnsembleSide side, EnsembleFrame frame)
{
if (side == null)
{
return "";
}
// 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))
{
label = side.Key ?? "";
}
if (frame == EnsembleFrame.KingdomVsKingdom)
{
label = LiveEnsemble.KingdomDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
}
else if (frame == EnsembleFrame.SpeciesVsSpecies)
{
label = LiveEnsemble.SpeciesDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
if (!string.IsNullOrEmpty(side.KingdomDisplay))
{
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
if (!string.IsNullOrEmpty(kingdom)
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
{
label = "[" + kingdom + "] " + label;
}
}
}
else
{
// CountOnly / unknown - refuse unit-looking labels.
return "";
}
if (string.IsNullOrEmpty(label))
{
return "";
}
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) + ")";
}
/// <summary>Dispatch by ensemble kind (combat first; other kinds plug in later).</summary>
public static string Ensemble(LiveEnsemble ensemble)
{
if (ensemble == null)
{
return "";
}
switch (ensemble.Kind)
{
case EnsembleKind.Combat:
return Combat(ensemble);
default:
return Combat(ensemble);
}
}
public static string SeekingLover(Actor a)

View file

@ -353,8 +353,19 @@ public static class InterestFeeds
string verb = taskOrBeat ?? "fighting";
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
Vector3 pos = follow != null ? follow.current_position : actor.current_position;
LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
if (ensemble != null && ensemble.HasFocus)
{
follow = ensemble.Focus;
related = ensemble.Related ?? related;
}
long followId = SafeId(follow ?? actor);
string key = EventCatalog.Combat.Key(followId);
string label = ensemble != null
? EventReason.Combat(ensemble)
: EventReason.Fight(follow ?? actor, related);
var candidate = new InterestCandidate
{
Key = key,
@ -368,12 +379,14 @@ public static class InterestFeeds
RelatedUnit = related,
SubjectId = followId,
RelatedId = related != null ? SafeId(related) : 0,
Label = EventReason.Fight(follow ?? actor, related),
Label = label,
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
Verb = verb,
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
CreatedAt = Time.unscaledTime,
LastSeenAt = Time.unscaledTime,
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
@ -381,15 +394,34 @@ public static class InterestFeeds
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
candidate.ParticipantIds.Add(followId);
if (related != null)
if (ensemble != null)
{
long rid = SafeId(related);
if (rid != 0)
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
if (ensemble.Scale >= EnsembleScale.Skirmish)
{
candidate.ParticipantIds.Add(rid);
candidate.AssetId = "live_battle";
}
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
&& ensemble.SideA != null
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
{
candidate.KingdomKey = ensemble.SideA.Key;
}
}
else
{
candidate.ParticipantIds.Add(followId);
if (related != null)
{
long rid = SafeId(related);
if (rid != 0)
{
candidate.ParticipantIds.Add(rid);
}
}
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
@ -1031,7 +1063,15 @@ public static class InterestFeeds
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
if (battle != null)
{
RegisterDirect(battle);
if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
&& InterestDirector.TryAbsorbCombatBattle(battle))
{
// Active combat scene refreshed in place.
}
else
{
RegisterDirect(battle);
}
}
// Ongoing wars from WarManager (complements WorldLog war start/end).

File diff suppressed because it is too large Load diff

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();
@ -1057,26 +1060,109 @@ internal static class HarnessScenarios
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
Step("cf21", "assert", expect: "tip_contains", value: "fighting"),
Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf22", "interest_clear_follow", asset: "wolf"),
Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"),
Step("cf25", "assert", expect: "tip_matches_focus"),
Step("cf25b", "assert", expect: "dossier_matches_focus"),
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
// Cold combat must not keep "is fighting".
// 1v1 → multi: sided species framing + 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"),
Step("cf46", "assert", expect: "dossier_matches_focus"),
Step("cf47", "assert", expect: "has_focus", value: "true"),
// Cold combat must not keep fight / ensemble tips.
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
Step("cf31", "assert", expect: "tip_contains", value: "fighting"),
Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf32", "interest_release_force"),
Step("cf33", "interest_mark_inactive", value: "1"),
Step("cf34", "combat_maintain_focus"),
Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"),
Step("cf35", "assert", expect: "tip_not_contains", value: " vs "),
Step("cf35b", "assert", expect: "tip_not_contains", value: "Duel"),
Step("cf35c", "assert", expect: "tip_not_contains", value: "fighting"),
Step("cf90", "fast_timing", value: "false"),
Step("cf99", "snapshot"),
};
}
/// <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

@ -45,7 +45,12 @@ public static class InterestDirector
private static float _enabledAt = -999f;
private static float _inactiveSince = -999f;
private static float _lastCombatFocusAt = -999f;
private const float CombatFocusThrottleSeconds = 0.4f;
private const float CombatFocusThrottleSeconds = 1.25f;
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
private const float CombatFocusSwitchMargin = 12f;
/// <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;
@ -199,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;
@ -420,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;
}
@ -475,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");
}
@ -506,8 +529,9 @@ public static class InterestDirector
}
/// <summary>
/// Combat scenes: keep camera on the highest-scored living participant and rewrite the tip
/// so the subject always matches focus. Clears fight Labels once combat goes cold.
/// Keep camera on the highest-scored combat participant and rewrite tip/counts
/// from a live <see cref="LiveEnsemble"/> so subject + scale always match focus.
/// Clears fight Labels once combat goes cold.
/// </summary>
private static void MaintainCombatFocus(float now, bool force)
{
@ -529,21 +553,282 @@ public static class InterestDirector
}
_lastCombatFocusAt = now;
if (!WorldActivityScanner.TryPickBestCombatFocus(
_current, out Actor best, out Actor foe, out int fighters))
ApplyCombatEnsembleToCurrent(forceWatch: false);
}
/// <summary>
/// Refresh active combat scene from the world ensemble. Returns false if no focus.
/// </summary>
private static bool ApplyCombatEnsembleToCurrent(bool forceWatch)
{
if (_current == null || _current.Completion != InterestCompletionKind.CombatActive)
{
return;
return false;
}
bool mass = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| fighters > 2
|| _current.ParticipantCount > 2;
string label = mass
? EventReason.Battle(best, fighters)
: EventReason.Fight(best, foe);
Vector3 pos = _current.Position;
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
{
pos = _current.FollowUnit.current_position;
}
else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
{
pos = _current.RelatedUnit.current_position;
}
bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| _current.ParticipantCount > 2;
float radius = massHint ? 14f : 10f;
// Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
if (!_current.HasFollowUnit
&& _current.RelatedUnit != null
&& _current.RelatedUnit.isAlive())
{
Actor handoff = _current.RelatedUnit;
Actor handoffFoe = null;
try
{
if (handoff.has_attack_target
&& handoff.attack_target != null
&& handoff.attack_target.isAlive()
&& handoff.attack_target.isActor())
{
handoffFoe = handoff.attack_target.a;
if (handoffFoe != null && !handoffFoe.isAlive())
{
handoffFoe = null;
}
}
}
catch
{
handoffFoe = null;
}
LiveEnsemble.TryBuildCombat(
handoff.current_position,
radius,
out LiveEnsemble handoffEnsemble,
priorParticipantIds: _current.ParticipantIds,
newcomerBonus: 8f);
if (handoffEnsemble != null)
{
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
|| HasStickyCombatSides(_current)
|| handoffEnsemble.Scale != EnsembleScale.Pair))
{
handoffEnsemble.Focus = handoff;
if (handoffEnsemble.Related == null || handoffEnsemble.Related == handoff)
{
handoffEnsemble.Related = handoffFoe;
}
handoffLabel = EventReason.Combat(handoffEnsemble);
}
else
{
handoffLabel = EventReason.Fight(handoff, handoffFoe);
_current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount);
}
bool handoffFollowChanged = _current.FollowUnit != handoff;
bool handoffLabelChanged = !string.Equals(_current.Label ?? "", handoffLabel ?? "", StringComparison.Ordinal);
_current.FollowUnit = handoff;
_current.SubjectId = EventFeedUtil.SafeId(handoff);
_current.RelatedUnit = handoffFoe;
_current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
_current.Label = handoffLabel;
try
{
_current.Position = handoff.current_position;
}
catch
{
// keep
}
if (forceWatch
|| handoffFollowChanged
|| handoffLabelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != handoff)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
return true;
}
LiveEnsemble.TryBuildCombat(
pos,
radius,
out LiveEnsemble ensemble,
priorParticipantIds: _current.ParticipantIds,
newcomerBonus: 8f);
Actor best;
Actor foe;
int fighters;
string label;
if (ensemble != null && ensemble.HasFocus)
{
StabilizeCombatScale(_current, ensemble, Time.unscaledTime);
best = ensemble.Focus;
foe = ensemble.Related;
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))
{
var fallback = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(fighters),
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
ParticipantCount = fighters,
Focus = best,
Related = foe
};
StabilizeCombatScale(_current, fallback, Time.unscaledTime);
ApplyEnsembleFields(_current, fallback);
RefreshStickyCombatSides(_current, fallback, pos, radius);
label = EventReason.Combat(fallback);
_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;
}
// 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(
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
if (curRostered && !curFighting)
{
curW += 20f;
}
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
if (newW < curW + CombatFocusSwitchMargin)
{
best = curFollow;
if (foe == null || foe == best)
{
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;
@ -558,13 +843,556 @@ public static class InterestDirector
// keep prior position
}
if (followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best)
bool needWatch = forceWatch
|| followChanged
|| labelChanged
|| !HasLivingCameraFocus()
|| MoveCamera._focus_unit != best;
if (needWatch)
{
CameraDirector.Watch(_current.ToInterestEvent());
}
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)
{
return;
}
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)
{
scene.AssetId = "live_battle";
}
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
&& ensemble.SideA != null
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
{
scene.KingdomKey = ensemble.SideA.Key;
}
if (ensemble.Frame == EnsembleFrame.SpeciesVsSpecies
&& ensemble.SideA != null
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
{
scene.SpeciesId = ensemble.SideA.Key;
}
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>
/// If the active combat scene already covers this battle anchor, refresh it in place
/// instead of registering a second live_battle candidate.
/// </summary>
public static bool TryAbsorbCombatBattle(InterestEvent battle)
{
if (battle == null
|| _current == null
|| _current.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
Vector3 anchor = battle.Position;
Vector3 cur = _current.Position;
try
{
if (_current.FollowUnit != null && _current.FollowUnit.isAlive())
{
cur = _current.FollowUnit.current_position;
}
}
catch
{
// keep
}
float dx = anchor.x - cur.x;
float dy = anchor.y - cur.y;
float mergeR = 16f;
bool near = dx * dx + dy * dy <= mergeR * mergeR;
bool shared = false;
if (!near && battle.FollowUnit != null)
{
long id = EventFeedUtil.SafeId(battle.FollowUnit);
shared = id != 0 && _current.ParticipantIds != null && _current.ParticipantIds.Contains(id);
}
if (!near && !shared)
{
return false;
}
if (battle.ParticipantCount > _current.ParticipantCount)
{
_current.ParticipantCount = battle.ParticipantCount;
}
if (battle.NotableParticipantCount > _current.NotableParticipantCount)
{
_current.NotableParticipantCount = battle.NotableParticipantCount;
}
ApplyCombatEnsembleToCurrent(forceWatch: true);
return true;
}
/// <summary>Harness: apply a synthetic combat ensemble onto the current scene.</summary>
public static bool HarnessApplyCombatEnsemble(LiveEnsemble ensemble)
{
if (_current == null
|| ensemble == null
|| !ensemble.HasFocus
|| _current.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
ApplyEnsembleFields(_current, ensemble);
RefreshStickyCombatSides(
_current,
ensemble,
ensemble.Focus.current_position,
14f);
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
_current.Label = EventReason.Combat(ensemble);
try
{
_current.Position = ensemble.Focus.current_position;
}
catch
{
// keep
}
CameraDirector.Watch(_current.ToInterestEvent());
return true;
}
private static void ClearStaleCombatLabel(float now)
@ -574,13 +1402,23 @@ public static class InterestDirector
return;
}
if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
&& _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0)
string label = _current.Label;
if (label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("duel", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("skirmish", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("clash", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("battle", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("mass -", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) < 0
&& label.IndexOf("leading a fight", StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
_current.Label = "";
_current.ClearCombatSticky();
_combatScaleDropSince = -999f;
_lastCombatFocusAt = now;
CameraDirector.Watch(_current.ToInterestEvent());
}

View file

@ -131,6 +131,16 @@ public static class InterestRegistry
existing.RelatedId = incoming.RelatedId;
}
if (incoming.ParticipantCount > existing.ParticipantCount)
{
existing.ParticipantCount = incoming.ParticipantCount;
}
if (incoming.NotableParticipantCount > existing.NotableParticipantCount)
{
existing.NotableParticipantCount = incoming.NotableParticipantCount;
}
if (incoming.ForceActive)
{
existing.ForceActive = true;

View file

@ -52,6 +52,8 @@ public class ScoringWeights
public float massBaseBonus = 28f;
public float massPerExtraFighter = 4f;
public float massExtraCap = 24f;
/// <summary>Fighter count at which combat ensembles use Mass scale framing.</summary>
public int massCrowdThreshold = 8;
public int duelMaxFighters = 2;
public float duelNotablePairBonus = 55f;
public float duelSingleNotableBonus = 15f;

View file

@ -348,12 +348,16 @@ public sealed class UnitDossier
// Ownership: never let the reason name a living stranger (not subject/related).
if (ReasonNamesStranger(beat, scene))
{
if (recordDrops)
// Tiered combat tips name sides / duelists on purpose.
if (!IsEnsembleCombatReason(beat))
{
InterestDropLog.Record("identity_filter", "stranger:" + beat);
}
if (recordDrops)
{
InterestDropLog.Record("identity_filter", "stranger:" + beat);
}
return "";
return "";
}
}
// Ownership: reject beats that lead with someone else's proper name.
@ -472,6 +476,9 @@ public sealed class UnitDossier
case "fighting":
case "war":
case "battle":
case "duel":
case "skirmish":
case "mass":
case "clash":
case "grief":
case "mourning":
@ -501,6 +508,20 @@ public sealed class UnitDossier
}
}
private static bool IsEnsembleCombatReason(string reason)
{
if (string.IsNullOrEmpty(reason))
{
return false;
}
return reason.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|| reason.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|| reason.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|| reason.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|| reason.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
{
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
@ -768,6 +789,11 @@ public sealed class UnitDossier
|| t.IndexOf(" killed", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" fell", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" from ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.IndexOf(" vs ", System.StringComparison.OrdinalIgnoreCase) >= 0
|| t.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Skirmish", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Mass", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("Rebellion:", System.StringComparison.OrdinalIgnoreCase)
|| t.StartsWith("War:", System.StringComparison.OrdinalIgnoreCase)
|| t.IndexOf(':') >= 0;
@ -998,7 +1024,7 @@ public sealed class UnitDossier
sb.Append(" · ");
}
sb.Append(d.KingdomName);
sb.Append(LiveEnsemble.KingdomDisplay(d.KingdomName));
}
else if (!string.IsNullOrEmpty(d.CityName)
&& !string.Equals(d.CityName, d.SpeciesId, System.StringComparison.OrdinalIgnoreCase)

View file

@ -561,12 +561,60 @@ public static class WatchCaption
SetVisible(true);
}
// Safety net: any focus change that skipped SetFromActor (or rematched follow)
// must not leave the nametag on the previous person for a frame.
ReconcileDossierToFocus();
RefreshLivePortrait();
RefreshLiveTask();
RefreshOwnedReason();
RefreshHistoryIfChanged();
}
/// <summary>
/// While Idle Spectator is live, dossier nametag must track the camera focus unit.
/// Skips pause/fallen pins where the archive subject intentionally differs from focus.
/// </summary>
private static void ReconcileDossierToFocus()
{
if (_pinnedWhilePaused || !SpectatorMode.Active)
{
return;
}
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
return;
}
Actor focus = MoveCamera._focus_unit;
if (!focus.isAlive())
{
return;
}
long focusId = 0;
try
{
focusId = focus.getID();
}
catch
{
return;
}
if (focusId == 0)
{
return;
}
if (_current != null && _current.UnitId == focusId)
{
return;
}
SetFromActor(focus);
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;

View file

@ -121,14 +121,24 @@ public static class WorldActivityScanner
}
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
fighters = Mathf.Max(1, fighters);
PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
string label = EventReason.Fight(follow, related);
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
int fighters = ensemble != null ? ensemble.ParticipantCount : 1;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
fighters = Mathf.Max(1, fighters);
if (ensemble != null && ensemble.HasFocus)
{
follow = ensemble.Focus;
related = ensemble.Related ?? related;
}
string label = ensemble != null
? EventReason.Combat(ensemble)
: EventReason.Fight(follow, related);
var candidate = new InterestCandidate
{
Key = EventCatalog.Combat.Key(id),
Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id),
LeadKind = InterestLeadKind.EventLed,
Category = "Combat",
Source = "scanner",
@ -155,6 +165,21 @@ public static class WorldActivityScanner
MaxWatch = EventCatalog.Combat.MaxWatch,
Completion = InterestCompletionKind.CombatActive
};
if (ensemble != null)
{
StampParticipantIds(candidate, ensemble);
if (ensemble.Scale >= EnsembleScale.Skirmish)
{
candidate.AssetId = "live_battle";
}
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)
&& ensemble.Frame == EnsembleFrame.KingdomVsKingdom)
{
candidate.KingdomKey = ensemble.SideA.Key;
}
}
InterestScoring.ScoreCheap(candidate);
EventFeedUtil.RegisterCandidate(candidate);
}
@ -230,7 +255,10 @@ public static class WorldActivityScanner
int deaths = battle.getDeathsTotal();
Vector3 pos = battle.tile.posV3;
CountFightCluster(pos, 14f, out int fighters, out int notables, out Actor follow);
LiveEnsemble.TryBuildCombat(pos, 14f, out LiveEnsemble ensemble);
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
Actor follow = ensemble != null ? ensemble.Focus : null;
if (deaths < 1 && fighters < 2)
{
continue;
@ -241,12 +269,16 @@ public static class WorldActivityScanner
if (score > bestScore)
{
bestScore = score;
string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
Actor focus = follow ?? FindNearestAliveUnit(pos, 14f);
string label = ensemble != null && ensemble.HasFocus
? EventReason.Combat(ensemble)
: EventReason.Battle(focus, fighters);
best = new InterestEvent
{
Score = score,
Position = pos,
FollowUnit = follow ?? FindNearestAliveUnit(pos, 14f),
FollowUnit = focus,
RelatedUnit = ensemble != null ? ensemble.Related : null,
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
@ -289,7 +321,10 @@ public static class WorldActivityScanner
continue;
}
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out Actor follow);
LiveEnsemble.TryBuildCombat(actor.current_position, 10f, out LiveEnsemble ensemble);
int fighters = ensemble != null ? ensemble.ParticipantCount : 0;
int notables = ensemble != null ? ensemble.NotableParticipantCount : 0;
Actor follow = ensemble != null ? ensemble.Focus : null;
if (fighters < 1)
{
continue;
@ -302,12 +337,16 @@ public static class WorldActivityScanner
{
bestScore = score;
Actor focus = follow ?? actor;
string label = ensemble != null && ensemble.HasFocus
? EventReason.Combat(ensemble)
: EventReason.Battle(focus, fighters);
best = new InterestEvent
{
Score = score,
Position = actor.current_position,
FollowUnit = focus,
Label = EventReason.Battle(focus, fighters),
RelatedUnit = ensemble != null ? ensemble.Related : null,
Label = label,
CreatedAt = Time.unscaledTime,
AssetId = "live_battle",
ParticipantCount = fighters,
@ -370,7 +409,7 @@ public static class WorldActivityScanner
/// <summary>
/// Highest-scored living participant for an active combat scene.
/// Considers Follow, Related, and (for mass fights) the nearby fight cluster.
/// Considers Follow, Related, and (for mass fights) the nearby fight ensemble.
/// </summary>
public static bool TryPickBestCombatFocus(
InterestCandidate scene,
@ -386,6 +425,35 @@ public static class WorldActivityScanner
return false;
}
LiveEnsemble ensemble = null;
Vector3 pos = scene.Position;
if (scene.FollowUnit != null && scene.FollowUnit.isAlive())
{
pos = scene.FollowUnit.current_position;
}
else if (scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
{
pos = scene.RelatedUnit.current_position;
}
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| scene.ParticipantCount > 2;
float radius = mass ? 14f : 10f;
LiveEnsemble.TryBuildCombat(
pos,
radius,
out ensemble,
priorParticipantIds: scene.ParticipantIds,
newcomerBonus: 8f);
if (ensemble != null && ensemble.HasFocus)
{
best = ensemble.Focus;
foe = ensemble.Related;
fighters = Mathf.Max(1, ensemble.ParticipantCount);
return true;
}
fighters = Mathf.Max(1, scene.ParticipantCount);
float bestWeight = -1f;
Actor bestLocal = null;
@ -397,7 +465,7 @@ public static class WorldActivityScanner
return;
}
float w = CombatFocusWeight(unit);
float w = LiveEnsemble.FocusWeight(unit, scene.ParticipantIds, 8f);
if (w > bestWeight)
{
bestWeight = w;
@ -407,37 +475,13 @@ public static class WorldActivityScanner
Consider(scene.FollowUnit);
Consider(scene.RelatedUnit);
bool mass = string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|| scene.ParticipantCount > 2;
Vector3 pos = scene.Position;
if (bestLocal != null)
{
pos = bestLocal.current_position;
}
else if (scene.FollowUnit != null)
{
pos = scene.FollowUnit.current_position;
}
else if (scene.RelatedUnit != null)
{
pos = scene.RelatedUnit.current_position;
}
if (mass || bestLocal == null)
{
float radius = mass ? 14f : 10f;
CountFightCluster(pos, radius, out int clusterFighters, out _, out Actor clusterBest);
fighters = Mathf.Max(fighters, clusterFighters);
Consider(clusterBest);
}
best = bestLocal;
if (best == null)
{
return false;
}
foe = null;
try
{
if (best.has_attack_target
@ -474,6 +518,39 @@ public static class WorldActivityScanner
return true;
}
/// <summary>Build a combat ensemble snapshot at a world position.</summary>
public static bool TryBuildCombatEnsemble(
Vector2 pos,
float radius,
out LiveEnsemble ensemble,
InterestCandidate prior = null)
{
return LiveEnsemble.TryBuildCombat(
pos,
radius,
out ensemble,
priorParticipantIds: prior?.ParticipantIds,
newcomerBonus: 8f);
}
public static void StampParticipantIds(InterestCandidate candidate, LiveEnsemble ensemble)
{
if (candidate == null || ensemble == null)
{
return;
}
candidate.ParticipantIds.Clear();
for (int i = 0; i < ensemble.ParticipantIds.Count; i++)
{
long id = ensemble.ParticipantIds[i];
if (id != 0 && !candidate.ParticipantIds.Contains(id))
{
candidate.ParticipantIds.Add(id);
}
}
}
private static float ScoreFightCluster(int deaths, int fighters, int notables)
{
ScoringWeights w = InterestScoringConfig.W;
@ -512,54 +589,14 @@ public static class WorldActivityScanner
fighters = 0;
notables = 0;
bestFollow = null;
float bestChar = -1f;
float r2 = radius * radius;
foreach (Actor actor in EnumerateAliveUnits())
if (!LiveEnsemble.TryBuildCombat(pos, radius, out LiveEnsemble ensemble))
{
if (actor == null || !actor.isAlive())
{
continue;
}
float dx = actor.current_position.x - pos.x;
float dy = actor.current_position.y - pos.y;
if (dx * dx + dy * dy > r2)
{
continue;
}
bool fighting = false;
try
{
fighting = actor.has_attack_target
|| (actor.hasTask() && actor.ai?.task != null && actor.ai.task.in_combat);
}
catch
{
fighting = false;
}
if (!fighting)
{
continue;
}
fighters++;
bool notable = InterestScoring.IsNotable(actor);
bool extreme = InterestScoring.IsExtremelyNotable(actor);
if (notable)
{
notables++;
}
SplitActorScore(actor, null, out _, out float charScore);
float weight = charScore + (extreme ? 30f : 0f);
if (weight > bestChar)
{
bestChar = weight;
bestFollow = actor;
}
return;
}
fighters = ensemble.ParticipantCount;
notables = ensemble.NotableParticipantCount;
bestFollow = ensemble.Focus;
}
private static void MarkClusterSeen(Vector2 pos, float radius, HashSet<long> seen)

View file

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

View file

@ -32,6 +32,7 @@
"massBaseBonus": 28,
"massPerExtraFighter": 4,
"massExtraCap": 24,
"massCrowdThreshold": 8,
"duelMaxFighters": 2,
"duelNotablePairBonus": 55,
"duelSingleNotableBonus": 15,

View file

@ -85,6 +85,9 @@ Done (see [camera-presentability-plan.md](camera-presentability-plan.md) and `co
- Combat tip/focus alignment (highest-scored participant; tip subject matches focus;
clears "is fighting" when combat goes cold)
**Polish next:** dossier caption/nametag sync if tip+focus match but the nametag briefly shows someone else.
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.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

@ -42,6 +42,8 @@ See [`camera-presentability-plan.md`](camera-presentability-plan.md).
| Ambient building falls are B-only | live patch + pipelines `id_drop` |
| Status-overlap onset without live status | `status_overlap_camera` / `status_overlap_absent` |
| Combat tip subject matches focus | `combat_focus` / `tip_matches_focus` |
| Combat dossier nametag matches focus | `combat_focus` / `dossier_matches_focus` |
| Combat 1v1 escalates to sided ensemble | `combat_focus` / `combat_ensemble_escalate` + `tip_matches_any` (`Battle -` / ` vs `) |
| Cold combat clears fight tip | `combat_focus` / `tip_not_contains` fighting |
Live idle soak helper (Player.log window):

View file

@ -28,6 +28,42 @@ Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSu
Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`.
## Live ensembles (escalating multi-actor)
Combat is the first consumer of `LiveEnsemble` ([`Events/LiveEnsemble.cs`](../IdleSpectator/Events/LiveEnsemble.cs)).
The director refreshes counts, sides, focus, and `EventReason.Combat` while `CombatActive` stays sticky.
| Scale | Fighters | Typical reason |
|-------|----------|----------------|
| Pair | ≤2 | `Duel - Rosado vs Waaf` |
| Skirmish / Battle | 3+ | `Battle - Essiona (10) vs Northreach (3)` or `Battle - humans (10) vs wolves (3)` |
| Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` |
Optional kingdom brackets on species sides: `Battle - [Essiona] humans (10) vs wolves (3)`.
Side framing priority: kingdom vs kingdom → species vs species → 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)
| Kind | Live signal | Side framing |
|------|-------------|--------------|
| Combat | attack_target / in_combat cluster | kingdom → species → camps |
| WarFront | `WarInterestFeed` + nearby battles | kingdom vs kingdom |
| StatusOutbreak | same status id in radius | species / city |
| FamilyPack | family_group peers | family / species |
| PlotCell | plot members / phase | kingdom / plot type |
| DiscoveryCluster | pending first-seens near anchor | species |
Shared pieces: `LiveEnsemble`, side helpers, `EventReason.Ensemble`, maintain/write-back pattern in `InterestDirector`.
## EventReason
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.

View file

@ -5,7 +5,8 @@
# ./scripts/soak-audit-player-log.sh # last 120s wall wait, then audit new bytes
# ./scripts/soak-audit-player-log.sh 60 # wait 60s then audit
# ./scripts/soak-audit-player-log.sh --since-bytes N # audit from byte offset (no wait)
# ./scripts/soak-audit-player-log.sh --no-wait # audit from current EOF backward? no - from mark file
# ./scripts/soak-audit-player-log.sh --since-bytes N 60 # wait 60s, then audit from N
# ./scripts/soak-audit-player-log.sh --no-wait # audit empty window from current EOF
#
# Env:
# PLAYER_LOG - override Player.log path
@ -46,15 +47,18 @@ if [[ ! -f "$LOG" ]]; then
exit 1
fi
MARKED_FROM_EOF=0
if [[ -z "$SINCE_BYTES" ]]; then
SINCE_BYTES=$(wc -c < "$LOG" | tr -d ' ')
if [[ "$SECONDS_WAIT" -gt 0 ]]; then
echo "Marked offset $SINCE_BYTES; waiting ${SECONDS_WAIT}s (leave Idle Spectator running) ..."
sleep "$SECONDS_WAIT"
else
echo "No wait and no --since-bytes: auditing from current EOF (empty window)."
echo "Tip: ./scripts/soak-audit-player-log.sh 60"
fi
MARKED_FROM_EOF=1
fi
if [[ "$SECONDS_WAIT" -gt 0 ]]; then
echo "Marked offset $SINCE_BYTES; waiting ${SECONDS_WAIT}s (leave Idle Spectator running) ..."
sleep "$SECONDS_WAIT"
elif [[ "$MARKED_FROM_EOF" -eq 1 ]]; then
echo "No wait and no --since-bytes: auditing from current EOF (empty window)."
echo "Tip: ./scripts/soak-audit-player-log.sh 60"
fi
python3 - "$LOG" "$SINCE_BYTES" <<'PY'