diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 80bc35ae..0c7a574e 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -705,7 +705,7 @@ pub(super) fn scaled_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> Opti /// remote's relayed flags ([`RemoteMotion`]), a creature's spline, else still. /// `creature_swimming` supplies the `SWIMMING` the wire never sends for a creature; `modes`, the /// `SMSG_SPLINE_MOVE_*` grants, join the remote and creature flags (one word in the reference). -pub(super) fn unify( +pub(crate) fn unify( movement: Option<&MovementState>, remote: Option<&RemoteMotion>, spline: Option<&Spline>, diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index d845d0a7..7486a11f 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -667,12 +667,20 @@ pub(crate) mod schedule_tests { /// over the `CastLadder`, the selection and the unit-token resolver: the class /// `drop_item_on_unit` and the world click's legs already carry, as none of those drains /// orders against the target chain. Such a gesture and a script call in one frame take - /// either order. + /// either order; + /// - the range compare's readers (`ui_action::state::feed_action_state`, + /// `ui_tooltip::spell_feed::feed_spell_tooltips`, `spell::targeting::feed_targeting_to_vm`, + /// the targeting cursor and the object-click commit) against the writers of `Player` and + /// `RemoteMotion` (`camera_saved::load_camera_pose`, `world_focus`'s focus publish and settle + /// release, `transport::compose_riders` and `ground_deck_riders`): the readers take the + /// movement flags of the caster (`Player::move_flags`) and its target + /// (`RemoteMotion::flags`), and those writers touch `login_pitch`, the settle fields and a + /// rider's pose, never the flags. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 4_849; + const UPDATE_ACTIONABLE_CEILING: usize = 4_860; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 2adbcaf2..d1a62cf7 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -486,6 +486,15 @@ pub(super) struct PendingKnockback { } impl Player { + /// A player whose last streamed movement word is `flags`, for the gates that read it. + #[cfg(test)] + pub(crate) fn with_move_flags(flags: u32) -> Self { + Self { + move_flags: flags, + ..Self::default() + } + } + /// Take the jump a hover grant owes, clearing the latch: `Jump(0)` refuses only ROOT and /// FALLING (`0x7c625c`), skipping the hover test (`0x7c6236`), and a refused jump is dropped, /// not retried. diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index 5f0a8f68..7a90122e 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -197,15 +197,17 @@ impl CastContext<'_> { } } -/// Positions and combat reaches for the pre-send range refusal: the client's cast runs +/// Positions and the two units for the pre-send range refusal: the client's cast runs /// `CanTargetUnit 0x6e4440` and `IsTargetInRange 0x6e47b0` before the commit, so an out-of-range /// press refuses locally and none of the commit tail runs. #[derive(Clone, Copy)] pub(crate) struct RangeInputs { pub(crate) self_pos: Option, pub(crate) target_pos: Option, - pub(crate) self_reach: f32, - pub(crate) target_reach: Option, + /// The caster's combat reach and motion. + pub(crate) caster: benilla_formats::RangeUnit, + /// The bound unit, which `GetMinMaxRange` reads for the ranged pad and the moving bonus. + pub(crate) target: Option, } impl RangeInputs { @@ -223,14 +225,7 @@ impl RangeInputs { .self_pos .zip(self.target_pos) .map(|(a, b)| a.distance_squared(b)); - super::validator::cast_range_refusal( - def, - row, - self.self_reach, - self.target_reach, - dist_sq, - mods, - ) + super::validator::cast_range_refusal(def, row, self.caster, self.target, dist_sq, mods) } } @@ -239,9 +234,8 @@ impl Default for RangeInputs { Self { self_pos: None, target_pos: None, - // The descriptor's default combat reach. - self_reach: 1.5, - target_reach: None, + caster: benilla_formats::RangeUnit::still(super::DEFAULT_REACH), + target: None, } } } @@ -268,6 +262,8 @@ pub(crate) struct CastTargeting<'w, 's> { /// `Option`: the body exists only in world, and the cast-result handler must fetch this param /// anywhere. With no body the moving gate passes and the server decides. player: Option>, + /// The caster's and the selection's reach and motion, for the range gate. + range_units: super::RangeUnits<'w, 's>, } impl CastTargeting<'_, '_> { @@ -304,12 +300,8 @@ impl CastTargeting<'_, '_> { .target .and_then(|e| self.transforms.get(e).ok()) .map(|t| t.translation), - self_reach: self - .self_store - .iter() - .next() - .map_or(1.5, |s| s.0.unit_combat_reach()), - target_reach: target_store.map(|s| s.0.unit_combat_reach()), + caster: self.range_units.caster(), + target: self.selection.target.and_then(|e| self.range_units.unit(e)), }, main_hand_item: self .self_store diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 67107a25..08f74602 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -20,6 +20,7 @@ mod group_relation; mod inflight; mod mods; pub(crate) mod net; +mod range_units; pub(crate) mod targeting; pub(crate) mod usable; pub(crate) mod validator; @@ -36,6 +37,7 @@ pub(crate) use inflight::{ 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}; +pub(crate) use range_units::{RangeUnits, DEFAULT_REACH}; // `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, diff --git a/crates/benilla-app/src/spell/mods.rs b/crates/benilla-app/src/spell/mods.rs index 80a2d8b4..6e1d4a31 100644 --- a/crates/benilla-app/src/spell/mods.rs +++ b/crates/benilla-app/src/spell/mods.rs @@ -227,16 +227,17 @@ impl SpellModifiers { }) } - /// `GetMinMaxRange` modifies only the maximum at its common tail. The on-next-swing - /// `Attributes & 0x404` arm returns before that tail (`0x6e350f`). + /// `GetMinMaxRange` modifies only the maximum at its common tail, after the moving bonus + /// (`0x6e3648`-`0x6e36a2`, then op 5 at `0x6e3746`). The on-next-swing `Attributes & 0x404` arm + /// returns before that tail (`0x6e350f`). pub(crate) fn min_max_range( &self, d: &SpellDisplay, row: Option<&SpellRange>, - caster_reach: f32, + caster: benilla_formats::RangeUnit, targets: benilla_formats::RangeTargets, ) -> Option<(f32, f32)> { - let (min, max) = benilla_formats::min_max_range(d, row, caster_reach, targets)?; + let (min, max) = benilla_formats::min_max_range(d, row, caster, targets)?; Some(( min, if d.on_next_swing() { @@ -521,23 +522,58 @@ mod tests { flags: 0, }; mods.set(false, 5, OP_RANGE, 20); + let caster = benilla_formats::RangeUnit::still(1.5); let target = benilla_formats::RangeTargets { - target: Some(1.5), + target: Some(benilla_formats::RangeUnit::still(1.5)), attack_target: None, }; - let (min, max) = mods.min_max_range(&d, Some(&row), 1.5, target).unwrap(); + let (min, max) = mods.min_max_range(&d, Some(&row), caster, target).unwrap(); assert_eq!(min, 11.0); assert!((max - 45.6).abs() < 0.001, "38 yards with +20% range"); - assert_eq!(mods.min_max_range(&d, None, 1.5, target), None); + assert_eq!(mods.min_max_range(&d, None, caster, target), None); let on_next_swing = SpellDisplay { attributes: 0x404, ..d }; assert_eq!( - mods.min_max_range(&on_next_swing, Some(&row), 1.5, target), - benilla_formats::min_max_range(&on_next_swing, Some(&row), 1.5, target), + mods.min_max_range(&on_next_swing, Some(&row), caster, target), + benilla_formats::min_max_range(&on_next_swing, Some(&row), caster, target), "the reference's early return bypasses op 5" ); } + + /// The moving bonus is part of the max before op 5 scales it (`0x6e3648`-`0x6e36a2`, then + /// `0x6e3746`): a +20% talent on the melee row of two running units scales 5 + 2.6667. + #[test] + fn range_modifier_scales_the_moving_bonus() { + let mut mods = mage_tables(); + let d = mage(&[5]); + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + mods.set(false, 5, OP_RANGE, 20); + let runner = benilla_formats::RangeUnit { + motion: benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }, + ..benilla_formats::RangeUnit::still(1.5) + }; + let targets = benilla_formats::RangeTargets { + target: Some(runner), + attack_target: None, + }; + let (_, max) = mods + .min_max_range(&d, Some(&melee), runner, targets) + .unwrap(); + let bonus = benilla_formats::MOVING_RANGE_BONUS; + assert!( + (max - (5.0 + bonus) * 1.2).abs() < 1e-4, + "the modifier scales the bonused max, not the bare 5.0: {max}" + ); + } } diff --git a/crates/benilla-app/src/spell/range_units.rs b/crates/benilla-app/src/spell/range_units.rs new file mode 100644 index 00000000..9f035982 --- /dev/null +++ b/crates/benilla-app/src/spell/range_units.rs @@ -0,0 +1,364 @@ +//! The units `GetMinMaxRange` (`0x6e3480`) reads, built from the world: the caster, a target unit, +//! and the caster's auto-attack target, each as its combat reach, player bit and motion +//! ([`benilla_formats::RangeUnit`]). + +use benilla_formats::{RangeUnit, UnitMotion}; +use benilla_protocol::messages::ObjectType; +use benilla_protocol::MoveSpeeds; +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use crate::creature_anim::select::unify; +use crate::creature_anim::{move_flags, Engaged}; +use crate::net::{ + current_speed, CreatureSwimming, Embodied, GuidIndex, ObjectStore, RemoteMotion, SelfPlayer, + Spline, UnitMoveModes, UnitSpeeds, +}; +use crate::player::Player; + +/// The descriptor's default combat reach, before a unit's store streams. +pub(crate) const DEFAULT_REACH: f32 = 1.5; + +/// One unit's motion as `0x7c4c90` and the moving test read it (`0x5fc350`, `0x6e364c`): `flags` +/// is the movement word (`[[unit+0x118]+0x40]`), and the current speed is 0 with no direction bit +/// (`0x7c4c99`), then the live server spline's speed, its length over its duration (`0x7c4caa`), +/// then the swim, walk, run or run-back speed the flags pick ([`current_speed`]). A unit with no +/// speeds streamed reads no current speed here, so it is not running; every unit's create block +/// applies its speeds, so none is seen that way. +pub(crate) fn unit_motion( + flags: u32, + speeds: Option<&MoveSpeeds>, + spline: Option<&Spline>, +) -> UnitMotion { + let Some(speeds) = speeds else { + return UnitMotion { + flags, + ..UnitMotion::default() + }; + }; + let speed = match spline { + Some(spline) if flags & move_flags::ANY_MOVE != 0 => spline.speed(), + _ => current_speed(speeds, flags), + }; + UnitMotion { + flags, + speed, + walk_speed: speeds.walk, + } +} + +/// What a unit's range inputs are read from: its descriptor, and the state its movement flags and +/// speeds come from ([`unify`], [`UnitSpeeds`]). +type UnitParts = ( + &'static ObjectStore, + Option<&'static RemoteMotion>, + Option<&'static Spline>, + Option<&'static UnitMoveModes>, + Option<&'static UnitSpeeds>, + Has, + Has, +); + +/// The reader of the range units: any streamed unit, our own caster, and a unit's auto-attack +/// target. +#[derive(SystemParam)] +pub(crate) struct RangeUnits<'w, 's> { + units: Query<'w, 's, UnitParts>, + caster: Query<'w, 's, Entity, With>, + /// The auto-attack target guid, `[unit+0xc48]`. + engaged: Query<'w, 's, &'static Engaged>, + index: Option>, + /// The flags word of the body we drive, [`Player::move_flags`]. + player: Option>, +} + +impl RangeUnits<'_, '_> { + /// The unit `entity` is, `None` before its descriptor streams. The body we drive reads the + /// word our controller builds, one answer for the wire and the local gates; any other unit's + /// is its relayed or spline-implied flags with the modes the server granted. + pub(crate) fn unit(&self, entity: Entity) -> Option { + let (store, remote, spline, modes, speeds, swimming, driven) = + self.units.get(entity).ok()?; + let flags = if driven { + self.player.as_deref().map_or(0, Player::move_flags) + } else { + unify(None, remote, spline, swimming, modes).flags + }; + Some(RangeUnit { + reach: store.0.unit_combat_reach(), + player: store.0.object_type() == Some(ObjectType::Player), + motion: unit_motion(flags, speeds.map(|s| &s.0), spline), + }) + } + + /// The caster: our own player, at the default reach and standing until its descriptor streams. + pub(crate) fn caster(&self) -> RangeUnit { + self.caster + .iter() + .next() + .and_then(|e| self.unit(e)) + .unwrap_or(RangeUnit::still(DEFAULT_REACH)) + } + + /// The auto-attack target of `entity`, the unit `GetMinMaxRange` looks up itself for its melee + /// arm (`0x6e3552`-`0x6e3584`). `None` with no target engaged or before it streams, where the + /// arm reads the caster's own reach (`0x6e3594`). + pub(crate) fn attack_target(&self, entity: Entity) -> Option { + let guid = self.engaged.get(entity).ok()?.0; + let target = *self.index.as_ref()?.0.get(&guid)?; + self.unit(target) + } + + /// The auto-attack target of our own player. + pub(crate) fn caster_attack_target(&self) -> Option { + self.attack_target(self.caster.iter().next()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creature_anim::move_flags as f; + use crate::net::UnitSpeeds; + + fn speeds() -> MoveSpeeds { + MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.722_222, + swim_back: 2.5, + turn_rate: std::f32::consts::PI, + } + } + + fn spline(length: f32, secs: u64) -> Spline { + Spline { + points: vec![[0.0; 3], [length, 0.0, 0.0]], + start: std::time::Instant::now(), + duration: std::time::Duration::from_secs(secs), + id: 1, + grounded: true, + run_mode: true, + deck: None, + } + } + + /// `0x7c4c90` for a forward runner answers the run speed, and `0x5fc350` finds it above twice + /// the walk speed. + #[test] + fn a_forward_runner_is_running() { + let m = unit_motion(f::FORWARD, Some(&speeds()), None); + assert_eq!((m.flags, m.speed, m.walk_speed), (f::FORWARD, 7.0, 2.5)); + assert!(m.moves_at_run_speed()); + } + + /// Walk mode picks `min(walk, run)`, no direction bit is speed 0, and swimming is the swim + /// speed, which is below twice the walk speed. + #[test] + fn a_walker_a_stander_and_a_swimmer_are_not_running() { + let s = speeds(); + for (name, flags) in [ + ("walking", f::FORWARD | f::WALK_MODE), + ("standing", 0), + ("jumping in place", f::FALLING), + ("swimming", f::FORWARD | f::SWIMMING), + ("backward", f::BACKWARD), + ] { + let m = unit_motion(flags, Some(&s), None); + assert!(!m.moves_at_run_speed(), "{name}: {m:?}"); + } + // A swim speed past twice the walk speed is running, as the compare has it. + let fast = MoveSpeeds { swim: 6.0, ..s }; + assert!(unit_motion(f::FORWARD | f::SWIMMING, Some(&fast), None).moves_at_run_speed()); + } + + /// A live spline is the current speed (`0x7c4caa`), whatever the flags pick: a mob fleeing at + /// 8 yd/s runs, and one wandering at 2.5 does not though its flags are a runner's (a creature's + /// spline sets forward and no walk mode, [`unify`]); with no direction bit it is 0. + #[test] + fn a_spline_is_its_own_speed() { + let s = speeds(); + let fleeing = unit_motion(f::FORWARD, Some(&s), Some(&spline(16.0, 2))); + assert_eq!(fleeing.speed, 8.0); + assert!(fleeing.moves_at_run_speed()); + let wandering = unit_motion(f::FORWARD, Some(&s), Some(&spline(5.0, 2))); + assert_eq!(wandering.speed, 2.5); + assert!(!wandering.moves_at_run_speed()); + assert_eq!(unit_motion(0, Some(&s), Some(&spline(16.0, 2))).speed, 0.0); + } + + /// No speeds streamed: not running. + #[test] + fn a_unit_without_speeds_is_not_running() { + assert!(!unit_motion(f::FORWARD, None, Some(&spline(16.0, 2))).moves_at_run_speed()); + } + + fn store(typemask: u32, reach: f32) -> ObjectStore { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, typemask), + (22, 100), + (130, reach.to_bits()), + ])) + } + + fn remote(flags: u32) -> RemoteMotion { + RemoteMotion { + wow_pos: [0.0; 3], + orientation: 0.0, + flags, + pitch: 0.0, + speed: 0.0, + vertical_velocity: 0.0, + jump_xy_vel: [0.0; 2], + fall_start_z: None, + pending: Default::default(), + relay: Default::default(), + last_apply_ms: 0.0, + last_apply_pos: [0.0; 3], + } + } + + const MOB: u64 = 0xF130_0000_0000_0009; + + /// The body we drive reads the word our controller built, a remote player its relayed flags, + /// a creature its spline; the player bit is `OBJECT_FIELD_TYPE` bit 4, and the caster's + /// auto-attack target is looked up through its `Engaged` guid. + #[test] + fn a_unit_is_read_off_its_own_state() { + use bevy::ecs::system::RunSystemOnce; + + let mut world = World::new(); + world.insert_resource(Player::with_move_flags(f::FORWARD)); + let run = UnitSpeeds(speeds()); + let me = world + .spawn((SelfPlayer, Embodied, run, store(0x19, 2.0))) + .id(); + let friend = world + .spawn((remote(f::STRAFE_LEFT), run, store(0x19, 1.5))) + .id(); + let mob = world.spawn((spline(16.0, 2), run, store(0x09, 1.25))).id(); + let wanderer = world.spawn((spline(5.0, 2), run, store(0x09, 1.5))).id(); + let idle = world.spawn((run, store(0x09, 1.5))).id(); + let bare = world.spawn(run).id(); + world.entity_mut(me).insert(Engaged(MOB)); + world.insert_resource(GuidIndex([(MOB, mob)].into_iter().collect())); + world + .run_system_once(move |units: RangeUnits| { + let me = units.caster(); + assert_eq!(me.reach, 2.0); + assert!(me.player && me.motion.moves_at_run_speed(), "{me:?}"); + let friend = units.unit(friend).unwrap(); + assert!( + friend.player && friend.motion.moves_at_run_speed(), + "{friend:?}" + ); + let mob_unit = units.unit(mob).unwrap(); + assert!(!mob_unit.player, "a creature"); + assert_eq!(mob_unit.reach, 1.25); + assert_eq!(mob_unit.motion.speed, 8.0, "its spline's speed"); + assert!(mob_unit.motion.moves_at_run_speed()); + let wanderer = units.unit(wanderer).unwrap(); + assert!(!wanderer.motion.moves_at_run_speed(), "{wanderer:?}"); + let idle = units.unit(idle).unwrap(); + assert!(!idle.motion.moves_at_run_speed(), "{idle:?}"); + assert!(units.unit(bare).is_none(), "no descriptor streamed"); + assert_eq!(units.caster_attack_target(), Some(mob_unit)); + assert_eq!(units.attack_target(mob), None, "the mob engages nothing"); + }) + .expect("the units read"); + } + + /// Our own body is the word `Player` holds, so the same body standing still, walking or with + /// no controller in the world is not running. + #[test] + fn the_driven_body_follows_the_controllers_word() { + use bevy::ecs::system::RunSystemOnce; + + let caster_at = |player: Option| { + let mut world = World::new(); + if let Some(player) = player { + world.insert_resource(player); + } + world.spawn((SelfPlayer, Embodied, UnitSpeeds(speeds()), store(0x19, 1.5))); + world + .run_system_once(|units: RangeUnits| units.caster().motion.moves_at_run_speed()) + .expect("the caster reads") + }; + assert!(caster_at(Some(Player::with_move_flags(f::FORWARD)))); + assert!(caster_at(Some(Player::with_move_flags( + f::STRAFE_RIGHT | f::FALLING + )))); + assert!(!caster_at(Some(Player::with_move_flags(0)))); + assert!(!caster_at(Some(Player::with_move_flags( + f::FORWARD | f::WALK_MODE + )))); + assert!(!caster_at(Some(Player::with_move_flags(f::BACKWARD)))); + assert!(!caster_at(None)); + } + + /// The send path's range gate ([`super::super::cast_target::CastTargeting`]): a warrior running + /// after a running mob presses Hamstring (the melee row) at 7 yards and is not refused, and + /// the same press with either one standing is "Out of range." + #[test] + fn the_press_reaches_the_moving_bonus_with_both_units_running() { + use super::super::cast_target::CastTargeting; + use benilla_formats::{SpellDisplay, SpellRange}; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 0xF130_0000_0000_0001; + let refusal = |caster_runs: bool, mob_runs: bool, yards: f32| { + let mut world = World::new(); + world.insert_resource(Player::with_move_flags(if caster_runs { + f::FORWARD + } else { + 0 + })); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.insert_resource(crate::net::SelfGuid(Some(ME))); + let run = UnitSpeeds(speeds()); + world.spawn(( + SelfPlayer, + Embodied, + run, + store(0x19, 1.5), + Transform::default(), + )); + let mob = world + .spawn((run, store(0x09, 1.5), Transform::from_xyz(yards, 0.0, 0.0))) + .id(); + if mob_runs { + world.entity_mut(mob).insert(spline(16.0, 2)); + } + world.insert_resource(crate::target::Selection { + target: Some(mob), + guid: Some(MOB), + ..Default::default() + }); + let hamstring = SpellDisplay { + range_index: 2, + ..Default::default() + }; + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + world + .run_system_once(move |targeting: CastTargeting| { + targeting.context().range.refusal( + &hamstring, + Some(&melee), + &crate::spell::SpellModifiers::default(), + ) + }) + .expect("the context builds") + }; + assert_eq!(refusal(true, true, 7.0), None, "both running"); + assert_eq!(refusal(true, true, 8.0), Some(0x59), "past 7.667"); + assert_eq!(refusal(true, false, 7.0), Some(0x59), "the mob stands"); + assert_eq!(refusal(false, true, 7.0), Some(0x59), "the caster stands"); + assert_eq!(refusal(false, false, 4.9), None, "the floor stands"); + } +} diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index c4f6f31a..eb6471a6 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -5,7 +5,7 @@ use bevy::prelude::*; -use benilla_formats::{RangeTargets, SpellDisplay, SpellRange}; +use benilla_formats::{RangeTargets, RangeUnit, SpellDisplay, SpellRange}; use crate::net::SelfPlayer; use crate::spell::SpellModifiers; @@ -17,15 +17,16 @@ use super::{SpellTargeting, TargetingWants}; /// 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. +/// tests for at `0x6e34e1`-`0x6e34f9`: the ranged arm pads nothing and takes no moving bonus, +/// and the melee arm sums the caster's reach with its auto-attack target's and asks that unit's +/// motion, with the caster's, for the bonus. #[derive(Clone, Copy)] struct RangeCall<'a> { spell: &'a SpellDisplay, row: &'a SpellRange, mods: &'a SpellModifiers, - self_reach: f32, - attack_target_reach: Option, + caster: RangeUnit, + attack_target: Option, } /// The call's inputs, or `None` where the data is absent (no catalog, an unknown spell, a row @@ -37,8 +38,8 @@ fn range_call<'a>(checks: &'a super::BindChecks, spell_id: u32) -> Option, self_pos: Vec3, point: Vec3) - }; let targets = RangeTargets { target: None, - attack_target: call.attack_target_reach, + attack_target: call.attack_target, }; // 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) + .min_max_range(call.spell, Some(call.row), call.caster, targets) .unwrap_or_default(); let dist_sq = self_pos.distance_squared(point); min * min <= dist_sq && dist_sq <= max * max @@ -249,8 +250,8 @@ mod tests { spell, row, mods, - self_reach: 1.5, - attack_target_reach: None, + caster: RangeUnit::still(1.5), + attack_target: None, }) } @@ -350,8 +351,8 @@ mod tests { spell: &spell, row: &melee, mods: &mods, - self_reach, - attack_target_reach, + caster: RangeUnit::still(self_reach), + attack_target: attack_target_reach.map(RangeUnit::still), }; ground_point_in_range(Some(call), Vec3::ZERO, Vec3::new(d, 0.0, 0.0)) }; @@ -366,6 +367,55 @@ mod tests { assert!(!ground(1.5, Some(4.0), 7.0)); } + /// The moving bonus (`0x6e3648`-`0x6e36a2`) on the ground verdict: with no unit passed, only + /// the melee arm has a unit for it, the auto-attack target, and both must run. The ranged arm + /// has none, whoever the caster's auto-attack target is. + #[test] + fn the_ground_verdict_takes_the_moving_bonus_on_the_melee_row_alone() { + let running = RangeUnit { + // A player, which would earn a ranged target the bonus, were one passed. + player: true, + motion: benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }, + ..RangeUnit::still(1.5) + }; + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + let blizzard = SpellRange { + min: 0.0, + max: 30.0, + flags: 0, + }; + let spell = SpellDisplay::default(); + let mods = SpellModifiers::default(); + let ground = |row: &SpellRange, caster, attack_target, d: f32| { + let call = RangeCall { + spell: &spell, + row, + mods: &mods, + caster, + attack_target, + }; + ground_point_in_range(Some(call), Vec3::ZERO, Vec3::new(d, 0.0, 0.0)) + }; + // 5.0 floor + 2.6667 = 7.667 for two runners. + assert!(ground(&melee, running, Some(running), 7.5)); + assert!(!ground(&melee, running, Some(running), 7.8)); + // One of them standing, or no auto-attack target, and the floor stands. + let still = RangeUnit::still(1.5); + assert!(!ground(&melee, running, Some(still), 7.5)); + assert!(!ground(&melee, still, Some(running), 7.5)); + assert!(!ground(&melee, running, None, 7.5)); + // The ranged arm never reads the auto-attack target: 30, not 32.67. + assert!(!ground(&blizzard, running, Some(running), 31.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() { @@ -800,6 +850,108 @@ mod tests { ); } + /// The unit leg's range compare, the tail of `SpellCanTargetUnit 0x6e6460`, hands + /// `GetMinMaxRange` the hovered unit (`0x6e679b`, `0x6e67d1`): a melee spell is in range to + /// 7.667 yards while the caster and that unit both run, and to the 5.0 floor otherwise. + #[test] + fn the_unit_leg_takes_the_moving_bonus_on_the_melee_row() { + use bevy::ecs::system::RunSystemOnce; + use std::collections::HashMap; + + const HAMSTRING: u32 = 1715; + let verdict = |caster_runs: bool, unit_runs: bool, distance: f32| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + world.insert_resource(crate::player::Player::with_move_flags(if caster_runs { + crate::creature_anim::move_flags::FORWARD + } else { + 0 + })); + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([( + HAMSTRING, + benilla_formats::SpellDisplay { + range_index: 2, + ..Default::default() + }, + )])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([( + 2, + SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }, + )])); + world.insert_resource(spells); + let speeds = crate::net::UnitSpeeds(benilla_protocol::MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: 3.1, + }); + let live = || { + crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(22, 100)])) + }; + world.spawn(( + SelfPlayer, + crate::net::Embodied, + speeds, + Transform::default(), + GlobalTransform::default(), + live(), + )); + let unit = world + .spawn(( + speeds, + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + live(), + )) + .id(); + if unit_runs { + world.entity_mut(unit).insert(crate::net::Spline { + points: vec![[distance, 0.0, 0.0], [distance + 16.0, 0.0, 0.0]], + start: std::time::Instant::now(), + duration: std::time::Duration::from_secs(2), + id: 1, + grounded: true, + run_mode: true, + deck: None, + }); + } + world.insert_resource(crate::target::Hovered { + target: Some(unit), + guid: Some(0xF130_0000_0000_0001), + distance: 5.0, + ..Default::default() + }); + world.resource_mut::().enter( + HAMSTRING, + crate::spell::CastCommit::Spell, + 0x0002, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + !world.resource::().unable + }; + assert!(verdict(true, true, 7.5), "both running: inside 7.667"); + assert!(!verdict(true, true, 8.0), "past it"); + assert!(!verdict(false, true, 7.5), "the caster stands"); + assert!(!verdict(true, false, 7.5), "the unit stands"); + assert!(verdict(false, false, 4.9), "the floor"); + } + /// `0x6e6460`'s corpse leg over a hovered corpse: bones, a hostile corpse under the ally bit /// and a corpse out of range are grey. The range pads a corpse by the caster's reach twice /// (`6e3605`–`6e361e`): Resurrection's 30 yd with a 3.0 reach is 36. diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 3614e9eb..2a85140f 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -74,8 +74,9 @@ pub(crate) struct BindChecks<'w, 's> { poses: Query<'w, 's, &'static GlobalTransform, Without>, spells: Option>, 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>, + /// The caster, a unit and the caster's auto-attack target (`[caster+0xc48]`, which + /// `GetMinMaxRange` looks up) as the range compare reads them. + range_units: super::RangeUnits<'w, 's>, } impl BindChecks<'_, '_> { @@ -123,28 +124,25 @@ impl BindChecks<'_, '_> { /// unknown spell or a missing row passes. fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option { let mut range = self.range_inputs(entity); - range.target_reach = self - .stores - .get(entity) - .ok() - .map(|s| s.0.unit_combat_reach()); + range.target = self.range_units.unit(entity); self.refusal(spell_id, range) } /// The same compare against a corpse. `GetMinMaxRange 0x6e3480` pads a corpse's bounds as a /// unit's (`6e35fe`), but with no unit to read the second reach from it reads the caster's - /// again (`6e3605`–`6e361e`). + /// again (`6e3605`–`6e361e`). On the ranged arm, which the corpse spells use, no unit means no + /// moving bonus; a melee row would fall back to the auto-attack target (`0x6e3580`). fn corpse_range_refusal(&self, spell_id: u32, entity: Entity) -> Option { let mut range = self.range_inputs(entity); - range.target_reach = Some(range.self_reach); + range.target = Some(benilla_formats::RangeUnit::still(range.caster.reach)); self.refusal(spell_id, range) } - /// Our position and reach and the candidate's position, each position the pose the hover - /// picks against. + /// Our position, reach and motion and the candidate's position, each position the pose the + /// hover picks against. fn range_inputs(&self, entity: Entity) -> super::cast_target::RangeInputs { let me = self.self_q.iter().next(); - let mut range = super::cast_target::RangeInputs { + super::cast_target::RangeInputs { self_pos: me .and_then(|(e, _)| self.poses.get(e).ok()) .map(GlobalTransform::translation), @@ -153,30 +151,9 @@ impl BindChecks<'_, '_> { .get(entity) .ok() .map(GlobalTransform::translation), - ..Default::default() - }; - 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 { - 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()) + caster: self.range_units.caster(), + target: None, + } } fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option { diff --git a/crates/benilla-app/src/spell/validator.rs b/crates/benilla-app/src/spell/validator.rs index 7eba0f9f..1f6bcb2f 100644 --- a/crates/benilla-app/src/spell/validator.rs +++ b/crates/benilla-app/src/spell/validator.rs @@ -5,7 +5,7 @@ //! the greying walk `0x6e3d60`. use super::SpellModifiers; -use benilla_formats::{SpellDisplay, SpellRange}; +use benilla_formats::{RangeUnit, SpellDisplay, SpellRange}; /// The range refusals `CanTargetUnit 0x6e4440` emits: "Out of range." and "Target too close". pub(super) const ERR_OUT_OF_RANGE: u8 = 0x59; @@ -14,23 +14,23 @@ pub(super) const ERR_TOO_CLOSE: u8 = 0x76; /// The range refusal, before `ArmCast`/`SendCast`, so an out-of-range press never runs the commit /// tail (the ranged sheath snap `0x6e5930` included): squared 3D distance against /// [`benilla_formats::min_max_range`], beyond max² out of range, inside a nonzero min² too close. -/// `target_reach` is the bound unit's, the target `IsTargetInRange 0x6e47b0` hands -/// `GetMinMaxRange` (`0x6e47ca`). The function looks the auto-attack target up only when that -/// target is no unit, which a bound unit never is, so none is passed. No range row or no -/// distance passes; the server judges. +/// `target` is the bound unit, the target `IsTargetInRange 0x6e47b0` hands `GetMinMaxRange` +/// (`0x6e47ca`): its reach pads a ranged row and its motion, with the caster's, earns the moving +/// bonus. The function looks the auto-attack target up only when that target is no unit, which a +/// bound unit never is, so none is passed. No range row or no distance passes; the server judges. pub(super) fn cast_range_refusal( spell: &SpellDisplay, row: Option<&SpellRange>, - self_reach: f32, - target_reach: Option, + caster: RangeUnit, + target: Option, dist_sq: Option, mods: &SpellModifiers, ) -> Option { let targets = benilla_formats::RangeTargets { - target: target_reach, + target, attack_target: None, }; - let (min, max) = mods.min_max_range(spell, row, self_reach, targets)?; + let (min, max) = mods.min_max_range(spell, row, caster, targets)?; let d2 = dist_sq?; if d2 > max * max { return Some(ERR_OUT_OF_RANGE); @@ -251,10 +251,10 @@ mod tests { max: 35.0, flags: 0, }; - let reach = Some(1.5); + let me = RangeUnit::still(1.5); + let reach = Some(RangeUnit::still(1.5)); // Both bounds carry the bare reach pad (self 1.5 + target 1.5): min = 11, max = 38. - let refuse = - |d2: f32| cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, Some(d2), &mods); + let refuse = |d2: f32| cast_range_refusal(&d, Some(&auto_shot), me, reach, Some(d2), &mods); assert_eq!(refuse(3.0 * 3.0), Some(ERR_TOO_CLOSE)); assert_eq!(refuse(20.0 * 20.0), None); assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE)); @@ -265,7 +265,7 @@ mod tests { max: 35.0, flags: 0, }; - let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), 1.5, reach, Some(d2), &mods); + let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), me, reach, Some(d2), &mods); assert_eq!(refuse(0.1), None); assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE)); // The bound target is the pad's second reach (`0x6e47ca`): 35 + 1.5 + 1.5 = 38. @@ -273,7 +273,7 @@ mod tests { assert_eq!(refuse(39.0 * 39.0), Some(ERR_OUT_OF_RANGE)); // With no unit bound the row stands as it is: 35. assert_eq!( - cast_range_refusal(&d, Some(&fireball), 1.5, None, Some(37.0 * 37.0), &mods), + cast_range_refusal(&d, Some(&fireball), me, None, Some(37.0 * 37.0), &mods), Some(ERR_OUT_OF_RANGE) ); @@ -285,14 +285,14 @@ mod tests { }; let melee_spell = spell_with_range(2, 0); assert_eq!( - cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(0.1), &mods), + cast_range_refusal(&melee_spell, Some(&melee), me, reach, Some(0.1), &mods), None ); assert_eq!( cast_range_refusal( &melee_spell, Some(&melee), - 1.5, + me, reach, Some(15.0 * 15.0), &mods @@ -302,15 +302,55 @@ mod tests { // No row or no distance passes. assert_eq!( - cast_range_refusal(&d, None, 1.5, reach, Some(1.0), &mods), + cast_range_refusal(&d, None, me, reach, Some(1.0), &mods), None ); assert_eq!( - cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, None, &mods), + cast_range_refusal(&d, Some(&auto_shot), me, reach, None, &mods), None ); } + /// A warrior running after a running mob presses Hamstring (the melee row) at 7 yards: + /// `GetMinMaxRange` adds the moving bonus to the 5.0 floor (`0x6e3648`-`0x6e36a2`), so the + /// press goes out to 7.667, and a standing pair is refused at the same distance. + #[test] + fn cast_range_refusal_lets_two_running_units_reach_the_moving_bonus() { + let mods = SpellModifiers::default(); + let hamstring = spell_with_range(2, 0); + let melee = SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + let running = RangeUnit { + motion: benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }, + ..RangeUnit::still(1.5) + }; + let press = |caster, target, yards: f32| { + cast_range_refusal( + &hamstring, + Some(&melee), + caster, + Some(target), + Some(yards * yards), + &mods, + ) + }; + let standing = RangeUnit::still(1.5); + assert_eq!(press(running, running, 7.0), None); + assert_eq!(press(running, running, 7.5), None); + assert_eq!(press(running, running, 8.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(standing, standing, 7.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(running, standing, 7.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(standing, running, 7.0), Some(ERR_OUT_OF_RANGE)); + assert_eq!(press(standing, standing, 5.0), None); + } + /// The mounted refusal (`0x609c6c`) and its bit-24 exemption (`0x609c6f`). #[test] fn cast_mounted_refusal_honors_the_bit24_exemption() { diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index e5f49d2c..9ede17f4 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -77,7 +77,8 @@ pub(super) fn feed_action_state( mut cooldowns: ResMut, clock: Res, auto_repeat: Res, - // One tuple for Bevy's 16-param ceiling: cast tracking, macro bindings and spell mods. + // One tuple for Bevy's 16-param ceiling: cast tracking, macro bindings, spell mods and the + // range units. cast_state: ( Res, Res, @@ -85,6 +86,8 @@ pub(super) fn feed_action_state( Res, Res, Res, + // The caster's and the target's reach and motion, which the range compare reads. + crate::spell::RangeUnits, ), self_q: Query<(&ObjectStore, &Transform, Has, Option<&Casting>), With>, selection: Res, @@ -117,7 +120,7 @@ pub(super) fn feed_action_state( memory.last_cd_trace = Some(now); } - let (pending, queued_melee, channel, targeting, bound, spell_mods) = &cast_state; + let (pending, queued_melee, channel, targeting, bound, spell_mods, range_units) = &cast_state; let me = self_q.iter().next(); // The bags, walked once per frame for every reagent, totem and item count below. let carried = me @@ -129,14 +132,15 @@ pub(super) fn feed_action_state( .unwrap_or(0); let casting_spell = me.and_then(|(_, _, _, c)| c.map(|c| c.spell_id)); let current_cast = pending.current(now).or(casting_spell); - let self_reach = me.map_or(1.5, |(s, _, _, _)| s.0.unit_combat_reach()); + let caster = range_units.caster(); let self_pos = me.map(|(_, t, _, _)| t.translation); - // The target's reach and squared distance (the reference tests dx²+dy²+dz², `0x6e47b0`). - let target = selection - .guid - .and_then(|g| objects.entity(g)) - .and_then(|e| units.get(e).ok()); - let target_reach = target.map(|(s, _)| s.0.unit_combat_reach()); + // The target's reach, motion and squared distance (the reference tests dx²+dy²+dz², + // `0x6e47b0`). + let target_entity = selection.guid.and_then(|g| objects.entity(g)); + let target = target_entity.and_then(|e| units.get(e).ok()); + let target_unit = target_entity + .filter(|_| target.is_some()) + .and_then(|e| range_units.unit(e)); let dist_sq = match (self_pos, target) { (Some(a), Some((_, t))) => Some(a.distance_squared(t.translation)), _ => None, @@ -219,19 +223,20 @@ pub(super) fn feed_action_state( // and tests `|min|` and `|max|` against `FLT_EPSILON`. The auto-attack target the // melee arm falls back to cannot move that: its max is at least the 5.0 floor. st.has_range = spell_mods - .min_max_range(d, row, self_reach, RangeTargets::default()) + .min_max_range(d, row, caster, RangeTargets::default()) .is_some_and(|(min, max)| min.abs() > f32::EPSILON || max.abs() > f32::EPSILON); // `IsActionInRange 0x4e56f0` runs `CanTargetUnit 0x6e4440`, which binds the selected // target, then `IsTargetInRange 0x6e47b0`, which hands `GetMinMaxRange` that target - // (`0x6e47ca`): its reach pads a ranged row. Nil without a target. + // (`0x6e47ca`): its reach pads a ranged row, and its motion with the caster's earns + // the moving bonus. Nil without a target. st.in_range = match dist_sq { Some(d2) if st.has_range => { let targets = RangeTargets { - target: target_reach, + target: target_unit, attack_target: None, }; spell_mods - .min_max_range(d, row, self_reach, targets) + .min_max_range(d, row, caster, targets) .map(|(min, max)| d2 >= min * min && d2 <= max * max) } _ => None, @@ -606,6 +611,135 @@ mod tests { ); } + /// `IsActionInRange` hands `GetMinMaxRange` the selected unit, so a melee button reads in + /// range out to 7.667 yards while the player and the selected mob both run, and to the 5.0 + /// floor otherwise (`0x6e3648`-`0x6e36a2`). + #[test] + fn a_melee_button_reaches_the_moving_bonus_while_both_units_run() { + use benilla_protocol::messages::ActionButton; + use benilla_protocol::ObjectFields; + + const HAMSTRING: u32 = 1715; + const SELECTED: u64 = 0xB2; + + let verdict = |caster_runs: bool, mob_runs: bool, distance: f32| { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + let mut actions = PlayerActions::default(); + actions.buttons.insert( + 0, + ActionButton { + slot: 0, + action: HAMSTRING, + kind: ACTION_KIND_SPELL, + }, + ); + let melee = benilla_formats::SpellRange { + min: 0.0, + max: 5.0, + flags: 1, + }; + app.insert_resource(actions) + .insert_resource(crate::ui_macro::MacroBoundSpells::default()) + .insert_resource(Spells { + catalog: benilla_formats::SpellCatalog::from_displays( + [( + HAMSTRING, + SpellDisplay { + range_index: 2, + ..Default::default() + }, + )] + .into_iter() + .collect(), + ), + forms: Default::default(), + ranges: benilla_formats::SpellRangeCatalog::from_rows( + [(2, melee)].into_iter().collect(), + ), + cast_times: Default::default(), + durations: Default::default(), + radii: Default::default(), + }) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(crate::player::Player::with_move_flags(if caster_runs { + crate::creature_anim::move_flags::FORWARD + } else { + 0 + })) + .insert_resource(NetCommands(tx)); + let speeds = crate::net::UnitSpeeds(benilla_protocol::MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: 3.1, + }); + app.world_mut().spawn(( + SelfPlayer, + crate::net::Embodied, + speeds, + Transform::default(), + ObjectStore(ObjectFields::from_pairs(&[(22, 100), (23, 500)])), + )); + // A mob at the descriptor's default 1.5 reach; a live spline at 8 yd/s is its run. + let mob = app + .world_mut() + .spawn(( + speeds, + Transform::from_xyz(distance, 0.0, 0.0), + ObjectStore(ObjectFields::from_pairs(&[(22, 100)])), + )) + .id(); + if mob_runs { + app.world_mut().entity_mut(mob).insert(crate::net::Spline { + points: vec![[distance, 0.0, 0.0], [distance + 16.0, 0.0, 0.0]], + start: Instant::now(), + duration: std::time::Duration::from_secs(2), + id: 1, + grounded: true, + run_mode: true, + deck: None, + }); + } + app.world_mut() + .resource_mut::() + .0 + .insert(SELECTED, mob); + app.insert_resource(Selection { + target: Some(mob), + guid: Some(SELECTED), + last: None, + }); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.add_systems(Update, feed_action_state); + app.update(); + let script = app.world().non_send_resource::(); + script + .eval::("return IsActionInRange(1) or -1") + .unwrap() + }; + + // 1.5 + 1.5 + 1.3333 is under the 5.0 floor; the bonus goes on the floor: 7.667. + assert_eq!(verdict(true, true, 7.5), 1, "both running: inside 7.667"); + assert_eq!(verdict(true, true, 8.0), 0, "past it"); + assert_eq!(verdict(false, true, 7.5), 0, "the player stands"); + assert_eq!(verdict(true, false, 7.5), 0, "the mob stands"); + assert_eq!(verdict(false, false, 4.9), 1, "the floor"); + } + /// An item slot's on-use spell takes the `0x6e3d60` walk (`0x4e5050`), and every shipped /// Food/Drink spell has `Attributes` bit 28, `ATTR_NOT_IN_COMBAT` (the walk's leg 8). #[test] diff --git a/crates/benilla-app/src/ui_tooltip/deps_tests.rs b/crates/benilla-app/src/ui_tooltip/deps_tests.rs index 803f4790..d7a76293 100644 --- a/crates/benilla-app/src/ui_tooltip/deps_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/deps_tests.rs @@ -12,7 +12,7 @@ use std::collections::BTreeSet; use benilla_protocol::ObjectFields; use benilla_ui::script::SpellTooltipView; -use super::spell_deps::{Changes, Deps, Reagents, Seen, UnitField}; +use super::spell_deps::{Changes, Deps, RangeSeen, Reagents, Seen, UnitField}; use super::spell_feed::{build_view, reagent_state, PetInputs, ViewCaster, ViewCtx}; use super::tests::{real_spells, TestCtx}; use crate::items::Items; @@ -23,17 +23,23 @@ use crate::spell::usable::{ use crate::spell::{ModsDiff, SpellModifiers}; use crate::ui_action::Spells; use crate::ui_items::TestObjects; +use benilla_formats::{RangeUnit, UnitMotion}; /// One side of each input group, so that moving an input is an xor: `false` is the empty side (no -/// bind point, form 0, no percentages, default reach, nothing worn, no skills, zero cells), `true` -/// the populated one. +/// bind point, form 0, no percentages, default reach, no auto-attack target, both units standing, +/// nothing worn, no skills, zero cells), `true` the populated one. #[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] struct Inputs { home: bool, form: bool, /// A `Chance` bit each: block, dodge, parry, crit. avoid: u8, + /// The caster's reach and, with a target engaged, its reach. reach: bool, + /// An auto-attack target: the melee range cell's second unit. + engaged: bool, + /// The caster and the auto-attack target run: with a target engaged, the moving bonus. + pace: bool, /// The equipment slots holding an item that meets the view's requirement. worn: u32, /// The disarm flag. @@ -58,6 +64,8 @@ impl Inputs { form: true, avoid: 0xf, reach: true, + engaged: true, + pace: true, worn: EQUIPMENT_MASK, disarm: true, unit: 0x1ff, @@ -72,6 +80,8 @@ impl Inputs { form: self.form ^ o.form, avoid: self.avoid ^ o.avoid, reach: self.reach ^ o.reach, + engaged: self.engaged ^ o.engaged, + pace: self.pace ^ o.pace, worn: self.worn ^ o.worn, disarm: self.disarm ^ o.disarm, unit: self.unit ^ o.unit, @@ -121,6 +131,24 @@ impl Inputs { }, )); } + if self.engaged { + parts.push(( + "the auto-attack target".to_string(), + Inputs { + engaged: true, + ..alone + }, + )); + } + if self.pace { + parts.push(( + "the units' run".to_string(), + Inputs { + pace: true, + ..alone + }, + )); + } for slot in (0..EQUIPMENT_SLOTS).filter(|s| self.worn >> s & 1 == 1) { let only = Inputs { worn: 1 << slot, @@ -182,7 +210,9 @@ impl Inputs { home: !deps.home, form: !deps.form, avoid: !deps.avoidance & 0xf, - reach: !deps.reach, + reach: !deps.range_units, + engaged: !deps.range_units, + pace: !deps.range_units, worn: EQUIPMENT_MASK & !deps.worn, disarm: !deps.disarm, unit: !deps.unit & 0x1ff, @@ -264,7 +294,10 @@ struct Mat { store: ObjectStore, mods: SpellModifiers, home: Option<&'static str>, - target_reach: Option, + /// The caster as the range cell reads it: the store's reach, and running or not. + caster: RangeUnit, + /// The auto-attack target, when one is engaged. + attack_target: Option, form: u8, } @@ -323,6 +356,15 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { if inputs.reach { pairs.push((130, 4.0f32.to_bits())); } + let motion = if inputs.pace { + UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + } + } else { + UnitMotion::default() + }; let mut slot = 0; for (i, &line) in layout.lines.iter().enumerate() { if inputs.lines >> i & 1 == 1 { @@ -352,15 +394,29 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { store: ObjectStore(ObjectFields::from_pairs(&pairs)), mods, home: inputs.home.then_some("Zzz"), - // The two reaches move together: the caster's 1.5 to 4.0, and an auto-attack target at 3.0. - target_reach: inputs.reach.then_some(3.0), + // The two reaches move together: the caster's 1.5 to 4.0, and an auto-attack target's 4.0 + // to 6.0, which needs one engaged to read. The target's start clears the melee row's 5.0 + // floor beside the caster's 1.5, so its presence moves the range cell. + caster: RangeUnit { + reach: if inputs.reach { 4.0 } else { 1.5 }, + motion, + ..RangeUnit::still(1.5) + }, + attack_target: inputs.engaged.then_some(RangeUnit { + reach: if inputs.reach { 6.0 } else { 4.0 }, + motion, + ..RangeUnit::still(1.5) + }), form, } } /// What differs between two scenarios, through the feed's own diffs. fn changes(before: &Mat, now: &Mat, objects: &Objects, items: &Items) -> Changes { - let seen = |m: &Mat| Seen::of(Some(&m.store), m.home, m.target_reach, objects, items); + let seen = |m: &Mat| { + let range = RangeSeen::of(&m.caster, m.attack_target.as_ref()); + Seen::of(Some(&m.store), m.home, range, objects, items) + }; seen(now) .changes_since(&seen(before)) .with_mods(now.mods.diff(&before.mods)) @@ -466,7 +522,8 @@ fn build( t.spell_mods = m.mods.clone(); let mut ctx = t.ctx_for(objects, m.form, None, Some(&m.store)); ctx.home_area = m.home; - ctx.attack_target_reach = m.target_reach; + ctx.range_caster = m.caster; + ctx.attack_target = m.attack_target; build_view(id, spells, &mut ctx) } @@ -664,6 +721,26 @@ fn every_view_that_moves_with_an_input_is_requeued_by_that_input() { assert_covered("the combat reaches", &s); assert!(s.moved >= 100, "only {} views read a reach", s.moved); row("combat reach", &s); + // The melee range cell's second unit, engaged; and, with it engaged, both units running. + let engaged = Inputs { + engaged: true, + ..empty + }; + let s = sweep(&mut rig, empty, &any, &own, &cause(engaged)); + assert_covered("the auto-attack target", &s); + assert!(s.moved >= 100, "only {} views read the target", s.moved); + row("auto-attack target", &s); + let pace = Inputs { + pace: true, + ..empty + }; + let s = sweep(&mut rig, engaged, &any, &own, &cause(pace)); + assert_covered("the units' run", &s); + assert!(s.moved >= 100, "only {} views read the run", s.moved); + row("both units running", &s); + // With no target engaged the melee arm has no unit for the bonus: nothing moves. + let s = sweep(&mut rig, empty, &any, &own, &cause(pace)); + assert_eq!(s.moved, 0, "the moving bonus needs a unit to read"); let all_worn = Inputs { worn: EQUIPMENT_MASK, ..empty @@ -813,7 +890,8 @@ fn a_reagent_moving_requeues_the_views_that_list_it() { store, mods: mods.clone(), home: None, - target_reach: None, + caster: RangeUnit::still(1.5), + attack_target: None, form: 0, }; @@ -917,7 +995,7 @@ fn a_pet_view_reads_no_player_form_equipment_reach_or_unit_field_but_the_ranged_ }; assert!( !deps.form - && !deps.reach + && !deps.range_units && deps.worn == 0 && !deps.disarm && deps.unit & !UnitField::RangedTime.bit() == 0, @@ -980,9 +1058,14 @@ fn no_pet_field_moves_a_pet_view_unwatched() { Some(&player.store), ); ctx.caster = ViewCaster::Pet(Some(pet)); - ctx.combat_reach = pet.unit_combat_reach(); + ctx.range_caster = RangeUnit::still(pet.unit_combat_reach()); build_view(id, spells, &mut ctx).map(|(view, _)| view) }; + // The feed reads the pet's reach off its entity's store, which these fields stand in for. + let pet_inputs = |pet: &ObjectFields| { + let unit = RangeUnit::still(pet.unit_combat_reach()); + PetInputs::of(None, Some(pet), RangeSeen::of(&unit, None)) + }; let base = pet_with(0..0); let before: Vec<_> = ids.iter().map(|&id| build_one(t, id, &base)).collect(); let (mut moved, mut misses) = (0, Vec::new()); @@ -993,7 +1076,7 @@ fn no_pet_field_moves_a_pet_view_unwatched() { continue; } moved += 1; - if PetInputs::of(None, Some(&pet), None) != PetInputs::of(None, Some(&base), None) { + if pet_inputs(&pet) != pet_inputs(&base) { continue; } let culprits: Vec = (first..first + 48) @@ -1184,8 +1267,8 @@ fn the_search_answers_the_same_wherever_the_watched_state_is_the_same() { form: 0, store: Some(&store), caster: ViewCaster::Player, - combat_reach: 1.5, - attack_target_reach: None, + range_caster: RangeUnit::still(1.5), + attack_target: None, objects: &objects, items: &mut items, commands: &commands, @@ -1261,13 +1344,30 @@ fn the_seen_diff_names_each_input_it_saw_move() { let objects = objs.get(); let items = Items::default(); let player = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs)); - let seen = |s: &ObjectStore| Seen::of(Some(s), None, None, &objects, &items); + // The feed reads the caster's reach off its store, and passes the units it read. + let seen_with = |s: &ObjectStore, caster_motion: UnitMotion, attack: Option<&RangeUnit>| { + let caster = RangeUnit { + motion: caster_motion, + ..RangeUnit::still(s.0.unit_combat_reach()) + }; + Seen::of( + Some(s), + None, + RangeSeen::of(&caster, attack), + &objects, + &items, + ) + }; + let seen = |s: &ObjectStore| seen_with(s, UnitMotion::default(), None); let base = player(&[(22, 100)]); for (i, field) in [1106u16, 1107, 1108, 1109].into_iter().enumerate() { let now = player(&[(22, 100), (field, 4.5f32.to_bits())]); let c = seen(&now).changes_since(&seen(&base)); assert_eq!(c.avoidance, 1 << i, "percentage field {field}"); - assert_eq!((c.worn, c.form, c.reach, c.home), (0, false, false, false)); + assert_eq!( + (c.worn, c.form, c.range_units, c.home), + (0, false, false, false) + ); assert_eq!((c.unit, c.disarm), (0, false)); } // A guid the object index does not hold is an unresolved item: the slot moved. @@ -1282,12 +1382,59 @@ fn the_seen_diff_names_each_input_it_saw_move() { let now = player(&[(22, 100), (138, 1 << 16)]); assert!(seen(&now).changes_since(&seen(&base)).form); let now = player(&[(22, 100), (130, 3.0f32.to_bits())]); - assert!(seen(&now).changes_since(&seen(&base)).reach); - let engaged = Seen::of(Some(&base), None, Some(2.0), &objects, &items); - assert!(engaged.changes_since(&seen(&base)).reach); - let bound = Seen::of(Some(&base), Some("Ironforge"), None, &objects, &items); + assert!(seen(&now).changes_since(&seen(&base)).range_units); + // The auto-attack target: engaged, its reach, then each unit starting to run on its own. + let target = RangeUnit::still(2.0); + let engaged = seen_with(&base, UnitMotion::default(), Some(&target)); + assert!(engaged.changes_since(&seen(&base)).range_units); + let target_reach = RangeUnit::still(3.0); + let wider = seen_with(&base, UnitMotion::default(), Some(&target_reach)); + assert!(wider.changes_since(&engaged).range_units); + let running = UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }; + let target_runs = RangeUnit { + motion: running, + ..target + }; + // The moving bonus needs both units running with a target engaged: a run of either one alone + // moves nothing a melee row shows. + let caster_runs = seen_with(&base, running, Some(&target)); + assert!(caster_runs.changes_since(&engaged).is_empty()); + let target_run = seen_with(&base, UnitMotion::default(), Some(&target_runs)); + assert!(target_run.changes_since(&engaged).is_empty()); + let both_run = seen_with(&base, running, Some(&target_runs)); + assert!( + both_run.changes_since(&caster_runs).range_units, + "the target starts running beside a running caster" + ); + assert!( + both_run.changes_since(&target_run).range_units, + "the caster starts running beside a running target" + ); + // With no target engaged there is no unit for the bonus. + let alone = seen_with(&base, running, None); + assert!(alone.changes_since(&seen(&base)).is_empty()); + // A walk is not a run: the answer a melee row follows does not move. + let walking = UnitMotion { + flags: 1, + speed: 2.5, + walk_speed: 2.5, + }; + let caster_walk = seen_with(&base, walking, Some(&target_runs)); + assert!(caster_walk.changes_since(&target_run).is_empty()); + assert!(both_run.changes_since(&caster_walk).range_units); + let bound = Seen::of( + Some(&base), + Some("Ironforge"), + RangeSeen::default(), + &objects, + &items, + ); assert!(bound.changes_since(&seen(&base)).home); - let gone = Seen::of(None, None, None, &objects, &items); + let gone = Seen::of(None, None, RangeSeen::default(), &objects, &items); assert_eq!(gone.changes_since(&seen(&base)).worn, EQUIPMENT_MASK); assert!(seen(&base).changes_since(&seen(&base)).is_empty()); // The disarm flag, and no other flag. diff --git a/crates/benilla-app/src/ui_tooltip/feed_tests.rs b/crates/benilla-app/src/ui_tooltip/feed_tests.rs index 9737d1d8..113a42b6 100644 --- a/crates/benilla-app/src/ui_tooltip/feed_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/feed_tests.rs @@ -9,6 +9,38 @@ use crate::net::{NetCommands, ObjectStore, SelfPlayer}; use crate::ui_action::{PlayerActions, Spells}; use bevy::prelude::*; +/// `SpellRange.dbc` rows 2, the melee row, and 4, an authored 0 to 35. +fn melee_and_authored_rows() -> benilla_formats::SpellRangeCatalog { + let row = |min, max, flags| benilla_formats::SpellRange { min, max, flags }; + benilla_formats::SpellRangeCatalog::from_rows( + [(2, row(0.0, 5.0, 1)), (4, row(0.0, 35.0, 0))].into(), + ) +} + +/// A creature on a live spline at `speed` yd/s over two seconds: a mover at that speed. +fn on_spline(speed: f32) -> crate::net::Spline { + crate::net::Spline { + points: vec![[0.0; 3], [speed * 2.0, 0.0, 0.0]], + start: std::time::Instant::now(), + duration: std::time::Duration::from_secs(2), + id: 1, + grounded: true, + run_mode: true, + deck: None, + } +} + +fn walking_and_running_speeds() -> crate::net::UnitSpeeds { + crate::net::UnitSpeeds(benilla_protocol::MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: 3.1, + }) +} + /// A feed over a bare app: the book holds `make(1)`'s spells, the player is `fields`, and each /// description names the generation of the catalog it was built from. The feed re-pushes a view /// only when it must, so which generation a tooltip shows says which views were rebuilt. @@ -82,6 +114,7 @@ impl FeedApp { let catalog = (self.make)(generation).into_iter().collect(); self.app.insert_resource(Spells { catalog: benilla_formats::SpellCatalog::from_displays(catalog), + ranges: melee_and_authored_rows(), ..Spells::empty_for_tests() }); } @@ -237,3 +270,180 @@ fn a_modifier_change_rebuilds_only_the_matching_familys_views() { "the mage spells with bit 40" ); } + +/// The melee range cell reads the two units' motion (`0x6e3648`-`0x6e36a2`): the caster and its +/// auto-attack target both running at run speed, or ceasing to, rebuilds the views on the melee +/// row and no other, and a change that leaves the bonus where it was rebuilds nothing. +#[test] +fn a_units_run_rebuilds_only_the_melee_range_views() { + use crate::creature_anim::{move_flags, Engaged}; + use crate::net::Embodied; + + const MOB: u64 = 0xF130_0000_0000_0009; + let spell = |range_index: u32, generation: u32| benilla_formats::SpellDisplay { + name: "Spell".into(), + range_index, + description: Some(format!("gen{generation}")), + ..Default::default() + }; + let spells = move |g: u32| { + vec![ + (30, spell(2, g)), // the melee row + (31, spell(4, g)), // an authored row, which reads no unit + (32, spell(0, g)), // no row at all + ] + }; + let mut feed = FeedApp::new(spells, &[(22, 100)]); + let speeds = walking_and_running_speeds(); + let mob = feed + .app + .world_mut() + .spawn((speeds, ObjectStore(ObjectFields::from_pairs(&[(22, 100)])))) + .id(); + feed.app + .world_mut() + .resource_mut::() + .0 + .insert(MOB, mob); + let player = feed.player; + feed.app + .world_mut() + .entity_mut(player) + .insert((Embodied, speeds, Engaged(MOB))); + let flags = |feed: &mut FeedApp, flags: u32| { + feed.app + .insert_resource(crate::player::Player::with_move_flags(flags)); + feed.app.update(); + }; + flags(&mut feed, 0); + assert_eq!(feed.generations(), [1, 1, 1]); + + // The bonus needs both units running: the caster alone changes nothing a view shows. + feed.catalog(2); + flags(&mut feed, move_flags::FORWARD); + assert_eq!(feed.generations(), [1, 1, 1], "the caster starts to run"); + + feed.catalog(3); + feed.app.world_mut().entity_mut(mob).insert(on_spline(8.0)); + feed.app.update(); + assert_eq!( + feed.generations(), + [3, 1, 1], + "its target starts to run: the moving bonus" + ); + + // Still running, by another flag: each unit's answer is where it was. + feed.catalog(4); + flags(&mut feed, move_flags::FORWARD | move_flags::STRAFE_LEFT); + assert_eq!(feed.generations(), [3, 1, 1], "nothing was rebuilt"); + + // A walk is no run. + feed.catalog(5); + flags(&mut feed, move_flags::FORWARD | move_flags::WALK_MODE); + assert_eq!(feed.generations(), [5, 1, 1], "the caster walks"); +} + +/// The pet's melee views read the pet's and its own auto-attack target's motion: a pet chasing a +/// running mob reads the moving bonus in its range cell, and stops when either halts. +#[test] +fn a_pet_view_reads_the_pets_and_its_targets_run() { + use crate::creature_anim::Engaged; + use benilla_ui::script::{PetBookState, SpellSlotView}; + + const ME: u64 = 0x10; + const IMP: u64 = 0xF140_0000_0000_0077; + const MOB: u64 = 0xF130_0000_0000_0009; + let claw = benilla_formats::SpellDisplay { + id: 1082, + name: "Claw".into(), + range_index: 2, + ..Default::default() + }; + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(Spells { + catalog: benilla_formats::SpellCatalog::from_displays([(1082, claw)].into()), + ranges: melee_and_authored_rows(), + ..Spells::empty_for_tests() + }) + .insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, feed_spell_tooltips); + let speeds = walking_and_running_speeds(); + let store = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs)); + let me = app + .world_mut() + .spawn(( + SelfPlayer, + store(&[(8, IMP as u32), (9, (IMP >> 32) as u32), (34, 60)]), + )) + .id(); + let imp = app + .world_mut() + .spawn((speeds, Engaged(MOB), store(&[(34, 14)]))) + .id(); + let mob = app.world_mut().spawn((speeds, store(&[(22, 100)]))).id(); + let mut index = app.world_mut().resource_mut::(); + index.0.insert(ME, me); + index.0.insert(IMP, imp); + index.0.insert(MOB, mob); + + let mut script = UiScript::new().unwrap(); + script.set_pet_book(PetBookState { + token: Some("CAT".into()), + slots: vec![SpellSlotView { + spell_id: 1082, + name: "Claw".into(), + ..Default::default() + }], + }); + script + .run( + r#" + SPELL_RANGE = "%s yd range" + local a = CreateFrame("Button", "B"); a:SetPoint("CENTER", 0, 0) + a:SetWidth(10); a:SetHeight(10) + CreateFrame("GameTooltip", "TT") + function RANGE() + TT:SetOwner(B, "ANCHOR_RIGHT"); TT:SetSpell(1, "pet") + for k = 1, TT:NumLines() do + for _, side in ipairs({"TTTextLeft", "TTTextRight"}) do + local t = getglobal(side .. k):GetText() + if t and string.find(t, "yd range") then return t end + end + end + end + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + let range = |app: &mut App| { + app.update(); + let script = app.world().non_send_resource::(); + script.eval::>("return RANGE()").unwrap() + }; + let _ = range(&mut app); + assert_eq!(range(&mut app).as_deref(), Some("5 yd range"), "both stand"); + app.world_mut().entity_mut(imp).insert(on_spline(8.0)); + assert_eq!( + range(&mut app).as_deref(), + Some("5 yd range"), + "the pet runs, its target stands" + ); + app.world_mut().entity_mut(mob).insert(on_spline(8.0)); + assert_eq!( + range(&mut app).as_deref(), + Some("8 yd range"), + "both run: 5.0 + 2.6667" + ); + app.world_mut() + .entity_mut(mob) + .remove::(); + assert_eq!( + range(&mut app).as_deref(), + Some("5 yd range"), + "the target halts" + ); +} diff --git a/crates/benilla-app/src/ui_tooltip/spell_deps.rs b/crates/benilla-app/src/ui_tooltip/spell_deps.rs index 9dba4386..13242dd2 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_deps.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_deps.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; +use benilla_formats::RangeUnit; use benilla_protocol::ObjectFields; use crate::items::Items; @@ -124,9 +125,9 @@ pub(super) struct Deps { pub(super) home: bool, /// The required-form test read the shapeshift form. pub(super) form: bool, - /// The range cell's melee arm read the caster's or the auto-attack target's combat reach; - /// the ranged arm, given no target, reads neither. - pub(super) reach: bool, + /// The range cell's melee arm read the caster's and the auto-attack target's combat reach and + /// motion; the ranged arm, given no target, reads neither. + pub(super) range_units: bool, /// The chance line read these percentages, a [`Chance`] bit each. pub(super) avoidance: u8, /// The equipped-item search read these equipment slots, a bit each. @@ -183,7 +184,7 @@ impl Deps { changes.everything || (changes.home && self.home) || (changes.form && self.form) - || (changes.reach && self.reach) + || (changes.range_units && self.range_units) || changes.avoidance & self.avoidance != 0 || changes.worn & self.worn != 0 || (changes.disarm && self.disarm) @@ -207,7 +208,7 @@ pub(super) struct Changes { pub(super) everything: bool, pub(super) home: bool, pub(super) form: bool, - pub(super) reach: bool, + pub(super) range_units: bool, /// A [`Chance`] bit each. pub(super) avoidance: u8, /// The equipment slots whose item moved in a way the search can tell, a bit each. @@ -226,7 +227,7 @@ impl Changes { !self.everything && !self.home && !self.form - && !self.reach + && !self.range_units && self.avoidance == 0 && self.worn == 0 && !self.disarm @@ -259,6 +260,29 @@ impl Changes { /// inline red, `0x854120`). pub(super) type Reagents = BTreeMap; +/// What the range cell's melee arm reads of two units: the caster's combat reach and its +/// auto-attack target's, as bit patterns (`None` with none engaged, where the arm reads the +/// caster's reach twice), and whether the moving bonus applies, the one fact of the units' motion +/// the cell's number follows: both move at run speed +/// ([`benilla_formats::UnitMotion::moves_at_run_speed`]) with a target engaged to read. +#[derive(Clone, Copy, Default, PartialEq)] +pub(super) struct RangeSeen { + caster_reach: u32, + attack_target_reach: Option, + bonus: bool, +} + +impl RangeSeen { + pub(super) fn of(caster: &RangeUnit, attack_target: Option<&RangeUnit>) -> Self { + Self { + caster_reach: caster.reach.to_bits(), + attack_target_reach: attack_target.map(|u| u.reach.to_bits()), + bonus: caster.motion.moves_at_run_speed() + && attack_target.is_some_and(|u| u.motion.moves_at_run_speed()), + } + } +} + /// The player-side inputs one frame saw, which the pushed views were built against. #[derive(Clone, PartialEq)] pub(super) struct Seen { @@ -275,8 +299,8 @@ pub(super) struct Seen { disarmed: bool, /// The [`Chance`] percentages as bit patterns. avoidance: [Option; 4], - /// The caster's and its auto-attack target's combat reach as bit patterns. - reach: (Option, Option), + /// The caster's and its auto-attack target's reach, and whether the moving bonus applies. + range_units: RangeSeen, /// The unit fields the cost, cast and cooldown cells read. unit: UnitFields, } @@ -285,7 +309,7 @@ impl Seen { pub(super) fn of( player: Option<&ObjectStore>, home: Option<&str>, - attack_target_reach: Option, + range_units: RangeSeen, objects: &Objects, items: &Items, ) -> Self { @@ -298,10 +322,7 @@ impl Seen { disarmed: player.is_some_and(crate::items::is_disarmed), avoidance: Chance::ALL .map(|which| player.and_then(|s| which.read(&s.0)).map(f32::to_bits)), - reach: ( - player.map(|s| s.0.unit_combat_reach().to_bits()), - attack_target_reach.map(f32::to_bits), - ), + range_units, unit: UnitFields::of(player), } } @@ -311,7 +332,7 @@ impl Seen { let mut changes = Changes { home: self.home != prev.home, form: self.form != prev.form, - reach: self.reach != prev.reach, + range_units: self.range_units != prev.range_units, disarm: self.disarmed != prev.disarmed, unit: self.unit.diff(&prev.unit), ..Changes::default() diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index dd0701ce..ec122449 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -12,7 +12,7 @@ use benilla_ui::script::UiScript; use benilla_ui::strings::Arg; use super::keyed; -use super::spell_deps::{Chance, Changes, Deps, Reagents, Seen, UnitField}; +use super::spell_deps::{Chance, Changes, Deps, RangeSeen, Reagents, Seen, UnitField}; use crate::items::Items; use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; use crate::spell::usable::{self, CostBasis}; @@ -53,11 +53,13 @@ pub(super) struct ViewCtx<'a, 'w, 's> { /// Whom the level terms and the cost read: the player or its pet. The form and the reaches /// are that same unit's. pub(super) caster: ViewCaster<'a>, - /// The caster's `UNIT_FIELD_COMBATREACH`; 1.5 is the descriptor default. - pub(super) combat_reach: f32, - /// The caster's auto-attack target's reach, which `0x6e3480` looks up itself - /// (`[caster+0xc48]`) for its melee arm alone; with none, the caster's reach counts twice. - pub(super) attack_target_reach: Option, + /// The caster as `0x6e3480` reads it: its `UNIT_FIELD_COMBATREACH` (1.5 is the descriptor + /// default) and its motion. + pub(super) range_caster: benilla_formats::RangeUnit, + /// The caster's auto-attack target, which `0x6e3480` looks up itself (`[caster+0xc48]`) for + /// its melee arm alone, reading its reach and motion; with none, the caster's reach counts + /// twice and there is no moving bonus. + pub(super) attack_target: Option, /// The object index the worn-item search and each reagent's carried count resolve through. pub(super) objects: &'a Objects<'w, 's>, pub(super) items: &'a mut Items, @@ -186,19 +188,19 @@ pub(super) fn build_view( let row = spells.ranges.get(d.range_index); // The pet views read the pet's reaches, which are among the pet's own inputs. if matches!(caster, ViewCaster::Player) - && benilla_formats::min_max_range_reads_reach(d, row) + && benilla_formats::min_max_range_reads_units(d, row) { - deps.borrow_mut().reach = true; + deps.borrow_mut().range_units = true; } // The call passes a null target (`0x52e9c2`): the auto-attack target is the melee arm's // own lookup, and the ranged arm, with no target, pads nothing. let targets = benilla_formats::RangeTargets { target: None, - attack_target: vctx.attack_target_reach, + attack_target: vctx.attack_target, }; let (min, max) = vctx .spell_mods - .min_max_range(d, row, vctx.combat_reach, targets)?; + .min_max_range(d, row, vctx.range_caster, targets)?; if max <= 0.0 { return None; } @@ -477,7 +479,7 @@ pub(super) fn reagent_state( } /// The pet unit's fields a pet view reads (`0x52e610` with the unit selector set): its level, the -/// cost bases `0x612c50` reads, its form, and its and its melee target's reach, as bit patterns. +/// cost bases `0x612c50` reads, its form, and its and its melee target's reach and motion. #[derive(Clone, Copy, PartialEq, Default)] pub(super) struct PetInputs { guid: Option, @@ -486,15 +488,14 @@ pub(super) struct PetInputs { max_health: u32, max_power: [u32; 5], form: u8, - combat_reach: u32, - attack_target_reach: Option, + range_units: RangeSeen, } impl PetInputs { pub(super) fn of( guid: Option, pet: Option<&benilla_protocol::ObjectFields>, - attack_target_reach: Option, + range_units: RangeSeen, ) -> Self { let Some(p) = pet else { return Self { @@ -509,8 +510,7 @@ impl PetInputs { max_health: p.unit_max_health().unwrap_or(0), max_power: std::array::from_fn(|i| p.unit_max_power(i as u8).unwrap_or(0)), form: p.unit_shapeshift_form(), - combat_reach: p.unit_combat_reach().to_bits(), - attack_target_reach: attack_target_reach.map(f32::to_bits), + range_units, } } } @@ -532,9 +532,9 @@ pub(super) fn feed_spell_tooltips( actions: Option>, spell_sources: SpellTooltipSources, self_q: Query<&ObjectStore, With>, - // The player's and the pet's auto-attack target, the melee range cell's second reach. - engaged_q: Query<&crate::creature_anim::Engaged, With>, - engaged_units: Query<&crate::creature_anim::Engaged>, + // The caster's and the pet's reach and motion and each one's auto-attack target, which the + // melee range cell reads. + range_units: crate::spell::RangeUnits, objects: Objects, home_bind: Option>, area_names: Option>, @@ -625,17 +625,16 @@ pub(super) fn feed_spell_tooltips( .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)) .map(str::to_string); let self_store = self_q.single().ok(); - let target_reach = - |e: &crate::creature_anim::Engaged| objects.object(e.0).map(|f| f.unit_combat_reach()); - let attack_target_reach = engaged_q.single().ok().and_then(target_reach); + let caster_unit = range_units.caster(); + let attack_target = range_units.caster_attack_target(); // What moved since the last frame, which each pushed view is checked against: the bind // point (Astral Recall's `$z`), the form, the skills, what the equipped-item search learns of // each equipment slot and the disarm flag, the level, cost bases and ranged attack time, the - // block, dodge, parry and crit percentages, and the two combat reaches. + // block, dodge, parry and crit percentages, and the two units the melee range cell reads. let seen = Seen::of( self_store, home_area.as_deref(), - attack_target_reach, + RangeSeen::of(&caster_unit, attack_target.as_ref()), &objects, &items, ); @@ -667,15 +666,19 @@ pub(super) fn feed_spell_tooltips( changes = changes.with_reagents(&memory.reagents, &reagent_state); memory.reagents = reagent_state; memory.requeue(&changes, &mut wanted, &mut wanted_pet); - // The pet's inputs: a new pet, or its level, bases, form or reach moving, rebuilds the pet - // views alone. + // The pet's inputs: a new pet, or its level, bases, form, reach or motion moving, rebuilds the + // pet views alone. let pet_guid = self_store.and_then(|s| s.0.unit_pet_guid()); let pet = pet_guid.and_then(|g| objects.object(g)); - let pet_target_reach = pet_guid - .and_then(|g| objects.entity(g)) - .and_then(|e| engaged_units.get(e).ok()) - .and_then(target_reach); - let pet_inputs = PetInputs::of(pet_guid, pet, pet_target_reach); + let pet_entity = pet_guid.and_then(|g| objects.entity(g)); + let pet_unit = + pet_entity + .and_then(|e| range_units.unit(e)) + .unwrap_or(benilla_formats::RangeUnit::still( + crate::spell::DEFAULT_REACH, + )); + let pet_target = pet_entity.and_then(|e| range_units.attack_target(e)); + let pet_inputs = PetInputs::of(pet_guid, pet, RangeSeen::of(&pet_unit, pet_target.as_ref())); if memory.pet != Some(pet_inputs) { memory.pet = Some(pet_inputs); wanted_pet.extend(memory.pet_pushed.drain().map(|(id, _)| id)); @@ -693,8 +696,8 @@ pub(super) fn feed_spell_tooltips( form: self_store.map_or(0, |s| s.0.unit_shapeshift_form()), store: self_store, caster: ViewCaster::Player, - combat_reach: self_store.map_or(1.5, |s| s.0.unit_combat_reach()), - attack_target_reach, + range_caster: caster_unit, + attack_target, objects: &objects, items: &mut items, commands: &commands, @@ -707,8 +710,8 @@ pub(super) fn feed_spell_tooltips( // The pet views read the pet's form and reach where the player's read the player's. vctx.caster = ViewCaster::Pet(pet); vctx.form = pet.map_or(0, |p| p.unit_shapeshift_form()); - vctx.combat_reach = pet.map_or(1.5, |p| p.unit_combat_reach()); - vctx.attack_target_reach = pet_target_reach; + vctx.range_caster = pet_unit; + vctx.attack_target = pet_target; let built_pet = build_views(wanted_pet, spells, &mut vctx, memory, true); (built, built_pet) }; diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index bb2749cd..28d76aa6 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -62,7 +62,7 @@ impl TestCtx { target_reach: f32, ) -> ViewCtx<'a, 'w, 's> { let mut ctx = self.ctx_for(objects, 0, None, store); - ctx.attack_target_reach = Some(target_reach); + ctx.attack_target = Some(benilla_formats::RangeUnit::still(target_reach)); ctx } @@ -78,8 +78,10 @@ impl TestCtx { form, store, caster: ViewCaster::Player, - combat_reach: store.map_or(1.5, |s| s.0.unit_combat_reach()), - attack_target_reach: None, + range_caster: benilla_formats::RangeUnit::still( + store.map_or(1.5, |s| s.0.unit_combat_reach()), + ), + attack_target: None, objects, items: &mut self.items, commands: &self.commands, @@ -612,6 +614,33 @@ fn range_cell_on_real_data() { .expect("Sinister Strike view"); assert_eq!(v.range.as_deref(), Some("7 yd range")); + // Both units running: the moving bonus goes on the floor, 5.0 + 2.6667 = 7.667, rounded to 8. + let running = benilla_formats::UnitMotion { + flags: 1, + speed: 7.0, + walk_speed: 2.5, + }; + let runner = benilla_formats::RangeUnit { + motion: running, + ..benilla_formats::RangeUnit::still(1.5) + }; + let mut ctx = t.ctx_for(&objects, 0, None, Some(&store)); + ctx.range_caster = runner; + ctx.attack_target = Some(runner); + let v = spell_tooltip_view(1752, &spells, &mut ctx).expect("Sinister Strike view"); + assert_eq!(v.range.as_deref(), Some("8 yd range")); + // One of them standing, or no auto-attack target to read: the floor. + ctx.attack_target = Some(benilla_formats::RangeUnit::still(1.5)); + let v = spell_tooltip_view(1752, &spells, &mut ctx).expect("Sinister Strike view"); + assert_eq!(v.range.as_deref(), Some("5 yd range")); + ctx.attack_target = None; + let v = spell_tooltip_view(1752, &spells, &mut ctx).expect("Sinister Strike view"); + assert_eq!(v.range.as_deref(), Some("5 yd range")); + // The ranged arm passes no target, so the bonus never reaches an authored row: Fireball's 35. + ctx.attack_target = Some(runner); + let v = spell_tooltip_view(133, &spells, &mut ctx).expect("Fireball view"); + assert_eq!(v.range.as_deref(), Some("35 yd range")); + // An authored row: Fireball's 0-35. let v = spell_tooltip_view( 133, diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f10634c1..87e044a1 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -197,16 +197,16 @@ mod spells; pub use spells::{ cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii, - load_spell_ranges, min_max_range, min_max_range_reads_reach, soft_modify, substitute, - CcExemption, FormRefusal, LearnAnnouncement, LearnEffect, OpenLock, RangeTargets, + load_spell_ranges, min_max_range, min_max_range_reads_units, soft_modify, substitute, + CcExemption, FormRefusal, LearnAnnouncement, LearnEffect, OpenLock, RangeTargets, RangeUnit, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellMods, SpellRadius, SpellRadiusCatalog, - SpellRange, SpellRangeCatalog, TokenContext, TokenNumber, ATTR_CASTABLE_WHILE_DEAD, + SpellRange, SpellRangeCatalog, TokenContext, TokenNumber, UnitMotion, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, - ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, - SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, - SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, - SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, + MOVING_BONUS_FLAGS, MOVING_RANGE_BONUS, ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, + SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, + SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, + SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, }; mod skill_lines; pub use skill_lines::{ diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 2cfad737..c3a49cb4 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -32,8 +32,9 @@ mod immunity; pub use immunity::{cc_exemption, grants_immunity, CcExemption}; pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog}; pub use ranges::{ - load_spell_ranges, min_max_range, min_max_range_reads_reach, RangeTargets, SpellRange, - SpellRangeCatalog, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, + load_spell_ranges, min_max_range, min_max_range_reads_units, RangeTargets, RangeUnit, + SpellRange, SpellRangeCatalog, UnitMotion, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, + MOVING_BONUS_FLAGS, MOVING_RANGE_BONUS, ON_NEXT_SWING_RANGE, }; pub use soft_float::modify as soft_modify; pub use tokens::{substitute, SpellMods, TokenContext, TokenNumber}; diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs index 21b055e7..ec7502da 100644 --- a/crates/benilla-formats/src/spells/ranges.rs +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -33,18 +33,78 @@ pub const MELEE_RANGE_FLOOR: f32 = 5.0; /// The on-next-swing short-circuit's max, `[0x8118d4]` (`0x6e3504`). pub const ON_NEXT_SWING_RANGE: f32 = 100.0; -/// The combat reaches `GetMinMaxRange` (`0x6e3480`) reads besides the caster's own, `None` where -/// that unit is absent. The two are different inputs: only the first is an argument of the call. +/// The movement-flag bits the moving bonus tests of each unit, `0x200d` (`0x6e3658`): forward, +/// either strafe, and jumping. Backward, turning and swimming are not among them. +pub const MOVING_BONUS_FLAGS: u32 = 0x200d; +/// The moving bonus, `[0x811914]` (`0x6e369c`), 2.6667: added to the max after both arms. +pub const MOVING_RANGE_BONUS: f32 = f32::from_bits(0x402a_aaab); + +/// One unit's motion as the moving bonus reads it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct UnitMotion { + /// The movement flags word, `[[unit+0x118]+0x40]` (`0x6e364f`-`0x6e3655`, `0x6e3661`). + pub flags: u32, + /// The current speed, `0x7c4c90` on `[unit+0x9a8]` (`0x5fc35c`-`0x5fc365`): 0 with no + /// direction bit, else the unit's spline speed, or the swim, walk, run or run-back speed the + /// flags pick. + pub speed: f32, + /// The walk speed, `[unit+0xa30]` (`0x5fc354`). + pub walk_speed: f32, +} + +impl UnitMotion { + /// `0x5fc350` answering 0: the current speed is above twice the walk speed. The reference + /// doubles the walk speed (`fadd st, st`), compares it with the speed (`fcompp`) and tests + /// C0 (`test ah, 1`, `0x5fc373`-`0x5fc376`), which is set by a doubled walk speed below the + /// speed and by an unordered pair, so a NaN reads as running and an equal pair as walking. + pub fn is_running(&self) -> bool { + let doubled = 2.0 * self.walk_speed; + self.speed > doubled || self.speed.is_nan() || doubled.is_nan() + } + + /// One unit's half of the bonus test: a movement flag in [`MOVING_BONUS_FLAGS`] + /// (`0x6e364c`-`0x6e366a`) and [`Self::is_running`] (`0x6e366c`-`0x6e3681`). + pub fn moves_at_run_speed(&self) -> bool { + self.flags & MOVING_BONUS_FLAGS != 0 && self.is_running() + } +} + +/// A unit as `GetMinMaxRange` (`0x6e3480`) reads it: its combat reach, whether it is a player, and +/// its motion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RangeUnit { + /// `UNIT_FIELD_COMBATREACH`, `[[unit+0x110]+0x1f0]`. + pub reach: f32, + /// `OBJECT_FIELD_TYPE` bit 4 (`0x6e3689`-`0x6e3695`). Only a unit target's read counts: the + /// ranged arm's bonus asks it of the target, and the melee arm's never does. The caster's + /// own is the gate of the `Attributes & 2` scale (`0x6e36b4`-`0x6e36bf`), which is not applied. + pub player: bool, + pub motion: UnitMotion, +} + +impl RangeUnit { + /// A unit that stands still: no motion, so it never earns the moving bonus. + pub fn still(reach: f32) -> Self { + Self { + reach, + player: false, + motion: UnitMotion::default(), + } + } +} + +/// The units `GetMinMaxRange` (`0x6e3480`) reads besides the caster, `None` where that unit is +/// absent. The two are different inputs: only the first is an argument of the call. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RangeTargets { /// The call's `target` argument (`[ebx+0x10]`), when it is a unit (typemask bit 3, /// `0x6e34e1`-`0x6e34f9`): a null target, and one that is no unit, read `None`. Both arms /// read its reach, and the ranged arm pads only for a target passed in (`0x6e35d8`). - pub target: Option, + pub target: Option, /// The caster's auto-attack target, `[caster+0xc48]`, which the function looks up itself /// (`0x47bf60`, `0x6e356a`). Only the melee arm reads it, and only with no unit target passed /// (`0x6e3552`-`0x6e3584`). A caller that has no such unit to hand passes `None`. - pub attack_target: Option, + pub attack_target: Option, } /// `GetMinMaxRange` (`0x6e3480`): a spell's `{min, max}` cast range, summed in `f64` because the @@ -55,55 +115,71 @@ pub struct RangeTargets { /// pads it: the max always, and the min only when nonzero (the `fcomp`-vs-0.0 guard), so a min-0 /// spell never refuses `TOO_CLOSE`. `None`: no row, or the self row (id 1, `{0, 0}`). /// -/// Not applied: the PvP `max += 2.6667` (`0x6e3648`, gated by `0x5fc350`), and for a player the -/// `Attributes & 2` scale (`0x6e36aa`), `max *= RangedModRange · 0.01` of the ranged-slot item, -/// which a relic (range mod 0, InventoryType 28, admitted by `0x809200`) would zero, though no -/// spell a relic class learns carries the bit. +/// After both arms the max takes the moving bonus ([`MOVING_RANGE_BONUS`], `0x6e3648`-`0x6e36a2`) +/// when there is a unit (the target, or on the melee arm the auto-attack target), the caster and +/// that unit each [`UnitMotion::moves_at_run_speed`], and the row is the melee row or the unit a +/// player. It comes after the melee floor and before the range modifier (op 5, `0x6e3746`), and +/// the min never takes it. The on-next-swing short-circuit skips it. +/// +/// Not applied: for a player the `Attributes & 2` scale (`0x6e36aa`), `max *= RangedModRange · +/// 0.01` of the ranged-slot item, which a relic (range mod 0, InventoryType 28, admitted by +/// `0x809200`) would zero, though no spell a relic class learns carries the bit. pub fn min_max_range( spell: &crate::spells::SpellDisplay, row: Option<&SpellRange>, - caster_reach: f32, + caster: RangeUnit, targets: RangeTargets, ) -> Option<(f32, f32)> { if spell.on_next_swing() { return Some((0.0, ON_NEXT_SWING_RANGE)); } let row = row?; - if row.is_melee() { - let reach = targets - .target - .or(targets.attack_target) - .unwrap_or(caster_reach); - let sum = f64::from(reach) + f64::from(caster_reach) + f64::from(COMBAT_REACH_ADD); + // The `esi` both arms leave (`0x6e3648`): the unit the bonus reads, if any. + let (min, max, unit) = if row.is_melee() { + let unit = targets.target.or(targets.attack_target); + let reach = unit.map_or(caster.reach, |u| u.reach); + let sum = f64::from(reach) + f64::from(caster.reach) + f64::from(COMBAT_REACH_ADD); let max = if sum > f64::from(MELEE_RANGE_FLOOR) { sum as f32 } else { MELEE_RANGE_FLOOR }; - return Some((0.0, max)); - } - if row.min == 0.0 && row.max == 0.0 { - return None; - } - let Some(target_reach) = targets.target else { - return Some((row.min, row.max)); - }; - let pad = f64::from(caster_reach) + f64::from(target_reach); - let min = if row.min == 0.0 { - 0.0 + (0.0, max, unit) } else { - (pad + f64::from(row.min)) as f32 + if row.min == 0.0 && row.max == 0.0 { + return None; + } + let Some(target) = targets.target else { + return Some((row.min, row.max)); + }; + let pad = f64::from(caster.reach) + f64::from(target.reach); + let min = if row.min == 0.0 { + 0.0 + } else { + (pad + f64::from(row.min)) as f32 + }; + (min, (f64::from(row.max) + pad) as f32, Some(target)) }; - Some((min, (f64::from(row.max) + pad) as f32)) + let earns_bonus = |unit: &RangeUnit| { + caster.motion.moves_at_run_speed() + && unit.motion.moves_at_run_speed() + && (row.is_melee() || unit.player) + }; + let max = if unit.as_ref().is_some_and(earns_bonus) { + (f64::from(max) + f64::from(MOVING_RANGE_BONUS)) as f32 + } else { + max + }; + Some((min, max)) } -/// Whether [`min_max_range`] reads a reach for this spell and row when it is passed no unit -/// target, as the spell tooltip calls it (`push 0`, `0x52e9c2`): only the melee arm, which sums -/// the auto-attack target's reach, else the caster's own, with the caster's. The ranged arm pads -/// only for a target passed in, so a tooltip prints its row as it is; the on-next-swing -/// short-circuit, the missing row and the self row read none. With a unit target the ranged arm -/// reads both reaches too. -pub fn min_max_range_reads_reach( +/// Whether [`min_max_range`] reads the units it is not handed for this spell and row when it is +/// passed no unit target, as the spell tooltip calls it (`push 0`, `0x52e9c2`): only the melee +/// arm, which sums the auto-attack target's reach, else the caster's own, with the caster's, and +/// asks both units' motion for the moving bonus. The ranged arm pads only for a target passed in, +/// so a tooltip prints its row as it is; the on-next-swing short-circuit, the missing row and the +/// self row read none. With a unit target the ranged arm reads both units too. +pub fn min_max_range_reads_units( spell: &crate::spells::SpellDisplay, row: Option<&SpellRange>, ) -> bool { @@ -175,8 +251,25 @@ mod tests { /// auto-attack target that the melee arm looks up itself. fn reaches(target: Option, attack: Option) -> RangeTargets { RangeTargets { - target, - attack_target: attack, + target: target.map(RangeUnit::still), + attack_target: attack.map(RangeUnit::still), + } + } + + const FORWARD: u32 = 0x1; + const BACKWARD: u32 = 0x2; + /// A unit running forward: the speed `0x7c4c90` answers for it is the run speed, above twice + /// the walk speed. + const RUNNING: UnitMotion = UnitMotion { + flags: FORWARD, + speed: 7.0, + walk_speed: 2.5, + }; + + fn runner(reach: f32) -> RangeUnit { + RangeUnit { + motion: RUNNING, + ..RangeUnit::still(reach) } } @@ -209,35 +302,74 @@ mod tests { // Two 1.5-reach units (4.333) floor at 5.0, let d = spell(2, 0); assert_eq!( - min_max_range(&d, Some(&MELEE), 1.5, reaches(Some(1.5), None)), + min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(1.5), + reaches(Some(1.5), None) + ), Some((0.0, MELEE_RANGE_FLOOR)) ); // a 4.0 pair (9.333) clears it. - let (_, max) = min_max_range(&d, Some(&MELEE), 4.0, reaches(Some(4.0), None)).unwrap(); + let (_, max) = min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(4.0), + reaches(Some(4.0), None), + ) + .unwrap(); assert!((max - 9.3333).abs() < 1e-3); // With no target the caster's reach counts twice, as in the client: 9.333 alone too. - let (_, max) = min_max_range(&d, Some(&MELEE), 4.0, RangeTargets::default()).unwrap(); + let (_, max) = min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(4.0), + RangeTargets::default(), + ) + .unwrap(); assert!((max - 9.3333).abs() < 1e-3); // Charge's 8-25 row pads both bounds by the bare reach sum; the 1.3333 is melee-only. - let (min, max) = min_max_range(&d, Some(&CHARGE), 1.5, reaches(Some(1.5), None)).unwrap(); + let (min, max) = min_max_range( + &d, + Some(&CHARGE), + RangeUnit::still(1.5), + reaches(Some(1.5), None), + ) + .unwrap(); assert!((min - (8.0 + 3.0)).abs() < 1e-3); assert!((max - (25.0 + 3.0)).abs() < 1e-3); // Fireball's 0-35 row pads the max only: the fcomp-vs-0.0 guard keeps the min at zero. - let (min, max) = min_max_range(&d, Some(&FIREBALL), 1.5, reaches(Some(1.5), None)).unwrap(); + let (min, max) = min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(Some(1.5), None), + ) + .unwrap(); assert_eq!(min, 0.0); assert!((max - 38.0).abs() < 1e-3); // No target, as the tooltip calls it (`target = NULL`, `0x52e9c2`): the raw bounds. assert_eq!( - min_max_range(&d, Some(&CHARGE), 1.5, RangeTargets::default()), + min_max_range( + &d, + Some(&CHARGE), + RangeUnit::still(1.5), + RangeTargets::default() + ), Some((8.0, 25.0)) ); // The on-next-swing attribute short-circuits to 100 without reading the row. assert_eq!( - min_max_range(&spell(1, 0x400), None, 1.5, RangeTargets::default()), + min_max_range( + &spell(1, 0x400), + None, + RangeUnit::still(1.5), + RangeTargets::default() + ), Some((0.0, ON_NEXT_SWING_RANGE)) ); @@ -247,7 +379,12 @@ mod tests { flags: 0, }; assert_eq!( - min_max_range(&d, Some(&self_row), 1.5, RangeTargets::default()), + min_max_range( + &d, + Some(&self_row), + RangeUnit::still(1.5), + RangeTargets::default() + ), None ); } @@ -259,22 +396,42 @@ mod tests { let d = spell(4, 0); // Fireball's 35 with no target passed but an auto-attack target of reach 1.5: 35, not 38. assert_eq!( - min_max_range(&d, Some(&FIREBALL), 1.5, reaches(None, Some(1.5))), + min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(None, Some(1.5)) + ), Some((0.0, 35.0)) ); // The same row with the target passed, and the caster's reach 1.5: 35 + 1.5 + 1.5. assert_eq!( - min_max_range(&d, Some(&FIREBALL), 1.5, reaches(Some(1.5), None)), + min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(Some(1.5), None) + ), Some((0.0, 38.0)) ); // An auto-attack target beside it pads nothing: the target passed is the pad's second reach. assert_eq!( - min_max_range(&d, Some(&FIREBALL), 1.5, reaches(Some(1.5), Some(4.0))), + min_max_range( + &d, + Some(&FIREBALL), + RangeUnit::still(1.5), + reaches(Some(1.5), Some(4.0)) + ), Some((0.0, 38.0)) ); // A bounded row's min stays unpadded without a target too: Charge's 8-25. assert_eq!( - min_max_range(&d, Some(&CHARGE), 1.5, reaches(None, Some(1.5))), + min_max_range( + &d, + Some(&CHARGE), + RangeUnit::still(1.5), + reaches(None, Some(1.5)) + ), Some((8.0, 25.0)) ); } @@ -285,9 +442,14 @@ mod tests { fn the_melee_arm_falls_back_from_the_target_to_the_attack_target_to_the_caster() { let d = spell(2, 0); let max = |target, attack| { - min_max_range(&d, Some(&MELEE), 4.0, reaches(target, attack)) - .unwrap() - .1 + min_max_range( + &d, + Some(&MELEE), + RangeUnit::still(4.0), + reaches(target, attack), + ) + .unwrap() + .1 }; let sum = |reach: f32| reach + 4.0 + COMBAT_REACH_ADD; // With no target passed the auto-attack target's 6.0 counts: 6 + 4 + 1.3333. @@ -299,10 +461,10 @@ mod tests { } /// The predicate is exact for the tooltip's call (`0x52e9c2`, no unit target passed): it is - /// true for a spell and row exactly when some change of the caster's reach or of the - /// auto-attack target's, present or not, moves [`min_max_range`]'s answer. + /// true for a spell and row exactly when some change of the caster's reach or motion, or of the + /// auto-attack target's reach, motion or presence, moves [`min_max_range`]'s answer. #[test] - fn the_reach_predicate_matches_what_min_max_range_reads_with_no_target() { + fn the_units_predicate_matches_what_min_max_range_reads_with_no_target() { let row = |min, max, flags| Some(SpellRange { min, max, flags }); let rows = [ None, @@ -312,34 +474,277 @@ mod tests { row(8.0, 25.0, 0), row(0.0, 5.0, 1), ]; + let still = RangeUnit::still; + let runner = |reach| RangeUnit { + motion: RUNNING, + ..still(reach) + }; + // (caster, auto-attack target) before and after. + let moves = [ + ((still(1.5), None), (still(4.0), None)), + ( + (still(1.5), Some(still(1.5))), + (still(1.5), Some(still(4.0))), + ), + ( + (still(1.5), Some(still(1.5))), + (still(4.0), Some(still(1.5))), + ), + ((still(1.5), None), (still(1.5), Some(still(4.0)))), + ((still(1.5), Some(still(1.5))), (still(1.5), None)), + // Motion alone: the caster's, then the auto-attack target's. + ( + (still(1.5), Some(runner(1.5))), + (runner(1.5), Some(runner(1.5))), + ), + ( + (runner(1.5), Some(still(1.5))), + (runner(1.5), Some(runner(1.5))), + ), + ]; for attributes in [0, 0x4, 0x400, 0x404] { let spell = crate::spells::SpellDisplay { attributes, ..Default::default() }; for row in &rows { - // (caster reach, auto-attack target's reach) before and after. - let moves = [ - ((1.5, None), (4.0, None)), - ((1.5, Some(1.5)), (1.5, Some(4.0))), - ((1.5, Some(1.5)), (4.0, Some(1.5))), - ((1.5, None), (1.5, Some(4.0))), - ((1.5, Some(1.5)), (1.5, None)), - ] - .iter() - .any(|&((a, a_attack), (b, b_attack))| { - min_max_range(&spell, row.as_ref(), a, reaches(None, a_attack)) - != min_max_range(&spell, row.as_ref(), b, reaches(None, b_attack)) + let answer = |caster, attack_target| { + let targets = RangeTargets { + target: None, + attack_target, + }; + min_max_range(&spell, row.as_ref(), caster, targets) + }; + let moved = moves.iter().any(|&((a, a_attack), (b, b_attack))| { + answer(a, a_attack) != answer(b, b_attack) }); assert_eq!( - min_max_range_reads_reach(&spell, row.as_ref()), - moves, + min_max_range_reads_units(&spell, row.as_ref()), + moved, "attributes {attributes:#x}, row {row:?}" ); } } } + /// `0x5fc350` answers 0 for a current speed above twice the walk speed (`fcompp`, then C0), + /// and 1 for an equal pair; an unordered pair sets C0 too. + #[test] + fn running_is_a_speed_above_twice_the_walk_speed() { + let at = |speed, walk_speed| UnitMotion { + flags: FORWARD, + speed, + walk_speed, + }; + assert!(at(7.0, 2.5).is_running(), "a run"); + assert!(!at(2.5, 2.5).is_running(), "a walk"); + assert!( + !at(5.0, 2.5).is_running(), + "equal to twice the walk speed is walking" + ); + assert!(at(5.001, 2.5).is_running()); + assert!(!at(0.0, 2.5).is_running(), "no direction bit reads speed 0"); + assert!(at(f32::NAN, 2.5).is_running(), "unordered sets C0"); + assert!(at(7.0, f32::NAN).is_running()); + // A unit whose speeds never streamed (all zero) is not running. + assert!(!UnitMotion::default().is_running()); + } + + /// The moving bonus (`0x6e3648`-`0x6e36a2`) on the melee row: two running units of reach 1.5 + /// sum 4.333, which the melee floor raises to 5.0 first, so the bonus makes 7.667. + #[test] + fn two_running_units_earn_the_moving_bonus_on_the_melee_row_after_the_floor() { + let d = spell(2, 0); + let max = |caster: RangeUnit, target: RangeUnit| { + min_max_range( + &d, + Some(&MELEE), + caster, + RangeTargets { + target: Some(target), + attack_target: None, + }, + ) + .unwrap() + }; + let running = runner(1.5); + let (min, both) = max(running, running); + assert_eq!(min, 0.0, "the min never takes the bonus"); + assert!((both - (MELEE_RANGE_FLOOR + MOVING_RANGE_BONUS)).abs() < 1e-6); + assert!((both - 7.6667).abs() < 1e-3); + // The bonus is added after the floor, not folded into the reach sum: a 4.0 pair + // (9.333) takes it on top. + let (_, wide) = max(runner(4.0), runner(4.0)); + assert!((wide - (9.3333 + 2.6667)).abs() < 1e-3); + // One unit walking, or standing, or running backward: none. + let walking = UnitMotion { + flags: FORWARD, + speed: 2.5, + walk_speed: 2.5, + }; + let backward = UnitMotion { + flags: BACKWARD, + ..RUNNING + }; + for (name, motion) in [ + ("walking", walking), + ("standing", UnitMotion::default()), + ("backward", backward), + ( + "turning", + UnitMotion { + flags: 0x10, + ..RUNNING + }, + ), + ( + "swimming", + UnitMotion { + flags: 0x20_0000, + ..RUNNING + }, + ), + ] { + let slow = RangeUnit { + motion, + ..runner(1.5) + }; + assert_eq!(max(running, slow).1, MELEE_RANGE_FLOOR, "the target {name}"); + assert_eq!(max(slow, running).1, MELEE_RANGE_FLOOR, "the caster {name}"); + } + } + + /// The flag test is `0x200d`: forward, either strafe, or jumping, each alone. + #[test] + fn the_moving_test_takes_forward_either_strafe_and_jumping() { + for (name, flags, bonus) in [ + ("forward", FORWARD, true), + ("strafe left", 0x4, true), + ("strafe right", 0x8, true), + ("jumping", 0x2000, true), + ("forward and jumping", FORWARD | 0x2000, true), + ("backward", BACKWARD, false), + ("root", 0x1000, false), + ("falling far", 0x4000, false), + ("still", 0, false), + ] { + let unit = RangeUnit { + motion: UnitMotion { flags, ..RUNNING }, + ..RangeUnit::still(1.5) + }; + let got = min_max_range( + &spell(2, 0), + Some(&MELEE), + unit, + RangeTargets { + target: Some(unit), + attack_target: None, + }, + ) + .unwrap() + .1; + let want = if bonus { + MELEE_RANGE_FLOOR + MOVING_RANGE_BONUS + } else { + MELEE_RANGE_FLOOR + }; + assert!((got - want).abs() < 1e-6, "{name}: {got}"); + } + } + + /// The bonus reads the unit `esi` holds after both arms: the melee arm's auto-attack target + /// when no target is passed (`0x6e3580`), never that target beside one passed in. + #[test] + fn the_melee_bonus_reads_the_attack_target_when_no_target_is_passed() { + let d = spell(2, 0); + let bonus = MELEE_RANGE_FLOOR + MOVING_RANGE_BONUS; + let call = |target: Option, attack_target: Option| { + min_max_range( + &d, + Some(&MELEE), + runner(1.5), + RangeTargets { + target, + attack_target, + }, + ) + .unwrap() + .1 + }; + let still = RangeUnit::still(1.5); + assert!((call(None, Some(runner(1.5))) - bonus).abs() < 1e-6); + assert_eq!(call(None, Some(still)), MELEE_RANGE_FLOOR); + // No unit at all: the caster's own reach twice, and no unit to move. + assert_eq!(call(None, None), MELEE_RANGE_FLOOR); + // A target passed in takes the place of the auto-attack target for the bonus too. + assert_eq!(call(Some(still), Some(runner(1.5))), MELEE_RANGE_FLOOR); + assert!((call(Some(runner(1.5)), Some(still)) - bonus).abs() < 1e-6); + } + + /// On a ranged row the bonus needs a target unit passed in, and a player: a creature target + /// earns none (`0x6e3683`-`0x6e3695`), nor does the auto-attack target, which that arm never + /// reads. + #[test] + fn the_ranged_bonus_needs_a_player_target_passed_in() { + let d = spell(4, 0); + let player = RangeUnit { + player: true, + ..runner(1.5) + }; + let creature = runner(1.5); + let ranged = |row: &SpellRange, target: Option, attack_target| { + min_max_range( + &d, + Some(row), + runner(1.5), + RangeTargets { + target, + attack_target, + }, + ) + .unwrap() + }; + // Fireball's 35 with the 1.5 + 1.5 pad, and the bonus for a running player only. + let (min, max) = ranged(&FIREBALL, Some(player), None); + assert_eq!(min, 0.0); + assert!((max - (38.0 + MOVING_RANGE_BONUS)).abs() < 1e-4); + assert_eq!(ranged(&FIREBALL, Some(creature), None), (0.0, 38.0)); + // Charge's min pads and takes no bonus; its max does. + let (min, max) = ranged(&CHARGE, Some(player), None); + assert!((min - 11.0).abs() < 1e-4); + assert!((max - (28.0 + MOVING_RANGE_BONUS)).abs() < 1e-4); + // A standing player target, or a running player as the auto-attack target alone: none. + let standing_player = RangeUnit { + player: true, + ..RangeUnit::still(1.5) + }; + assert_eq!(ranged(&FIREBALL, Some(standing_player), None), (0.0, 38.0)); + assert_eq!(ranged(&FIREBALL, None, Some(player)), (0.0, 35.0)); + // The caster must run too. + let caster = RangeUnit::still(1.5); + let targets = RangeTargets { + target: Some(player), + attack_target: None, + }; + assert_eq!( + min_max_range(&d, Some(&FIREBALL), caster, targets), + Some((0.0, 38.0)) + ); + } + + /// The on-next-swing short-circuit returns before the bonus (`0x6e350f`). + #[test] + fn the_on_next_swing_range_takes_no_bonus() { + let targets = RangeTargets { + target: Some(runner(1.5)), + attack_target: None, + }; + assert_eq!( + min_max_range(&spell(2, 0x400), Some(&MELEE), runner(1.5), targets), + Some((0.0, ON_NEXT_SWING_RANGE)) + ); + } + /// Row 2 is the melee family, 114 Auto Shot's 8-35, 95 Charge's 8-25. #[test] fn real_spell_ranges_read_the_byte_laws_rows() {