validates translations at compile time, also update bedrock translations to latest 26.40.5
3040 lines
112 KiB
Rust
3040 lines
112 KiB
Rust
use pumpkin_protocol::bedrock::server::text::SText;
|
|
use pumpkin_util::{Hand, PermissionLvl};
|
|
use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey};
|
|
use rsa::signature::Verifier;
|
|
use sha1::Sha1;
|
|
use std::num::NonZeroU8;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use thiserror::Error;
|
|
use tracing::{Level, debug, error, info, trace, warn};
|
|
|
|
use crate::block::BlockHitResult;
|
|
use crate::block::registry::BlockActionResult;
|
|
use crate::block::{self};
|
|
use crate::entity::EntityBase;
|
|
use crate::entity::equipment_break_status;
|
|
use crate::entity::player::statistics::{CustomStatistic, StatisticCategory};
|
|
use crate::entity::player::{ChatMode, ChatSession, MINE_BLOCK_EXHAUSTION, Player};
|
|
use crate::error::PumpkinError;
|
|
use crate::log_at_level;
|
|
use crate::net::PlayerConfig;
|
|
use crate::net::java::JavaClient;
|
|
use crate::plugin::player::changed_main_hand::PlayerChangedMainHandEvent;
|
|
use crate::plugin::player::fish::{PlayerFishEvent, PlayerFishState};
|
|
use crate::plugin::player::item_held::PlayerItemHeldEvent;
|
|
use crate::plugin::player::player_chat::PlayerChatEvent;
|
|
use crate::plugin::player::player_command_send::PlayerCommandSendEvent;
|
|
use crate::plugin::player::player_interact_entity_event::PlayerInteractEntityEvent;
|
|
use crate::plugin::player::player_interact_event::{InteractAction, PlayerInteractEvent};
|
|
use crate::plugin::player::player_interact_unknown_entity_event::PlayerInteractUnknownEntityEvent;
|
|
use crate::plugin::player::player_move::PlayerMoveEvent;
|
|
use crate::plugin::player::player_toggle_flight_event::PlayerToggleFlightEvent;
|
|
use crate::plugin::player::player_toggle_sneak_event::PlayerToggleSneakEvent;
|
|
|
|
use crate::block::entities::command_block::CommandBlockEntity;
|
|
use crate::block::entities::jigsaw_block::JigsawBlockEntity;
|
|
use crate::block::entities::sign::SignBlockEntity;
|
|
use crate::plugin::player::player_toggle_sprint_event::PlayerToggleSprintEvent;
|
|
use crate::server::{Server, seasonal_events};
|
|
use crate::world::{BlockBreakingProgress, World, chunker};
|
|
use pumpkin_data::block_properties::{BlockProperties, CommandBlockLikeProperties};
|
|
use pumpkin_data::data_component::DataComponent;
|
|
use pumpkin_data::data_component_impl::{
|
|
BlocksAttacksImpl, ConsumableImpl, DataComponentImpl, EquipmentSlot, EquippableImpl, FoodImpl,
|
|
WritableBookContentImpl, WrittenBookContentImpl,
|
|
};
|
|
use pumpkin_data::item::Item;
|
|
use pumpkin_data::item_stack::ItemStack;
|
|
use pumpkin_data::sound::{Sound, SoundCategory};
|
|
use pumpkin_data::{Advancement, Block, BlockDirection, translation};
|
|
use pumpkin_inventory::InventoryError;
|
|
use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler;
|
|
use pumpkin_inventory::player::player_inventory::PlayerInventory;
|
|
use pumpkin_inventory::screen_handler::{InventoryPlayer, ScreenHandler};
|
|
use pumpkin_protocol::bedrock::client::CMovePlayer;
|
|
use pumpkin_protocol::codec::var_int::VarInt;
|
|
use pumpkin_protocol::codec::var_ulong::VarULong;
|
|
use pumpkin_protocol::java::client::play::{
|
|
CBlockUpdate, CCommandSuggestions, CEntityPositionSync, CHeadRot, COpenSignEditor,
|
|
CPingResponse, CPlayerInfoUpdate, CPlayerPosition, CSetCamera, CSetSelectedSlot,
|
|
CSystemChatMessage, CUpdateEntityPos, CUpdateEntityPosRot, CUpdateEntityRot, InitChat,
|
|
PlayerAction,
|
|
};
|
|
use pumpkin_protocol::java::server::play::{
|
|
Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SBundleItemSelected,
|
|
SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClientCommand,
|
|
SClientInformationPlay, SCloseContainer, SCommandSuggestion, SConfirmTeleport,
|
|
SCookieResponse as SPCookieResponse, SEditBook, SInteract, SJigsawGenerate, SKeepAlive,
|
|
SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPickItemFromEntity, SPlaceRecipe,
|
|
SPlayPingRequest, SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput,
|
|
SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession,
|
|
SRecipeBookChangeSettings, SRecipeBookSeenRecipe, SSeenAdvancement, SSelectTrade,
|
|
SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetJigsawBlock, SSetPlayerGround,
|
|
SSetTestBlock, SSwingArm, STeleportToEntity, STestInstanceBlockAction, SUpdateSign, SUseItem,
|
|
SUseItemOn, Status,
|
|
};
|
|
use pumpkin_util::math::vector3::Vector3;
|
|
use pumpkin_util::math::{polynomial_rolling_hash, position::BlockPos, wrap_degrees};
|
|
use pumpkin_util::{GameMode, text::TextComponent};
|
|
use pumpkin_world::generation::structure::structures::jigsaw::JigsawJointType;
|
|
use pumpkin_world::world::BlockFlags;
|
|
use tokio::sync::Mutex;
|
|
|
|
/// In secure chat mode, Player will be kicked if they send a chat message with a timestamp that is older than this (in ms)
|
|
/// Vanilla: 2 minutes
|
|
const CHAT_MESSAGE_MAX_AGE: i64 = 1000 * 60 * 2;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum BlockPlacingError {
|
|
BlockOutOfReach,
|
|
InvalidHand,
|
|
InvalidBlockFace,
|
|
BlockOutOfWorld,
|
|
InvalidGamemode,
|
|
}
|
|
|
|
impl std::fmt::Display for BlockPlacingError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{self:?}")
|
|
}
|
|
}
|
|
|
|
impl PumpkinError for BlockPlacingError {
|
|
fn is_kick(&self) -> bool {
|
|
match self {
|
|
Self::BlockOutOfReach | Self::BlockOutOfWorld | Self::InvalidGamemode => false,
|
|
Self::InvalidBlockFace | Self::InvalidHand => true,
|
|
}
|
|
}
|
|
|
|
fn severity(&self) -> Level {
|
|
match self {
|
|
Self::BlockOutOfWorld | Self::InvalidGamemode => Level::TRACE,
|
|
Self::BlockOutOfReach | Self::InvalidBlockFace | Self::InvalidHand => Level::WARN,
|
|
}
|
|
}
|
|
|
|
fn client_kick_reason(&self) -> Option<String> {
|
|
match self {
|
|
Self::BlockOutOfReach | Self::BlockOutOfWorld | Self::InvalidGamemode => None,
|
|
Self::InvalidBlockFace => Some("Invalid block face".into()),
|
|
Self::InvalidHand => Some("Invalid hand".into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ChatError {
|
|
#[error("sent an oversized message")]
|
|
OversizedMessage,
|
|
#[error("sent a message with illegal characters")]
|
|
IllegalCharacters,
|
|
#[error("sent a chat with invalid/no signature")]
|
|
UnsignedChat,
|
|
#[error("has too many unacknowledged chats queued")]
|
|
TooManyPendingChats,
|
|
#[error("sent a chat that couldn't be validated")]
|
|
ChatValidationFailed,
|
|
#[error("sent a chat with an out of order timestamp")]
|
|
OutOfOrderChat,
|
|
#[error("has an expired public key")]
|
|
ExpiredPublicKey,
|
|
#[error("attempted to initialize a session with an invalid public key")]
|
|
InvalidPublicKey,
|
|
}
|
|
|
|
impl PumpkinError for ChatError {
|
|
fn is_kick(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn severity(&self) -> Level {
|
|
Level::WARN
|
|
}
|
|
|
|
fn client_kick_reason(&self) -> Option<String> {
|
|
match self {
|
|
Self::OversizedMessage => Some("Chat message too long".into()),
|
|
Self::IllegalCharacters => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_ILLEGAL_CHARACTERS,
|
|
translation::java::MULTIPLAYER_DISCONNECT_ILLEGAL_CHARACTERS,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
Self::UnsignedChat => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT,
|
|
translation::java::MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
Self::TooManyPendingChats => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS,
|
|
translation::java::MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
Self::ChatValidationFailed => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED,
|
|
translation::java::MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
Self::OutOfOrderChat => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT,
|
|
translation::java::MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
Self::ExpiredPublicKey => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY,
|
|
translation::java::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
Self::InvalidPublicKey => Some(
|
|
TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE,
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE,
|
|
[],
|
|
)
|
|
.get_text(),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles all Play packets sent by a real player.
|
|
/// NEVER TRUST THE CLIENT. HANDLE EVERY ERROR; UNWRAP/EXPECT ARE FORBIDDEN.
|
|
impl JavaClient {
|
|
pub async fn handle_confirm_teleport(
|
|
&self,
|
|
player: &Player,
|
|
confirm_teleport: SConfirmTeleport,
|
|
) {
|
|
let mut awaiting_teleport = player.awaiting_teleport.lock().await;
|
|
if let Some((id, position)) = awaiting_teleport.as_ref() {
|
|
if id == &confirm_teleport.teleport_id {
|
|
// We should set the position now to what we requested in the teleport packet.
|
|
// This may fix issues when the client sends the position while being teleported.
|
|
player.get_entity().set_pos(*position);
|
|
|
|
*awaiting_teleport = None;
|
|
drop(awaiting_teleport);
|
|
} else {
|
|
drop(awaiting_teleport);
|
|
self.kick(TextComponent::text("Wrong teleport id")).await;
|
|
}
|
|
} else {
|
|
drop(awaiting_teleport);
|
|
self.kick(TextComponent::text(
|
|
"Send Teleport confirm, but we did not teleport",
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn handle_change_game_mode(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
change_game_mode: SChangeGameMode,
|
|
) {
|
|
if player.permission_lvl.load() >= PermissionLvl::Two {
|
|
player.set_gamemode(change_game_mode.game_mode).await;
|
|
let gamemode_string = format!("gameMode.{}", change_game_mode.game_mode.name());
|
|
player
|
|
.send_system_message(&TextComponent::translate_cross(
|
|
translation::java::COMMANDS_GAMEMODE_SUCCESS_SELF,
|
|
translation::bedrock::COMMANDS_GAMEMODE_SUCCESS_SELF,
|
|
[TextComponent::translate_cross(
|
|
gamemode_string.clone(),
|
|
gamemode_string,
|
|
[],
|
|
)],
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
const fn clamp_horizontal(pos: f64) -> f64 {
|
|
pos.clamp(-3.0E7, 3.0E7)
|
|
}
|
|
|
|
const fn clamp_vertical(pos: f64) -> f64 {
|
|
pos.clamp(-2.0E7, 2.0E7)
|
|
}
|
|
|
|
pub fn handle_player_loaded(player: &Player) {
|
|
player.set_client_loaded(true);
|
|
}
|
|
|
|
/// Returns whether syncing the position was needed
|
|
fn sync_position(
|
|
player: &Arc<Player>,
|
|
world: &World,
|
|
pos: Vector3<f64>,
|
|
last_pos: Vector3<f64>,
|
|
yaw: f32,
|
|
pitch: f32,
|
|
on_ground: bool,
|
|
) -> bool {
|
|
let delta = Vector3::new(pos.x - last_pos.x, pos.y - last_pos.y, pos.z - last_pos.z);
|
|
let entity_id = player.entity_id();
|
|
|
|
// Teleport when more than 8 blocks (-8..=7.999755859375)
|
|
if delta.length_squared() < 64.0 {
|
|
return false;
|
|
}
|
|
// Sync position with all other players.
|
|
world.broadcast_packet_except(
|
|
&[player.gameprofile.id],
|
|
&CEntityPositionSync::new(
|
|
entity_id.into(),
|
|
pos,
|
|
Vector3::new(0.0, 0.0, 0.0),
|
|
yaw,
|
|
pitch,
|
|
on_ground,
|
|
),
|
|
);
|
|
true
|
|
}
|
|
|
|
#[expect(clippy::too_many_lines)]
|
|
pub async fn handle_position(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
server: &Arc<Server>,
|
|
packet: SPlayerPosition,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
if player.get_entity().has_vehicle().await {
|
|
return;
|
|
}
|
|
// Ignore movement packets while awaiting a teleport confirmation (vanilla behavior)
|
|
if player.awaiting_teleport.lock().await.is_some() {
|
|
return;
|
|
}
|
|
// y = feet Y
|
|
let position = packet.position;
|
|
if position.x.is_nan() || position.y.is_nan() || position.z.is_nan() {
|
|
self.kick(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT,
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT,
|
|
[],
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
let position = Vector3::new(
|
|
Self::clamp_horizontal(position.x),
|
|
Self::clamp_vertical(position.y),
|
|
Self::clamp_horizontal(position.z),
|
|
);
|
|
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerMoveEvent {
|
|
player: player.clone(),
|
|
from: player.get_entity().pos.load(),
|
|
to: position,
|
|
cancelled: false,
|
|
};
|
|
|
|
'after: {
|
|
let pos = event.to;
|
|
let entity = &player.get_entity();
|
|
let last_pos = entity.pos.load();
|
|
player.get_entity().set_pos(pos);
|
|
|
|
let distance = last_pos.squared_distance_to_vec(&pos).sqrt();
|
|
let cm = (distance * 100.0) as i32;
|
|
if cm > 0 {
|
|
let stat = player.get_movement_statistic().await;
|
|
player
|
|
.increment_stat(StatisticCategory::Custom, stat as i32, cm)
|
|
.await;
|
|
}
|
|
|
|
let height_difference = pos.y - last_pos.y;
|
|
if entity.on_ground.load(Ordering::Relaxed) && packet.collision & FLAG_ON_GROUND == 0 && height_difference > 0.0 {
|
|
player.jump().await;
|
|
}
|
|
|
|
let new_on_ground = packet.collision & FLAG_ON_GROUND != 0;
|
|
entity.on_ground.store(new_on_ground, Ordering::Relaxed);
|
|
if new_on_ground && entity.is_fall_flying() {
|
|
entity.set_fall_flying(false).await;
|
|
}
|
|
let world = &player.world();
|
|
|
|
// TODO: Warn when player moves to quickly
|
|
if !Self::sync_position(player, world, pos, last_pos, entity.yaw.load(), entity.pitch.load(), packet.collision & FLAG_ON_GROUND != 0) {
|
|
// Send the new position to all other players.
|
|
world.broadcast_packet_except_editioned_sync(
|
|
&[player.gameprofile.id],
|
|
&CUpdateEntityPos::new(
|
|
player.entity_id().into(),
|
|
Vector3::new(
|
|
pos.x.mul_add(4096.0, -(last_pos.x * 4096.0)) as i16,
|
|
pos.y.mul_add(4096.0, -(last_pos.y * 4096.0)) as i16,
|
|
pos.z.mul_add(4096.0, -(last_pos.z * 4096.0)) as i16,
|
|
),
|
|
packet.collision & FLAG_ON_GROUND != 0,
|
|
),
|
|
&CMovePlayer::new(
|
|
VarULong(player.entity_id() as u64),
|
|
Vector3::new(pos.x as f32, pos.y as f32 + player.get_entity().entity_type.eye_height, pos.z as f32),
|
|
entity.pitch.load(),
|
|
entity.yaw.load(),
|
|
entity.yaw.load(),
|
|
CMovePlayer::MODE_NORMAL,
|
|
(packet.collision & FLAG_ON_GROUND) != 0,
|
|
VarULong(0),
|
|
0,
|
|
0,
|
|
VarULong(0),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Only process fall damage if player is alive
|
|
if !player.abilities.lock().await.flying
|
|
&& player.living_entity.health.load() > 0.0
|
|
&& !player.living_entity.dead.load(Ordering::Relaxed)
|
|
{
|
|
player.living_entity
|
|
.fall(
|
|
player.clone(),
|
|
height_difference,
|
|
packet.collision & FLAG_ON_GROUND != 0,
|
|
player.gamemode.load() == GameMode::Creative,
|
|
)
|
|
.await;
|
|
}
|
|
chunker::update_position(player).await;
|
|
let delta = Vector3::new(
|
|
pos.x - last_pos.x,
|
|
pos.y - last_pos.y,
|
|
pos.z - last_pos.z,
|
|
);
|
|
// Only update idle timeout if there's actual movement (vanilla threshold)
|
|
if delta.length_squared() > 1.0E-5 {
|
|
player.update_last_action_time();
|
|
}
|
|
player.progress_motion(delta).await;
|
|
}
|
|
|
|
'cancelled: {
|
|
self.force_tp(player, player.get_entity().pos.load()).await;
|
|
}
|
|
}}
|
|
}
|
|
|
|
#[expect(clippy::too_many_lines)]
|
|
pub async fn handle_position_rotation(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
server: &Arc<Server>,
|
|
packet: SPlayerPositionRotation,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
if player.get_entity().has_vehicle().await {
|
|
return;
|
|
}
|
|
// Ignore movement packets while awaiting a teleport confirmation (vanilla behavior)
|
|
if player.awaiting_teleport.lock().await.is_some() {
|
|
return;
|
|
}
|
|
// y = feet Y
|
|
let position = packet.position;
|
|
if !position.x.is_finite()
|
|
|| !position.y.is_finite()
|
|
|| !position.z.is_finite()
|
|
|| !packet.yaw.is_finite()
|
|
|| !packet.pitch.is_finite()
|
|
{
|
|
self.kick(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT,
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT,
|
|
[],
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let position = Vector3::new(
|
|
Self::clamp_horizontal(position.x),
|
|
Self::clamp_vertical(position.y),
|
|
Self::clamp_horizontal(position.z),
|
|
);
|
|
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerMoveEvent::new(
|
|
player.clone(),
|
|
player.get_entity().pos.load(),
|
|
position,
|
|
);
|
|
|
|
'after: {
|
|
let pos = event.to;
|
|
let entity = &player.get_entity();
|
|
let last_pos = entity.pos.load();
|
|
player.get_entity().set_pos(pos);
|
|
|
|
let distance = last_pos.squared_distance_to_vec(&pos).sqrt();
|
|
let cm = (distance * 100.0) as i32;
|
|
if cm > 0 {
|
|
let stat = player.get_movement_statistic().await;
|
|
player
|
|
.increment_stat(StatisticCategory::Custom, stat as i32, cm)
|
|
.await;
|
|
}
|
|
|
|
let height_difference = pos.y - last_pos.y;
|
|
if entity.on_ground.load(Ordering::Relaxed)
|
|
&& (packet.collision & FLAG_ON_GROUND) != 0
|
|
&& height_difference > 0.0
|
|
{
|
|
player.jump().await;
|
|
}
|
|
entity
|
|
.on_ground
|
|
.store((packet.collision & FLAG_ON_GROUND) != 0, Ordering::Relaxed);
|
|
|
|
entity.set_rotation(wrap_degrees(packet.yaw) % 360.0, wrap_degrees(packet.pitch));
|
|
|
|
let entity_id = entity.entity_id;
|
|
|
|
let yaw = (entity.yaw.load() * 256.0 / 360.0).rem_euclid(256.0);
|
|
let pitch = (entity.pitch.load() * 256.0 / 360.0).rem_euclid(256.0);
|
|
// let head_yaw = (entity.head_yaw * 256.0 / 360.0).floor();
|
|
let world = entity.world.load_full();
|
|
|
|
// TODO: Warn when player moves to quickly
|
|
if !Self::
|
|
sync_position(player, &world, pos, last_pos, yaw, pitch, (packet.collision & FLAG_ON_GROUND) != 0)
|
|
{
|
|
// Send the new position to all other players.
|
|
world.broadcast_packet_except_editioned_sync(
|
|
&[player.gameprofile.id],
|
|
&CUpdateEntityPosRot::new(
|
|
entity_id.into(),
|
|
Vector3::new(
|
|
pos.x.mul_add(4096.0, -(last_pos.x * 4096.0)) as i16,
|
|
pos.y.mul_add(4096.0, -(last_pos.y * 4096.0)) as i16,
|
|
pos.z.mul_add(4096.0, -(last_pos.z * 4096.0)) as i16,
|
|
),
|
|
yaw as u8,
|
|
pitch as u8,
|
|
(packet.collision & FLAG_ON_GROUND) != 0,
|
|
),
|
|
&CMovePlayer::new(
|
|
VarULong(entity_id as u64),
|
|
Vector3::new(pos.x as f32, pos.y as f32 + player.get_entity().entity_type.eye_height, pos.z as f32),
|
|
entity.pitch.load(),
|
|
entity.yaw.load(),
|
|
entity.yaw.load(),
|
|
CMovePlayer::MODE_NORMAL,
|
|
(packet.collision & FLAG_ON_GROUND) != 0,
|
|
VarULong(0),
|
|
0,
|
|
0,
|
|
VarULong(0),
|
|
),
|
|
);
|
|
}
|
|
|
|
world
|
|
.broadcast_packet_except(
|
|
&[player.gameprofile.id],
|
|
&CHeadRot::new(entity_id.into(), yaw as u8),
|
|
)
|
|
;
|
|
// Only process fall damage if player is alive
|
|
if !player.abilities.lock().await.flying
|
|
&& player.living_entity.health.load() > 0.0
|
|
&& !player.living_entity.dead.load(Ordering::Relaxed)
|
|
{
|
|
player.living_entity
|
|
.fall(
|
|
player.clone(),
|
|
height_difference,
|
|
(packet.collision & FLAG_ON_GROUND) != 0,
|
|
player.gamemode.load() == GameMode::Creative,
|
|
)
|
|
.await;
|
|
}
|
|
chunker::update_position(player).await;
|
|
let delta = Vector3::new(
|
|
pos.x - last_pos.x,
|
|
pos.y - last_pos.y,
|
|
pos.z - last_pos.z,
|
|
);
|
|
// Only update idle timeout if there's actual movement (vanilla threshold)
|
|
if delta.length_squared() > 1.0E-5 {
|
|
player.update_last_action_time();
|
|
}
|
|
player.progress_motion(delta).await;
|
|
}
|
|
|
|
'cancelled: {
|
|
self.force_tp(player, position).await;
|
|
}
|
|
}}
|
|
}
|
|
|
|
pub async fn force_tp(&self, player: &Arc<Player>, position: Vector3<f64>) {
|
|
let teleport_id = player.teleport_id_count.fetch_add(1, Ordering::Relaxed) + 1;
|
|
*player.awaiting_teleport.lock().await = Some((teleport_id.into(), position));
|
|
self.enqueue_packet(&CPlayerPosition::new(
|
|
teleport_id.into(),
|
|
player.get_entity().pos.load(),
|
|
Vector3::new(0.0, 0.0, 0.0),
|
|
player.get_entity().yaw.load(),
|
|
player.get_entity().pitch.load(),
|
|
Vec::new(),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
pub async fn handle_rotation(&self, player: &Player, rotation: SPlayerRotation) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
if !rotation.yaw.is_finite() || !rotation.pitch.is_finite() {
|
|
self.kick(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT,
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT,
|
|
[],
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
let entity = &player.get_entity();
|
|
entity.on_ground.store(rotation.ground, Ordering::Relaxed);
|
|
entity.set_rotation(
|
|
wrap_degrees(rotation.yaw) % 360.0,
|
|
wrap_degrees(rotation.pitch),
|
|
);
|
|
// Send the new position to all other players.
|
|
let entity_id = entity.entity_id;
|
|
let yaw = (entity.yaw.load() * 256.0 / 360.0).rem_euclid(256.0);
|
|
let pitch = (entity.pitch.load() * 256.0 / 360.0).rem_euclid(256.0);
|
|
// let head_yaw = modulus(entity.head_yaw * 256.0 / 360.0, 256.0);
|
|
|
|
let world = entity.world.load_full();
|
|
let je_packet =
|
|
CUpdateEntityRot::new(entity_id.into(), yaw as u8, pitch as u8, rotation.ground);
|
|
|
|
let pos = entity.pos.load();
|
|
|
|
let be_packet = CMovePlayer::new(
|
|
VarULong(entity_id as u64),
|
|
Vector3::new(
|
|
pos.x as f32,
|
|
pos.y as f32 + player.get_entity().entity_type.eye_height,
|
|
pos.z as f32,
|
|
),
|
|
entity.pitch.load(),
|
|
entity.yaw.load(),
|
|
entity.yaw.load(),
|
|
CMovePlayer::MODE_ROTATION,
|
|
rotation.ground,
|
|
VarULong(0),
|
|
0,
|
|
0,
|
|
VarULong(0),
|
|
);
|
|
|
|
world.broadcast_packet_except_editioned_sync(
|
|
&[player.gameprofile.id],
|
|
&je_packet,
|
|
&be_packet,
|
|
);
|
|
|
|
let je_packet = CHeadRot::new(entity_id.into(), yaw as u8);
|
|
world.broadcast_packet_except(&[player.gameprofile.id], &je_packet);
|
|
}
|
|
|
|
pub async fn handle_chat_command(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
server: &Arc<Server>,
|
|
command: &SChatCommand<'_>,
|
|
) {
|
|
player.update_last_action_time();
|
|
let player_clone = player.clone();
|
|
let server_clone = server.clone();
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerCommandSendEvent {
|
|
player: player.clone(),
|
|
command: command.command.to_string(),
|
|
cancelled: false
|
|
};
|
|
|
|
'after: {
|
|
let command = event.command;
|
|
let command_clone = command.clone();
|
|
// Some commands can take a long time to execute. If they do, they block packet processing for the player.
|
|
// That's why we will spawn a task instead.
|
|
server.spawn_task(async move {
|
|
let dispatcher = server_clone.command_dispatcher.read().await;
|
|
dispatcher.handle_command(
|
|
&player_clone.get_command_source(&server_clone).await,
|
|
&command_clone
|
|
).await;
|
|
});
|
|
|
|
if server.advanced_config.commands.log_console {
|
|
info!(
|
|
"Player ({}): executed command /{}",
|
|
player.gameprofile.name,
|
|
command
|
|
);
|
|
}
|
|
}
|
|
}}
|
|
}
|
|
|
|
pub fn handle_player_ground(&self, player: &Player, ground: &SSetPlayerGround) {
|
|
player
|
|
.living_entity
|
|
.entity
|
|
.on_ground
|
|
.store(ground.on_ground, Ordering::Relaxed);
|
|
}
|
|
|
|
pub async fn handle_pick_item_from_block(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
pick_item: SPickItemFromBlock,
|
|
) {
|
|
if !player.can_interact_with_block_at(&pick_item.pos, 1.0) {
|
|
return;
|
|
}
|
|
|
|
let world = player.world();
|
|
let block = world.get_block(&pick_item.pos);
|
|
|
|
if block.item_id == 0 {
|
|
// Invalid block id (blocks such as tall seagrass)
|
|
return;
|
|
}
|
|
|
|
let Some(item) = Item::from_id(block.item_id) else {
|
|
return;
|
|
};
|
|
let stack = ItemStack::new(1, item);
|
|
|
|
let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await;
|
|
|
|
if slot_with_stack != -1 {
|
|
if PlayerInventory::is_valid_hotbar_index(slot_with_stack as usize) {
|
|
player.inventory.set_selected_slot(slot_with_stack as u8);
|
|
} else {
|
|
player
|
|
.inventory
|
|
.swap_slot_with_hotbar(slot_with_stack as usize)
|
|
.await;
|
|
}
|
|
} else if player.gamemode.load() == GameMode::Creative {
|
|
player.inventory.swap_stack_with_hotbar(stack).await;
|
|
}
|
|
|
|
player
|
|
.client
|
|
.enqueue_packet(&CSetSelectedSlot::new(
|
|
player.inventory.get_selected_slot() as i8
|
|
))
|
|
.await;
|
|
player
|
|
.player_screen_handler
|
|
.lock()
|
|
.await
|
|
.send_content_updates()
|
|
.await;
|
|
}
|
|
|
|
pub async fn handle_pick_item_from_entity(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
pick_item: SPickItemFromEntity,
|
|
) {
|
|
use pumpkin_data::entity::{entity_from_egg, spawn_egg_ids};
|
|
|
|
let world = player.world();
|
|
let Some(target) = world.get_entity_by_id(pick_item.id.0) else {
|
|
return;
|
|
};
|
|
|
|
let p_eye = player.get_entity().get_eye_pos();
|
|
let t_eye = target.get_eye_pos();
|
|
let dx = p_eye.x - t_eye.x;
|
|
let dy = p_eye.y - t_eye.y;
|
|
let dz = p_eye.z - t_eye.z;
|
|
if dx * dx + dy * dy + dz * dz > 64.0 {
|
|
return;
|
|
}
|
|
|
|
let target_type_id = target.get_entity().entity_type.id;
|
|
let mut found_egg: Option<u16> = None;
|
|
for &egg_id in &spawn_egg_ids() {
|
|
if let Some(et) = entity_from_egg(egg_id)
|
|
&& et.id == target_type_id
|
|
{
|
|
found_egg = Some(egg_id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if let Some(item) = found_egg.and_then(Item::from_id) {
|
|
let stack = ItemStack::new(1, item);
|
|
|
|
let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await;
|
|
|
|
if slot_with_stack != -1 {
|
|
if PlayerInventory::is_valid_hotbar_index(slot_with_stack as usize) {
|
|
player.inventory.set_selected_slot(slot_with_stack as u8);
|
|
} else {
|
|
player
|
|
.inventory
|
|
.swap_slot_with_hotbar(slot_with_stack as usize)
|
|
.await;
|
|
}
|
|
} else if player.gamemode.load() == GameMode::Creative {
|
|
player.inventory.swap_stack_with_hotbar(stack).await;
|
|
}
|
|
|
|
player
|
|
.client
|
|
.enqueue_packet(&CSetSelectedSlot::new(
|
|
player.inventory.get_selected_slot() as i8
|
|
))
|
|
.await;
|
|
player
|
|
.player_screen_handler
|
|
.lock()
|
|
.await
|
|
.send_content_updates()
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn handle_set_command_block(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
command: SSetCommandBlock<'_>,
|
|
) {
|
|
if !player.is_creative() {
|
|
return;
|
|
}
|
|
if player.permission_lvl.load() < PermissionLvl::Two {
|
|
return;
|
|
}
|
|
let pos = command.pos;
|
|
if let Some(block_entity) = player.world().get_block_entity(&pos) {
|
|
if block_entity.resource_location() != CommandBlockEntity::ID {
|
|
warn!("Client tried to change Command block but not Command block entity found");
|
|
return;
|
|
}
|
|
|
|
let Ok(command_block_mode) = CommandBlockMode::try_from(command.mode) else {
|
|
self.kick(TextComponent::text("Invalid Command block mode"))
|
|
.await;
|
|
return;
|
|
};
|
|
|
|
let block = player.world().get_block(&pos);
|
|
let old_state_id = player.world().get_block_state_id(&pos);
|
|
let mut props = CommandBlockLikeProperties::from_state_id(old_state_id, block);
|
|
|
|
let block_type = match command_block_mode {
|
|
CommandBlockMode::Chain => Block::CHAIN_COMMAND_BLOCK,
|
|
CommandBlockMode::Repeating => Block::REPEATING_COMMAND_BLOCK,
|
|
CommandBlockMode::Impulse => Block::COMMAND_BLOCK,
|
|
};
|
|
|
|
let Some(old_command_block) =
|
|
block_entity.as_any().downcast_ref::<CommandBlockEntity>()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
props.conditional = command.flags & 0x2 != 0;
|
|
|
|
let new_state_id = props.to_state_id(&block_type);
|
|
player
|
|
.world()
|
|
.set_block_state(
|
|
&command.pos,
|
|
new_state_id,
|
|
BlockFlags::SKIP_BLOCK_ADDED_CALLBACK,
|
|
)
|
|
.await;
|
|
|
|
let mut cmd = command.command;
|
|
if cmd.starts_with('/') {
|
|
cmd = &cmd[1..];
|
|
}
|
|
|
|
let command_block = CommandBlockEntity {
|
|
position: pos,
|
|
powered: old_command_block.powered.load(Ordering::SeqCst).into(),
|
|
condition_met: old_command_block
|
|
.condition_met
|
|
.load(Ordering::SeqCst)
|
|
.into(),
|
|
auto: (command.flags & 0x4 != 0).into(),
|
|
dirty: old_command_block.dirty.load(Ordering::SeqCst).into(),
|
|
command: Mutex::new(cmd.to_string()),
|
|
last_output: old_command_block.last_output.lock().await.clone().into(),
|
|
track_output: (command.flags & 0x1 != 0).into(),
|
|
success_count: AtomicU32::new(0),
|
|
};
|
|
player.world().add_block_entity(Arc::new(command_block));
|
|
|
|
player
|
|
.send_system_message(&TextComponent::text(format!(
|
|
"Command set: {}",
|
|
command.command
|
|
)))
|
|
.await;
|
|
|
|
// The 0x4 flag means always active
|
|
if command.flags & 0x4 != 0 && block_type != Block::CHAIN_COMMAND_BLOCK {
|
|
player.world().schedule_block_tick(
|
|
&block_type,
|
|
pos,
|
|
1,
|
|
pumpkin_world::tick::TickPriority::Normal,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_set_jigsaw_block(&self, player: &Arc<Player>, jigsaw: SSetJigsawBlock<'_>) {
|
|
if !player.is_creative() {
|
|
return;
|
|
}
|
|
if player.permission_lvl.load() < PermissionLvl::Two {
|
|
return;
|
|
}
|
|
let pos = jigsaw.pos;
|
|
if let Some(block_entity) = player.world().get_block_entity(&pos) {
|
|
if block_entity.resource_location() != JigsawBlockEntity::ID {
|
|
warn!("Client tried to change Jigsaw block but not Jigsaw block entity found");
|
|
return;
|
|
}
|
|
|
|
let Some(jigsaw_block) = block_entity.as_any().downcast_ref::<JigsawBlockEntity>()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
*jigsaw_block.name.lock().await = jigsaw.name.to_string();
|
|
*jigsaw_block.target.lock().await = jigsaw.target.to_string();
|
|
*jigsaw_block.pool.lock().await = jigsaw.pool.to_string();
|
|
*jigsaw_block.final_state.lock().await = jigsaw.final_state.to_string();
|
|
*jigsaw_block.joint.lock().await = JigsawJointType::from_str(jigsaw.joint);
|
|
jigsaw_block
|
|
.selection_priority
|
|
.store(jigsaw.selection_priority.0, Ordering::SeqCst);
|
|
jigsaw_block
|
|
.placement_priority
|
|
.store(jigsaw.placement_priority.0, Ordering::SeqCst);
|
|
|
|
player.world().update_block_entity(&block_entity);
|
|
}
|
|
}
|
|
|
|
pub async fn handle_jigsaw_generate(&self, player: &Arc<Player>, generate: SJigsawGenerate) {
|
|
if !player.is_creative() {
|
|
return;
|
|
}
|
|
if player.permission_lvl.load() < PermissionLvl::Two {
|
|
return;
|
|
}
|
|
let pos = generate.pos;
|
|
if let Some(block_entity) = player.world().get_block_entity(&pos)
|
|
&& let Some(jigsaw_block) = block_entity.as_any().downcast_ref::<JigsawBlockEntity>()
|
|
{
|
|
jigsaw_block
|
|
.generate(&player.world(), generate.levels.0, generate.keep_jigsaws)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn handle_player_command(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
command: SPlayerCommand,
|
|
server: &Arc<Server>,
|
|
) {
|
|
if command.entity_id != player.entity_id().into() {
|
|
return;
|
|
}
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
|
|
let entity = &player.get_entity();
|
|
match command.action {
|
|
Action::StartSprinting => {
|
|
if !entity.is_sprinting() {
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerToggleSprintEvent::new(player.clone(), true);
|
|
'after: {
|
|
player.get_entity().set_sprinting(event.is_sprinting).await;
|
|
}
|
|
}}
|
|
}
|
|
}
|
|
Action::StopSprinting => {
|
|
if entity.is_sprinting() {
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerToggleSprintEvent::new(player.clone(), false);
|
|
'after: {
|
|
player.get_entity().set_sprinting(event.is_sprinting).await;
|
|
}
|
|
}}
|
|
}
|
|
}
|
|
Action::LeaveBed => player.wake_up().await,
|
|
|
|
Action::StartHorseJump | Action::StopHorseJump | Action::OpenVehicleInventory => {
|
|
debug!("todo");
|
|
}
|
|
Action::StartFlyingElytra => {
|
|
let fall_flying = entity.check_fall_flying();
|
|
if entity.is_fall_flying() != fall_flying {
|
|
entity.set_fall_flying(fall_flying).await;
|
|
}
|
|
}
|
|
// <= 1.21.5
|
|
Action::StartSneaking | Action::StopSneaking => {
|
|
self.handle_player_input(
|
|
player,
|
|
SPlayerInput {
|
|
input: SPlayerInput::SNEAK,
|
|
},
|
|
server,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_player_input(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
input: SPlayerInput,
|
|
server: &Arc<Server>,
|
|
) {
|
|
player.last_input.store(input.input, Ordering::Relaxed);
|
|
|
|
let sneak = input.input & SPlayerInput::SNEAK != 0;
|
|
if sneak
|
|
&& player.gamemode.load() == GameMode::Spectator
|
|
&& player.camera_target_id.load().is_some()
|
|
{
|
|
player.camera_target_id.store(None);
|
|
player
|
|
.client
|
|
.send_packet_now(&CSetCamera::new(player.entity_id().into()))
|
|
.await;
|
|
}
|
|
|
|
if player.get_entity().is_sneaking() != sneak {
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerToggleSneakEvent::new(player.clone(), sneak);
|
|
'after: {
|
|
player.get_entity().set_sneaking(event.is_sneaking).await;
|
|
if event.is_sneaking {
|
|
let vehicle = player.get_entity().vehicle.lock().await.clone();
|
|
if let Some(vehicle) = vehicle {
|
|
vehicle
|
|
.get_entity()
|
|
.remove_passenger(player.entity_id())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
} else if sneak {
|
|
let vehicle = player.get_entity().vehicle.lock().await.clone();
|
|
if let Some(vehicle) = vehicle {
|
|
vehicle
|
|
.get_entity()
|
|
.remove_passenger(player.entity_id())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_move_vehicle(&self, player: &Arc<Player>, packet: SMoveVehicle) {
|
|
let entity = player.get_entity();
|
|
let pos = Vector3::new(packet.x, packet.y, packet.z);
|
|
let vehicle = entity.vehicle.lock().await;
|
|
if let Some(vehicle) = vehicle.as_ref() {
|
|
let vehicle_entity = vehicle.get_entity();
|
|
vehicle_entity.set_pos(pos);
|
|
vehicle_entity.set_rotation(packet.yaw, packet.pitch);
|
|
}
|
|
drop(vehicle);
|
|
entity.set_pos(pos);
|
|
chunker::update_position(player).await;
|
|
}
|
|
|
|
pub async fn handle_paddle_boat(&self, player: &Arc<Player>, packet: SPaddleBoat) {
|
|
let vehicle = player.get_entity().vehicle.lock().await.clone();
|
|
if let Some(vehicle) = vehicle {
|
|
vehicle
|
|
.set_paddle_state(packet.left_paddle, packet.right_paddle)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::unused_async)]
|
|
pub async fn handle_recipe_book_change_settings(
|
|
&self,
|
|
_player: &Arc<Player>,
|
|
_packet: SRecipeBookChangeSettings,
|
|
) {
|
|
// Client is updating its recipe book filter/open state; no server action needed.
|
|
}
|
|
|
|
#[allow(clippy::unused_async)]
|
|
pub async fn handle_recipe_book_seen_recipe(
|
|
&self,
|
|
_player: &Arc<Player>,
|
|
_packet: SRecipeBookSeenRecipe,
|
|
) {
|
|
// Client acknowledged a recipe display; no server action needed.
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn handle_place_recipe(
|
|
&self,
|
|
server: &Arc<Server>,
|
|
player: &Arc<Player>,
|
|
packet: SPlaceRecipe,
|
|
) {
|
|
use super::recipe_helper::{
|
|
GenericIngredient, compute_biggest_craftable, take_n_ingredient,
|
|
};
|
|
use crate::server::recipe::DynamicRecipe;
|
|
use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_COOKING, RECIPES_CRAFTING};
|
|
use pumpkin_data::screen::WindowType;
|
|
use pumpkin_inventory::crafting::recipe_provider::RecipeProvider;
|
|
|
|
let target_id = packet.recipe_display_id.0 as usize;
|
|
let use_max = packet.use_max_items;
|
|
|
|
// Count crafting display IDs.
|
|
let crafting_display_count = RECIPES_CRAFTING
|
|
.iter()
|
|
.filter(|r| {
|
|
!matches!(
|
|
r,
|
|
CraftingRecipeTypes::CraftingSpecial
|
|
| CraftingRecipeTypes::CraftingDecoratedPot { .. }
|
|
)
|
|
})
|
|
.count();
|
|
let cooking_display_count = RECIPES_COOKING.len();
|
|
let dynamic_recipes = server.recipe_manager.get_dynamic_recipes().await;
|
|
|
|
let (grid_width, crafting_inv) = {
|
|
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
|
|
let handler = screen_handler_arc.lock().await;
|
|
let grid_width: usize = match handler.window_type() {
|
|
Some(WindowType::Crafting) => 3,
|
|
None => 2, // player inventory 2x2
|
|
_ => return,
|
|
};
|
|
(grid_width, handler.get_behaviour().slots[1].get_inventory())
|
|
};
|
|
|
|
let grid_size = grid_width * grid_width;
|
|
let mut ingredient_slots: Vec<Option<GenericIngredient<'_>>> = vec![None; grid_size];
|
|
|
|
if target_id < crafting_display_count {
|
|
// Crafting recipe
|
|
let mut counter = 0usize;
|
|
let recipe = RECIPES_CRAFTING.iter().find(|r| {
|
|
if matches!(
|
|
r,
|
|
CraftingRecipeTypes::CraftingSpecial
|
|
| CraftingRecipeTypes::CraftingDecoratedPot { .. }
|
|
) {
|
|
return false;
|
|
}
|
|
let found = counter == target_id;
|
|
counter += 1;
|
|
found
|
|
});
|
|
let Some(recipe) = recipe else { return };
|
|
|
|
match recipe {
|
|
CraftingRecipeTypes::CraftingShaped { pattern, key, .. } => {
|
|
for (row, row_str) in pattern.iter().enumerate() {
|
|
for (col, ch) in row_str.chars().enumerate() {
|
|
if ch != ' '
|
|
&& let Some(ing) =
|
|
key.iter().find_map(|(k, v)| (*k == ch).then_some(v))
|
|
&& row * grid_width + col < grid_size
|
|
{
|
|
ingredient_slots[row * grid_width + col] =
|
|
Some(GenericIngredient::Vanilla(ing));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
CraftingRecipeTypes::CraftingShapeless { ingredients, .. } => {
|
|
for (i, ing) in ingredients.iter().enumerate().take(grid_size) {
|
|
ingredient_slots[i] = Some(GenericIngredient::Vanilla(ing));
|
|
}
|
|
}
|
|
CraftingRecipeTypes::CraftingTransmute {
|
|
input, material, ..
|
|
} => {
|
|
if grid_size >= 2 {
|
|
ingredient_slots[0] = Some(GenericIngredient::Vanilla(input));
|
|
ingredient_slots[1] = Some(GenericIngredient::Vanilla(material));
|
|
}
|
|
}
|
|
_ => return,
|
|
}
|
|
} else if target_id < crafting_display_count + cooking_display_count {
|
|
// TODO: cooking recipes
|
|
return;
|
|
} else {
|
|
let dynamic_id = target_id - crafting_display_count - cooking_display_count;
|
|
let Some(DynamicRecipe::Crafting(crafting)) = dynamic_recipes.get(dynamic_id) else {
|
|
return;
|
|
};
|
|
|
|
match crafting {
|
|
pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shaped {
|
|
pattern,
|
|
key,
|
|
..
|
|
} => {
|
|
for (row, row_str) in pattern.iter().enumerate() {
|
|
for (col, ch) in row_str.chars().enumerate() {
|
|
if ch != ' '
|
|
&& let Some((_, ing)) = key.iter().find(|(k, _)| *k == ch)
|
|
&& row * grid_width + col < grid_size
|
|
{
|
|
ingredient_slots[row * grid_width + col] =
|
|
Some(GenericIngredient::Dynamic(ing));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shapeless {
|
|
ingredients,
|
|
..
|
|
} => {
|
|
for (i, ing) in ingredients.iter().enumerate().take(grid_size) {
|
|
ingredient_slots[i] = Some(GenericIngredient::Dynamic(ing));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if this exact recipe is already placed (determines stacking vs fresh fill).
|
|
let recipe_matches = {
|
|
let mut ok = true;
|
|
for (idx, ing) in ingredient_slots.iter().enumerate() {
|
|
let stack = crafting_inv.get_stack(idx).await;
|
|
match ing {
|
|
None => {
|
|
if !stack.is_empty() {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
Some(ingredient) => {
|
|
if stack.is_empty() || !ingredient.match_item(stack.item) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ok
|
|
};
|
|
|
|
// Read minimum count from occupied slots before clearing (needed for stacking).
|
|
let current_min = if recipe_matches && !use_max {
|
|
let mut min = u8::MAX;
|
|
for (idx, ing) in ingredient_slots.iter().enumerate() {
|
|
if ing.is_some() {
|
|
let stack = crafting_inv.get_stack(idx).await;
|
|
if !stack.is_empty() {
|
|
min = min.min(stack.item_count);
|
|
}
|
|
}
|
|
}
|
|
if min == u8::MAX { 0 } else { min }
|
|
} else {
|
|
0
|
|
};
|
|
|
|
// Always clear the grid first, returning items to inventory.
|
|
for i in 0..grid_size {
|
|
let stack = crafting_inv.remove_stack(i).await;
|
|
if !stack.is_empty() {
|
|
player.inventory.offer(stack, false, player.as_ref()).await;
|
|
}
|
|
}
|
|
|
|
// Determine how many of each ingredient to place per slot.
|
|
let active_ingredients: Vec<GenericIngredient<'_>> =
|
|
ingredient_slots.iter().flatten().copied().collect();
|
|
let amount_to_craft = if use_max {
|
|
compute_biggest_craftable(&active_ingredients, &player.inventory).await
|
|
} else if recipe_matches {
|
|
current_min.saturating_add(1)
|
|
} else {
|
|
1
|
|
};
|
|
|
|
if amount_to_craft == 0 {
|
|
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
|
|
screen_handler_arc.lock().await.send_content_updates().await;
|
|
return;
|
|
}
|
|
|
|
// Fill each grid slot with exactly `amount_to_craft` matching items.
|
|
for (idx, ing) in ingredient_slots.iter().enumerate() {
|
|
let Some(ingredient) = ing else { continue };
|
|
let taken = take_n_ingredient(&player.inventory, ingredient, amount_to_craft).await;
|
|
if !taken.is_empty() {
|
|
crafting_inv.set_stack(idx, taken).await;
|
|
}
|
|
}
|
|
|
|
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
|
|
screen_handler_arc.lock().await.send_content_updates().await;
|
|
}
|
|
|
|
pub async fn handle_swing_arm(
|
|
&self,
|
|
server: &Arc<Server>,
|
|
player: &Arc<Player>,
|
|
swing_arm: SSwingArm,
|
|
) {
|
|
player.update_last_action_time();
|
|
let Ok(hand) = Hand::try_from(swing_arm.hand.0) else {
|
|
self.kick(TextComponent::text("Invalid hand")).await;
|
|
return;
|
|
};
|
|
|
|
let mut anim_event = crate::plugin::api::events::player::player_animation::PlayerAnimationEvent::new(
|
|
player.clone(),
|
|
match hand {
|
|
Hand::Left => crate::plugin::api::events::player::player_animation::PlayerAnimationType::ArmSwingOff,
|
|
Hand::Right => crate::plugin::api::events::player::player_animation::PlayerAnimationType::ArmSwingMain,
|
|
},
|
|
);
|
|
server.plugin_manager.fire(server, &mut anim_event).await;
|
|
if anim_event.cancelled {
|
|
return;
|
|
}
|
|
|
|
let (yaw, pitch) = player.rotation();
|
|
let hit_result = player
|
|
.world()
|
|
.raycast(
|
|
player.eye_position(),
|
|
player
|
|
.eye_position()
|
|
.add(&(Vector3::rotation_vector(f64::from(pitch), f64::from(yaw)) * 4.5)),
|
|
async |pos, world| {
|
|
let block = world.get_block(pos);
|
|
block != &Block::AIR && block != &Block::WATER && block != &Block::LAVA
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let event = if let Some((hit_pos, _hit_dir)) = hit_result {
|
|
PlayerInteractEvent::new(
|
|
player,
|
|
InteractAction::LeftClickBlock,
|
|
player.world().get_block(&hit_pos),
|
|
Some(hit_pos),
|
|
)
|
|
} else {
|
|
PlayerInteractEvent::new(player, InteractAction::LeftClickAir, &Block::AIR, None)
|
|
};
|
|
|
|
send_cancellable! {{
|
|
&server;
|
|
event;
|
|
'after: {
|
|
player.swing_hand(hand, false).await;
|
|
}
|
|
}}
|
|
}
|
|
|
|
pub async fn handle_chat_message(
|
|
&self,
|
|
server: &Arc<Server>,
|
|
player: &Arc<Player>,
|
|
chat_message: SChatMessage<'_>,
|
|
) {
|
|
player.update_last_action_time();
|
|
let gameprofile = &player.gameprofile;
|
|
|
|
if let Err(err) = self
|
|
.validate_chat_message(server, player, &chat_message)
|
|
.await
|
|
{
|
|
log_at_level!(
|
|
err.severity(),
|
|
"{} (uuid {}) {}",
|
|
gameprofile.name,
|
|
gameprofile.id,
|
|
err
|
|
);
|
|
if err.is_kick()
|
|
&& let Some(reason) = err.client_kick_reason()
|
|
{
|
|
self.kick(TextComponent::text(reason)).await;
|
|
}
|
|
return;
|
|
}
|
|
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerChatEvent::new(player.clone(), chat_message.message.to_string(), vec![]);
|
|
|
|
'after: {
|
|
info!("<chat> {}: {}", gameprofile.name, event.message);
|
|
|
|
let config = &server.advanced_config;
|
|
|
|
let message = match seasonal_events::modify_chat_message(&event.message, config) {
|
|
Some(m) => m,
|
|
None => event.message.clone(),
|
|
};
|
|
|
|
let decorated_message = TextComponent::chat_decorated(
|
|
&config.chat.format,
|
|
&gameprofile.name,
|
|
&message,
|
|
);
|
|
|
|
let entity = &player.get_entity();
|
|
let world = entity.world.load_full();
|
|
if server.basic_config.allow_chat_reports {
|
|
world.broadcast_secure_player_chat(player, &chat_message, &decorated_message).await;
|
|
} else {
|
|
let je_packet = CSystemChatMessage::new(
|
|
&decorated_message,
|
|
false,
|
|
);
|
|
let be_packet = SText::new(
|
|
message, player.gameprofile.name.clone()
|
|
);
|
|
|
|
world.broadcast_editioned(&je_packet, &be_packet).await;
|
|
}
|
|
}
|
|
}}
|
|
}
|
|
|
|
/// Runs all vanilla checks for a valid chat message
|
|
pub async fn validate_chat_message(
|
|
&self,
|
|
server: &Server,
|
|
player: &Arc<Player>,
|
|
chat_message: &SChatMessage<'_>,
|
|
) -> Result<(), ChatError> {
|
|
// Check for oversized messages
|
|
// If we're able to find the 257th UTF-16 character, the message is too big.
|
|
if chat_message.message.encode_utf16().nth(256).is_some() {
|
|
return Err(ChatError::OversizedMessage);
|
|
}
|
|
// Check for illegal characters
|
|
if chat_message
|
|
.message
|
|
.chars()
|
|
.any(|c| c == '§' || c < ' ' || c == '\x7F')
|
|
{
|
|
return Err(ChatError::IllegalCharacters);
|
|
}
|
|
// These checks are only run in secure chat mode
|
|
if server.basic_config.allow_chat_reports {
|
|
// Check for unsigned chat
|
|
if let Some(signature) = &chat_message.signature {
|
|
if signature.len() != 256 {
|
|
return Err(ChatError::UnsignedChat); // Signature is the wrong length
|
|
}
|
|
} else {
|
|
return Err(ChatError::UnsignedChat); // There is no signature
|
|
}
|
|
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as i64;
|
|
|
|
// Verify message timestamp
|
|
if chat_message.timestamp > now || chat_message.timestamp < (now - CHAT_MESSAGE_MAX_AGE)
|
|
{
|
|
return Err(ChatError::OutOfOrderChat);
|
|
}
|
|
|
|
// Verify session expiry
|
|
if player.chat_session.lock().await.expires_at < now {
|
|
return Err(ChatError::ExpiredPublicKey);
|
|
}
|
|
|
|
// Validate previous signature checksum (new in 1.21.5)
|
|
// The client can bypass this check by sending 0
|
|
if chat_message.checksum != 0 {
|
|
let checksum =
|
|
polynomial_rolling_hash(player.signature_cache.lock().await.last_seen.as_ref());
|
|
if checksum != chat_message.checksum {
|
|
return Err(ChatError::ChatValidationFailed);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn handle_chat_session_update(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
server: &Server,
|
|
session: SPlayerSession,
|
|
) {
|
|
// Keep the chat session default if we don't want reports
|
|
if !server.basic_config.allow_chat_reports {
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = self.validate_chat_session(player, server, &session) {
|
|
log_at_level!(
|
|
err.severity(),
|
|
"{} (uuid {}) {}",
|
|
player.gameprofile.name,
|
|
player.gameprofile.id,
|
|
err
|
|
);
|
|
if err.is_kick()
|
|
&& let Some(reason) = err.client_kick_reason()
|
|
{
|
|
self.kick(TextComponent::text(reason)).await;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update the chat session fields
|
|
*player.chat_session.lock().await = ChatSession::new(
|
|
session.session_id,
|
|
session.expires_at,
|
|
session.public_key.clone(),
|
|
session.key_signature.clone(),
|
|
);
|
|
|
|
server.broadcast_packet_all(&CPlayerInfoUpdate::new(
|
|
0x02,
|
|
&[pumpkin_protocol::java::client::play::Player {
|
|
uuid: player.gameprofile.id,
|
|
actions: &[PlayerAction::InitializeChat(Some(InitChat {
|
|
session_id: session.session_id,
|
|
expires_at: session.expires_at,
|
|
public_key: session.public_key.clone(),
|
|
signature: session.key_signature.clone(),
|
|
}))],
|
|
}],
|
|
));
|
|
}
|
|
|
|
/// Runs vanilla checks for a valid player session
|
|
pub fn validate_chat_session(
|
|
&self,
|
|
player: &Player,
|
|
server: &Server,
|
|
session: &SPlayerSession,
|
|
) -> Result<(), ChatError> {
|
|
// Verify session expiry
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as i64;
|
|
if session.expires_at < now {
|
|
return Err(ChatError::InvalidPublicKey);
|
|
}
|
|
|
|
let key_signature = RsaPkcs1v15Signature::try_from(session.key_signature.as_ref())
|
|
.map_err(|_| ChatError::InvalidPublicKey)?;
|
|
|
|
let mut signable = Vec::new();
|
|
signable.extend_from_slice(player.gameprofile.id.as_bytes());
|
|
signable.extend_from_slice(&session.expires_at.to_be_bytes());
|
|
signable.extend_from_slice(&session.public_key);
|
|
|
|
let public_keys_guard = server.mojang_public_keys.load();
|
|
|
|
// Verify signature with RSA-SHA1
|
|
let is_valid = public_keys_guard.iter().any(|key| {
|
|
let verifying_key = VerifyingKey::<Sha1>::new(key.clone());
|
|
verifying_key.verify(&signable, &key_signature).is_ok()
|
|
});
|
|
|
|
// Verify that the signable is valid for any one of Mojang's public keys
|
|
if !is_valid {
|
|
return Err(ChatError::InvalidPublicKey);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn handle_client_information(
|
|
&self,
|
|
server: &Arc<Server>,
|
|
player: &Arc<Player>,
|
|
client_information: SClientInformationPlay<'_>,
|
|
) {
|
|
if let (Ok(main_hand), Ok(chat_mode)) = (
|
|
Hand::try_from(client_information.main_hand.0),
|
|
ChatMode::try_from(client_information.chat_mode.0),
|
|
) {
|
|
if client_information.view_distance <= 0 {
|
|
self.kick(TextComponent::text(
|
|
"Cannot have zero or negative view distance!",
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let (update_settings, update_watched, main_hand_changed) = {
|
|
// 1. Load current snapshot
|
|
let current_config = player.config.load();
|
|
|
|
// 2. Calculate if settings changed before we overwrite
|
|
let main_hand_changed = current_config.main_hand != main_hand;
|
|
let update_settings =
|
|
main_hand_changed || current_config.skin_parts != client_information.skin_parts;
|
|
|
|
let old_view_distance = current_config.view_distance;
|
|
let new_view_distance_raw = client_information.view_distance as u8;
|
|
|
|
let update_watched = if old_view_distance.get() == new_view_distance_raw {
|
|
false
|
|
} else {
|
|
debug!(
|
|
"Player {} ({}) updated their render distance: {} -> {}.",
|
|
player.gameprofile.name, self.id, old_view_distance, new_view_distance_raw
|
|
);
|
|
true
|
|
};
|
|
|
|
// 3. Construct the new config
|
|
// If view_distance is 0, we exit early (safe guard)
|
|
let Some(new_view_distance) = NonZeroU8::new(new_view_distance_raw) else {
|
|
return;
|
|
};
|
|
|
|
let new_config = PlayerConfig {
|
|
locale: client_information.locale.to_string(),
|
|
view_distance: new_view_distance,
|
|
chat_mode,
|
|
chat_colors: client_information.chat_colors,
|
|
skin_parts: client_information.skin_parts,
|
|
main_hand,
|
|
text_filtering: client_information.text_filtering,
|
|
server_listing: client_information.server_listing,
|
|
};
|
|
|
|
// 4. Atomically swap the new config into the player
|
|
player.config.store(std::sync::Arc::new(new_config));
|
|
|
|
(update_settings, update_watched, main_hand_changed)
|
|
};
|
|
|
|
if update_watched {
|
|
chunker::update_position(player).await;
|
|
}
|
|
|
|
if main_hand_changed {
|
|
let mut event = PlayerChangedMainHandEvent::new(player.clone(), main_hand);
|
|
server.plugin_manager.fire(server, &mut event).await;
|
|
}
|
|
|
|
if update_settings {
|
|
debug!(
|
|
"Player {} ({}) updated their skin.",
|
|
player.gameprofile.name, self.id,
|
|
);
|
|
player.send_client_information();
|
|
}
|
|
} else {
|
|
self.kick(TextComponent::text("Invalid hand or chat type"))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn handle_client_status(&self, player: &Arc<Player>, client_status: SClientCommand) {
|
|
player.update_last_action_time();
|
|
match client_status.action_id.0 {
|
|
0 => {
|
|
// Perform respawn
|
|
if player.living_entity.health.load() > 0.0 {
|
|
return;
|
|
}
|
|
player.world().clone().respawn_player(player, false).await;
|
|
|
|
{
|
|
let screen_handler = player.current_screen_handler.lock().await;
|
|
let mut screen_handler = screen_handler.lock().await;
|
|
screen_handler.sync_state().await;
|
|
};
|
|
|
|
// Restore abilities based on gamemode after respawn
|
|
{
|
|
let mut abilities = player.abilities.lock().await;
|
|
abilities.set_for_gamemode(player.gamemode.load());
|
|
};
|
|
player.send_abilities_update().await;
|
|
}
|
|
1 => {
|
|
// Request stats
|
|
player.send_stats().await;
|
|
}
|
|
_ => {
|
|
self.kick(TextComponent::text("Invalid client status"))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_attack(&self, player: &Arc<Player>, attack: SAttack, server: &Arc<Server>) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
let entity_id = attack.entity_id;
|
|
let player_entity = &player.get_entity();
|
|
let world = player_entity.world.load_full();
|
|
|
|
let config = &server.advanced_config.pvp;
|
|
if !config.enabled {
|
|
return;
|
|
}
|
|
|
|
if entity_id.0 == player.entity_id() {
|
|
self.kick(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED,
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED,
|
|
[],
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let player_target = world.get_player_by_id(entity_id.0);
|
|
let target: Option<Arc<dyn EntityBase>> = player_target
|
|
.as_ref()
|
|
.map(|p| Arc::clone(p) as Arc<dyn EntityBase>)
|
|
.or_else(|| world.get_entity_by_id(entity_id.0));
|
|
let Some(target) = target else {
|
|
self.kick(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED,
|
|
translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED,
|
|
[],
|
|
))
|
|
.await;
|
|
return;
|
|
};
|
|
if let Some(player_victim) = &player_target {
|
|
if player_victim.living_entity.health.load() <= 0.0 {
|
|
return;
|
|
}
|
|
if config.protect_creative && player_victim.gamemode.load() == GameMode::Creative {
|
|
world.play_sound(
|
|
Sound::EntityPlayerAttackNodamage,
|
|
SoundCategory::Players,
|
|
&player_victim.position(),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
player.attack(target).await;
|
|
}
|
|
|
|
#[expect(clippy::too_many_lines)]
|
|
pub async fn handle_interact(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
interact: SInteract,
|
|
server: &Arc<Server>,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
let entity_id = interact.entity_id;
|
|
|
|
let sneaking = interact.sneaking;
|
|
let player_entity = &player.get_entity();
|
|
if player_entity.is_sneaking() != sneaking {
|
|
player_entity.set_sneaking(sneaking).await;
|
|
}
|
|
let Ok(action) = ActionType::try_from(interact.r#type.0) else {
|
|
self.kick(TextComponent::text("Invalid action type")).await;
|
|
return;
|
|
};
|
|
|
|
// Resolve the target entity for the event
|
|
let world = player_entity.world.load_full();
|
|
let player_target = world.get_player_by_id(entity_id.0);
|
|
let target: Option<Arc<dyn EntityBase>> = player_target
|
|
.as_ref()
|
|
.map(|p| Arc::clone(p) as Arc<dyn EntityBase>)
|
|
.or_else(|| world.get_entity_by_id(entity_id.0));
|
|
|
|
if let Some(target) = target {
|
|
if player.gamemode.load() == GameMode::Spectator {
|
|
player.camera_target_id.store(Some(entity_id.0));
|
|
player
|
|
.client
|
|
.send_packet_now(&CSetCamera::new(entity_id))
|
|
.await;
|
|
return;
|
|
}
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerInteractEntityEvent::new(
|
|
player,
|
|
Arc::clone(&target),
|
|
action.clone(),
|
|
interact.target_position,
|
|
sneaking,
|
|
);
|
|
|
|
'after: {
|
|
match event.action {
|
|
ActionType::Attack => {
|
|
let config = &server.advanced_config.pvp;
|
|
if !config.enabled {
|
|
return;
|
|
}
|
|
|
|
if entity_id.0 == player.entity_id() {
|
|
self.kick(TextComponent::translate_cross(translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, [],))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
if let Some(player_victim) = &player_target {
|
|
if player_victim.living_entity.health.load() <= 0.0 {
|
|
return;
|
|
}
|
|
if config.protect_creative
|
|
&& player_victim.gamemode.load() == GameMode::Creative
|
|
{
|
|
world
|
|
.play_sound(
|
|
Sound::EntityPlayerAttackNodamage,
|
|
SoundCategory::Players,
|
|
&player_victim.position(),
|
|
)
|
|
;
|
|
return;
|
|
}
|
|
}
|
|
player.attack(event.target).await;
|
|
}
|
|
ActionType::Interact | ActionType::InteractAt => {
|
|
let mut stack = player.inventory().held_item().await;
|
|
let target_entity = event.target.get_entity();
|
|
if target_entity.entity_type.resource_name == "zombie_villager"
|
|
&& stack.item.registry_key == "golden_apple"
|
|
{
|
|
player.trigger_advancement(crate::entity::player::advancement::trigger::AdvancementTrigger::CuredZombieVillager).await;
|
|
}
|
|
|
|
let interacted = event.target.interact(player, &mut stack).await;
|
|
if !interacted {
|
|
server
|
|
.item_registry
|
|
.use_on_entity(&mut stack, player, event.target)
|
|
.await;
|
|
}
|
|
player.inventory().set_held_item(stack).await;
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
} else {
|
|
// Entity not found
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerInteractUnknownEntityEvent::new(player, entity_id.0, action);
|
|
|
|
'after: {
|
|
if event.action == ActionType::Attack {
|
|
error!(
|
|
"Player id {} interacted with entity id {}, which was not found.",
|
|
player.entity_id(),
|
|
event.entity_id
|
|
);
|
|
self.kick(TextComponent::translate_cross(translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, [],))
|
|
.await;
|
|
}
|
|
}
|
|
}}
|
|
}
|
|
}
|
|
|
|
#[expect(clippy::too_many_lines)]
|
|
pub async fn handle_player_action(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
player_action: SPlayerAction,
|
|
server: &Server,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
match Status::try_from(player_action.status.0) {
|
|
Ok(status) => match status {
|
|
Status::StartedDigging => {
|
|
if !player.can_interact_with_block_at(&player_action.position, 1.0) {
|
|
warn!(
|
|
"Player {0} tried to interact with block out of reach at {1}",
|
|
player.gameprofile.name, player_action.position
|
|
);
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
return;
|
|
}
|
|
let position = player_action.position;
|
|
let entity = &player.get_entity();
|
|
let world = entity.world.load_full();
|
|
let (block, state) = world.get_block_and_state(&position);
|
|
|
|
if let Some(server_arc) = world.server.upgrade() {
|
|
let mut event =
|
|
crate::plugin::api::events::block::block_damage::BlockDamageEvent::new(
|
|
player.clone(),
|
|
block,
|
|
position,
|
|
false,
|
|
);
|
|
server_arc
|
|
.plugin_manager
|
|
.fire(&server_arc, &mut event)
|
|
.await;
|
|
if event.cancelled {
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if block == &pumpkin_data::Block::NOTE_BLOCK {
|
|
let props =
|
|
pumpkin_data::block_properties::NoteBlockLikeProperties::from_state_id(
|
|
state.id, block,
|
|
);
|
|
crate::block::blocks::note::NoteBlock::play_note(&props, &world, &position)
|
|
.await;
|
|
player
|
|
.increment_stat(
|
|
StatisticCategory::Custom,
|
|
CustomStatistic::PlayNoteblock as i32,
|
|
1,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let inventory = player.inventory();
|
|
let held = inventory.held_item().await;
|
|
if !server.item_registry.can_mine(held.item, player) {
|
|
self.enqueue_packet(&CBlockUpdate::new(
|
|
position,
|
|
VarInt(i32::from(state.id.as_u16())),
|
|
))
|
|
.await;
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
return;
|
|
}
|
|
|
|
// TODO: do validation
|
|
// TODO: Config
|
|
if player.gamemode.load() == GameMode::Creative {
|
|
// Block break & play sound
|
|
let new_state = world
|
|
.break_block(
|
|
&position,
|
|
Some(player.clone()),
|
|
BlockFlags::NOTIFY_NEIGHBORS | BlockFlags::SKIP_DROPS,
|
|
)
|
|
.await;
|
|
if new_state.is_some() {
|
|
server
|
|
.block_registry
|
|
.broken(&world, block, player, &position, server, state)
|
|
.await;
|
|
}
|
|
self.sync_block_state_to_client(&world, position).await;
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
return;
|
|
}
|
|
player.start_mining_time.store(
|
|
player.tick_counter.load(Ordering::Relaxed),
|
|
Ordering::Relaxed,
|
|
);
|
|
if !state.is_air() {
|
|
let speed = block::calc_block_breaking(player, state, block).await;
|
|
// Instant break
|
|
if speed >= 1.0 {
|
|
let broken_state = world.get_block_state(&position);
|
|
let can_harvest = player.can_harvest(broken_state, block).await;
|
|
let new_state = world
|
|
.break_block(
|
|
&position,
|
|
Some(player.clone()),
|
|
BlockFlags::NOTIFY_NEIGHBORS,
|
|
)
|
|
.await;
|
|
if new_state.is_some() {
|
|
server
|
|
.block_registry
|
|
.broken(&world, block, player, &position, server, broken_state)
|
|
.await;
|
|
player.apply_tool_damage_for_block_break(broken_state).await;
|
|
if can_harvest {
|
|
player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await;
|
|
}
|
|
let item_id = player.inventory().held_item().await.item.id;
|
|
player
|
|
.increment_stat(StatisticCategory::Used, item_id as i32, 1)
|
|
.await;
|
|
player
|
|
.increment_stat(
|
|
StatisticCategory::Mined,
|
|
broken_state.id.as_u16() as i32,
|
|
1,
|
|
)
|
|
.await;
|
|
}
|
|
self.sync_block_state_to_client(&world, position).await;
|
|
} else {
|
|
player.mining.store(true, Ordering::Relaxed);
|
|
*player.mining_pos.lock().await = position;
|
|
let progress = (speed * 10.0) as i32;
|
|
player
|
|
.current_block_breaking_speed
|
|
.store(speed.to_bits(), Ordering::Relaxed);
|
|
world
|
|
.set_block_breaking(
|
|
entity,
|
|
position,
|
|
BlockBreakingProgress::Start {
|
|
stage: progress,
|
|
speed,
|
|
},
|
|
)
|
|
.await;
|
|
player
|
|
.current_block_destroy_stage
|
|
.store(progress, Ordering::Relaxed);
|
|
}
|
|
}
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
}
|
|
Status::CancelledDigging => {
|
|
if !player.can_interact_with_block_at(&player_action.position, 1.0) {
|
|
warn!(
|
|
"Player {0} tried to interact with block out of reach at {1}",
|
|
player.gameprofile.name, player_action.position
|
|
);
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
return;
|
|
}
|
|
player.mining.store(false, Ordering::Relaxed);
|
|
let entity = &player.get_entity();
|
|
entity
|
|
.world
|
|
.load()
|
|
.set_block_breaking(
|
|
entity,
|
|
player_action.position,
|
|
BlockBreakingProgress::Stop,
|
|
)
|
|
.await;
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
}
|
|
Status::FinishedDigging => {
|
|
// TODO: do validation
|
|
let location = player_action.position;
|
|
if !player.can_interact_with_block_at(&location, 1.0) {
|
|
warn!(
|
|
"Player {0} tried to interact with block out of reach at {1}",
|
|
player.gameprofile.name, player_action.position
|
|
);
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
return;
|
|
}
|
|
|
|
// Block break & play sound
|
|
let entity = &player.get_entity();
|
|
let world = entity.world.load_full();
|
|
|
|
player.mining.store(false, Ordering::Relaxed);
|
|
world
|
|
.set_block_breaking(entity, location, BlockBreakingProgress::Stop)
|
|
.await;
|
|
|
|
let (block, state) = world.get_block_and_state(&location);
|
|
let block_drop = player.gamemode.load() != GameMode::Creative
|
|
&& player.can_harvest(state, block).await;
|
|
|
|
let new_state = world
|
|
.break_block(
|
|
&location,
|
|
Some(player.clone()),
|
|
if block_drop {
|
|
BlockFlags::NOTIFY_NEIGHBORS
|
|
} else {
|
|
BlockFlags::SKIP_DROPS | BlockFlags::NOTIFY_NEIGHBORS
|
|
},
|
|
)
|
|
.await;
|
|
if new_state.is_some() {
|
|
server
|
|
.block_registry
|
|
.broken(&world, block, player, &location, server, state)
|
|
.await;
|
|
|
|
player.apply_tool_damage_for_block_break(state).await;
|
|
if block_drop {
|
|
player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await;
|
|
}
|
|
let item_id = player.inventory().held_item().await.item.id;
|
|
player
|
|
.increment_stat(StatisticCategory::Used, item_id as i32, 1)
|
|
.await;
|
|
player
|
|
.increment_stat(StatisticCategory::Mined, state.id.as_u16() as i32, 1)
|
|
.await;
|
|
}
|
|
|
|
self.sync_block_state_to_client(&world, location).await;
|
|
|
|
self.update_sequence(player, player_action.sequence.0);
|
|
}
|
|
Status::DropItem => {
|
|
player.drop_held_item(false).await;
|
|
}
|
|
Status::DropItemStack => {
|
|
player.drop_held_item(true).await;
|
|
}
|
|
Status::ReleaseItemInUse => {
|
|
let item_in_use = player.living_entity.item_in_use.lock().await.clone();
|
|
if let Some(stack) = item_in_use {
|
|
server.item_registry.on_stopped_using(&stack, player).await;
|
|
}
|
|
|
|
player.living_entity.clear_active_hand().await;
|
|
}
|
|
Status::SwapItem => {
|
|
player.swap_item().await;
|
|
}
|
|
Status::SpearJab => {
|
|
debug!("todo");
|
|
}
|
|
},
|
|
Err(_) => self.kick(TextComponent::text("Invalid status")).await,
|
|
}
|
|
}
|
|
|
|
pub async fn handle_keep_alive(&self, player: &Player, keep_alive: SKeepAlive) {
|
|
if self.wait_for_keep_alive.load(Ordering::Relaxed)
|
|
&& keep_alive.keep_alive_id == self.keep_alive_id.load()
|
|
{
|
|
let ping = self.last_keep_alive_time.load().elapsed();
|
|
// Vanilla logic
|
|
player.ping.store(
|
|
(player.ping.load(Ordering::Relaxed) * 3 + ping.as_millis() as u32) / 4,
|
|
Ordering::Relaxed,
|
|
);
|
|
self.wait_for_keep_alive.store(false, Ordering::Relaxed);
|
|
} else {
|
|
self.kick(pumpkin_macros::translate_cross!(
|
|
translation::java::DISCONNECT_TIMEOUT,
|
|
translation::bedrock::DISCONNECT_TIMEOUT
|
|
))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub fn update_sequence(&self, _player: &Player, sequence: i32) {
|
|
if sequence < 0 {
|
|
error!("Expected packet sequence >= 0");
|
|
}
|
|
self.packet_sequence.store(
|
|
self.packet_sequence.load(Ordering::Relaxed).max(sequence),
|
|
Ordering::Relaxed,
|
|
);
|
|
}
|
|
|
|
async fn sync_block_state_to_client(&self, world: &World, position: BlockPos) {
|
|
let synced_state_id = world.get_block_state_id(&position);
|
|
self.send_packet_now(&CBlockUpdate::new(
|
|
position,
|
|
VarInt(i32::from(synced_state_id.as_u16())),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
pub async fn handle_player_abilities(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
player_abilities: SPlayerAbilities,
|
|
server: &Arc<Server>,
|
|
) {
|
|
let (flying, allow_flying) = {
|
|
let abilities = player.abilities.lock().await;
|
|
(abilities.flying, abilities.allow_flying)
|
|
};
|
|
|
|
// Set the flying ability
|
|
let new_flying = player_abilities.flags & 0x02 != 0 && allow_flying;
|
|
if flying != new_flying {
|
|
send_cancellable! {{
|
|
server;
|
|
PlayerToggleFlightEvent::new(player.clone(), new_flying);
|
|
'after: {
|
|
if event.is_flying {
|
|
player.living_entity.fall_distance.store(0.0);
|
|
}
|
|
player.abilities.lock().await.flying = event.is_flying;
|
|
}
|
|
'cancelled: {
|
|
player.send_abilities_update().await;
|
|
}
|
|
}}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_play_ping_request(&self, request: SPlayPingRequest) {
|
|
self.enqueue_packet(&CPingResponse::new(request.payload))
|
|
.await;
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn handle_use_item_on(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
use_item_on: SUseItemOn,
|
|
server: &Arc<Server>,
|
|
) -> Result<(), BlockPlacingError> {
|
|
if !player.has_client_loaded() {
|
|
return Ok(());
|
|
}
|
|
player.update_last_action_time();
|
|
self.update_sequence(player, use_item_on.sequence.0);
|
|
|
|
let position = use_item_on.position;
|
|
let cursor_pos = use_item_on.cursor_pos;
|
|
|
|
let mut should_try_decrement = false;
|
|
|
|
if !player.can_interact_with_block_at(&position, 1.0) {
|
|
// TODO: maybe log?
|
|
return Err(BlockPlacingError::BlockOutOfReach);
|
|
}
|
|
|
|
let Ok(face) = BlockDirection::try_from(use_item_on.face.0) else {
|
|
return Err(BlockPlacingError::InvalidBlockFace);
|
|
};
|
|
|
|
let Ok(hand) = Hand::try_from(use_item_on.hand.0) else {
|
|
return Err(BlockPlacingError::InvalidHand);
|
|
};
|
|
|
|
if player.gamemode.load() == GameMode::Spectator {
|
|
// TODO: openMenu
|
|
return Ok(());
|
|
}
|
|
|
|
let inventory = player.inventory();
|
|
let held_item = inventory.held_item().await;
|
|
let off_hand_item = inventory.off_hand_item().await;
|
|
let held_item_empty = held_item.is_empty();
|
|
let off_hand_item_empty = off_hand_item.is_empty();
|
|
|
|
let item_id = if matches!(hand, Hand::Left) {
|
|
held_item.item.id
|
|
} else {
|
|
off_hand_item.item.id
|
|
};
|
|
player
|
|
.increment_stat(StatisticCategory::Used, item_id as i32, 1)
|
|
.await;
|
|
|
|
let entity = &player.get_entity();
|
|
let world = entity.world.load_full();
|
|
let block = world.get_block(&position);
|
|
|
|
let event = PlayerInteractEvent::new(
|
|
player,
|
|
InteractAction::RightClickBlock,
|
|
block,
|
|
Some(position),
|
|
);
|
|
|
|
send_cancellable! {{
|
|
server;
|
|
event;
|
|
'cancelled: {
|
|
let state_id = world.get_block_state_id(&position);
|
|
self.enqueue_packet(&CBlockUpdate::new(
|
|
position,
|
|
VarInt(i32::from(state_id.as_u16())),
|
|
))
|
|
.await;
|
|
return Ok(());
|
|
}
|
|
}}
|
|
|
|
let mut item = if matches!(hand, Hand::Left) {
|
|
held_item
|
|
} else {
|
|
off_hand_item
|
|
};
|
|
let equipment_slot = if matches!(hand, Hand::Left) {
|
|
EquipmentSlot::MAIN_HAND
|
|
} else {
|
|
EquipmentSlot::OFF_HAND
|
|
};
|
|
|
|
let sneaking = player.get_entity().is_sneaking();
|
|
|
|
// Code based on the java class ServerPlayerInteractionManager
|
|
if !(sneaking && (!held_item_empty || !off_hand_item_empty)) {
|
|
let result = self
|
|
.call_use_item_on(
|
|
player,
|
|
&position,
|
|
&cursor_pos,
|
|
&face,
|
|
&mut item,
|
|
&equipment_slot,
|
|
&world,
|
|
block,
|
|
server,
|
|
)
|
|
.await;
|
|
if result.consumes_action() {
|
|
// TODO: Trigger ANY_BLOCK_USE Criteria
|
|
|
|
if matches!(result, BlockActionResult::SuccessServer) {
|
|
player.swing_hand(hand, true).await;
|
|
}
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let slot_index = if matches!(hand, Hand::Left) {
|
|
inventory.get_selected_slot() as usize
|
|
} else {
|
|
PlayerInventory::OFF_HAND_SLOT
|
|
};
|
|
|
|
if item.is_empty() {
|
|
// TODO item cool down
|
|
// If the hand is empty we stop here
|
|
return Ok(());
|
|
}
|
|
|
|
let before = item.clone();
|
|
|
|
server
|
|
.item_registry
|
|
.use_on_block(&mut item, player, position, face, cursor_pos, block, server)
|
|
.await;
|
|
|
|
// Check if the item is a block, because not every item can be placed :D
|
|
let item_id = item.item.id;
|
|
if let Some(block) = Block::from_item_id(item_id) {
|
|
should_try_decrement = self
|
|
.run_is_block_place(player, block, server, use_item_on, position, face)
|
|
.await?;
|
|
}
|
|
|
|
if should_try_decrement {
|
|
// TODO: Config
|
|
// Decrease block count
|
|
if player.gamemode.load() != GameMode::Creative {
|
|
item.decrement(1);
|
|
}
|
|
}
|
|
|
|
let after = item.clone();
|
|
|
|
// Broadcast the break entity status before the slot sync; the client
|
|
// needs the old item texture in the slot for break particles.
|
|
if !before.is_empty() && after.is_empty() {
|
|
let slot = if slot_index == player.inventory.get_selected_slot() as usize {
|
|
&EquipmentSlot::MAIN_HAND
|
|
} else {
|
|
&EquipmentSlot::OFF_HAND
|
|
};
|
|
player.world().send_entity_status(
|
|
player.get_entity(),
|
|
equipment_break_status(slot),
|
|
None,
|
|
);
|
|
}
|
|
|
|
if !after.are_equal(&before) {
|
|
player.sync_hand_slot(slot_index, after.clone()).await;
|
|
inventory.set_stack_in_hand(hand, after).await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[expect(clippy::too_many_arguments)]
|
|
async fn call_use_item_on(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
position: &BlockPos,
|
|
cursor_pos: &Vector3<f32>,
|
|
face: &BlockDirection,
|
|
held_item: &mut ItemStack,
|
|
equipment_slot: &EquipmentSlot,
|
|
world: &Arc<World>,
|
|
block: &Block,
|
|
server: &Arc<Server>,
|
|
) -> BlockActionResult {
|
|
let result = server
|
|
.block_registry
|
|
.use_with_item(
|
|
block,
|
|
player,
|
|
position,
|
|
&BlockHitResult { face, cursor_pos },
|
|
held_item,
|
|
equipment_slot,
|
|
server,
|
|
world,
|
|
)
|
|
.await;
|
|
|
|
if result.consumes_action() {
|
|
// TODO: Trigger ITEM_USED_ON_BLOCK Criteria
|
|
return result;
|
|
}
|
|
|
|
if matches!(result, BlockActionResult::PassToDefaultBlockAction) {
|
|
let result = server
|
|
.block_registry
|
|
.on_use(
|
|
block,
|
|
player,
|
|
position,
|
|
&BlockHitResult { face, cursor_pos },
|
|
server,
|
|
world,
|
|
)
|
|
.await;
|
|
|
|
if result.consumes_action() {
|
|
// TODO: Trigger DEFAULT_BLOCK_USE Criteria
|
|
return result;
|
|
}
|
|
}
|
|
|
|
BlockActionResult::Pass
|
|
}
|
|
|
|
pub async fn handle_sign_update(&self, player: &Player, sign_data: SUpdateSign<'_>) {
|
|
let world = player.get_entity().world.load_full();
|
|
let Some(block_entity) = world.get_block_entity(&sign_data.location) else {
|
|
return;
|
|
};
|
|
let Some(sign_entity) = block_entity.as_any().downcast_ref::<SignBlockEntity>() else {
|
|
return;
|
|
};
|
|
if sign_entity.is_waxed.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
|
|
let text = if sign_data.is_front_text {
|
|
&sign_entity.front_text
|
|
} else {
|
|
&sign_entity.back_text
|
|
};
|
|
|
|
*text
|
|
.messages
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = [
|
|
sign_data.line_1.into(),
|
|
sign_data.line_2.into(),
|
|
sign_data.line_3.into(),
|
|
sign_data.line_4.into(),
|
|
];
|
|
*sign_entity.currently_editing_player.lock().await = None;
|
|
world.update_block_entity(&block_entity);
|
|
}
|
|
|
|
pub async fn handle_edit_book(&self, player: &Player, packet: SEditBook<'_>) {
|
|
let held_stack = player.inventory().held_item().await;
|
|
if held_stack.item.id != Item::WRITABLE_BOOK.id {
|
|
return;
|
|
}
|
|
|
|
let pages: Vec<String> = packet.pages.iter().map(|p| (*p).to_string()).collect();
|
|
|
|
if let Some(title) = packet.title {
|
|
let mut written_book = ItemStack::new(1, &Item::WRITTEN_BOOK);
|
|
let content = WrittenBookContentImpl {
|
|
title: title.to_string(),
|
|
author: player.gameprofile.name.clone(),
|
|
pages,
|
|
};
|
|
written_book
|
|
.patch
|
|
.push((DataComponent::WrittenBookContent, Some(content.to_dyn())));
|
|
player.inventory().set_held_item(written_book).await;
|
|
} else {
|
|
let mut writable_book = held_stack;
|
|
let content = WritableBookContentImpl { pages };
|
|
writable_book
|
|
.patch
|
|
.retain(|(component, _)| *component != DataComponent::WritableBookContent);
|
|
writable_book
|
|
.patch
|
|
.push((DataComponent::WritableBookContent, Some(content.to_dyn())));
|
|
player.inventory().set_held_item(writable_book).await;
|
|
}
|
|
}
|
|
|
|
pub async fn handle_use_item(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
use_item: &SUseItem,
|
|
server: &Arc<Server>,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
|
|
let inventory = player.inventory();
|
|
let Ok(hand) = Hand::from_packet_id(use_item.hand.0) else {
|
|
self.kick(TextComponent::text("InvalidHand")).await;
|
|
return;
|
|
};
|
|
self.update_sequence(player, use_item.sequence.0);
|
|
|
|
let mut item_in_hand = inventory.get_stack_in_hand(hand).await;
|
|
|
|
let (item_id, _item) = (item_in_hand.item.id, item_in_hand.item);
|
|
player
|
|
.increment_stat(StatisticCategory::Used, item_id as i32, 1)
|
|
.await;
|
|
|
|
let hit_result = player
|
|
.world()
|
|
.raycast(
|
|
player.eye_position(),
|
|
player.eye_position().add(
|
|
&(Vector3::rotation_vector(f64::from(use_item.pitch), f64::from(use_item.yaw))
|
|
* 4.5),
|
|
),
|
|
async |pos, world| {
|
|
let block = world.get_block(pos);
|
|
block != &Block::AIR && block != &Block::WATER && block != &Block::LAVA
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let event = if let Some((hit_pos, _hit_dir)) = hit_result {
|
|
PlayerInteractEvent::new(
|
|
player,
|
|
InteractAction::RightClickBlock,
|
|
player.world().get_block(&hit_pos),
|
|
Some(hit_pos),
|
|
)
|
|
} else {
|
|
PlayerInteractEvent::new(player, InteractAction::RightClickAir, &Block::AIR, None)
|
|
};
|
|
let (item_for_use, stack_for_use) = (item_in_hand.item, item_in_hand.clone());
|
|
self.prepare_hand_item_for_use(player, hand, &mut item_in_hand)
|
|
.await;
|
|
|
|
if !self
|
|
.should_continue_use_after_fish_event(server, player, hand, item_for_use)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
|
|
send_cancellable! {{
|
|
server;
|
|
event;
|
|
'after: {
|
|
server.item_registry.on_use(&stack_for_use, player).await;
|
|
}
|
|
}}
|
|
}
|
|
|
|
async fn prepare_hand_item_for_use(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
hand: Hand,
|
|
held: &mut ItemStack,
|
|
) {
|
|
let inventory = player.inventory();
|
|
|
|
if let Some(cooldown) = held.get_use_cooldown() {
|
|
let group = cooldown
|
|
.cooldown_group
|
|
.clone()
|
|
.unwrap_or_else(|| held.item.registry_key.to_string());
|
|
if player.is_on_cooldown(&group).await {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if held.get_data_component::<ConsumableImpl>().is_some()
|
|
|| held.get_data_component::<BlocksAttacksImpl>().is_some()
|
|
{
|
|
// If its food we want to make sure we can actually consume it
|
|
if let Some(food) = held.get_data_component::<FoodImpl>() {
|
|
if player.abilities.lock().await.invulnerable
|
|
|| food.can_always_eat
|
|
|| player.hunger_manager.level.load() < 20
|
|
{
|
|
player
|
|
.living_entity
|
|
.set_active_hand(hand, held.clone(), held.get_max_use_time())
|
|
.await;
|
|
}
|
|
} else {
|
|
player
|
|
.living_entity
|
|
.set_active_hand(hand, held.clone(), held.get_max_use_time())
|
|
.await;
|
|
}
|
|
}
|
|
let equipment_slot = held
|
|
.get_data_component::<EquippableImpl>()
|
|
.map(|equippable| equippable.slot.clone());
|
|
if let Some(slot) = equipment_slot {
|
|
// The equipment lock has to be released before touching the hand again:
|
|
// the off hand lives in the same map, so holding it here would deadlock.
|
|
let current_equipped = inventory.entity_equipment.lock().await.get(&slot);
|
|
if current_equipped.are_items_and_components_equal(held) {
|
|
return;
|
|
}
|
|
|
|
player.enqueue_equipment_change(&slot, held).await;
|
|
|
|
let equipped = if current_equipped.is_empty() {
|
|
let equipped = held.clone();
|
|
held.decrement_unless_creative(player.gamemode.load(), 1);
|
|
equipped
|
|
} else {
|
|
std::mem::replace(held, current_equipped)
|
|
};
|
|
inventory.entity_equipment.lock().await.put(&slot, equipped);
|
|
inventory.set_stack_in_hand(hand, held.clone()).await;
|
|
}
|
|
}
|
|
|
|
async fn should_continue_use_after_fish_event(
|
|
&self,
|
|
server: &Arc<Server>,
|
|
player: &Arc<Player>,
|
|
hand: Hand,
|
|
item_for_use: &Item,
|
|
) -> bool {
|
|
if item_for_use.id != Item::FISHING_ROD.id {
|
|
return true;
|
|
}
|
|
|
|
// TODO: Apply fishing rod durability on retrieval based on catch type.
|
|
let mut fish_event = PlayerFishEvent::new(
|
|
player.clone(),
|
|
None,
|
|
uuid::Uuid::nil(),
|
|
String::new(),
|
|
PlayerFishState::Fishing,
|
|
hand,
|
|
0,
|
|
);
|
|
server.plugin_manager.fire(server, &mut fish_event).await;
|
|
!fish_event.cancelled
|
|
}
|
|
|
|
pub async fn handle_set_held_item(
|
|
&self,
|
|
server: &Arc<Server>,
|
|
|
|
player: &Player,
|
|
held: SSetHeldItem,
|
|
) {
|
|
player.update_last_action_time();
|
|
let slot = held.slot;
|
|
if !(0..=8).contains(&slot) {
|
|
self.kick(TextComponent::text("Invalid held slot")).await;
|
|
return;
|
|
}
|
|
let slot = slot as u8;
|
|
let previous_slot = player.inventory.get_selected_slot();
|
|
let Some(player_arc) = player.world().get_player_by_uuid(player.gameprofile.id) else {
|
|
return;
|
|
};
|
|
let mut event = PlayerItemHeldEvent::new(player_arc, previous_slot, slot);
|
|
server.plugin_manager.fire(server, &mut event).await;
|
|
if event.cancelled {
|
|
player
|
|
.client
|
|
.enqueue_packet(&CSetSelectedSlot::new(previous_slot as i8))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let inv = player.inventory();
|
|
inv.set_selected_slot(slot);
|
|
let stack = inv.held_item().await;
|
|
let equipment = &[(EquipmentSlot::MAIN_HAND, stack)];
|
|
player.living_entity.send_equipment_changes(equipment);
|
|
}
|
|
|
|
pub async fn handle_set_creative_slot(
|
|
&self,
|
|
player: &Player,
|
|
packet: SSetCreativeSlot,
|
|
) -> Result<(), InventoryError> {
|
|
if player.gamemode.load() != GameMode::Creative {
|
|
return Err(InventoryError::PermissionError);
|
|
}
|
|
let is_negative = packet.slot < 0;
|
|
let valid_slot = packet.slot >= 1 && packet.slot as usize <= 45;
|
|
let item_stack = packet
|
|
.clicked_item
|
|
.to_stack_for_version(&self.version.load());
|
|
let is_legal =
|
|
item_stack.is_empty() || item_stack.item_count <= item_stack.get_max_stack_size();
|
|
|
|
if valid_slot && is_legal {
|
|
let mut player_screen_handler = player.player_screen_handler.lock().await;
|
|
|
|
let is_armor_equipped = player_screen_handler
|
|
.get_slot(packet.slot as usize)
|
|
.get_stack()
|
|
.await
|
|
.are_equal(&item_stack);
|
|
if !is_armor_equipped {
|
|
if (5..9).contains(&packet.slot) {
|
|
player
|
|
.enqueue_equipment_change(
|
|
&match packet.slot {
|
|
5 => EquipmentSlot::HEAD,
|
|
6 => EquipmentSlot::CHEST,
|
|
7 => EquipmentSlot::LEGS,
|
|
8 => EquipmentSlot::FEET,
|
|
_ => {
|
|
tracing::error!("Invalid armor slot: {}", packet.slot);
|
|
EquipmentSlot::HEAD
|
|
}
|
|
},
|
|
&item_stack,
|
|
)
|
|
.await;
|
|
} else if (36..45).contains(&packet.slot) {
|
|
let slot = packet.slot - 36;
|
|
if player.inventory().get_selected_slot() == slot as u8 {
|
|
let equipment = &[(EquipmentSlot::MAIN_HAND, item_stack.clone())];
|
|
player.living_entity.send_equipment_changes(equipment);
|
|
}
|
|
}
|
|
}
|
|
|
|
player_screen_handler
|
|
.get_slot(packet.slot as usize)
|
|
.set_stack(item_stack.clone())
|
|
.await;
|
|
player_screen_handler.set_received_stack(packet.slot as usize, item_stack);
|
|
player_screen_handler.send_content_updates().await;
|
|
drop(player_screen_handler);
|
|
} else if is_negative && is_legal {
|
|
// Item drop
|
|
player.drop_item(item_stack).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn handle_chunk_batch(&self, player: &Player, packet: SChunkBatch) {
|
|
player
|
|
.chunk_manager
|
|
.lock()
|
|
.await
|
|
.handle_acknowledge(packet.chunks_per_tick);
|
|
trace!(
|
|
"Client requested {} chunks per tick",
|
|
packet.chunks_per_tick
|
|
);
|
|
}
|
|
|
|
pub async fn handle_close_container(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
_server: &Server,
|
|
_packet: SCloseContainer,
|
|
) {
|
|
player.on_handled_screen_closed().await;
|
|
}
|
|
|
|
pub async fn handle_command_suggestion(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
packet: SCommandSuggestion<'_>,
|
|
server: &Arc<Server>,
|
|
) {
|
|
let Some(cmd) = &packet.command.get(1..) else {
|
|
return;
|
|
};
|
|
|
|
let Some((last_word_start, _)) = cmd.char_indices().rfind(|(_, c)| c.is_whitespace())
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let suggestions = server
|
|
.command_dispatcher
|
|
.read()
|
|
.await
|
|
.suggest(cmd, &player.get_command_source(server).await)
|
|
.await;
|
|
|
|
let response = CCommandSuggestions::new(
|
|
packet.id,
|
|
((last_word_start + 2) as i32).into(),
|
|
((cmd.len() - last_word_start - 1) as i32).into(),
|
|
suggestions.into(),
|
|
);
|
|
|
|
self.enqueue_packet(&response).await;
|
|
}
|
|
|
|
pub fn handle_cookie_response(&self, packet: &SPCookieResponse<'_>) {
|
|
// TODO: allow plugins to access this
|
|
debug!(
|
|
"Received cookie_response[play]: key: \"{}\", payload_length: \"{:?}\"",
|
|
packet.key,
|
|
packet.payload.as_ref().map(|p| p.len())
|
|
);
|
|
}
|
|
|
|
async fn run_is_block_place(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
block: &'static Block,
|
|
server: &Arc<Server>,
|
|
use_item_on: SUseItemOn,
|
|
location: BlockPos,
|
|
face: BlockDirection,
|
|
) -> Result<bool, BlockPlacingError> {
|
|
match server
|
|
.block_registry
|
|
.place_block(player, block, server, &use_item_on, location, face)
|
|
.await
|
|
{
|
|
Ok(Some((final_block_pos, new_state))) => {
|
|
self.send_packet_now(&CBlockUpdate::new(
|
|
final_block_pos,
|
|
VarInt(i32::from(new_state.as_u16())),
|
|
))
|
|
.await;
|
|
Ok(true)
|
|
}
|
|
Ok(None) => Ok(false),
|
|
Err(crate::block::registry::BlockPlacingError::InvalidGamemode) => {
|
|
Err(BlockPlacingError::InvalidGamemode)
|
|
}
|
|
Err(crate::block::registry::BlockPlacingError::BlockOutOfWorld) => {
|
|
Err(BlockPlacingError::BlockOutOfWorld)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checks if the block placed was a sign, then opens a dialog.
|
|
pub async fn send_sign_packet(&self, block_position: BlockPos, is_front_text: bool) {
|
|
self.enqueue_packet(&COpenSignEditor::new(block_position, is_front_text))
|
|
.await;
|
|
}
|
|
|
|
pub async fn handle_select_trade(&self, player: &Arc<Player>, packet: SSelectTrade) {
|
|
let screen_handler = player.current_screen_handler.lock().await;
|
|
let mut screen_handler = screen_handler.lock().await;
|
|
if let Some(merchant) = screen_handler
|
|
.as_any_mut()
|
|
.downcast_mut::<MerchantScreenHandler>()
|
|
{
|
|
merchant
|
|
.set_selected_offer(packet.selected_slot.0 as usize)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn handle_seen_advancement(&self, player: &Arc<Player>, packet: SSeenAdvancement) {
|
|
if let SSeenAdvancement::OpenTab(tab) = packet {
|
|
let advancement = Advancement::from_minecraft_name(&tab.to_string());
|
|
if advancement.is_some() {
|
|
player
|
|
.advancements
|
|
.lock()
|
|
.await
|
|
.set_selected_tab(advancement)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn handle_bundle_item_selected(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
packet: SBundleItemSelected,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
|
|
let selected_item_index = packet.selected_item_index.0;
|
|
if selected_item_index < 0 && selected_item_index != -1 {
|
|
self.kick(TextComponent::text("Invalid selected item index"))
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
debug!(
|
|
"Bundle item selected: Slot ID {}, Selected Item Index {}",
|
|
packet.slot_id.0, selected_item_index
|
|
);
|
|
}
|
|
|
|
pub async fn handle_teleport_to_entity(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
packet: STeleportToEntity,
|
|
server: &Server,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
|
|
if player.gamemode.load() != GameMode::Spectator {
|
|
return;
|
|
}
|
|
|
|
if let Some(target_player) = server.get_player_by_uuid(packet.target) {
|
|
let target_pos = target_player.living_entity.entity.pos.load();
|
|
let target_yaw = target_player.living_entity.entity.yaw.load();
|
|
let target_pitch = target_player.living_entity.entity.pitch.load();
|
|
|
|
let target_id = target_player.living_entity.entity.entity_id;
|
|
player.camera_target_id.store(Some(target_id));
|
|
player
|
|
.client
|
|
.send_packet_now(&CSetCamera::new(target_id.into()))
|
|
.await;
|
|
|
|
player
|
|
.request_teleport(target_pos, target_yaw, target_pitch)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub fn handle_test_instance_block_action(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
packet: &STestInstanceBlockAction<'_>,
|
|
) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
debug!(
|
|
"Test instance block action at {:?}: action={:?}",
|
|
packet.pos, packet.action
|
|
);
|
|
}
|
|
|
|
pub fn handle_set_test_block(&self, player: &Arc<Player>, packet: &SSetTestBlock<'_>) {
|
|
if !player.has_client_loaded() {
|
|
return;
|
|
}
|
|
player.update_last_action_time();
|
|
debug!(
|
|
"Set test block at {:?}: mode={:?}, message={}",
|
|
packet.position, packet.mode, packet.message
|
|
);
|
|
}
|
|
|
|
pub fn handle_debug_subscription_request(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
packet: &pumpkin_protocol::java::server::play::SDebugSubscriptionRequest,
|
|
) {
|
|
if player.permission_lvl.load() >= PermissionLvl::Two && packet.sample_type.0 == 0 {
|
|
player
|
|
.subscribed_debug_sample
|
|
.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
pub fn handle_debug_sample_subscription(
|
|
&self,
|
|
player: &Arc<Player>,
|
|
packet: &pumpkin_protocol::java::server::play::SDebugSampleSubscription,
|
|
) {
|
|
if player.permission_lvl.load() >= PermissionLvl::Two && packet.sample_type.0 == 0 {
|
|
player
|
|
.subscribed_debug_sample
|
|
.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}
|