1mod 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
37pub const DEFAULT_GRAVITY: f64 = 0.08;
39
40pub const SPEED_THRESHOLD_NORMAL: f64 = 100.0;
42pub const SPEED_THRESHOLD_FLYING: f64 = 300.0;
44
45pub const CLAMP_HORIZONTAL: f64 = 3.0E7;
47pub const CLAMP_VERTICAL: f64 = 2.0E7;
49
50pub const RESET_ACTION_TIME_DELTA_THRESHOLD: f64 = 1E-5;
52
53#[must_use]
55pub fn clamp_horizontal(value: f64) -> f64 {
56 value.clamp(-CLAMP_HORIZONTAL, CLAMP_HORIZONTAL)
57}
58
59#[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 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 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 pub fn apply_post_impulse_grace_time(&self, ticks: i32) {
165 LivingEntity::apply_post_impulse_grace_time(self, ticks);
166 }
167
168 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 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 #[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 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 #[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 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 pub(super) fn maximum_flying_ticks(&self) -> i32 {
685 Self::maximum_flying_ticks_for_gravity(self.get_gravity())
686 }
687
688 #[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 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 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 #[must_use]
755 pub fn is_awaiting_teleport(&self) -> bool {
756 self.teleport_state.lock().is_awaiting()
757 }
758
759 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 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 #[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 pub(crate) fn reset_flying_ticks(&self) {
839 self.movement.lock().reset_flying_ticks();
840 }
841
842 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 #[must_use]
870 pub fn last_client_input(&self) -> PlayerInput {
871 self.movement.lock().last_client_input()
872 }
873
874 pub fn handle_player_input(&self, packet: SPlayerInput) {
876 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 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 }
929 }
930
931 }
933
934 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 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 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}