diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 78d3373..6f48447 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2013,6 +2013,10 @@ public static class AgentHarness break; } + case "combat_ensemble_escalate": + DoCombatEnsembleEscalate(cmd); + break; + case "interest_variety_clear": InterestVariety.Clear(); _cmdOk++; @@ -3542,7 +3546,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( @@ -3586,6 +3602,153 @@ public static class AgentHarness detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'"); } + /// + /// 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. + /// + 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) { string key = cmd.expect; @@ -3711,8 +3874,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 +3891,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; @@ -6688,6 +6889,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, diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index f02ffd7..bbc2344 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -30,19 +30,154 @@ 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); + } + + /// + /// Context-aware combat orange reason from a snapshot. + /// Multi-fighter form: Battle - [Essiona] humans (10) vs wolves (3). + /// + public static string Combat(LiveEnsemble ensemble) + { + if (ensemble == null || !ensemble.HasFocus) + { + return ""; } - int n = Math.Max(1, fighters); - if (n <= 2) + if (ensemble.Frame == EnsembleFrame.NamedPair + || 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) + ")"; + } + + /// Dispatch by ensemble kind (combat first; other kinds plug in later). + 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) diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index d2b41e6..18aec05 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -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). diff --git a/IdleSpectator/Events/LiveEnsemble.cs b/IdleSpectator/Events/LiveEnsemble.cs new file mode 100644 index 0000000..f9d4ec7 --- /dev/null +++ b/IdleSpectator/Events/LiveEnsemble.cs @@ -0,0 +1,877 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// Families that can escalate into multi-actor live scenes. +public enum EnsembleKind +{ + Combat = 0, + StatusOutbreak = 1, + FamilyPack = 2, + WarFront = 3, + PlotCell = 4, + DiscoveryCluster = 5 +} + +/// Size band for reason + scoring alignment. +public enum EnsembleScale +{ + Pair = 0, + Skirmish = 1, + Battle = 2, + Mass = 3 +} + +/// How opposing camps are named in the orange reason. +public enum EnsembleFrame +{ + NamedPair = 0, + KingdomVsKingdom = 1, + SpeciesVsSpecies = 2, + NamedLeaders = 3, + CountOnly = 4 +} + +/// One camp inside a . +public sealed class EnsembleSide +{ + public string Key = ""; + public string Display = ""; + /// Optional kingdom pretty-name when the side is species-framed. + public string KingdomDisplay = ""; + public int Count; + public int NotableCount; + public Actor Best; +} + +/// +/// Snapshot of a live multi-actor scene. Combat is the first consumer; +/// wars / status / family / plots can reuse the same shape later. +/// +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 ParticipantIds = new List(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; + } + + /// + /// Collect living fighters near and partition into sides. + /// + public static bool TryBuildCombat( + Vector2 pos, + float radius, + out LiveEnsemble ensemble, + ICollection priorParticipantIds = null, + float newcomerBonus = 8f) + { + ensemble = null; + var fighters = new List(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 fighters, + Vector2 anchor, + ICollection 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 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(); + } + + /// Human-readable kingdom / civ label (never raw snake_case ids). + 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"; + } + + /// Title-case / underscore cleanup for kingdom and civ-like ids. + 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 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 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 fighters, + ICollection 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 fighters, + LiveEnsemble ensemble, + ICollection prior, + float newcomerBonus) + { + var groups = new Dictionary>(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 list)) + { + list = new List(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 fighters, + LiveEnsemble ensemble, + ICollection prior, + float newcomerBonus) + { + var groups = new Dictionary>(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 list)) + { + list = new List(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 fighters, + LiveEnsemble ensemble, + ICollection prior, + float newcomerBonus) + { + Actor focus = ensemble.Focus; + Actor foe = AttackTargetOf(focus); + if (foe == null || !foe.isAlive()) + { + return false; + } + + var sideA = new List { focus }; + var sideB = new List { 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> groups, + EnsembleFrame frame, + ICollection prior, + float newcomerBonus, + Func displayFromKey) + { + string keyA = null; + string keyB = null; + int countA = -1; + int countB = -1; + foreach (KeyValuePair> 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 members, + ICollection 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; + } +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index b5d7d7c..fa940bc 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -1057,7 +1057,7 @@ 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"), @@ -1065,13 +1065,25 @@ internal static class HarnessScenarios 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 + 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("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"), diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 29610db..d5e5784 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -45,7 +45,9 @@ 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; + /// Require this much extra focus weight before stealing the camera mid-fight. + private const float CombatFocusSwitchMargin = 12f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => CurrentScoreLabel; @@ -506,8 +508,9 @@ public static class InterestDirector } /// - /// 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 so subject + scale always match focus. + /// Clears fight Labels once combat goes cold. /// private static void MaintainCombatFocus(float now, bool force) { @@ -529,18 +532,188 @@ public static class InterestDirector } _lastCombatFocusAt = now; - if (!WorldActivityScanner.TryPickBestCombatFocus( - _current, out Actor best, out Actor foe, out int fighters)) + ApplyCombatEnsembleToCurrent(forceWatch: false); + } + + /// + /// Refresh active combat scene from the world ensemble. Returns false if no focus. + /// + 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) + { + 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 labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); @@ -558,13 +731,133 @@ public static class InterestDirector // keep prior position } - if (followChanged + if (forceWatch + || followChanged || labelChanged || !HasLivingCameraFocus() || MoveCamera._focus_unit != best) { 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; + } + } + + /// + /// If the active combat scene already covers this battle anchor, refresh it in place + /// instead of registering a second live_battle candidate. + /// + 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; + } + + /// Harness: apply a synthetic combat ensemble onto the current scene. + 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) @@ -574,8 +867,16 @@ 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; } diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 0b500c7..899ee6c 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -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; diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 1b642c9..7275c7c 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -52,6 +52,8 @@ public class ScoringWeights public float massBaseBonus = 28f; public float massPerExtraFighter = 4f; public float massExtraCap = 24f; + /// Fighter count at which combat ensembles use Mass scale framing. + public int massCrowdThreshold = 8; public int duelMaxFighters = 2; public float duelNotablePairBonus = 55f; public float duelSingleNotableBonus = 15f; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index df8921f..29821d8 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -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) diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index fc765eb..6a52bf2 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -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 /// /// 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. /// 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; } + /// Build a combat ensemble snapshot at a world position. + 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 seen) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 8033faa..390271e 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.52", - "description": "AFK Idle Spectator (I) + Lore (L). Dossier nametag tracks focus on handoff.", + "version": "0.25.54", + "description": "AFK Idle Spectator (I) + Lore (L). Tiered battle reasons + steadier combat focus.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index bef2ca4..acfbf3f 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -32,6 +32,7 @@ "massBaseBonus": 28, "massPerExtraFighter": 4, "massExtraCap": 24, + "massCrowdThreshold": 8, "duelMaxFighters": 2, "duelNotablePairBonus": 55, "duelSingleNotableBonus": 15, diff --git a/docs/event-audit.md b/docs/event-audit.md index ecaba80..556134f 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -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; `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`). diff --git a/docs/event-e2e.md b/docs/event-e2e.md index 43c8322..a747bdf 100644 --- a/docs/event-e2e.md +++ b/docs/event-e2e.md @@ -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` | | 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): diff --git a/docs/event-reason.md b/docs/event-reason.md index 071dfaf..9595c7f 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -28,6 +28,34 @@ 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 → 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 [`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.