feat: add Spawn Protection

This commit is contained in:
Alexander Medvedev 2026-09-13 13:35:12 +02:00
parent 927b7fccd7
commit 25d1ecc65a
11 changed files with 157 additions and 20 deletions

View File

@ -28,9 +28,20 @@ impl Default for ChatConfig {
pub struct AntiSpamConfig { pub struct AntiSpamConfig {
/// Whether anti-spam protection is enabled. /// Whether anti-spam protection is enabled.
pub enabled: bool, pub enabled: bool,
/// The spam counter threshold at which a player will be kicked. /// Legacy tick-based spam threshold. Kept for compatibility.
/// Vanilla default is 200 ticks.
pub spam_threshold: u32, 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<u32>,
/// 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<u32>,
/// The amount added to the spam counter for each chat message or command sent. /// The amount added to the spam counter for each chat message or command sent.
/// Vanilla default is 20 ticks. /// Vanilla default is 20 ticks.
pub message_cost: u32, pub message_cost: u32,
@ -41,11 +52,29 @@ pub struct AntiSpamConfig {
pub ops_bypass: bool, 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 { impl Default for AntiSpamConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: true, enabled: true,
spam_threshold: 200, spam_threshold: 200,
chat_spam_threshold_seconds: None,
command_spam_threshold_seconds: None,
message_cost: 20, message_cost: 20,
decay_per_tick: 1, decay_per_tick: 1,
ops_bypass: true, ops_bypass: true,
@ -63,6 +92,8 @@ mod tests {
assert_eq!(config.format, "<{DISPLAYNAME}> {MESSAGE}"); assert_eq!(config.format, "<{DISPLAYNAME}> {MESSAGE}");
assert!(config.anti_spam.enabled); assert!(config.anti_spam.enabled);
assert_eq!(config.anti_spam.spam_threshold, 200); 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.message_cost, 20);
assert_eq!(config.anti_spam.decay_per_tick, 1); assert_eq!(config.anti_spam.decay_per_tick, 1);
assert!(config.anti_spam.ops_bypass); assert!(config.anti_spam.ops_bypass);
@ -75,6 +106,8 @@ mod tests {
[anti_spam] [anti_spam]
enabled = false enabled = false
spam_threshold = 100 spam_threshold = 100
chat-spam-threshold-seconds = 5
command-spam-threshold-seconds = 7
message_cost = 10 message_cost = 10
decay_per_tick = 2 decay_per_tick = 2
ops_bypass = false ops_bypass = false
@ -83,8 +116,12 @@ mod tests {
assert_eq!(config.format, "<{NAME}> {MESSAGE}"); assert_eq!(config.format, "<{NAME}> {MESSAGE}");
assert!(!config.anti_spam.enabled); assert!(!config.anti_spam.enabled);
assert_eq!(config.anti_spam.spam_threshold, 100); 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.message_cost, 10);
assert_eq!(config.anti_spam.decay_per_tick, 2); assert_eq!(config.anti_spam.decay_per_tick, 2);
assert!(!config.anti_spam.ops_bypass); assert!(!config.anti_spam.ops_bypass);
assert_eq!(config.anti_spam.chat_threshold_ticks(), 100);
assert_eq!(config.anti_spam.command_threshold_ticks(), 140);
} }
} }

View File

@ -216,6 +216,13 @@ pub struct BasicConfiguration {
pub white_list: bool, pub white_list: bool,
/// Whether to enforce the whitelist. /// Whether to enforce the whitelist.
pub enforce_whitelist: bool, 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 { impl Default for BasicConfiguration {
@ -237,6 +244,8 @@ impl Default for BasicConfiguration {
allow_chat_reports: false, allow_chat_reports: false,
white_list: false, white_list: false,
enforce_whitelist: false, enforce_whitelist: false,
accepts_transfers: true,
spawn_protection: 16,
} }
} }
} }
@ -279,6 +288,7 @@ pub trait LoadConfiguration {
debug!("creating new config root folder"); debug!("creating new config root folder");
let _ = fs::create_dir(config_dir); let _ = fs::create_dir(config_dir);
} }
let path = config_dir.join(Self::get_path()); let path = config_dir.join(Self::get_path());
let config = if path.exists() { let config = if path.exists() {
@ -424,3 +434,25 @@ pub trait LoadConfiguration {
/// Validates the configuration after loading or merging. /// Validates the configuration after loading or merging.
fn validate(&self); 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);
}
}

View File

