Implement durable combat pair ownership and enhance outbreak handling. Introduce FixedDwell beat cooldowns to prevent immediate reclaiming of camera focus on the same subject. Update combat event catalog to support unordered pair keys for combat scenes. Increment version to 0.25.87 in mod.json.
This commit is contained in:
parent
c5b3c96886
commit
cb13b29c9e
15 changed files with 1099 additions and 82 deletions
|
|
@ -31,6 +31,7 @@ public static class AgentHarness
|
||||||
private static long _activitySubjectId;
|
private static long _activitySubjectId;
|
||||||
private static bool _directorRunActive;
|
private static bool _directorRunActive;
|
||||||
private static string _rememberedFocusKey = "";
|
private static string _rememberedFocusKey = "";
|
||||||
|
private static string _rememberedTip = "";
|
||||||
private static string LastScreenshotPath = "";
|
private static string LastScreenshotPath = "";
|
||||||
private static readonly string[] PreferredSpawnAssets =
|
private static readonly string[] PreferredSpawnAssets =
|
||||||
{
|
{
|
||||||
|
|
@ -225,11 +226,28 @@ public static class AgentHarness
|
||||||
break;
|
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":
|
case "remember_focus":
|
||||||
{
|
{
|
||||||
_rememberedFocusKey = FocusKey();
|
_rememberedFocusKey = FocusKey();
|
||||||
bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-";
|
bool ok = !string.IsNullOrEmpty(_rememberedFocusKey) && _rememberedFocusKey != "-"; if (ok)
|
||||||
if (ok)
|
|
||||||
{
|
{
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
}
|
}
|
||||||
|
|
@ -2071,7 +2089,8 @@ public static class AgentHarness
|
||||||
case "combat_maintain_focus":
|
case "combat_maintain_focus":
|
||||||
{
|
{
|
||||||
InterestDirector.HarnessMaintainCombatFocus();
|
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();
|
string focus = FocusLabel();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
Emit(
|
Emit(
|
||||||
|
|
@ -3743,9 +3762,24 @@ public static class AgentHarness
|
||||||
c.Category = "Combat";
|
c.Category = "Combat";
|
||||||
c.Verb = "fighting";
|
c.Verb = "fighting";
|
||||||
c.ClearCombatSticky();
|
c.ClearCombatSticky();
|
||||||
|
c.StampCombatPair(follow, related);
|
||||||
if (related != null)
|
if (related != null)
|
||||||
{
|
{
|
||||||
c.ParticipantCount = 2;
|
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);
|
InterestScoring.ScoreCheap(c);
|
||||||
|
|
@ -5559,6 +5593,16 @@ public static class AgentHarness
|
||||||
detail = $"remembered={_rememberedFocusKey} now={nowKey}";
|
detail = $"remembered={_rememberedFocusKey} now={nowKey}";
|
||||||
break;
|
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 "focus_arrows":
|
||||||
case "relationship_arrows":
|
case "relationship_arrows":
|
||||||
{
|
{
|
||||||
|
|
@ -8744,6 +8788,7 @@ public static class AgentHarness
|
||||||
_lastSpawned = null;
|
_lastSpawned = null;
|
||||||
_lastSpawnedAssetId = "";
|
_lastSpawnedAssetId = "";
|
||||||
_rememberedFocusKey = "";
|
_rememberedFocusKey = "";
|
||||||
|
_rememberedTip = "";
|
||||||
InterestDirector.SetHarnessFastTiming(false);
|
InterestDirector.SetHarnessFastTiming(false);
|
||||||
SpeciesDiscovery.SetHarnessFastTiming(false);
|
SpeciesDiscovery.SetHarnessFastTiming(false);
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -85,5 +85,37 @@ public static partial class EventCatalog
|
||||||
public const float ScannerTtl = 18f;
|
public const float ScannerTtl = 18f;
|
||||||
|
|
||||||
public static string Key(long subjectId) => "combat:" + subjectId;
|
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);
|
LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
|
||||||
if (ensemble != null && ensemble.HasFocus)
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
{
|
{
|
||||||
follow = ensemble.Focus;
|
// Multi camps may reframe focus; NamedPair keeps the first claimant.
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
long followId = SafeId(follow ?? actor);
|
Actor followActor = follow ?? actor;
|
||||||
string key = EventCatalog.Combat.Key(followId);
|
long followId = SafeId(followActor);
|
||||||
|
string key = EventCatalog.Combat.KeyFor(followActor, related);
|
||||||
string label = ensemble != null
|
string label = ensemble != null
|
||||||
? EventReason.Combat(ensemble)
|
? EventReason.Combat(ensemble)
|
||||||
: EventReason.Fight(follow ?? actor, related);
|
: EventReason.Fight(followActor, related);
|
||||||
var candidate = new InterestCandidate
|
var candidate = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = key,
|
Key = key,
|
||||||
|
|
@ -374,17 +385,17 @@ public static class InterestFeeds
|
||||||
Source = "activity",
|
Source = "activity",
|
||||||
EventStrength = EventCatalog.Combat.ActivityStrength,
|
EventStrength = EventCatalog.Combat.ActivityStrength,
|
||||||
VisualConfidence = 0.8f,
|
VisualConfidence = 0.8f,
|
||||||
Position = follow != null ? follow.current_position : actor.current_position,
|
Position = followActor != null ? followActor.current_position : actor.current_position,
|
||||||
FollowUnit = follow ?? actor,
|
FollowUnit = followActor,
|
||||||
RelatedUnit = related,
|
RelatedUnit = related,
|
||||||
SubjectId = followId,
|
SubjectId = followId,
|
||||||
RelatedId = related != null ? SafeId(related) : 0,
|
RelatedId = related != null ? SafeId(related) : 0,
|
||||||
Label = label,
|
Label = label,
|
||||||
AssetId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
AssetId = followActor.asset != null ? followActor.asset.id : "",
|
||||||
SpeciesId = (follow ?? actor).asset != null ? (follow ?? actor).asset.id : "",
|
SpeciesId = followActor.asset != null ? followActor.asset.id : "",
|
||||||
Verb = verb,
|
Verb = verb,
|
||||||
CityKey = (follow ?? actor).city != null ? ((follow ?? actor).city.name ?? "") : "",
|
CityKey = followActor.city != null ? (followActor.city.name ?? "") : "",
|
||||||
KingdomKey = (follow ?? actor).kingdom != null ? ((follow ?? actor).kingdom.name ?? "") : "",
|
KingdomKey = followActor.kingdom != null ? (followActor.kingdom.name ?? "") : "",
|
||||||
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
|
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
|
||||||
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
|
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
|
||||||
CreatedAt = Time.unscaledTime,
|
CreatedAt = Time.unscaledTime,
|
||||||
|
|
@ -394,6 +405,7 @@ public static class InterestFeeds
|
||||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||||
Completion = InterestCompletionKind.CombatActive
|
Completion = InterestCompletionKind.CombatActive
|
||||||
};
|
};
|
||||||
|
candidate.StampCombatPair(followActor, related);
|
||||||
if (ensemble != null)
|
if (ensemble != null)
|
||||||
{
|
{
|
||||||
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
|
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upgrades / registers sticky status outbreaks when multiple nearby units share a
|
/// Upgrades / registers sticky status outbreaks when multiple nearby units share a
|
||||||
/// camera-worthy affliction / transformation status. Stickies require a real group (2+).
|
/// lasting affliction / disease / spectacle status. Stickies require a real group (2+).
|
||||||
/// Relationship / emotion / ambient statuses stay single-unit tips even in crowds.
|
/// Brief combat FX (stun, silence, shield, …) and social/emotion statuses stay unit tips.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class StatusOutbreakFeed
|
public static class StatusOutbreakFeed
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Affliction / spectacle statuses sampled by <see cref="Tick"/> for cluster outbreaks.
|
/// Authored affliction cluster inventory sampled by <see cref="Tick"/>.
|
||||||
/// Social statuses (fell_in_love, sleeping, …) must also pass <see cref="IsOutbreakWorthy"/>.
|
/// Live ids may also pass via <see cref="LooksLikeAfflictionCluster"/> tokens.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly string[] HotStatusIds =
|
private static readonly string[] HotStatusIds =
|
||||||
{
|
{
|
||||||
|
|
@ -71,9 +72,36 @@ public static class StatusOutbreakFeed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample a few camera-worthy affliction statuses from living units.
|
// Sample live affliction statuses from living units (HotStatusIds + discovered ids).
|
||||||
int registered = 0;
|
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())
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
{
|
{
|
||||||
if (actor == null || !actor.isAlive() || registered >= 6)
|
if (actor == null || !actor.isAlive() || registered >= 6)
|
||||||
|
|
@ -81,8 +109,9 @@ public static class StatusOutbreakFeed
|
||||||
break;
|
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))
|
if (!LiveEnsemble.HasActorStatus(actor, statusId) || !seenStatus.Add(statusId))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -108,9 +137,10 @@ public static class StatusOutbreakFeed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbreak tips are for affliction / transformation clusters (2+ nearby).
|
/// Outbreak tips are lasting affliction / disease / spectacle clusters (2+ nearby).
|
||||||
/// Never "Outbreak - Fell in love" / sleeping / pregnant / emotion crowds.
|
/// Never love/emotion/ambient crowds, and never brief combat interrupt FX
|
||||||
/// Solo drowning / curse / etc. stay unit StatusPhase tips until a group forms.
|
/// (stun, confuse, silence, shield, dodge, recovery_*, …) even if StatusTransformation.
|
||||||
|
/// Solo carriers stay unit StatusPhase tips until a group forms.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsOutbreakWorthy(string statusId, StatusInterestEntry entry = null)
|
public static bool IsOutbreakWorthy(string statusId, StatusInterestEntry entry = null)
|
||||||
{
|
{
|
||||||
|
|
@ -134,9 +164,11 @@ public static class StatusOutbreakFeed
|
||||||
return false;
|
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++)
|
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;
|
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)
|
private static bool TryRegisterOutbreak(Actor seed, string statusId, StatusInterestEntry entry)
|
||||||
{
|
{
|
||||||
Vector2 pos;
|
Vector2 pos;
|
||||||
|
|
|
||||||
|
|
@ -1077,8 +1077,16 @@ internal static class HarnessScenarios
|
||||||
Step("cf10", "interest_end_session"),
|
Step("cf10", "interest_end_session"),
|
||||||
|
|
||||||
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
|
// 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 "),
|
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("cf22", "interest_clear_follow", asset: "wolf"),
|
||||||
Step("cf23", "combat_maintain_focus"),
|
Step("cf23", "combat_maintain_focus"),
|
||||||
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
Step("cf24", "assert", expect: "has_focus", value: "true"),
|
||||||
|
|
@ -1087,11 +1095,16 @@ internal static class HarnessScenarios
|
||||||
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
|
Step("cf26", "assert", expect: "unit_asset", asset: "wolf"),
|
||||||
|
|
||||||
// 1v1 → multi: sided species framing + live sticky counts.
|
// 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("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
||||||
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
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("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("cf44", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -| vs "),
|
||||||
Step("cf45", "assert", expect: "tip_matches_focus"),
|
Step("cf45", "assert", expect: "tip_matches_focus"),
|
||||||
Step("cf46", "assert", expect: "dossier_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("son25", "assert", expect: "outbreak_worthy", value: "fell_in_love", label: "false"),
|
||||||
Step("son26", "assert", expect: "outbreak_worthy", value: "drowning", label: "true"),
|
Step("son26", "assert", expect: "outbreak_worthy", value: "drowning", label: "true"),
|
||||||
Step("son27", "assert", expect: "outbreak_worthy", value: "cursed", 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("son90", "fast_timing", value: "false"),
|
||||||
Step("son99", "snapshot"),
|
Step("son99", "snapshot"),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ public sealed class InterestCandidate
|
||||||
public Actor RelatedUnit;
|
public Actor RelatedUnit;
|
||||||
public long SubjectId;
|
public long SubjectId;
|
||||||
public long RelatedId;
|
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 Label = "";
|
||||||
public string AssetId = "";
|
public string AssetId = "";
|
||||||
public string SpeciesId = "";
|
public string SpeciesId = "";
|
||||||
|
|
@ -157,6 +163,29 @@ public sealed class InterestCandidate
|
||||||
|
|
||||||
public void ClearCombatSticky() => Sticky.Clear();
|
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()
|
public InterestEvent ToInterestEvent()
|
||||||
{
|
{
|
||||||
return new InterestEvent
|
return new InterestEvent
|
||||||
|
|
@ -194,6 +223,8 @@ public sealed class InterestCandidate
|
||||||
RelatedUnit = RelatedUnit,
|
RelatedUnit = RelatedUnit,
|
||||||
SubjectId = SubjectId,
|
SubjectId = SubjectId,
|
||||||
RelatedId = RelatedId,
|
RelatedId = RelatedId,
|
||||||
|
PairOwnerId = PairOwnerId,
|
||||||
|
PairPartnerId = PairPartnerId,
|
||||||
Label = Label,
|
Label = Label,
|
||||||
AssetId = AssetId,
|
AssetId = AssetId,
|
||||||
SpeciesId = SpeciesId,
|
SpeciesId = SpeciesId,
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,13 @@ public static class InterestDirector
|
||||||
return false;
|
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.FollowUnit = null;
|
||||||
|
_current.SubjectId = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -667,18 +673,93 @@ public static class InterestDirector
|
||||||
return false;
|
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;
|
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;
|
pos = _current.FollowUnit.current_position;
|
||||||
}
|
}
|
||||||
|
else if (ownedRelated != null && ownedRelated.isAlive())
|
||||||
|
{
|
||||||
|
pos = ownedRelated.current_position;
|
||||||
|
}
|
||||||
else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
else if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
||||||
{
|
{
|
||||||
pos = _current.RelatedUnit.current_position;
|
pos = _current.RelatedUnit.current_position;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
bool massHint = string.Equals(_current.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|| _current.ParticipantCount > 2;
|
|| _current.ParticipantCount > 2
|
||||||
|
|| HasStickyCombatSides(_current);
|
||||||
float radius = massHint ? 14f : 10f;
|
float radius = massHint ? 14f : 10f;
|
||||||
|
|
||||||
// Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
|
// Death / clear-follow handoff: promote Related before cluster re-pick can steal focus.
|
||||||
|
|
@ -738,7 +819,34 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
else
|
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);
|
_current.ParticipantCount = Mathf.Max(2, _current.ParticipantCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -748,6 +856,7 @@ public static class InterestDirector
|
||||||
_current.SubjectId = EventFeedUtil.SafeId(handoff);
|
_current.SubjectId = EventFeedUtil.SafeId(handoff);
|
||||||
_current.RelatedUnit = handoffFoe;
|
_current.RelatedUnit = handoffFoe;
|
||||||
_current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
|
_current.RelatedId = handoffFoe != null ? EventFeedUtil.SafeId(handoffFoe) : 0;
|
||||||
|
_current.StampCombatPair(handoff, handoffFoe);
|
||||||
_current.Label = handoffLabel;
|
_current.Label = handoffLabel;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -854,40 +963,78 @@ public static class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never stick on a sleeper / bystander unless they are on the sticky combat roster.
|
// Prefer the ownership snapshot over any Follow/Related rewrite during Refresh.
|
||||||
Actor curFollow = _current.FollowUnit;
|
Actor curFollow = ownedFocus != null && ownedFocus.isAlive()
|
||||||
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
|
? ownedFocus
|
||||||
bool curRostered = StickyScoreboard.IsOnRoster(_current, curFollow);
|
: _current.FollowUnit;
|
||||||
if (!LiveEnsemble.IsCombatParticipant(best))
|
Actor curRelated = ownedRelated != null && ownedRelated.isAlive()
|
||||||
{
|
? ownedRelated
|
||||||
if (curFighting || curRostered)
|
: _current.RelatedUnit;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
|
// Named-pair lock: ambient nearby camps must not steal / flip a living Duel.
|
||||||
if (newW < curW + CombatFocusSwitchMargin)
|
// 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 (curFighting || curRostered)
|
||||||
if (foe == null || foe == best)
|
|
||||||
{
|
{
|
||||||
|
best = curFollow;
|
||||||
foe = ResolveAttackFoe(best) ?? foe;
|
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.
|
// Never replace a sticky scoreboard tip with thin Fight prose.
|
||||||
if (HasStickyCombatSides(_current)
|
if (HasStickyCombatSides(_current)
|
||||||
|
|
@ -928,6 +1075,18 @@ public static class InterestDirector
|
||||||
label = previousLabel;
|
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 followChanged = _current.FollowUnit != best;
|
||||||
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
|
bool labelChanged = !string.Equals(previousLabel, label ?? "", StringComparison.Ordinal);
|
||||||
_current.FollowUnit = best;
|
_current.FollowUnit = best;
|
||||||
|
|
@ -1459,6 +1618,320 @@ public static class InterestDirector
|
||||||
return StickyScoreboard.HasOpposingSides(scene);
|
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)
|
private static Actor ResolveAttackFoe(Actor actor)
|
||||||
{
|
{
|
||||||
if (actor == null)
|
if (actor == null)
|
||||||
|
|
@ -1637,12 +2110,44 @@ public static class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int authoredN = Mathf.Max(
|
||||||
|
ensemble.ParticipantCount,
|
||||||
|
(ensemble.SideA?.Count ?? 0) + (ensemble.SideB?.Count ?? 0));
|
||||||
ApplyEnsembleFields(_current, ensemble);
|
ApplyEnsembleFields(_current, ensemble);
|
||||||
|
StickyScoreboard.TryLockFromEnsemble(_current.Sticky, ensemble);
|
||||||
StickyScoreboard.Refresh(
|
StickyScoreboard.Refresh(
|
||||||
_current,
|
_current,
|
||||||
ensemble,
|
ensemble,
|
||||||
ensemble.Focus.current_position,
|
ensemble.Focus.current_position,
|
||||||
14f);
|
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.FollowUnit = ensemble.Focus;
|
||||||
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
|
||||||
_current.RelatedUnit = ensemble.Related;
|
_current.RelatedUnit = ensemble.Related;
|
||||||
|
|
@ -2534,6 +3039,13 @@ public static class InterestDirector
|
||||||
return false;
|
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)
|
if (_current == null)
|
||||||
{
|
{
|
||||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
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)
|
if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null)
|
||||||
{
|
{
|
||||||
// Ownership merge: same key must not adopt a different subject's FollowUnit/Label.
|
// 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
|
bool sameSubject = incoming.SubjectId == 0
|
||||||
|| existing.SubjectId == 0
|
|| existing.SubjectId == 0
|
||||||
|| incoming.SubjectId == existing.SubjectId;
|
|| incoming.SubjectId == existing.SubjectId;
|
||||||
|
if (pairLocked
|
||||||
|
&& incoming.SubjectId != 0
|
||||||
|
&& incoming.SubjectId != existing.PairOwnerId
|
||||||
|
&& existing.PairOwnerId != 0)
|
||||||
|
{
|
||||||
|
sameSubject = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sameSubject)
|
if (!sameSubject)
|
||||||
{
|
{
|
||||||
// Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly.
|
// 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.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
|
||||||
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
|
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
|
||||||
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
|
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;
|
existing.Selected = false;
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
@ -107,18 +131,49 @@ public static class InterestRegistry
|
||||||
existing.VisualConfidence = Mathf.Max(existing.VisualConfidence, incoming.VisualConfidence);
|
existing.VisualConfidence = Mathf.Max(existing.VisualConfidence, incoming.VisualConfidence);
|
||||||
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
|
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))
|
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())
|
if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive())
|
||||||
{
|
{
|
||||||
existing.FollowUnit = incoming.FollowUnit;
|
long incomingId = EventFeedUtil.SafeId(incoming.FollowUnit);
|
||||||
existing.SubjectId = incoming.SubjectId != 0
|
if (!pairLocked
|
||||||
? incoming.SubjectId
|
|| existing.PairOwnerId == 0
|
||||||
: EventFeedUtil.SafeId(incoming.FollowUnit);
|
|| incomingId == existing.PairOwnerId)
|
||||||
existing.Position = incoming.FollowUnit.current_position;
|
{
|
||||||
|
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)
|
else if (incoming.Position != Vector3.zero)
|
||||||
{
|
{
|
||||||
|
|
@ -129,6 +184,10 @@ public static class InterestRegistry
|
||||||
{
|
{
|
||||||
existing.RelatedUnit = incoming.RelatedUnit;
|
existing.RelatedUnit = incoming.RelatedUnit;
|
||||||
existing.RelatedId = incoming.RelatedId;
|
existing.RelatedId = incoming.RelatedId;
|
||||||
|
if (existing.PairPartnerId == 0)
|
||||||
|
{
|
||||||
|
existing.PairPartnerId = incoming.RelatedId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incoming.ParticipantCount > existing.ParticipantCount)
|
if (incoming.ParticipantCount > existing.ParticipantCount)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,11 @@ public class ScoringWeights
|
||||||
public float eventLedTarget = 0.7f;
|
public float eventLedTarget = 0.7f;
|
||||||
public int mixWindow = 20;
|
public int mixWindow = 20;
|
||||||
public float hardCooldownSeconds = 12f;
|
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 int enrichTopK = 8;
|
||||||
public float metaCacheTtl = 2f;
|
public float metaCacheTtl = 2f;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static class InterestVariety
|
public static class InterestVariety
|
||||||
{
|
{
|
||||||
public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget;
|
public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget;
|
||||||
public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow);
|
public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow);
|
||||||
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
|
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 List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
|
||||||
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
|
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);
|
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>
|
/// <summary>
|
||||||
|
|
@ -133,6 +174,13 @@ public static class InterestVariety
|
||||||
continue;
|
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)
|
if (c.LeadKind == InterestLeadKind.CharacterLed)
|
||||||
{
|
{
|
||||||
CharPool.Add(c);
|
CharPool.Add(c);
|
||||||
|
|
@ -173,7 +221,8 @@ public static class InterestVariety
|
||||||
{
|
{
|
||||||
InterestCandidate c = pool[i];
|
InterestCandidate c = pool[i];
|
||||||
float penalty = NoveltyPenalty(c, now);
|
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))
|
if (InterestScoring.IsHotScore(c.TotalScore) || InterestScoring.IsNotable(c.FollowUnit))
|
||||||
{
|
{
|
||||||
penalty *= 0.15f;
|
penalty *= 0.15f;
|
||||||
|
|
@ -219,6 +268,112 @@ public static class InterestVariety
|
||||||
|
|
||||||
public static int MixSampleCount => RecentMix.Count;
|
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)
|
private static float NoveltyPenalty(InterestCandidate c, float now)
|
||||||
{
|
{
|
||||||
float p = 0f;
|
float p = 0f;
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,17 @@ public static class WorldActivityScanner
|
||||||
fighters = Mathf.Max(1, fighters);
|
fighters = Mathf.Max(1, fighters);
|
||||||
if (ensemble != null && ensemble.HasFocus)
|
if (ensemble != null && ensemble.HasFocus)
|
||||||
{
|
{
|
||||||
follow = ensemble.Focus;
|
if (ensemble.ParticipantCount > 2
|
||||||
related = ensemble.Related ?? related;
|
|| LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|
||||||
|
|| ensemble.Scale > EnsembleScale.Pair)
|
||||||
|
{
|
||||||
|
follow = ensemble.Focus;
|
||||||
|
related = ensemble.Related ?? related;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
related = related ?? ensemble.Related;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string label = ensemble != null
|
string label = ensemble != null
|
||||||
|
|
@ -138,7 +147,7 @@ public static class WorldActivityScanner
|
||||||
: EventReason.Fight(follow, related);
|
: EventReason.Fight(follow, related);
|
||||||
var candidate = new InterestCandidate
|
var candidate = new InterestCandidate
|
||||||
{
|
{
|
||||||
Key = EventCatalog.Combat.Key(follow != null ? EventFeedUtil.SafeId(follow) : id),
|
Key = EventCatalog.Combat.KeyFor(follow ?? actor, related),
|
||||||
LeadKind = InterestLeadKind.EventLed,
|
LeadKind = InterestLeadKind.EventLed,
|
||||||
Category = "Combat",
|
Category = "Combat",
|
||||||
Source = "scanner",
|
Source = "scanner",
|
||||||
|
|
@ -165,6 +174,7 @@ public static class WorldActivityScanner
|
||||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||||
Completion = InterestCompletionKind.CombatActive
|
Completion = InterestCompletionKind.CombatActive
|
||||||
};
|
};
|
||||||
|
candidate.StampCombatPair(follow ?? actor, related);
|
||||||
if (ensemble != null)
|
if (ensemble != null)
|
||||||
{
|
{
|
||||||
StampParticipantIds(candidate, ensemble);
|
StampParticipantIds(candidate, ensemble);
|
||||||
|
|
@ -385,6 +395,8 @@ public static class WorldActivityScanner
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void PreferCombatFollow(Actor a, Actor b, out Actor follow, out Actor related)
|
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;
|
follow = a;
|
||||||
related = b;
|
related = b;
|
||||||
if (a == null || !a.isAlive())
|
if (a == null || !a.isAlive())
|
||||||
|
|
@ -397,13 +409,6 @@ public static class WorldActivityScanner
|
||||||
if (b == null || !b.isAlive())
|
if (b == null || !b.isAlive())
|
||||||
{
|
{
|
||||||
related = null;
|
related = null;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CombatFocusWeight(b) > CombatFocusWeight(a))
|
|
||||||
{
|
|
||||||
follow = b;
|
|
||||||
related = a;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "IdleSpectator",
|
"name": "IdleSpectator",
|
||||||
"author": "dazed",
|
"author": "dazed",
|
||||||
"version": "0.25.82",
|
"version": "0.25.87",
|
||||||
"description": "AFK Idle Spectator (I) + Lore (L). PlotCell sticky needs 2+ plotters; Watching log debounced.",
|
"description": "AFK Idle Spectator (I) + Lore (L). Durable combat pair ownership; affliction-only outbreaks; FixedDwell beat cooldown.",
|
||||||
"GUID": "com.dazed.idlespectator"
|
"GUID": "com.dazed.idlespectator"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@
|
||||||
"eventLedTarget": 0.7,
|
"eventLedTarget": 0.7,
|
||||||
"mixWindow": 20,
|
"mixWindow": 20,
|
||||||
"hardCooldownSeconds": 12,
|
"hardCooldownSeconds": 12,
|
||||||
|
"fixedDwellBeatCooldownSeconds": 45,
|
||||||
"enrichTopK": 8,
|
"enrichTopK": 8,
|
||||||
"metaCacheTtl": 2
|
"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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| DiscoveryCluster | pending first-seens near anchor | species | Optional |
|
||||||
|
|
||||||
Roadmap + shared contract: [`sticky-ensemble-plan.md`](sticky-ensemble-plan.md).
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 | Army / Clan | `Army.units` / `Clan.units` | usually feed WarFront / siege | - | - | Feed only |
|
||||||
| later | Alliance | `Alliance.getUnits()` | meta aggregation | - | - | Prefer war sides |
|
| later | Alliance | `Alliance.getUnits()` | meta aggregation | - | - | Prefer war sides |
|
||||||
| later | DiscoveryCluster | pending first-seens | soft cluster | - | - | Optional |
|
| later | DiscoveryCluster | pending first-seens | soft cluster | - | - | Optional |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue