Skip to main content

steel_core/player/movement/
mod.rs

1//! Player movement physics and validation.
2//!
3//! This module handles server-side movement simulation and anti-cheat checks.
4//! It implements collision detection and physics similar to vanilla Minecraft.
5
6mod input;
7mod state;
8mod teleport;
9
10pub use input::PlayerInput;
11pub(super) use state::MovementState;
12pub(super) use teleport::TeleportState;
13
14use glam::{DVec3, Vec3Swizzles};
15use std::sync::Arc;
16use steel_math::wrap_degrees;
17use steel_protocol::packets::game::{
18    CMoveVehicle, CPlayerPosition, PlayerCommandAction, RelativeMovement, SAcceptTeleportation,
19    SMovePlayer, SMoveVehicle, SPlayerCommand, SPlayerInput,
20};
21use steel_registry::entity_type::EntityTypeRef;
22use steel_registry::stat::custom::CustomStatRef;
23use steel_registry::vanilla_game_rules::{ELYTRA_MOVEMENT_CHECK, PLAYER_MOVEMENT_CHECK};
24use steel_registry::{vanilla_custom_stats, vanilla_entities, vanilla_mob_effects};
25use steel_utils::types::GameType;
26use steel_utils::{TICKS_PER_SECOND, translations};
27
28use crate::entity::{
29    AcceptedClientMovement, AcceptedClientMovementOutcome, Entity, EntityMoveError, LivingEntity,
30    get_input_vector,
31};
32use crate::physics::{
33    MOVEMENT_ERROR_THRESHOLD, MovementCollisionValidation, MoverType, WorldCollisionProvider,
34    is_colliding_with_new_shapes, movement_error_delta,
35};
36use crate::player::Player;
37use crate::world::World;
38
39/// Default gravity for players (blocks/tick²). Vanilla uses 0.08.
40pub const DEFAULT_GRAVITY: f64 = 0.08;
41
42/// Maximum movement speed threshold for normal movement (meters per tick squared).
43pub const SPEED_THRESHOLD_NORMAL: f64 = 100.0;
44/// Maximum movement speed threshold for elytra flight (meters per tick squared).
45pub const SPEED_THRESHOLD_FLYING: f64 = 300.0;
46
47/// Horizontal position clamping limit (matches vanilla).
48pub const CLAMP_HORIZONTAL: f64 = 3.0E7;
49/// Vertical position clamping limit (matches vanilla).
50pub const CLAMP_VERTICAL: f64 = 2.0E7;
51
52/// The threshold of the client delta of a player in order to reset their action time.
53pub const RESET_ACTION_TIME_DELTA_THRESHOLD: f64 = 1E-5;
54
55/// Clamps a horizontal coordinate to vanilla limits.
56#[must_use]
57pub fn clamp_horizontal(value: f64) -> f64 {
58    value.clamp(-CLAMP_HORIZONTAL, CLAMP_HORIZONTAL)
59}
60
61/// Clamps a vertical coordinate to vanilla limits.
62#[must_use]
63pub fn clamp_vertical(value: f64) -> f64 {
64    value.clamp(-CLAMP_VERTICAL, CLAMP_VERTICAL)
65}
66
67#[must_use]
68pub(crate) fn custom_stat_from_riding_vehicle(
69    vehicle_entity_type: EntityTypeRef,
70) -> Option<CustomStatRef> {
71    if vehicle_entity_type.is_abstract_minecart {
72        Some(&vanilla_custom_stats::MINECART_ONE_CM)
73    } else if vehicle_entity_type.is_abstract_boat {
74        Some(&vanilla_custom_stats::BOAT_ONE_CM)
75    } else if vehicle_entity_type == &vanilla_entities::PIG {
76        Some(&vanilla_custom_stats::PIG_ONE_CM)
77    } else if vehicle_entity_type.is_abstract_horse {
78        Some(&vanilla_custom_stats::HORSE_ONE_CM)
79    } else if vehicle_entity_type == &vanilla_entities::STRIDER {
80        Some(&vanilla_custom_stats::STRIDER_ONE_CM)
81    } else if vehicle_entity_type == &vanilla_entities::HAPPY_GHAST {
82        Some(&vanilla_custom_stats::HAPPY_GHAST_ONE_CM)
83    } else if vehicle_entity_type.is_abstract_nautilus {
84        Some(&vanilla_custom_stats::NAUTILUS_ONE_CM)
85    } else {
86        None
87    }
88}
89
90const FLOATING_Y_THRESHOLD: f64 = -0.03125;
91
92#[derive(Debug, Clone, Copy)]
93struct PlayerFloatingValidation {
94    y_dist: f64,
95    player_stands_on_something: bool,
96    is_spectator: bool,
97    server_allows_flight: bool,
98    may_fly: bool,
99    has_levitation: bool,
100    is_fall_flying: bool,
101}
102
103impl PlayerFloatingValidation {
104    fn can_violate(self) -> bool {
105        self.y_dist >= FLOATING_Y_THRESHOLD
106            && !self.player_stands_on_something
107            && !self.is_spectator
108            && !self.server_allows_flight
109            && !self.may_fly
110            && !self.has_levitation
111            && !self.is_fall_flying
112    }
113}
114
115impl Player {
116    const fn is_invalid_position(x: f64, y: f64, z: f64, rot_x: f32, rot_y: f32) -> bool {
117        if x.is_nan() || y.is_nan() || z.is_nan() {
118            return true;
119        }
120
121        if !rot_x.is_finite() || !rot_y.is_finite() {
122            return true;
123        }
124
125        false
126    }
127
128    fn move_vehicle_packet_from_entity(entity: &dyn Entity) -> CMoveVehicle {
129        let rotation = entity.rotation();
130        CMoveVehicle {
131            position: entity.position(),
132            y_rot: rotation.0,
133            x_rot: rotation.1,
134        }
135    }
136
137    /// Checks if we're awaiting a teleport confirmation and handles timeout/resend.
138    ///
139    /// Returns `true` if awaiting teleport (movement should be rejected),
140    /// `false` if normal movement processing should continue.
141    fn update_awaiting_teleport(&self) -> bool {
142        let mut tp = self.teleport_state.lock();
143        let Some(pos) = tp.awaiting_position else {
144            tp.teleport_time = self.tick_count();
145            return false;
146        };
147
148        let current_tick = self.tick_count();
149
150        // Resend teleport after 20 ticks (~1 second) timeout
151        if current_tick.wrapping_sub(tp.teleport_time) > 20 {
152            drop(tp);
153
154            let (yaw, pitch) = self.rotation();
155            if let Err(error) = self.teleport(pos, yaw, pitch) {
156                log::warn!(
157                    "Failed to resend pending teleport for player {}: {error}",
158                    self.id()
159                );
160            }
161        }
162        true
163    }
164
165    /// Applies vanilla post-impulse movement validation grace.
166    pub fn apply_post_impulse_grace_time(&self, ticks: i32) {
167        LivingEntity::apply_post_impulse_grace_time(self, ticks);
168    }
169
170    /// Resets per-tick vanilla movement validation bases for the controlled root vehicle.
171    pub(super) fn reset_vehicle_movement_for_tick(&self) {
172        let Some(vehicle) = self.root_vehicle() else {
173            self.movement.lock().clear_vehicle_for_tick();
174            return;
175        };
176
177        let controlled_by_player = vehicle
178            .controlling_passenger()
179            .is_some_and(|controller| controller.id() == self.id());
180        if !controlled_by_player {
181            self.movement.lock().clear_vehicle_for_tick();
182            return;
183        }
184
185        self.movement
186            .lock()
187            .reset_vehicle_for_tick(vehicle.id(), vehicle.position());
188    }
189
190    /// Checks if movement validation should be performed for this player.
191    ///
192    /// Uses the `playerMovementCheck` and `elytraMovementCheck` gamerules.
193    ///
194    /// Returns `true` if movement should be validated, `false` to skip validation.
195    fn should_validate_movement(world: &World, is_fall_flying: bool) -> bool {
196        let player_check = world.get_game_rule(&PLAYER_MOVEMENT_CHECK);
197        if !player_check {
198            return false;
199        }
200
201        if is_fall_flying {
202            let elytra_check = world.get_game_rule(&ELYTRA_MOVEMENT_CHECK);
203            return elytra_check;
204        }
205
206        true
207    }
208
209    /// Handles a move player packet.
210    ///
211    /// # Panics
212    ///
213    /// Panics if the server cannot restore the player to the last accepted position after rejecting
214    /// invalid movement. That indicates world entity state refused an authoritative correction.
215    pub fn handle_move_player(self: &Arc<Self>, packet: SMovePlayer) {
216        if Self::is_invalid_position(
217            packet.get_x(0.0),
218            packet.get_y(0.0),
219            packet.get_z(0.0),
220            packet.get_x_rot(0.0),
221            packet.get_y_rot(0.0),
222        ) {
223            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT.msg());
224            return;
225        }
226        if packet.has_pos && !self.movement.lock().record_position_packet() {
227            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT.msg());
228            return;
229        }
230
231        if self.has_won_game() {
232            return;
233        }
234
235        if self.is_world_change_pending() {
236            return;
237        }
238
239        let current_rotation = self.rotation();
240        let target_yaw = wrap_degrees(packet.get_y_rot(current_rotation.0));
241        let target_pitch = wrap_degrees(packet.get_x_rot(current_rotation.1));
242
243        if self.update_awaiting_teleport() {
244            self.set_rotation((target_yaw, target_pitch));
245            return;
246        }
247
248        if !self.has_client_loaded() {
249            return;
250        }
251
252        let current_position = self.position();
253        self.handle_player_position_change(
254            DVec3::new(
255                packet.get_x(current_position.x),
256                packet.get_y(current_position.y),
257                packet.get_z(current_position.z),
258            ),
259            packet.get_y_rot(current_rotation.0),
260            packet.get_x_rot(current_rotation.1),
261            packet.on_ground,
262            packet.horizontal_collision,
263        );
264    }
265
266    /// Applies a client-reported position and rotation to the player.
267    ///
268    /// Shared by the movement packet and the teleport acknowledgement.
269    #[expect(
270        clippy::too_many_lines,
271        reason = "matches vanilla handlePlayerPositionChange; splitting would hurt readability"
272    )]
273    fn handle_player_position_change(
274        self: &Arc<Self>,
275        requested: DVec3,
276        requested_yaw: f32,
277        requested_pitch: f32,
278        is_on_ground: bool,
279        horizontal_collision: bool,
280    ) {
281        let current_rotation = self.rotation();
282        let target_yaw = wrap_degrees(requested_yaw);
283        let target_pitch = wrap_degrees(requested_pitch);
284        let start_pos = self.position();
285        let target_pos = DVec3::new(
286            clamp_horizontal(requested.x),
287            clamp_vertical(requested.y),
288            clamp_horizontal(requested.z),
289        );
290        let game_mode = self.game_mode();
291        let is_sleeping = self.is_sleeping();
292        let is_fall_flying = self.is_fall_flying();
293        let was_on_ground = self.on_ground();
294        let is_spectator = game_mode == GameType::Spectator;
295        let is_creative = game_mode == GameType::Creative;
296        let world = self.get_world();
297        let tick_runs_normally = world.tick_runs_normally();
298        if self.is_passenger() {
299            let passenger_pos = self.position();
300            if let Err(error) = self.try_set_position(passenger_pos) {
301                log::warn!(
302                    "Failed to refresh passenger player {} position during movement: {error}",
303                    self.id()
304                );
305                return;
306            }
307            self.set_rotation((target_yaw, target_pitch));
308            world.chunk_map.update_player_status(self);
309            return;
310        }
311
312        let (first_good, last_good) = self.movement.lock().good_positions();
313
314        if is_sleeping {
315            let dx = target_pos.x - first_good.x;
316            let dy = target_pos.y - first_good.y;
317            let dz = target_pos.z - first_good.z;
318            let moved_dist_sq = dx * dx + dy * dy + dz * dz;
319
320            if moved_dist_sq > 1.0 {
321                if let Err(error) = self.teleport(start_pos, target_yaw, target_pitch) {
322                    log::warn!(
323                        "Failed to correct sleeping player {} movement: {error}",
324                        self.id()
325                    );
326                }
327                return;
328            }
329            return;
330        }
331
332        let dx = target_pos.x - first_good.x;
333        let dy = target_pos.y - first_good.y;
334        let dz = target_pos.z - first_good.z;
335        let moved_dist_sq = dx * dx + dy * dy + dz * dz;
336
337        if tick_runs_normally {
338            let mut delta_packets = {
339                let mut mv = self.movement.lock();
340                mv.record_move_packet_delta()
341            };
342
343            if delta_packets > 5 {
344                delta_packets = 1;
345            }
346
347            if Self::should_validate_movement(&world, is_fall_flying) {
348                let threshold = if is_fall_flying {
349                    SPEED_THRESHOLD_FLYING
350                } else {
351                    SPEED_THRESHOLD_NORMAL
352                } * f64::from(delta_packets);
353
354                if moved_dist_sq - self.velocity().length_squared() > threshold {
355                    if let Err(error) =
356                        self.teleport(start_pos, current_rotation.0, current_rotation.1)
357                    {
358                        log::warn!(
359                            "Failed to correct too-fast player {} movement: {error}",
360                            self.id()
361                        );
362                    }
363                    return;
364                }
365            }
366        }
367
368        let old_aabb = self.bounding_box();
369        let move_delta = target_pos - last_good;
370        let moved_upwards = move_delta.y > 0.0;
371        let player_stands_on_something = self.vertical_collision_below();
372
373        if was_on_ground && !is_on_ground && moved_upwards {
374            self.jump_from_ground();
375        }
376
377        if Arc::clone(self)
378            .move_entity(MoverType::Player, move_delta)
379            .is_none()
380        {
381            if let Err(error) = self.teleport(start_pos, target_yaw, target_pitch) {
382                panic!(
383                    "failed to correct rejected player {} movement: {error}",
384                    self.id()
385                );
386            }
387            return;
388        }
389
390        let error_delta = movement_error_delta(target_pos, self.position());
391        let error_dist_sq = error_delta.length_squared();
392        let in_impulse_grace = self.is_in_post_impulse_grace_time();
393        let fail = error_dist_sq > MOVEMENT_ERROR_THRESHOLD
394            && !is_creative
395            && !is_spectator
396            && !in_impulse_grace;
397
398        let new_aabb = self.bounding_box().translate(target_pos - self.position());
399        let collision_world = WorldCollisionProvider::for_entity(&world, self.as_ref());
400        let old_collision = collision_world.has_entity_context_collision(
401            old_aabb,
402            self.position().y,
403            self.is_descending(),
404        );
405        let new_collision =
406            is_colliding_with_new_shapes(&collision_world, old_aabb, new_aabb, self.is_crouching());
407
408        if (MovementCollisionValidation {
409            no_physics: self.no_physics(),
410            moved_wrongly: fail,
411            old_collision,
412            new_collision,
413        })
414        .rejects()
415        {
416            if let Err(error) = self.teleport(start_pos, target_yaw, target_pitch) {
417                log::warn!(
418                    "Failed to correct collided player {} movement: {error}",
419                    self.id()
420                );
421            }
422            self.refresh_supporting_block_for_fall_damage(DVec3::ZERO, is_on_ground);
423            Arc::clone(self).do_check_fall_damage(DVec3::ZERO, is_on_ground, &world);
424            self.remove_latest_movement_recording();
425            return;
426        }
427
428        // Vanilla saves this requested Y delta before recomputing the
429        // post-move residual used by moved-wrongly validation.
430        let floating_check = Some((player_stands_on_something, move_delta.y));
431
432        let client_delta = target_pos - start_pos;
433        match Arc::clone(self).apply_accepted_client_movement(
434            &world,
435            AcceptedClientMovement {
436                position: Some(target_pos),
437                rotation: (target_yaw, target_pitch),
438                on_ground: is_on_ground,
439                horizontal_collision,
440                movement: client_delta,
441                reset_fall_distance: moved_upwards,
442            },
443        ) {
444            Ok(AcceptedClientMovementOutcome::Applied) => {}
445            Ok(AcceptedClientMovementOutcome::Handled) => return,
446            Err(error) => {
447                log::warn!(
448                    "Rejected accepted player movement for entity {}: {error}",
449                    self.id()
450                );
451                if let Err(teleport_error) = self.teleport(start_pos, target_yaw, target_pitch) {
452                    log::warn!(
453                        "Failed to correct rejected player movement for entity {}: {teleport_error}",
454                        self.id()
455                    );
456                }
457                self.remove_latest_movement_recording();
458                return;
459            }
460        }
461
462        self.check_movement_statistics(client_delta);
463        world.chunk_map.update_player_status(self);
464
465        if let Some((player_stands_on_something, y_dist)) = floating_check {
466            self.record_client_floating(
467                &world,
468                y_dist,
469                player_stands_on_something,
470                is_spectator,
471                is_fall_flying,
472            );
473        }
474        self.movement
475            .lock()
476            .mark_last_good_position(self.position());
477        self.handle_player_known_movement(client_delta);
478    }
479
480    /// Handles a controlled-vehicle movement packet.
481    #[expect(
482        clippy::too_many_lines,
483        reason = "matches vanilla handleMoveVehicle; splitting would hurt readability"
484    )]
485    pub fn handle_move_vehicle(&self, packet: SMoveVehicle) {
486        if Self::is_invalid_position(
487            packet.pos.x,
488            packet.pos.y,
489            packet.pos.z,
490            packet.x_rot,
491            packet.y_rot,
492        ) {
493            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_VEHICLE_MOVEMENT.msg());
494            return;
495        }
496
497        if self.is_world_change_pending() {
498            return;
499        }
500
501        if self.update_awaiting_teleport() || !self.has_client_loaded() {
502            return;
503        }
504
505        let Some(vehicle) = self.root_vehicle() else {
506            return;
507        };
508        if vehicle.is_world_change_pending() {
509            return;
510        }
511        let controlled_by_player = vehicle
512            .controlling_passenger()
513            .is_some_and(|controller| controller.id() == self.id());
514        if !controlled_by_player {
515            let current_tick = self.tick_count();
516            let last_reset = self.movement.lock().vehicle_position_last_reset_at;
517
518            if current_tick.wrapping_sub(last_reset) > TICKS_PER_SECOND
519                || vehicle.position().distance_squared(packet.pos) > 1.0
520            {
521                log::warn!(
522                    "{} was expected to be controlling vehicle {} but was not. Resetting vehicle position.",
523                    self.gameprofile.name(),
524                    vehicle.id()
525                );
526
527                self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
528                self.movement.lock().vehicle_position_last_reset_at = current_tick;
529            }
530
531            return;
532        }
533        let Some((first_good, last_good)) =
534            self.movement.lock().vehicle_good_positions(vehicle.id())
535        else {
536            return;
537        };
538
539        let world = self.get_world();
540        let old_position = vehicle.position();
541        let target_pos = DVec3::new(
542            clamp_horizontal(packet.pos.x),
543            clamp_vertical(packet.pos.y),
544            clamp_horizontal(packet.pos.z),
545        );
546        let target_yaw = wrap_degrees(packet.y_rot);
547        let target_pitch = wrap_degrees(packet.x_rot);
548        let first_good_delta = target_pos - first_good;
549        let moved_dist_sq = first_good_delta.length_squared();
550        let expected_dist_sq = vehicle.velocity().length_squared();
551        if moved_dist_sq - expected_dist_sq > SPEED_THRESHOLD_NORMAL {
552            log::warn!(
553                "{} (vehicle of {}) moved too quickly! {},{},{}",
554                vehicle.id(),
555                self.gameprofile.name(),
556                first_good_delta.x,
557                first_good_delta.y,
558                first_good_delta.z
559            );
560            self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
561            return;
562        }
563
564        let old_aabb = vehicle.bounding_box();
565        let move_delta = target_pos - last_good;
566        let vehicle_rests_on_something = vehicle.vertical_collision_below();
567        if vehicle.is_living_entity() && vehicle.on_climbable() {
568            vehicle.reset_fall_distance();
569        }
570
571        if Arc::clone(&vehicle)
572            .move_entity(MoverType::Player, move_delta)
573            .is_none()
574        {
575            self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
576            return;
577        }
578
579        let error_delta = movement_error_delta(target_pos, vehicle.position());
580        let error_dist_sq = error_delta.length_squared();
581        let fail = error_dist_sq > MOVEMENT_ERROR_THRESHOLD;
582        if fail {
583            log::warn!(
584                "{} (vehicle of {}) moved wrongly! {}",
585                vehicle.id(),
586                self.gameprofile.name(),
587                error_dist_sq.sqrt()
588            );
589        }
590
591        let new_aabb = vehicle
592            .bounding_box()
593            .translate(target_pos - vehicle.position());
594        let collision_world = WorldCollisionProvider::for_entity(&world, vehicle.as_ref());
595        let old_collision = collision_world.has_entity_context_collision(
596            old_aabb,
597            vehicle.position().y,
598            vehicle.is_descending(),
599        );
600        let new_collision = is_colliding_with_new_shapes(
601            &collision_world,
602            old_aabb,
603            new_aabb,
604            vehicle.is_descending(),
605        );
606
607        if (MovementCollisionValidation {
608            no_physics: false,
609            moved_wrongly: fail,
610            old_collision,
611            new_collision,
612        })
613        .rejects()
614        {
615            if let Err(error) = vehicle.try_set_position(old_position) {
616                log::warn!(
617                    "Failed to roll vehicle {} back after rejected movement: {error}",
618                    vehicle.id()
619                );
620            }
621            vehicle.set_rotation((target_yaw, target_pitch));
622            self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
623            vehicle.remove_latest_movement_recording();
624            return;
625        }
626
627        let client_delta = target_pos - old_position;
628        match Arc::clone(&vehicle).apply_accepted_client_vehicle_movement(
629            &world,
630            AcceptedClientMovement {
631                position: Some(target_pos),
632                rotation: (target_yaw, target_pitch),
633                on_ground: packet.on_ground,
634                horizontal_collision: vehicle.horizontal_collision(),
635                movement: client_delta,
636                reset_fall_distance: false,
637            },
638        ) {
639            Ok(AcceptedClientMovementOutcome::Applied) => {}
640            Ok(AcceptedClientMovementOutcome::Handled) => return,
641            Err(error) => {
642                log::warn!(
643                    "Rejected accepted vehicle movement for entity {}: {error}",
644                    vehicle.id()
645                );
646                if let Err(rollback_error) = vehicle.try_set_position(old_position) {
647                    log::warn!(
648                        "Failed to roll vehicle {} back after rejected movement: {rollback_error}",
649                        vehicle.id()
650                    );
651                }
652                vehicle.set_rotation((target_yaw, target_pitch));
653                self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
654                vehicle.remove_latest_movement_recording();
655                return;
656            }
657        }
658        self.handle_player_known_movement(client_delta);
659        self.check_movement_statistics(client_delta);
660        world.chunk_map.update_player_status(self);
661        self.record_client_vehicle_floating(
662            &world,
663            vehicle.as_ref(),
664            move_delta.y,
665            vehicle_rests_on_something,
666        );
667        self.movement
668            .lock()
669            .mark_vehicle_last_good_position(vehicle.id(), vehicle.position());
670    }
671
672    fn handle_player_known_movement(&self, client_delta: DVec3) {
673        if client_delta.length_squared() > RESET_ACTION_TIME_DELTA_THRESHOLD {
674            self.reset_last_action_time();
675        }
676        self.movement
677            .lock()
678            .set_last_known_client_movement(client_delta);
679    }
680
681    fn record_client_floating(
682        &self,
683        world: &World,
684        y_dist: f64,
685        player_stands_on_something: bool,
686        is_spectator: bool,
687        is_fall_flying: bool,
688    ) {
689        // TODO: Add auto-spin exemption when riptide/spin attack state exists.
690        let can_violate_floating = PlayerFloatingValidation {
691            y_dist,
692            player_stands_on_something,
693            is_spectator,
694            server_allows_flight: self.config.allow_flight,
695            may_fly: self.abilities.lock().may_fly,
696            has_levitation: self.has_mob_effect(vanilla_mob_effects::LEVITATION),
697            is_fall_flying,
698        }
699        .can_violate();
700
701        let client_is_floating = can_violate_floating && Self::no_blocks_around_entity(world, self);
702        self.movement
703            .lock()
704            .record_client_floating(client_is_floating);
705    }
706
707    fn record_client_vehicle_floating(
708        &self,
709        world: &World,
710        vehicle: &dyn Entity,
711        y_dist: f64,
712        vehicle_rests_on_something: bool,
713    ) {
714        let client_is_floating = y_dist >= FLOATING_Y_THRESHOLD
715            && !vehicle_rests_on_something
716            && !self.config.allow_flight
717            && !vehicle.is_flying_vehicle()
718            && !vehicle.is_no_gravity()
719            && Self::no_blocks_around_entity(world, vehicle);
720        self.movement
721            .lock()
722            .record_vehicle_client_floating(vehicle.id(), client_is_floating);
723    }
724
725    fn no_blocks_around_entity(world: &World, entity: &dyn Entity) -> bool {
726        let block_query = entity
727            .bounding_box()
728            .inflate(0.0625)
729            .expand_towards(DVec3::new(0.0, -0.55, 0.0));
730        world.block_states_in_aabb_are_air(block_query)
731    }
732
733    /// Returns how long vanilla permits unsupported floating for this player's gravity.
734    pub(super) fn maximum_flying_ticks(&self) -> i32 {
735        Self::maximum_flying_ticks_for_gravity(self.get_gravity())
736    }
737
738    /// Returns how long vanilla permits unsupported floating for an entity gravity value.
739    #[expect(
740        clippy::cast_possible_truncation,
741        reason = "gravity threshold bounds the result far below i32::MAX"
742    )]
743    fn maximum_flying_ticks_for_gravity(gravity: f64) -> i32 {
744        if gravity < 1.0E-5 {
745            return i32::MAX;
746        }
747
748        let gravity_modifier = DEFAULT_GRAVITY / gravity;
749        (80.0 * gravity_modifier.max(1.0)).ceil() as i32
750    }
751
752    /// Advances vanilla's floating violation tracker and disconnects when exceeded.
753    pub(super) fn disconnect_if_floating_too_long(&self) -> bool {
754        let should_count = !self.is_sleeping() && !self.is_passenger() && !self.is_dead_or_dying();
755        let maximum_flying_ticks = self.maximum_flying_ticks();
756        let should_disconnect = self
757            .movement
758            .lock()
759            .tick_client_floating(should_count, maximum_flying_ticks);
760
761        if should_disconnect {
762            log::warn!(
763                "{} was kicked for floating too long!",
764                self.gameprofile.name()
765            );
766            self.disconnect(translations::MULTIPLAYER_DISCONNECT_FLYING.msg());
767        }
768
769        should_disconnect
770    }
771
772    /// Advances vanilla's controlled-vehicle floating tracker and disconnects when exceeded.
773    pub(super) fn disconnect_if_vehicle_floating_too_long(&self) -> bool {
774        let Some(vehicle) = self.root_vehicle() else {
775            self.movement.lock().clear_vehicle_for_tick();
776            return false;
777        };
778        let controlled_by_player = vehicle
779            .controlling_passenger()
780            .is_some_and(|controller| controller.id() == self.id());
781        if !controlled_by_player {
782            self.movement.lock().clear_vehicle_for_tick();
783            return false;
784        }
785
786        let maximum_flying_ticks = Self::maximum_flying_ticks_for_gravity(vehicle.get_gravity());
787        let should_disconnect = self
788            .movement
789            .lock()
790            .tick_vehicle_client_floating(vehicle.id(), maximum_flying_ticks);
791
792        if should_disconnect {
793            log::warn!(
794                "{} was kicked for floating a vehicle too long!",
795                self.gameprofile.name()
796            );
797            self.disconnect(translations::MULTIPLAYER_DISCONNECT_FLYING.msg());
798        }
799
800        should_disconnect
801    }
802
803    /// Returns true if we're waiting for a teleport confirmation.
804    #[must_use]
805    pub fn is_awaiting_teleport(&self) -> bool {
806        self.teleport_state.lock().is_awaiting()
807    }
808
809    /// Teleports the player to a new position.
810    ///
811    /// Sends a `CPlayerPosition` packet and waits for client acknowledgment.
812    /// Until acknowledged, movement packets from the client will be rejected.
813    pub fn teleport(&self, pos: DVec3, yaw: f32, pitch: f32) -> Result<(), EntityMoveError> {
814        self.teleport_with_velocity(pos, DVec3::ZERO, yaw, pitch)
815    }
816
817    /// Sends a `CPlayerPosition` packet with explicit delta movement for vanilla
818    /// `ServerPlayer.teleport(TeleportTransition)` paths.
819    pub(crate) fn teleport_with_velocity(
820        &self,
821        pos: DVec3,
822        velocity: DVec3,
823        yaw: f32,
824        pitch: f32,
825    ) -> Result<(), EntityMoveError> {
826        self.teleport_with_velocity_packet(
827            pos,
828            velocity,
829            (yaw, pitch),
830            pos,
831            velocity,
832            (yaw, pitch),
833            RelativeMovement::NONE,
834        )
835    }
836
837    /// Commits resolved server state while sending vanilla packet-relative values.
838    #[expect(
839        clippy::too_many_arguments,
840        reason = "packet-relative teleports must keep resolved and protocol values separate"
841    )]
842    pub(crate) fn teleport_with_velocity_packet(
843        &self,
844        pos: DVec3,
845        velocity: DVec3,
846        rotation: (f32, f32),
847        packet_pos: DVec3,
848        packet_velocity: DVec3,
849        packet_rotation: (f32, f32),
850        relatives: RelativeMovement,
851    ) -> Result<(), EntityMoveError> {
852        let world = self.get_world();
853        self.try_set_position(pos)?;
854        if world.entity_manager().get_by_id(self.id()).is_some() {
855            world.chunk_map.update_player_status(self);
856        }
857        self.set_velocity(velocity);
858
859        let new_id = {
860            let mut tp = self.teleport_state.lock();
861            tp.teleport_time = self.tick_count();
862            let id = tp.next_id();
863            tp.awaiting_position = Some(pos);
864            id
865        };
866
867        self.set_rotation(rotation);
868        self.set_old_position_to_current();
869        {
870            let mut movement = self.movement.lock();
871            movement.reset_last_known_client_movement();
872        }
873
874        self.send_packet(CPlayerPosition::new(
875            new_id,
876            packet_pos,
877            packet_velocity,
878            packet_rotation.0,
879            packet_rotation.1,
880            relatives,
881        ));
882        Ok(())
883    }
884
885    /// Resets vanilla floating violation counters after a successful high-level teleport.
886    pub(crate) fn reset_flying_ticks(&self) {
887        self.movement.lock().reset_flying_ticks();
888    }
889
890    /// Handles a teleport acknowledgment from the client.
891    pub fn handle_accept_teleportation(self: &Arc<Self>, packet: SAcceptTeleportation) {
892        let mut tp = self.teleport_state.lock();
893
894        // Vanilla rejects a NaN/infinite acknowledged position before accepting the teleport.
895        if packet.teleport_id == tp.teleport_id
896            && Self::is_invalid_position(packet.x, packet.y, packet.z, packet.x_rot, packet.y_rot)
897        {
898            drop(tp);
899            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT.msg());
900            return;
901        }
902
903        if let Some(pos) = tp.try_accept(packet.teleport_id) {
904            drop(tp);
905            if let Err(error) = self.try_set_position(pos) {
906                log::warn!(
907                    "Failed to commit accepted teleport for player entity {}: {error}",
908                    self.id()
909                );
910                self.teleport_state.lock().awaiting_position = Some(pos);
911                return;
912            }
913            self.set_old_position_to_current();
914            {
915                let mut movement = self.movement.lock();
916                movement.mark_last_good_position(pos);
917                movement.reset_last_known_client_movement();
918            }
919
920            // Vanilla feeds the acknowledged position through the shared movement path
921            // once the awaited position has been committed.
922            self.handle_player_position_change(
923                DVec3::new(packet.x, packet.y, packet.z),
924                packet.y_rot,
925                packet.x_rot,
926                false,
927                false,
928            );
929        } else if packet.teleport_id == tp.teleport_id && tp.awaiting_position.is_none() {
930            drop(tp);
931            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT.msg());
932        }
933    }
934
935    /// Returns the latest vanilla client input snapshot.
936    #[must_use]
937    pub fn last_client_input(&self) -> PlayerInput {
938        self.movement.lock().last_client_input()
939    }
940
941    /// Returns the latest client movement intent.
942    #[must_use]
943    pub fn last_client_move_intent(&self) -> DVec3 {
944        get_input_vector(
945            self.last_client_input().movement_input(),
946            1.0,
947            self.rotation().0,
948        )
949    }
950
951    /// Handles a player input packet (movement keys, sneaking, sprinting).
952    pub fn handle_player_input(&self, packet: SPlayerInput) {
953        // Vanilla stores the input unconditionally before the guard check.
954        let input = PlayerInput::from_flags(packet.flags);
955        self.movement.lock().set_last_client_input(input);
956
957        if !self.has_client_loaded() {
958            return;
959        }
960
961        self.reset_last_action_time();
962
963        self.set_crouching(input.shift());
964    }
965
966    /// Handles a player command packet (sprinting, elytra, leaving bed, etc).
967    pub fn handle_player_command(&self, packet: SPlayerCommand) {
968        if !self.has_client_loaded() {
969            return;
970        }
971
972        if packet.entity_id != self.id() {
973            log::warn!(
974                "Player {} (eid {}) sent SPlayerCommand with mismatched entity_id {}",
975                self.gameprofile.name(),
976                self.id(),
977                packet.entity_id
978            );
979            return;
980        }
981
982        self.reset_last_action_time();
983
984        match packet.action {
985            PlayerCommandAction::StartSprinting => {
986                self.set_sprinting(true);
987            }
988            PlayerCommandAction::StopSprinting => {
989                self.set_sprinting(false);
990            }
991            PlayerCommandAction::StartFallFlying => {
992                if !self.try_to_start_fall_flying() {
993                    self.stop_fall_flying();
994                }
995            }
996            PlayerCommandAction::LeaveBed => {
997                if self.is_sleeping() {
998                    self.stop_sleep_in_bed(false, true);
999                }
1000            }
1001            PlayerCommandAction::StartRidingJump
1002            | PlayerCommandAction::StopRidingJump
1003            | PlayerCommandAction::OpenVehicleInventory => {
1004                // TODO: Implement once controlled vehicle jumping and vehicle inventory interfaces exist.
1005            }
1006        }
1007
1008        // Dirty shared flags are synced once per tick by sync_entity_data().
1009    }
1010
1011    /// Checks new player non-riding movement provided to award stats for it,
1012    /// and also causes food exhaustion in some cases.
1013    ///
1014    /// Mirrors Vanilla's `ServerPlayer.checkMovementStatistics`.
1015    pub fn check_movement_statistics(&self, delta_movement: DVec3) {
1016        if self.is_passenger() || delta_movement == DVec3::ZERO {
1017            return;
1018        }
1019        if self.is_swimming() {
1020            self.award_3d_distance_stat(&vanilla_custom_stats::SWIM_ONE_CM, delta_movement, 0.01);
1021        } else if self.is_eye_in_water() {
1022            self.award_3d_distance_stat(
1023                &vanilla_custom_stats::WALK_UNDER_WATER_ONE_CM,
1024                delta_movement,
1025                0.01,
1026            );
1027        } else if self.is_in_water() {
1028            self.award_horizontal_distance_stat(
1029                &vanilla_custom_stats::WALK_ON_WATER_ONE_CM,
1030                delta_movement,
1031                0.01,
1032            );
1033        } else if self.on_climbable() && delta_movement.y > 0.0 {
1034            self.award_custom_stat_with_count(
1035                &vanilla_custom_stats::CLIMB_ONE_CM,
1036                (delta_movement.y * 100.0).round() as i32,
1037            );
1038        } else if self.on_ground() {
1039            if self.is_sprinting() {
1040                self.award_horizontal_distance_stat(
1041                    &vanilla_custom_stats::SPRINT_ONE_CM,
1042                    delta_movement,
1043                    0.1,
1044                );
1045            } else if self.is_crouching() {
1046                self.award_horizontal_distance_stat(
1047                    &vanilla_custom_stats::CROUCH_ONE_CM,
1048                    delta_movement,
1049                    0.0,
1050                );
1051            } else {
1052                self.award_horizontal_distance_stat(
1053                    &vanilla_custom_stats::WALK_ONE_CM,
1054                    delta_movement,
1055                    0.0,
1056                );
1057            }
1058        } else if self.is_fall_flying() {
1059            self.award_3d_distance_stat(&vanilla_custom_stats::AVIATE_ONE_CM, delta_movement, 0.0);
1060        } else {
1061            self.award_horizontal_distance_stat_with_lower_bound(
1062                &vanilla_custom_stats::FLY_ONE_CM,
1063                delta_movement,
1064                0.0,
1065                25,
1066            );
1067        }
1068    }
1069
1070    /// Checks new player riding movement provided to award stats for it,
1071    /// and also causes food exhaustion in some cases.
1072    ///
1073    /// Mirrors Vanilla's `ServerPlayer.checkRidingStatistics`.
1074    pub fn check_riding_statistics(&self, delta_movement: DVec3) {
1075        if delta_movement == DVec3::ZERO {
1076            return;
1077        }
1078        let Some(vehicle) = self.vehicle() else {
1079            return;
1080        };
1081
1082        if let Some(stat) = custom_stat_from_riding_vehicle(vehicle.entity_type()) {
1083            let distance = (delta_movement.length() as f32 * 100.0).round() as i32;
1084            self.award_custom_stat_with_count(stat, distance);
1085        }
1086    }
1087
1088    fn award_3d_distance_stat(
1089        &self,
1090        stat: CustomStatRef,
1091        delta_movement: DVec3,
1092        exhaust_multiplier: f32,
1093    ) {
1094        let distance = (delta_movement.length() as f32 * 100.0).round() as i32;
1095        if distance > 0 {
1096            self.award_custom_stat_with_count(stat, distance);
1097            self.cause_food_exhaustion(exhaust_multiplier * distance as f32 * 0.01);
1098        }
1099    }
1100
1101    fn award_horizontal_distance_stat(
1102        &self,
1103        stat: CustomStatRef,
1104        delta_movement: DVec3,
1105        exhaust_multiplier: f32,
1106    ) {
1107        self.award_horizontal_distance_stat_with_lower_bound(
1108            stat,
1109            delta_movement,
1110            exhaust_multiplier,
1111            0,
1112        );
1113    }
1114
1115    fn award_horizontal_distance_stat_with_lower_bound(
1116        &self,
1117        stat: CustomStatRef,
1118        delta_movement: DVec3,
1119        exhaust_multiplier: f32,
1120        lower_bound: i32,
1121    ) {
1122        let horizontal_distance = (delta_movement.xz().length() as f32 * 100.0).round() as i32;
1123        if horizontal_distance > lower_bound {
1124            self.award_custom_stat_with_count(stat, horizontal_distance);
1125            if exhaust_multiplier != 0.0 {
1126                self.cause_food_exhaustion(exhaust_multiplier * horizontal_distance as f32 * 0.01);
1127            }
1128        }
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135
1136    #[test]
1137    fn test_clamp_horizontal() {
1138        assert_eq!(clamp_horizontal(0.0), 0.0);
1139        assert_eq!(clamp_horizontal(1e8), CLAMP_HORIZONTAL);
1140        assert_eq!(clamp_horizontal(-1e8), -CLAMP_HORIZONTAL);
1141    }
1142
1143    #[test]
1144    fn test_clamp_vertical() {
1145        assert_eq!(clamp_vertical(0.0), 0.0);
1146        assert_eq!(clamp_vertical(1e8), CLAMP_VERTICAL);
1147        assert_eq!(clamp_vertical(-1e8), -CLAMP_VERTICAL);
1148    }
1149
1150    #[test]
1151    fn test_wrap_degrees() {
1152        assert_eq!(wrap_degrees(181.0), -179.0);
1153        assert_eq!(wrap_degrees(-181.0), 179.0);
1154        assert_eq!(wrap_degrees(90.0), 90.0);
1155    }
1156
1157    #[test]
1158    fn sprinting_charges_food_exhaustion_once_per_move() {
1159        use steel_utils::ChunkPos;
1160
1161        use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
1162
1163        let world_fixture = fresh_test_world("sprint_exhaustion_single_charge");
1164        let world = &world_fixture.world;
1165        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
1166
1167        let player = TestPlayerBuilder::new(Arc::clone(world), "SprintTester", 1).build();
1168        player.set_client_loaded(true);
1169
1170        let start = DVec3::new(8.0, 64.0, 8.0);
1171        player.base().set_position_local(start);
1172        player.movement.lock().reset_for_position_sync(start);
1173        player.set_sprinting(true);
1174        player.food_data.lock().exhaustion_level = 0.0;
1175
1176        // A 0.25-block ground sprint. Vanilla `handleMovePlayer` runs the sprint
1177        // exhaustion once, inside `checkMovementStatistics`: `Player.SPRINTING`
1178        // costs 0.1 per meter, so 0.1 * 0.25 = 0.025.
1179        player.handle_move_player(SMovePlayer {
1180            position: start + DVec3::new(0.25, 0.0, 0.0),
1181            y_rot: 0.0,
1182            x_rot: 0.0,
1183            on_ground: true,
1184            horizontal_collision: false,
1185            has_pos: true,
1186            has_rot: false,
1187        });
1188
1189        let exhaustion = player.food_data.lock().exhaustion_level;
1190        assert!(
1191            (exhaustion - 0.025).abs() < 1e-4,
1192            "sprinting should charge exhaustion once (0.025), got {exhaustion}"
1193        );
1194    }
1195
1196    #[test]
1197    fn floating_validation_exempts_levitation_like_vanilla() {
1198        let validation = PlayerFloatingValidation {
1199            y_dist: 0.0,
1200            player_stands_on_something: false,
1201            is_spectator: false,
1202            server_allows_flight: false,
1203            may_fly: false,
1204            has_levitation: false,
1205            is_fall_flying: false,
1206        };
1207
1208        assert!(validation.can_violate());
1209        assert!(
1210            !PlayerFloatingValidation {
1211                y_dist: -0.0784,
1212                ..validation
1213            }
1214            .can_violate()
1215        );
1216        assert!(
1217            !PlayerFloatingValidation {
1218                has_levitation: true,
1219                ..validation
1220            }
1221            .can_violate()
1222        );
1223    }
1224}