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 {
/// 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<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.
/// 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);
}
}

View File

@ -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);
}
}

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(
Some(server),
Some(&*world),

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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<Server>, 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 {

View File

@ -248,7 +248,7 @@ impl PendingConnection {
packet: &RawPacket,
) -> Result<Option<PacketHandlerResult>, 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<Server>,
packet: &RawPacket,
) -> Result<Option<PacketHandlerResult>, 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)
}

View File

@ -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);

View File

@ -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;
}

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