diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 9a611d9..78d3373 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1996,6 +1996,23 @@ public static class AgentHarness DoInterestForceSession(cmd); break; + case "interest_combat_session": + DoInterestCombatSession(cmd); + break; + + case "combat_maintain_focus": + { + InterestDirector.HarnessMaintainCombatFocus(); + string tip = CameraDirector.LastWatchLabel ?? ""; + string focus = FocusLabel(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: $"key={InterestDirector.CurrentKey} tip='{tip}' focus={focus} completion={InterestDirector.CurrentCandidate?.Completion}"); + break; + } + case "interest_variety_clear": InterestVariety.Clear(); _cmdOk++; @@ -3475,6 +3492,100 @@ public static class AgentHarness Emit(cmd, ok, detail: $"key={InterestDirector.CurrentKey} score={InterestDirector.CurrentScoreLabel} active={InterestDirector.CurrentIsActive}"); } + /// + /// Force a CombatActive session with follow + related so handoff/tip rewrite can be asserted. + /// asset = follow, value = related asset (optional), label overrides Fight prose. + /// + private static void DoInterestCombatSession(HarnessCommand cmd) + { + if (!WorldReady()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "world_not_ready"); + return; + } + + Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); + if (follow == null) + { + follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + if (follow == null) + { + follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f); + } + + Actor related = null; + if (!string.IsNullOrEmpty(cmd.value) && cmd.value != "auto") + { + related = ResolveUnit(cmd.value); + } + + if (related == null || related == follow) + { + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) + { + if (actor != null && actor != follow && actor.isAlive()) + { + related = actor; + break; + } + } + } + + if (follow == null || !follow.isAlive()) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no follow unit for interest_combat_session"); + return; + } + + string label = string.IsNullOrEmpty(cmd.label) + ? EventReason.Fight(follow, related) + : cmd.label.Replace("{a}", SafeName(follow)).Replace("{b}", SafeName(related)); + string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "combat_focus" : cmd.expect; + InterestCandidate c = InterestFeeds.RegisterHarness( + follow, + label, + keySuffix, + lead: InterestLeadKind.EventLed, + completion: InterestCompletionKind.CombatActive, + forceActive: true, + eventStrength: 120f, + characterSignificance: 20f, + maxWatch: 60f, + ttlSeconds: 60f, + related: related, + resumable: true); + if (c != null) + { + c.Category = "Combat"; + c.Verb = "fighting"; + if (related != null) + { + c.ParticipantCount = 2; + } + + InterestScoring.ScoreCheap(c); + } + + bool ok = InterestDirector.HarnessForceSession(c); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: $"key={InterestDirector.CurrentKey} follow={SafeName(follow)} related={SafeName(related)} tip='{CameraDirector.LastWatchLabel}'"); + } + private static void DoSetSetting(HarnessCommand cmd) { string key = cmd.expect; @@ -3581,6 +3692,41 @@ public static class AgentHarness $"tipAsset={tipAsset} unitAsset={unitAsset} tip={tip}"; break; } + case "tip_matches_focus": + { + Actor unit = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string tip = CameraDirector.LastWatchLabel ?? ""; + string name = SafeName(unit); + if (string.IsNullOrEmpty(tip)) + { + // Cleared fight tip during combat cold is OK. + pass = true; + detail = "tip_empty focus=" + name; + } + else if (string.IsNullOrEmpty(name)) + { + pass = false; + detail = "no_focus tip='" + tip + "'"; + } + else + { + pass = tip.StartsWith(name, StringComparison.OrdinalIgnoreCase) + || tip.IndexOf(name, StringComparison.OrdinalIgnoreCase) == 0; + // Also accept Battle-style where name leads. + detail = $"tip='{tip}' focus='{name}' pass={pass}"; + } + + break; + } + case "tip_not_contains": + { + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + string tip = CameraDirector.LastWatchLabel ?? ""; + pass = string.IsNullOrEmpty(needle) + || tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0; + detail = $"tip='{tip}' not_contains='{needle}'"; + break; + } case "unit_asset": { string wantAsset = string.IsNullOrEmpty(cmd.asset) ? cmd.value : cmd.asset; diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 2c703dd..d2b41e6 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -338,7 +338,6 @@ public static class InterestFeeds return; } - long id = SafeId(actor); Actor foe = null; try { @@ -353,7 +352,9 @@ public static class InterestFeeds } string verb = taskOrBeat ?? "fighting"; - string key = EventCatalog.Combat.Key(id); + WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related); + long followId = SafeId(follow ?? actor); + string key = EventCatalog.Combat.Key(followId); var candidate = new InterestCandidate { Key = key, @@ -362,17 +363,17 @@ public static class InterestFeeds Source = "activity", EventStrength = EventCatalog.Combat.ActivityStrength, VisualConfidence = 0.8f, - Position = actor.current_position, - FollowUnit = actor, - RelatedUnit = foe, - SubjectId = id, - RelatedId = foe != null ? SafeId(foe) : 0, - Label = EventReason.Fight(actor, foe), - AssetId = actor.asset != null ? actor.asset.id : "", - SpeciesId = actor.asset != null ? actor.asset.id : "", + Position = follow != null ? follow.current_position : actor.current_position, + FollowUnit = follow ?? actor, + RelatedUnit = related, + SubjectId = followId, + RelatedId = related != null ? SafeId(related) : 0, + Label = EventReason.Fight(follow ?? actor, related), + AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "", + SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "", Verb = verb, - CityKey = actor.city != null ? (actor.city.name ?? "") : "", - KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "", + CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "", + KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "", CreatedAt = Time.unscaledTime, LastSeenAt = Time.unscaledTime, ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl, @@ -380,7 +381,15 @@ public static class InterestFeeds MaxWatch = EventCatalog.Combat.MaxWatch, Completion = InterestCompletionKind.CombatActive }; - candidate.ParticipantIds.Add(id); + candidate.ParticipantIds.Add(followId); + if (related != null) + { + long rid = SafeId(related); + if (rid != 0) + { + candidate.ParticipantIds.Add(rid); + } + } InterestScoring.ScoreCheap(candidate); EventFeedUtil.RegisterCandidate(candidate); } diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 66bf0f6..25c64ad 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -50,6 +50,9 @@ internal static class HarnessScenarios case "status_overlap_camera": case "status_overlap": return StatusOverlapCamera(); + case "combat_focus": + case "combat_tip_align": + return CombatFocusAlign(); case "director_action_priority": case "action_priority": return DirectorActionPriority(); @@ -1029,6 +1032,48 @@ internal static class HarnessScenarios Step("cp90", "fast_timing", value: "false"), Step("cp99", "snapshot"), Nested("cp_status_overlap", "status_overlap_camera"), + Nested("cp_combat_focus", "combat_focus"), + }; + } + + /// + /// Combat tip subject must match focus; death handoff rewrites tip; cold combat clears fight tip. + /// + private static List CombatFocusAlign() + { + return new List + { + Step("cf0", "dismiss_windows"), + Step("cf1", "wait_world"), + Step("cf2", "set_setting", expect: "enabled", value: "true"), + Step("cf3", "fast_timing", value: "true"), + Step("cf4", "spawn", asset: "human", count: 1), + Step("cf5", "spawn", asset: "wolf", count: 1), + Step("cf6", "spectator", value: "off"), + Step("cf7", "spectator", value: "on"), + Step("cf8", "pick_unit", asset: "human"), + Step("cf9", "focus", asset: "human"), + Step("cf10", "interest_end_session"), + + // 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("cf22", "interest_clear_follow", asset: "wolf"), + Step("cf23", "combat_maintain_focus"), + Step("cf24", "assert", expect: "has_focus", value: "true"), + Step("cf25", "assert", expect: "tip_matches_focus"), + Step("cf26", "assert", expect: "unit_asset", asset: "wolf"), + + // Cold combat must not keep "is fighting". + Step("cf30", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_cold"), + Step("cf31", "assert", expect: "tip_contains", value: "fighting"), + 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("cf90", "fast_timing", value: "false"), + Step("cf99", "snapshot"), }; } diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index fcbe9cb..29610db 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -44,6 +44,8 @@ public static class InterestDirector private static float _lastFeedsAt = -999f; private static float _enabledAt = -999f; private static float _inactiveSince = -999f; + private static float _lastCombatFocusAt = -999f; + private const float CombatFocusThrottleSeconds = 0.4f; private static readonly List PendingScratch = new List(96); public static string CurrentTierName => CurrentScoreLabel; @@ -357,6 +359,7 @@ public static class InterestDirector float sinceSwitch = now - _lastSwitchAt; UpdateSessionLiveness(now, onCurrent); + MaintainCombatFocus(now, force: false); InterestCandidate next = SelectNext(now); if (next != null && CanSwitchTo(next, onCurrent, sinceSwitch)) @@ -466,6 +469,18 @@ public static class InterestDirector return; } + if (_current.Completion == InterestCompletionKind.CombatActive) + { + MaintainCombatFocus(Time.unscaledTime, force: true); + if (!_current.HasFollowUnit + && (_current.RelatedUnit == null || !_current.RelatedUnit.isAlive())) + { + EndCurrent(Time.unscaledTime, reason: "follow_lost"); + } + + return; + } + if (_current.HasFollowUnit) { // Living subject still owned - reattach if vanilla cleared focus mid-scene. @@ -490,6 +505,92 @@ public static class InterestDirector EndCurrent(Time.unscaledTime, reason: "follow_lost"); } + /// + /// 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. + /// + private static void MaintainCombatFocus(float now, bool force) + { + if (_current == null || _current.Completion != InterestCompletionKind.CombatActive) + { + return; + } + + bool combatHot = InterestCompletion.IsActive(_current, _currentStartedAt, now); + if (!combatHot) + { + ClearStaleCombatLabel(now); + return; + } + + if (!force && now - _lastCombatFocusAt < CombatFocusThrottleSeconds) + { + return; + } + + _lastCombatFocusAt = now; + if (!WorldActivityScanner.TryPickBestCombatFocus( + _current, out Actor best, out Actor foe, out int fighters)) + { + return; + } + + 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); + + bool followChanged = _current.FollowUnit != best; + bool labelChanged = !string.Equals(_current.Label ?? "", label ?? "", StringComparison.Ordinal); + _current.FollowUnit = best; + _current.SubjectId = EventFeedUtil.SafeId(best); + _current.RelatedUnit = foe; + _current.RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0; + _current.Label = label; + try + { + _current.Position = best.current_position; + } + catch + { + // keep prior position + } + + if (followChanged + || labelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != best) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + } + + private static void ClearStaleCombatLabel(float now) + { + if (_current == null || string.IsNullOrEmpty(_current.Label)) + { + return; + } + + if (_current.Label.IndexOf("fighting", StringComparison.OrdinalIgnoreCase) < 0 + && _current.Label.IndexOf("fight of", StringComparison.OrdinalIgnoreCase) < 0) + { + return; + } + + _current.Label = ""; + _lastCombatFocusAt = now; + CameraDirector.Watch(_current.ToInterestEvent()); + } + + /// Harness: force one combat focus maintenance pass. + public static void HarnessMaintainCombatFocus() + { + MaintainCombatFocus(Time.unscaledTime, force: true); + } + /// /// Keep a living focus unit whenever Idle Spectator is on. /// Covers death mid-combat and quiet_grace windows where vanilla cleared follow. @@ -508,6 +609,15 @@ public static class InterestDirector if (_current != null) { + if (_current.Completion == InterestCompletionKind.CombatActive) + { + MaintainCombatFocus(now, force: true); + if (HasLivingCameraFocus()) + { + return; + } + } + if (_current.HasFollowUnit) { CameraDirector.Watch(_current.ToInterestEvent()); @@ -519,12 +629,16 @@ public static class InterestDirector if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive()) { - _current.FollowUnit = _current.RelatedUnit; - _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit); - CameraDirector.Watch(_current.ToInterestEvent()); - if (HasLivingCameraFocus()) + // Non-combat ownership handoff; combat already tried picker above. + if (_current.Completion != InterestCompletionKind.CombatActive) { - return; + _current.FollowUnit = _current.RelatedUnit; + _current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit); + CameraDirector.Watch(_current.ToInterestEvent()); + if (HasLivingCameraFocus()) + { + return; + } } } diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index 9a37321..fc765eb 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using NeoModLoader.services; using UnityEngine; @@ -123,7 +124,8 @@ public static class WorldActivityScanner CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _); fighters = Mathf.Max(1, fighters); - string label = EventReason.Fight(actor, foe); + PreferCombatFollow(actor, foe, out Actor follow, out Actor related); + string label = EventReason.Fight(follow, related); var candidate = new InterestCandidate { Key = EventCatalog.Combat.Key(id), @@ -133,17 +135,17 @@ public static class WorldActivityScanner EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor), CharacterSignificance = charScore, VisualConfidence = 0.7f, - Position = actor.current_position, - FollowUnit = actor, - RelatedUnit = foe, - SubjectId = id, - RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0, + Position = follow != null ? follow.current_position : actor.current_position, + FollowUnit = follow, + RelatedUnit = related, + SubjectId = follow != null ? EventFeedUtil.SafeId(follow) : id, + RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0, Label = label, Verb = "fighting", - AssetId = actor.asset != null ? actor.asset.id : "scored_unit", - SpeciesId = actor.asset != null ? actor.asset.id : "", - CityKey = actor.city != null ? (actor.city.name ?? "") : "", - KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "", + AssetId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : "scored_unit"), + SpeciesId = follow?.asset != null ? follow.asset.id : (actor.asset != null ? actor.asset.id : ""), + CityKey = follow?.city != null ? (follow.city.name ?? "") : (actor.city != null ? (actor.city.name ?? "") : ""), + KingdomKey = follow?.kingdom != null ? (follow.kingdom.name ?? "") : (actor.kingdom != null ? (actor.kingdom.name ?? "") : ""), ParticipantCount = fighters, NotableParticipantCount = notables, CreatedAt = Time.unscaledTime, @@ -318,6 +320,160 @@ public static class WorldActivityScanner return best; } + /// + /// Character weight for picking who to watch in a fight (notable / renown / role). + /// Matches bestFollow ranking. + /// + public static float CombatFocusWeight(Actor actor) + { + if (actor == null || !actor.isAlive()) + { + return -1f; + } + + SplitActorScore(actor, null, out _, out float charScore); + float weight = charScore; + if (InterestScoring.IsExtremelyNotable(actor)) + { + weight += 30f; + } + + return weight; + } + + /// + /// Prefer the higher-scored of two combatants as follow; the other becomes related. + /// + public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related) + { + follow = a; + related = b; + if (a == null || !a.isAlive()) + { + follow = b != null && b.isAlive() ? b : null; + related = null; + return; + } + + if (b == null || !b.isAlive()) + { + related = null; + return; + } + + if (CombatFocusWeight(b) > CombatFocusWeight(a)) + { + follow = b; + related = a; + } + } + + /// + /// Highest-scored living participant for an active combat scene. + /// Considers Follow, Related, and (for mass fights) the nearby fight cluster. + /// + public static bool TryPickBestCombatFocus( + InterestCandidate scene, + out Actor best, + out Actor foe, + out int fighters) + { + best = null; + foe = null; + fighters = 1; + if (scene == null) + { + return false; + } + + fighters = Mathf.Max(1, scene.ParticipantCount); + float bestWeight = -1f; + Actor bestLocal = null; + + void Consider(Actor unit) + { + if (unit == null || !unit.isAlive()) + { + return; + } + + float w = CombatFocusWeight(unit); + if (w > bestWeight) + { + bestWeight = w; + bestLocal = unit; + } + } + + 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; + } + + try + { + if (best.has_attack_target + && best.attack_target != null + && best.attack_target.isAlive() + && best.attack_target.isActor()) + { + foe = best.attack_target.a; + if (foe != null && !foe.isAlive()) + { + foe = null; + } + } + } + catch + { + foe = null; + } + + if (foe == null) + { + Actor related = scene.RelatedUnit; + Actor follow = scene.FollowUnit; + if (related != null && related.isAlive() && related != best) + { + foe = related; + } + else if (follow != null && follow.isAlive() && follow != best) + { + foe = follow; + } + } + + return true; + } + private static float ScoreFightCluster(int deaths, int fighters, int notables) { ScoringWeights w = InterestScoringConfig.W; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 5b5519d..a5a6f27 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.50", - "description": "AFK Idle Spectator (I) + Lore (L). Status-overlap camera hold gate.", + "version": "0.25.51", + "description": "AFK Idle Spectator (I) + Lore (L). Combat focus + tip alignment.", "GUID": "com.dazed.idlespectator" } diff --git a/docs/event-audit.md b/docs/event-audit.md index 0c5b282..5e8ca69 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -76,7 +76,11 @@ See [world-event-inventory.md](world-event-inventory.md) for live library counts `mutation_discovery` refreshes `.harness/mutation_gaps.tsv`. Open rows are unexplained Wire candidates only. -## Idle quality (next) +## Idle quality -Fire accuracy is covered by [event-live-verification-plan.md](event-live-verification-plan.md). -Idle watch quality (presentability gate, ambient fall demotion, focus hold, tip placeholder) is tracked in [camera-presentability-plan.md](camera-presentability-plan.md). +Presentability gate, ambient fall demotion, focus hold, and tip placeholder bans are done +(see [camera-presentability-plan.md](camera-presentability-plan.md)). + +**Polish next:** combat tip/focus alignment - camera follows the highest-scored living +participant and rewrites the Watching tip so the subject matches focus; clears "is fighting" +once combat goes cold.