Compare commits

...

2 commits

16 changed files with 1125 additions and 90 deletions

View file

@ -13,6 +13,22 @@ Always validate IdleSpectator changes with the in-game harness.
Do not rely on OS mouse/keyboard automation (Wayland-flaky).
Do not call `Application.Quit()` (can hang Steam on Linux).
Quality bar (also in `.cursor/rules/idle-spectator-quality.mdc`):
1. **Comprehensive / scales** - fix the class via discovery, not one logged example.
2. **Long-term / maintainable** - no brittle shortcuts.
3. **Always live-test** - open the game, spawn/drop entities, statuses, wars, plots; do not stop at code review or a single assert.
## Live playtest (required for features/fixes)
Harness scenarios are the gate, not a substitute for poking the world.
- Start or reuse WorldBox with `worldbox-ctl` / `harness-run.sh` (`all` permissions).
- Use harness actions that actually seed the situation: `spawn`, `status_apply` / `status_apply_nearby`, combat sessions, war/plot/family/outbreak sessions, wire attack sides, etc.
- When a bug was seen in a soak or Player.log, reproduce that shape in-world (same sticky kind, pair size, status class) before declaring fixed.
- Prefer extending `HarnessScenarios.cs` so the live repro becomes a repeatable `--repeat 3` gate.
- Watching the camera tip + dossier after drops counts; reading only the C# change does not.
## Sandbox / permissions (required)
**Never start, relaunch, or kill WorldBox / Steam from the Cursor sandbox.**
@ -49,23 +65,25 @@ Vanilla `Tooltip` / `TooltipLibrary` (`tooltips/tooltip_actor`, etc.) may be reu
After any feature or bug fix in `IdleSpectator/`:
1. Bump `IdleSpectator/mod.json` version (NML recompiles on game load).
2. If the game was already running, recompile with:
1. Design for full coverage (discovery / class rules), not the single failing tip.
2. Bump `IdleSpectator/mod.json` version (NML recompiles on game load).
3. If the game was already running, recompile with:
```bash
./scripts/worldbox-ctl.sh restart
```
3. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior.
4. Day-to-day / PR gate:
4. Live-test: spawn/drop the relevant entities and statuses in-world (via harness scenario or focused harness commands). Confirm tip/focus/dossier behavior matches the contract.
5. Extend or add a scenario in `IdleSpectator/HarnessScenarios.cs` when the change is new behavior.
6. Day-to-day / PR gate:
```bash
./scripts/harness-run.sh --repeat 3 critical_smoke
```
5. After larger changes (director, discovery, settings, input, tips, chronicle), also run:
7. After larger changes (director, discovery, settings, input, tips, chronicle), also run:
```bash
./scripts/harness-run.sh regression
```
6. If you added a focused scenario, run it directly too.
7. Iterate until **all repeats PASS**. Do not stop after one flaky pass.
8. Report: scenario name(s), PASS/FAIL counts, and any new asserts.
8. If you added a focused scenario, run it directly too with `--repeat 3`.
9. Iterate until **all repeats PASS**. Do not stop after one flaky pass.
10. Report: scenario name(s), PASS/FAIL counts, what you spawned/live-checked, and any new asserts.
### HUD / visual changes - screenshot gate (required)

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 |