diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index fe4380d..d6fc2ce 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -31,6 +31,7 @@ public static class AgentHarness private static long _activitySubjectId; private static bool _directorRunActive; private static string _rememberedFocusKey = ""; + private static string _rememberedTip = ""; private static string LastScreenshotPath = ""; private static readonly string[] PreferredSpawnAssets = { @@ -225,11 +226,28 @@ public static class AgentHarness break; } + case "remember_tip": + { + _rememberedTip = InterestDirector.CurrentCandidate?.Label + ?? CameraDirector.LastWatchLabel + ?? ""; + bool okTip = !string.IsNullOrEmpty(_rememberedTip); + if (okTip) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, okTip, detail: $"tip='{_rememberedTip}'"); + return; + } case "remember_focus": { _rememberedFocusKey = FocusKey(); - bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-"; - if (ok) + bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-"; if (ok) { _cmdOk++; } @@ -2071,7 +2089,8 @@ public static class AgentHarness case "combat_maintain_focus": { InterestDirector.HarnessMaintainCombatFocus(); - string tip = CameraDirector.LastWatchLabel ?? ""; + // Prefer the candidate tip (Watch may debounce); LastWatchLabel can lag a hold. + string tip = InterestDirector.CurrentCandidate?.Label ?? CameraDirector.LastWatchLabel ?? ""; string focus = FocusLabel(); _cmdOk++; Emit( @@ -3743,9 +3762,24 @@ public static class AgentHarness c.Category = "Combat"; c.Verb = "fighting"; c.ClearCombatSticky(); + c.StampCombatPair(follow, related); if (related != null) { c.ParticipantCount = 2; + // Optional: label/expect may say no_attack so ownership asserts are not race-killed. + string meta = ((cmd.label ?? "") + " " + (cmd.expect ?? "")).ToLowerInvariant(); + bool wireAttack = meta.IndexOf("no_attack", StringComparison.Ordinal) < 0; + if (wireAttack) + { + TrySetAttackTarget(follow, related); + TrySetAttackTarget(related, follow); + } + else + { + // Keep the pair alive through ownership asserts (fast-timing worlds are lethal). + StatusGameApi.TryAddStatus(follow, "invincible", 120f); + StatusGameApi.TryAddStatus(related, "invincible", 120f); + } } InterestScoring.ScoreCheap(c); @@ -5559,6 +5593,16 @@ public static class AgentHarness detail = $"remembered={_rememberedFocusKey} now={nowKey}"; break; } + case "tip_same": + case "tip_unchanged": + { + string nowTip = InterestDirector.CurrentCandidate?.Label + ?? CameraDirector.LastWatchLabel + ?? ""; + pass = !string.IsNullOrEmpty(_rememberedTip) && nowTip == _rememberedTip; + detail = $"remembered='{_rememberedTip}' now='{nowTip}'"; + break; + } case "focus_arrows": case "relationship_arrows": { @@ -8744,6 +8788,7 @@ public static class AgentHarness _lastSpawned = null; _lastSpawnedAssetId = ""; _rememberedFocusKey = ""; + _rememberedTip = ""; InterestDirector.SetHarnessFastTiming(false); SpeciesDiscovery.SetHarnessFastTiming(false); try diff --git a/IdleSpectator/Events/EventCatalog.cs b/IdleSpectator/Events/EventCatalog.cs index 51c3cab..41bb6c4 100644 --- a/IdleSpectator/Events/EventCatalog.cs +++ b/IdleSpectator/Events/EventCatalog.cs @@ -85,5 +85,37 @@ public static partial class EventCatalog public const float ScannerTtl = 18f; public static string Key(long subjectId) => "combat:" + subjectId; + + /// + /// Unordered pair key so A-vs-B and B-vs-A collapse to one durable combat scene. + /// + public static string PairKey(long a, long b) + { + if (a == 0 && b == 0) + { + return "combat:0"; + } + + if (a == 0) + { + return Key(b); + } + + if (b == 0 || a == b) + { + return Key(a); + } + + long lo = a < b ? a : b; + long hi = a < b ? b : a; + return "combat:pair:" + lo + ":" + hi; + } + + public static string KeyFor(Actor follow, Actor related) + { + long a = EventFeedUtil.SafeId(follow); + long b = EventFeedUtil.SafeId(related); + return b != 0 ? PairKey(a, b) : Key(a); + } } } diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 47ba1d6..22a86f3 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -357,15 +357,26 @@ public static class InterestFeeds LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble); if (ensemble != null && ensemble.HasFocus) { - follow = ensemble.Focus; - related = ensemble.Related ?? related; + // Multi camps may reframe focus; NamedPair keeps the first claimant. + if (ensemble.ParticipantCount > 2 + || LiveEnsemble.HasOpposingCollectiveSides(ensemble) + || ensemble.Scale > EnsembleScale.Pair) + { + follow = ensemble.Focus; + related = ensemble.Related ?? related; + } + else + { + related = related ?? ensemble.Related; + } } - long followId = SafeId(follow ?? actor); - string key = EventCatalog.Combat.Key(followId); + Actor followActor = follow ?? actor; + long followId = SafeId(followActor); + string key = EventCatalog.Combat.KeyFor(followActor, related); string label = ensemble != null ? EventReason.Combat(ensemble) - : EventReason.Fight(follow ?? actor, related); + : EventReason.Fight(followActor, related); var candidate = new InterestCandidate { Key = key, @@ -374,17 +385,17 @@ public static class InterestFeeds Source = "activity", EventStrength = EventCatalog.Combat.ActivityStrength, VisualConfidence = 0.8f, - Position = follow != null ? follow.current_position : actor.current_position, - FollowUnit = follow ?? actor, + Position = followActor != null ? followActor.current_position : actor.current_position, + FollowUnit = followActor, RelatedUnit = related, SubjectId = followId, RelatedId = related != null ? SafeId(related) : 0, Label = label, - AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "", - SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "", + AssetId = followActor.asset != null ? followActor.asset.id : "", + SpeciesId = followActor.asset != null ? followActor.asset.id : "", Verb = verb, - CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "", - KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "", + CityKey = followActor.city != null ? (followActor.city.name ?? "") : "", + KingdomKey = followActor.kingdom != null ? (followActor.kingdom.name ?? "") : "", ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1), NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0, CreatedAt = Time.unscaledTime, @@ -394,6 +405,7 @@ public static class InterestFeeds MaxWatch = EventCatalog.Combat.MaxWatch, Completion = InterestCompletionKind.CombatActive }; + candidate.StampCombatPair(followActor, related); if (ensemble != null) { WorldActivityScanner.StampParticipantIds(candidate, ensemble); diff --git a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs index a5161cf..bd4463d 100644 --- a/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs +++ b/IdleSpectator/Events/Feeds/StatusOutbreakFeed.cs @@ -1,18 +1,19 @@ using System; +using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// /// Upgrades / registers sticky status outbreaks when multiple nearby units share a -/// camera-worthy affliction / transformation status. Stickies require a real group (2+). -/// Relationship / emotion / ambient statuses stay single-unit tips even in crowds. +/// lasting affliction / disease / spectacle status. Stickies require a real group (2+). +/// Brief combat FX (stun, silence, shield, …) and social/emotion statuses stay unit tips. /// public static class StatusOutbreakFeed { /// - /// Affliction / spectacle statuses sampled by for cluster outbreaks. - /// Social statuses (fell_in_love, sleeping, …) must also pass . + /// Authored affliction cluster inventory sampled by . + /// Live ids may also pass via tokens. /// private static readonly string[] HotStatusIds = { @@ -71,9 +72,36 @@ public static class StatusOutbreakFeed return; } - // Sample a few camera-worthy affliction statuses from living units. + // Sample live affliction statuses from living units (HotStatusIds + discovered ids). int registered = 0; - var seenStatus = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + var seenStatus = new HashSet(StringComparer.OrdinalIgnoreCase); + var sampleIds = new List(HotStatusIds.Length + 16); + sampleIds.AddRange(HotStatusIds); + List liveIds = ActivityAssetCatalog.EnumerateLiveStatusIds(); + for (int i = 0; i < liveIds.Count; i++) + { + string id = liveIds[i]; + if (string.IsNullOrEmpty(id)) + { + continue; + } + + bool already = false; + for (int j = 0; j < sampleIds.Count; j++) + { + if (sampleIds[j].Equals(id, StringComparison.OrdinalIgnoreCase)) + { + already = true; + break; + } + } + + if (!already && IsOutbreakWorthy(id)) + { + sampleIds.Add(id); + } + } + foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic()) { if (actor == null || !actor.isAlive() || registered >= 6) @@ -81,8 +109,9 @@ public static class StatusOutbreakFeed break; } - foreach (string statusId in HotStatusIds) + for (int s = 0; s < sampleIds.Count; s++) { + string statusId = sampleIds[s]; if (!LiveEnsemble.HasActorStatus(actor, statusId) || !seenStatus.Add(statusId)) { continue; @@ -108,9 +137,10 @@ public static class StatusOutbreakFeed } /// - /// Outbreak tips are for affliction / transformation clusters (2+ nearby). - /// Never "Outbreak - Fell in love" / sleeping / pregnant / emotion crowds. - /// Solo drowning / curse / etc. stay unit StatusPhase tips until a group forms. + /// Outbreak tips are lasting affliction / disease / spectacle clusters (2+ nearby). + /// Never love/emotion/ambient crowds, and never brief combat interrupt FX + /// (stun, confuse, silence, shield, dodge, recovery_*, …) even if StatusTransformation. + /// Solo carriers stay unit StatusPhase tips until a group forms. /// public static bool IsOutbreakWorthy(string statusId, StatusInterestEntry entry = null) { @@ -134,9 +164,11 @@ public static class StatusOutbreakFeed return false; } - if (cat.Equals("StatusTransformation", StringComparison.OrdinalIgnoreCase)) + // Brief combat / spell FX - unit StatusPhase only, never sticky Outbreak. + // Token deny + live StatusAsset duration (short timers are interrupt FX). + if (IsBriefCombatOrSpellFx(id)) { - return true; + return false; } for (int i = 0; i < HotStatusIds.Length; i++) @@ -147,9 +179,117 @@ public static class StatusOutbreakFeed } } + // Live-discovered disease / affliction ids not yet authored in HotStatusIds. + return LooksLikeAfflictionCluster(id); + } + + /// + /// Combat interrupt / recovery / shield FX that fire in packs during battles + /// but are not lasting affliction stories. Uses id tokens plus live asset duration. + /// + public static bool IsBriefCombatOrSpellFx(string statusId) + { + if (string.IsNullOrEmpty(statusId)) + { + return false; + } + + string s = statusId.Trim().ToLowerInvariant(); + // Lasting afflictions keep outbreak eligibility even when short-lived in water/combat. + if (LooksLikeAfflictionClusterTokensOnly(s)) + { + return false; + } + + if (s.StartsWith("recovery_", StringComparison.Ordinal) + || s.StartsWith("spell_", StringComparison.Ordinal)) + { + return true; + } + + if (s == "stunned" + || s == "confused" + || s == "surprised" + || s == "shield" + || s == "slowness" + || s == "dash" + || s == "dodge" + || s == "flicked" + || s == "cough" + || s == "afterglow" + || s == "on_guard" + || s == "powerup" + || s == "invincible" + || s == "magnetized" + || s.Contains("stun") + || s.Contains("confus") + || s.Contains("silence") + || s.Contains("shield") + || s.Contains("dodge") + || s.Contains("recovery") + || s.Contains("cooldown") + || s.Contains("windup") + || s.Contains("cast_")) + { + return true; + } + + // Live library: very short statuses are combat FX, not outbreak stories. + try + { + StatusAsset asset = ActivityAssetCatalog.TryGetStatusAsset(s); + if (asset != null && asset.duration > 0f && asset.duration <= 6f) + { + return true; + } + } + catch + { + // Asset library unavailable during early boot. + } + return false; } + /// Token heuristic for live plague/disease/curse-like statuses. + public static bool LooksLikeAfflictionCluster(string statusId) + { + if (string.IsNullOrEmpty(statusId)) + { + return false; + } + + string s = statusId.Trim().ToLowerInvariant(); + if (!LooksLikeAfflictionClusterTokensOnly(s)) + { + return false; + } + + // Affliction tokens win; still reject if the id is also an explicit interrupt FX name. + return !(s.Contains("stun") || s.Contains("shield") || s.Contains("dodge") || s.Contains("recovery")); + } + + private static bool LooksLikeAfflictionClusterTokensOnly(string s) + { + if (string.IsNullOrEmpty(s)) + { + return false; + } + + return s.Contains("plague") + || s.Contains("infect") + || s.Contains("poison") + || s.Contains("curse") + || s.Contains("burn") + || s.Contains("drown") + || s.Contains("possess") + || s.Contains("frozen") + || s.Contains("fever") + || s.Contains("sick") + || s == "rage" + || s.Contains("ash_fever"); + } + private static bool TryRegisterOutbreak(Actor seed, string statusId, StatusInterestEntry entry) { Vector2 pos; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index cc75799..0360f8e 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -1077,8 +1077,16 @@ internal static class HarnessScenarios 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"), + // expect suffix no_attack: ownership assert must not race a fast-timing kill mid-wait. + Step("cf20", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_fight_no_attack"), Step("cf21", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), + // Pair ownership: maintain must not flip Duel - A vs B ↔ B vs A. + Step("cf21a", "remember_tip"), + Step("cf21b", "combat_maintain_focus"), + Step("cf21d", "combat_maintain_focus"), + Step("cf21e", "assert", expect: "tip_same"), + Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), + Step("cf21w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"), Step("cf22", "interest_clear_follow", asset: "wolf"), Step("cf23", "combat_maintain_focus"), Step("cf24", "assert", expect: "has_focus", value: "true"), @@ -1087,11 +1095,16 @@ internal static class HarnessScenarios Step("cf26", "assert", expect: "unit_asset", asset: "wolf"), // 1v1 → multi: sided species framing + live sticky counts. + // Re-seed human/wolf so a polluted world cannot escalate an angle camp. + Step("cf39", "interest_end_session"), + Step("cf39b", "spawn", asset: "human", count: 1), + Step("cf39c", "spawn", asset: "wolf", count: 1), + Step("cf39d", "pick_unit", asset: "human"), Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"), Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "), Step("cf42", "combat_ensemble_escalate", value: "6"), - Step("cf42b", "combat_wire_attack_sides", asset: "human", value: "wolf"), Step("cf43", "assert", expect: "participant_count_min", value: "6"), + Step("cf42b", "combat_wire_attack_sides", asset: "human", value: "wolf"), 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"), @@ -1459,6 +1472,13 @@ internal static class HarnessScenarios Step("son25", "assert", expect: "outbreak_worthy", value: "fell_in_love", label: "false"), Step("son26", "assert", expect: "outbreak_worthy", value: "drowning", label: "true"), Step("son27", "assert", expect: "outbreak_worthy", value: "cursed", label: "true"), + // Brief combat FX never become Outbreak - even when many units share them. + Step("son28", "assert", expect: "outbreak_worthy", value: "stunned", label: "false"), + Step("son29", "assert", expect: "outbreak_worthy", value: "confused", label: "false"), + Step("son30", "assert", expect: "outbreak_worthy", value: "shield", label: "false"), + Step("son31", "assert", expect: "outbreak_worthy", value: "recovery_combat_action", label: "false"), + Step("son32", "assert", expect: "outbreak_worthy", value: "poisoned", label: "true"), + Step("son33", "assert", expect: "outbreak_worthy", value: "plague", label: "true"), Step("son90", "fast_timing", value: "false"), Step("son99", "snapshot"), diff --git a/IdleSpectator/InterestCandidate.cs b/IdleSpectator/InterestCandidate.cs index a4a49d8..27f3542 100644 --- a/IdleSpectator/InterestCandidate.cs +++ b/IdleSpectator/InterestCandidate.cs @@ -62,6 +62,12 @@ public sealed class InterestCandidate public Actor RelatedUnit; public long SubjectId; public long RelatedId; + /// + /// Durable 1v1 ownership: first Follow stays camera owner while both pair ids live. + /// Prevents Duel A↔B thrash across maintain ticks and registry upserts. + /// + public long PairOwnerId; + public long PairPartnerId; public string Label = ""; public string AssetId = ""; public string SpeciesId = ""; @@ -157,6 +163,29 @@ public sealed class InterestCandidate public void ClearCombatSticky() => Sticky.Clear(); + /// Lock durable 1v1 owner/partner ids (no-op when owner missing). + public void StampCombatPair(Actor owner, Actor partner) + { + long oid = EventFeedUtil.SafeId(owner); + if (oid == 0) + { + return; + } + + if (PairOwnerId == 0) + { + PairOwnerId = oid; + } + + long pid = EventFeedUtil.SafeId(partner); + if (pid != 0 && pid != PairOwnerId) + { + PairPartnerId = pid; + } + } + + public bool HasCombatPairLock => PairOwnerId != 0; + public InterestEvent ToInterestEvent() { return new InterestEvent @@ -194,6 +223,8 @@ public sealed class InterestCandidate RelatedUnit = RelatedUnit, SubjectId = SubjectId, RelatedId = RelatedId, + PairOwnerId = PairOwnerId, + PairPartnerId = PairPartnerId, Label = Label, AssetId = AssetId, SpeciesId = SpeciesId, diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 2960aa2..fbaf4e4 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -277,7 +277,13 @@ public static class InterestDirector return false; } + // Transfer durable pair ownership to the handoff unit so maintain does not restore the old Follow. + long newOwner = EventFeedUtil.SafeId(_current.RelatedUnit); + long oldOwner = _current.PairOwnerId; + _current.PairOwnerId = newOwner; + _current.PairPartnerId = oldOwner != 0 && oldOwner != newOwner ? oldOwner : 0; _current.FollowUnit = null; + _current.SubjectId = 0; return true; } @@ -667,18 +673,93 @@ public static class InterestDirector return false; } + // Snapshot durable pair ownership before cluster rebuild can rewrite Follow+Related. + ResolveCombatPairActors(_current, out Actor ownedFocus, out Actor ownedRelated); + if (ownedFocus != null) + { + _current.StampCombatPair(ownedFocus, ownedRelated); + } + + bool pairOwned = IsNamedPairCombatOwnership(_current) + || (_current.HasCombatPairLock + && ownedFocus != null + && ownedFocus.isAlive() + && ownedRelated != null + && ownedRelated.isAlive() + && !HasStickyCollectiveMulti(_current)); + + // Fast path: living NamedPair never runs sticky Refresh (ambient camps were flipping Duels). + // Skip when Follow was cleared (death / harness handoff) so Related can take ownership. + if (pairOwned + && _current.HasFollowUnit + && ownedFocus != null + && ownedFocus.isAlive() + && ownedRelated != null + && ownedRelated.isAlive() + && !ShouldEscalateNamedPair(_current, null, 2)) + { + string pairLabel = ""; + Actor holdFocus = ownedFocus; + Actor holdFoe = ownedRelated; + int holdFighters = 2; + ApplyNamedPairHold( + _current, ownedFocus, ownedRelated, ref holdFocus, ref holdFoe, ref holdFighters, ref pairLabel); + string priorLabel = _current.Label ?? ""; + if (string.IsNullOrEmpty(pairLabel)) + { + pairLabel = priorLabel; + } + + bool holdFollowChanged = _current.FollowUnit != holdFocus; + bool holdLabelChanged = !string.Equals(priorLabel, pairLabel ?? "", StringComparison.Ordinal); + _current.FollowUnit = holdFocus; + _current.SubjectId = EventFeedUtil.SafeId(holdFocus); + _current.RelatedUnit = holdFoe; + _current.RelatedId = holdFoe != null ? EventFeedUtil.SafeId(holdFoe) : 0; + _current.Label = pairLabel; + try + { + _current.Position = holdFocus.current_position; + } + catch + { + // keep + } + + bool holdNeedWatch = forceWatch + || holdFollowChanged + || holdLabelChanged + || !HasLivingCameraFocus() + || MoveCamera._focus_unit != holdFocus; + if (holdNeedWatch) + { + CameraDirector.Watch(_current.ToInterestEvent()); + } + + return true; + } + Vector3 pos = _current.Position; - if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) + if (ownedFocus != null && ownedFocus.isAlive()) + { + pos = ownedFocus.current_position; + } + else if (_current.FollowUnit != null && _current.FollowUnit.isAlive()) { pos = _current.FollowUnit.current_position; } + else if (ownedRelated != null && ownedRelated.isAlive()) + { + pos = ownedRelated.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; + || _current.ParticipantCount > 2 + || HasStickyCombatSides(_current); float radius = massHint ? 14f : 10f; // Death / clear-follow handoff: promote Related before cluster re-pick can steal focus. @@ -738,7 +819,34 @@ public static class InterestDirector } else { - handoffLabel = EventReason.Fight(handoff, handoffFoe); + // NamedPair handoff: keep Duel framing with the other pair id when possible. + if (handoffFoe == null || !handoffFoe.isAlive()) + { + long handoffId = EventFeedUtil.SafeId(handoff); + long otherId = _current.PairOwnerId != 0 && _current.PairOwnerId != handoffId + ? _current.PairOwnerId + : _current.PairPartnerId; + if (otherId != 0 && otherId != handoffId) + { + handoffFoe = LiveEnsemble.FindTrackedActor(otherId); + } + } + + var pairHandoff = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = EnsembleScale.Pair, + Frame = EnsembleFrame.NamedPair, + ParticipantCount = 2, + Focus = handoff, + Related = handoffFoe + }; + handoffLabel = EventReason.Combat(pairHandoff); + if (string.IsNullOrEmpty(handoffLabel)) + { + handoffLabel = EventReason.Fight(handoff, handoffFoe); + } + _current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount); } @@ -748,6 +856,7 @@ public static class InterestDirector _current.SubjectId = EventFeedUtil.SafeId(handoff); _current.RelatedUnit = handoffFoe; _current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0; + _current.StampCombatPair(handoff, handoffFoe); _current.Label = handoffLabel; try { @@ -854,40 +963,78 @@ public static class InterestDirector return false; } - // Never stick on a sleeper / bystander unless they are on the sticky combat roster. - Actor curFollow = _current.FollowUnit; - bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); - bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow); - if (!LiveEnsemble.IsCombatParticipant(best)) - { - if (curFighting || curRostered) - { - best = curFollow; - foe = ResolveAttackFoe(best) ?? foe; - } - } - else if ((curFighting || curRostered) - && best != curFollow - && curFollow != null - && curFollow.isAlive()) - { - float curW = LiveEnsemble.FocusWeight( - curFollow, _current.ParticipantIds, newcomerBonus: 0f); - if (curRostered && !curFighting) - { - curW += 20f; - } + // Prefer the ownership snapshot over any Follow/Related rewrite during Refresh. + Actor curFollow = ownedFocus != null && ownedFocus.isAlive() + ? ownedFocus + : _current.FollowUnit; + Actor curRelated = ownedRelated != null && ownedRelated.isAlive() + ? ownedRelated + : _current.RelatedUnit; - float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f); - if (newW < curW + CombatFocusSwitchMargin) + // Named-pair lock: ambient nearby camps must not steal / flip a living Duel. + // Only a true multi escalate (or intentional sticky Battle) may leave the pair. + if (pairOwned + && curFollow != null + && curFollow.isAlive() + && !ShouldEscalateNamedPair(_current, ensemble, fighters)) + { + ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); + } + else + { + // Never stick on a sleeper / bystander unless they are on the sticky combat roster. + bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow); + bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow); + if (!LiveEnsemble.IsCombatParticipant(best)) { - best = curFollow; - if (foe == null || foe == best) + if (curFighting || curRostered) { + best = curFollow; foe = ResolveAttackFoe(best) ?? foe; } } - } + else if ((curFighting || curRostered) + && best != curFollow + && curFollow != null + && curFollow.isAlive() + && !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) + { + float curW = LiveEnsemble.FocusWeight( + curFollow, _current.ParticipantIds, newcomerBonus: 0f); + if (curRostered && !curFighting) + { + curW += 20f; + } + + float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f); + if (newW < curW + CombatFocusSwitchMargin) + { + best = curFollow; + if (foe == null || foe == best) + { + foe = ResolveAttackFoe(best) ?? foe; + } + } + } + + // Preserve duel partner when rebuild briefly loses Related (attack_target gaps). + bool duelLabeled = !string.IsNullOrEmpty(_current.Label) + && _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + if (foe == null + && curRelated != null + && curRelated.isAlive() + && curRelated != best + && (duelLabeled || _current.ForceActive || fighters <= 2)) + { + foe = curRelated; + } + + // Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks. + if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated)) + { + ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label); + } + } // Never replace a sticky scoreboard tip with thin Fight prose. if (HasStickyCombatSides(_current) @@ -928,6 +1075,18 @@ public static class InterestDirector label = previousLabel; } + // Never drop the duel partner while the tip is still a Duel - empty Related + // makes the next maintain flip ownership to a thin "X is fighting" tip. + if (foe == null + && curRelated != null + && curRelated.isAlive() + && curRelated != best + && !string.IsNullOrEmpty(label) + && label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) + { + foe = curRelated; + } + bool followChanged = _current.FollowUnit != best; bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal); _current.FollowUnit = best; @@ -1459,6 +1618,320 @@ public static class InterestDirector return StickyScoreboard.HasOpposingSides(scene); } + private static void ResolveCombatPairActors( + InterestCandidate scene, + out Actor owner, + out Actor partner) + { + owner = null; + partner = null; + if (scene == null) + { + return; + } + + if (scene.PairOwnerId != 0) + { + owner = LiveEnsemble.FindTrackedActor(scene.PairOwnerId); + } + + if (owner == null || !owner.isAlive()) + { + owner = scene.FollowUnit != null && scene.FollowUnit.isAlive() + ? scene.FollowUnit + : null; + } + + if (scene.PairPartnerId != 0) + { + partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId); + } + + if (partner == null || !partner.isAlive()) + { + partner = scene.RelatedUnit != null && scene.RelatedUnit.isAlive() + ? scene.RelatedUnit + : null; + } + + if (partner != null && owner != null && partner == owner) + { + partner = null; + } + } + + /// + /// True when the scene already owns a collective multi battle (post-escalate), + /// not a NamedPair duel that ambient units merely wandered near. + /// + private static bool HasStickyCollectiveMulti(InterestCandidate scene) + { + if (scene == null || !HasStickyCombatSides(scene)) + { + return false; + } + + if (scene.CombatSideFrame != EnsembleFrame.SpeciesVsSpecies + && scene.CombatSideFrame != EnsembleFrame.KingdomVsKingdom) + { + return false; + } + + return scene.CombatPeakParticipants >= 3 + || string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Living 1v1 ownership: Duel tips and forced pair sessions stay NamedPair until a + /// real multi escalate (sticky collective camps), not ambient nearby melees. + /// + private static bool IsNamedPairCombatOwnership(InterestCandidate scene) + { + if (scene == null || scene.Completion != InterestCompletionKind.CombatActive) + { + return false; + } + + ResolveCombatPairActors(scene, out Actor owner, out Actor partner); + bool pairIdsLive = owner != null && owner.isAlive() + && partner != null && partner.isAlive(); + + if (HasStickyCollectiveMulti(scene)) + { + string label = scene.Label ?? ""; + // Explicit Duel / durable pair lock wins over ambient sticky pollution. + if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase) + || (scene.HasCombatPairLock && pairIdsLive)) + { + return true; + } + + return false; + } + + if (scene.HasCombatPairLock && pairIdsLive) + { + return true; + } + + string tip = scene.Label ?? ""; + bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + bool thinFight = tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0; + bool hasPartner = partner != null || (scene.RelatedUnit != null && scene.RelatedUnit.isAlive()); + if (duelLabeled) + { + return true; + } + + if (scene.ForceActive && hasPartner) + { + return true; + } + + if (hasPartner + && owner != null + && owner.isAlive() + && (thinFight || scene.ParticipantCount <= 2 || scene.CombatPeakParticipants <= 2)) + { + return true; + } + + return false; + } + + /// + /// Allow leaving NamedPair only after an intentional multi frame exists + /// (harness escalate / real sticky Battle), never from ambient radius noise. + /// + private static bool ShouldEscalateNamedPair( + InterestCandidate scene, + LiveEnsemble ensemble, + int fighters) + { + if (scene == null) + { + return false; + } + + string tip = scene.Label ?? ""; + if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) + { + // Duel tips never escalate from a scan rebuild; escalate harness rewrites the tip first. + return false; + } + + if (HasStickyCollectiveMulti(scene)) + { + return true; + } + + if (fighters < 3 || ensemble == null || !LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + return false; + } + + // Natural growth: both pair members still fighting inside a sided multi cluster. + Actor focus = scene.FollowUnit; + Actor related = scene.RelatedUnit; + if (focus == null || !focus.isAlive() || related == null || !related.isAlive()) + { + return false; + } + + return LiveEnsemble.IsCombatParticipant(focus) + && LiveEnsemble.IsCombatParticipant(related) + && !scene.ForceActive; + } + + private static void ApplyNamedPairHold( + InterestCandidate scene, + Actor ownedFocus, + Actor ownedRelated, + ref Actor best, + ref Actor foe, + ref int fighters, + ref string label) + { + if (scene == null || ownedFocus == null || !ownedFocus.isAlive()) + { + return; + } + + // Drop ambient species/kingdom sticky that leaked in during cluster Refresh. + if (HasStickyCombatSides(scene) + && (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies + || scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom)) + { + scene.ClearCombatSticky(); + } + + best = ownedFocus; + foe = ResolvePairPartner(ownedFocus, ownedRelated, foe); + fighters = Mathf.Max(2, Math.Min(fighters, 2)); + scene.ParticipantCount = Mathf.Max(2, scene.ParticipantCount); + if (string.Equals(scene.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase) + && scene.CombatPeakParticipants <= 2) + { + scene.AssetId = "live_combat"; + } + + var pairHold = new LiveEnsemble + { + Kind = EnsembleKind.Combat, + Scale = EnsembleScale.Pair, + Frame = EnsembleFrame.NamedPair, + ParticipantCount = 2, + Focus = best, + Related = foe + }; + label = EventReason.Combat(pairHold); + if (string.IsNullOrEmpty(label) && !string.IsNullOrEmpty(scene.Label)) + { + label = scene.Label; + } + } + + private static Actor ResolvePairPartner(Actor focus, Actor ownedRelated, Actor rebuiltFoe) + { + if (ownedRelated != null && ownedRelated.isAlive() && ownedRelated != focus) + { + return ownedRelated; + } + + Actor attack = ResolveAttackFoe(focus); + if (attack != null && attack != focus) + { + return attack; + } + + if (rebuiltFoe != null && rebuiltFoe.isAlive() && rebuiltFoe != focus) + { + return rebuiltFoe; + } + + return null; + } + + /// + /// Hold Duel ownership whenever Follow+Related still form the live pair. + /// Ambient sticky camps do not unlock a living NamedPair. + /// + private static bool ShouldHoldDuelOwnership( + InterestCandidate scene, + Actor best, + Actor foe, + int fighters, + Actor ownedFocus = null, + Actor ownedRelated = null) + { + if (scene == null) + { + return false; + } + + Actor curFocus = ownedFocus != null && ownedFocus.isAlive() + ? ownedFocus + : scene.FollowUnit; + Actor curRelated = ownedRelated != null && ownedRelated.isAlive() + ? ownedRelated + : scene.RelatedUnit; + if (curFocus == null || !curFocus.isAlive() + || curRelated == null || !curRelated.isAlive()) + { + return false; + } + + // Already-escalated collective multi may leave the pair (not ambient Duel pollution). + if (HasStickyCollectiveMulti(scene)) + { + string tip = scene.Label ?? ""; + if (!tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + bool duelLabeled = !string.IsNullOrEmpty(scene.Label) + && scene.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase); + bool smallCluster = fighters <= 2; + if (!duelLabeled && !smallCluster && !scene.ForceActive) + { + return false; + } + + // Rebuilt focus/foe is the same two actors (any order), or only one of them + // remains visible in the cluster (foe briefly missing). + return IsSameCombatPair(curFocus, curRelated, best, foe); + } + + /// + /// True when / are the same two actors as the + /// current pair (possibly swapped). Used to lock Duel tip ownership. + /// + private static bool IsSameCombatPair(Actor curFocus, Actor curRelated, Actor best, Actor foe) + { + if (curFocus == null || !curFocus.isAlive() || best == null || !best.isAlive()) + { + return false; + } + + if (best == curFocus) + { + return foe == null + || foe == curRelated + || foe == curFocus + || (curRelated == null && ResolveAttackFoe(curFocus) == foe); + } + + if (best == curRelated) + { + return foe == null || foe == curFocus || foe == curRelated; + } + + Actor liveFoe = ResolveAttackFoe(curFocus) ?? curRelated; + return liveFoe != null && (best == liveFoe) && (foe == null || foe == curFocus); + } + private static Actor ResolveAttackFoe(Actor actor) { if (actor == null) @@ -1637,12 +2110,44 @@ public static class InterestDirector return false; } + int authoredN = Mathf.Max( + ensemble.ParticipantCount, + (ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0)); ApplyEnsembleFields(_current, ensemble); + StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble); StickyScoreboard.Refresh( _current, ensemble, ensemble.Focus.current_position, 14f); + // Harness escalate authors side counts before units fully enter combat scans. + if (authoredN > _current.ParticipantCount) + { + _current.ParticipantCount = authoredN; + } + + if (ensemble.SideA != null && ensemble.SideA.Count > _current.CombatSideACount) + { + _current.CombatSideACount = ensemble.SideA.Count; + } + + if (ensemble.SideB != null && ensemble.SideB.Count > _current.CombatSideBCount) + { + _current.CombatSideBCount = ensemble.SideB.Count; + } + + if (authoredN > _current.CombatPeakParticipants) + { + _current.CombatPeakParticipants = authoredN; + } + + // Leaving NamedPair: clear durable pair lock so maintain may keep collective framing. + if (authoredN >= 3 || LiveEnsemble.HasOpposingCollectiveSides(ensemble)) + { + _current.PairOwnerId = 0; + _current.PairPartnerId = 0; + } + _current.FollowUnit = ensemble.Focus; _current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus); _current.RelatedUnit = ensemble.Related; @@ -2534,6 +3039,13 @@ public static class InterestDirector return false; } + // Same FixedDwell beat already watched on this subject - never cut back in. + if (InterestVariety.IsBeatCooled(candidate)) + { + InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key); + return false; + } + if (_current == null) { return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false); diff --git a/IdleSpectator/InterestRegistry.cs b/IdleSpectator/InterestRegistry.cs index 899ee6c..79ef5ea 100644 --- a/IdleSpectator/InterestRegistry.cs +++ b/IdleSpectator/InterestRegistry.cs @@ -85,9 +85,20 @@ public static class InterestRegistry if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null) { // Ownership merge: same key must not adopt a different subject's FollowUnit/Label. + // Combat pair locks also reject A↔B flips under combat:pair:lo:hi keys. + bool pairLocked = existing.HasCombatPairLock + && existing.Completion == InterestCompletionKind.CombatActive; bool sameSubject = incoming.SubjectId == 0 || existing.SubjectId == 0 || incoming.SubjectId == existing.SubjectId; + if (pairLocked + && incoming.SubjectId != 0 + && incoming.SubjectId != existing.PairOwnerId + && existing.PairOwnerId != 0) + { + sameSubject = false; + } + if (!sameSubject) { // Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly. @@ -95,6 +106,19 @@ public static class InterestRegistry existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt); existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength); existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore); + if (existing.PairPartnerId == 0 && incoming.PairPartnerId != 0) + { + existing.PairPartnerId = incoming.PairPartnerId; + } + + if (incoming.RelatedUnit != null + && incoming.RelatedUnit.isAlive() + && EventFeedUtil.SafeId(incoming.RelatedUnit) == existing.PairPartnerId) + { + existing.RelatedUnit = incoming.RelatedUnit; + existing.RelatedId = incoming.RelatedId; + } + existing.Selected = false; return existing; } @@ -107,18 +131,49 @@ public static class InterestRegistry existing.VisualConfidence = Mathf.Max(existing.VisualConfidence, incoming.VisualConfidence); existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore); + if (existing.PairOwnerId == 0 && incoming.PairOwnerId != 0) + { + existing.PairOwnerId = incoming.PairOwnerId; + } + + if (existing.PairPartnerId == 0 && incoming.PairPartnerId != 0) + { + existing.PairPartnerId = incoming.PairPartnerId; + } + if (!string.IsNullOrEmpty(incoming.Label)) { - existing.Label = incoming.Label; + // Pair-locked Duels keep their owned tip wording across upserts. + bool keepPairLabel = pairLocked + && !string.IsNullOrEmpty(existing.Label) + && existing.Label.StartsWith("Duel", System.StringComparison.OrdinalIgnoreCase); + if (!keepPairLabel) + { + existing.Label = incoming.Label; + } } if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive()) { - existing.FollowUnit = incoming.FollowUnit; - existing.SubjectId = incoming.SubjectId != 0 - ? incoming.SubjectId - : EventFeedUtil.SafeId(incoming.FollowUnit); - existing.Position = incoming.FollowUnit.current_position; + long incomingId = EventFeedUtil.SafeId(incoming.FollowUnit); + if (!pairLocked + || existing.PairOwnerId == 0 + || incomingId == existing.PairOwnerId) + { + existing.FollowUnit = incoming.FollowUnit; + existing.SubjectId = incoming.SubjectId != 0 + ? incoming.SubjectId + : incomingId; + existing.Position = incoming.FollowUnit.current_position; + } + else if (existing.FollowUnit != null && existing.FollowUnit.isAlive()) + { + existing.Position = existing.FollowUnit.current_position; + } + else if (incoming.Position != Vector3.zero) + { + existing.Position = incoming.Position; + } } else if (incoming.Position != Vector3.zero) { @@ -129,6 +184,10 @@ public static class InterestRegistry { existing.RelatedUnit = incoming.RelatedUnit; existing.RelatedId = incoming.RelatedId; + if (existing.PairPartnerId == 0) + { + existing.PairPartnerId = incoming.RelatedId; + } } if (incoming.ParticipantCount > existing.ParticipantCount) diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs index 7275c7c..8ab1a4a 100644 --- a/IdleSpectator/InterestScoringConfig.cs +++ b/IdleSpectator/InterestScoringConfig.cs @@ -112,6 +112,11 @@ public class ScoringWeights public float eventLedTarget = 0.7f; public int mixWindow = 20; public float hardCooldownSeconds = 12f; + /// + /// Hard suppress window for the same FixedDwell beat (happiness effect / status / label) + /// on the same subject so life milestones do not reclaim the camera every few seconds. + /// + public float fixedDwellBeatCooldownSeconds = 45f; public int enrichTopK = 8; public float metaCacheTtl = 2f; } diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 71c2e14..6c632be 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -1,16 +1,20 @@ +using System; using System.Collections.Generic; using UnityEngine; namespace IdleSpectator; /// -/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties. +/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties +/// and hard FixedDwell beat cooldowns (same life beat / subject cannot reclaim immediately). /// public static class InterestVariety { public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget; public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow); public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds; + public static float FixedDwellBeatCooldownSeconds => + Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds); private static readonly List RecentMix = new List(32); private static readonly Dictionary CooldownUntil = new Dictionary(64); @@ -104,6 +108,43 @@ public static class InterestVariety { StampCooldown("verb:" + chosen.Verb, until * 0.4f); } + + // Moment beats: hard suppress the same story on the same subject. + if (IsMomentBeat(chosen)) + { + float beatUntil = Time.unscaledTime + FixedDwellBeatCooldownSeconds; + foreach (string beatKey in EnumerateBeatKeys(chosen)) + { + StampCooldown(beatKey, beatUntil); + } + } + } + + /// + /// True when a FixedDwell / moment beat for this candidate is still hard-cooled. + /// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked. + /// + public static bool IsBeatCooled(InterestCandidate c, float now = -1f) + { + if (c == null || !IsMomentBeat(c)) + { + return false; + } + + if (now < 0f) + { + now = Time.unscaledTime; + } + + foreach (string beatKey in EnumerateBeatKeys(c)) + { + if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until) + { + return true; + } + } + + return false; } /// @@ -133,6 +174,13 @@ public static class InterestVariety continue; } + // Same life beat on the same subject already watched - hard skip. + if (IsBeatCooled(c, now)) + { + InterestDropLog.Record("beat_cooldown", BeatDropDetail(c)); + continue; + } + if (c.LeadKind == InterestLeadKind.CharacterLed) { CharPool.Add(c); @@ -173,7 +221,8 @@ public static class InterestVariety { InterestCandidate c = pool[i]; float penalty = NoveltyPenalty(c, now); - // Epic / favorites bypass most novelty. + // Epic / favorites bypass most soft novelty - never FixedDwell beat cooldowns + // (those already hard-skipped above). if (InterestScoring.IsHotScore(c.TotalScore) || InterestScoring.IsNotable(c.FollowUnit)) { penalty *= 0.15f; @@ -219,6 +268,112 @@ public static class InterestVariety public static int MixSampleCount => RecentMix.Count; + private static bool IsMomentBeat(InterestCandidate c) + { + if (c == null) + { + return false; + } + + switch (c.Completion) + { + case InterestCompletionKind.FixedDwell: + case InterestCompletionKind.Manual: + case InterestCompletionKind.HappinessGrief: + // Discrete life / emotion beats (parenthood, grief, catalog moments). + return true; + case InterestCompletionKind.StatusPhase: + // Unit status tips cool by status id; sticky outbreaks are excluded below. + return string.IsNullOrEmpty(c.AssetId) + || !c.AssetId.Equals("live_outbreak", System.StringComparison.OrdinalIgnoreCase); + default: + return false; + } + } + + private static IEnumerable EnumerateBeatKeys(InterestCandidate c) + { + if (c == null) + { + yield break; + } + + long subject = c.SubjectId; + if (!string.IsNullOrEmpty(c.HappinessEffectId)) + { + yield return "beat:hap:" + c.HappinessEffectId + ":" + subject; + } + + if (!string.IsNullOrEmpty(c.StatusId)) + { + yield return "beat:status:" + c.StatusId + ":" + subject; + } + + // Discrete catalog asset ids (not species / not sticky live_* frames). + string asset = c.AssetId ?? ""; + if (!string.IsNullOrEmpty(asset) + && !asset.StartsWith("live_", StringComparison.OrdinalIgnoreCase) + && !string.Equals(asset, c.SpeciesId, StringComparison.OrdinalIgnoreCase) + && !string.Equals(asset, "scored_unit", StringComparison.OrdinalIgnoreCase) + && !string.Equals(asset, "harness", StringComparison.OrdinalIgnoreCase)) + { + yield return "beat:asset:" + asset + ":" + subject; + } + + string labelNorm = NormalizeBeatLabel(c.Label); + if (!string.IsNullOrEmpty(labelNorm)) + { + yield return "beat:label:" + labelNorm + ":" + subject; + } + } + + private static string NormalizeBeatLabel(string label) + { + if (string.IsNullOrEmpty(label)) + { + return ""; + } + + string t = label.Trim().ToLowerInvariant(); + // Drop leading proper name so "Ukalo feels the joy…" and rebuilds share a beat. + int space = t.IndexOf(' '); + if (space > 0 && space < t.Length - 1) + { + string rest = t.Substring(space + 1).Trim(); + if (rest.StartsWith("feels ", StringComparison.Ordinal) + || rest.StartsWith("has ", StringComparison.Ordinal) + || rest.StartsWith("is ", StringComparison.Ordinal) + || rest.StartsWith("becomes ", StringComparison.Ordinal) + || rest.StartsWith("gains ", StringComparison.Ordinal) + || rest.StartsWith("decides ", StringComparison.Ordinal)) + { + t = rest; + } + } + + return t.Length > 64 ? t.Substring(0, 64) : t; + } + + private static string BeatDropDetail(InterestCandidate c) + { + if (c == null) + { + return "beat"; + } + + if (!string.IsNullOrEmpty(c.HappinessEffectId)) + { + return c.HappinessEffectId; + } + + if (!string.IsNullOrEmpty(c.StatusId)) + { + return c.StatusId; + } + + return c.AssetId ?? c.Key ?? "beat"; + } + private static float NoveltyPenalty(InterestCandidate c, float now) { float p = 0f; diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index 6a52bf2..74ca9f3 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -129,8 +129,17 @@ public static class WorldActivityScanner fighters = Mathf.Max(1, fighters); if (ensemble != null && ensemble.HasFocus) { - follow = ensemble.Focus; - related = ensemble.Related ?? related; + if (ensemble.ParticipantCount > 2 + || LiveEnsemble.HasOpposingCollectiveSides(ensemble) + || ensemble.Scale > EnsembleScale.Pair) + { + follow = ensemble.Focus; + related = ensemble.Related ?? related; + } + else + { + related = related ?? ensemble.Related; + } } string label = ensemble != null @@ -138,7 +147,7 @@ public static class WorldActivityScanner : EventReason.Fight(follow, related); var candidate = new InterestCandidate { - Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id), + Key = EventCatalog.Combat.KeyFor(follow ?? actor, related), LeadKind = InterestLeadKind.EventLed, Category = "Combat", Source = "scanner", @@ -165,6 +174,7 @@ public static class WorldActivityScanner MaxWatch = EventCatalog.Combat.MaxWatch, Completion = InterestCompletionKind.CombatActive }; + candidate.StampCombatPair(follow ?? actor, related); if (ensemble != null) { StampParticipantIds(candidate, ensemble); @@ -385,6 +395,8 @@ public static class WorldActivityScanner /// public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related) { + // Stable ownership: never swap A↔B by momentary weight (that thrashes Duel tips). + // Caller / first claimant stays Follow; dead follow hands off to related. follow = a; related = b; if (a == null || !a.isAlive()) @@ -397,13 +409,6 @@ public static class WorldActivityScanner if (b == null || !b.isAlive()) { related = null; - return; - } - - if (CombatFocusWeight(b) > CombatFocusWeight(a)) - { - follow = b; - related = a; } } diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index c671879..f762339 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.25.82", - "description": "AFK Idle Spectator (I) + Lore (L). PlotCell sticky needs 2+ plotters; Watching log debounced.", + "version": "0.25.87", + "description": "AFK Idle Spectator (I) + Lore (L). Durable combat pair ownership; affliction-only outbreaks; FixedDwell beat cooldown.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json index acfbf3f..93d0a1c 100644 --- a/IdleSpectator/scoring-model.json +++ b/IdleSpectator/scoring-model.json @@ -89,6 +89,7 @@ "eventLedTarget": 0.7, "mixWindow": 20, "hardCooldownSeconds": 12, + "fixedDwellBeatCooldownSeconds": 45, "enrichTopK": 8, "metaCacheTtl": 2 }, diff --git a/docs/event-reason.md b/docs/event-reason.md index c618429..ec828fa 100644 --- a/docs/event-reason.md +++ b/docs/event-reason.md @@ -89,7 +89,7 @@ Do **not** invent inventory from catalogs: builders must read live `World.world. | WarFront | `War` attackers/defenders + kingdom pop | kingdom vs kingdom (`War - A (n) vs B (m)`) | Done | | PlotCell | `Plot.units` (2+) + target kingdom | plotters vs kingdom (`Plot - Plotters (n) vs A (m)`) | Done | | FamilyPack | `Family.units` + alpha | soft species pack (`Pack - Wolves (5) · gathering`); yields to discrete unit events | Done | -| StatusOutbreak | same status id in radius, **2+ carriers** | status carriers (`Outbreak - Cursed (4)`; drowning OK as group) | Done | +| StatusOutbreak | same status id in radius, **2+ carriers**; affliction/disease only (deny stun/shield/recovery_* and short combat FX via live duration) | status carriers (`Outbreak - Cursed (4)`; drowning OK as group) | Done | | DiscoveryCluster | pending first-seens near anchor | species | Optional | Roadmap + shared contract: [`sticky-ensemble-plan.md`](sticky-ensemble-plan.md). diff --git a/docs/sticky-ensemble-plan.md b/docs/sticky-ensemble-plan.md index 9196b7b..ebcac17 100644 --- a/docs/sticky-ensemble-plan.md +++ b/docs/sticky-ensemble-plan.md @@ -31,11 +31,11 @@ Authored catalogs (`event-catalog.json`, war type seeds) are **policy** (strengt | Priority | Kind | Live surface | Side frame | Completion | Tip shape | Status | |----------|------|--------------|------------|------------|-----------|--------| -| 0 | **Combat** | attack_target / in_combat cluster | kingdom → species → camps | `CombatActive` | `Battle - Humans (4) vs Wolves (0)` | Done | +| 0 | **Combat** | attack_target / in_combat cluster; **NamedPair** uses durable `PairOwnerId` + unordered `combat:pair:lo:hi` (no A↔B thrash) | kingdom → species → camps | `CombatActive` | `Duel - A vs B` / `Battle - Humans (4) vs Wolves (0)` | Done | | 1 | **WarFront** | `War.main_attacker` / `main_defender`, kingdom pop | kingdom vs kingdom | `WarFront` | `War - Essiona (120) vs Northreach (80)` | Done | | 2 | **PlotCell** | `Plot.units`, `target_*`, **2+ plotters** (solo → unit Plot tip) | plotters vs target kingdom/city when useful | `PlotActive` | `Plot - Plotters (3) vs Essiona (120)` | Done | | 3 | **FamilyPack** | `Family.units`, alpha | family / species pack | `FamilyPack` | `Pack - Wolves (5) · gathering` (soft cluster - yields to unit events) | Done | -| 4 | **StatusOutbreak** | affliction clusters only, **2+ carriers** (not love/emotion/ambient; never tip at 0/1) | status carriers | `StatusOutbreak` | `Outbreak - Cursed (4)` / `Outbreak - Drowning (3)` | Done | +| 4 | **StatusOutbreak** | lasting affliction clusters only (**2+**; not love/ambient/combat-FX stun/shield/recovery; never tip at 0/1) | status carriers | `StatusOutbreak` | `Outbreak - Cursed (4)` / `Outbreak - Drowning (3)` | Done | | later | Army / Clan | `Army.units` / `Clan.units` | usually feed WarFront / siege | - | - | Feed only | | later | Alliance | `Alliance.getUnits()` | meta aggregation | - | - | Prefer war sides | | later | DiscoveryCluster | pending first-seens | soft cluster | - | - | Optional |