From 25d1ecc65a51ec8ad1adedfe2c24f908ee457dc4 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sun, 13 Sep 2026 13:35:12 +0200 Subject: [PATCH] feat: add Spawn Protection --- crates/pumpkin-config/src/chat.rs | 41 ++++++++++++++++++- crates/pumpkin-config/src/lib.rs | 32 +++++++++++++++ crates/pumpkin/src/block/registry.rs | 11 +++++ crates/pumpkin/src/entity/player.rs | 21 ++++++---- .../src/net/bedrock/play/chat_command.rs | 2 +- .../src/net/bedrock/play/chat_message.rs | 2 +- crates/pumpkin/src/net/java/handshake.rs | 16 +++++++- crates/pumpkin/src/net/java/pending.rs | 14 ++++--- .../pumpkin/src/net/java/play/chat_command.rs | 2 +- .../pumpkin/src/net/java/play/chat_message.rs | 2 +- crates/pumpkin/src/world/mod.rs | 34 +++++++++++++++ 11 files changed, 157 insertions(+), 20 deletions(-) diff --git a/crates/pumpkin-config/src/chat.rs b/crates/pumpkin-config/src/chat.rs index c7bebfa56..ece8ae14c 100644 --- a/crates/pumpkin-config/src/chat.rs +++ b/crates/pumpkin-config/src/chat.rs @@ -28,9 +28,20 @@ impl Default for ChatConfig { pub struct AntiSpamConfig { /// Whether anti-spam protection is enabled. pub enabled: bool, - /// The spam counter threshold at which a player will be kicked. - /// Vanilla default is 200 ticks. + /// Legacy tick-based spam threshold. Kept for compatibility. pub spam_threshold: u32, + /// Chat spam threshold in seconds. Overrides the legacy tick threshold when set. + #[serde( + alias = "chat-spam-threshold-seconds", + alias = "chat_spam_threshold_seconds" + )] + pub chat_spam_threshold_seconds: Option, + /// Command spam threshold in seconds. Overrides the legacy tick threshold when set. + #[serde( + alias = "command-spam-threshold-seconds", + alias = "command_spam_threshold_seconds" + )] + pub command_spam_threshold_seconds: Option, /// The amount added to the spam counter for each chat message or command sent. /// Vanilla default is 20 ticks. pub message_cost: u32, @@ -41,11 +52,29 @@ pub struct AntiSpamConfig { pub ops_bypass: bool, } +impl AntiSpamConfig { + /// Resolves the effective chat threshold in ticks. + #[must_use] + pub fn chat_threshold_ticks(&self) -> u32 { + self.chat_spam_threshold_seconds + .map_or(self.spam_threshold, |seconds| seconds.saturating_mul(20)) + } + + /// Resolves the effective command threshold in ticks. + #[must_use] + pub fn command_threshold_ticks(&self) -> u32 { + self.command_spam_threshold_seconds + .map_or(self.spam_threshold, |seconds| seconds.saturating_mul(20)) + } +} + impl Default for AntiSpamConfig { fn default() -> Self { Self { enabled: true, spam_threshold: 200, + chat_spam_threshold_seconds: None, + command_spam_threshold_seconds: None, message_cost: 20, decay_per_tick: 1, ops_bypass: true, @@ -63,6 +92,8 @@ mod tests { assert_eq!(config.format, "<{DISPLAYNAME}> {MESSAGE}"); assert!(config.anti_spam.enabled); assert_eq!(config.anti_spam.spam_threshold, 200); + assert_eq!(config.anti_spam.chat_spam_threshold_seconds, None); + assert_eq!(config.anti_spam.command_spam_threshold_seconds, None); assert_eq!(config.anti_spam.message_cost, 20); assert_eq!(config.anti_spam.decay_per_tick, 1); assert!(config.anti_spam.ops_bypass); @@ -75,6 +106,8 @@ mod tests { [anti_spam] enabled = false spam_threshold = 100 + chat-spam-threshold-seconds = 5 + command-spam-threshold-seconds = 7 message_cost = 10 decay_per_tick = 2 ops_bypass = false @@ -83,8 +116,12 @@ mod tests { assert_eq!(config.format, "<{NAME}> {MESSAGE}"); assert!(!config.anti_spam.enabled); assert_eq!(config.anti_spam.spam_threshold, 100); + assert_eq!(config.anti_spam.chat_spam_threshold_seconds, Some(5)); + assert_eq!(config.anti_spam.command_spam_threshold_seconds, Some(7)); assert_eq!(config.anti_spam.message_cost, 10); assert_eq!(config.anti_spam.decay_per_tick, 2); assert!(!config.anti_spam.ops_bypass); + assert_eq!(config.anti_spam.chat_threshold_ticks(), 100); + assert_eq!(config.anti_spam.command_threshold_ticks(), 140); } } diff --git a/crates/pumpkin-config/src/lib.rs b/crates/pumpkin-config/src/lib.rs index 24b5660d3..67bb80d8a 100644 --- a/crates/pumpkin-config/src/lib.rs +++ b/crates/pumpkin-config/src/lib.rs @@ -216,6 +216,13 @@ pub struct BasicConfiguration { pub white_list: bool, /// Whether to enforce the whitelist. pub enforce_whitelist: bool, + /// Whether this server accepts incoming transfers from other servers. + #[serde(alias = "accepts-transfers", alias = "accepts_transfers")] + pub accepts_transfers: bool, + /// The radius of the spawn protection area around the world spawn point. + /// Players without operator privileges cannot break or place blocks within this radius. + /// Set to 0 to disable spawn protection. + pub spawn_protection: u32, } impl Default for BasicConfiguration { @@ -237,6 +244,8 @@ impl Default for BasicConfiguration { allow_chat_reports: false, white_list: false, enforce_whitelist: false, + accepts_transfers: true, + spawn_protection: 16, } } } @@ -279,6 +288,7 @@ pub trait LoadConfiguration { debug!("creating new config root folder"); let _ = fs::create_dir(config_dir); } + let path = config_dir.join(Self::get_path()); let config = if path.exists() { @@ -424,3 +434,25 @@ pub trait LoadConfiguration { /// Validates the configuration after loading or merging. fn validate(&self); } + +#[cfg(test)] +mod tests { + use toml::from_str; + + use super::BasicConfiguration; + + #[test] + fn accepts_transfers_defaults_to_true() { + let config: BasicConfiguration = BasicConfiguration::default(); + assert!(config.accepts_transfers); + } + + #[test] + fn accepts_transfers_reads_vanilla_aliases() { + let config: BasicConfiguration = from_str("accepts-transfers = false").unwrap(); + assert!(!config.accepts_transfers); + + let config: BasicConfiguration = from_str("accepts_transfers = false").unwrap(); + assert!(!config.accepts_transfers); + } +} diff --git a/crates/pumpkin/src/block/registry.rs b/crates/pumpkin/src/block/registry.rs index ad86bda84..f42a603d1 100644 --- a/crates/pumpkin/src/block/registry.rs +++ b/crates/pumpkin/src/block/registry.rs @@ -711,6 +711,17 @@ impl BlockRegistry { } }; + if world.is_in_spawn_protection(player, &final_block_pos) { + player.send_system_message(&pumpkin_util::text::TextComponent::translate_cross( + pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION, + pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION, + [pumpkin_util::text::TextComponent::text( + player.gameprofile.name.clone(), + )], + )); + return Ok(None); + } + if !self.can_place_at( Some(server), Some(&*world), diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs index 72f3e7c6b..386d701df 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -388,6 +388,12 @@ pub enum PlayerWeather { Downfall, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpamType { + Chat, + Command, +} + pub struct Player { /// The underlying living entity object that represents the player. pub living_entity: LivingEntity, @@ -4006,11 +4012,7 @@ impl Player { } /// Checks whether sending a chat message or command constitutes spam. - /// - /// Increments the player's spam counter by `message_cost`. If the counter - /// exceeds `spam_threshold`, the player is kicked with the vanilla - /// `disconnect.spam` message and this method returns `true`. - pub fn check_chat_spam(&self, server: &Server) -> bool { + pub fn check_chat_spam(&self, server: &Server, spam_type: SpamType) -> bool { let anti_spam = &server.advanced_config.chat.anti_spam; if !anti_spam.enabled { return false; @@ -4020,15 +4022,20 @@ impl Player { return false; } + let threshold = match spam_type { + SpamType::Chat => anti_spam.chat_threshold_ticks(), + SpamType::Command => anti_spam.command_threshold_ticks(), + }; + let new_count = self .chat_spam_tick_count .fetch_add(anti_spam.message_cost, Ordering::SeqCst) + anti_spam.message_cost; - if new_count > anti_spam.spam_threshold { + if new_count > threshold { warn!( "Player {} kicked for spamming (spam score: {}/{})", - self.gameprofile.name, new_count, anti_spam.spam_threshold + self.gameprofile.name, new_count, threshold ); self.kick( DisconnectReason::Kicked, diff --git a/crates/pumpkin/src/net/bedrock/play/chat_command.rs b/crates/pumpkin/src/net/bedrock/play/chat_command.rs index 594cfd75f..d31683b7b 100644 --- a/crates/pumpkin/src/net/bedrock/play/chat_command.rs +++ b/crates/pumpkin/src/net/bedrock/play/chat_command.rs @@ -9,7 +9,7 @@ impl BedrockClient { packet: SCommandRequest<'_>, ) { player.update_last_action_time(); - if player.check_chat_spam(server) { + if player.check_chat_spam(server, crate::entity::player::SpamType::Command) { return; } let command = packet.command.strip_prefix('/').unwrap_or(&packet.command); diff --git a/crates/pumpkin/src/net/bedrock/play/chat_message.rs b/crates/pumpkin/src/net/bedrock/play/chat_message.rs index 6def4968d..d32485e9c 100644 --- a/crates/pumpkin/src/net/bedrock/play/chat_message.rs +++ b/crates/pumpkin/src/net/bedrock/play/chat_message.rs @@ -9,7 +9,7 @@ impl BedrockClient { packet: SText<'_>, ) { player.update_last_action_time(); - if player.check_chat_spam(server) { + if player.check_chat_spam(server, crate::entity::player::SpamType::Chat) { return; } let gameprofile = &player.gameprofile; diff --git a/crates/pumpkin/src/net/java/handshake.rs b/crates/pumpkin/src/net/java/handshake.rs index e1a6633ce..8f4126826 100644 --- a/crates/pumpkin/src/net/java/handshake.rs +++ b/crates/pumpkin/src/net/java/handshake.rs @@ -6,10 +6,11 @@ use pumpkin_protocol::{ConnectionState, java::server::handshake::SHandShake}; use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion}; use tracing::debug; -use crate::net::java::pending::PendingConnection; +use crate::{net::java::pending::PendingConnection, server::Server}; +use std::sync::Arc; impl PendingConnection { - pub async fn handle_handshake(&mut self, handshake: SHandShake) { + pub async fn handle_handshake(&mut self, server: &Arc, handshake: SHandShake) { let version = handshake.protocol_version.0 as u32; self.server_address = handshake.server_address.to_string(); self.version @@ -17,6 +18,17 @@ impl PendingConnection { debug!("Handshake: next state is {:?}", &handshake.next_state); self.connection_state.store(handshake.next_state); + if handshake.next_state == ConnectionState::Transfer + && !server.basic_config.accepts_transfers + { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_TRANSFERS_DISABLED, + translation::java::MULTIPLAYER_DISCONNECT_TRANSFERS_DISABLED, + [], + )) + .await; + return; + } if self.connection_state.load() != ConnectionState::Status { let protocol = version; if protocol < LOWEST_SUPPORTED_MC_VERSION.protocol_version() as u32 { diff --git a/crates/pumpkin/src/net/java/pending.rs b/crates/pumpkin/src/net/java/pending.rs index abf2eb94b..80a1f4bc1 100644 --- a/crates/pumpkin/src/net/java/pending.rs +++ b/crates/pumpkin/src/net/java/pending.rs @@ -248,7 +248,7 @@ impl PendingConnection { packet: &RawPacket, ) -> Result, ReadingError> { match self.connection_state.load() { - ConnectionState::HandShake => self.handle_handshake_packet(packet).await, + ConnectionState::HandShake => self.handle_handshake_packet(server, packet).await, ConnectionState::Status => self.handle_status_packet(server, packet).await, ConnectionState::Login | ConnectionState::Transfer => { self.handle_login_packet(server, packet).await @@ -260,16 +260,20 @@ impl PendingConnection { async fn handle_handshake_packet( &mut self, + server: &Arc, packet: &RawPacket, ) -> Result, ReadingError> { debug!("Handling handshake group"); let mut payload = &packet.payload[..]; match packet.id { 0 => { - self.handle_handshake(pumpkin_protocol::java::server::handshake::SHandShake::read( - &mut payload, - &self.version.load(), - )?) + self.handle_handshake( + server, + pumpkin_protocol::java::server::handshake::SHandShake::read( + &mut payload, + &self.version.load(), + )?, + ) .await; Ok(None) } diff --git a/crates/pumpkin/src/net/java/play/chat_command.rs b/crates/pumpkin/src/net/java/play/chat_command.rs index dd4112ef8..a2740195f 100644 --- a/crates/pumpkin/src/net/java/play/chat_command.rs +++ b/crates/pumpkin/src/net/java/play/chat_command.rs @@ -9,7 +9,7 @@ impl JavaClient { command: &SChatCommand<'_>, ) { player.update_last_action_time(); - if player.check_chat_spam(server) { + if player.check_chat_spam(server, crate::entity::player::SpamType::Command) { return; } let command_str = command.command.strip_prefix('/').unwrap_or(command.command); diff --git a/crates/pumpkin/src/net/java/play/chat_message.rs b/crates/pumpkin/src/net/java/play/chat_message.rs index 589277311..6536ce659 100644 --- a/crates/pumpkin/src/net/java/play/chat_message.rs +++ b/crates/pumpkin/src/net/java/play/chat_message.rs @@ -36,7 +36,7 @@ impl JavaClient { return; } - if player.check_chat_spam(server) { + if player.check_chat_spam(server, crate::entity::player::SpamType::Chat) { return; } diff --git a/crates/pumpkin/src/world/mod.rs b/crates/pumpkin/src/world/mod.rs index 0a603e210..1fba68bb3 100644 --- a/crates/pumpkin/src/world/mod.rs +++ b/crates/pumpkin/src/world/mod.rs @@ -547,6 +547,29 @@ impl World { ) } + #[must_use] + pub fn is_in_spawn_protection(&self, player: &Player, position: &BlockPos) -> bool { + if player.permission_lvl.load() == pumpkin_util::permission::PermissionLvl::Four { + return false; + } + + let Some(server) = self.server.upgrade() else { + return false; + }; + + let radius = server.basic_config.spawn_protection; + if radius == 0 { + return false; + } + + let radius = i32::try_from(radius).unwrap_or(i32::MAX); + let spawn = self.get_spawn_location().0; + let dx = (spawn.0.x - position.0.x).abs(); + let dz = (spawn.0.z - position.0.z).abs(); + + dx <= radius && dz <= radius + } + pub async fn shutdown(&self) { for entity in self.entities.load().iter() { self.save_entity(entity).await; @@ -5516,6 +5539,17 @@ impl World { cause: Option<&Arc>, flags: BlockFlags, ) -> Option { + if let Some(player) = cause + && self.is_in_spawn_protection(player, position) + { + player.send_system_message(&TextComponent::translate_cross( + pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION, + pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION, + [TextComponent::text(player.gameprofile.name.clone())], + )); + return None; + } + let (broken_block, broken_block_state) = self.get_block_and_state(position); if broken_block_state.is_air() { return None;