fix(combat): #298 — admit player targets to melee/missile attack and the camera

Selecting a PKLite player and attacking did nothing: with auto-target on it
retargeted to the nearest monster, with auto-target off it logged
"combat: attack ignored; no creature target found". Spells on the same target
worked, which was the clue.

Root cause: CombatTargetPolicy.IsHostileMonster:31-33 rejects any candidate
carrying BfPlayer BEFORE reaching ObjectIsAttackable, so the both-PKLite pool
match at SelectedObjectHealthPolicy.cs:70-71 was unreachable for players. Melee
and missile targeting never supported player targets at all — the gate is named
IsHostileMonster and does exactly what it says. Nobody could hit it until
69ba9486 made PK Lite reachable.

Retail uses ONE predicate for monsters and players, with no player exclusion:
ClientCombatSystem::ExecuteAttack @0x0056BB70 gates unconditionally on
ObjectIsAttackable @0x0056A600 (creature type, Free-PK short-circuit on either
side, then IsPlayer -> bothPK || bothPKLite, else BF_ATTACKABLE with pets
excluded). acdream already ported that predicate verbatim; it was simply
unreachable.

The fix SPLITS the two concerns rather than relaxing the shared predicate:
explicit-target admission routes through ObjectIsAttackable, while auto-target
ACQUISITION keeps the monster-only gate. That is required by register row
IA-19 — explicit product direction that Auto Target must never select NPCs,
players or pets. IA-19 is not overridden here; its own justification promises
"manual player-selection commands remain available", and that promise was never
implemented, so this makes the row true. Review confirmed no path lets
auto-acquisition select a player: every automatic Select is fed by a
FindClosest* filtered through IsHostileMonster.

Review also found a second site with the same bug, which the first pass froze in
place on my instruction: retail gates combat-camera tracking on the SAME
predicate as the attack. ClientCombatSystem::UpdateTargetTracking @0x0056A950
reads GetAttackTarget() then gates CameraSet::TrackTarget on ObjectIsAttackable.
Ours used the monster-only gate, so with ViewCombatTarget on by default the
attack would land while the camera refused to track the opponent — user-visible
in exactly the duel this fix enables. GetCombatCameraTargetPoint now uses the
wide predicate. IA-19 does not reach the camera: it performs no acquisition,
only presentation on an already-chosen target. The first pass had added a
source comment asserting IA-19 covered it; that comment and the matching text in
docs/ISSUES.md are corrected, since a wrong citation is how a real divergence
becomes invisible.

Depends on 9b1e6fc6 (#297): the both-PKLite arm needs the LOCAL player's own bit
to be live. Review confirmed both admission sites read ClientObjectTable on every
call, so this is not inert in production.

Newly reachable and now pinned: ObjectIsAttackable's pet-exclusion arm, which
CombatTargetPolicy rejected before it could ever run.

Follow-ups filed: #304 (SelectionInteractionController.GetSelectedOrClosestCombatTarget
has no production caller — one of the two widened call sites is dead code),
#305 (HeadlessGameplayOperations has the identical pre-existing bug, so the
graphical/headless hosts now diverge).

Gates: complete Release solution 10,904 passed / 4 skipped / 0 failed (baseline
10,900). Adversarial + retail-conformance review PASS after one FAIL round; the
predicate was re-verified branch-for-branch against 0x0056A600 since it goes
live here for the first time. Camera fix discrimination-verified by revert.
Connected acceptance NOT run — needs a live two-client PKLite session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 21:29:15 +02:00
parent 9b1e6fc637
commit bc0077a55f
11 changed files with 410 additions and 25 deletions

View file

