Enhance combat handling and introduce related unit management.
- Add functionality to remember related units during combat scenarios - Implement combat target swapping while maintaining pair partner integrity - Update InterestCandidate to support durable combat pair ownership - Refactor InterestDirector to prioritize sticky collective tips in combat - Increment version to 0.28.21 in mod.json
This commit is contained in:
parent
04d5bcb381
commit
45a37d7471
7 changed files with 520 additions and 47 deletions
|
|
@ -32,6 +32,7 @@ public static class AgentHarness
|
||||||
private static bool _directorRunActive;
|
private static bool _directorRunActive;
|
||||||
private static string _rememberedFocusKey = "";
|
private static string _rememberedFocusKey = "";
|
||||||
private static string _rememberedTip = "";
|
private static string _rememberedTip = "";
|
||||||
|
private static long _rememberedRelatedId;
|
||||||
private static string LastScreenshotPath = "";
|
private static string LastScreenshotPath = "";
|
||||||
private static readonly string[] PreferredSpawnAssets =
|
private static readonly string[] PreferredSpawnAssets =
|
||||||
{
|
{
|
||||||
|
|
@ -259,6 +260,28 @@ public static class AgentHarness
|
||||||
Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}");
|
Emit(cmd, ok, detail: $"focus={_rememberedFocusKey}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "remember_related":
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
long id = scene != null
|
||||||
|
? (scene.PairPartnerId != 0
|
||||||
|
? scene.PairPartnerId
|
||||||
|
: EventFeedUtil.SafeId(scene.RelatedUnit))
|
||||||
|
: 0;
|
||||||
|
_rememberedRelatedId = id;
|
||||||
|
bool okRel = id != 0;
|
||||||
|
if (okRel)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(cmd, okRel, detail: $"related={id}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "spectator":
|
case "spectator":
|
||||||
{
|
{
|
||||||
|
|
@ -2691,6 +2714,14 @@ public static class AgentHarness
|
||||||
DoCombatWireAttackSides(cmd);
|
DoCombatWireAttackSides(cmd);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "combat_swap_attack_target":
|
||||||
|
DoCombatSwapAttackTarget(cmd);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "combat_probe_fighters":
|
||||||
|
DoCombatProbeFighters(cmd);
|
||||||
|
break;
|
||||||
|
|
||||||
case "combat_park_bystander_follow":
|
case "combat_park_bystander_follow":
|
||||||
DoCombatParkBystanderFollow(cmd);
|
DoCombatParkBystanderFollow(cmd);
|
||||||
break;
|
break;
|
||||||
|
|
@ -6000,6 +6031,13 @@ public static class AgentHarness
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enroll camps on the sticky scoreboard. AI often clears attack_target on pack
|
||||||
|
// extras within a tick; roster membership still lets Duel → Mass escalate.
|
||||||
|
if (scene != null && campA.Count > 0 && campB.Count > 0)
|
||||||
|
{
|
||||||
|
StampCombatCampsOntoScene(scene, sideA, sideB, campA, campB);
|
||||||
|
}
|
||||||
|
|
||||||
InterestDirector.HarnessMaintainCombatFocus();
|
InterestDirector.HarnessMaintainCombatFocus();
|
||||||
bool ok = wired > 0;
|
bool ok = wired > 0;
|
||||||
if (ok)
|
if (ok)
|
||||||
|
|
@ -6014,7 +6052,170 @@ public static class AgentHarness
|
||||||
Emit(
|
Emit(
|
||||||
cmd,
|
cmd,
|
||||||
ok,
|
ok,
|
||||||
detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
detail: $"wired={wired} a={campA.Count}:{sideA} b={campB.Count}:{sideB} sticky={scene?.CombatSideACount ?? 0}:{scene?.CombatSideBCount ?? 0} peak={scene?.CombatPeakParticipants ?? 0} tip='{CameraDirector.LastWatchLabel}' focus={FocusLabel()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StampCombatCampsOntoScene(
|
||||||
|
InterestCandidate scene,
|
||||||
|
string sideA,
|
||||||
|
string sideB,
|
||||||
|
List<Actor> campA,
|
||||||
|
List<Actor> campB)
|
||||||
|
{
|
||||||
|
if (scene?.Sticky == null || campA == null || campB == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveSceneStickyState sticky = scene.Sticky;
|
||||||
|
sticky.Kind = EnsembleKind.Combat;
|
||||||
|
sticky.Frame = EnsembleFrame.SpeciesVsSpecies;
|
||||||
|
sticky.SideAKey = sideA;
|
||||||
|
sticky.SideBKey = sideB;
|
||||||
|
sticky.SideADisplay = LiveEnsemble.SpeciesDisplay(sideA);
|
||||||
|
sticky.SideBDisplay = LiveEnsemble.SpeciesDisplay(sideB);
|
||||||
|
sticky.SideAIds.Clear();
|
||||||
|
sticky.SideBIds.Clear();
|
||||||
|
for (int i = 0; i < campA.Count; i++)
|
||||||
|
{
|
||||||
|
long id = EventFeedUtil.SafeId(campA[i]);
|
||||||
|
if (id != 0 && !sticky.SideAIds.Contains(id))
|
||||||
|
{
|
||||||
|
sticky.SideAIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < campB.Count; i++)
|
||||||
|
{
|
||||||
|
long id = EventFeedUtil.SafeId(campB[i]);
|
||||||
|
if (id != 0 && !sticky.SideBIds.Contains(id))
|
||||||
|
{
|
||||||
|
sticky.SideBIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sticky.SideACount = sticky.SideAIds.Count;
|
||||||
|
sticky.SideBCount = sticky.SideBIds.Count;
|
||||||
|
int total = sticky.SideACount + sticky.SideBCount;
|
||||||
|
scene.ParticipantCount = Math.Max(scene.ParticipantCount, total);
|
||||||
|
if (total > scene.CombatPeakParticipants)
|
||||||
|
{
|
||||||
|
scene.CombatPeakParticipants = total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus.</summary>
|
||||||
|
private static void DoCombatProbeFighters(HarnessCommand cmd)
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
Actor anchor = scene?.FollowUnit;
|
||||||
|
if (anchor == null || !anchor.isAlive())
|
||||||
|
{
|
||||||
|
anchor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor == null || !anchor.isAlive())
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_anchor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float radius = ParseFloat(cmd.value, 18f);
|
||||||
|
if (radius < 4f)
|
||||||
|
{
|
||||||
|
radius = 18f;
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveEnsemble.TryBuildCombat(
|
||||||
|
anchor.current_position,
|
||||||
|
radius,
|
||||||
|
out LiveEnsemble ensemble,
|
||||||
|
priorParticipantIds: scene?.ParticipantIds,
|
||||||
|
newcomerBonus: 8f);
|
||||||
|
int n = ensemble != null ? ensemble.ParticipantCount : 0;
|
||||||
|
string frame = ensemble != null ? ensemble.Frame.ToString() : "none";
|
||||||
|
string scale = ensemble != null ? ensemble.Scale.ToString() : "none";
|
||||||
|
bool sided = ensemble != null && LiveEnsemble.HasOpposingCollectiveSides(ensemble);
|
||||||
|
bool focusFight = LiveEnsemble.IsCombatParticipant(anchor);
|
||||||
|
bool relatedFight = scene?.RelatedUnit != null
|
||||||
|
&& LiveEnsemble.IsCombatParticipant(scene.RelatedUnit);
|
||||||
|
_cmdOk++;
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
ok: true,
|
||||||
|
detail: $"n={n} r={radius:0.#} frame={frame} scale={scale} sided={sided} focusFight={focusFight} relatedFight={relatedFight} tip='{CameraDirector.LastWatchLabel}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spawn a distractor and point the combat Follow's attack_target at them while the
|
||||||
|
/// locked pair partner stays alive. Maintain / director must not flip the Duel tip.
|
||||||
|
/// </summary>
|
||||||
|
private static void DoCombatSwapAttackTarget(HarnessCommand cmd)
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_combat_scene");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Actor focus = scene.FollowUnit;
|
||||||
|
Actor partner = scene.RelatedUnit;
|
||||||
|
if (focus == null || !focus.isAlive())
|
||||||
|
{
|
||||||
|
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focus == null || !focus.isAlive())
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "no_focus");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner == null || !partner.isAlive())
|
||||||
|
{
|
||||||
|
partner = LiveEnsemble.FindTrackedActor(scene.PairPartnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
string distractorAsset = !string.IsNullOrEmpty(cmd.asset)
|
||||||
|
? cmd.asset.Trim()
|
||||||
|
: (partner?.asset != null ? partner.asset.id : "wolf");
|
||||||
|
Actor distractor = SpawnAssetNear(distractorAsset, focus.current_position, 3.5f);
|
||||||
|
if (distractor == null || !distractor.isAlive())
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
Emit(cmd, ok: false, detail: "spawn_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the locked partner alive so the hold must prefer them over the new target.
|
||||||
|
if (partner != null && partner.isAlive())
|
||||||
|
{
|
||||||
|
StatusGameApi.TryAddStatus(partner, "invincible", 12f);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusGameApi.TryAddStatus(distractor, "invincible", 8f);
|
||||||
|
bool wired = TrySetAttackTarget(focus, distractor);
|
||||||
|
TrySetAttackTarget(distractor, focus);
|
||||||
|
// Publish the noisy peer pair the way live activity does (new combat:pair key).
|
||||||
|
InterestFeeds.OnActivityNote(focus, "fighting", hot: true);
|
||||||
|
|
||||||
|
if (wired)
|
||||||
|
{
|
||||||
|
_cmdOk++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_cmdFail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
wired,
|
||||||
|
detail: $"focus={SafeName(focus)} partner={SafeName(partner)} distractor={SafeName(distractor)} tip='{CameraDirector.LastWatchLabel}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TrySetAttackTarget(Actor unit, Actor foe)
|
private static bool TrySetAttackTarget(Actor unit, Actor foe)
|
||||||
|
|
@ -6476,6 +6677,22 @@ public static class AgentHarness
|
||||||
detail = $"remembered='{_rememberedTip}' now='{nowTip}'";
|
detail = $"remembered='{_rememberedTip}' now='{nowTip}'";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "related_same":
|
||||||
|
case "pair_partner_same":
|
||||||
|
{
|
||||||
|
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||||
|
long nowId = 0;
|
||||||
|
if (scene != null)
|
||||||
|
{
|
||||||
|
nowId = scene.PairPartnerId != 0
|
||||||
|
? scene.PairPartnerId
|
||||||
|
: EventFeedUtil.SafeId(scene.RelatedUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
pass = _rememberedRelatedId != 0 && nowId == _rememberedRelatedId;
|
||||||
|
detail = $"remembered={_rememberedRelatedId} now={nowId}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "focus_arrows":
|
case "focus_arrows":
|
||||||
case "relationship_arrows":
|
case "relationship_arrows":
|
||||||
{
|
{
|
||||||
|
|
@ -10257,6 +10474,7 @@ public static class AgentHarness
|
||||||
_lastSpawnedAssetId = "";
|
_lastSpawnedAssetId = "";
|
||||||
_rememberedFocusKey = "";
|
_rememberedFocusKey = "";
|
||||||
_rememberedTip = "";
|
_rememberedTip = "";
|
||||||
|
_rememberedRelatedId = 0;
|
||||||
InterestDirector.SetHarnessFastTiming(false);
|
InterestDirector.SetHarnessFastTiming(false);
|
||||||
SpeciesDiscovery.SetHarnessFastTiming(false);
|
SpeciesDiscovery.SetHarnessFastTiming(false);
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,9 @@ public static class CameraDirector
|
||||||
string tip = FormatWatchTip(interest);
|
string tip = FormatWatchTip(interest);
|
||||||
string label = interest.Label ?? "";
|
string label = interest.Label ?? "";
|
||||||
string assetId = interest.AssetId ?? "";
|
string assetId = interest.AssetId ?? "";
|
||||||
|
// AssetId-only churn (live_combat ↔ live_battle) must not re-log the same tip.
|
||||||
bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal)
|
bool tipChanged = !string.Equals(tip, LastFormattedWatchTip, StringComparison.Ordinal)
|
||||||
|| !string.Equals(label, LastWatchLabel, StringComparison.Ordinal)
|
|| !string.Equals(label, LastWatchLabel, StringComparison.Ordinal);
|
||||||
|| !string.Equals(assetId, LastWatchAssetId, StringComparison.Ordinal);
|
|
||||||
bool headcountOnly = tipChanged
|
bool headcountOnly = tipChanged
|
||||||
&& EventReason.IsHeadcountOnlyChange(LastWatchLabel, label);
|
&& EventReason.IsHeadcountOnlyChange(LastWatchLabel, label);
|
||||||
LastFormattedWatchTip = tip;
|
LastFormattedWatchTip = tip;
|
||||||
|
|
|
||||||
|
|
@ -1122,6 +1122,15 @@ internal static class HarnessScenarios
|
||||||
Step("cf21e", "assert", expect: "tip_same"),
|
Step("cf21e", "assert", expect: "tip_same"),
|
||||||
Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
Step("cf21f", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
Step("cf21w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"),
|
Step("cf21w", "combat_wire_attack_sides", asset: "human", value: "wolf", expect: "pair"),
|
||||||
|
// Attack-target swap must keep locked pair partner (tip may escalate 1v2→Skirmish).
|
||||||
|
Step("cf21p", "remember_related"),
|
||||||
|
Step("cf21q", "combat_swap_attack_target", asset: "wolf"),
|
||||||
|
Step("cf21r", "wait", value: "0.35"),
|
||||||
|
Step("cf21s", "director_run", wait: 0.6f),
|
||||||
|
Step("cf21t", "combat_maintain_focus"),
|
||||||
|
Step("cf21u", "combat_maintain_focus"),
|
||||||
|
Step("cf21v", "assert", expect: "related_same"),
|
||||||
|
Step("cf21x", "assert", expect: "tip_matches_any", value: "Duel|Skirmish|Battle|Mass|fighting| vs "),
|
||||||
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"),
|
||||||
|
|
@ -1144,14 +1153,15 @@ internal static class HarnessScenarios
|
||||||
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"),
|
||||||
Step("cf47", "assert", expect: "has_focus", value: "true"),
|
Step("cf47", "assert", expect: "has_focus", value: "true"),
|
||||||
// Theater lead: collective maintain must not thrash Follow across the mob.
|
// Theater lead: settle once, then maintain must not thrash Follow across the mob.
|
||||||
Step("cf47a", "remember_focus"),
|
Step("cf47a", "combat_maintain_focus"),
|
||||||
Step("cf47b", "combat_maintain_focus"),
|
Step("cf47b", "remember_focus"),
|
||||||
Step("cf47c", "wait", value: "0.35"),
|
Step("cf47c", "combat_maintain_focus"),
|
||||||
Step("cf47d", "combat_maintain_focus"),
|
Step("cf47d", "wait", value: "0.35"),
|
||||||
Step("cf47e", "wait", value: "0.35"),
|
Step("cf47e", "combat_maintain_focus"),
|
||||||
Step("cf47f", "combat_maintain_focus"),
|
Step("cf47f", "wait", value: "0.35"),
|
||||||
Step("cf47g", "assert", expect: "focus_same"),
|
Step("cf47g", "combat_maintain_focus"),
|
||||||
|
Step("cf47h", "assert", expect: "focus_same"),
|
||||||
|
|
||||||
// Spectacle theater lead: evil mage vs human mob holds the mage, not a random human.
|
// Spectacle theater lead: evil mage vs human mob holds the mage, not a random human.
|
||||||
Step("cf48", "interest_end_session"),
|
Step("cf48", "interest_end_session"),
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,12 @@ public sealed class InterestCandidate
|
||||||
ClearTheaterLead();
|
ClearTheaterLead();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lock durable 1v1 owner/partner ids (no-op when owner missing).</summary>
|
/// <summary>
|
||||||
public void StampCombatPair(Actor owner, Actor partner)
|
/// Lock durable 1v1 owner/partner ids (no-op when owner missing).
|
||||||
|
/// First living partner sticks across attack_target swaps; replace only when the
|
||||||
|
/// locked partner is dead/missing (or <paramref name="replacePartner"/>).
|
||||||
|
/// </summary>
|
||||||
|
public void StampCombatPair(Actor owner, Actor partner, bool replacePartner = false)
|
||||||
{
|
{
|
||||||
long oid = EventFeedUtil.SafeId(owner);
|
long oid = EventFeedUtil.SafeId(owner);
|
||||||
if (oid == 0)
|
if (oid == 0)
|
||||||
|
|
@ -191,7 +195,25 @@ public sealed class InterestCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
long pid = EventFeedUtil.SafeId(partner);
|
long pid = EventFeedUtil.SafeId(partner);
|
||||||
if (pid != 0 && pid != PairOwnerId)
|
if (pid == 0 || pid == PairOwnerId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PairPartnerId == 0 || replacePartner)
|
||||||
|
{
|
||||||
|
PairPartnerId = pid;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PairPartnerId == pid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locked partner still alive: keep them. Dead/missing: adopt the new foe once.
|
||||||
|
Actor locked = LiveEnsemble.FindTrackedActor(PairPartnerId);
|
||||||
|
if (locked == null || !locked.isAlive())
|
||||||
{
|
{
|
||||||
PairPartnerId = pid;
|
PairPartnerId = pid;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -724,13 +724,20 @@ public static class InterestDirector
|
||||||
int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2;
|
int probeFighters = escalateProbe != null ? escalateProbe.ParticipantCount : 2;
|
||||||
bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus);
|
bool focusFighting = LiveEnsemble.IsCombatParticipant(ownedFocus);
|
||||||
bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated);
|
bool relatedFighting = LiveEnsemble.IsCombatParticipant(ownedRelated);
|
||||||
|
int stickyCampN = _current.CombatSideACount + _current.CombatSideBCount;
|
||||||
|
// Sticky camps enrolled (pack wire / live growth) outrank NamedPair hold even when
|
||||||
|
// the live probe still looks like a thin Duel this tick.
|
||||||
|
bool stickyPackEscalate = HasStickyCollectiveMulti(_current)
|
||||||
|
&& stickyCampN >= 3
|
||||||
|
&& (focusFighting || relatedFighting);
|
||||||
// Both left the scrap (chores / idle): do not hold a Duel camera on them.
|
// Both left the scrap (chores / idle): do not hold a Duel camera on them.
|
||||||
// Keep durable A vs B order while either still fights (no tip flip on attack gaps).
|
// Keep durable A vs B order while either still fights (no tip flip on attack gaps).
|
||||||
if (!focusFighting && !relatedFighting)
|
if (!focusFighting && !relatedFighting)
|
||||||
{
|
{
|
||||||
// Fall through to sticky rebuild / live fighter pick.
|
// Fall through to sticky rebuild / live fighter pick.
|
||||||
}
|
}
|
||||||
else if (!ShouldEscalateNamedPair(_current, escalateProbe, probeFighters))
|
else if (!stickyPackEscalate
|
||||||
|
&& !ShouldEscalateNamedPair(_current, escalateProbe, probeFighters))
|
||||||
{
|
{
|
||||||
string pairLabel = "";
|
string pairLabel = "";
|
||||||
Actor holdFocus = ownedFocus;
|
Actor holdFocus = ownedFocus;
|
||||||
|
|
@ -1106,10 +1113,16 @@ public static class InterestDirector
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never replace a sticky scoreboard tip with thin Fight prose.
|
// Prefer sticky collective tips over thin Fight / leftover Duel prose.
|
||||||
|
// Live probes often collapse to NamedPair when AI drops pack attack_targets.
|
||||||
|
int stickyFighters = _current.CombatSideACount + _current.CombatSideBCount;
|
||||||
|
bool stickyCollectiveTip = HasStickyCombatSides(_current)
|
||||||
|
&& (HasStickyCollectiveMulti(_current) || stickyFighters >= 3);
|
||||||
if (HasStickyCombatSides(_current)
|
if (HasStickyCombatSides(_current)
|
||||||
&& (string.IsNullOrEmpty(label)
|
&& (string.IsNullOrEmpty(label)
|
||||||
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0))
|
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| (stickyCollectiveTip
|
||||||
|
&& label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))))
|
||||||
{
|
{
|
||||||
var stickyEns = new LiveEnsemble
|
var stickyEns = new LiveEnsemble
|
||||||
{
|
{
|
||||||
|
|
@ -1134,7 +1147,7 @@ public static class InterestDirector
|
||||||
Count = _current.CombatSideBCount,
|
Count = _current.CombatSideBCount,
|
||||||
Best = foe
|
Best = foe
|
||||||
},
|
},
|
||||||
ParticipantCount = _current.CombatSideACount + _current.CombatSideBCount
|
ParticipantCount = Math.Max(stickyFighters, _current.CombatSideACount + _current.CombatSideBCount)
|
||||||
};
|
};
|
||||||
label = EventReason.Combat(stickyEns);
|
label = EventReason.Combat(stickyEns);
|
||||||
}
|
}
|
||||||
|
|
@ -2144,19 +2157,6 @@ public static class InterestDirector
|
||||||
string tip = scene.Label ?? "";
|
string tip = scene.Label ?? "";
|
||||||
bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|
bool duelLabeled = tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Already-framed collective multi may leave NamedPair, unless a Duel tip is still
|
|
||||||
// holding ownership over ambient sticky pollution (needs a live sided ensemble).
|
|
||||||
if (HasStickyCollectiveMulti(scene) && !duelLabeled)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fighters < 3 || ensemble == null || !LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Natural growth: both pair members still fighting inside a sided multi cluster.
|
|
||||||
ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
|
ResolveCombatPairActors(scene, out Actor owner, out Actor partner);
|
||||||
Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit;
|
Actor focus = owner != null && owner.isAlive() ? owner : scene.FollowUnit;
|
||||||
Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit;
|
Actor related = partner != null && partner.isAlive() ? partner : scene.RelatedUnit;
|
||||||
|
|
@ -2165,8 +2165,40 @@ public static class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return LiveEnsemble.IsCombatParticipant(focus)
|
bool pairEngaged = LiveEnsemble.IsCombatParticipant(focus)
|
||||||
&& LiveEnsemble.IsCombatParticipant(related);
|
|| LiveEnsemble.IsCombatParticipant(related);
|
||||||
|
|
||||||
|
// Already-framed collective multi may leave NamedPair. Duel tips still escalate when
|
||||||
|
// sticky camps were enrolled (AI often drops attack_target on pack extras).
|
||||||
|
if (HasStickyCollectiveMulti(scene))
|
||||||
|
{
|
||||||
|
if (!duelLabeled)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int stickyN = scene.CombatSideACount + scene.CombatSideBCount;
|
||||||
|
if (stickyN >= 3 && pairEngaged)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fighters < 3 || ensemble == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sidedMulti = LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|
||||||
|
|| ensemble.Scale >= EnsembleScale.Skirmish;
|
||||||
|
if (!sidedMulti)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Natural growth: pair still owns a living scrap inside a sided multi.
|
||||||
|
// Attack-target gaps on one partner must not block Duel → Mass/Battle.
|
||||||
|
return pairEngaged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyNamedPairHold(
|
private static void ApplyNamedPairHold(
|
||||||
|
|
@ -2183,8 +2215,11 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop ambient species/kingdom sticky that leaked in during cluster Refresh.
|
// Drop ambient species/kingdom sticky that leaked in during cluster Refresh,
|
||||||
|
// but never wipe a real multi camp roster (3+ enrolled) while holding a Duel tip.
|
||||||
|
int enrolled = scene.CombatSideACount + scene.CombatSideBCount;
|
||||||
if (HasStickyCombatSides(scene)
|
if (HasStickyCombatSides(scene)
|
||||||
|
&& enrolled < 3
|
||||||
&& (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies
|
&& (scene.CombatSideFrame == EnsembleFrame.SpeciesVsSpecies
|
||||||
|| scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom))
|
|| scene.CombatSideFrame == EnsembleFrame.KingdomVsKingdom))
|
||||||
{
|
{
|
||||||
|
|
@ -3487,6 +3522,15 @@ public static class InterestDirector
|
||||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
|
||||||
|
if (IsCombatPartnerSwapNoise(_current, candidate))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record(
|
||||||
|
"combat_partner_hold",
|
||||||
|
$"cur={_current.Key} next={candidate.Key}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
float now = Time.unscaledTime;
|
float now = Time.unscaledTime;
|
||||||
bool inGrace = InQuietGrace;
|
bool inGrace = InQuietGrace;
|
||||||
if (inGrace)
|
if (inGrace)
|
||||||
|
|
@ -3826,14 +3870,21 @@ public static class InterestDirector
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combat / hatch share key prefixes per subject.
|
// Hatch: same subject beat may refresh without full margin.
|
||||||
if (!string.IsNullOrEmpty(current.Key) && !string.IsNullOrEmpty(next.Key))
|
if (!string.IsNullOrEmpty(current.Key)
|
||||||
|
&& !string.IsNullOrEmpty(next.Key)
|
||||||
|
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
|
||||||
{
|
{
|
||||||
if (SameKeyPrefix(current.Key, next.Key, "combat:")
|
return true;
|
||||||
|| SameKeyPrefix(current.Key, next.Key, "hatch:"))
|
}
|
||||||
{
|
|
||||||
return true;
|
// Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
|
||||||
}
|
// Different combat:pair keys used to soft-cut and thrash Duel partners.
|
||||||
|
if (current.Completion == InterestCompletionKind.CombatActive
|
||||||
|
&& next.Completion == InterestCompletionKind.CombatActive
|
||||||
|
&& IsSameCombatPairArc(current, next))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.Completion == InterestCompletionKind.HappinessGrief
|
if (current.Completion == InterestCompletionKind.HappinessGrief
|
||||||
|
|
@ -3847,6 +3898,164 @@ public static class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
|
||||||
|
{
|
||||||
|
if (current == null || next == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(current.Key)
|
||||||
|
&& !string.IsNullOrEmpty(next.Key)
|
||||||
|
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectCombatActorIds(current, out long curA, out long curB);
|
||||||
|
CollectCombatActorIds(next, out long nextA, out long nextB);
|
||||||
|
bool samePair = curA != 0
|
||||||
|
&& curB != 0
|
||||||
|
&& nextA != 0
|
||||||
|
&& nextB != 0
|
||||||
|
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
|
||||||
|
if (samePair)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duel → Mass/Battle escalate: shared fighter + larger theater.
|
||||||
|
bool nextMulti = next.ParticipantCount >= 3
|
||||||
|
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| HasStickyCollectiveMulti(next);
|
||||||
|
if (!nextMulti)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SharesCombatFighterId(current, nextA)
|
||||||
|
|| SharesCombatFighterId(current, nextB)
|
||||||
|
|| SharesCombatFighterId(next, curA)
|
||||||
|
|| SharesCombatFighterId(next, curB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
|
||||||
|
{
|
||||||
|
if (current == null
|
||||||
|
|| next == null
|
||||||
|
|| current.Completion != InterestCompletionKind.CombatActive
|
||||||
|
|| next.Completion != InterestCompletionKind.CombatActive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi escalate / different theater is not partner-swap noise.
|
||||||
|
bool nextMulti = next.ParticipantCount >= 3
|
||||||
|
|| HasStickyCollectiveMulti(next)
|
||||||
|
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& next.ParticipantCount > 2);
|
||||||
|
if (nextMulti && !IsThinPairCombat(next))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsThinPairCombat(next))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectCombatActorIds(current, out long curA, out long curB);
|
||||||
|
CollectCombatActorIds(next, out long nextA, out long nextB);
|
||||||
|
if (curA == 0 && curB == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact same unordered pair is same scene (registry key should match) - not noise.
|
||||||
|
if (curA != 0
|
||||||
|
&& curB != 0
|
||||||
|
&& nextA != 0
|
||||||
|
&& nextB != 0
|
||||||
|
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared fighter + different partner = attack_target thrash.
|
||||||
|
return SharesCombatFighterId(current, nextA)
|
||||||
|
|| SharesCombatFighterId(current, nextB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsThinPairCombat(InterestCandidate c)
|
||||||
|
{
|
||||||
|
if (c == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string tip = c.Label ?? "";
|
||||||
|
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = c.Key ?? "";
|
||||||
|
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|
||||||
|
|| (key.StartsWith("combat:", StringComparison.Ordinal)
|
||||||
|
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
|
||||||
|
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
|
||||||
|
{
|
||||||
|
a = 0;
|
||||||
|
b = 0;
|
||||||
|
if (c == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
|
||||||
|
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
|
||||||
|
if (a == 0)
|
||||||
|
{
|
||||||
|
a = EventFeedUtil.SafeId(c.FollowUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b == 0)
|
||||||
|
{
|
||||||
|
b = EventFeedUtil.SafeId(c.RelatedUnit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SharesCombatFighterId(InterestCandidate c, long id)
|
||||||
|
{
|
||||||
|
if (c == null || id == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectCombatActorIds(c, out long a, out long b);
|
||||||
|
return id == a || id == b;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool SameKeyPrefix(string a, string b, string prefix)
|
private static bool SameKeyPrefix(string a, string b, string prefix)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
|
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
|
||||||
|
|
@ -3980,6 +4189,12 @@ public static class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same scene object: maintain already owns focus - do not re-Watch / re-log.
|
||||||
|
if (next == _current)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!EventPresentability.WouldShow(next)
|
if (!EventPresentability.WouldShow(next)
|
||||||
&& !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(next.Source, "harness", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -180,13 +180,21 @@ public static class InterestRegistry
|
||||||
existing.Position = incoming.Position;
|
existing.Position = incoming.Position;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incoming.RelatedUnit != null)
|
if (incoming.RelatedUnit != null && incoming.RelatedUnit.isAlive())
|
||||||
{
|
{
|
||||||
existing.RelatedUnit = incoming.RelatedUnit;
|
long incomingRelatedId = EventFeedUtil.SafeId(incoming.RelatedUnit);
|
||||||
existing.RelatedId = incoming.RelatedId;
|
// Pair-locked Duels keep the locked partner across attack_target upserts.
|
||||||
if (existing.PairPartnerId == 0)
|
bool adoptRelated = !pairLocked
|
||||||
|
|| existing.PairPartnerId == 0
|
||||||
|
|| incomingRelatedId == existing.PairPartnerId;
|
||||||
|
if (adoptRelated)
|
||||||
{
|
{
|
||||||
existing.PairPartnerId = incoming.RelatedId;
|
existing.RelatedUnit = incoming.RelatedUnit;
|
||||||
|
existing.RelatedId = incoming.RelatedId;
|
||||||
|
if (existing.PairPartnerId == 0 && incomingRelatedId != 0)
|
||||||
|
{
|
||||||
|
existing.PairPartnerId = incomingRelatedId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "IdleSpectator",
|
"name": "IdleSpectator",
|
||||||
"author": "dazed",
|
"author": "dazed",
|
||||||
"version": "0.28.18",
|
"version": "0.28.21",
|
||||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||||
"GUID": "com.dazed.idlespectator"
|
"GUID": "com.dazed.idlespectator"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue