fix(combat): synchronize auto-target presentation

Make the retained toolbar consume retail's payload-free selection notice from canonical live state so a reentrant Auto Target replacement cannot be erased by the outer clear. Restrict automatic acquisition to the requested hostile non-player monster policy and record the intentional PK edge divergence.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-12 20:26:00 +02:00
parent 8be933fc94
commit 0f2d98c501
11 changed files with 225 additions and 7 deletions

View file

@ -171,6 +171,38 @@ public class SelectedObjectControllerTests
Assert.Empty(textChild.LinesProvider());
}
[Fact]
public void ReentrantAutoTargetNotice_RendersCurrentReplacementNotStaleClear()
{
const uint Dead = 0xAA00u;
const uint Replacement = 0xAA01u;
var (layout, nameEl, _, _) = FakeLayout();
var h = new Harness();
h.NameMap[Dead] = "Dead Drudge";
h.NameMap[Replacement] = "Drudge Prowler";
h.StackMap[Dead] = 1u;
h.StackMap[Replacement] = 1u;
h.Selection.Select(Dead, SelectionChangeSource.World);
// CombatTargetController is registered before the retained toolbar in
// production. Its clear handler selects a replacement reentrantly.
h.Selection.Changed += transition =>
{
if (transition.SelectedObjectId is null)
h.Selection.Select(Replacement, SelectionChangeSource.System);
};
h.Bind(layout);
h.Selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.CombatTargetDied);
Assert.Equal(Replacement, h.Selection.SelectedObjectId);
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
Assert.Single(lines);
Assert.Equal("Drudge Prowler", lines[0].Text);
}
// ── H1: Select a health target — meter does NOT show on select alone ─────
[Fact]

View file

@ -0,0 +1,46 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatTargetPolicyTests
{
private const uint PlayerId = 0x50000001u;
private static ClientObject Creature(
uint id,
uint flags = 0,
uint petOwner = 0) => new()
{
ObjectId = id,
Type = ItemType.Creature,
PublicWeenieBitfield = flags,
PetOwnerId = petOwner,
};
[Fact]
public void AutomaticAcquisition_AcceptsOnlyAttackableNonPlayerMonster()
{
var player = Creature(PlayerId, SelectedObjectHealthPolicy.BfPlayer);
var monster = Creature(0x50000010u, SelectedObjectHealthPolicy.BfAttackable);
var friendlyNpc = Creature(0x50000011u);
var pet = Creature(0x50000012u, petOwner: PlayerId);
var hostilePlayer = Creature(
0x50000013u,
SelectedObjectHealthPolicy.BfPlayer
| SelectedObjectHealthPolicy.BfPlayerKiller);
player.PublicWeenieBitfield |= SelectedObjectHealthPolicy.BfPlayerKiller;
var attackableDoor = new ClientObject
{
ObjectId = 0x50000014u,
Type = ItemType.Misc,
PublicWeenieBitfield = SelectedObjectHealthPolicy.BfAttackable,
};
Assert.True(CombatTargetPolicy.IsHostileMonster(PlayerId, player, monster));
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, friendlyNpc));
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, pet));
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, hostilePlayer));
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, attackableDoor));
}
}