This commit is contained in:
DazedAnon 2026-07-16 22:27:59 -05:00
parent 3ed85d01d1
commit e37cbf7890
15 changed files with 1812 additions and 125 deletions

View file

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

View file

@ -30,19 +30,154 @@ public static class EventReason
public static string Battle(Actor focus, int fighters) public static string Battle(Actor focus, int fighters)
{ {
string name = Name(focus); // Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing.
if (string.IsNullOrEmpty(name)) var e = new LiveEnsemble
{ {
name = "Someone"; Kind = EnsembleKind.Combat,
Scale = LiveEnsemble.ScaleForCount(fighters),
Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly,
ParticipantCount = Math.Max(1, fighters),
Focus = focus
};
return Combat(e);
}
/// <summary>
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
/// Multi-fighter form: <c>Battle - [Essiona] humans (10) vs wolves (3)</c>.
/// </summary>
public static string Combat(LiveEnsemble ensemble)
{
if (ensemble == null || !ensemble.HasFocus)
{
return "";
} }
int n = Math.Max(1, fighters); if (ensemble.Frame == EnsembleFrame.NamedPair
if (n <= 2) || ensemble.Scale == EnsembleScale.Pair)
{ {
return name + " is in a duel"; string a = Name(ensemble.Focus);
string b = Name(ensemble.Related);
if (string.IsNullOrEmpty(a))
{
a = "Someone";
}
if (string.IsNullOrEmpty(b))
{
return "Duel - " + a;
}
return "Duel - " + a + " vs " + b;
} }
return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture); string tier = ScaleTierLabel(ensemble.Scale);
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
bool sided = !string.IsNullOrEmpty(sideA)
&& !string.IsNullOrEmpty(sideB)
&& !sideA.Equals(sideB, StringComparison.OrdinalIgnoreCase);
if (sided)
{
return tier + " - " + sideA + " vs " + sideB;
}
int n = Math.Max(1, ensemble.ParticipantCount);
if (!string.IsNullOrEmpty(sideA))
{
return tier + " - " + sideA + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
}
return tier + " - fight of " + n.ToString(CultureInfo.InvariantCulture);
}
private static string ScaleTierLabel(EnsembleScale scale)
{
switch (scale)
{
case EnsembleScale.Pair:
return "Duel";
case EnsembleScale.Skirmish:
return "Skirmish";
case EnsembleScale.Mass:
return "Mass";
default:
return "Battle";
}
}
private static string FormatCombatSide(EnsembleSide side, EnsembleFrame frame)
{
if (side == null)
{
return "";
}
string label = side.Display ?? "";
if (string.IsNullOrEmpty(label))
{
label = side.Key ?? "";
}
if (frame == EnsembleFrame.KingdomVsKingdom)
{
label = LiveEnsemble.KingdomDisplay(label);
}
else if (frame == EnsembleFrame.SpeciesVsSpecies)
{
label = LiveEnsemble.SpeciesDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key);
if (!string.IsNullOrEmpty(side.KingdomDisplay))
{
string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay);
// Avoid "[Humans] humans" when kingdom label is just the species.
if (!string.IsNullOrEmpty(kingdom)
&& !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase)
&& kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0)
{
label = "[" + kingdom + "] " + label;
}
}
}
else if (frame == EnsembleFrame.NamedLeaders)
{
if (side.Best != null)
{
label = Name(side.Best);
}
}
if (string.IsNullOrEmpty(label))
{
return "";
}
// Consistent side casing for collective labels.
if (frame == EnsembleFrame.SpeciesVsSpecies
|| frame == EnsembleFrame.KingdomVsKingdom)
{
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
}
int n = Math.Max(1, side.Count);
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
}
/// <summary>Dispatch by ensemble kind (combat first; other kinds plug in later).</summary>
public static string Ensemble(LiveEnsemble ensemble)
{
if (ensemble == null)
{
return "";
}
switch (ensemble.Kind)
{
case EnsembleKind.Combat:
return Combat(ensemble);
default:
return Combat(ensemble);
}
} }
public static string SeekingLover(Actor a) public static string SeekingLover(Actor a)

View file

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

View file

@ -0,0 +1,877 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Families that can escalate into multi-actor live scenes.</summary>
public enum EnsembleKind
{
Combat = 0,
StatusOutbreak = 1,
FamilyPack = 2,
WarFront = 3,
PlotCell = 4,
DiscoveryCluster = 5
}
/// <summary>Size band for reason + scoring alignment.</summary>
public enum EnsembleScale
{
Pair = 0,
Skirmish = 1,
Battle = 2,
Mass = 3
}
/// <summary>How opposing camps are named in the orange reason.</summary>
public enum EnsembleFrame
{
NamedPair = 0,
KingdomVsKingdom = 1,
SpeciesVsSpecies = 2,
NamedLeaders = 3,
CountOnly = 4
}
/// <summary>One camp inside a <see cref="LiveEnsemble"/>.</summary>
public sealed class EnsembleSide
{
public string Key = "";
public string Display = "";
/// <summary>Optional kingdom pretty-name when the side is species-framed.</summary>
public string KingdomDisplay = "";
public int Count;
public int NotableCount;
public Actor Best;
}
/// <summary>
/// Snapshot of a live multi-actor scene. Combat is the first consumer;
/// wars / status / family / plots can reuse the same shape later.
/// </summary>
public sealed class LiveEnsemble
{
public EnsembleKind Kind = EnsembleKind.Combat;
public EnsembleScale Scale = EnsembleScale.Pair;
public EnsembleFrame Frame = EnsembleFrame.CountOnly;
public int ParticipantCount;
public int NotableParticipantCount;
public Actor Focus;
public Actor Related;
public EnsembleSide SideA;
public EnsembleSide SideB;
public readonly List<long> ParticipantIds = new List<long>(8);
public Vector3 Anchor;
public bool HasFocus => Focus != null && Focus.isAlive();
public static EnsembleScale ScaleForCount(int count)
{
ScoringWeights w = InterestScoringConfig.W;
int n = Math.Max(0, count);
if (n <= Math.Max(1, w.duelMaxFighters))
{
return EnsembleScale.Pair;
}
int crowd = Math.Max(w.massFighterThreshold + 1, w.massCrowdThreshold);
if (n >= crowd)
{
return EnsembleScale.Mass;
}
if (n >= Math.Max(3, w.massFighterThreshold))
{
return EnsembleScale.Battle;
}
return EnsembleScale.Skirmish;
}
/// <summary>
/// Collect living fighters near <paramref name="pos"/> and partition into sides.
/// </summary>
public static bool TryBuildCombat(
Vector2 pos,
float radius,
out LiveEnsemble ensemble,
ICollection<long> priorParticipantIds = null,
float newcomerBonus = 8f)
{
ensemble = null;
var fighters = new List<Actor>(16);
CollectCombatFighters(pos, radius, fighters);
if (fighters.Count == 0)
{
return false;
}
ensemble = FromCombatFighters(fighters, pos, priorParticipantIds, newcomerBonus);
return ensemble != null && ensemble.HasFocus;
}
public static LiveEnsemble FromCombatFighters(
List<Actor> fighters,
Vector2 anchor,
ICollection<long> priorParticipantIds = null,
float newcomerBonus = 8f)
{
if (fighters == null || fighters.Count == 0)
{
return null;
}
var ensemble = new LiveEnsemble
{
Kind = EnsembleKind.Combat,
Anchor = new Vector3(anchor.x, anchor.y, 0f)
};
int notables = 0;
float bestWeight = -1f;
Actor best = null;
for (int i = 0; i < fighters.Count; i++)
{
Actor actor = fighters[i];
if (actor == null || !actor.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(actor);
if (id != 0)
{
ensemble.ParticipantIds.Add(id);
}
if (InterestScoring.IsNotable(actor))
{
notables++;
}
float weight = FocusWeight(actor, priorParticipantIds, newcomerBonus);
if (weight > bestWeight)
{
bestWeight = weight;
best = actor;
}
}
ensemble.ParticipantCount = ensemble.ParticipantIds.Count > 0
? ensemble.ParticipantIds.Count
: fighters.Count;
ensemble.NotableParticipantCount = notables;
ensemble.Focus = best;
ensemble.Scale = ScaleForCount(ensemble.ParticipantCount);
if (ensemble.Focus == null)
{
return null;
}
try
{
ensemble.Anchor = ensemble.Focus.current_position;
}
catch
{
// keep anchor
}
if (ensemble.ParticipantCount <= 2)
{
ApplyNamedPair(ensemble, fighters);
return ensemble;
}
if (TryPartitionByKingdom(fighters, ensemble, priorParticipantIds, newcomerBonus))
{
return ensemble;
}
if (TryPartitionBySpecies(fighters, ensemble, priorParticipantIds, newcomerBonus))
{
return ensemble;
}
if (TryPartitionByAttackCamps(fighters, ensemble, priorParticipantIds, newcomerBonus))
{
return ensemble;
}
ApplyCountOnly(ensemble, fighters, priorParticipantIds, newcomerBonus);
return ensemble;
}
public static float FocusWeight(
Actor actor,
ICollection<long> priorParticipantIds,
float newcomerBonus)
{
float weight = WorldActivityScanner.CombatFocusWeight(actor);
if (weight < 0f)
{
return weight;
}
if (priorParticipantIds != null
&& priorParticipantIds.Count > 0
&& newcomerBonus > 0f)
{
long id = EventFeedUtil.SafeId(actor);
if (id != 0 && !priorParticipantIds.Contains(id))
{
weight += newcomerBonus;
}
}
return weight;
}
public static string KingdomKeyOf(Actor actor)
{
if (actor?.kingdom == null)
{
return "";
}
string name = actor.kingdom.name ?? "";
return string.IsNullOrEmpty(name) ? "" : name.Trim();
}
/// <summary>Human-readable kingdom / civ label (never raw snake_case ids).</summary>
public static string KingdomDisplay(string kingdomKey)
{
if (string.IsNullOrEmpty(kingdomKey))
{
return "";
}
string key = kingdomKey.Trim();
// Some worlds use a bare species asset id as the kingdom name.
if (key.IndexOf(' ') < 0
&& key.IndexOf('_') < 0
&& ActivityAssetCatalog.IsLiveActorAssetId(key))
{
return SpeciesDisplay(key);
}
return HumanizeWorldLabel(key);
}
public static string SpeciesKeyOf(Actor actor)
{
return actor?.asset != null ? (actor.asset.id ?? "") : "";
}
public static string SpeciesDisplay(string speciesId)
{
if (string.IsNullOrEmpty(speciesId))
{
return "creatures";
}
string fromAsset = ActivityAssetCatalog.SpeciesDisplayLabel(speciesId);
string s = !string.IsNullOrEmpty(fromAsset) ? fromAsset : speciesId.Replace('_', ' ').Trim();
if (string.IsNullOrEmpty(s))
{
return "creatures";
}
string lower = s.ToLowerInvariant();
if (lower.StartsWith("nomads ", StringComparison.Ordinal))
{
s = s.Substring(7).Trim();
lower = s.ToLowerInvariant();
}
switch (lower)
{
case "wolf":
return "wolves";
case "human":
case "humans":
return "humans";
case "sheep":
return "sheep";
case "fish":
return "fish";
case "goose":
return "geese";
case "mouse":
return "mice";
case "ox":
return "oxen";
case "dwarf":
return "dwarves";
case "elf":
return "elves";
}
if (lower.EndsWith("s", StringComparison.Ordinal)
|| lower.EndsWith("x", StringComparison.Ordinal)
|| lower.EndsWith("ch", StringComparison.Ordinal)
|| lower.EndsWith("sh", StringComparison.Ordinal))
{
return lower;
}
if (lower.EndsWith("y", StringComparison.Ordinal) && lower.Length > 1)
{
char prev = lower[lower.Length - 2];
if (prev != 'a' && prev != 'e' && prev != 'i' && prev != 'o' && prev != 'u')
{
return lower.Substring(0, lower.Length - 1) + "ies";
}
}
return lower + "s";
}
/// <summary>Title-case / underscore cleanup for kingdom and civ-like ids.</summary>
public static string HumanizeWorldLabel(string raw)
{
if (string.IsNullOrEmpty(raw))
{
return "";
}
string s = raw.Trim().Replace('_', ' ');
while (s.IndexOf(" ", StringComparison.Ordinal) >= 0)
{
s = s.Replace(" ", " ");
}
if (s.Length == 0)
{
return "";
}
// Strip civ prefixes that leak into labels ("nomads human" → species when match).
string lower = s.ToLowerInvariant();
if (lower.StartsWith("nomads ", StringComparison.Ordinal))
{
string rest = s.Substring(7).Trim();
string restId = rest.Replace(' ', '_');
if (ActivityAssetCatalog.IsLiveActorAssetId(restId)
|| ActivityAssetCatalog.IsLiveActorAssetId(rest))
{
return SpeciesDisplay(
ActivityAssetCatalog.IsLiveActorAssetId(restId) ? restId : rest);
}
}
var sb = new System.Text.StringBuilder(s.Length);
bool cap = true;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (char.IsWhiteSpace(c))
{
sb.Append(' ');
cap = true;
continue;
}
if (cap)
{
sb.Append(char.ToUpperInvariant(c));
cap = false;
}
else
{
sb.Append(char.ToLowerInvariant(c));
}
}
return sb.ToString();
}
private static void CollectCombatFighters(Vector2 pos, float radius, List<Actor> into)
{
float r2 = radius * radius;
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
{
if (actor == null || !actor.isAlive())
{
continue;
}
float dx = actor.current_position.x - pos.x;
float dy = actor.current_position.y - pos.y;
if (dx * dx + dy * dy > r2)
{
continue;
}
if (!IsCombatParticipant(actor))
{
continue;
}
into.Add(actor);
}
}
public static bool IsCombatParticipant(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return false;
}
try
{
if (actor.has_attack_target)
{
return true;
}
return actor.hasTask()
&& actor.ai?.task != null
&& actor.ai.task.in_combat;
}
catch
{
return false;
}
}
private static void ApplyNamedPair(LiveEnsemble ensemble, List<Actor> fighters)
{
ensemble.Frame = EnsembleFrame.NamedPair;
Actor other = null;
for (int i = 0; i < fighters.Count; i++)
{
Actor a = fighters[i];
if (a != null && a.isAlive() && a != ensemble.Focus)
{
other = a;
break;
}
}
if (other == null)
{
other = AttackTargetOf(ensemble.Focus);
}
ensemble.Related = other;
ensemble.SideA = SideFromActor(ensemble.Focus, "focus");
if (other != null)
{
ensemble.SideB = SideFromActor(other, "foe");
}
}
private static void ApplyCountOnly(
LiveEnsemble ensemble,
List<Actor> fighters,
ICollection<long> prior,
float newcomerBonus)
{
ensemble.Frame = EnsembleFrame.CountOnly;
ensemble.SideA = SideFromActor(ensemble.Focus, "focus");
ensemble.SideA.Count = ensemble.ParticipantCount;
ensemble.SideA.NotableCount = ensemble.NotableParticipantCount;
Actor foe = AttackTargetOf(ensemble.Focus);
if (foe == null || !foe.isAlive())
{
// Second-best by weight as a soft related.
float bestW = -1f;
for (int i = 0; i < fighters.Count; i++)
{
Actor a = fighters[i];
if (a == null || a == ensemble.Focus || !a.isAlive())
{
continue;
}
float w = FocusWeight(a, prior, newcomerBonus);
if (w > bestW)
{
bestW = w;
foe = a;
}
}
}
ensemble.Related = foe;
if (foe != null)
{
ensemble.SideB = SideFromActor(foe, "foe");
if (ensemble.NotableParticipantCount >= 2
&& InterestScoring.IsNotable(ensemble.Focus)
&& InterestScoring.IsNotable(foe))
{
ensemble.Frame = EnsembleFrame.NamedLeaders;
}
}
}
private static bool TryPartitionByKingdom(
List<Actor> fighters,
LiveEnsemble ensemble,
ICollection<long> prior,
float newcomerBonus)
{
var groups = new Dictionary<string, List<Actor>>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < fighters.Count; i++)
{
Actor a = fighters[i];
string key = KingdomKeyOf(a);
if (string.IsNullOrEmpty(key))
{
continue;
}
if (!groups.TryGetValue(key, out List<Actor> list))
{
list = new List<Actor>(4);
groups[key] = list;
}
list.Add(a);
}
if (groups.Count < 2)
{
return false;
}
return FinishTwoSides(
ensemble,
groups,
EnsembleFrame.KingdomVsKingdom,
prior,
newcomerBonus,
displayFromKey: KingdomDisplay);
}
private static bool TryPartitionBySpecies(
List<Actor> fighters,
LiveEnsemble ensemble,
ICollection<long> prior,
float newcomerBonus)
{
var groups = new Dictionary<string, List<Actor>>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < fighters.Count; i++)
{
Actor a = fighters[i];
string key = SpeciesKeyOf(a);
if (string.IsNullOrEmpty(key))
{
continue;
}
if (!groups.TryGetValue(key, out List<Actor> list))
{
list = new List<Actor>(4);
groups[key] = list;
}
list.Add(a);
}
if (groups.Count < 2)
{
return false;
}
return FinishTwoSides(
ensemble,
groups,
EnsembleFrame.SpeciesVsSpecies,
prior,
newcomerBonus,
displayFromKey: SpeciesDisplay);
}
private static bool TryPartitionByAttackCamps(
List<Actor> fighters,
LiveEnsemble ensemble,
ICollection<long> prior,
float newcomerBonus)
{
Actor focus = ensemble.Focus;
Actor foe = AttackTargetOf(focus);
if (foe == null || !foe.isAlive())
{
return false;
}
var sideA = new List<Actor> { focus };
var sideB = new List<Actor> { foe };
for (int i = 0; i < fighters.Count; i++)
{
Actor a = fighters[i];
if (a == null || a == focus || a == foe || !a.isAlive())
{
continue;
}
Actor target = AttackTargetOf(a);
if (target == foe || target == null && SameCampHint(a, focus))
{
sideA.Add(a);
}
else if (target == focus || SameCampHint(a, foe))
{
sideB.Add(a);
}
else if (AttackTargetOf(foe) == a)
{
sideB.Add(a);
}
else
{
// Default: share focus's kingdom/species if any, else side A.
if (!string.IsNullOrEmpty(KingdomKeyOf(focus))
&& string.Equals(KingdomKeyOf(a), KingdomKeyOf(focus), StringComparison.OrdinalIgnoreCase))
{
sideA.Add(a);
}
else if (!string.IsNullOrEmpty(SpeciesKeyOf(focus))
&& string.Equals(SpeciesKeyOf(a), SpeciesKeyOf(focus), StringComparison.OrdinalIgnoreCase))
{
sideA.Add(a);
}
else
{
sideB.Add(a);
}
}
}
if (sideA.Count == 0 || sideB.Count == 0)
{
return false;
}
ensemble.Frame = EnsembleFrame.NamedLeaders;
ensemble.SideA = BuildSide("camp:a", EventFeedUtil.SafeName(focus), sideA, prior, newcomerBonus, EnsembleFrame.NamedLeaders);
ensemble.SideB = BuildSide("camp:b", EventFeedUtil.SafeName(foe), sideB, prior, newcomerBonus, EnsembleFrame.NamedLeaders);
PreferFocusSide(ensemble);
ensemble.Related = ensemble.SideB?.Best ?? foe;
return true;
}
private static bool SameCampHint(Actor a, Actor camp)
{
if (a == null || camp == null)
{
return false;
}
string ka = KingdomKeyOf(a);
string kc = KingdomKeyOf(camp);
if (!string.IsNullOrEmpty(ka) && string.Equals(ka, kc, StringComparison.OrdinalIgnoreCase))
{
return true;
}
string sa = SpeciesKeyOf(a);
string sc = SpeciesKeyOf(camp);
return !string.IsNullOrEmpty(sa) && string.Equals(sa, sc, StringComparison.OrdinalIgnoreCase);
}
private static bool FinishTwoSides(
LiveEnsemble ensemble,
Dictionary<string, List<Actor>> groups,
EnsembleFrame frame,
ICollection<long> prior,
float newcomerBonus,
Func<string, string> displayFromKey)
{
string keyA = null;
string keyB = null;
int countA = -1;
int countB = -1;
foreach (KeyValuePair<string, List<Actor>> kv in groups)
{
int n = kv.Value?.Count ?? 0;
if (n > countA)
{
keyB = keyA;
countB = countA;
keyA = kv.Key;
countA = n;
}
else if (n > countB)
{
keyB = kv.Key;
countB = n;
}
}
if (string.IsNullOrEmpty(keyA) || string.IsNullOrEmpty(keyB))
{
return false;
}
ensemble.Frame = frame;
ensemble.SideA = BuildSide(keyA, displayFromKey(keyA), groups[keyA], prior, newcomerBonus, frame);
ensemble.SideB = BuildSide(keyB, displayFromKey(keyB), groups[keyB], prior, newcomerBonus, frame);
PreferFocusSide(ensemble);
ensemble.Related = ensemble.SideB?.Best;
if (ensemble.Related == null)
{
ensemble.Related = AttackTargetOf(ensemble.Focus);
}
return ensemble.SideA != null && ensemble.SideB != null;
}
private static void PreferFocusSide(LiveEnsemble ensemble)
{
if (ensemble?.Focus == null || ensemble.SideA == null || ensemble.SideB == null)
{
return;
}
bool onA = ActorMatchesSide(ensemble.Focus, ensemble.SideA);
bool onB = ActorMatchesSide(ensemble.Focus, ensemble.SideB);
if (onB && !onA)
{
EnsembleSide tmp = ensemble.SideA;
ensemble.SideA = ensemble.SideB;
ensemble.SideB = tmp;
}
}
private static bool ActorMatchesSide(Actor actor, EnsembleSide side)
{
if (actor == null || side == null || string.IsNullOrEmpty(side.Key))
{
return false;
}
if (side.Key.StartsWith("camp:", StringComparison.OrdinalIgnoreCase))
{
return side.Best == actor;
}
string kingdom = KingdomKeyOf(actor);
if (!string.IsNullOrEmpty(kingdom)
&& kingdom.Equals(side.Key, StringComparison.OrdinalIgnoreCase))
{
return true;
}
string species = SpeciesKeyOf(actor);
return !string.IsNullOrEmpty(species)
&& species.Equals(side.Key, StringComparison.OrdinalIgnoreCase);
}
private static EnsembleSide BuildSide(
string key,
string display,
List<Actor> members,
ICollection<long> prior,
float newcomerBonus,
EnsembleFrame frame)
{
string pretty = string.IsNullOrEmpty(display) ? (key ?? "") : display;
if (frame == EnsembleFrame.KingdomVsKingdom)
{
pretty = KingdomDisplay(pretty);
}
else if (frame == EnsembleFrame.SpeciesVsSpecies)
{
pretty = SpeciesDisplay(key);
}
var side = new EnsembleSide
{
Key = key ?? "",
Display = pretty,
Count = members?.Count ?? 0
};
if (members == null)
{
return side;
}
float bestW = -1f;
for (int i = 0; i < members.Count; i++)
{
Actor a = members[i];
if (a == null || !a.isAlive())
{
continue;
}
if (InterestScoring.IsNotable(a))
{
side.NotableCount++;
}
float w = FocusWeight(a, prior, newcomerBonus);
if (w > bestW)
{
bestW = w;
side.Best = a;
}
}
if (frame == EnsembleFrame.SpeciesVsSpecies && side.Best != null)
{
string kingdom = KingdomKeyOf(side.Best);
if (!string.IsNullOrEmpty(kingdom))
{
side.KingdomDisplay = KingdomDisplay(kingdom);
}
}
return side;
}
private static EnsembleSide SideFromActor(Actor actor, string fallbackKey)
{
if (actor == null)
{
return null;
}
string kingdom = KingdomKeyOf(actor);
string species = SpeciesKeyOf(actor);
return new EnsembleSide
{
Key = !string.IsNullOrEmpty(kingdom) ? kingdom
: (!string.IsNullOrEmpty(species) ? species : fallbackKey),
Display = !string.IsNullOrEmpty(kingdom)
? KingdomDisplay(kingdom)
: (!string.IsNullOrEmpty(species) ? SpeciesDisplay(species) : EventFeedUtil.SafeName(actor)),
KingdomDisplay = !string.IsNullOrEmpty(kingdom) ? KingdomDisplay(kingdom) : "",
Count = 1,
NotableCount = InterestScoring.IsNotable(actor) ? 1 : 0,
Best = actor
};
}
private static Actor AttackTargetOf(Actor actor)
{
if (actor == null)
{
return null;
}
try
{
if (actor.has_attack_target
&& actor.attack_target != null
&& actor.attack_target.isAlive()
&& actor.attack_target.isActor())
{
Actor foe = actor.attack_target.a;
return foe != null && foe.isAlive() ? foe : null;
}
}
catch
{
// ignore
}
return null;
}
}

