ground-targeting cursor: the range verdict takes GetMinMaxRange's bounds, so a range talent (op 5) and the caster's reaches reach it (0x6e6810, 0x6e3480) (#658)

The ground-targeting cursor's range verdict compared the caster-to-point distance against the `SpellRange` row's raw min and max. A range talent therefore never reached it: with Arctic Reach, Blizzard read out of range past its base 30 yd.

**In the reference**, `CheckGroundPointInRange 0x6e6810` takes its bounds from `GetMinMaxRange` (`0x6e68b0`, null target at `0x6e6879`). That applies spell-mod op 5 to the max (`0x6e3744`) and, on a melee row, sums the caster's reach with the auto-attack target's. The compare (`0x6e68b5`-`0x6e68d3`) is inclusive at both bounds and fails on an unordered result. The GameObject leg of `SpellCanTargetUnit 0x6e6460` runs the same compare (`0x6e67d6`-`0x6e6802`) with the object as target, which reads as none.

**Changes:**
- `ground_point_in_range` takes its `(min, max)` from `SpellModifiers::min_max_range`, with no explicit target and with the caster's and the auto-attack target's reach. It compares `min² <= d² <= max²`.
- The terrain arm and the GameObject leg share one `RangeCall`, built once per frame.
- `BindChecks` gains the caster's reach and the auto-attack target's reach.
- Some ground spells do use the melee row (Earthshaker, Kadrak's Flag and a few quest spells), so the reaches are passed, not defaulted.

**Tests** (in `cursor.rs`):
- `a_range_talent_moves_the_ground_verdict`: Arctic Reach's cells on Blizzard's family bits put 33 yd in and 37 yd out, with the boundaries at 35.9 and 36.1. A family mismatch does not apply.
- `the_melee_row_reads_the_casters_reaches`.
- `the_terrain_verdict_reads_the_range_talent_and_the_casters_reaches` and `the_object_leg_reads_the_range_talent`, both through `drive_targeting_cursor`.
- The mirror test gains inclusive-bound and NaN cases.

Fixes #653

🤖 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-30 04:31:13 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 79dc262eea
commit 3a0728cde2
3 changed files with 393 additions and 32 deletions
+2 -2
View File
@@ -33,9 +33,9 @@ pub(crate) use inflight::{
inflight, ActiveChannel, AutoRepeatActive, LocalMoveStart, PendingCast, QueuedMeleeSpell,
SelfCancel, SPELL_INTERRUPT_MOVEMENT,
};
#[cfg(test)]
pub(crate) use mods::OP_COOLDOWN;
pub(crate) use mods::{ModsDiff, SpellModifiers, OP_CAST_TIME, OP_COST, OP_GCD, OP_RADIUS};
#[cfg(test)]
pub(crate) use mods::{OP_COOLDOWN, OP_RANGE};
// `TargetingWants` is exported for the ground reticle, which draws for the location word alone.
pub(crate) use targeting::{
ground_cast_radius, CorpsePick, PicksSelf, ScriptCursor, SpellTargeting, TargetingWants,
+368 -27
View File
@@ -5,31 +5,65 @@
use bevy::prelude::*;
use benilla_formats::SpellRange;
use benilla_formats::{RangeTargets, SpellDisplay, SpellRange};
use crate::net::SelfPlayer;
use crate::spell::SpellModifiers;
use crate::target::{CursorKind, PickOcclusion, WorldCursor};
use crate::ui_action::Spells;
use super::{SpellTargeting, TargetingWants};
/// `CheckGroundPointInRange 0x6e6810`: min² and max² from the `SpellRange` row against the squared
/// caster-to-point distance. Its one caller is the hover classifier `0x4820f0`, so it colours the
/// cursor and the click never asks. No row is permissive; the server judges every send.
fn ground_point_in_range(row: Option<&SpellRange>, self_pos: Vec3, point: Vec3) -> bool {
let Some(row) = row else {
return true;
};
let dist_sq = self_pos.distance_squared(point);
if row.min > 0.0 && dist_sq < row.min * row.min {
return false;
}
dist_sq <= row.max * row.max
/// What `GetMinMaxRange 0x6e3480` reads for the cursor's two range verdicts: the ground point
/// (`CheckGroundPointInRange 0x6e6810`, a null target at `0x6e6879`) and the GameObject leg of
/// `0x6e6460` (the object as target at `0x6e679b`). Neither passes a unit, which the function
/// tests for at `0x6e34e1`-`0x6e34f9`: the ranged arm pads nothing, and the melee arm sums the
/// caster's reach with its auto-attack target's.
#[derive(Clone, Copy)]
struct RangeCall<'a> {
spell: &'a SpellDisplay,
row: &'a SpellRange,
mods: &'a SpellModifiers,
self_reach: f32,
attack_target_reach: Option<f32>,
}
fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> {
let spells = spells?;
spells.ranges.get(spells.catalog.get(spell_id)?.range_index)
/// The call's inputs, or `None` where the data is absent (no catalog, an unknown spell, a row
/// missing from `SpellRange`), which is permissive: the server judges every send.
fn range_call<'a>(checks: &'a super::BindChecks, spell_id: u32) -> Option<RangeCall<'a>> {
let spells = checks.spells.as_deref()?;
let spell = spells.catalog.get(spell_id)?;
Some(RangeCall {
spell,
row: spells.ranges.get(spell.range_index)?,
mods: &checks.spell_mods,
self_reach: checks.self_reach(),
attack_target_reach: checks.attack_target_reach(),
})
}
/// `CheckGroundPointInRange 0x6e6810`: the squared caster-to-point distance against
/// `GetMinMaxRange`'s bounds, whose max carries spell-mod op 5 (`0x6e3744`), so a range talent
/// moves the verdict. The compare fails on min² > d² (`0x6e68b5`-`0x6e68c3`) and on max² < d²
/// (`0x6e68c5`-`0x6e68d3`), a NaN failing both; the min has no `> 0` guard, a zero one is inert.
/// Its one caller is the hover classifier `0x4820f0`, so it colours the cursor and the click never
/// asks.
fn ground_point_in_range(call: Option<RangeCall>, self_pos: Vec3, point: Vec3) -> bool {
let Some(call) = call else {
return true;
};
let targets = RangeTargets {
target: None,
attack_target: call.attack_target_reach,
};
// The self row is `{0, 0}` (`0x6e35dd`, `0x6e35ea`) and `min_max_range` answers `None` for it:
// only the caster's own spot passes it here.
let (min, max) = call
.mods
.min_max_range(call.spell, Some(call.row), call.self_reach, targets)
.unwrap_or_default();
let dist_sq = self_pos.distance_squared(point);
min * min <= dist_sq && dist_sq <= max * max
}
/// `GetCurrentCastRadius 0x6e6350`: per effect `radius + casterLevel × perLevel` over
@@ -81,7 +115,8 @@ pub(crate) fn ground_cast_radius(
/// its ray (`0x4812c8`) and the world cursor stays grey.
///
/// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`,
/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is
/// then the same min/max range test through `GetMinMaxRange 0x6e3480`, its range modifier included.
/// Its unit leg is
/// [`SpellTargeting::can_target_unit`] and its corpse leg [`SpellTargeting::can_target_corpse`],
/// over a corpse the pick admitted under a word in `0x8600`. The world-item leg is not built.
///
@@ -92,7 +127,6 @@ pub(crate) fn drive_targeting_cursor(
occlusion: Res<PickOcclusion>,
hovered: Res<crate::target::Hovered>,
hovered_object: Res<crate::target::HoveredObject>,
spells: Option<Res<Spells>>,
self_tf: Query<&Transform, With<SelfPlayer>>,
go_tf: Query<&Transform>,
stores: Query<(
@@ -108,7 +142,7 @@ pub(crate) fn drive_targeting_cursor(
let Some(spell_id) = targeting.spell() else {
return;
};
let row = range_row(spells.as_deref(), spell_id);
let range = range_call(&checks, spell_id);
let me = self_tf.single().ok().map(|tf| tf.translation);
// The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject
// word's nearest GameObject, else the picked unit or corpse, else the terrain.
@@ -121,7 +155,7 @@ pub(crate) fn drive_targeting_cursor(
&go_tf,
&lock_inputs,
spell_id,
row,
range,
me,
)
} else if let Some(unit) = hovered
@@ -137,7 +171,7 @@ pub(crate) fn drive_targeting_cursor(
} else if targeting.wants(TargetingWants::Location) {
// `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast.
match (occlusion.point, me) {
(Some(point), Some(me)) => ground_point_in_range(row, me, point),
(Some(point), Some(me)) => ground_point_in_range(range, me, point),
_ => false,
}
} else {
@@ -161,7 +195,7 @@ fn object_arm(
go_tf: &Query<&Transform>,
lock_inputs: &crate::target::lock::GoLockInputs,
spell_id: u32,
row: Option<&SpellRange>,
range: Option<RangeCall>,
me: Option<Vec3>,
) -> bool {
let (Some(entity), Some(guid)) = (hovered_object.target, hovered_object.guid) else {
@@ -195,7 +229,7 @@ fn object_arm(
}
// The range tail: the caster-to-object distance against the spell's min and max.
match (me, go_tf.get(entity)) {
(Some(me), Ok(tf)) => ground_point_in_range(row, me, tf.translation),
(Some(me), Ok(tf)) => ground_point_in_range(range, me, tf.translation),
_ => false,
}
}
@@ -204,22 +238,134 @@ fn object_arm(
mod tests {
use super::*;
/// The call for a spell and row under these tables, with the default combat reach and no
/// auto-attack target.
fn call<'a>(
spell: &'a SpellDisplay,
row: &'a SpellRange,
mods: &'a SpellModifiers,
) -> Option<RangeCall<'a>> {
Some(RangeCall {
spell,
row,
mods,
self_reach: 1.5,
attack_target_reach: None,
})
}
/// Blizzard's row 4 is 0 to 30 yd; a synthetic min exercises the too-close arm.
#[test]
fn ground_point_in_range_mirrors_check_ground_point_in_range() {
let row = |min: f32, max: f32| SpellRange { min, max, flags: 0 };
let origin = Vec3::ZERO;
let at = |d: f32| Vec3::new(d, 0.0, 0.0);
let spell = SpellDisplay::default();
let mods = SpellModifiers::default();
let blizzard = row(0.0, 30.0);
assert!(ground_point_in_range(Some(&blizzard), origin, at(29.9)));
assert!(!ground_point_in_range(Some(&blizzard), origin, at(30.1)));
let ground = |row: &SpellRange, d: f32| {
ground_point_in_range(call(&spell, row, &mods), origin, at(d))
};
assert!(ground(&blizzard, 29.9));
assert!(!ground(&blizzard, 30.1));
let banded = row(8.0, 35.0);
assert!(!ground_point_in_range(Some(&banded), origin, at(5.0)));
assert!(ground_point_in_range(Some(&banded), origin, at(20.0)));
assert!(!ground(&banded, 5.0));
assert!(ground(&banded, 20.0));
// Both bounds are inclusive (`min² > d²` and `max² < d²` fail, `0x6e68c3`, `0x6e68d3`), and
// a NaN distance fails both.
assert!(ground(&banded, 8.0));
assert!(ground(&banded, 35.0));
assert!(!ground(&banded, f32::NAN));
assert!(!ground(&blizzard, f32::NAN));
// The self row is `{0, 0}`: only the caster's own spot is in range.
let own = row(0.0, 0.0);
assert!(ground(&own, 0.0));
assert!(!ground(&own, 1.0));
// No row → permissive (the server still validates).
assert!(ground_point_in_range(None, origin, at(500.0)));
}
/// Arctic Reach adds op 5 with family mask `0xa0` (bits 5 and 7), and Blizzard's `0x80080` sets
/// bit 7: 30 yd at +20% is 36. The talent moves the max only, and a spell whose mask it does
/// not cover keeps its 30.
#[test]
fn a_range_talent_moves_the_ground_verdict() {
let mage = |flags: u64| SpellDisplay {
spell_family: 3,
spell_family_flags: flags,
..SpellDisplay::default()
};
let blizzard = mage(0x8_0080);
let flamestrike = mage(0x4000_0004);
let row = SpellRange {
min: 0.0,
max: 30.0,
flags: 0,
};
let banded = SpellRange {
min: 8.0,
max: 35.0,
flags: 0,
};
let plain = {
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
mods
};
let mut talented = plain.clone();
for bit in [5, 7] {
talented.set(false, bit, crate::spell::OP_RANGE, 20);
}
let ground = |spell: &SpellDisplay, row: &SpellRange, mods: &SpellModifiers, d: f32| {
ground_point_in_range(call(spell, row, mods), Vec3::ZERO, Vec3::new(d, 0.0, 0.0))
};
assert!(!ground(&blizzard, &row, &plain, 33.0), "30 yd without it");
assert!(ground(&blizzard, &row, &talented, 29.9));
assert!(ground(&blizzard, &row, &talented, 33.0));
assert!(ground(&blizzard, &row, &talented, 35.9));
assert!(!ground(&blizzard, &row, &talented, 36.1));
assert!(!ground(&blizzard, &row, &talented, 37.0));
// Bit 7 is not in Flamestrike's mask (bits 2 and 30).
assert!(!ground(&flamestrike, &row, &talented, 33.0));
// Only the max moves: 8 to 35 is 8 to 42.
assert!(!ground(&blizzard, &banded, &talented, 5.0));
assert!(ground(&blizzard, &banded, &talented, 41.0));
assert!(!ground(&blizzard, &banded, &talented, 43.0));
}
/// With no unit passed, the melee arm sums the caster's reach with its auto-attack target's, else
/// its own, and the max is floored at 5.0 (`0x6e3552`-`0x6e35bf`).
#[test]
fn the_melee_row_reads_the_casters_reaches() {
let melee = SpellRange {
min: 0.0,
max: 5.0,
flags: 1,
};
let spell = SpellDisplay::default();
let mods = SpellModifiers::default();
let ground = |self_reach: f32, attack_target_reach: Option<f32>, d: f32| {
let call = RangeCall {
spell: &spell,
row: &melee,
mods: &mods,
self_reach,
attack_target_reach,
};
ground_point_in_range(Some(call), Vec3::ZERO, Vec3::new(d, 0.0, 0.0))
};
// 1.5 + 1.5 + 1.3333 is under the floor.
assert!(ground(1.5, None, 4.9));
assert!(!ground(1.5, None, 5.1));
// The caster's 3.0 twice: 7.33.
assert!(ground(3.0, None, 7.2));
assert!(!ground(3.0, None, 7.5));
// An auto-attack target's 4.0 in place of the second: 1.5 + 4.0 + 1.3333 = 6.83.
assert!(ground(1.5, Some(4.0), 6.7));
assert!(!ground(1.5, Some(4.0), 7.0));
}
/// Fixture rows mirror the real table: row 14 is 8.0 (Blizzard), row 8 is 5.0 (Flamestrike).
#[test]
fn ground_cast_radius_mirrors_get_current_cast_radius() {
@@ -370,6 +516,201 @@ mod tests {
assert!(verdict(0x4840, Some(Vec3::ZERO), None));
}
/// `0x4820f0` over terrain. Blizzard (10, `Targets 0x40`, family 3 mask `0x80080`, row 4, 0 to
/// 30 yd) under Arctic Reach's cells, and a melee-row ground spell (12684, row 2) under the
/// caster's reach and its auto-attack target's.
#[test]
fn the_terrain_verdict_reads_the_range_talent_and_the_casters_reaches() {
use super::super::corpse_fixture as fx;
use crate::creature_anim::Engaged;
use bevy::ecs::system::RunSystemOnce;
use std::collections::HashMap;
const BLIZZARD: u32 = 10;
const MELEE_GROUND: u32 = 12684;
const ATTACK_TARGET: u64 = 0xF130_0000_0000_0007;
// A spell, a talent or not, the caster's reach, an engaged target's reach, the distance.
let verdict =
|spell: u32, talent: bool, self_reach: f32, engaged: Option<f32>, d: f32| -> bool {
let mut world = World::new();
world.init_resource::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::target::Hovered>();
world.init_resource::<crate::target::HoveredObject>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.insert_resource(PickOcclusion {
distance: 10.0,
point: Some(Vec3::new(d, 0.0, 0.0)),
});
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
if talent {
for bit in [5, 7] {
mods.set(false, bit, crate::spell::OP_RANGE, 20);
}
}
world.insert_resource(mods);
let display = |range_index, flags| SpellDisplay {
range_index,
targets: 0x40,
spell_family: 3,
spell_family_flags: flags,
..SpellDisplay::default()
};
let mut spells = Spells::empty_for_tests();
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
(BLIZZARD, display(4, 0x8_0080)),
(MELEE_GROUND, display(2, 0)),
]));
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([
(
4,
SpellRange {
min: 0.0,
max: 30.0,
flags: 0,
},
),
(
2,
SpellRange {
min: 0.0,
max: 5.0,
flags: 1,
},
),
]));
world.insert_resource(spells);
let mut index = HashMap::new();
let mut me =
world.spawn((SelfPlayer, Transform::default(), fx::caster(self_reach)));
if engaged.is_some() {
me.insert(Engaged(ATTACK_TARGET));
}
if let Some(reach) = engaged {
let target = world.spawn(fx::caster(reach)).id();
index.insert(ATTACK_TARGET, target);
}
world.insert_resource(crate::net::GuidIndex(index));
world.resource_mut::<SpellTargeting>().enter(
spell,
crate::spell::CastCommit::Spell,
0x0040,
);
world
.run_system_once(drive_targeting_cursor)
.expect("the targeting cursor drives");
!world.resource::<WorldCursor>().unable
};
// 30 yd without the talent, 36 with it.
assert!(!verdict(BLIZZARD, false, 1.5, None, 33.0), "past 30 yd");
assert!(verdict(BLIZZARD, true, 1.5, None, 33.0), "inside 36 yd");
assert!(!verdict(BLIZZARD, true, 1.5, None, 37.0), "past 36 yd");
// The melee row's 5.0 floor; the caster's own reach twice, 3.0 + 3.0 + 1.3333; an auto-attack
// target's 4.0 in place of the second, 1.5 + 4.0 + 1.3333.
assert!(!verdict(MELEE_GROUND, false, 1.5, None, 7.0));
assert!(verdict(MELEE_GROUND, false, 3.0, None, 7.0));
assert!(verdict(MELEE_GROUND, false, 1.5, Some(4.0), 6.5));
assert!(!verdict(MELEE_GROUND, false, 1.5, Some(4.0), 7.0));
}
/// `0x6e6460`'s GameObject leg asks the same range through the same call: a chest a lock spell
/// opens, `d` yd out, on a family-3 lock spell (fixture) with row 12, 0 to 5 yd.
#[test]
fn the_object_leg_reads_the_range_talent() {
use super::super::corpse_fixture as fx;
use benilla_formats::{LockCatalog, LockSlot, OpenLock, LOCK_KEY_SKILL};
use bevy::ecs::system::RunSystemOnce;
use std::collections::HashMap;
const OPENER: u32 = 6477;
const LOCK: u32 = 55;
// Entry 7, a chest whose `data[0]` is the lock.
const CHEST: u64 = 0xF110_0000_0000_0001 | 7 << 24;
let verdict = |talent: bool, d: f32| -> bool {
let mut world = World::new();
world.init_resource::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::target::Hovered>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<PickOcclusion>();
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
if talent {
mods.set(false, 7, crate::spell::OP_RANGE, 20);
}
world.insert_resource(mods);
let mut spells = Spells::empty_for_tests();
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([(
OPENER,
SpellDisplay {
range_index: 12,
targets: 0x4000,
spell_family: 3,
spell_family_flags: 1 << 7,
open_lock: Some(OpenLock {
effect: 0,
lock_type: 1,
}),
..SpellDisplay::default()
},
)]));
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([(
12,
SpellRange {
min: 0.0,
max: 5.0,
flags: 0,
},
)]));
world.insert_resource(spells);
let mut slots = [LockSlot::default(); benilla_formats::MAX_LOCK_SLOTS];
slots[0] = LockSlot {
key_type: LOCK_KEY_SKILL,
index: 1,
skill: 0,
action: 5,
};
world.insert_resource(crate::go_templates::Locks(LockCatalog::from_rows([(
LOCK, slots,
)])));
let mut templates = crate::go_templates::GameObjectTemplates::default();
let mut data = [0; 24];
data[0] = LOCK as i32;
templates.insert(7, 3, "Chest".into(), &data);
world.insert_resource(templates);
world.spawn((SelfPlayer, Transform::default(), fx::caster(1.5)));
let chest = world
.spawn(Transform::from_translation(Vec3::new(d, 0.0, 0.0)))
.id();
world.insert_resource(crate::target::HoveredObject {
target: Some(chest),
guid: Some(CHEST),
distance: 5.0,
});
world.resource_mut::<SpellTargeting>().enter(
OPENER,
crate::spell::CastCommit::Spell,
0x4800,
);
world
.run_system_once(drive_targeting_cursor)
.expect("the targeting cursor drives");
!world.resource::<WorldCursor>().unable
};
// 5 yd without the talent, 6 with it.
assert!(verdict(false, 4.5));
assert!(!verdict(false, 5.5), "past 5 yd");
assert!(verdict(true, 5.5), "inside 6 yd");
assert!(!verdict(true, 6.5), "past 6 yd");
}
/// `0x6e6460`'s unit leg over a hovered unit: the relation checks, then min² ≤ d² ≤ max²
/// (`6e677c`–`6e6802`). Row 5 is 0 to 30 yd and row 114 is 8 to 35 yd, both padded by the two
/// 1.5 combat reaches: 0 to 33 and 11 to 38.
+23 -3
View File
@@ -74,6 +74,8 @@ pub(crate) struct BindChecks<'w, 's> {
poses: Query<'w, 's, &'static GlobalTransform, Without<benilla_world::view::WorldCamera>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
spell_mods: Res<'w, super::SpellModifiers>,
/// The caster's auto-attack target guid, `[caster+0xc48]`, which `GetMinMaxRange` looks up.
engaged: Query<'w, 's, &'static crate::creature_anim::Engaged, With<crate::net::SelfPlayer>>,
}
impl BindChecks<'_, '_> {
@@ -153,12 +155,30 @@ impl BindChecks<'_, '_> {
.map(GlobalTransform::translation),
..Default::default()
};
if let Some((_, Some(store))) = me {
range.self_reach = store.0.unit_combat_reach();
}
range.self_reach = self.self_reach();
range
}
/// The caster's combat reach, the descriptor's default until its store streams.
fn self_reach(&self) -> f32 {
self.self_q
.iter()
.next()
.and_then(|(_, store)| store)
.map_or(super::cast_target::RangeInputs::default().self_reach, |s| {
s.0.unit_combat_reach()
})
}
/// The reach of the caster's auto-attack target, the unit `GetMinMaxRange 0x6e3480` looks up
/// itself for its melee arm when no unit is passed (`6e3552`-`6e3584`). `None` with no target
/// engaged or before it streams, where the arm reads the caster's own reach (`6e3594`).
fn attack_target_reach(&self) -> Option<f32> {
let guid = self.engaged.single().ok()?.0;
let entity = *self.index.as_ref()?.0.get(&guid)?;
Some(self.stores.get(entity).ok()?.0.unit_combat_reach())
}
fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option<u8> {
let spells = self.spells.as_deref()?;
let def = spells.catalog.get(spell_id)?;