@ -500,19 +500,31 @@ it. Do #297 FIRST — #298 depends on it.
`BF_ATTACKABLE` with pets excluded. We already have that predicate ported `BF_ATTACKABLE` with pets excluded. We already have that predicate ported
verbatim and correctly at `SelectedObjectHealthPolicy.cs:41-78` — it is verbatim and correctly at `SelectedObjectHealthPolicy.cs:41-78` — it is
simply unreachable. simply unreachable.
**DO NOT fix by relaxing the shared predicate.** `IsHostileMonster` also **DO NOT fix by relaxing `IsHostileMonster`'s automatic-acquisition
backs auto-target ACQUISITION (`CombatAttackTargetSource.cs:80`, scope.** `IsHostileMonster` also backs auto-target ACQUISITION
`WorldSelectionQuery.cs:255`) and the combat camera (`CombatAttackTargetSource.cs:80`, `WorldSelectionQuery.cs:280`), and
(`WorldSelectionQuery.cs:264-269`), and relaxing it would violate register row relaxing it would violate register row **IA-19**, explicit product
**IA-19**, explicit product direction that auto-target must never select direction that auto-target must never select NPCs, players or pets.
NPCs, players or pets. Retail's own auto-target DOES admit players Retail's own auto-target DOES admit players (@0x0056C040
(@0x0056C040 pc:377318-377327), so retail and IA-19 genuinely disagree here. pc:377318-377327), so retail and IA-19 genuinely disagree here — for
Fix shape: SPLIT explicit-target admission (-> `ObjectIsAttackable`, acquisition only.
retail-exact) from auto-acquisition (-> keep `IsHostileMonster`, IA-19 **The combat camera is NOT an IA-19 concern, despite an earlier draft of
intact). IA-19's own text already promises "manual player-selection commands this note claiming otherwise.** Retail `ClientCombatSystem::
remain available"; that promise is currently unimplemented, which is the real UpdateTargetTracking` @0x0056A950 (pc:375691-375696) gates
gap. Not affected: the health bar (`SelectedObjectHealthPolicy.cs:32` already `CameraSet::TrackTarget` on the SAME `ObjectIsAttackable` predicate as
admits `BfPlayer`) and the vivid target indicator. `ExecuteAttack` @0x0056BB98, not the narrow monster-only policy. The
camera performs no acquisition of its own — it only tracks whatever the
player already selected — so `WorldSelectionQuery.GetCombatCameraTargetPoint`
must route through the wide predicate exactly like explicit-target
admission. (Landed: `GetCombatCameraTargetPoint` now calls
`IsAttackableTarget`.)
Fix shape: SPLIT explicit-target admission AND the combat camera
(-> `ObjectIsAttackable`/`IsAttackableTarget`, retail-exact) from
auto-acquisition (-> keep `IsHostileMonster`, IA-19 intact). IA-19's own
text already promises "manual player-selection commands remain
available"; that promise was unimplemented before this fix. Not affected:
the health bar (`SelectedObjectHealthPolicy.cs:32` already admits
`BfPlayer`) and the vivid target indicator.
Correct model to copy: spells already work on PKLite players because Correct model to copy: spells already work on PKLite players because
`RetailSpellTargetPolicy.cs:40-46` treats `BF_PLAYER` as an ACCEPT and never `RetailSpellTargetPolicy.cs:40-46` treats `BF_PLAYER` as an ACCEPT and never
calls `ObjectIsAttackable` — the client checks target-TYPE compatibility and calls `ObjectIsAttackable` — the client checks target-TYPE compatibility and
@ -580,6 +592,41 @@ it. Do #297 FIRST — #298 depends on it.
`LiveEntityCollisionBuilder` and therefore no live-entity target shadows), so `LiveEntityCollisionBuilder` and therefore no live-entity target shadows), so
this is shape rather than a defect. Filed from the #297 delta review. this is shape rather than a defect. Filed from the #297 delta review.
## Follow-ups from the #298 fix and its review — 2026-08-03
- **#304 — OPEN — `SelectionInteractionController.GetSelectedOrClosestCombatTarget`
has no production caller. LOW/shape.** Grep confirms only tests reach it
(`GetSelectedOrClosestCombatTarget:114-121`); no `GameplayInputActionRouter`
or other App wiring calls it. The #298 fix widened it correctly (explicit
selection now checks `IWorldSelectionQuery.IsAttackableTarget` instead of
`IsHostileMonster`), matching `CombatAttackTargetSource`'s live path
defensively, but it currently exists only to keep the (also unused-in-
production) `IsAttackableTarget` member exercised by four `IWorldSelectionQuery`
fakes. Fix shape: delete the dead method (and, if nothing else calls
`IsAttackableTarget` through this interface after that, the interface member
and its fake stubs too) — or find the caller that was supposed to exist and
wire it. Filed from the #298 review.
- **#305 — OPEN — `HeadlessGameplayOperations.GetSelectedOrClosestTarget` has
the same player-exclusion bug #298 fixed for graphical hosts.
MEDIUM.** `src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs:235-244`
checks explicit selection via `RuntimeHostileTargetQuery.IsHostile`
(`src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs:73-101`), which is
the monster-only `CombatTargetPolicy.IsHostileMonster` gate — structurally
identical to the bug #298 fixed in `CombatAttackTargetSource`/
`SelectionInteractionController`. A headless bot that explicitly selects a
compatible-PK player and attacks will fall through to `SelectClosestTarget()`
(since `AutoTarget` is hardcoded `true` at `:128`) instead of attacking the
selected player. Pre-existing (not introduced by #298 — confirmed by the
same `9966b531`/`3361a8d7`/`2644d1d5`/`0f2d98c5` diff boundary #297 used), but
the graphical/headless behavioral *divergence* is new as of the #298 commit,
and Slice K makes headless a first-class host, so the gap is now live for bot
PvP. Fix shape: same split as #298 — add an `ObjectIsAttackable`-backed
explicit-admission query to `RuntimeHostileTargetQuery` (or a sibling) and
route `GetSelectedOrClosestTarget`'s explicit branch through it, leaving
`FindClosest`/auto-acquisition on the narrow policy. Filed from the #298
review.
## C3c placement cutover — 2026-08-02 ## C3c placement cutover — 2026-08-02
- **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved - **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved

View file

@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`) | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target — do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
| IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` | | IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` |

View file

@ -38,7 +38,7 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{ {
if (_selection.SelectedObjectId is { } selected if (_selection.SelectedObjectId is { } selected
&& IsHostileMonster(selected)) && IsAttackableExplicitTarget(selected))
{ {
return selected; return selected;
} }
@ -89,8 +89,52 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
return best; return best;
} }
/// <summary>
/// Automatic-acquisition eligibility (register row IA-19): narrowed to
/// non-player, non-pet, attackable monsters. Backs
/// <see cref="FindClosestHostileMonster"/> only — never explicit
/// selection.
/// </summary>
private bool IsHostileMonster(uint serverGuid) private bool IsHostileMonster(uint serverGuid)
{ {
if (!TryGetLiveCombatCandidate(serverGuid, out ClientObject? candidate))
return false;
uint playerGuid = _player.ServerGuid;
return (candidate.Type & ItemType.Creature) != 0
&& CombatTargetPolicy.IsHostileMonster(
playerGuid,
_objects.Get(playerGuid),
candidate);
}
/// <summary>
/// Explicit-target admission for a user-issued attack (#298). Retail
/// <c>ClientCombatSystem::ExecuteAttack @ 0x0056BB70</c> gates
/// unconditionally on <c>ObjectIsAttackable @ 0x0056A600</c>, with no
/// player exclusion — a compatible-PK player is a valid attack target.
/// This is deliberately a different, wider predicate than
/// <see cref="IsHostileMonster"/>, which stays narrowed to monsters for
/// automatic acquisition per register row IA-19.
/// </summary>
private bool IsAttackableExplicitTarget(uint serverGuid)
{
if (!TryGetLiveCombatCandidate(serverGuid, out ClientObject? candidate))
return false;
uint playerGuid = _player.ServerGuid;
return SelectedObjectHealthPolicy.ObjectIsAttackable(
playerGuid,
_objects.Get(playerGuid),
serverGuid,
candidate);
}
private bool TryGetLiveCombatCandidate(
uint serverGuid,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ClientObject? candidate)
{
candidate = null;
uint playerGuid = _player.ServerGuid; uint playerGuid = _player.ServerGuid;
if (serverGuid == playerGuid if (serverGuid == playerGuid
|| !_liveEntities.TryGetInteractionEligibleRecord( || !_liveEntities.TryGetInteractionEligibleRecord(
@ -107,12 +151,7 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
return false; return false;
} }
ClientObject? candidate = _objects.Get(serverGuid); candidate = _objects.Get(serverGuid);
return candidate is not null return candidate is not null;
&& (candidate.Type & ItemType.Creature) != 0
&& CombatTargetPolicy.IsHostileMonster(
playerGuid,
_objects.Get(playerGuid),
candidate);
} }
} }

View file

@ -104,10 +104,17 @@ internal sealed class SelectionInteractionController
_items.PlaceIn3D(payload, target); _items.PlaceIn3D(payload, target);
} }
/// <summary>
/// Explicit selection uses the wider <c>ObjectIsAttackable</c>-backed
/// admission (#298) so a compatible-PK player is a valid attack target;
/// falling back to auto-acquisition still uses the narrower
/// <see cref="IWorldSelectionQuery.IsHostileMonster"/> monster-only
/// policy (register row IA-19).
/// </summary>
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{ {
if (_selection.SelectedObjectId is { } selected if (_selection.SelectedObjectId is { } selected
&& _query.IsHostileMonster(selected)) && _query.IsAttackableTarget(selected))
{ {
return selected; return selected;
} }

View file

@ -44,6 +44,7 @@ internal interface IWorldSelectionQuery
string Describe(uint serverGuid); string Describe(uint serverGuid);
bool IsCreature(uint serverGuid); bool IsCreature(uint serverGuid);
bool IsHostileMonster(uint serverGuid); bool IsHostileMonster(uint serverGuid);
bool IsAttackableTarget(uint serverGuid);
ClosestCombatTarget? FindClosestHostileMonster(); ClosestCombatTarget? FindClosestHostileMonster();
bool IsUseable(uint serverGuid); bool IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid); bool IsPickupable(uint serverGuid);
@ -229,6 +230,14 @@ internal sealed class WorldSelectionQuery
return (GetItemType(serverGuid) & ItemType.Creature) != 0; return (GetItemType(serverGuid) & ItemType.Creature) != 0;
} }
/// <summary>
/// Automatic-acquisition eligibility (register row IA-19): narrowed to
/// non-player, non-pet, attackable monsters. Backs
/// <see cref="FindClosestHostileMonster"/> only — never explicit
/// selection, and never the combat camera (retail gates camera tracking
/// on <c>ObjectIsAttackable</c>, not this narrower policy — see
/// <see cref="GetCombatCameraTargetPoint"/>).
/// </summary>
public bool IsHostileMonster(uint serverGuid) public bool IsHostileMonster(uint serverGuid)
=> IsCreature(serverGuid) => IsCreature(serverGuid)
&& CombatTargetPolicy.IsHostileMonster( && CombatTargetPolicy.IsHostileMonster(
@ -236,6 +245,27 @@ internal sealed class WorldSelectionQuery
_objects.Get(_playerGuid()), _objects.Get(_playerGuid()),
_objects.Get(serverGuid)); _objects.Get(serverGuid));
/// <summary>
/// Explicit-target admission for a user-issued attack (#298), and the
/// combat camera's tracking gate. Retail
/// <c>ClientCombatSystem::ExecuteAttack @ 0x0056BB70</c> and
/// <c>UpdateTargetTracking @ 0x0056A950</c> both gate unconditionally on
/// <c>ObjectIsAttackable @ 0x0056A600</c>, with no player exclusion — a
/// compatible-PK player is a valid attack target and a valid camera
/// target. This is deliberately a different, wider predicate than
/// <see cref="IsHostileMonster"/>, which stays narrowed to monsters for
/// AUTOMATIC ACQUISITION ONLY per register row IA-19 — IA-19 does not
/// reach explicit selection or the camera (a presentation gate on an
/// already-chosen target, not an acquisition path).
/// </summary>
public bool IsAttackableTarget(uint serverGuid)
=> IsCreature(serverGuid)
&& SelectedObjectHealthPolicy.ObjectIsAttackable(
_playerGuid(),
_objects.Get(_playerGuid()),
serverGuid,
_objects.Get(serverGuid));
public bool ShouldShowHealth(uint serverGuid) public bool ShouldShowHealth(uint serverGuid)
=> SelectedObjectHealthPolicy.ShouldQueryHealth( => SelectedObjectHealthPolicy.ShouldQueryHealth(
_playerGuid(), _playerGuid(),
@ -261,8 +291,18 @@ internal sealed class WorldSelectionQuery
return best; return best;
} }
/// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
/// on <c>ObjectIsAttackable @ 0x0056A600</c> — the SAME wide predicate as
/// <c>ExecuteAttack</c>, not the narrower automatic-acquisition policy.
/// The camera performs no acquisition of its own; it only tracks a
/// target the player already selected, so routing it through
/// <see cref="IsAttackableTarget"/> does not touch register row IA-19
/// (which scopes itself to automatic acquisition).
/// </summary>
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
=> IsHostileMonster(serverGuid) => IsAttackableTarget(serverGuid)
&& TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target) && TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
? target.Entity.Position ? target.Entity.Position
+ Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation) + Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation)

View file

@ -32,6 +32,142 @@ public sealed class CombatAttackTargetSourceTests
harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false));
} }
/// <summary>
/// #298: explicit selection of a compatible-PK player is a valid attack
/// target. Retail <c>ObjectIsAttackable @ 0x0056A600</c> admits a player
/// target when both sides carry the same PKLite status, and this is
/// reachable ONLY through explicit selection — never automatic
/// acquisition (register row IA-19).
/// </summary>
[Fact]
public void ExplicitSelectedPkLitePlayerIsAcceptedWithoutAutoTarget()
{
var harness = new Harness();
harness.SetLocalPlayerPvpFlags(SelectedObjectHealthPolicy.BfPkLiteStatus);
const uint target = 0x7000_0002u;
harness.Add(
target,
new Vector3(2f, 0f, 0f),
attackable: false,
isPlayer: true,
extraFlags: SelectedObjectHealthPolicy.BfPkLiteStatus);
harness.Selection.Select(target, SelectionChangeSource.Keyboard);
Assert.Equal(
target,
harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false));
}
/// <summary>
/// #298: explicit selection of a player whose PK pool does not match
/// (neither side is PKLite/PK/Free) is still refused — the fix widens
/// admission to compatible-PK players, it does not admit every player.
/// </summary>
[Fact]
public void ExplicitSelectedNonPkPlayerIsRefused()
{
var harness = new Harness();
const uint target = 0x7000_0003u;
harness.Add(
target,
new Vector3(2f, 0f, 0f),
attackable: false,
isPlayer: true);
harness.Selection.Select(target, SelectionChangeSource.Keyboard);
Assert.Null(
harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false));
}
/// <summary>
/// #298 follow-up: explicit selection no longer short-circuits on
/// <c>PetOwnerId != 0</c> the way <c>CombatTargetPolicy.IsHostileMonster</c>
/// does (`:33`) — it now reaches <c>ObjectIsAttackable</c>'s OWN pet arm
/// (retail `else if (esi->pwd._pet_owner == 0)` @ 0x0056a683) for the
/// first time. That arm still refuses an attackable, owned pet, so the
/// end behavior is unchanged; this pins reachability through the new
/// path, not just the Core predicate (see
/// <c>SelectedObjectHealthPolicyTests.ObjectIsAttackable_AttackablePetIsRejected</c>).
/// </summary>
[Fact]
public void ExplicitSelectedAttackablePetIsRefused()
{
var harness = new Harness();
const uint pet = 0x7000_0007u;
harness.Add(
pet,
new Vector3(2f, 0f, 0f),
attackable: true,
petOwnerId: Player);
harness.Selection.Select(pet, SelectionChangeSource.Keyboard);
Assert.Null(
harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false));
}
/// <summary>
/// Mutation guard for <see cref="CombatAttackTargetSource.FindClosestHostileMonster"/>:
/// with nothing explicitly selected, auto-acquisition must still filter
/// the nearer compatible-PK player out and pick the farther monster. This
/// does NOT exercise the explicit-selection branch (nothing is selected),
/// so it does not by itself prove the #298 split kept
/// <see cref="CombatTargetPolicy.IsHostileMonster"/> narrow — it proves
/// nobody swapped <c>FindClosestHostileMonster</c>'s gate for the wider
/// predicate. See <see cref="AutoTargetNeverAcquiresAPlayerWhenNoMonsterIsInRange"/>
/// for the case that actually forces the narrow policy to reject the
/// only candidate.
/// </summary>
[Fact]
public void AutoTargetNeverAcquiresAPlayerOverAMonster()
{
var harness = new Harness();
harness.SetLocalPlayerPvpFlags(SelectedObjectHealthPolicy.BfPkLiteStatus);
const uint monster = 0x7000_0004u;
const uint pkLitePlayer = 0x7000_0005u;
harness.Add(monster, new Vector3(8f, 0f, 0f), attackable: true);
harness.Add(
pkLitePlayer,
new Vector3(1f, 0f, 0f),
attackable: false,
isPlayer: true,
extraFlags: SelectedObjectHealthPolicy.BfPkLiteStatus);
uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget(
autoTarget: true);
Assert.Equal(monster, selected);
Assert.Equal(monster, harness.Selection.SelectedObjectId);
}
/// <summary>
/// The actual IA-19 invariant: with nothing selected and NO monster in
/// range at all, a nearby compatible-PK player must still be rejected by
/// automatic acquisition — <c>FindClosestHostileMonster</c> filters
/// through the narrow <see cref="CombatTargetPolicy.IsHostileMonster"/>,
/// finds no candidate, and the stale selection is cleared, exactly as
/// retail's IA-19 divergence documents (auto-target never acquires a
/// player even though retail's own <c>AutoTarget</c> would).
/// </summary>
[Fact]
public void AutoTargetNeverAcquiresAPlayerWhenNoMonsterIsInRange()
{
var harness = new Harness();
harness.SetLocalPlayerPvpFlags(SelectedObjectHealthPolicy.BfPkLiteStatus);
const uint pkLitePlayer = 0x7000_0006u;
harness.Add(
pkLitePlayer,
new Vector3(1f, 0f, 0f),
attackable: false,
isPlayer: true,
extraFlags: SelectedObjectHealthPolicy.BfPkLiteStatus);
uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget(
autoTarget: true);
Assert.Null(selected);
Assert.Null(harness.Selection.SelectedObjectId);
}
[Fact] [Fact]
public void AutoTargetUsesNearestEligibleLiveHostile() public void AutoTargetUsesNearestEligibleLiveHostile()
{ {
@ -107,11 +243,22 @@ public sealed class CombatAttackTargetSourceTests
Add(Player, Vector3.Zero, attackable: false, isPlayer: true); Add(Player, Vector3.Zero, attackable: false, isPlayer: true);
} }
public void SetLocalPlayerPvpFlags(uint extraFlags) =>
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = $"Object {Player:X8}",
Type = ItemType.Creature,
PublicWeenieBitfield = SelectedObjectHealthPolicy.BfPlayer | extraFlags,
});
public WorldEntity Add( public WorldEntity Add(
uint guid, uint guid,
Vector3 position, Vector3 position,
bool attackable, bool attackable,
bool isPlayer = false) bool isPlayer = false,
uint extraFlags = 0u,
uint petOwnerId = 0u)
{ {
Runtime.RegisterLiveEntity(Spawn(guid)); Runtime.RegisterLiveEntity(Spawn(guid));
WorldEntity entity = Runtime.MaterializeLiveEntity( WorldEntity entity = Runtime.MaterializeLiveEntity(
@ -130,12 +277,14 @@ public sealed class CombatAttackTargetSourceTests
uint flags = attackable ? SelectedObjectHealthPolicy.BfAttackable : 0u; uint flags = attackable ? SelectedObjectHealthPolicy.BfAttackable : 0u;
if (isPlayer) if (isPlayer)
flags |= SelectedObjectHealthPolicy.BfPlayer; flags |= SelectedObjectHealthPolicy.BfPlayer;
flags |= extraFlags;
Objects.AddOrUpdate(new ClientObject Objects.AddOrUpdate(new ClientObject
{ {
ObjectId = guid, ObjectId = guid,
Name = $"Object {guid:X8}", Name = $"Object {guid:X8}",
Type = ItemType.Creature, Type = ItemType.Creature,
PublicWeenieBitfield = flags, PublicWeenieBitfield = flags,
PetOwnerId = petOwnerId,
}); });
return entity; return entity;
} }

View file

@ -57,6 +57,7 @@ public sealed class CombatCameraTargetSourceTests
public string Describe(uint serverGuid) => string.Empty; public string Describe(uint serverGuid) => string.Empty;
public bool IsCreature(uint serverGuid) => false; public bool IsCreature(uint serverGuid) => false;
public bool IsHostileMonster(uint serverGuid) => false; public bool IsHostileMonster(uint serverGuid) => false;
public bool IsAttackableTarget(uint serverGuid) => false;
public ClosestCombatTarget? FindClosestHostileMonster() => null; public ClosestCombatTarget? FindClosestHostileMonster() => null;
public bool IsUseable(uint serverGuid) => false; public bool IsUseable(uint serverGuid) => false;
public bool IsPickupable(uint serverGuid) => false; public bool IsPickupable(uint serverGuid) => false;

View file

@ -30,6 +30,7 @@ public sealed class SelectionInteractionControllerTests
public bool Current { get; set; } = true; public bool Current { get; set; } = true;
public bool Creature { get; set; } public bool Creature { get; set; }
public bool Hostile { get; set; } public bool Hostile { get; set; }
public bool Attackable { get; set; }
public bool Useable { get; set; } = true; public bool Useable { get; set; } = true;
public bool Pickupable { get; set; } = true; public bool Pickupable { get; set; } = true;
public bool WieldedByPlayer { get; set; } public bool WieldedByPlayer { get; set; }
@ -66,6 +67,7 @@ public sealed class SelectionInteractionControllerTests
public string Describe(uint serverGuid) => $"Target {serverGuid:X8}"; public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
public bool IsCreature(uint serverGuid) => Creature; public bool IsCreature(uint serverGuid) => Creature;
public bool IsHostileMonster(uint serverGuid) => Hostile; public bool IsHostileMonster(uint serverGuid) => Hostile;
public bool IsAttackableTarget(uint serverGuid) => Attackable;
public ClosestCombatTarget? FindClosestHostileMonster() => Closest; public ClosestCombatTarget? FindClosestHostileMonster() => Closest;
public bool IsUseable(uint serverGuid) => Useable; public bool IsUseable(uint serverGuid) => Useable;
public bool IsPickupable(uint serverGuid) => Pickupable; public bool IsPickupable(uint serverGuid) => Pickupable;
@ -1040,6 +1042,44 @@ public sealed class SelectionInteractionControllerTests
Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _)); Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _));
} }
/// <summary>
/// #298: explicit-target admission routes through the wider
/// <c>IsAttackableTarget</c> query (retail <c>ObjectIsAttackable</c>,
/// which admits a compatible-PK player), not the narrower
/// <c>IsHostileMonster</c> that auto-acquisition uses.
/// </summary>
[Fact]
public void GetSelectedOrClosestCombatTargetAcceptsExplicitAttackableEvenWhenNotHostileMonster()
{
var h = new Harness();
h.Query.Attackable = true;
h.Query.Hostile = false;
h.Selection.Select(Target, SelectionChangeSource.Keyboard);
Assert.Equal(
Target,
h.Controller.GetSelectedOrClosestCombatTarget(autoTarget: false));
}
/// <summary>
/// #298 / IA-19 guard: when the explicit selection is not an admitted
/// attack target, auto-acquisition falls back to the narrower
/// <c>FindClosestHostileMonster</c> query unchanged.
/// </summary>
[Fact]
public void GetSelectedOrClosestCombatTargetFallsBackToAutoAcquisitionWhenExplicitTargetIsNotAttackable()
{
var h = new Harness();
const uint monster = 0x7000_0099u;
h.Query.Attackable = false;
h.Query.Closest = new ClosestCombatTarget(monster, DistanceSquared: 4f);
h.Selection.Select(Target, SelectionChangeSource.Keyboard);
Assert.Equal(
monster,
h.Controller.GetSelectedOrClosestCombatTarget(autoTarget: true));
}
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
=> new( => new(
guid, guid,

View file

@ -299,6 +299,44 @@ public sealed class WorldSelectionQueryTests
Assert.Equal(64f, closest?.DistanceSquared); Assert.Equal(64f, closest?.DistanceSquared);
} }
/// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
/// on <c>ObjectIsAttackable @ 0x0056A600</c> — the SAME wide predicate as
/// <c>ExecuteAttack</c>, not the narrower monster-only
/// <c>IsHostileMonster</c>. A compatible-PK player under explicit
/// selection is a valid camera target; a non-PK player is not.
/// </summary>
[Fact]
public void CombatCameraTracksACompatiblePkPlayerButNotAnIncompatibleOne()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = $"Object {Player:X8}",
Type = ItemType.Creature,
PublicWeenieBitfield = SelectedObjectHealthPolicy.BfPlayer
| SelectedObjectHealthPolicy.BfPkLiteStatus,
});
const uint pkLiteOpponent = 0x7000_0050u;
const uint nonPkPlayer = 0x7000_0051u;
h.Add(
pkLiteOpponent,
new Vector3(2f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfPlayer
| SelectedObjectHealthPolicy.BfPkLiteStatus);
h.Add(
nonPkPlayer,
new Vector3(3f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfPlayer);
Assert.NotNull(h.Query.GetCombatCameraTargetPoint(pkLiteOpponent));
Assert.Null(h.Query.GetCombatCameraTargetPoint(nonPkPlayer));
}
[Theory] [Theory]
[InlineData(0.59f, true)] [InlineData(0.59f, true)]
[InlineData(0.61f, false)] [InlineData(0.61f, false)]

View file

@ -1181,6 +1181,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public string Describe(uint serverGuid) => "Runtime target"; public string Describe(uint serverGuid) => "Runtime target";
public bool IsCreature(uint serverGuid) => serverGuid == target; public bool IsCreature(uint serverGuid) => serverGuid == target;
public bool IsHostileMonster(uint serverGuid) => serverGuid == target; public bool IsHostileMonster(uint serverGuid) => serverGuid == target;
public bool IsAttackableTarget(uint serverGuid) => serverGuid == target;
public ClosestCombatTarget? FindClosestHostileMonster() => public ClosestCombatTarget? FindClosestHostileMonster() =>
new(target, DistanceSquared: 4f); new(target, DistanceSquared: 4f);
public bool IsUseable(uint serverGuid) => serverGuid == target; public bool IsUseable(uint serverGuid) => serverGuid == target;

View file

@ -93,4 +93,27 @@ public sealed class SelectedObjectHealthPolicyTests
Assert.False(SelectedObjectHealthPolicy.ObjectIsAttackable( Assert.False(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, null, creature.ObjectId, creature)); PlayerId, null, creature.ObjectId, creature));
} }
/// <summary>
/// #298 follow-up: this arm (retail <c>else if (esi->pwd._pet_owner ==
/// 0)</c> @ 0x0056a683 — falling through to <c>eax = 0; return 0</c> when
/// it does NOT hold) only became reachable from the App layer once
/// explicit-target admission started calling <c>ObjectIsAttackable</c>
/// directly; <c>CombatTargetPolicy.IsHostileMonster</c> rejects pets
/// earlier and never reaches it. Pin it here at the source predicate: an
/// attackable, owned pet is refused even though its
/// <c>BfAttackable</c> bit is set.
/// </summary>
[Fact]
public void ObjectIsAttackable_AttackablePetIsRejected()
{
var player = Obj(PlayerId, flags: SelectedObjectHealthPolicy.BfPlayer);
var pet = Obj(
0x50000041u,
flags: SelectedObjectHealthPolicy.BfAttackable,
petOwner: PlayerId);
Assert.False(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, player, pet.ObjectId, pet));
}
} }