View file

@ -1057,7 +1057,7 @@ internal static class HarnessScenarios
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus. // Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"), Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight"),
Step("cf21", "assert", expect: "tip_contains", value: "fighting"), Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf22", "interest_clear_follow", asset: "wolf"), Step("cf22", "interest_clear_follow", asset: "wolf"),
Step("cf23", "combat_maintain_focus"), Step("cf23", "combat_maintain_focus"),
Step("cf24", "assert", expect: "has_focus", value: "true"), Step("cf24", "assert", expect: "has_focus", value: "true"),
@ -1065,13 +1065,25 @@ internal static class HarnessScenarios
Step("cf25b", "assert", expect: "dossier_matches_focus"), Step("cf25b", "assert", expect: "dossier_matches_focus"),
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"), Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
// Cold combat must not keep "is fighting". // 1v1 → multi: sided species framing + participant write-back.
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf42", "combat_ensemble_escalate", value: "6"),
Step("cf43", "assert", expect: "participant_count_min", value: "6"),
Step("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
Step("cf45", "assert", expect: "tip_matches_focus"),
Step("cf46", "assert", expect: "dossier_matches_focus"),
Step("cf47", "assert", expect: "has_focus", value: "true"),
// Cold combat must not keep fight / ensemble tips.
Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"), Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"),
Step("cf31", "assert", expect: "tip_contains", value: "fighting"), Step("cf31", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
Step("cf32", "interest_release_force"), Step("cf32", "interest_release_force"),
Step("cf33", "interest_mark_inactive", value: "1"), Step("cf33", "interest_mark_inactive", value: "1"),
Step("cf34", "combat_maintain_focus"), Step("cf34", "combat_maintain_focus"),
Step("cf35", "assert", expect: "tip_not_contains", value: "fighting"), Step("cf35", "assert", expect: "tip_not_contains", value: " vs "),
Step("cf35b", "assert", expect: "tip_not_contains", value: "Duel"),
Step("cf35c", "assert", expect: "tip_not_contains", value: "fighting"),
Step("cf90", "fast_timing", value: "false"), Step("cf90", "fast_timing", value: "false"),
Step("cf99", "snapshot"), Step("cf99", "snapshot"),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "author": "dazed",
"version": "0.25.52", "version": "0.25.54",
"description": "AFK Idle Spectator (I) + Lore (L). Dossier nametag tracks focus on handoff.", "description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.",
"GUID": "com.dazed.idlespectator" "GUID": "com.dazed.idlespectator"
} }

View file

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

View file

@ -88,4 +88,6 @@ Done (see [camera-presentability-plan.md](camera-presentability-plan.md) and `co
Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption; Done (0.25.52): dossier nametag tracks focus on handoff (`CameraDirector.Watch` focus-then-caption;
`WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`). `WatchCaption.ReconcileDossierToFocus`; `combat_focus` asserts `dossier_matches_focus`).
Done (0.25.54): tiered battle reasons (`Battle - [Kingdom] side (n) vs …`) + combat focus hysteresis.
Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`). Soak audit helper: `./scripts/soak-audit-player-log.sh [seconds]` (or pass `--since-bytes N`).

View file

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

View file

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