feat: add Spawn Protection
This commit is contained in:
parent
927b7fccd7
commit
25d1ecc65a
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user