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 |