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