A ghost is dead to the attack check, the action bar, the cast ladder and the cursor (#389)

A ghost could swing, got a lit action bar, a sword cursor over enemies, and an Attack button that did not refuse. benilla tested death as health 0 only, and a released ghost has health 1.

Fixes #246. Fixes #248. Fixes #250. Fixes #251.

- **One predicate** (`0x605f30`): dead or ghost is health 0 or below, or a player with the `PLAYER_FLAGS` ghost bit `0x10`. It lives on the object fields and every site uses it: the attack check (`0x612e10`), the usable walk's first check (`0x6e3dad`), summon, trade and the death screen.
- **#248:** the cast ladder refuses a dead or ghost caster with `SPELL_FAILED_CASTER_DEAD` and sends nothing, unless the spell's `Attributes` has `0x800000` (`0x6e4c49`-`0x6e4c68`); the Attack action button takes it first ("You are dead").
- **#251:** the sword cursor also needs a live, unmounted, unpacified player in control (`0x482648`-`0x4826cb`). A right-click now decides attack from the reference's CanInteract/CanAttack fork (`0x60c18c`-`0x60c247`), not from the cursor kind, so a ghost right-clicking a hostile NPC with a service flag selects it and does nothing else, instead of opening its window.

Tests: seven tests, each with a ghost at health 1; `scripts/check.sh` is green.

To see it: release spirit. The action bar stays grey; Attack Target reads "Can't attack while dead."; hovering a hostile mob shows the pointer; right-clicking a hostile vendor selects it and opens nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-26 07:23:28 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 459a9a5697
commit b8a32a06e7
14 changed files with 369 additions and 73 deletions
@@ -325,6 +325,11 @@ impl ObjectFields {
pub fn player_is_ghost(&self) -> bool {
self.player_flags() & 0x10 != 0
}
/// Dead or a ghost, the reference's `0x605f30`: health 0, or for a player the ghost flag. Not
/// feign death, which is `unit_reads_dead`; a creature has no `PLAYER_FLAGS`, so reads clear.
pub fn is_dead_or_ghost(&self) -> bool {
self.unit_is_dead() || self.player_is_ghost()
}
/// `PLAYER_FLAGS_HIDE_HELM` (`0x400`, `Player.h:325`), set by `CMSG_TOGGLE_HELM`; public.
pub fn player_hides_helm(&self) -> bool {
self.player_flags() & 0x400 != 0
@@ -755,6 +755,33 @@ fn dynamicobject_fields_read_the_live_blizzard_capture() {
);
}
/// `0x605f30`: health 0 (`0x605f3b`), or `PLAYER_FLAGS & 0x10` (`0x605f59`); a released ghost has
/// health 1, so only the flag catches it, and feign death is neither.
#[test]
fn dead_or_ghost_is_health_zero_or_the_ghost_flag() {
let unit = |pairs: &[(u16, u32)]| ObjectFields::from_pairs(pairs);
let alive = unit(&[(FIELD_UNIT_HEALTH, 100), (FIELD_UNIT_MAXHEALTH, 100)]);
let corpse = unit(&[(FIELD_UNIT_HEALTH, 0), (FIELD_UNIT_MAXHEALTH, 100)]);
let ghost = unit(&[
(FIELD_UNIT_HEALTH, 1),
(FIELD_UNIT_MAXHEALTH, 100),
(FIELD_PLAYER_FLAGS, 0x10),
]);
let feigning = unit(&[
(FIELD_UNIT_HEALTH, 100),
(FIELD_UNIT_MAXHEALTH, 100),
(FIELD_UNIT_DYNAMIC_FLAGS, 0x20),
]);
assert!(!alive.is_dead_or_ghost());
assert!(corpse.is_dead_or_ghost());
assert!(!ghost.unit_is_dead(), "a ghost has health");
assert!(ghost.is_dead_or_ghost(), "the ghost-flag leg");
assert!(
!feigning.is_dead_or_ghost(),
"feign death is 0x605f90's, not this"
);
}
/// `Unit::SetFeignDeath` sets only `UNIT_DYNFLAG_DEAD`: the raw predicates stay false, the
/// reads-dead ones (`0x605f90`, `UnitHealth`, `UnitMana`) flip, and the maxima stay.
#[test]