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;
15use steel_protocol::packets::game::{
16    CMoveVehicle, CPlayerPosition, PlayerCommandAction, RelativeMovement, SAcceptTeleportation,
17    SMovePlayer, SMoveVehicle, SPlayerCommand, SPlayerInput,
18};
19use steel_registry::vanilla_game_rules::{ELYTRA_MOVEMENT_CHECK, PLAYER_MOVEMENT_CHECK};
20use steel_registry::vanilla_mob_effects;
21use steel_utils::translations;
22use steel_utils::types::GameType;
23
24use crate::entity::{
25    AcceptedClientMovement, AcceptedClientMovementOutcome, Entity, EntityMoveError, LivingEntity,
26};
27use crate::physics::{
28    MOVEMENT_ERROR_THRESHOLD, MovementCollisionValidation, MoverType, WorldCollisionProvider,
29    is_colliding_with_new_shapes, movement_error_delta,
30};
31use crate::player::Player;
32use crate::player::food_data::food_constants;
33use crate::world::World;
34
35/// Default gravity for players (blocks/tick²). Vanilla uses 0.08.
36pub const DEFAULT_GRAVITY: f64 = 0.08;
37
38/// Maximum movement speed threshold for normal movement (meters per tick squared).
39pub const SPEED_THRESHOLD_NORMAL: f64 = 100.0;
40/// Maximum movement speed threshold for elytra flight (meters per tick squared).
41pub const SPEED_THRESHOLD_FLYING: f64 = 300.0;
42
43/// Horizontal position clamping limit (matches vanilla).
44pub const CLAMP_HORIZONTAL: f64 = 3.0E7;
45/// Vertical position clamping limit (matches vanilla).
46pub const CLAMP_VERTICAL: f64 = 2.0E7;
47
48/// Clamps a horizontal coordinate to vanilla limits.
49#[must_use]
50pub fn clamp_horizontal(value: f64) -> f64 {
51    value.clamp(-CLAMP_HORIZONTAL, CLAMP_HORIZONTAL)
52}
53
54/// Clamps a vertical coordinate to vanilla limits.
55#[must_use]
56pub fn clamp_vertical(value: f64) -> f64 {
57    value.clamp(-CLAMP_VERTICAL, CLAMP_VERTICAL)
58}
59
60#[must_use]
61pub(crate) fn wrap_degrees(mut degrees: f32) -> f32 {
62    degrees %= 360.0;
63    if degrees >= 180.0 {
64        degrees -= 360.0;
65    }
66    if degrees < -180.0 {
67        degrees += 360.0;
68    }
69    degrees
70}
71
72#[derive(Debug, Clone, Copy)]
73struct PlayerFloatingValidation {
74    y_dist: f64,
75    player_stands_on_something: bool,
76    is_spectator: bool,
77    server_allows_flight: bool,
78    may_fly: bool,
79    has_levitation: bool,
80    is_fall_flying: bool,
81}
82
83impl PlayerFloatingValidation {
84    fn can_violate(self) -> bool {
85        self.y_dist >= -0.03125
86            && !self.player_stands_on_something
87            && !self.is_spectator
88            && !self.server_allows_flight
89            && !self.may_fly
90            && !self.has_levitation
91            && !self.is_fall_flying
92    }
93}
94
95impl Player {
96    const fn is_invalid_position(x: f64, y: f64, z: f64, rot_x: f32, rot_y: f32) -> bool {
97        if x.is_nan() || y.is_nan() || z.is_nan() {
98            return true;
99        }
100
101        if !rot_x.is_finite() || !rot_y.is_finite() {
102            return true;
103        }
104
105        false
106    }
107
108    fn move_vehicle_packet_from_entity(entity: &dyn Entity) -> CMoveVehicle {
109        let rotation = entity.rotation();
110        CMoveVehicle {
111            position: entity.position(),
112            y_rot: rotation.0,
113            x_rot: rotation.1,
114        }
115    }
116
117    /// Checks if we're awaiting a teleport confirmation and handles timeout/resend.
118    ///
119    /// Returns `true` if awaiting teleport (movement should be rejected),
120    /// `false` if normal movement processing should continue.
121    fn update_awaiting_teleport(&self) -> bool {
122        let mut tp = self.teleport_state.lock();
123        let Some(pos) = tp.awaiting_position else {
124            tp.teleport_time = self.tick_count();
125            return false;
126        };
127
128        let current_tick = self.tick_count();
129
130        // Resend teleport after 20 ticks (~1 second) timeout
131        if current_tick.wrapping_sub(tp.teleport_time) > 20 {
132            drop(tp);
133
134            let (yaw, pitch) = self.rotation();
135            if let Err(error) = self.teleport(pos, yaw, pitch) {
136                log::warn!(
137                    "Failed to resend pending teleport for player {}: {error}",
138                    self.id()
139                );
140            }
141        }
142        true
143    }
144
145    /// Applies vanilla post-impulse movement validation grace.
146    pub fn apply_post_impulse_grace_time(&self, ticks: i32) {
147        LivingEntity::apply_post_impulse_grace_time(self, ticks);
148    }
149
150    /// Resets per-tick vanilla movement validation bases for the controlled root vehicle.
151    pub(super) fn reset_vehicle_movement_for_tick(&self) {
152        let Some(vehicle) = self.root_vehicle() else {
153            self.movement.lock().clear_vehicle_for_tick();
154            return;
155        };
156
157        let controlled_by_player = vehicle
158            .controlling_passenger()
159            .is_some_and(|controller| controller.id() == self.id());
160        if !controlled_by_player {
161            self.movement.lock().clear_vehicle_for_tick();
162            return;
163        }
164
165        self.movement
166            .lock()
167            .reset_vehicle_for_tick(vehicle.id(), vehicle.position());
168    }
169
170    /// Checks if movement validation should be performed for this player.
171    ///
172    /// Matches vanilla's `ServerGamePacketListenerImpl.shouldValidateMovement()`.
173    /// Uses the `playerMovementCheck` and `elytraMovementCheck` gamerules.
174    ///
175    /// Returns `true` if movement should be validated, `false` to skip validation.
176    fn should_validate_movement(world: &World, is_fall_flying: bool) -> bool {
177        let player_check = world.get_game_rule(&PLAYER_MOVEMENT_CHECK);
178        if !player_check {
179            return false;
180        }
181
182        if is_fall_flying {
183            let elytra_check = world.get_game_rule(&ELYTRA_MOVEMENT_CHECK);
184            return elytra_check;
185        }
186
187        true
188    }
189
190    /// Handles a move player packet.
191    ///
192    /// Matches vanilla `ServerGamePacketListenerImpl.handleMovePlayer()`.
193    ///
194    /// # Panics
195    ///
196    /// Panics if the server cannot restore the player to the last accepted position after rejecting
197    /// invalid movement. That indicates world entity state refused an authoritative correction.
198    #[expect(
199        clippy::too_many_lines,
200        reason = "matches vanilla handleMovePlayer; splitting would hurt readability"
201    )]
202    pub fn handle_move_player(&self, packet: SMovePlayer) {
203        if Self::is_invalid_position(
204            packet.get_x(0.0),
205            packet.get_y(0.0),
206            packet.get_z(0.0),
207            packet.get_x_rot(0.0),
208            packet.get_y_rot(0.0),
209        ) {
210            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT.msg());
211            return;
212        }
213        if self.has_won_game() {
214            return;
215        }
216
217        if self.is_world_change_pending() {
218            return;
219        }
220
221        let current_rotation = self.rotation();
222        let target_yaw = wrap_degrees(packet.get_y_rot(current_rotation.0));
223        let target_pitch = wrap_degrees(packet.get_x_rot(current_rotation.1));
224
225        if self.update_awaiting_teleport() {
226            self.set_rotation((target_yaw, target_pitch));
227            return;
228        }
229
230        if !self.has_client_loaded() {
231            return;
232        }
233
234        let start_pos = self.position();
235        let target_pos = DVec3::new(
236            clamp_horizontal(packet.get_x(start_pos.x)),
237            clamp_vertical(packet.get_y(start_pos.y)),
238            clamp_horizontal(packet.get_z(start_pos.z)),
239        );
240        let game_mode = self.game_mode();
241        let is_sleeping = self.is_sleeping();
242        let is_fall_flying = self.is_fall_flying();
243        let was_on_ground = self.on_ground();
244        let is_spectator = game_mode == GameType::Spectator;
245        let is_creative = game_mode == GameType::Creative;
246        let world = self.get_world();
247        let tick_runs_normally = world.tick_runs_normally();
248        if self.is_passenger() {
249            let passenger_pos = self.position();
250            if let Err(error) = self.try_set_position(passenger_pos) {
251                log::warn!(
252                    "Failed to refresh passenger player {} position during movement: {error}",
253                    self.id()
254                );
255                return;
256            }
257            self.set_rotation((target_yaw, target_pitch));
258            world.chunk_map.update_player_status(self);
259            return;
260        }
261
262        let (first_good, last_good) = self.movement.lock().good_positions();
263
264        if is_sleeping {
265            let dx = target_pos.x - first_good.x;
266            let dy = target_pos.y - first_good.y;
267            let dz = target_pos.z - first_good.z;
268            let moved_dist_sq = dx * dx + dy * dy + dz * dz;
269
270            if moved_dist_sq > 1.0 {
271                if let Err(error) = self.teleport(start_pos, target_yaw, target_pitch) {
272                    log::warn!(
273                        "Failed to correct sleeping player {} movement: {error}",
274                        self.id()
275                    );
276                }
277                return;
278            }
279            return;
280        }
281
282        let dx = target_pos.x - first_good.x;
283        let dy = target_pos.y - first_good.y;
284        let dz = target_pos.z - first_good.z;
285        let moved_dist_sq = dx * dx + dy * dy + dz * dz;
286
287        if tick_runs_normally {
288            let mut delta_packets = {
289                let mut mv = self.movement.lock();
290                mv.record_move_packet_delta()
291            };
292
293            if delta_packets > 5 {
294                delta_packets = 1;
295            }
296
297            if Self::should_validate_movement(&world, is_fall_flying) {
298                let threshold = if is_fall_flying {
299                    SPEED_THRESHOLD_FLYING
300                } else {
301                    SPEED_THRESHOLD_NORMAL
302                } * f64::from(delta_packets);
303
304                if moved_dist_sq - self.velocity().length_squared() > threshold {
305                    if let Err(error) =
306                        self.teleport(start_pos, current_rotation.0, current_rotation.1)
307                    {
308                        log::warn!(
309                            "Failed to correct too-fast player {} movement: {error}",
310                            self.id()
311                        );
312                    }
313                    return;
314                }
315            }
316        }
317
318        let old_aabb = self.bounding_box();
319        let move_delta = target_pos - last_good;
320        let moved_upwards = move_delta.y > 0.0;
321        let player_stands_on_something = self.vertical_collision_below();
322
323        if was_on_ground && !packet.on_ground && moved_upwards {
324            self.jump_from_ground();
325        }
326
327        if self.move_entity(MoverType::Player, move_delta).is_none() {
328            if let Err(error) = self.teleport(start_pos, target_yaw, target_pitch) {
329                panic!(
330                    "failed to correct rejected player {} movement: {error}",
331                    self.id()
332                );
333            }
334            return;
335        }
336
337        let error_delta = movement_error_delta(target_pos, self.position());
338        let error_dist_sq = error_delta.length_squared();
339        let in_impulse_grace = self.is_in_post_impulse_grace_time();
340        let fail = error_dist_sq > MOVEMENT_ERROR_THRESHOLD
341            && !is_creative
342            && !is_spectator
343            && !in_impulse_grace;
344
345        let new_aabb = self.bounding_box().translate(target_pos - self.position());
346        let collision_world = WorldCollisionProvider::for_entity(&world, self);
347        let old_collision = collision_world.has_entity_context_collision(
348            old_aabb,
349            self.position().y,
350            self.is_descending(),
351        );
352        let new_collision =
353            is_colliding_with_new_shapes(&collision_world, old_aabb, new_aabb, self.is_crouching());
354
355        if (MovementCollisionValidation {
356            no_physics: self.no_physics(),
357            moved_wrongly: fail,
358            old_collision,
359            new_collision,
360        })
361        .rejects()
362        {
363            if let Err(error) = self.teleport(start_pos, target_yaw, target_pitch) {
364                log::warn!(
365                    "Failed to correct collided player {} movement: {error}",
366                    self.id()
367                );
368            }
369            self.refresh_supporting_block_for_fall_damage(DVec3::ZERO, packet.on_ground);
370            self.do_check_fall_damage(DVec3::ZERO, packet.on_ground, &world);
371            self.remove_latest_movement_recording();
372            return;
373        }
374
375        // Vanilla saves this requested Y delta before recomputing the
376        // post-move residual used by moved-wrongly validation.
377        let floating_check = Some((player_stands_on_something, move_delta.y));
378
379        if packet.on_ground && self.is_sprinting() {
380            let dx = move_delta.x;
381            let dz = move_delta.z;
382
383            let cm = ((dx * dx + dz * dz).sqrt() as f32 * 100.0).round() as i32;
384            if cm > 0 {
385                self.cause_food_exhaustion(food_constants::EXHAUSTION_SPRINT * cm as f32 * 0.01);
386            }
387        }
388
389        let client_delta = target_pos - start_pos;
390        match self.apply_accepted_client_movement(
391            &world,
392            AcceptedClientMovement {
393                position: Some(target_pos),
394                rotation: (target_yaw, target_pitch),
395                on_ground: packet.on_ground,
396                horizontal_collision: packet.horizontal_collision,
397                movement: client_delta,
398                reset_fall_distance: moved_upwards,
399            },
400        ) {
401            Ok(AcceptedClientMovementOutcome::Applied) => {}
402            Ok(AcceptedClientMovementOutcome::Handled) => return,
403            Err(error) => {
404                log::warn!(
405                    "Rejected accepted player movement for entity {}: {error}",
406                    self.id()
407                );
408                if let Err(teleport_error) = self.teleport(start_pos, target_yaw, target_pitch) {
409                    log::warn!(
410                        "Failed to correct rejected player movement for entity {}: {teleport_error}",
411                        self.id()
412                    );
413                }
414                self.remove_latest_movement_recording();
415                return;
416            }
417        }
418        world.chunk_map.update_player_status(self);
419
420        if let Some((player_stands_on_something, y_dist)) = floating_check {
421            self.record_client_floating(
422                &world,
423                y_dist,
424                player_stands_on_something,
425                is_spectator,
426                is_fall_flying,
427            );
428        }
429        self.movement
430            .lock()
431            .mark_last_good_position(self.position());
432
433        self.movement
434            .lock()
435            .set_last_known_client_movement(client_delta);
436    }
437
438    /// Handles a controlled-vehicle movement packet.
439    ///
440    /// Matches vanilla `ServerGamePacketListenerImpl.handleMoveVehicle()`.
441    #[expect(
442        clippy::too_many_lines,
443        reason = "matches vanilla handleMoveVehicle; splitting would hurt readability"
444    )]
445    pub fn handle_move_vehicle(&self, packet: SMoveVehicle) {
446        if Self::is_invalid_position(
447            packet.pos.x,
448            packet.pos.y,
449            packet.pos.z,
450            packet.x_rot,
451            packet.y_rot,
452        ) {
453            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_VEHICLE_MOVEMENT.msg());
454            return;
455        }
456
457        if self.is_world_change_pending() {
458            return;
459        }
460
461        if self.update_awaiting_teleport() || !self.has_client_loaded() {
462            return;
463        }
464
465        let Some(vehicle) = self.root_vehicle() else {
466            return;
467        };
468        if vehicle.is_world_change_pending() {
469            return;
470        }
471        let controlled_by_player = vehicle
472            .controlling_passenger()
473            .is_some_and(|controller| controller.id() == self.id());
474        if !controlled_by_player {
475            return;
476        }
477        let Some((first_good, last_good)) =
478            self.movement.lock().vehicle_good_positions(vehicle.id())
479        else {
480            return;
481        };
482
483        let world = self.get_world();
484        let old_position = vehicle.position();
485        let target_pos = DVec3::new(
486            clamp_horizontal(packet.pos.x),
487            clamp_vertical(packet.pos.y),
488            clamp_horizontal(packet.pos.z),
489        );
490        let target_yaw = wrap_degrees(packet.y_rot);
491        let target_pitch = wrap_degrees(packet.x_rot);
492        let first_good_delta = target_pos - first_good;
493        let moved_dist_sq = first_good_delta.length_squared();
494        let expected_dist_sq = vehicle.velocity().length_squared();
495        if moved_dist_sq - expected_dist_sq > SPEED_THRESHOLD_NORMAL {
496            log::warn!(
497                "{} (vehicle of {}) moved too quickly! {},{},{}",
498                vehicle.id(),
499                self.gameprofile.name,
500                first_good_delta.x,
501                first_good_delta.y,
502                first_good_delta.z
503            );
504            self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
505            return;
506        }
507
508        let old_aabb = vehicle.bounding_box();
509        let move_delta = target_pos - last_good;
510        let vehicle_rests_on_something = vehicle.vertical_collision_below();
511        if vehicle.is_living_entity() && vehicle.on_climbable() {
512            vehicle.reset_fall_distance();
513        }
514
515        if vehicle.move_entity(MoverType::Player, move_delta).is_none() {
516            self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
517            return;
518        }
519
520        let error_delta = movement_error_delta(target_pos, vehicle.position());
521        let error_dist_sq = error_delta.length_squared();
522        let fail = error_dist_sq > MOVEMENT_ERROR_THRESHOLD;
523        if fail {
524            log::warn!(
525                "{} (vehicle of {}) moved wrongly! {}",
526                vehicle.id(),
527                self.gameprofile.name,
528                error_dist_sq.sqrt()
529            );
530        }
531
532        let new_aabb = vehicle
533            .bounding_box()
534            .translate(target_pos - vehicle.position());
535        let collision_world = WorldCollisionProvider::for_entity(&world, vehicle.as_ref());
536        let old_collision = collision_world.has_entity_context_collision(
537            old_aabb,
538            vehicle.position().y,
539            vehicle.is_descending(),
540        );
541        let new_collision = is_colliding_with_new_shapes(
542            &collision_world,
543            old_aabb,
544            new_aabb,
545            vehicle.is_descending(),
546        );
547
548        if (MovementCollisionValidation {
549            no_physics: false,
550            moved_wrongly: fail,
551            old_collision,
552            new_collision,
553        })
554        .rejects()
555        {
556            if let Err(error) = vehicle.try_set_position(old_position) {
557                log::warn!(
558                    "Failed to roll vehicle {} back after rejected movement: {error}",
559                    vehicle.id()
560                );
561            }
562            vehicle.refresh_fluid_contact();
563            vehicle.set_rotation((target_yaw, target_pitch));
564            self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
565            vehicle.remove_latest_movement_recording();
566            return;
567        }
568
569        let client_delta = target_pos - old_position;
570        match vehicle.apply_accepted_client_vehicle_movement(
571            &world,
572            AcceptedClientMovement {
573                position: Some(target_pos),
574                rotation: (target_yaw, target_pitch),
575                on_ground: packet.on_ground,
576                horizontal_collision: vehicle.horizontal_collision(),
577                movement: client_delta,
578                reset_fall_distance: false,
579            },
580        ) {
581            Ok(AcceptedClientMovementOutcome::Applied) => {}
582            Ok(AcceptedClientMovementOutcome::Handled) => return,
583            Err(error) => {
584                log::warn!(
585                    "Rejected accepted vehicle movement for entity {}: {error}",
586                    vehicle.id()
587                );
588                if let Err(rollback_error) = vehicle.try_set_position(old_position) {
589                    log::warn!(
590                        "Failed to roll vehicle {} back after rejected movement: {rollback_error}",
591                        vehicle.id()
592                    );
593                }
594                vehicle.refresh_fluid_contact();
595                vehicle.set_rotation((target_yaw, target_pitch));
596                self.send_packet(Self::move_vehicle_packet_from_entity(vehicle.as_ref()));
597                vehicle.remove_latest_movement_recording();
598                return;
599            }
600        }
601        self.movement
602            .lock()
603            .set_last_known_client_movement(client_delta);
604        world.chunk_map.update_player_status(self);
605        self.record_client_vehicle_floating(
606            &world,
607            vehicle.as_ref(),
608            move_delta.y,
609            vehicle_rests_on_something,
610        );
611        self.movement
612            .lock()
613            .mark_vehicle_last_good_position(vehicle.id(), vehicle.position());
614    }
615
616    fn record_client_floating(
617        &self,
618        world: &World,
619        y_dist: f64,
620        player_stands_on_something: bool,
621        is_spectator: bool,
622        is_fall_flying: bool,
623    ) {
624        // TODO: Add auto-spin exemption when riptide/spin attack state exists.
625        let can_violate_floating = PlayerFloatingValidation {
626            y_dist,
627            player_stands_on_something,
628            is_spectator,
629            server_allows_flight: self.config.allow_flight,
630            may_fly: self.abilities.lock().may_fly,
631            has_levitation: self.has_mob_effect(vanilla_mob_effects::LEVITATION),
632            is_fall_flying,
633        }
634        .can_violate();
635
636        let client_is_floating = can_violate_floating && Self::no_blocks_around_entity(world, self);
637        self.movement
638            .lock()
639            .record_client_floating(client_is_floating);
640    }
641
642    fn record_client_vehicle_floating(
643        &self,
644        world: &World,
645        vehicle: &dyn Entity,
646        y_dist: f64,
647        vehicle_rests_on_something: bool,
648    ) {
649        let client_is_floating = y_dist >= -0.03125
650            && !vehicle_rests_on_something
651            && !self.config.allow_flight
652            && !vehicle.is_flying_vehicle()
653            && !vehicle.is_no_gravity()
654            && Self::no_blocks_around_entity(world, vehicle);
655        self.movement
656            .lock()
657            .record_vehicle_client_floating(vehicle.id(), client_is_floating);
658    }
659
660    fn no_blocks_around_entity(world: &World, entity: &dyn Entity) -> bool {
661        let block_query = entity
662            .bounding_box()
663            .inflate(0.0625)
664            .expand_towards(DVec3::new(0.0, -0.55, 0.0));
665        world.block_states_in_aabb_are_air(block_query)
666    }
667
668    /// Returns how long vanilla permits unsupported floating for this player's gravity.
669    pub(super) fn maximum_flying_ticks(&self) -> i32 {
670        Self::maximum_flying_ticks_for_gravity(self.get_gravity())
671    }
672
673    /// Returns how long vanilla permits unsupported floating for an entity gravity value.
674    #[expect(
675        clippy::cast_possible_truncation,
676        reason = "gravity threshold bounds the result far below i32::MAX"
677    )]
678    fn maximum_flying_ticks_for_gravity(gravity: f64) -> i32 {
679        if gravity < 1.0E-5 {
680            return i32::MAX;
681        }
682
683        let gravity_modifier = DEFAULT_GRAVITY / gravity;
684        (80.0 * gravity_modifier.max(1.0)).ceil() as i32
685    }
686
687    /// Advances vanilla's floating violation tracker and disconnects when exceeded.
688    pub(super) fn disconnect_if_floating_too_long(&self) -> bool {
689        let should_count = !self.is_sleeping() && !self.is_passenger() && !self.is_dead_or_dying();
690        let maximum_flying_ticks = self.maximum_flying_ticks();
691        let should_disconnect = self
692            .movement
693            .lock()
694            .tick_client_floating(should_count, maximum_flying_ticks);
695
696        if should_disconnect {
697            log::warn!(
698                "{} was kicked for floating too long!",
699                self.gameprofile.name
700            );
701            self.disconnect(translations::MULTIPLAYER_DISCONNECT_FLYING.msg());
702        }
703
704        should_disconnect
705    }
706
707    /// Advances vanilla's controlled-vehicle floating tracker and disconnects when exceeded.
708    pub(super) fn disconnect_if_vehicle_floating_too_long(&self) -> bool {
709        let Some(vehicle) = self.root_vehicle() else {
710            self.movement.lock().clear_vehicle_for_tick();
711            return false;
712        };
713        let controlled_by_player = vehicle
714            .controlling_passenger()
715            .is_some_and(|controller| controller.id() == self.id());
716        if !controlled_by_player {
717            self.movement.lock().clear_vehicle_for_tick();
718            return false;
719        }
720
721        let maximum_flying_ticks = Self::maximum_flying_ticks_for_gravity(vehicle.get_gravity());
722        let should_disconnect = self
723            .movement
724            .lock()
725            .tick_vehicle_client_floating(vehicle.id(), maximum_flying_ticks);
726
727        if should_disconnect {
728            log::warn!(
729                "{} was kicked for floating a vehicle too long!",
730                self.gameprofile.name
731            );
732            self.disconnect(translations::MULTIPLAYER_DISCONNECT_FLYING.msg());
733        }
734
735        should_disconnect
736    }
737
738    /// Returns true if we're waiting for a teleport confirmation.
739    #[must_use]
740    pub fn is_awaiting_teleport(&self) -> bool {
741        self.teleport_state.lock().is_awaiting()
742    }
743
744    /// Teleports the player to a new position.
745    ///
746    /// Sends a `CPlayerPosition` packet and waits for client acknowledgment.
747    /// Until acknowledged, movement packets from the client will be rejected.
748    ///
749    /// Matches vanilla `ServerGamePacketListenerImpl.teleport()`.
750    pub fn teleport(&self, pos: DVec3, yaw: f32, pitch: f32) -> Result<(), EntityMoveError> {
751        self.teleport_with_velocity(pos, DVec3::ZERO, yaw, pitch)
752    }
753
754    /// Sends a `CPlayerPosition` packet with explicit delta movement for vanilla
755    /// `ServerPlayer.teleport(TeleportTransition)` paths.
756    pub(crate) fn teleport_with_velocity(
757        &self,
758        pos: DVec3,
759        velocity: DVec3,
760        yaw: f32,
761        pitch: f32,
762    ) -> Result<(), EntityMoveError> {
763        self.teleport_with_velocity_packet(
764            pos,
765            velocity,
766            (yaw, pitch),
767            pos,
768            velocity,
769            (yaw, pitch),
770            RelativeMovement::NONE,
771        )
772    }
773
774    /// Commits resolved server state while sending vanilla packet-relative values.
775    #[expect(
776        clippy::too_many_arguments,
777        reason = "packet-relative teleports must keep resolved and protocol values separate"
778    )]
779    pub(crate) fn teleport_with_velocity_packet(
780        &self,
781        pos: DVec3,
782        velocity: DVec3,
783        rotation: (f32, f32),
784        packet_pos: DVec3,
785        packet_velocity: DVec3,
786        packet_rotation: (f32, f32),
787        relatives: RelativeMovement,
788    ) -> Result<(), EntityMoveError> {
789        let world = self.get_world();
790        self.try_set_position(pos)?;
791        if world.entity_manager().get_by_id(self.id()).is_some() {
792            world.chunk_map.update_player_status(self);
793        }
794        self.set_velocity(velocity);
795
796        let new_id = {
797            let mut tp = self.teleport_state.lock();
798            tp.teleport_time = self.tick_count();
799            let id = tp.next_id();
800            tp.awaiting_position = Some(pos);
801            id
802        };
803
804        self.set_rotation(rotation);
805        self.set_old_position_to_current();
806        {
807            let mut movement = self.movement.lock();
808            movement.reset_last_known_client_movement();
809        }
810
811        self.send_packet(CPlayerPosition::new(
812            new_id,
813            packet_pos,
814            packet_velocity,
815            packet_rotation.0,
816            packet_rotation.1,
817            relatives,
818        ));
819        Ok(())
820    }
821
822    /// Resets vanilla floating violation counters after a successful high-level teleport.
823    pub(crate) fn reset_flying_ticks(&self) {
824        self.movement.lock().reset_flying_ticks();
825    }
826
827    /// Handles a teleport acknowledgment from the client.
828    ///
829    /// Matches vanilla `ServerGamePacketListenerImpl.handleAcceptTeleportPacket()`.
830    pub fn handle_accept_teleportation(&self, packet: SAcceptTeleportation) {
831        let mut tp = self.teleport_state.lock();
832
833        if let Some(pos) = tp.try_accept(packet.teleport_id) {
834            drop(tp);
835            if let Err(error) = self.try_set_position(pos) {
836                log::warn!(
837                    "Failed to commit accepted teleport for player entity {}: {error}",
838                    self.id()
839                );
840                self.teleport_state.lock().awaiting_position = Some(pos);
841                return;
842            }
843            self.set_old_position_to_current();
844            let mut movement = self.movement.lock();
845            movement.mark_last_good_position(pos);
846            movement.reset_last_known_client_movement();
847        } else if packet.teleport_id == tp.teleport_id && tp.awaiting_position.is_none() {
848            drop(tp);
849            self.disconnect(translations::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT.msg());
850        }
851    }
852
853    /// Returns the latest vanilla client input snapshot.
854    #[must_use]
855    pub fn last_client_input(&self) -> PlayerInput {
856        self.movement.lock().last_client_input()
857    }
858
859    /// Handles a player input packet (movement keys, sneaking, sprinting).
860    pub fn handle_player_input(&self, packet: SPlayerInput) {
861        // Vanilla stores the input unconditionally before the guard check.
862        let input = PlayerInput::from_flags(packet.flags);
863        self.movement.lock().set_last_client_input(input);
864
865        if !self.has_client_loaded() {
866            return;
867        }
868
869        // TODO: Vanilla calls this.player.resetLastActionTime() here which sets
870        // lastActionTime = Util.getMillis(), preventing idle-kick. Add when idle-kick system is implemented.
871
872        self.set_crouching(input.shift());
873    }
874
875    /// Handles a player command packet (sprinting, elytra, leaving bed, etc).
876    pub fn handle_player_command(&self, packet: SPlayerCommand) {
877        if !self.has_client_loaded() {
878            return;
879        }
880
881        if packet.entity_id != self.id() {
882            log::warn!(
883                "Player {} (eid {}) sent SPlayerCommand with mismatched entity_id {}",
884                self.gameprofile.name,
885                self.id(),
886                packet.entity_id
887            );
888            return;
889        }
890
891        // TODO: Vanilla calls this.player.resetLastActionTime() here which sets
892        // noActionTime = 0, preventing idle-kick. Add when idle-kick system is implemented.
893
894        match packet.action {
895            PlayerCommandAction::StartSprinting => {
896                self.set_sprinting(true);
897            }
898            PlayerCommandAction::StopSprinting => {
899                self.set_sprinting(false);
900            }
901            PlayerCommandAction::StartFallFlying => {
902                if !self.try_to_start_fall_flying() {
903                    self.stop_fall_flying();
904                }
905            }
906            PlayerCommandAction::LeaveBed => {
907                if self.is_sleeping() {
908                    self.stop_sleeping();
909                    // TODO: Full bed wake-up logic:
910                    //   - set bed block OCCUPIED property to false
911                    //   - compute stand-up position via BedBlock::findStandUpPosition
912                    //   - teleport player + set rotation toward bed
913                    //   - set pose to Standing, clear sleeping pos entity data
914                    //   - update server sleeping player list (for sleep-skip)
915                    //   - set sleepCounter = 100
916                    //   - set awaiting_position_from_client
917                    // Blocked on: bed block properties, sleeping pos entity data
918                }
919            }
920            PlayerCommandAction::StartRidingJump
921            | PlayerCommandAction::StopRidingJump
922            | PlayerCommandAction::OpenVehicleInventory => {
923                // TODO: Implement once controlled vehicle jumping and vehicle inventory interfaces exist.
924            }
925        }
926
927        // Dirty shared flags are synced once per tick by sync_entity_data().
928    }
929}
930
931#[cfg(test)]
932#[expect(clippy::float_cmp, reason = "exact match against vanilla test vectors")]
933mod tests {
934    use super::*;
935
936    #[test]
937    fn test_clamp_horizontal() {
938        assert_eq!(clamp_horizontal(0.0), 0.0);
939        assert_eq!(clamp_horizontal(1e8), CLAMP_HORIZONTAL);
940        assert_eq!(clamp_horizontal(-1e8), -CLAMP_HORIZONTAL);
941    }
942
943    #[test]
944    fn test_clamp_vertical() {
945        assert_eq!(clamp_vertical(0.0), 0.0);
946        assert_eq!(clamp_vertical(1e8), CLAMP_VERTICAL);
947        assert_eq!(clamp_vertical(-1e8), -CLAMP_VERTICAL);
948    }
949
950    #[test]
951    fn test_wrap_degrees() {
952        assert_eq!(wrap_degrees(181.0), -179.0);
953        assert_eq!(wrap_degrees(-181.0), 179.0);
954        assert_eq!(wrap_degrees(90.0), 90.0);
955    }
956
957    #[test]
958    fn floating_validation_exempts_levitation_like_vanilla() {
959        let validation = PlayerFloatingValidation {
960            y_dist: 0.0,
961            player_stands_on_something: false,
962            is_spectator: false,
963            server_allows_flight: false,
964            may_fly: false,
965            has_levitation: false,
966            is_fall_flying: false,
967        };
968
969        assert!(validation.can_violate());
970        assert!(
971            !PlayerFloatingValidation {
972                y_dist: -0.0784,
973                ..validation
974            }
975            .can_violate()
976        );
977        assert!(
978            !PlayerFloatingValidation {
979                has_levitation: true,
980                ..validation
981            }
982            .can_violate()
983        );
984    }
985}