@ -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( if !self.can_place_at(
Some(server), Some(server),
Some(&*world), Some(&*world),

View File

@ -388,6 +388,12 @@ pub enum PlayerWeather {
Downfall, Downfall,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpamType {
Chat,
Command,
}
pub struct Player { pub struct Player {
/// The underlying living entity object that represents the player. /// The underlying living entity object that represents the player.
pub living_entity: LivingEntity, pub living_entity: LivingEntity,
@ -4006,11 +4012,7 @@ impl Player {
} }
/// Checks whether sending a chat message or command constitutes spam. /// Checks whether sending a chat message or command constitutes spam.
/// pub fn check_chat_spam(&self, server: &Server, spam_type: SpamType) -> bool {
/// 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 {
let anti_spam = &server.advanced_config.chat.anti_spam; let anti_spam = &server.advanced_config.chat.anti_spam;
if !anti_spam.enabled { if !anti_spam.enabled {
return false; return false;
@ -4020,15 +4022,20 @@ impl Player {
return false; 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 let new_count = self
.chat_spam_tick_count .chat_spam_tick_count
.fetch_add(anti_spam.message_cost, Ordering::SeqCst) .fetch_add(anti_spam.message_cost, Ordering::SeqCst)
+ anti_spam.message_cost; + anti_spam.message_cost;
if new_count > anti_spam.spam_threshold { if new_count > threshold {
warn!( warn!(
"Player {} kicked for spamming (spam score: {}/{})", "Player {} kicked for spamming (spam score: {}/{})",
self.gameprofile.name, new_count, anti_spam.spam_threshold self.gameprofile.name, new_count, threshold
); );
self.kick( self.kick(
DisconnectReason::Kicked, DisconnectReason::Kicked,

View File

@ -9,7 +9,7 @@ impl BedrockClient {
packet: SCommandRequest<'_>, packet: SCommandRequest<'_>,
) { ) {
player.update_last_action_time(); player.update_last_action_time();
if player.check_chat_spam(server) { if player.check_chat_spam(server, crate::entity::player::SpamType::Command) {
return; return;
} }
let command = packet.command.strip_prefix('/').unwrap_or(&packet.command); let command = packet.command.strip_prefix('/').unwrap_or(&packet.command);

View File

@ -9,7 +9,7 @@ impl BedrockClient {
packet: SText<'_>, packet: SText<'_>,
) { ) {
player.update_last_action_time(); player.update_last_action_time();
if player.check_chat_spam(server) { if player.check_chat_spam(server, crate::entity::player::SpamType::Chat) {
return; return;
} }
let gameprofile = &player.gameprofile; let gameprofile = &player.gameprofile;

View File

@ -6,10 +6,11 @@ use pumpkin_protocol::{ConnectionState, java::server::handshake::SHandShake};
use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion}; use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion};
use tracing::debug; use tracing::debug;
use crate::net::java::pending::PendingConnection; use crate::{net::java::pending::PendingConnection, server::Server};
use std::sync::Arc;
impl PendingConnection { impl PendingConnection {
pub async fn handle_handshake(&mut self, handshake: SHandShake) { pub async fn handle_handshake(&mut self, server: &Arc<Server>, handshake: SHandShake) {
let version = handshake.protocol_version.0 as u32; let version = handshake.protocol_version.0 as u32;
self.server_address = handshake.server_address.to_string(); self.server_address = handshake.server_address.to_string();
self.version self.version
@ -17,6 +18,17 @@ impl PendingConnection {
debug!("Handshake: next state is {:?}", &handshake.next_state); debug!("Handshake: next state is {:?}", &handshake.next_state);
self.connection_state.store(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 { if self.connection_state.load() != ConnectionState::Status {
let protocol = version; let protocol = version;
if protocol < LOWEST_SUPPORTED_MC_VERSION.protocol_version() as u32 { if protocol < LOWEST_SUPPORTED_MC_VERSION.protocol_version() as u32 {

View File

@ -248,7 +248,7 @@ impl PendingConnection {
packet: &RawPacket, packet: &RawPacket,
) -> Result<Option<PacketHandlerResult>, ReadingError> { ) -> Result<Option<PacketHandlerResult>, ReadingError> {
match self.connection_state.load() { 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::Status => self.handle_status_packet(server, packet).await,
ConnectionState::Login | ConnectionState::Transfer => { ConnectionState::Login | ConnectionState::Transfer => {
self.handle_login_packet(server, packet).await self.handle_login_packet(server, packet).await
@ -260,16 +260,20 @@ impl PendingConnection {
async fn handle_handshake_packet( async fn handle_handshake_packet(
&mut self, &mut self,
server: &Arc<Server>,
packet: &RawPacket, packet: &RawPacket,
) -> Result<Option<PacketHandlerResult>, ReadingError> { ) -> Result<Option<PacketHandlerResult>, ReadingError> {
debug!("Handling handshake group"); debug!("Handling handshake group");
let mut payload = &packet.payload[..]; let mut payload = &packet.payload[..];
match packet.id { match packet.id {
0 => { 0 => {
self.handle_handshake(pumpkin_protocol::java::server::handshake::SHandShake::read( self.handle_handshake(
server,
pumpkin_protocol::java::server::handshake::SHandShake::read(
&mut payload, &mut payload,
&self.version.load(), &self.version.load(),
)?) )?,
)
.await; .await;
Ok(None) Ok(None)
} }

View File

@ -9,7 +9,7 @@ impl JavaClient {
command: &SChatCommand<'_>, command: &SChatCommand<'_>,
) { ) {
player.update_last_action_time(); player.update_last_action_time();
if player.check_chat_spam(server) { if player.check_chat_spam(server, crate::entity::player::SpamType::Command) {
return; return;
} }
let command_str = command.command.strip_prefix('/').unwrap_or(command.command); let command_str = command.command.strip_prefix('/').unwrap_or(command.command);

View File

@ -36,7 +36,7 @@ impl JavaClient {
return; return;
} }
if player.check_chat_spam(server) { if player.check_chat_spam(server, crate::entity::player::SpamType::Chat) {
return; return;
} }

View File

@ -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) { pub async fn shutdown(&self) {
for entity in self.entities.load().iter() { for entity in self.entities.load().iter() {
self.save_entity(entity).await; self.save_entity(entity).await;
@ -5516,6 +5539,17 @@ impl World {
cause: Option<&Arc<Player>>, cause: Option<&Arc<Player>>,
flags: BlockFlags, flags: BlockFlags,
) -> Option<BlockStateId> { ) -> Option<BlockStateId> {
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); let (broken_block, broken_block_state) = self.get_block_and_state(position);
if broken_block_state.is_air() { if broken_block_state.is_air() {
return None; return None;