feat: click to move walks to an out-of-reach target, then interacts (#752)

- Click to Move: with AutoInteract on, a right-click on an NPC, a body to loot or skin, or a GameObject out of reach walks there and runs the verb on arrival
- Click to Move: the arrival scales by the mover's current speed, and a Use still out of range walks again
- Click to Move: a Loot walk stops at the square root of the melee reach
- Put the meeting-stone test's doc comment back on its test
- A GameObject click out of range raises ERR_USE_TOO_FAR unless the walk starts
- Wrap the finish's doc lines at the column limit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Mathih13
2026-10-02 05:57:47 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent cfd5fd2c8a
commit 2ce6a3e881
15 changed files with 1757 additions and 372 deletions
+12 -3
View File
@@ -438,7 +438,7 @@
Controls = {
"RowStickyTarget", "RowAssistAttack", "RowClearAFK",
"RowBlockTrades", "RowAutoSelfCast",
"RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
"RowAutoLoot", "RowInvertMouse", "RowMouseSpeed", "RowClickToMove",
"RowCameraFollowStyle", "RowFollowTerrain", "RowHeadBob",
"RowAutoFollowSpeed", "RowWaterCollision", "RowSmartPivot",
"RowMouseLookSpeed", "RowMaxCameraDistance",
@@ -1614,12 +1614,21 @@
BenillaOptionsSliderRow_Setup(this, 0.5, 1.5, 0.05)
</OnLoad></Scripts>
</Frame>
<!-- 1.12's box sits under the mouse slider. -->
<Frame name="$parentRowClickToMove" inherits="BenillaOptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>BenillaOptionsRow_OnLoad(this, "autointeract", "Click-to-Move",
"OPTION_TOOLTIP_CLICK_TO_MOVE")</OnLoad></Scripts>
</Frame>
<!-- Smart 1, Always 2, Never 0 (UIOptionsFrame.lua:525,
536, 547). -->
<Frame name="$parentRowCameraFollowStyle" inherits="BenillaOptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="$parentRowClickToMove" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowClickToMove" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
BenillaOptionsRow_OnLoad(this, "cameraSmoothStyle",
+3
View File
@@ -203,6 +203,9 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `deselectOnClick` and `mouseInvertPitch` are 1.12's own (`UIOptionsFrame.lua:8,4`);
// `autoLootDefault` is the later-era name, 1.12 having only the shift gesture.
same("deselectOnClick", "1"),
// `AutoInteract` (`0x603390`, record `[0xc4d9a4]`), Click-to-Move (`UIOptionsFrame.lua:7`):
// "1" on koKR alone (`0x603368`). The knob is [`crate::player::Approach`].
same("AutoInteract", "0"),
// `BlockTrades` (`0x842fbc`), `UIOptionsFrame.lua:11`: the refusal leg `0x4bf7bc` fires only
// when it is set. The knob is [`crate::ui_trade::BlockTrades`].
same("BlockTrades", "0"),
+8 -2
View File
@@ -675,12 +675,18 @@ pub(crate) mod schedule_tests {
/// 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.
/// rider's pose, never the flags;
/// - Click to Move's steer and arrival (`player::approach::steer_approach`,
/// `target::click::act_on_arrival`) against what `steer_follow` and `act_on_right_click`
/// already pair with: the steer is chained after follow's and writes the same `Player`
/// fields, the arrival runs the click's dispatchers, and their `Transform` reads are the
/// disjoint-lane kind. The dispatchers also carry the walk's start (`Player`,
/// `FollowState`, `Approach`, the stand request), which only a click or an arrival writes.
///
/// 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_860;
const UPDATE_ACTIONABLE_CEILING: usize = 4_930;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+11 -3
View File
@@ -23,6 +23,7 @@ use benilla_assets::AssetSet;
use benilla_world::interact::{WorldClick, WorldRightClick};
use benilla_world::schedule::WorldStage;
mod approach;
mod arc;
mod net;
// Writes the frame onto the body we drive: pose, `MovementState`, the counter-twist gap.
@@ -77,6 +78,11 @@ use camera_zoom::{apply_zoom_scroll, CAM_DIST_DEFAULT};
// `/follow`: chat sends the request, `crate::target` resolves the subject into the state, and
// `follow` owns the motion.
pub(crate) use follow::{FollowRequest, FollowState};
// Click to Move: `crate::target`'s dispatchers start it and run the verb it owes on arrival.
pub(crate) use approach::{
can_auto_interact, Approach, ApproachVerb, AutoMove, Refused, Subject, RANGE_STOP_FRACTION,
TALK_STOP,
};
use state::{
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT,
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
@@ -205,6 +211,7 @@ impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
net::register(app);
follow::plugin(app);
approach::plugin(app);
camera_saved::plugin(app);
camera_view::plugin(app);
// The stream focus is published after `control`'s teleport snap and before the stream
@@ -339,11 +346,12 @@ impl Plugin for PlayerPlugin {
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
.in_set(crate::char_select::InWorldGated),
)
// `/follow` steers before `control`, so the player's own turn input lands after it and
// wins over the follow's steering.
// `/follow` and Click to Move steer before `control`, so the player's own turn input
// lands after them and wins over their steering.
.add_systems(
Update,
follow::steer_follow
(follow::steer_follow, approach::steer_approach)
.chain()
.in_set(WorldStage::Input)
.before(control)
.in_set(crate::char_select::InWorldGated),
+587
View File
@@ -0,0 +1,587 @@
//! Click to Move's approach (`AutoInteract`, "Toggles auto-move to interact target"): with the
//! option on, a right-click on an NPC, a body to loot or skin, or a GameObject beyond the verb's
//! reach walks the player there, and the verb runs on arrival. It is [`super::follow`]'s auto-move:
//! one cell holds either (mode `0xc4d888`, guid `0xc4d980`), so arming one ends the other
//! (`0x611130` calls the canceller `0x60fb60` first), the same input cancels both, and nothing goes
//! on the wire but the movement. The terrain and sky walks (modes 4, 8) and the attack approach
//! (mode 10) are not built.
use std::f32::consts::PI;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use crate::net::{Embodied, Guid, GuidIndex, NetEntity, ObjectStore, SelfPlayer, UnitSpeeds};
use super::follow::{self, FollowInput, FollowState};
use super::state::Player;
/// The verb an approach owes, by its mode in `0xc4d888`; the pending dispatcher `0x60fa20` runs it
/// at the stop.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ApproachVerb {
/// Mode 5: the NPC-service dispatch `0x5f0130`.
Talk,
/// Mode 6: the unit loot `0x5df2a0`.
Loot,
/// Mode 7: the GameObject use `0x5f86b0`.
Use,
/// Mode 9: the skin cast `0x5f05e0`.
Skin,
}
/// What the start gate (`0x60fed0`) checks the target for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Subject {
Unit { dead: bool },
Corpse,
GameObject,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Refused {
Silent,
/// `ERR_AUTOFOLLOW_TOO_FAR` (`0x126`, `0x61110c`).
TooFar,
}
/// Talk stops at half the 5.5556 yd service reach (`0x60e680`).
pub(crate) const TALK_STOP: f32 = 5.555_555_3 * 0.5;
/// Use and Skin stop at this fraction of the verb's range (`0x804588`).
pub(crate) const RANGE_STOP_FRACTION: f32 = 0.9;
/// No approach starts or lasts at 80 yd or more: modes 5-10's row in `0x860a58` (`0x60e5d1`).
const LEASH_SQ: f32 = 6400.0;
/// Within this many yards on the ground the goal's height is ignored (`0x80c4c8`).
const FLAT_SNAP: f32 = 0.5;
/// `automoveturnspeednarrow` and `automoveturnspeedwide`, deg/s (`0x6039e9`, `0x6039ca`).
const TURN_NARROW: f32 = 800.0;
const TURN_WIDE: f32 = 1200.0;
/// Less ground than this in a frame while lined up and holding forward is stuck (`0x80c4dc`).
const STUCK: f32 = 1.0 / 360.0;
/// The approach in flight, and the verb an arrival owes.
#[derive(Resource, Default)]
pub(crate) struct Approach {
/// `AutoInteract`, `CanAutoInteract`'s last term (`0x60f925`).
pub(crate) enabled: bool,
goal: Option<Goal>,
/// The pending cells `0x60f940` fills (`0xc4da28`, `0xc4da78`, `0xc4d950`, `0xc4da20`).
pub(crate) arrived: Option<(ApproachVerb, u64)>,
}
struct Goal {
verb: ApproachVerb,
guid: u64,
/// `0xc4d910`.
stop: f32,
/// Latched by the first turn (`0x61042c`).
turn_rate: Option<f32>,
/// Bit 1 of `0xc4da48`: the facing has lined up once.
aligned: bool,
/// Last frame's position (`0xc4da84`).
last_pos: Vec3,
held: bool,
}
impl Approach {
/// The reference's `0x6103a0` for this mode family: an approach is in flight.
pub(crate) fn active(&self) -> bool {
self.goal.is_some()
}
pub(crate) fn stop(&mut self) {
self.goal = None;
}
#[cfg(test)]
pub(crate) fn stop_distance(&self) -> Option<f32> {
self.goal.as_ref().map(|g| g.stop)
}
}
/// `CanAutoInteract` (`0x60f900`): alive, driving our own body, and the option on.
pub(crate) fn can_auto_interact(enabled: bool, me: Option<&ObjectStore>, player: &Player) -> bool {
enabled
&& me.is_some_and(|s| s.0.unit_health().unwrap_or(0) > 0)
&& player.foreign_mover.is_none()
&& !player.control_lost
}
/// The start: the gate (`0x60fed0`, `0x610300`) and the arm `0x611130`.
#[derive(SystemParam)]
pub(crate) struct AutoMove<'w, 's> {
pub(crate) approach: ResMut<'w, Approach>,
follow: ResMut<'w, FollowState>,
player: Res<'w, Player>,
me: Query<'w, 's, (&'static Guid, &'static ObjectStore), With<SelfPlayer>>,
stand: MessageWriter<'w, super::StandStateRequest>,
}
impl AutoMove<'_, '_> {
pub(crate) fn can_auto_interact(&self) -> bool {
can_auto_interact(
self.approach.enabled,
self.me.single().ok().map(|(_, s)| s),
&self.player,
)
}
pub(crate) fn start(
&mut self,
verb: ApproachVerb,
guid: u64,
subject: Subject,
at: Vec3,
stop: f32,
) -> Result<(), Refused> {
let Ok((me, store)) = self.me.single() else {
return Err(Refused::Silent);
};
let admitted = match (verb, subject) {
(ApproachVerb::Talk, Subject::Unit { .. }) => true,
(ApproachVerb::Loot | ApproachVerb::Skin, Subject::Corpse) => true,
(ApproachVerb::Loot | ApproachVerb::Skin, Subject::Unit { dead }) => dead,
(ApproachVerb::Use, Subject::GameObject) => true,
_ => false,
};
if !admitted
|| guid == me.0
|| self.player.foreign_mover.is_some()
|| self.player.control_lost
|| store.0.unit_health().unwrap_or(0) == 0
{
return Err(Refused::Silent);
}
let standing = store.0.unit_stand_state() == 0;
self.follow.stop();
self.approach.stop();
if self.player.pos.distance_squared(at) >= LEASH_SQ {
return Err(Refused::TooFar);
}
info!("approach: {verb:?} {guid:#x}, stopping {stop:.2} yd short");
self.approach.goal = Some(Goal {
verb,
guid,
stop,
turn_rate: None,
aligned: false,
last_pos: self.player.pos,
held: false,
});
if !standing {
self.stand.write(super::StandStateRequest { state: 0 });
}
Ok(())
}
}
fn speed_scale(speed: f32) -> f32 {
(speed / follow::SPEED_NORM).max(1.0)
}
/// The goal distance, squared (`0x610e40`'s tail): on the ground alone once inside the snap
/// radius, unless swimming. Talk's radius never scales with speed.
fn goal_distance_sq(verb: ApproachVerb, delta: Vec3, speed: f32, swimming: bool) -> f32 {
let snap = if verb == ApproachVerb::Talk {
FLAT_SNAP
} else {
FLAT_SNAP * speed_scale(speed)
};
let flat = delta.x * delta.x + delta.z * delta.z;
if !swimming && flat <= snap * snap {
flat
} else {
delta.length_squared()
}
}
/// The arrive distance (`0x610add`-`0x610b07`): the stop distance scaled by the mover's current
/// speed over 7 (`0x7c4c90`, 0 at rest), never below 1×; Talk's is never scaled.
fn arrive_distance(verb: ApproachVerb, stop: f32, speed: f32) -> f32 {
if verb == ApproachVerb::Talk {
stop
} else {
stop * speed_scale(speed)
}
}
/// The rate the first turn latches, rad/s: wide for a turn of more than 120° (`0x61043f`).
fn turn_rate(remaining: f32) -> f32 {
let deg = if remaining.abs() > 2.0 * PI / 3.0 {
TURN_WIDE
} else {
TURN_NARROW
};
deg.to_radians()
}
/// Steer toward the goal and hold forward; on arrival face it and owe its verb. After
/// [`follow::steer_follow`], which clears [`Player::auto_forward`].
pub(super) fn steer_approach(
time: Res<Time>,
mut approach: ResMut<Approach>,
mut player: ResMut<Player>,
mover: Query<&UnitSpeeds, With<Embodied>>,
index: Res<GuidIndex>,
targets: Query<(&Transform, Option<&ObjectStore>, Option<&NetEntity>)>,
input: FollowInput,
) {
let Some(goal) = approach.goal.as_mut() else {
return;
};
// `GetCurrentSpeed` on last frame's word, as the reference reads the mover's own.
let speed = mover
.single()
.map_or(0.0, |s| crate::net::current_speed(&s.0, player.move_flags));
if input.cancels(&player) {
info!("approach: cancelled by the player's own movement input");
approach.stop();
return;
}
let Some((tf, store, kind)) = index.0.get(&goal.guid).and_then(|e| targets.get(*e).ok()) else {
info!("approach: the target is gone");
approach.stop();
return;
};
// `0x610f13`-`0x610f40`: a unit on a taxi ends it, a dead one too unless the verb wants a body.
if kind.is_some_and(|k| matches!(k.kind, EntityKind::Unit | EntityKind::Player)) {
let store = store.map(|s| &s.0);
let taxi = store.is_some_and(|s| s.unit_flags() & super::UNIT_FLAG_TAXI_FLIGHT != 0);
let dead = store.is_none_or(|s| s.unit_health().unwrap_or(0) == 0);
if taxi || (dead && !matches!(goal.verb, ApproachVerb::Loot | ApproachVerb::Skin)) {
info!("approach: the target left reach (taxi {taxi}, dead {dead})");
approach.stop();
return;
}
}
let target = tf.translation;
let delta = target - player.pos;
let d2 = goal_distance_sq(goal.verb, delta, speed, player.swimming);
if d2 >= LEASH_SQ {
info!("approach: beyond 80 yd");
approach.stop();
return;
}
// `0x61073c`: once lined up, a frame that ends farther off than the last aborts, but not Talk.
if goal.aligned
&& goal.verb != ApproachVerb::Talk
&& goal.last_pos.distance_squared(target) < player.pos.distance_squared(target)
{
info!("approach: no longer closing");
approach.stop();
return;
}
let flat = Vec3::new(delta.x, 0.0, delta.z);
let bearing = follow::bearing_to(flat);
let remaining = follow::wrap_pi(bearing - player.face_yaw);
let turning = remaining.abs() > follow::TURN_DEADZONE;
if turning {
let rate = *goal.turn_rate.get_or_insert_with(|| turn_rate(remaining));
player.face_yaw = follow::steer(player.face_yaw, bearing, rate, time.delta_secs());
} else {
goal.aligned = true;
}
let arrive = arrive_distance(goal.verb, goal.stop, speed);
if d2 <= arrive * arrive {
info!("approach: arrived at {:.2} yd", d2.sqrt());
// The arrival's canceller faces the target (`0x60fbe8`).
if flat.length_squared() > f32::EPSILON {
player.face_yaw = bearing;
}
approach.arrived = Some((goal.verb, goal.guid));
approach.goal = None;
return;
}
// Rooted, the reference lets go of forward (`0x610b4a`), which also spares the stuck test.
let hold = !player.modes.rooted;
if !turning && goal.held && player.pos.distance_squared(goal.last_pos) < STUCK * STUCK {
info!("approach: stuck");
approach.stop();
return;
}
if follow::vertically_degenerate(delta) {
info!("approach: the target is straight overhead");
approach.stop();
return;
}
goal.last_pos = player.pos;
goal.held = hold;
player.auto_forward = hold;
}
fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut approach: ResMut<Approach>) {
if ev.is("AutoInteract") {
approach.enabled = ev.flag();
}
}
pub(super) fn plugin(app: &mut App) {
app.init_resource::<Approach>().add_observer(on_cvar);
}
#[cfg(test)]
mod tests {
use super::super::state::MoveSpeed;
use super::*;
use benilla_protocol::messages::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
#[test]
fn talk_stops_inside_the_service_reach_and_never_scales() {
assert!((TALK_STOP - 2.777_777_7).abs() < 1e-5);
assert_eq!(
arrive_distance(ApproachVerb::Talk, TALK_STOP, 14.0),
TALK_STOP
);
assert_eq!(arrive_distance(ApproachVerb::Loot, 5.0, 7.0), 5.0);
assert_eq!(arrive_distance(ApproachVerb::Loot, 5.0, 14.0), 10.0);
assert_eq!(arrive_distance(ApproachVerb::Use, 5.0, 3.5), 5.0);
assert_eq!(arrive_distance(ApproachVerb::Use, 5.0, 0.0), 5.0, "at rest");
}
#[test]
fn the_goal_flattens_only_inside_the_snap_radius() {
let above = Vec3::new(0.3, 4.0, 0.3);
assert!((goal_distance_sq(ApproachVerb::Use, above, 7.0, false) - 0.18).abs() < 1e-5);
assert!((goal_distance_sq(ApproachVerb::Use, above, 7.0, true) - 16.18).abs() < 1e-4);
let far = Vec3::new(3.0, 4.0, 0.0);
assert_eq!(goal_distance_sq(ApproachVerb::Use, far, 7.0, false), 25.0);
}
const ME: u64 = 0x1;
const VENDOR: u64 = 0xF00D;
const DT: f32 = 1.0 / 60.0;
/// Our body at the origin facing -Z, alive (and seated when `stand_state` is set), a vendor at
/// `at`, and both steers chained as the plugin runs them.
fn world(at: Vec3, stand_state: u32) -> App {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<Approach>()
.init_resource::<FollowState>()
.init_resource::<Player>()
.init_resource::<GuidIndex>()
.init_resource::<ButtonInput<MouseButton>>()
.init_resource::<crate::bindings::BindingsState>()
.init_resource::<super::super::camera::CameraControl>()
.init_resource::<super::super::view_subject::ViewSubject>()
.insert_resource(MoveSpeed {
value: 7.0,
env_override: false,
})
.add_message::<super::super::StandStateRequest>()
.add_systems(Update, (follow::steer_follow, steer_approach).chain());
app.world_mut().resource_mut::<Approach>().enabled = true;
app.world_mut().spawn((
SelfPlayer,
Guid(ME),
ObjectStore(ObjectFields::from_pairs(&[
(22, 100),
(28, 100),
(138, stand_state),
])),
));
let vendor = app
.world_mut()
.spawn((Guid(VENDOR), Transform::from_translation(at)))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(VENDOR, vendor);
app
}
fn start(app: &mut App, verb: ApproachVerb, subject: Subject, at: Vec3) -> Result<(), Refused> {
app.world_mut()
.run_system_once(move |mut auto: AutoMove| {
auto.start(verb, VENDOR, subject, at, TALK_STOP)
})
.unwrap()
}
/// One frame: the steers, then the controller's walk along the facing while forward is held.
fn frame(app: &mut App, walks: bool) {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(DT));
app.update();
let mut player = app.world_mut().resource_mut::<Player>();
if walks && player.auto_forward {
let forward = Quat::from_rotation_y(player.face_yaw) * Vec3::NEG_Z;
player.pos += forward * 7.0 * DT;
}
}
#[test]
fn a_vendor_out_of_reach_is_walked_to_and_owed_the_talk() {
let at = Vec3::new(14.0, 0.0, 0.0);
let mut app = world(at, 0);
assert_eq!(
start(
&mut app,
ApproachVerb::Talk,
Subject::Unit { dead: false },
at
),
Ok(())
);
frame(&mut app, true);
assert!(
app.world().resource::<Player>().auto_forward,
"forward from the first frame"
);
let mut frames = 1;
while app.world().resource::<Approach>().arrived.is_none() {
assert!(frames < 300, "never arrived");
frame(&mut app, true);
frames += 1;
}
let player = app.world().resource::<Player>();
let left = player.pos.distance(at);
assert!(
left <= TALK_STOP && left > TALK_STOP - 7.0 * DT - 1e-3,
"stopped {left} yd off, not at the 2.78 yd stop"
);
assert!(!player.auto_forward, "forward let go at the stop");
assert!(
(follow::wrap_pi(player.face_yaw - follow::bearing_to(at - player.pos))).abs() < 1e-4,
"facing the vendor"
);
let approach = app.world().resource::<Approach>();
assert_eq!(approach.arrived, Some((ApproachVerb::Talk, VENDOR)));
assert!(!approach.active());
}
#[test]
fn the_arm_ends_a_follow_and_refuses_past_80_yards() {
let near = Vec3::new(14.0, 0.0, 0.0);
let mut app = world(near, 0);
app.world_mut()
.resource_mut::<FollowState>()
.start(0xBEEF, "Friend".into());
assert_eq!(
start(
&mut app,
ApproachVerb::Talk,
Subject::Unit { dead: false },
near
),
Ok(())
);
assert!(
app.world().resource::<FollowState>().guid.is_none(),
"one auto-move cell"
);
let far = Vec3::new(80.0, 0.0, 0.0);
assert_eq!(
start(
&mut app,
ApproachVerb::Talk,
Subject::Unit { dead: false },
far
),
Err(Refused::TooFar)
);
assert!(
!app.world().resource::<Approach>().active(),
"the arm cancelled the last walk"
);
}
#[test]
fn the_gate_admits_each_verb_its_own_kind_of_target() {
let at = Vec3::new(14.0, 0.0, 0.0);
let mut app = world(at, 0);
for (verb, subject, ok) in [
(ApproachVerb::Talk, Subject::Unit { dead: false }, true),
(ApproachVerb::Talk, Subject::GameObject, false),
(ApproachVerb::Loot, Subject::Unit { dead: true }, true),
(ApproachVerb::Loot, Subject::Unit { dead: false }, false),
(ApproachVerb::Loot, Subject::Corpse, true),
(ApproachVerb::Skin, Subject::Unit { dead: false }, false),
(ApproachVerb::Use, Subject::GameObject, true),
(ApproachVerb::Use, Subject::Unit { dead: false }, false),
] {
let got = start(&mut app, verb, subject, at);
assert_eq!(got.is_ok(), ok, "{verb:?} at {subject:?}: {got:?}");
}
}
#[test]
fn a_seated_player_stands_to_walk() {
let at = Vec3::new(14.0, 0.0, 0.0);
let mut app = world(at, 1);
assert_eq!(
start(
&mut app,
ApproachVerb::Talk,
Subject::Unit { dead: false },
at
),
Ok(())
);
let asks: Vec<u8> = app
.world_mut()
.resource_mut::<Messages<super::super::StandStateRequest>>()
.drain()
.map(|m| m.state)
.collect();
assert_eq!(asks, vec![0]);
}
#[test]
fn the_players_own_movement_cancels_the_walk() {
let at = Vec3::new(14.0, 0.0, 0.0);
let mut app = world(at, 0);
assert_eq!(
start(
&mut app,
ApproachVerb::Talk,
Subject::Unit { dead: false },
at
),
Ok(())
);
frame(&mut app, true);
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Forward);
frame(&mut app, true);
let approach = app.world().resource::<Approach>();
assert!(!approach.active() && approach.arrived.is_none());
}
#[test]
fn a_walk_that_cannot_move_gives_up_once_lined_up() {
let at = Vec3::new(14.0, 0.0, 0.0);
let mut app = world(at, 0);
assert_eq!(
start(&mut app, ApproachVerb::Use, Subject::GameObject, at),
Ok(())
);
frame(&mut app, false);
frame(&mut app, false);
assert!(
app.world().resource::<Approach>().active(),
"still turning, where standing still is not stuck"
);
for _ in 0..60 {
frame(&mut app, false);
}
let approach = app.world().resource::<Approach>();
assert!(!approach.active(), "stuck against a wall");
assert!(approach.arrived.is_none(), "nothing owed");
}
#[test]
fn the_first_turn_picks_the_wide_rate_past_120_degrees() {
assert_eq!(turn_rate(0.5), 800f32.to_radians());
assert_eq!(turn_rate(-2.0 * PI / 3.0), 800f32.to_radians());
assert_eq!(turn_rate(2.2), 1200f32.to_radians());
assert_eq!(turn_rate(-PI), 1200f32.to_radians());
}
}
+4
View File
@@ -15,6 +15,7 @@
use bevy::prelude::*;
use super::approach::Approach;
use super::follow::FollowState;
use super::state::Player;
use crate::creature_anim::MovementState;
@@ -26,6 +27,7 @@ pub(super) fn maintain_embodiment(
mut commands: Commands,
mut player: ResMut<Player>,
mut follow: ResMut<FollowState>,
mut approach: ResMut<Approach>,
guids: (Res<SelfGuid>, Res<GuidIndex>),
self_body: Query<Entity, With<SelfPlayer>>,
attached: Query<Entity, With<Embodied>>,
@@ -49,6 +51,7 @@ pub(super) fn maintain_embodiment(
// `SetActiveMover` `0x6006e0` cancels click-to-move and follow on the outgoing mover,
// through `0x6103a0` → `0x60fb60(0, 1)`.
follow.stop();
approach.stop();
}
}
@@ -91,6 +94,7 @@ mod tests {
let mut app = App::new();
app.init_resource::<Player>()
.init_resource::<FollowState>()
.init_resource::<Approach>()
.init_resource::<SelfGuid>()
.init_resource::<GuidIndex>()
.add_systems(Update, maintain_embodiment);
+37 -31
View File
@@ -7,7 +7,7 @@
//! A player's movement start cancels it on the key-down edge: the start emitters call `0x60e990`,
//! which cancels at `0x60e9b5` unless bit 0 of `ds:0xc4da48` is set, and only follow's own emitters
//! (`0x60e790`, `0x60e7f0`, `0x60e8a0`, `0x60e940`) set it; ours is a flag,
//! [`Player::follow_forward`], so it never reaches the cancel. Jump survives (`0x60e990(0, 0)` at
//! [`Player::auto_forward`], so it never reaches the cancel. Jump survives (`0x60e990(0, 0)` at
//! `0x60dea8`), as do key releases, sitting, the walk toggle, damage, combat, mounting, casting and
//! the followee leaving range (`0x6107e5` skips follow's mode at `0x610749`, `0x610752`).
//!
@@ -29,10 +29,10 @@ use super::state::{MoveSpeed, Player};
const TURN_RATE: f32 = PI;
/// Inside this many radians of the bearing, follow stops turning (`0x6108bb` → `0x60e920`).
const TURN_DEADZONE: f32 = 0.001;
pub(super) const TURN_DEADZONE: f32 = 0.001;
/// The speed the thresholds scale by (`0x80c4d0`), the base run speed.
const SPEED_NORM: f32 = 7.0;
pub(super) const SPEED_NORM: f32 = 7.0;
/// The base stop distance (`0x80c4c0`), a constant, not a CVar.
const STOP_DISTANCE: f32 = 3.0;
@@ -116,7 +116,7 @@ fn follow_cancelled(
}
/// Wrap an angle into `[-π, π)`.
fn wrap_pi(a: f32) -> f32 {
pub(super) fn wrap_pi(a: f32) -> f32 {
let t = std::f32::consts::TAU;
let x = (a + PI).rem_euclid(t);
x - PI
@@ -124,23 +124,23 @@ fn wrap_pi(a: f32) -> f32 {
/// The `face_yaw` pointing at a horizontal Bevy-space delta, matching `control`'s forward
/// `from_rotation_y(y) * NEG_Z = (-sin y, 0, -cos y)`: `y = atan2(-dx, -dz)`.
fn bearing_to(delta: Vec3) -> f32 {
pub(super) fn bearing_to(delta: Vec3) -> f32 {
(-delta.x).atan2(-delta.z)
}
/// The followee is within 1° of straight up or down, where the bearing means nothing (`0x610d32`).
fn vertically_degenerate(delta: Vec3) -> bool {
pub(super) fn vertically_degenerate(delta: Vec3) -> bool {
let dist = delta.length();
dist > f32::EPSILON && delta.y.abs() / dist > VERTICAL_ALIGN_COS
}
/// Turn toward `bearing` by at most `rate × dt`, not inside the deadzone (`0x6103d0`).
fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
pub(super) fn steer(face: f32, bearing: f32, rate: f32, dt: f32) -> f32 {
let remaining = wrap_pi(bearing - face);
if remaining.abs() <= TURN_DEADZONE {
return face;
}
let budget = TURN_RATE * dt;
let budget = rate * dt;
face + remaining.clamp(-budget, budget)
}
@@ -176,6 +176,26 @@ impl FollowInput<'_, '_> {
.any(|&c| self.binds.just_pressed(c))
}
/// The cancel set, the start emitters' `0x60e990` and the input tick's teardown, which end any
/// auto-move.
pub(super) fn cancels(&self, player: &Player) -> bool {
// The world's buttons, not the device's: the both-button run is two bindings held, and a
// press a UI frame captured dispatches neither.
let both_engaged = self.rig.world_mouse.both() && self.rig.world_mouse.rose();
follow_cancelled(
self.move_start(),
// The on edge: `control` toggles `autorun` after this, so the flag is the pre-toggle
// value.
self.buttons.just_pressed(MouseButton::Forward) && !player.autorun,
both_engaged,
self.rig.look == Some(LookButton::Right),
// The teardown leg, or the body handed to a server spline; the ride term is not one of
// the reference's conjuncts (`0x5144e0`), and its reference side is untraced.
self.input_torn_down(player.modes.rooted, player.foreign_mover.is_none())
|| player.server_riding(),
)
}
/// The input tick's teardown leg ([`super::state::MoverInput::torn_down`]), which the reference
/// takes (`0x5146d6 call 0x60fb60`) only with both predicates down (`0x5146c3`, `0x5146ce`):
/// death, far sight, or root and stun together end a follow, a root alone does not.
@@ -193,7 +213,7 @@ impl FollowInput<'_, '_> {
}
}
/// Cancel, re-resolve the followee, steer, and set [`Player::follow_forward`] for `control`. The
/// Cancel, re-resolve the followee, steer, and set [`Player::auto_forward`] for `control`. The
/// cancel comes first, as the reference's start emitters run ahead of the axis math (`0x5150a7` vs
/// the emitter tail `0x5151a0`).
pub(super) fn steer_follow(
@@ -205,25 +225,11 @@ pub(super) fn steer_follow(
transforms: Query<&Transform>,
input: FollowInput,
) {
player.follow_forward = false;
player.auto_forward = false;
if follow.guid.is_none() {
return;
}
// ── The cancel set ──
// The world's buttons, not the device's: the both-button run is two bindings held, and a
// press a UI frame captured dispatches neither.
let both_engaged = input.rig.world_mouse.both() && input.rig.world_mouse.rose();
if follow_cancelled(
input.move_start(),
// The on edge: `control` toggles `autorun` after this, so the flag is the pre-toggle value.
input.buttons.just_pressed(MouseButton::Forward) && !player.autorun,
both_engaged,
input.rig.look == Some(LookButton::Right),
// The teardown leg, or the body handed to a server spline; the ride term is not one of the
// reference's conjuncts (`0x5144e0`), and its reference side is untraced.
input.input_torn_down(player.modes.rooted, player.foreign_mover.is_none())
|| player.server_riding(),
) {
if input.cancels(&player) {
info!("follow: cancelled by the player's own movement input");
follow.stop();
return;
@@ -253,7 +259,7 @@ pub(super) fn steer_follow(
return;
}
let bearing = bearing_to(flat);
player.face_yaw = steer(player.face_yaw, bearing, time.delta_secs());
player.face_yaw = steer(player.face_yaw, bearing, TURN_RATE, time.delta_secs());
let moving = should_move(follow.moving, distance, speed.value);
if moving != follow.moving {
info!(
@@ -283,7 +289,7 @@ pub(super) fn steer_follow(
}
}
follow.moving = moving;
player.follow_forward = moving;
player.auto_forward = moving;
}
/// Whether `WOW_FOLLOW_TRACE` is set, read once.
@@ -350,18 +356,18 @@ mod tests {
#[test]
fn the_turn_is_rate_limited_and_has_a_deadzone() {
// 180°/s for a 0.1 s tick is 18° of the 90°.
let after = steer(0.0, PI / 2.0, 0.1);
let after = steer(0.0, PI / 2.0, TURN_RATE, 0.1);
assert!(
(after - TURN_RATE * 0.1).abs() < 1e-6,
"clamped to the budget"
);
assert!((steer(0.0, 0.05, 1.0) - 0.05).abs() < 1e-6);
assert_eq!(steer(1.0, 1.0 + TURN_DEADZONE / 2.0, 1.0), 1.0);
assert!((steer(0.0, 0.05, TURN_RATE, 1.0) - 0.05).abs() < 1e-6);
assert_eq!(steer(1.0, 1.0 + TURN_DEADZONE / 2.0, TURN_RATE, 1.0), 1.0);
}
#[test]
fn steering_takes_the_short_way_round() {
let after = steer(PI - 0.05, -PI + 0.05, 1.0);
let after = steer(PI - 0.05, -PI + 0.05, TURN_RATE, 1.0);
assert!(
wrap_pi(after - (PI - 0.05)) > 0.0,
"should wrap forward across ±π"
+6 -5
View File
@@ -41,9 +41,9 @@ pub(super) fn look_input(
rig.world_mouse.held(LookButton::Left) || steer_held,
bit::LEFT_MOUSE,
);
// `/follow` sets the forward bit through W's own setter, so it arms the camera like W.
// The auto-move sets the forward bit through W's own setter, so it arms the camera like W.
set(
binds.pressed(crate::bindings::Input::MoveForward) || player.follow_forward,
binds.pressed(crate::bindings::Input::MoveForward) || player.auto_forward,
bit::FORWARD,
);
set(
@@ -153,10 +153,11 @@ pub(super) fn move_axes(
// ── The forward/back axis ── The order of S and autorun decides: S held, then autorun
// toggled, keeps both (the toggle pushes `0x1000`, so `test cl,0x30` misses) and the
// reference sends MSG_MOVE_STOP with S held; autorun, then S, clears the bit at key-down and
// walks you backward. `/follow` is a held forward, W's own bit `0x100000` through W's setter,
// so it nets against S like W and never trips the cancel set, which fires on key-down edges.
// walks you backward. The auto-move is a held forward, W's own bit `0x100000` through W's
// setter, so it nets against S like W and never trips the cancel set, which fires on key-down
// edges.
let fwd_axis = state::forward_axis(
binds.pressed(crate::bindings::Input::MoveForward) || player.follow_forward,
binds.pressed(crate::bindings::Input::MoveForward) || player.auto_forward,
binds.pressed(crate::bindings::Input::MoveBackward),
both_buttons,
autorun,
+5 -4
View File
@@ -296,10 +296,11 @@ pub(crate) struct Player {
/// current bit (`0x60e080`). Observers read our gait off it, and it is in the server-authored
/// merge mask, so a server move can flip it ([`super::wire_in`]); it owes no ack.
pub(super) walking: bool,
/// `/follow` holding forward this frame: the reference's follow pushes W's own bit
/// (`0x60e790`), so this is a [`forward_axis`] term. Rewritten each frame by
/// [`super::follow::steer_follow`] just before the controller reads it.
pub(super) follow_forward: bool,
/// The auto-move (`/follow` or Click to Move) holding forward this frame: the reference pushes
/// W's own bit (`0x60e790`), so this is a [`forward_axis`] term. Rewritten each frame by
/// [`super::follow::steer_follow`] and [`super::approach::steer_approach`] just before the
/// controller reads it.
pub(super) auto_forward: bool,
/// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen.
pub(crate) detached: bool,
/// `SMSG_CLIENT_CONTROL_UPDATE` with `allowMove = 0` named the unit we drive: us, while someone
+3
View File
@@ -406,6 +406,7 @@ pub(super) fn follow_requests(
cast: Res<crate::spell::PendingCast>,
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
mut follow: ResMut<crate::player::FollowState>,
mut approach: ResMut<crate::player::Approach>,
) {
for request in requests.read() {
let resolved = match request {
@@ -448,6 +449,8 @@ pub(super) fn follow_requests(
match resolved {
Some((guid, name)) => {
info!("follow: now following \"{name}\" guid {guid:#x}");
// The arm `0x611130` cancels what runs first: one auto-move cell.
approach.stop();
follow.start(guid, name);
}
None => {
File diff suppressed because it is too large Load Diff
+41 -8
View File
@@ -150,12 +150,16 @@ const ATTACK_RANGE_SQ: f32 = 109.2025;
/// floor, so large creatures reach farther. Gates skin (`0x6e3480`) and loot (`CanLootNow
/// 0x5ec110`), center to center, boundary-inclusive.
const MELEE_OFFSET: f32 = 1.333_33;
const MELEE_FLOOR: f32 = 5.0;
pub(super) const MELEE_FLOOR: f32 = 5.0;
pub(super) fn melee_reach(target: &ObjectStore, me: &ObjectStore) -> f32 {
(target.0.unit_combat_reach() + me.0.unit_combat_reach() + MELEE_OFFSET).max(MELEE_FLOOR)
}
/// A corpse's interact reach, squared: a flat 5 yd, since a corpse has no combat reach and the
/// melee formula falls to its floor. `CanLootNow 0x5ec110` and the `CMSG_LOOT` sender `0x5df130`
/// both compare against 25.0.
const CORPSE_INTERACT_RANGE_SQ: f32 = 25.0;
pub(super) const CORPSE_INTERACT_RANGE_SQ: f32 = 25.0;
/// `GAMEOBJECT_TYPE_GENERIC`: decoration whose highlightable slot is constant false (`0x5f47f0`),
/// so it never shows an interact cursor.
@@ -203,7 +207,7 @@ pub(crate) const GO_TYPE_FISHINGNODE: i32 = 17;
/// `GAMEOBJECT_TYPE_CHAIR` (7): its own range predicate `0x5f5670` accepts within 3.0 yd
/// (`[0xc4d808]`, written only by `0x5f98b0` as 3.0 squared).
pub(crate) const GO_TYPE_CHAIR: i32 = 7;
fn go_interact_range_sq(type_id: i32) -> f32 {
pub(super) fn go_interact_range_sq(type_id: i32) -> f32 {
match type_id {
GO_TYPE_FISHINGNODE => 100.0 * 100.0,
// Measured to the object; the reference (seats from `0x5f5760`) and vmangos
@@ -493,6 +497,8 @@ struct CorpseFacts {
dist_sq: f32,
/// The already-looting override: `[player+0x1d28/2c] == corpse GUID`.
looting_this: bool,
/// `CanAutoInteract`, which `CanLootNow` asks first and then skips its range (`0x5ec11b`).
can_auto_interact: bool,
auto_loot: bool,
/// Key 0, shift.
shift_held: bool,
@@ -502,7 +508,7 @@ fn corpse_cursor(f: CorpseFacts) -> Option<(CursorKind, bool)> {
// Leg 1, lootable: able within `CanLootNow 0x5ec110`'s reach or while this corpse's loot
// window is open; Pickup or LootAll by key 0 (`0x41f8f0` at `0x4827f8`), grayed at `0x482818`.
if f.lootable {
let able = f.dist_sq <= CORPSE_INTERACT_RANGE_SQ || f.looting_this;
let able = f.can_auto_interact || f.dist_sq <= CORPSE_INTERACT_RANGE_SQ || f.looting_this;
return Some((loot_cursor(f.auto_loot, f.shift_held), !able));
}
// Leg 2, the PvP insignia (`0x482875..0x4828a2`), inert in 1.12.1 as no player holds a
@@ -523,7 +529,7 @@ pub(super) fn classify_cursor(
reputations: Res<Reputations>,
(mut cursor, mut fork): (ResMut<WorldCursor>, ResMut<AttackFork>),
// `control_lost` is the attack leg's `[0xb4b3e4]` clear.
player: Res<crate::player::Player>,
(player, approach): (Res<crate::player::Player>, Res<crate::player::Approach>),
units: Query<(
&Transform,
Option<&ObjectStore>,
@@ -542,6 +548,11 @@ pub(super) fn classify_cursor(
// `[0xb72038]`, the queued area; absent in a headless build, which reads as not queued.
stone: Option<Res<crate::ui_dialog_verbs::MeetingStone>>,
) {
let can_auto_interact = crate::player::can_auto_interact(
approach.enabled,
self_q.single().ok().map(|(_, s)| s),
&player,
);
// A GameObject that is not highlightable clears the cursor, as the reference's handler does.
let resolve_go = || {
let (go_tf, store, anim) = units.get(hovered_object.target?).ok()?;
@@ -606,8 +617,7 @@ pub(super) fn classify_cursor(
let store = store?;
let (self_tf, self_store) = self_q.single().ok()?;
let dist_sq = unit_tf.translation.distance_squared(self_tf.translation);
let reach = (store.0.unit_combat_reach() + self_store.0.unit_combat_reach() + MELEE_OFFSET)
.max(MELEE_FLOOR);
let reach = melee_reach(store, self_store);
let in_melee = dist_sq <= reach * reach;
let dead = store.0.unit_is_dead();
@@ -616,7 +626,10 @@ pub(super) fn classify_cursor(
// Grayed by `CanLootNow 0x5ec110`'s melee reach; the reference's mid-loot state
// block and open-loot-window override are not applied to units.
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
return Some((loot_cursor(loot_cfg.auto_loot, shift), !in_melee));
return Some((
loot_cursor(loot_cfg.auto_loot, shift),
!(can_auto_interact || in_melee),
));
}
// Skin also needs the learn latch `[0xb700e4 + 4×isPlayerTarget]` (`0x482589`): a
// player who has learned no Skinning spell sees Point.
@@ -666,6 +679,7 @@ pub(super) fn classify_cursor(
skin_latch: learned.skin_player_corpse.is_some(),
dist_sq: corpse_tf.translation.distance_squared(self_tf.translation),
looting_this: loot_latch.0.is_some() && loot_latch.0 == hovered.corpse_guid,
can_auto_interact,
auto_loot: loot_cfg.auto_loot,
shift_held: shift,
})
@@ -693,6 +707,25 @@ mod tests {
use super::*;
use benilla_protocol::messages::dialog_status;
/// `CanLootNow 0x5ec110` skips its range under `CanAutoInteract` (`0x5ec11b`), so the click
/// that walks there finds a lit pouch.
#[test]
fn click_to_move_lights_a_far_corpses_pouch() {
let far = CorpseFacts {
lootable: true,
dist_sq: 400.0,
..CorpseFacts::default()
};
assert_eq!(corpse_cursor(far), Some((CursorKind::Pickup, true)));
assert_eq!(
corpse_cursor(CorpseFacts {
can_auto_interact: true,
..far
}),
Some((CursorKind::Pickup, false))
);
}
#[test]
fn the_corpse_classifier_has_two_legs_and_no_own_corpse_leg() {
let near = 25.0; // exactly the gate, which is inclusive
+2 -1
View File
@@ -326,7 +326,8 @@ impl Plugin for TargetPlugin {
// order is free.
crate::spell::targeting::commit_ground_cast_on_click,
crate::spell::targeting::commit_object_cast_on_click,
click::act_on_right_click,
// The verb a walk owes runs at its stop, ahead of this frame's click.
(click::act_on_arrival, click::act_on_right_click).chain(),
// The loot close's guid-scoped deselect.
click::clear_target_requests,
// The script calls that touch the selection, the cast or the targeting cursor
+9 -6
View File
@@ -90,9 +90,10 @@ pub(crate) struct IdleGates {
/// `UNIT_FIELD_MOUNTDISPLAYID > 0`, the sit's.
pub(crate) mounted: bool,
/// The sit's fourth gate (`0x482f84 call 0x6103a0`): for a solo player, the movement-action
/// mode `[0xc4d888]` at its in-world `0xc`. `/follow` sets 3 (`FollowUnit 0x489e00`) and
/// autorun never moves it, so a follower whose target has stopped is not seated.
pub(crate) following: bool,
/// mode `[0xc4d888]` at its in-world `0xc`. `/follow` sets 3 (`FollowUnit 0x489e00`), Click to
/// Move 5 to 9, and autorun never moves it, so a follower whose target has stopped is not
/// seated.
pub(crate) auto_moving: bool,
/// `UNIT_FIELD_FLAGS` bit 20 (`0x100000`), the AFK's, not the sit's.
pub(crate) on_taxi: bool,
/// The optimistic mirror `[0xb6e5cc]`: the AFK's second gate, and its anti-repeat.
@@ -114,7 +115,7 @@ pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction {
};
}
IdleAction {
sit: gates.stand_state == 0 && !gates.in_combat && !gates.mounted && !gates.following,
sit: gates.stand_state == 0 && !gates.in_combat && !gates.mounted && !gates.auto_moving,
afk: !gates.on_taxi && !gates.afk,
camp: false,
}
@@ -131,6 +132,7 @@ pub(crate) fn idle_handler(
commands: Res<crate::net::NetCommands>,
mut stand: MessageWriter<crate::player::StandStateRequest>,
follow: Res<crate::player::FollowState>,
approach: Res<crate::player::Approach>,
logout: Res<crate::ui_logout::LogoutState>,
) {
let idle = time.elapsed().saturating_sub(last.0);
@@ -148,7 +150,7 @@ pub(crate) fn idle_handler(
mounted: store.0.unit_mount_display_id() != 0,
on_taxi: flags & crate::player::UNIT_FLAG_TAXI_FLIGHT != 0,
afk: mirror.is_afk(),
following: follow.guid.is_some(),
auto_moving: follow.guid.is_some() || approach.active(),
}
});
let action = match gates {
@@ -325,7 +327,7 @@ mod tests {
`[0xc4d888]` non-`0xc` implies translating, which `stand_state_refused` refuses \
downstream. The AFK mark is untouched: it is not one of the sit's gates",
IdleGates {
following: true,
auto_moving: true,
..open()
},
IdleAction {
@@ -501,6 +503,7 @@ mod tests {
.init_resource::<ChatLog>()
.init_resource::<LastInput>()
.init_resource::<crate::player::FollowState>()
.init_resource::<crate::player::Approach>()
.init_resource::<crate::ui_logout::LogoutState>()
.add_message::<crate::player::StandStateRequest>()
.insert_resource(NetCommands(tx));
@@ -1796,6 +1796,39 @@ fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Click-to-Move (`UIOptionsFrame.lua:7`) moves `AutoInteract`, which boots off as 1.12's Western
/// client registers it (`0x603374`).
#[test]
fn the_controls_page_offers_click_to_move_on_autointeract() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let _ = s.take_cvar_changes();
let row = "BenillaOptionsFrameContainerBodyControlsRowClickToMove";
assert_eq!(
s.eval::<String>(&format!("return {row}Label:GetText()"))
.unwrap(),
"Click-to-Move"
);
assert!(
!s.eval::<bool>(&format!("return {row}Check:GetChecked()"))
.unwrap(),
"off by default"
);
s.run(&format!("{row}Check:Click()")).unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("AutoInteract".to_string(), "1".to_string())]
);
assert_eq!(
s.eval::<String>("return GetCVar(\"AutoInteract\")")
.unwrap(),
"1"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn defaults_resets_the_controls_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
@@ -2281,9 +2314,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 82 rows less the three untipped below; four of the 79 carry a `BENILLA_` key, and a dropdown
// 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown
// row is checked on the key it wears at rest.
assert_eq!(checked, 79, "every tipped row carries a live key");
assert_eq!(checked, 80, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2379,8 +2412,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// Every tipped row: the same 79 the key census counts.
assert_eq!(raised, 79, "every row but Auto Loot raises a description");
// Every tipped row: the same 80 the key census counts.
assert_eq!(raised, 80, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
@@ -4036,17 +4069,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
// registered only once something reads it.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
(
"autointeract",
"click-to-move — the one row here that is a whole movement mode rather than a knob. \
`CanAutoInteract 0x60f900` gates the world-click pick mask's bit 0 and inverts \
`0x5ec110`'s interaction-distance refusal, so an out-of-range corpse/GO/NPC/cast click \
queues an approach (`0x60fed0`, move kinds 6/7/9/0xa) instead of refusing. The commit \
itself puts NOTHING on the wire — it stamps a local goal at `0x611130` — and the packets \
are its consequences (a stand, then the original verb on arrival). benilla has only the \
keyboard controller and /follow (mode 3). Registered default is \"0\" on every locale but \
koKR (`0x603374` selects on the locale index), so stock West ships it OFF",
),
(
"UnitNamePlayerPVPTitle",
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \