Compare commits
2 commits
c5b3c96886
...
6b15466a92
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b15466a92 | |||
| cb13b29c9e |
16 changed files with 1125 additions and 90 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
"eventLedTarget": 0.7,
|
||||
"mixWindow": 20,
|
||||
"hardCooldownSeconds": 12,
|
||||
"fixedDwellBeatCooldownSeconds": 45,
|
||||
"enrichTopK": 8,
|
||||
"metaCacheTtl": 2
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue