667 lines
20 KiB
Rust
667 lines
20 KiB
Rust
use crate::{
|
|
entity::player::ChatMode,
|
|
net::{bedrock::BedrockClient, java::JavaClient},
|
|
server::Server,
|
|
};
|
|
use arc_swap::ArcSwap;
|
|
use bytes::Bytes;
|
|
use pumpkin_world::level::SyncChunk;
|
|
use std::{
|
|
net::SocketAddr,
|
|
num::NonZeroU8,
|
|
sync::{Arc, atomic::Ordering},
|
|
};
|
|
|
|
use pumpkin_data::translation;
|
|
use pumpkin_protocol::{BClientPacket, ClientPacket, Property};
|
|
use pumpkin_util::{
|
|
Hand, ProfileAction,
|
|
text::TextComponent,
|
|
version::{BedrockMinecraftVersion, JavaMinecraftVersion},
|
|
};
|
|
use serde::{Deserialize, Deserializer};
|
|
use sha1::Digest;
|
|
use sha2::Sha256;
|
|
use tokio::task::JoinHandle;
|
|
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
pub mod authentication;
|
|
pub mod bedrock;
|
|
pub mod java;
|
|
pub mod lan_broadcast;
|
|
mod proxy;
|
|
pub mod query;
|
|
pub mod rcon;
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
pub struct GameProfile {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
#[serde(deserialize_with = "from_vec")]
|
|
pub properties: ArcSwap<Vec<Property>>,
|
|
#[serde(rename = "profileActions")]
|
|
pub profile_actions: Option<Vec<ProfileAction>>,
|
|
}
|
|
|
|
impl Clone for GameProfile {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
id: self.id,
|
|
name: self.name.clone(),
|
|
properties: ArcSwap::new(self.properties.load().clone()),
|
|
profile_actions: self.profile_actions.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn from_vec<'de, D>(deserializer: D) -> Result<ArcSwap<Vec<Property>>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let v = Vec::<Property>::deserialize(deserializer)?;
|
|
Ok(ArcSwap::new(Arc::new(v)))
|
|
}
|
|
|
|
pub fn offline_uuid(username: &str) -> Result<Uuid, uuid::Error> {
|
|
Uuid::from_slice(&Sha256::digest(username)[..16])
|
|
}
|
|
|
|
/// Represents a player's configuration settings.
|
|
///
|
|
/// This struct contains various options that can be customized by the player, affecting their gameplay experience.
|
|
///
|
|
/// **Usage:**
|
|
///
|
|
/// This struct is typically used to store and manage a player's preferences. It can be sent to the server when a player joins or when they change their settings.
|
|
#[derive(Clone)]
|
|
pub struct PlayerConfig {
|
|
/// The player's preferred language.
|
|
pub locale: String, // 16
|
|
/// The maximum distance at which chunks are rendered.
|
|
pub view_distance: NonZeroU8,
|
|
/// The player's chat mode settings
|
|
pub chat_mode: ChatMode,
|
|
/// Whether chat colors are enabled.
|
|
pub chat_colors: bool,
|
|
/// The player's skin configuration options.
|
|
pub skin_parts: u8,
|
|
/// The player's dominant hand (left or right).
|
|
pub main_hand: Hand,
|
|
/// Whether text filtering is enabled.
|
|
pub text_filtering: bool,
|
|
/// Whether the player wants to appear in the server list.
|
|
pub server_listing: bool,
|
|
}
|
|
|
|
impl Default for PlayerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
locale: "en_us".to_string(),
|
|
view_distance: NonZeroU8::new(8).unwrap_or(NonZeroU8::MIN),
|
|
chat_mode: ChatMode::Enabled,
|
|
chat_colors: true,
|
|
skin_parts: 0x7F,
|
|
main_hand: Hand::Right,
|
|
text_filtering: false,
|
|
server_listing: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub enum PacketHandlerResult {
|
|
Stop,
|
|
ReadyToPlay(GameProfile, PlayerConfig),
|
|
}
|
|
|
|
/// This is just a Wrapper for both Java & Bedrock connections
|
|
#[expect(clippy::large_enum_variant)]
|
|
pub enum ClientPlatform {
|
|
Java(JavaClient),
|
|
Bedrock(Arc<BedrockClient>),
|
|
}
|
|
|
|
impl ClientPlatform {
|
|
pub fn address(&self) -> SocketAddr {
|
|
match self {
|
|
Self::Java(java) => java.address,
|
|
Self::Bedrock(bedrock) => bedrock.address,
|
|
}
|
|
}
|
|
|
|
/// This function should only be used where you know that the client is bedrock!
|
|
#[inline]
|
|
#[must_use]
|
|
pub const fn bedrock(&self) -> Option<&Arc<BedrockClient>> {
|
|
if let Self::Bedrock(client) = self {
|
|
return Some(client);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// This function should only be used where you know that the client is java!
|
|
#[inline]
|
|
#[must_use]
|
|
pub const fn java(&self) -> Option<&JavaClient> {
|
|
if let Self::Java(client) = self {
|
|
return Some(client);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn closed(&self) -> bool {
|
|
match self {
|
|
Self::Java(java) => java.is_closed(),
|
|
Self::Bedrock(bedrock) => bedrock.is_closed(),
|
|
}
|
|
}
|
|
|
|
pub fn java_version(&self) -> JavaMinecraftVersion {
|
|
match self {
|
|
Self::Java(java) => java.version.load(),
|
|
Self::Bedrock(_) => JavaMinecraftVersion::Unknown,
|
|
}
|
|
}
|
|
|
|
pub fn bedrock_version(&self) -> BedrockMinecraftVersion {
|
|
match self {
|
|
Self::Java(_) => BedrockMinecraftVersion::Unknown,
|
|
Self::Bedrock(bedrock) => bedrock.version.load(),
|
|
}
|
|
}
|
|
|
|
pub fn try_enqueue_packet_data(&self, packet_data: Bytes) {
|
|
match self {
|
|
Self::Java(java) => java.try_enqueue_packet_data(packet_data),
|
|
Self::Bedrock(bedrock) => bedrock.try_enqueue_packet_data(packet_data),
|
|
}
|
|
}
|
|
|
|
pub async fn await_close_interrupt(&self) {
|
|
match self {
|
|
Self::Java(java) => java.await_close_interrupt().await,
|
|
Self::Bedrock(bedrock) => bedrock.await_close_interrupt().await,
|
|
}
|
|
}
|
|
|
|
pub fn spawn_task<F>(&self, task: F) -> Option<JoinHandle<F::Output>>
|
|
where
|
|
F: Future + Send + 'static,
|
|
F::Output: Send + 'static,
|
|
{
|
|
match self {
|
|
Self::Java(java) => java.spawn_task(task),
|
|
Self::Bedrock(bedrock) => bedrock.spawn_task(task),
|
|
}
|
|
}
|
|
|
|
pub async fn enqueue_packet_editioned<J: ClientPacket, B: BClientPacket>(
|
|
&self,
|
|
je_packet: &J,
|
|
be_packet: &B,
|
|
) {
|
|
match self {
|
|
Self::Java(java) => java.enqueue_packet(je_packet).await,
|
|
Self::Bedrock(bedrock) => bedrock.enqueue_packet(be_packet).await,
|
|
}
|
|
}
|
|
|
|
pub fn try_enqueue_packet_editioned<J: ClientPacket, B: BClientPacket>(
|
|
&self,
|
|
je_packet: &J,
|
|
be_packet: &B,
|
|
) {
|
|
match self {
|
|
Self::Java(java) => java.try_enqueue_packet(je_packet),
|
|
Self::Bedrock(bedrock) => bedrock.try_enqueue_packet(be_packet),
|
|
}
|
|
}
|
|
|
|
pub async fn enqueue_packet<P: ClientPacket>(&self, packet: &P) {
|
|
if let Self::Java(java) = self {
|
|
java.enqueue_packet(packet).await;
|
|
}
|
|
}
|
|
|
|
pub async fn enqueue_be_packet<P: BClientPacket>(&self, packet: &P) {
|
|
if let Self::Bedrock(bedrock) = self {
|
|
bedrock.enqueue_packet(packet).await;
|
|
}
|
|
}
|
|
|
|
pub fn try_enqueue_packet<P: ClientPacket>(&self, packet: &P) {
|
|
if let Self::Java(java) = self {
|
|
java.try_enqueue_packet(packet);
|
|
}
|
|
}
|
|
|
|
pub fn try_enqueue_be_packet<P: BClientPacket>(&self, packet: &P) {
|
|
if let Self::Bedrock(bedrock) = self {
|
|
bedrock.try_enqueue_packet(packet);
|
|
}
|
|
}
|
|
|
|
pub fn try_enqueue_spawn_packet(&self, entity: &Arc<dyn crate::entity::EntityBase>) {
|
|
match self {
|
|
Self::Java(java) => java.try_enqueue_packet(&entity.get_entity().create_spawn_packet()),
|
|
Self::Bedrock(bedrock) => bedrock.enqueue_spawn_packet(entity.clone()),
|
|
}
|
|
}
|
|
|
|
pub async fn enqueue_spawn_packet(&self, entity: &Arc<dyn crate::entity::EntityBase>) {
|
|
match self {
|
|
Self::Java(java) => entity.send_java_spawn_packet(java).await,
|
|
Self::Bedrock(bedrock) => entity.send_bedrock_spawn_packet(bedrock).await,
|
|
}
|
|
}
|
|
|
|
pub async fn send_chunks(&self, chunks: &[SyncChunk]) {
|
|
match self {
|
|
Self::Java(java) => java.send_chunks(chunks).await,
|
|
Self::Bedrock(bedrock) => bedrock.send_chunks(chunks).await,
|
|
}
|
|
}
|
|
|
|
pub async fn send_packet_now<P: ClientPacket>(&self, packet: &P) {
|
|
if let Self::Java(java) = self {
|
|
java.send_packet_now(packet).await;
|
|
}
|
|
}
|
|
|
|
pub async fn send_be_packet_now<P: BClientPacket>(&self, packet: &P) {
|
|
if let Self::Bedrock(bedrock) = self {
|
|
bedrock.send_game_packet(packet).await;
|
|
}
|
|
}
|
|
|
|
pub async fn send_packet_now_editioned<J: ClientPacket, B: BClientPacket>(
|
|
&self,
|
|
je_packet: &J,
|
|
be_packet: &B,
|
|
) {
|
|
match self {
|
|
Self::Java(java) => java.send_packet_now(je_packet).await,
|
|
Self::Bedrock(bedrock) => bedrock.send_game_packet(be_packet).await,
|
|
}
|
|
}
|
|
|
|
pub async fn send_packet_now_data(&self, data: Bytes) {
|
|
match self {
|
|
Self::Java(java) => java.send_packet_now_data(data).await,
|
|
Self::Bedrock(bedrock) => bedrock.enqueue_packet_data(data).await,
|
|
}
|
|
}
|
|
|
|
pub async fn kick(&self, reason: DisconnectReason, message: TextComponent) {
|
|
match self {
|
|
Self::Java(java) => java.kick(message).await,
|
|
Self::Bedrock(bedrock) => bedrock.kick(reason, message.get_text()).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn can_not_join(
|
|
profile: &GameProfile,
|
|
address: &SocketAddr,
|
|
server: &Server,
|
|
) -> Option<TextComponent> {
|
|
const FORMAT_DESCRIPTION: &[time::format_description::FormatItem<'static>] = time::macros::format_description!(
|
|
"[year]-[month]-[day] at [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]"
|
|
);
|
|
|
|
let mut banned_players = server.data.banned_player_list.write().await;
|
|
if let Some(entry) = banned_players.get_entry(profile) {
|
|
let text = TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_REASON,
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_REASON,
|
|
[TextComponent::text(entry.reason.clone())],
|
|
);
|
|
return Some(match entry.expires {
|
|
Some(expires) => text.add_child(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_EXPIRATION,
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_EXPIRATION,
|
|
[TextComponent::text(
|
|
expires.format(FORMAT_DESCRIPTION).unwrap_or_default(),
|
|
)],
|
|
)),
|
|
None => text,
|
|
});
|
|
}
|
|
drop(banned_players);
|
|
|
|
if server.white_list.load(Ordering::Relaxed) {
|
|
let ops = server.data.operator_config.read().await;
|
|
let whitelist = server.data.whitelist_config.read().await;
|
|
|
|
if ops.get_entry(&profile.id).is_none() && !whitelist.is_whitelisted(profile) {
|
|
return Some(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_NOT_WHITELISTED,
|
|
translation::java::MULTIPLAYER_DISCONNECT_NOT_WHITELISTED,
|
|
&[],
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Some(entry) = server
|
|
.data
|
|
.banned_ip_list
|
|
.write()
|
|
.await
|
|
.get_entry(&address.ip())
|
|
{
|
|
let text = TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_IP_REASON,
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_IP_REASON,
|
|
[TextComponent::text(entry.reason.clone())],
|
|
);
|
|
return Some(match entry.expires {
|
|
Some(expires) => text.add_child(TextComponent::translate_cross(
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_IP_EXPIRATION,
|
|
translation::java::MULTIPLAYER_DISCONNECT_BANNED_IP_EXPIRATION,
|
|
[TextComponent::text(
|
|
expires.format(FORMAT_DESCRIPTION).unwrap_or_default(),
|
|
)],
|
|
)),
|
|
None => text,
|
|
});
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum EncryptionError {
|
|
#[error("failed to decrypt shared secret")]
|
|
FailedDecrypt,
|
|
#[error("shared secret has the wrong length")]
|
|
SharedWrongLength,
|
|
#[error("encryption is already enabled")]
|
|
AlreadyEncrypted,
|
|
}
|
|
|
|
fn is_valid_player_name(name: &str) -> bool {
|
|
if name.len() > 16 {
|
|
return false;
|
|
}
|
|
!name.chars().any(|c| c.is_control() || c == ' ')
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum DisconnectReason {
|
|
Unknown = 0,
|
|
CantConnectNoInternet = 1,
|
|
NoPermissions = 2,
|
|
UnrecoverableError = 3,
|
|
ThirdPartyBlocked = 4,
|
|
ThirdPartyNoInternet = 5,
|
|
ThirdPartyBadIP = 6,
|
|
ThirdPartyNoServerOrServerLocked = 7,
|
|
VersionMismatch = 8,
|
|
SkinIssue = 9,
|
|
InviteSessionNotFound = 10,
|
|
EduLevelSettingsMissing = 11,
|
|
LocalServerNotFound = 12,
|
|
LegacyDisconnect = 13,
|
|
UserLeaveGameAttempted = 14,
|
|
PlatformLockedSkinsError = 15,
|
|
RealmsWorldUnassigned = 16,
|
|
RealmsServerCantConnect = 17,
|
|
RealmsServerHidden = 18,
|
|
RealmsServerDisabledBeta = 19,
|
|
RealmsServerDisabled = 20,
|
|
CrossPlatformDisabled = 21,
|
|
CantConnect = 22,
|
|
SessionNotFound = 23,
|
|
ClientSettingsIncompatibleWithServer = 24,
|
|
ServerFull = 25,
|
|
InvalidPlatformSkin = 26,
|
|
EditionVersionMismatch = 27,
|
|
EditionMismatch = 28,
|
|
LevelNewerThanExeVersion = 29,
|
|
NoFailOccurred = 30,
|
|
BannedSkin = 31,
|
|
Timeout = 32,
|
|
ServerNotFound = 33,
|
|
OutdatedServer = 34,
|
|
OutdatedClient = 35,
|
|
NoPremiumPlatform = 36,
|
|
MultiplayerDisabled = 37,
|
|
NoWiFi = 38,
|
|
WorldCorruption = 39,
|
|
NoReason = 40,
|
|
Disconnected = 41,
|
|
InvalidPlayer = 42,
|
|
LoggedInOtherLocation = 43,
|
|
ServerIdConflict = 44,
|
|
NotAllowed = 45,
|
|
NotAuthenticated = 46,
|
|
InvalidTenant = 47,
|
|
UnknownPacket = 48,
|
|
UnexpectedPacket = 49,
|
|
InvalidCommandRequestPacket = 50,
|
|
HostSuspended = 51,
|
|
LoginPacketNoRequest = 52,
|
|
LoginPacketNoCert = 53,
|
|
MissingClient = 54,
|
|
Kicked = 55,
|
|
KickedForExploit = 56,
|
|
KickedForIdle = 57,
|
|
ResourcePackProblem = 58,
|
|
IncompatiblePack = 59,
|
|
OutOfStorage = 60,
|
|
InvalidLevel = 61,
|
|
DisconnectPacket = 62,
|
|
BlockMismatch = 63,
|
|
InvalidHeights = 64,
|
|
InvalidWidths = 65,
|
|
ConnectionLost = 66,
|
|
ZombieConnection = 67,
|
|
Shutdown = 68,
|
|
ReasonNotSet = 69,
|
|
LoadingStateTimeout = 70,
|
|
ResourcePackLoadingFailed = 71,
|
|
SearchingForSessionLoadingScreenFailed = 72,
|
|
NetherNetProtocolVersion = 73,
|
|
SubsystemStatusError = 74,
|
|
EmptyAuthFromDiscovery = 75,
|
|
EmptyUrlFromDiscovery = 76,
|
|
ExpiredAuthFromDiscovery = 77,
|
|
UnknownSignalServiceSignInFailure = 78,
|
|
XBLJoinLobbyFailure = 79,
|
|
UnspecifiedClientInstanceDisconnection = 80,
|
|
NetherNetSessionNotFound = 81,
|
|
NetherNetCreatePeerConnection = 82,
|
|
NetherNetICE = 83,
|
|
NetherNetConnectRequest = 84,
|
|
NetherNetConnectResponse = 85,
|
|
NetherNetNegotiationTimeout = 86,
|
|
NetherNetInactivityTimeout = 87,
|
|
StaleConnectionBeingReplaced = 88,
|
|
RealmsSessionNotFound = 89,
|
|
BadPacket = 90,
|
|
NetherNetFailedToCreateOffer = 91,
|
|
NetherNetFailedToCreateAnswer = 92,
|
|
NetherNetFailedToSetLocalDescription = 93,
|
|
NetherNetFailedToSetRemoteDescription = 94,
|
|
NetherNetNegotiationTimeoutWaitingForResponse = 95,
|
|
NetherNetNegotiationTimeoutWaitingForAccept = 96,
|
|
NetherNetIncomingConnectionIgnored = 97,
|
|
NetherNetSignalingParsingFailure = 98,
|
|
NetherNetSignalingUnknownError = 99,
|
|
NetherNetSignalingUnicastDeliveryFailed = 100,
|
|
NetherNetSignalingBroadcastDeliveryFailed = 101,
|
|
NetherNetSignalingGenericDeliveryFailed = 102,
|
|
EditorMismatchEditorWorld = 103,
|
|
EditorMismatchVanillaWorld = 104,
|
|
WorldTransferNotPrimaryClient = 105,
|
|
RequestServerShutdown = 106,
|
|
ClientGameSetupCancelled = 107,
|
|
ClientGameSetupFailed = 108,
|
|
NoVenue = 109,
|
|
NetherNetSignalingSigninFailed = 110,
|
|
SessionAccessDenied = 111,
|
|
ServiceSigninIssue = 112,
|
|
NetherNetNoSignalingChannel = 113,
|
|
NetherNetNotLoggedIn = 114,
|
|
NetherNetClientSignalingError = 115,
|
|
SubClientLoginDisabled = 116,
|
|
DeepLinkTryingToOpenDemoWorldWhileSignedIn = 117,
|
|
AsyncJoinTaskDenied = 118,
|
|
RealmsTimelineRequired = 119,
|
|
GuestWithoutHost = 120,
|
|
FailedToJoinExperience = 121,
|
|
NetherNetDataChannelClosed = 122,
|
|
DiscoveryEnvironmentMismatch = 123,
|
|
HostWithoutKeys = 124,
|
|
HostSignedOut = 125,
|
|
ScriptWatchdogException = 126,
|
|
ScriptMemoryLimitExceeded = 127,
|
|
StorageLowDuringGameplay = 128,
|
|
StorageFullDuringGameplay = 129,
|
|
LevelStorageCorruption = 130,
|
|
EditionMismatchVanillaToEdu = 131,
|
|
EditionMismatchEduToVanilla = 132,
|
|
EditorMismatchEditorToVanilla = 133,
|
|
EditorMismatchVanillaToEditor = 134,
|
|
DenyListed = 135,
|
|
NonceMissing = 136,
|
|
NonceNotFound = 137,
|
|
NonceExpired = 138,
|
|
NonceNotValid = 139,
|
|
HostDisconnected = 140,
|
|
EditorJoinIntentPolicyFailure = 141,
|
|
NetherNetIdentityNotAllowed = 142,
|
|
InvalidName = 143,
|
|
ExpiredToken = 144,
|
|
HostAcceptsNoTypeOfAuth = 145,
|
|
NotAuthenticatedFastFail = 146,
|
|
EditorNotAllowed = 147,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::net::is_valid_player_name;
|
|
|
|
/// Test case for a standard, valid English name at max length.
|
|
#[test]
|
|
fn valid_max_length_ascii() {
|
|
let name = "player_name_1234"; // 16 characters (16 bytes)
|
|
assert!(
|
|
is_valid_player_name(name),
|
|
"Max length ASCII name should be valid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a short, valid ASCII name.
|
|
#[test]
|
|
fn valid_short_ascii() {
|
|
let name = "GamerX";
|
|
assert!(
|
|
is_valid_player_name(name),
|
|
"Short ASCII name should be valid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name containing allowed punctuation (codepoints 33-126).
|
|
#[test]
|
|
fn valid_with_punctuation() {
|
|
let name = "!-@#$%.^&*_+-=";
|
|
assert!(
|
|
is_valid_player_name(name),
|
|
"Name with valid punctuation should be valid"
|
|
);
|
|
}
|
|
|
|
/// Test case for allowed high-codepoint Unicode characters (like Chinese/CJK).
|
|
#[test]
|
|
fn valid_unicode_chinese() {
|
|
let name = "玩家一号"; // 4 characters, 12 bytes
|
|
assert!(
|
|
is_valid_player_name(name),
|
|
"Chinese characters should be valid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a mix of valid ASCII and Unicode characters.
|
|
#[test]
|
|
fn valid_mixed_chars() {
|
|
let name = "Player_玩家"; // 9 characters
|
|
assert!(
|
|
is_valid_player_name(name),
|
|
"Mixed ASCII and Unicode should be valid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name that exceeds the 16-byte limit (ASCII).
|
|
#[test]
|
|
fn invalid_length_ascii_over() {
|
|
let name = "this_name_is_too_long"; // 21 characters (21 bytes)
|
|
assert!(
|
|
!is_valid_player_name(name),
|
|
"Name over 16 bytes (ASCII) should be invalid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name that exceeds the 16-byte limit (Unicode).
|
|
#[test]
|
|
fn invalid_length_unicode_over() {
|
|
let name = "超长玩家名称哈哈"; // 8 Chinese characters * 3 bytes/char = 24 bytes
|
|
assert!(
|
|
!is_valid_player_name(name),
|
|
"Name over 16 bytes (Unicode) should be invalid by byte count"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name containing a standard space (codepoint 32).
|
|
#[test]
|
|
fn invalid_contains_space() {
|
|
let name = "Player Name";
|
|
assert!(
|
|
!is_valid_player_name(name),
|
|
"Name containing a space should be invalid"
|
|
);
|
|
}
|
|
|
|
/// Test case for an empty string (length 0, but included for completeness).
|
|
#[test]
|
|
fn invalid_empty_string() {
|
|
let name = "";
|
|
assert!(
|
|
is_valid_player_name(name),
|
|
"Empty string should be valid (length <= 16 and no invalid chars)"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name containing a control character (e.g., Null, codepoint 0).
|
|
#[test]
|
|
fn invalid_contains_null() {
|
|
let name = "Player\0Name";
|
|
assert!(
|
|
!is_valid_player_name(name),
|
|
"Name containing a null character should be invalid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name containing a newline character (codepoint 10).
|
|
#[test]
|
|
fn invalid_contains_newline() {
|
|
let name = "Player\nName";
|
|
assert!(
|
|
!is_valid_player_name(name),
|
|
"Name containing a newline should be invalid"
|
|
);
|
|
}
|
|
|
|
/// Test case for a name containing the DEL control character (codepoint 127).
|
|
#[test]
|
|
fn invalid_contains_del() {
|
|
// DEL character is char::from_u32(127).unwrap()
|
|
let name = format!("Player{}Name", 127u8 as char);
|
|
assert!(
|
|
!is_valid_player_name(&name),
|
|
"Name containing DEL (127) should be invalid"
|
|
);
|
|
}
|
|
}
|