Implement durable combat pair ownership and enhance outbreak handling. Introduce FixedDwell beat cooldowns to prevent immediate reclaiming of camera focus on the same subject. Update combat event catalog to support unordered pair keys for combat scenes. Increment version to 0.25.87 in mod.json.

This commit is contained in:
DazedAnon 2026-07-17 02:32:43 -05:00
parent c5b3c96886
commit cb13b29c9e
15 changed files with 1099 additions and 82 deletions

View file

@ -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

View file

@ -85,5 +85,37 @@ public static partial class EventCatalog
public const float ScannerTtl = 18f;
public static string Key(long subjectId) => "combat:" + subjectId;
/// <summary>
/// Unordered pair key so A-vs-B and B-vs-A collapse to one durable combat scene.
/// </summary>
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);
}
}
}

View file

@ -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);

View file

@ -1,18 +1,19 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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.
/// </summary>
public static class StatusOutbreakFeed
{
/// <summary>
/// Affliction / spectacle statuses sampled by <see cref="Tick"/> for cluster outbreaks.
/// Social statuses (fell_in_love, sleeping, …) must also pass <see cref="IsOutbreakWorthy"/>.
/// Authored affliction cluster inventory sampled by <see cref="Tick"/>.
/// Live ids may also pass via <see cref="LooksLikeAfflictionCluster"/> tokens.
/// </summary>
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<string>(StringComparer.OrdinalIgnoreCase);
var seenStatus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sampleIds = new List<string>(HotStatusIds.Length + 16);
sampleIds.AddRange(HotStatusIds);
List<string> 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
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Combat interrupt / recovery / shield FX that fire in packs during battles
/// but are not lasting affliction stories. Uses id tokens plus live asset duration.
/// </summary>
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;
}
/// <summary>Token heuristic for live plague/disease/curse-like statuses.</summary>
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;

View file

@ -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"),

View file

@ -62,6 +62,12 @@ public sealed class InterestCandidate
public Actor RelatedUnit;
public long SubjectId;
public long RelatedId;
/// <summary>
/// Durable 1v1 ownership: first Follow stays camera owner while both pair ids live.
/// Prevents Duel A↔B thrash across maintain ticks and registry upserts.
/// </summary>
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();
/// <summary>Lock durable 1v1 owner/partner ids (no-op when owner missing).</summary>
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,

View file

@ -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;
}
}
/// <summary>
/// True when the scene already owns a collective multi battle (post-escalate),
/// not a NamedPair duel that ambient units merely wandered near.
/// </summary>
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);
}
/// <summary>
/// Living 1v1 ownership: Duel tips and forced pair sessions stay NamedPair until a
/// real multi escalate (sticky collective camps), not ambient nearby melees.
/// </summary>
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;
}
/// <summary>
/// Allow leaving NamedPair only after an intentional multi frame exists
/// (harness escalate / real sticky Battle), never from ambient radius noise.
/// </summary>
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;
}
/// <summary>
/// Hold Duel ownership whenever Follow+Related still form the live pair.
/// Ambient sticky camps do not unlock a living NamedPair.
/// </summary>
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);
}
/// <summary>
/// True when <paramref name="best"/>/<paramref name="foe"/> are the same two actors as the
/// current pair (possibly swapped). Used to lock Duel tip ownership.
/// </summary>
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);

View file

@ -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)

View file

@ -112,6 +112,11 @@ public class ScoringWeights
public float eventLedTarget = 0.7f;
public int mixWindow = 20;
public float hardCooldownSeconds = 12f;
/// <summary>
/// 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.
/// </summary>
public float fixedDwellBeatCooldownSeconds = 45f;
public int enrichTopK = 8;
public float metaCacheTtl = 2f;
}

View file

@ -1,16 +1,20 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// 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).
/// </summary>
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<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(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);
}
}
}
/// <summary>
/// True when a FixedDwell / moment beat for this candidate is still hard-cooled.
/// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked.
/// </summary>
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;
}
/// <summary>
@ -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<string> 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;

View file

@ -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
/// </summary>
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;
}
}

View file

@ -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"
}

View file

@ -89,6 +89,7 @@
"eventLedTarget": 0.7,
"mixWindow": 20,
"hardCooldownSeconds": 12,
"fixedDwellBeatCooldownSeconds": 45,
"enrichTopK": 8,
"metaCacheTtl": 2
},

View file

@ -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).

View file

@ -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 |