diff --git a/Cargo.toml b/Cargo.toml index b75cdec3..2bed3080 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "src/lib/derive_macros", "src/lib/adapters/nbt", "src/lib/adapters/mca", "src/tests", "src/lib/adapters/anvil", + "src/lib/text", ] #================== Lints ==================# @@ -74,6 +75,7 @@ ferrumc-core = { path = "src/lib/core" } ferrumc-ecs = { path = "src/lib/ecs" } ferrumc-events = { path = "src/lib/events" } ferrumc-net = { path = "src/lib/net" } +ferrumc-text = { path = "src/lib/text" } ferrumc-net-encryption = { path = "src/lib/net/crates/encryption" } ferrumc-net-codec = { path = "src/lib/net/crates/codec" } ferrumc-plugins = { path = "src/lib/plugins" } @@ -117,18 +119,19 @@ rand = "0.9.0-alpha.2" fnv = "1.0.7" # Encoding/Serialization -serde = "1.0.210" +serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" serde_derive = "1.0.210" base64 = "0.22.1" bitcode = "0.6.3" bitcode_derive = "0.6.3" + # Data types hashbrown = "0.15.0" tinyvec = "1.8.0" dashmap = "6.1.0" -uuid = "1.1" +uuid = { version = "1.1", features = ["v4", "v3", "serde"] } # Macros lazy_static = "1.5.0" @@ -136,6 +139,7 @@ quote = "1.0.37" syn = "2.0.77" proc-macro2 = "1.0.86" proc-macro-crate = "3.2.0" +paste = "1.0.15" maplit = "1.0.2" macro_rules_attribute = "0.2.0" @@ -159,6 +163,7 @@ colored = "2.1.0" # Misc deepsize = "0.2.0" + # I/O tempfile = "3.12.0" memmap2 = "0.9.5" @@ -174,4 +179,4 @@ debug = false debug-assertions = false overflow-checks = false panic = "abort" -codegen-units = 1 \ No newline at end of file +codegen-units = 1 diff --git a/src/bin/src/packet_handlers/login_process.rs b/src/bin/src/packet_handlers/login_process.rs index d44638fa..18c1b8ff 100644 --- a/src/bin/src/packet_handlers/login_process.rs +++ b/src/bin/src/packet_handlers/login_process.rs @@ -4,12 +4,14 @@ use ferrumc_macros::event_handler; use ferrumc_net::connection::{ConnectionState, StreamWriter}; use ferrumc_net::errors::NetError; use ferrumc_net::packets::incoming::ack_finish_configuration::AckFinishConfigurationEvent; +use ferrumc_net::packets::incoming::keep_alive::IncomingKeepAlivePacket; use ferrumc_net::packets::incoming::login_acknowledged::LoginAcknowledgedEvent; use ferrumc_net::packets::incoming::login_start::LoginStartEvent; use ferrumc_net::packets::incoming::server_bound_known_packs::ServerBoundKnownPacksEvent; use ferrumc_net::packets::outgoing::client_bound_known_packs::ClientBoundKnownPacksPacket; +use ferrumc_net::packets::outgoing::finish_configuration::FinishConfigurationPacket; use ferrumc_net::packets::outgoing::game_event::GameEventPacket; -use ferrumc_net::packets::outgoing::keep_alive::{KeepAlive, KeepAlivePacket}; +use ferrumc_net::packets::outgoing::keep_alive::OutgoingKeepAlivePacket; use ferrumc_net::packets::outgoing::login_play::LoginPlayPacket; use ferrumc_net::packets::outgoing::login_success::LoginSuccessPacket; use ferrumc_net::packets::outgoing::registry_data::get_registry_packets; @@ -18,7 +20,6 @@ use ferrumc_net::packets::outgoing::synchronize_player_position::SynchronizePlay use ferrumc_net::GlobalState; use ferrumc_net_codec::encode::NetEncodeOpts; use tracing::{debug, trace}; -use ferrumc_net::packets::outgoing::finish_configuration::FinishConfigurationPacket; #[event_handler] async fn handle_login_start( @@ -31,19 +32,23 @@ async fn handle_login_start( let username = login_start_event.login_start_packet.username.as_str(); debug!("Received login start from user with username {}", username); - // Add the player identity component to the ECS for the entity. state.universe.add_component::( login_start_event.conn_id, PlayerIdentity::new(username.to_string(), uuid), )?; - + //Send a Login Success Response to further the login sequence let mut writer = state .universe .get_mut::(login_start_event.conn_id)?; - writer.send_packet(&LoginSuccessPacket::new(uuid, username), &NetEncodeOpts::WithLength).await?; + writer + .send_packet( + &LoginSuccessPacket::new(uuid, username), + &NetEncodeOpts::WithLength, + ) + .await?; Ok(login_start_event) } @@ -62,7 +67,6 @@ async fn handle_login_acknowledged( *connection_state = ConnectionState::Configuration; - // Send packets packet let client_bound_known_packs = ClientBoundKnownPacksPacket::new(); @@ -70,7 +74,9 @@ async fn handle_login_acknowledged( .universe .get_mut::(login_acknowledged_event.conn_id)?; - writer.send_packet(&client_bound_known_packs, &NetEncodeOpts::WithLength).await?; + writer + .send_packet(&client_bound_known_packs, &NetEncodeOpts::WithLength) + .await?; Ok(login_acknowledged_event) } @@ -87,10 +93,17 @@ async fn handle_server_bound_known_packs( .get_mut::(server_bound_known_packs_event.conn_id)?; let registry_packets = get_registry_packets(); - writer.send_packet(®istry_packets, &NetEncodeOpts::None).await?; - - writer.send_packet(&FinishConfigurationPacket::new(), &NetEncodeOpts::WithLength).await?; - + writer + .send_packet(®istry_packets, &NetEncodeOpts::None) + .await?; + + writer + .send_packet( + &FinishConfigurationPacket::new(), + &NetEncodeOpts::WithLength, + ) + .await?; + Ok(server_bound_known_packs_event) } @@ -103,34 +116,56 @@ async fn handle_ack_finish_configuration( let conn_id = ack_finish_configuration_event.conn_id; - let mut conn_state = state - .universe - .get_mut::(conn_id)?; + let mut conn_state = state.universe.get_mut::(conn_id)?; *conn_state = ConnectionState::Play; - let mut writer = state - .universe - .get_mut::(conn_id)?; - - writer.send_packet(&LoginPlayPacket::new(conn_id), &NetEncodeOpts::WithLength).await?; - writer.send_packet(&SetDefaultSpawnPositionPacket::default(), &NetEncodeOpts::WithLength).await?; - writer.send_packet(&SynchronizePlayerPositionPacket::default(), &NetEncodeOpts::WithLength).await?; - writer.send_packet(&GameEventPacket::start_waiting_for_level_chunks(), &NetEncodeOpts::WithLength).await?; + let mut writer = state.universe.get_mut::(conn_id)?; + + writer + .send_packet(&LoginPlayPacket::new(conn_id), &NetEncodeOpts::WithLength) + .await?; + writer + .send_packet( + &SetDefaultSpawnPositionPacket::default(), + &NetEncodeOpts::WithLength, + ) + .await?; + writer + .send_packet( + &SynchronizePlayerPositionPacket::default(), + &NetEncodeOpts::WithLength, + ) + .await?; + writer + .send_packet( + &GameEventPacket::start_waiting_for_level_chunks(), + &NetEncodeOpts::WithLength, + ) + .await?; send_keep_alive(conn_id, state, &mut writer).await?; - Ok(ack_finish_configuration_event) } -async fn send_keep_alive(conn_id: usize, state: GlobalState, writer: &mut ComponentRefMut<'_, StreamWriter>) -> Result<(), NetError> { - let keep_alive_packet = KeepAlivePacket::default(); - writer.send_packet(&keep_alive_packet, &NetEncodeOpts::WithLength).await?; - - let id = keep_alive_packet.id; +async fn send_keep_alive( + conn_id: usize, + state: GlobalState, + writer: &mut ComponentRefMut<'_, StreamWriter>, +) -> Result<(), NetError> { + let keep_alive_packet = OutgoingKeepAlivePacket::default(); + writer + .send_packet(&keep_alive_packet, &NetEncodeOpts::WithLength) + .await?; - state.universe.add_component::(conn_id, id)?; + let timestamp = keep_alive_packet.timestamp; + state + .universe + .add_component::(conn_id, keep_alive_packet)?; + state + .universe + .add_component::(conn_id, IncomingKeepAlivePacket { timestamp })?; Ok(()) -} \ No newline at end of file +} diff --git a/src/bin/src/systems/keep_alive_system.rs b/src/bin/src/systems/keep_alive_system.rs index 18eafdf9..cb0dbbc2 100644 --- a/src/bin/src/systems/keep_alive_system.rs +++ b/src/bin/src/systems/keep_alive_system.rs @@ -2,8 +2,10 @@ use crate::systems::definition::System; use async_trait::async_trait; use ferrumc_core::identity::player_identity::PlayerIdentity; use ferrumc_net::connection::{ConnectionState, StreamWriter}; -use ferrumc_net::packets::outgoing::keep_alive::{KeepAlive, KeepAlivePacket}; +use ferrumc_net::packets::incoming::keep_alive::IncomingKeepAlivePacket; +use ferrumc_net::packets::outgoing::keep_alive::OutgoingKeepAlivePacket; use ferrumc_net::utils::broadcast::{BroadcastOptions, BroadcastToAll}; +use ferrumc_net::utils::state::terminate_connection; use ferrumc_net::GlobalState; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -46,19 +48,17 @@ impl System for KeepAliveSystem { last_time = current_time; } - let fifteen_seconds_ms = 15000; // 15 seconds in milliseconds - let entities = state .universe - .query::<(&mut StreamWriter, &ConnectionState, &KeepAlive)>() + .query::<(&mut StreamWriter, &ConnectionState)>() .into_entities() .into_iter() .filter_map(|entity| { let conn_state = state.universe.get::(entity).ok()?; - let keep_alive = state.universe.get_mut::(entity).ok()?; + let keep_alive = state.universe.get_mut::(entity).ok()?; if matches!(*conn_state, ConnectionState::Play) - && (current_time - keep_alive.id) >= fifteen_seconds_ms + && (current_time - keep_alive.timestamp) >= 15000 { Some(entity) } else { @@ -68,26 +68,58 @@ impl System for KeepAliveSystem { .collect::>(); if !entities.is_empty() { trace!("there are {:?} players to keep alive", entities.len()); - } - let packet = KeepAlivePacket::default(); + // I know this is the second iteration of the entities vector, but it has to be done since terminate_connection is async + for entity in entities.iter() { + let keep_alive = state + .universe + .get_mut::(*entity) + .ok() + .unwrap(); + + if (current_time - keep_alive.timestamp) >= 30000 { + // two iterations missed + if let Err(e) = terminate_connection( + state.clone(), + *entity, + "Keep alive timeout".to_string(), + ) + .await + { + warn!( + "Failed to terminate connection for entity {:?} , Err : {:?}", + entity, e + ); + } + } + } + let packet = OutgoingKeepAlivePacket { timestamp: current_time }; + + let broadcast_opts = BroadcastOptions::default() + .only(entities) + .with_sync_callback(move |entity, state| { + let Ok(mut keep_alive) = + state.universe.get_mut::(entity) + else { + warn!( + "Failed to get component for entity {}", + entity + ); + return; + }; - let broadcast_opts = BroadcastOptions::default() - .only(entities) - .with_sync_callback(move |entity, state| { - let Ok(mut keep_alive) = state.universe.get_mut::(entity) else { - warn!("Failed to get component for entity {}", entity); - return; - }; + *keep_alive = packet.clone(); + }); - *keep_alive = KeepAlive::from(current_time); - }); + if let Err(e) = state + .broadcast(&OutgoingKeepAlivePacket { timestamp: current_time }, broadcast_opts) + .await + { + error!("Error sending keep alive packet: {}", e); + }; + } - if let Err(e) = state.broadcast(&packet, broadcast_opts).await { - error!("Error sending keep alive packet: {}", e); - }; - // TODO, this should be configurable as some people may have bad network so the clients may end up disconnecting from the server moments before the keep alive is sent - tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + tokio::time::sleep(tokio::time::Duration::from_secs(15)).await; } } diff --git a/src/lib/adapters/nbt/Cargo.toml b/src/lib/adapters/nbt/Cargo.toml index 890c570e..ce8889f8 100644 --- a/src/lib/adapters/nbt/Cargo.toml +++ b/src/lib/adapters/nbt/Cargo.toml @@ -11,6 +11,7 @@ ferrumc-net-codec = { workspace = true } tracing = { workspace = true} tokio = { workspace = true } ferrumc-general-purpose = { workspace = true } +uuid = { workspace = true } [lints] workspace = true diff --git a/src/lib/adapters/nbt/src/de/borrow.rs b/src/lib/adapters/nbt/src/de/borrow.rs index fd381fd2..03ecb8c3 100644 --- a/src/lib/adapters/nbt/src/de/borrow.rs +++ b/src/lib/adapters/nbt/src/de/borrow.rs @@ -634,7 +634,7 @@ impl NbtTapeElement<'_> { writer.write_all(&[self.nbt_id()])?; name.serialize(writer, &NBTSerializeOptions::None); } - NBTSerializeOptions::Network => { + NBTSerializeOptions::Network | NBTSerializeOptions::Flatten => { writer.write_all(&[self.nbt_id()])?; } } @@ -754,4 +754,4 @@ impl NbtTapeElement<'_> { } } } -} \ No newline at end of file +} diff --git a/src/lib/adapters/nbt/src/ser/impl.rs b/src/lib/adapters/nbt/src/ser/impl.rs index a3d6bbbc..e5087c93 100644 --- a/src/lib/adapters/nbt/src/ser/impl.rs +++ b/src/lib/adapters/nbt/src/ser/impl.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use ferrumc_general_purpose::simd::arrays; use super::{NBTSerializable, NBTSerializeOptions}; +use uuid::Uuid; macro_rules! impl_ser_primitives { ($($($ty:ty) | * > $id:expr),*) => { @@ -41,6 +42,19 @@ impl_ser_primitives!( f64 > TAG_DOUBLE ); +impl NBTSerializable for Box +where + T: NBTSerializable, +{ + fn serialize(&self, buf: &mut Vec, options: &NBTSerializeOptions<'_>) { + T::serialize(self, buf, options); + } + + fn id() -> u8 { + T::id() + } +} + impl NBTSerializable for bool { fn serialize(&self, buf: &mut Vec, options: &NBTSerializeOptions<'_>) { write_header::(buf, options); @@ -75,6 +89,16 @@ impl NBTSerializable for &str { } } +impl NBTSerializable for Uuid { + fn serialize(&self, buf: &mut Vec, options: &NBTSerializeOptions<'_>) { + NBTSerializable::serialize(&self.as_hyphenated().to_string().as_str(), buf, options); + } + + fn id() -> u8 { + TAG_STRING + } +} + impl NBTSerializable for Vec { fn serialize(&self, buf: &mut Vec, options: &NBTSerializeOptions<'_>) { self.as_slice().serialize(buf, options); @@ -269,7 +293,7 @@ fn write_header(buf: &mut Vec, opts: &NBTSerializeOption T::id().serialize(buf, &NBTSerializeOptions::None); tag_name.serialize(buf, &NBTSerializeOptions::None); } - NBTSerializeOptions::Network => { + NBTSerializeOptions::Network | NBTSerializeOptions::Flatten => { T::id().serialize(buf, &NBTSerializeOptions::None); } } diff --git a/src/lib/adapters/nbt/src/ser/mod.rs b/src/lib/adapters/nbt/src/ser/mod.rs index 63143b38..230cdcde 100644 --- a/src/lib/adapters/nbt/src/ser/mod.rs +++ b/src/lib/adapters/nbt/src/ser/mod.rs @@ -7,8 +7,10 @@ pub trait NBTSerializable { /// Options for serializing NBT data. /// To simplify root serialization. +#[derive(PartialEq, Debug)] pub enum NBTSerializeOptions<'a> { None, WithHeader(&'a str), - Network -} \ No newline at end of file + Network, + Flatten, +} diff --git a/src/lib/derive_macros/src/nbt/de.rs b/src/lib/derive_macros/src/nbt/de.rs index 6a010e9d..8940b30c 100644 --- a/src/lib/derive_macros/src/nbt/de.rs +++ b/src/lib/derive_macros/src/nbt/de.rs @@ -69,6 +69,7 @@ pub fn derive(input: TokenStream) -> TokenStream { NbtFieldAttribute::Skip => { skip = true; } + _ => {} } } diff --git a/src/lib/derive_macros/src/nbt/helpers.rs b/src/lib/derive_macros/src/nbt/helpers.rs index 14eb2777..f9a7794d 100644 --- a/src/lib/derive_macros/src/nbt/helpers.rs +++ b/src/lib/derive_macros/src/nbt/helpers.rs @@ -1,11 +1,85 @@ use crate::helpers::is_field_type_optional; -use syn::{Field, LitStr, Meta}; +use syn::{Variant, Field, LitStr, LitInt, Meta, DeriveInput}; + +#[derive(Debug, Clone)] +pub enum Cases { + Normal, + LowerCase, + UpperCase, + SnakeCase, + CamelCase, +} + +impl Cases { + pub fn transform(&self, str: impl Into) -> String { + let str = str.into(); + match self { + Self::Normal => str, + Self::UpperCase => str.to_uppercase(), + Self::LowerCase => str.to_lowercase(), + Self::SnakeCase => { + let mut snake_case = String::with_capacity(str.len()); + for (i, c) in str.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + snake_case.push('_'); + } + snake_case.extend(c.to_lowercase()); + } else { + snake_case.push(c); + } + } + snake_case + }, + Self::CamelCase => { + let mut camel_case = String::with_capacity(str.len()); + let mut next_word = false; + for c in str.chars() { + if c == '_' { + next_word = true; + } else if next_word { + camel_case.extend(c.to_uppercase()); + next_word = false; + } else { + camel_case.push(c); + } + } + camel_case + }, + } + } +} + +impl From for Cases { + fn from(value: String) -> Self { + match value.as_str() { + "default" => Self::Normal, + "lower_case" => Self::LowerCase, + "upper_case" => Self::UpperCase, + "snake_case" => Self::SnakeCase, + "camel_case" => Self::CamelCase, + _ => unimplemented!(), + } + } +} /// Enum representing possible attributes that can be present on a field for serialization/deserialization. #[derive(Debug)] pub enum NbtFieldAttribute { /// Represents the `rename` attribute, e.g., `#[nbt(rename = "new_name")]`. Rename { new_name: String }, + /// Rename all fields or tagged enum variants to match a case. + RenameAll { case: Cases }, + /// For enums only. + Tag { tag: String }, + /// For enums only. + Content { content: String }, + /// Changes the tag type used in seralization. + TagType { tag: u8 }, + /// Flatten the contents of this field into the container it is defined in. + Flatten, + /// Field will be skip if the condition is true. + SkipIf { condition: String }, /// If the field should be completely skipped, and use field's Default method. Skip, /// If the field is optional or not @@ -13,6 +87,123 @@ pub enum NbtFieldAttribute { } impl NbtFieldAttribute { + pub fn from_input(input: &DeriveInput) -> Vec { + let mut attributes = Vec::new(); + + for attr in &input.attrs { + if !attr.path().is_ident("nbt") { + continue; + } + + let meta = &attr.meta; + let Meta::List(list) = meta else { + continue; + }; + + list.parse_nested_meta(|nested_meta| { + let name = nested_meta + .path + .get_ident() + .expect("Expected an identifier"); + + match name.to_string().as_str() { + "tag" => { + let tag = nested_meta + .value() + .expect("Expected tag to have a value"); + let tag = tag + .parse::() + .expect("Expected tag to be a string"); + attributes.push(NbtFieldAttribute::Tag { + tag: tag.value(), + }); + } + "tag_type" => { + let tag = nested_meta + .value() + .expect("Expected tag to have a value"); + let tag = tag + .parse::() + .expect("Expected tag to be a string"); + attributes.push(NbtFieldAttribute::TagType { + tag: tag.base10_parse::().expect("Not a valid u8"), + }); + } + "content" => { + let content = nested_meta + .value() + .expect("Expected contenf to have a value"); + let content = content + .parse::() + .expect("Expected content to be a string"); + attributes.push(NbtFieldAttribute::Content { + content: content.value(), + }); + } + "rename_all" => { + let case = nested_meta + .value() + .expect("Expected case to have a value"); + let case: Cases = case + .parse::() + .expect("Expected case to be a string").value().into(); + attributes.push(NbtFieldAttribute::RenameAll { + case + }); + } + _ => {} + } + + Ok(()) + }) + .unwrap_or_else(|_| println!("[WARN] Failed to parse nested meta parsing input attributes")); + } + + attributes + } + + pub fn from_variant(variant: &Variant) -> Vec { + let mut attributes = Vec::new(); + + for attr in &variant.attrs { + if !attr.path().is_ident("nbt") { + continue; + } + + let meta = &attr.meta; + let Meta::List(list) = meta else { + continue; + }; + + list.parse_nested_meta(|nested_meta| { + let name = nested_meta + .path + .get_ident() + .expect("Expected an identifier"); + + match name.to_string().as_str() { + "rename" => { + let rename = nested_meta + .value() + .expect("Expected rename to have a value"); + let rename = rename + .parse::() + .expect("Expected rename to be a string"); + attributes.push(NbtFieldAttribute::Rename { + new_name: rename.value(), + }); + } + _ => panic!("Unknown attribute: {}", name), + } + + Ok(()) + }) + .expect("Failed to parse nested meta"); + } + + attributes + } + pub fn from_field(field: &Field) -> Vec { let mut attributes = Vec::new(); @@ -44,9 +235,23 @@ impl NbtFieldAttribute { new_name: rename.value(), }); } + "skip_if" => { + let skip_if = nested_meta + .value() + .expect("Expected skip_if to have a value"); + let skip_if = skip_if + .parse::() + .expect("Expected skip_if to be a string"); + attributes.push(NbtFieldAttribute::SkipIf { + condition: skip_if.value(), + }); + } "skip" => { attributes.push(NbtFieldAttribute::Skip); } + "flatten" => { + attributes.push(NbtFieldAttribute::Flatten); + } _ => panic!("Unknown attribute: {}", name), } diff --git a/src/lib/derive_macros/src/nbt/ser.rs b/src/lib/derive_macros/src/nbt/ser.rs index a3321150..72bd4419 100644 --- a/src/lib/derive_macros/src/nbt/ser.rs +++ b/src/lib/derive_macros/src/nbt/ser.rs @@ -1,14 +1,17 @@ -use crate::nbt::helpers::NbtFieldAttribute; +use crate::nbt::helpers::{NbtFieldAttribute, Cases}; use proc_macro::TokenStream; use proc_macro2::Span; use quote::quote; use syn::spanned::Spanned; -use syn::{Data, Fields}; +use syn::{Data, Fields, Expr, LitStr}; pub fn derive(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); let name = &input.ident; let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let input_attributes = NbtFieldAttribute::from_input(&input); + + let mut tag_type = 10u8; let serialize_impl = match &input.data { Data::Struct(data_struct) => { @@ -18,6 +21,19 @@ pub fn derive(input: TokenStream) -> TokenStream { Fields::Unit => panic!("Unit structs are not supported!"), }; + let mut variant_case: Cases = Cases::Normal; + + for attr in &input_attributes { + match attr { + NbtFieldAttribute::RenameAll { case } => { + variant_case = case.clone(); + }, + NbtFieldAttribute::TagType { tag } => { tag_type = *tag; }, + _ => {} + } + } + + let fields = fields.iter().enumerate().map(|(i, field)| { let ident = format!("_{}", i); let ident = syn::Ident::new(&ident, field.span()); @@ -30,6 +46,8 @@ pub fn derive(input: TokenStream) -> TokenStream { let attributes = NbtFieldAttribute::from_field(field); let mut skip = false; + let mut skip_if: Option = None; + let mut flatten = false; for attr in attributes { match attr { @@ -39,16 +57,43 @@ pub fn derive(input: TokenStream) -> TokenStream { NbtFieldAttribute::Skip => { skip = true; } + NbtFieldAttribute::Flatten => { + flatten = true; + } + NbtFieldAttribute::SkipIf { condition } => { + if skip { + return quote! {}; + } + + let condition = syn::parse_str::(&condition).unwrap(); + skip_if = Some(condition); + } _ => {} } } + serialize_name = variant_case.transform(serialize_name); + if skip { return quote! {}; } - quote! { - <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(&self.#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#serialize_name)); + if flatten { + return quote! { + <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(&self.#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::Flatten); + }; + } + + if let Some(condition) = skip_if { + quote! { + if !#condition (&self.#ident) { + <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(&self.#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#serialize_name)); + } + } + } else { + quote! { + <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(&self.#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#serialize_name)); + } } }); @@ -58,7 +103,40 @@ pub fn derive(input: TokenStream) -> TokenStream { } Data::Enum(data_enum) => { let variants = data_enum.variants.iter().map(|variant| { - let variant_name = &variant.ident; + let variant_ident = &variant.ident; + let mut variant_name = variant_ident.to_string(); + + let mut variant_case: Cases = Cases::Normal; // will only be used if tagged + let mut tagged: Option = None; + let mut untagged = false; + let mut variant_content = LitStr::new("content", Span::call_site()); // will only be used if tagged + + for attr in &input_attributes { + match attr { + NbtFieldAttribute::RenameAll { case } => { + variant_case = case.clone(); + }, + NbtFieldAttribute::Tag { tag } => { + tagged = Some(LitStr::new(tag.as_str(), Span::call_site())); + if tag.as_str() == "untagged" { + untagged = true; + } + } + NbtFieldAttribute::Content { content } => { + variant_content = LitStr::new(content.as_str(), Span::call_site()); + } + NbtFieldAttribute::TagType { tag } => { tag_type = *tag; }, + _ => {} + } + } + + for attr in NbtFieldAttribute::from_variant(variant) { + if let NbtFieldAttribute::Rename { new_name } = attr { + variant_name = new_name.clone(); + } + } + + let tag_name = variant_case.transform(variant_name); let serialize_fields = match &variant.fields { Fields::Named(fields_named) => { @@ -96,9 +174,23 @@ pub fn derive(input: TokenStream) -> TokenStream { let field_idents = fields_named.named.iter().map(|f| &f.ident); + let fields = quote! { #(#fields)* }; + let tagged = if let Some(tag) = tagged { + if untagged { fields } + else { + quote! { + <&'_ str as ::ferrumc_nbt::NBTSerializable>::serialize(&#tag_name, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#tag)); + ::serialize(&10, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + <&'_ str as ::ferrumc_nbt::NBTSerializable>::serialize(&#variant_content, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + #fields + ::serialize(&0u8, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + } + } + } else { fields }; + quote! { { #(#field_idents),* } => { - #(#fields)* + #tagged } } } @@ -107,26 +199,64 @@ pub fn derive(input: TokenStream) -> TokenStream { let ident = syn::Ident::new(&format!("_{}", i), field.span()); let ty = &field.ty; - quote! { - <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + if !untagged && tagged.is_some() { + if fields_unnamed.unnamed.len() == 1 { + quote! { + <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#variant_content)); + } + } else { + quote! { unimplemented!(); } + } + } else { + quote! { + <#ty as ::ferrumc_nbt::NBTSerializable>::serialize(#ident, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + } } }); let idents = (0..fields_unnamed.unnamed.len()).map(|i| syn::Ident::new(&format!("_{}", i), Span::call_site())); + let fields = quote! { #(#fields)* }; + let tagged = if let Some(tag) = tagged { + if untagged { fields } + else { + quote! { + <&'_ str as ::ferrumc_nbt::NBTSerializable>::serialize(&#tag_name, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#tag)); + #fields + } + } + } else { fields }; + quote! { (#(#idents),*) => { - #(#fields)* + #tagged } } } - Fields::Unit => quote! { - => {} - }, + Fields::Unit => match tagged { + Some(tag) => { + if untagged { + quote! { + => { + <&'_ str as ::ferrumc_nbt::NBTSerializable>::serialize(&#tag_name, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + } + } + } else { + quote! { + => { + <&'_ str as ::ferrumc_nbt::NBTSerializable>::serialize(&#tag_name, writer, &::ferrumc_nbt::NBTSerializeOptions::WithHeader(#tag)); + } + } + } + }, + None => quote! { + => {} + }, + } }; quote! { - Self::#variant_name #serialize_fields + Self::#variant_ident #serialize_fields } }); @@ -151,15 +281,18 @@ pub fn derive(input: TokenStream) -> TokenStream { ::serialize(&Self::id(), writer, &::ferrumc_nbt::NBTSerializeOptions::None); } ::ferrumc_nbt::NBTSerializeOptions::None => {} + ::ferrumc_nbt::NBTSerializeOptions::Flatten => {} } #serialize_impl - ::serialize(&0u8, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + if options != &::ferrumc_nbt::NBTSerializeOptions::Flatten && Self::id() == 10 { + ::serialize(&0u8, writer, &::ferrumc_nbt::NBTSerializeOptions::None); + } } fn id() -> u8 { - 10 + #tag_type } } diff --git a/src/lib/net/Cargo.toml b/src/lib/net/Cargo.toml index ffbb1238..5e493007 100644 --- a/src/lib/net/Cargo.toml +++ b/src/lib/net/Cargo.toml @@ -14,6 +14,7 @@ ferrumc-ecs = { workspace = true } ferrumc-events = { workspace = true } ferrumc-nbt = { workspace = true } ferrumc-core = { workspace = true } +ferrumc-text = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } socket2 = { workspace = true } diff --git a/src/lib/net/src/connection.rs b/src/lib/net/src/connection.rs index c5a2453b..5f0bc6fb 100644 --- a/src/lib/net/src/connection.rs +++ b/src/lib/net/src/connection.rs @@ -1,12 +1,33 @@ use crate::packets::incoming::packet_skeleton::PacketSkeleton; +use crate::utils::state::terminate_connection; use crate::{handle_packet, NetResult, ServerState}; use ferrumc_net_codec::encode::NetEncode; use ferrumc_net_codec::encode::NetEncodeOpts; use std::sync::Arc; +use std::time::Duration; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::TcpStream; +use tokio::time::timeout; use tracing::{debug, debug_span, trace, warn, Instrument}; +#[derive(Debug)] +pub struct ConnectionControl { + pub should_disconnect: bool, +} + +impl ConnectionControl { + pub fn new() -> Self { + Self { + should_disconnect: false, + } + } +} + +impl Default for ConnectionControl { + fn default() -> Self { + Self::new() + } +} #[derive(Clone)] pub enum ConnectionState { Handshaking, @@ -83,12 +104,40 @@ pub async fn handle_connection(state: Arc, tcp_stream: TcpStream) - .with(StreamWriter::new(writer))? .with(ConnectionState::Handshaking)? .with(CompressionStatus::new())? + .with(ConnectionControl::new())? .build(); 'recv: loop { let compressed = state.universe.get::(entity)?.enabled; - let Ok(mut packet_skele) = PacketSkeleton::new(&mut reader, compressed).await else { - trace!("Failed to read packet. Possibly connection closed."); + let should_disconnect = state + .universe + .get::(entity)? + .should_disconnect; + + if should_disconnect { + debug!( + "should_disconnect is true for entity: {}, breaking out of connection loop.", + entity + ); + break 'recv; + } + + let read_timeout = Duration::from_secs(2); + let packet_task = timeout(read_timeout, PacketSkeleton::new(&mut reader, compressed)).await; + + if let Err(err) = packet_task { + trace!( + "failed to read packet within {:?} for entity {:?}, err: {:?} + continuing to next iteration", + read_timeout, + entity, + err + ); + continue; + } + + let Ok(Ok(mut packet_skele)) = packet_task else { + trace!("Failed to read packet. Possibly connection closed. Breaking out of connection loop"); break 'recv; }; @@ -105,12 +154,19 @@ pub async fn handle_connection(state: Arc, tcp_stream: TcpStream) - &mut packet_skele.data, Arc::clone(&state), ) - .await - .instrument(debug_span!("eid", %entity)) - .inner() + .await + .instrument(debug_span!("eid", %entity)) + .inner() { - warn!("Failed to handle packet: {:?}. packet_id: {:02X}; conn_state: {}", e, packet_skele.id, conn_state.as_str()); + warn!( + "Failed to handle packet: {:?}. packet_id: {:02X}; conn_state: {}", + e, + packet_skele.id, + conn_state.as_str() + ); // Kick the player (when implemented). + terminate_connection(state.clone(), entity, "Failed to handle packet".to_string()) + .await?; break 'recv; }; } @@ -119,8 +175,6 @@ pub async fn handle_connection(state: Arc, tcp_stream: TcpStream) - // Remove all components from the entity - drop(reader); - // Wait until anything that might be using the entity is done if let Err(e) = remove_all_components_blocking(state.clone(), entity).await { warn!("Failed to remove all components from entity: {:?}", e); @@ -133,9 +187,8 @@ pub async fn handle_connection(state: Arc, tcp_stream: TcpStream) - /// Since parking_lot is single-threaded, we use spawn_blocking to remove all components from the entity asynchronously (on another thread). async fn remove_all_components_blocking(state: Arc, entity: usize) -> NetResult<()> { - let res = tokio::task::spawn_blocking(move || { - state.universe.remove_all_components(entity) - }).await?; + let res = + tokio::task::spawn_blocking(move || state.universe.remove_all_components(entity)).await?; Ok(res?) -} \ No newline at end of file +} diff --git a/src/lib/net/src/packets/incoming/keep_alive.rs b/src/lib/net/src/packets/incoming/keep_alive.rs index a68c3e56..2aa6049a 100644 --- a/src/lib/net/src/packets/incoming/keep_alive.rs +++ b/src/lib/net/src/packets/incoming/keep_alive.rs @@ -1,5 +1,6 @@ -use crate::packets::outgoing::keep_alive::KeepAlive; +use crate::packets::outgoing::keep_alive::OutgoingKeepAlivePacket; use crate::packets::IncomingPacket; +use crate::utils::state::terminate_connection; use crate::{NetResult, ServerState}; use ferrumc_macros::{packet, NetDecode}; use std::sync::Arc; @@ -8,20 +9,25 @@ use tracing::debug; #[derive(NetDecode)] #[packet(packet_id = 0x18, state = "play")] pub struct IncomingKeepAlivePacket { - pub id: i64, + pub timestamp: i64, } impl IncomingPacket for IncomingKeepAlivePacket { async fn handle(self, conn_id: usize, state: Arc) -> NetResult<()> { - let mut last_keep_alive = state.universe.get_mut::(conn_id)?; - if self.id != last_keep_alive.id { + let last_sent_keep_alive = state.universe.get::(conn_id)?; + if self.timestamp != last_sent_keep_alive.timestamp { debug!( "Invalid keep alive packet received from {:?} with id {:?} (expected {:?})", - conn_id, self.id, last_keep_alive.id + conn_id, self.timestamp, last_sent_keep_alive.timestamp ); - // TODO Kick player + if let Err(e) = + terminate_connection(state, conn_id, "Invalid keep alive packet".to_string()).await + { + debug!("Error terminating connection: {:?}", e); + } } else { - *last_keep_alive = KeepAlive::from(self.id); + let mut last_rec_keep_alive = state.universe.get_mut::(conn_id)?; + *last_rec_keep_alive = self; } Ok(()) diff --git a/src/lib/net/src/packets/outgoing/disconnect.rs b/src/lib/net/src/packets/outgoing/disconnect.rs new file mode 100644 index 00000000..2b7dfb0a --- /dev/null +++ b/src/lib/net/src/packets/outgoing/disconnect.rs @@ -0,0 +1,27 @@ +use ferrumc_macros::{packet, NetEncode}; +use ferrumc_text::{ComponentBuilder, TextComponent}; +use std::io::Write; + +#[derive(NetEncode)] +#[packet(packet_id = 0x1D)] +pub struct DisconnectPacket { + pub reason: TextComponent, +} + +impl DisconnectPacket { + pub fn new(reason: TextComponent) -> Self { + Self { reason } + } + pub fn from_string(reason: String) -> Self { + let reason = ComponentBuilder::text(reason); + Self { + reason: reason.build(), + } + } +} + +impl Default for DisconnectPacket { + fn default() -> Self { + Self::from_string("FERRUMC-DISCONNECTED".to_string()) + } +} diff --git a/src/lib/net/src/packets/outgoing/keep_alive.rs b/src/lib/net/src/packets/outgoing/keep_alive.rs index 23a36b77..583295a9 100644 --- a/src/lib/net/src/packets/outgoing/keep_alive.rs +++ b/src/lib/net/src/packets/outgoing/keep_alive.rs @@ -1,26 +1,13 @@ use ferrumc_macros::{packet, NetEncode}; use std::io::Write; -#[derive(Debug, NetEncode)] -pub struct KeepAlive { - pub id: i64, -} - -mod adapters { - impl From for super::KeepAlive { - fn from(id: i64) -> Self { - Self { id } - } - } -} - -#[derive(NetEncode)] +#[derive(NetEncode, Clone)] #[packet(packet_id = 0x26)] -pub struct KeepAlivePacket { - pub id: KeepAlive, +pub struct OutgoingKeepAlivePacket { + pub timestamp: i64, } -impl Default for KeepAlivePacket { +impl Default for OutgoingKeepAlivePacket { fn default() -> Self { let current_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -30,10 +17,8 @@ impl Default for KeepAlivePacket { } } -impl KeepAlivePacket { - pub fn new(id: i64) -> Self { - Self { - id: KeepAlive::from(id), - } +impl OutgoingKeepAlivePacket { + pub fn new(timestamp: i64) -> Self { + Self { timestamp } } } diff --git a/src/lib/net/src/packets/outgoing/mod.rs b/src/lib/net/src/packets/outgoing/mod.rs index c2050277..a6d14105 100644 --- a/src/lib/net/src/packets/outgoing/mod.rs +++ b/src/lib/net/src/packets/outgoing/mod.rs @@ -9,4 +9,5 @@ pub mod set_default_spawn_position; pub mod synchronize_player_position; pub mod keep_alive; pub mod game_event; -pub mod update_time; \ No newline at end of file +pub mod update_time; +pub mod disconnect; \ No newline at end of file diff --git a/src/lib/net/src/utils/mod.rs b/src/lib/net/src/utils/mod.rs index 927e3b53..4daafa52 100644 --- a/src/lib/net/src/utils/mod.rs +++ b/src/lib/net/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod ecs_helpers; -pub mod broadcast; \ No newline at end of file +pub mod broadcast; +pub mod state; \ No newline at end of file diff --git a/src/lib/net/src/utils/state.rs b/src/lib/net/src/utils/state.rs new file mode 100644 index 00000000..bdab35f0 --- /dev/null +++ b/src/lib/net/src/utils/state.rs @@ -0,0 +1,64 @@ +use crate::{ + connection::{ConnectionControl, StreamWriter}, + errors::NetError, + packets::outgoing::disconnect::DisconnectPacket, + GlobalState, NetResult, +}; +use ferrumc_net_codec::encode::NetEncodeOpts; +use tracing::{trace, warn}; + +use super::ecs_helpers::EntityExt; + +// used codium for this function comment, very useful + +/// Terminates the connection of an entity with the given `conn_id`. +/// +/// Sends a disconnect packet with the given `reason` to the client, and marks the connection as +/// terminated. This will cause the connection to be dropped on the next tick of the +/// `ConnectionSystem`. +/// +/// # Errors +/// +/// Returns an error if the stream writer or connection control component cannot be accessed for +/// the given `conn_id`. +pub async fn terminate_connection( + state: GlobalState, + conn_id: usize, + reason: String, +) -> NetResult<()> { + let mut writer = match conn_id.get_mut::(state.clone()) { + Ok(writer) => writer, + Err(e) => { + warn!("Failed to get stream writer for entity {}: {}", conn_id, e); + return Err(NetError::ECSError(e)); + } + }; + + if let Err(e) = writer + .send_packet(&DisconnectPacket::from_string(reason), &NetEncodeOpts::WithLength) + .await + { + warn!( + "Failed to send disconnect packet to entity {}: {}", + conn_id, e + ); + return Err(e); + } + + match conn_id.get_mut::(state.clone()) { + Ok(mut control) => { + control.should_disconnect = true; + + trace!("Set should_disconnect to true for entity {}", conn_id); + } + Err(e) => { + warn!( + "Failed to get connection control for entity {}: {}", + conn_id, e + ); + return Err(NetError::ECSError(e)); + } + } + + Ok(()) +} diff --git a/src/lib/text/.gitignore b/src/lib/text/.gitignore new file mode 100644 index 00000000..359ba4e6 --- /dev/null +++ b/src/lib/text/.gitignore @@ -0,0 +1 @@ +foo.nbt diff --git a/src/lib/text/Cargo.toml b/src/lib/text/Cargo.toml new file mode 100644 index 00000000..8d1e8366 --- /dev/null +++ b/src/lib/text/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ferrumc-text" +version = "0.1.0" +edition = "2021" + +[dependencies] +ferrumc-net-codec = { workspace = true } +ferrumc-nbt = { workspace = true } +ferrumc-macros = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +paste = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +ferrumc-config = { workspace = true } +flate2 = { workspace = true } + +valence_text = "=0.2.0-alpha.1" + +[lints] +workspace = true diff --git a/src/lib/text/src/builders.rs b/src/lib/text/src/builders.rs new file mode 100644 index 00000000..829b85f1 --- /dev/null +++ b/src/lib/text/src/builders.rs @@ -0,0 +1,113 @@ +use crate::*; +use paste::paste; + +/// Build a component (text, translate, keybind). +/// +pub struct ComponentBuilder { + _private: () +} + +impl ComponentBuilder { + #[inline] + pub fn text>(value: S) -> TextComponentBuilder { + TextComponentBuilder::new(value) + } + + #[inline] + pub fn keybind>(keybind: S) -> TextComponent { + TextComponent { + content: TextContent::Keybind { + keybind: keybind.into() + }, + ..Default::default() + } + } + + #[inline] + pub fn translate>(translate: S, with: Vec) -> TextComponent { + TextComponent { + content: TextContent::Translate { + translate: translate.into(), + with, + }, + ..Default::default() + } + } + + #[inline] + pub fn space() -> TextComponent { + " ".into() + } +} + +/// A builder to build a TextComponent of type text. +/// +/// ```rust +/// # use ferrumc_text::*; +/// let _ = ComponentBuilder::text("Hello,") +/// .color(NamedColor::Red) +/// .space() +/// .extra(ComponentBuilder::text("World!")) +/// .build(); +/// ``` +#[derive(Default)] +pub struct TextComponentBuilder { + pub(crate) text: String, + pub(crate) color: Option, + pub(crate) font: Option, + pub(crate) bold: Option, + pub(crate) italic: Option, + pub(crate) underlined: Option, + pub(crate) strikethrough: Option, + pub(crate) obfuscated: Option, + pub(crate) insertion: Option, + pub(crate) click_event: Option, + pub(crate) hover_event: Option, + pub(crate) extra: Vec, +} + +impl TextComponentBuilder { + pub fn new>(value: S) -> TextComponentBuilder { + TextComponentBuilder { + text: value.into(), + ..Default::default() + } + } + + make_setters!((Color, color), (Font, font), (String, insertion), (ClickEvent, click_event), (HoverEvent, hover_event)); + make_bool_setters!(bold, italic, underlined, strikethrough, obfuscated); + + pub fn space(self) -> Self { + self.extra(ComponentBuilder::space()) + } + + pub fn extra(mut self, component: impl Into) -> Self { + self.extra.push(component.into()); + self + } + + pub fn build(self) -> TextComponent { + TextComponent { + content: TextContent::Text { + text: self.text, + }, + color: self.color, + font: self.font, + bold: self.bold, + italic: self.italic, + underlined: self.underlined, + strikethrough: self.strikethrough, + obfuscated: self.obfuscated, + insertion: self.insertion, + click_event: self.click_event, + hover_event: self.hover_event, + extra: self.extra, + } + } +} + +impl From for TextComponent { + fn from(value: TextComponentBuilder) -> Self { + value.build() + } +} diff --git a/src/lib/text/src/impl.rs b/src/lib/text/src/impl.rs new file mode 100644 index 00000000..5e5f14be --- /dev/null +++ b/src/lib/text/src/impl.rs @@ -0,0 +1,117 @@ +use crate::*; +use ferrumc_net_codec::encode::{ + NetEncode, NetEncodeOpts, errors::NetEncodeError +}; +use ferrumc_nbt::{NBTSerializable, NBTSerializeOptions}; +use std::io::Write; +use std::marker::Unpin; +use tokio::io::AsyncWriteExt; +use std::fmt; +use std::ops::Add; +use std::str::FromStr; +use paste::paste; + +impl From for TextComponent { + fn from(value: String) -> Self { + Self { + content: TextContent::Text { + text: value, + }, + ..Default::default() + } + } +} + +impl From<&str> for TextComponent { + fn from(value: &str) -> Self { + Self { + content: TextContent::Text { + text: value.into(), + }, + ..Default::default() + } + } +} + +impl Add for TextComponent +where + T: Into, +{ + type Output = Self; + + fn add(mut self, other: T) -> Self { + self.extra.push(other.into()); + self + } +} + +impl Add for TextComponentBuilder +where + T: Into, +{ + type Output = Self; + + fn add(mut self, other: T) -> Self { + self.extra.push(other.into()); + self + } +} + +impl FromStr for TextComponent { + type Err = serde_json::error::Error; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + Ok(Self::default()) + } else { + serde_json::from_str(s) + } + } +} + +impl From for String { + fn from(value: TextComponent) -> String { + serde_json::to_string(&value).unwrap() + } +} + +impl fmt::Display for TextComponent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Ok(value) = serde_json::to_string(self) { + write!(f, "{}", value) + } else { + write!(f, "Couldn't convert to String") + } + } +} + +impl TextComponent { + make_setters!((Color, color), (Font, font), (String, insertion), (ClickEvent, click_event), (HoverEvent, hover_event)); + make_bool_setters!(bold, italic, underlined, strikethrough, obfuscated); + + pub fn serialize_nbt(&self) -> Vec { + let mut vec = Vec::new(); + NBTSerializable::serialize(self, &mut vec, &NBTSerializeOptions::Network); + vec + } +} + +impl NetEncode for TextComponent { + fn encode(&self, writer: &mut W, _: &NetEncodeOpts) -> Result<(), NetEncodeError> { + writer.write_all(&self.serialize_nbt()[..])?; + Ok(()) + } + + async fn encode_async(&self, writer: &mut W, _: &NetEncodeOpts) -> Result<(), NetEncodeError>{ + writer.write_all(&self.serialize_nbt()[..]).await?; + Ok(()) + } +} + +impl Default for TextContent { + fn default() -> Self { + TextContent::Text { + text: String::new(), + } + } +} diff --git a/src/lib/text/src/lib.rs b/src/lib/text/src/lib.rs new file mode 100644 index 00000000..0434b8bc --- /dev/null +++ b/src/lib/text/src/lib.rs @@ -0,0 +1,102 @@ +use ferrumc_macros::NBTSerialize; +use serde::{Serialize, Deserialize}; + +#[cfg(test)] +mod tests; + +mod utils; +mod builders; +mod r#impl; + +pub use builders::*; +pub use utils::*; + +pub type JsonTextComponent = String; + +/// A TextComponent that can be a Text, Translate or Keybind. +/// +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, Default, NBTSerialize)] +#[serde(rename_all = "camelCase")] +#[nbt(rename_all = "camel_case")] +pub struct TextComponent { + #[serde(flatten)] + #[nbt(flatten)] + /// The content field of this TextComponent. + /// + /// ```ignore + /// # use ferrumc_text::*; + /// TextContent::Text { text: "text".to_string() }; + /// TextContent::Translate { + /// translate: "translation".to_string(), + /// with: vec![], + /// }; + /// TextContent::Keybind { keybind: "key.jump".to_string() }; + /// ``` + pub content: TextContent, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The color field of this TextComponent. + pub color: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The font field of this TextComponent. + pub font: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The bold field of this TextComponent. + pub bold: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The italic field of this TextComponent. + pub italic: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The underlined field of this TextComponent. + pub underlined: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The strikethrough field of this TextComponent. + pub strikethrough: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The obfuscated field of this TextComponent. + pub obfuscated: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Text to be inserted into chat at the cursor when shift-clicked. + /// + /// Only used for messages in chat; has no effect in other locations at this time. + pub insertion: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Defines an event that occurs when this component is clicked. + /// + pub click_event: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Defines an event that occurs when this component is hovered over. + /// + pub hover_event: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[nbt(skip_if = "Vec::is_empty")] + /// The with field of this TextComponent. + pub extra: Vec, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, NBTSerialize)] +#[serde(untagged)] +pub enum TextContent { + Text { + text: String, + }, + Translate { + translate: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[nbt(skip_if = "Vec::is_empty")] + with: Vec, + }, + Keybind { + keybind: String, + }, +} diff --git a/src/lib/text/src/tests.rs b/src/lib/text/src/tests.rs new file mode 100644 index 00000000..c33baed7 --- /dev/null +++ b/src/lib/text/src/tests.rs @@ -0,0 +1,165 @@ +use crate::*; +use valence_text::{IntoText, Text}; + +fn bytes_to_readable_string(bytes: &[u8]) -> String { + bytes + .iter() + .map(|&byte| { + if byte.is_ascii_graphic() || byte == b' ' { + (byte as char).to_string() + } else { + format!("{:02X}", byte) + } + }) + .collect::>() + .join(" ") +} + +fn bytes_to_string(bytes: &[u8]) -> String { + bytes + .iter() + .map(|&byte| { + format!("{:02X}", byte) + }) + .collect::>() + .join(" ") +} + +#[test] +fn test_to_string() { + let component = TextComponent::from("This is a test!"); + assert_eq!( + component.to_string(), + "{\"text\":\"This is a test!\"}".to_string() + ); + let component = ComponentBuilder::text("This is a test!") + .color(NamedColor::Blue) + .build(); + assert_eq!( + component.to_string(), + "{\"text\":\"This is a test!\",\"color\":\"blue\"}".to_string() + ); + let component = ComponentBuilder::keybind("key.jump"); + assert_eq!( + component.to_string(), + "{\"keybind\":\"key.jump\"}".to_string() + ); + let component = + TextComponent::from("This is a test!") + + TextComponent::from(" extra!"); + assert_eq!( + component.to_string(), + "{\"text\":\"This is a test!\",\"extra\":[{\"text\":\" extra!\"}]}".to_string() + ); + let component = ComponentBuilder::text("This is a test!") + .hover_event(HoverEvent::ShowText(Box::new(TextComponent::from("boo")))) + .build(); + assert_eq!( + component.to_string(), + ("This is a test!".into_text() + .on_hover_show_text("boo")) + .to_string() + ); + let component = ComponentBuilder::text("This is a test!") + .underlined() + .hover_event(HoverEvent::ShowText(Box::new(TextComponent::from("boo")))) + .build(); + assert_eq!( + component.to_string(), + ("This is a test!".into_text() + .underlined() + .on_hover_show_text("boo")) + .to_string() + ); + let component = ComponentBuilder::text("This is a test!") + .underlined() + .bold() + .hover_event(HoverEvent::ShowText(Box::new(TextComponent::from("boo")))) + .build(); + assert_eq!( + component.to_string(), + ("This is a test!" + .underlined() + .bold() + .on_hover_show_text("boo")) + .to_string() + ); + let component = ComponentBuilder::keybind("key.jump"); + assert_eq!( + component.to_string(), + Text::keybind("key.jump").to_string() + ); + +} + +use std::io::{Cursor, Write}; +use ferrumc_macros::{NetEncode, packet}; +use ferrumc_net_codec::{ + encode::{NetEncode, NetEncodeOpts}, + decode::{NetDecode, NetDecodeOpts}, + net_types::var_int::VarInt +}; +use ferrumc_nbt::NBTSerializable; +use ferrumc_nbt::NBTSerializeOptions; +use std::fs::File; + +#[derive(NetEncode)] +#[packet(packet_id = 0x6C)] +struct TestPacket { + message: TextComponent, + overlay: bool, +} + +#[tokio::test] +#[ignore] +async fn test_serialize_to_nbt() { + let component = ComponentBuilder::translate("chat.type.text", vec![ + ComponentBuilder::text("GStudiosX") + .click_event(ClickEvent::SuggestCommand("/msg GStudiosX".to_string())) + .hover_event(HoverEvent::ShowEntity { + entity_type: "minecraft:player".to_string(), + id: uuid::Uuid::new_v4(), + name: Some("GStudiosX".to_string()), + }) + .color(NamedColor::Blue) + .build(), + ComponentBuilder::text("Hi") + .font("custom:test") + .extra(ComponentBuilder::keybind("key.jump")) + .build(), + ]); + //println!("{:#?}", component.color); + println!("{}", component); + println!("{}", bytes_to_readable_string(&component.serialize_nbt()[..])); + + println!("{}", component.serialize_nbt().len()); + + //println!("\n{}", bytes_to_readable_string(&component.content.serialize_as_network()[..])); + + let mut file = File::create("foo.nbt").unwrap(); + let mut bytes = Vec::new(); + NBTSerializable::serialize(&vec![component.clone()], &mut bytes, &NBTSerializeOptions::Network); + //file.write_all(&bytes).unwrap(); + println!("\n{}\n", bytes_to_readable_string(&bytes[..])); + file.write_all(&component.serialize_nbt()[..]).unwrap(); + + let mut cursor = Cursor::new(Vec::new()); + TestPacket::encode_async(&TestPacket { + message: TextComponentBuilder::new("test") + .color(NamedColor::Blue) + .build(), + overlay: false, + }, &mut cursor, &NetEncodeOpts::WithLength).await.unwrap(); + + println!("\n{}\n", bytes_to_string(&cursor.get_ref()[..])); + + cursor.set_position(0); + + let length = VarInt::decode(&mut cursor, &NetDecodeOpts::None).unwrap(); + let id = VarInt::decode(&mut cursor, &NetDecodeOpts::None).unwrap(); + + println!("{}\n", bytes_to_string(&component.serialize_nbt()[..])); + + println!("id: {}, length: {}, left: {}", id.val, length.val, length.val as u64 - cursor.position()); + println!("{}", bytes_to_readable_string(&cursor.get_ref()[cursor.position() as usize..])); +} diff --git a/src/lib/text/src/utils.rs b/src/lib/text/src/utils.rs new file mode 100644 index 00000000..92058166 --- /dev/null +++ b/src/lib/text/src/utils.rs @@ -0,0 +1,173 @@ +use crate::*; +use serde::{Serialize, Deserialize}; +use ferrumc_macros::NBTSerialize; + +#[macro_export] +macro_rules! make_bool_setters { + ($($field:ident),*) => { + paste! { + $( + pub fn $field(mut self) -> Self { + self.$field = Some(true); + self + } + + pub fn [](mut self) -> Self { + self.$field = Some(true); + self + } + + pub fn [](mut self) -> Self { + self.$field = None; + self + } + )* + } + } +} + +#[macro_export] +macro_rules! make_setters { + ($(($ty:ident, $field:ident)),*) => { + paste! { + $( + pub fn $field(mut self, $field: impl Into<$ty>) -> Self { + self.$field = Some($field.into()); + self + } + + pub fn [](mut self) -> Self { + self.$field = None; + self + } + )* + } + } +} + +// TODO: better api for custom colors +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, NBTSerialize)] +#[serde(untagged)] +#[nbt(tag_type = 8)] +pub enum Color { + Named(NamedColor), + Hex(String), +} + +impl From for Color { + fn from(value: NamedColor) -> Self { + Self::Named(value) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default, NBTSerialize)] +#[serde(rename_all(serialize = "snake_case"))] +#[nbt(tag_type = 8, tag = "untagged", rename_all = "snake_case")] +pub enum NamedColor { + Black, + DarkBlue, + DarkGreen, + DarkAqua, + DarkRed, + DarkPurple, + Gold, + Gray, + DarkGray, + Blue, + Green, + Aqua, + Red, + LightPurple, + Yellow, + #[default] + White, +} + +/// The font of the text component. +/// +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, NBTSerialize)] +#[nbt(tag_type = 8, tag = "untagged")] +pub enum Font { + /// The default font. + #[serde(rename = "minecraft:default")] + #[nbt(rename = "minecraft:default")] + Default, + /// Unicode font. + #[serde(rename = "minecraft:uniform")] + #[nbt(rename = "minecraft:uniform")] + Uniform, + /// Enchanting table font. + #[serde(rename = "minecraft:alt")] + #[nbt(rename = "minecraft:alt")] + Alt, + #[serde(untagged)] + Custom(String), +} + +impl From for Font { + fn from(value: String) -> Self { + Self::Custom(value) + } +} + +impl From<&str> for Font { + fn from(value: &str) -> Self { + Self::Custom(value.to_string()) + } +} + +/// The click event of the text component +/// +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, NBTSerialize)] +#[serde(tag = "action", content = "value", rename_all(serialize = "snake_case"))] +#[nbt(tag = "action", content = "value", rename_all = "snake_case")] +pub enum ClickEvent { + /// Opens an URL + /// + OpenUrl(String), + /// Sends a chat command. Doesn't actually have to be a command, can be a normal chat message. + /// + RunCommand(String), + /// Replaces the contents of the chat box with the text, not necessarily command. + /// + SuggestCommand(String), + /// Only usable within written books. Changes the page of the book. Indexing + /// starts at 1. + ChangePage(i32), + /// Copies the given text to the client's clipboard when clicked. + /// + CopyToClipboard(String), +} + +/// The hover event of the text component +/// +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, NBTSerialize)] +#[serde(tag = "action", content = "contents", rename_all(serialize = "snake_case"))] +#[nbt(tag = "action", content = "contents", rename_all = "snake_case")] +pub enum HoverEvent { + ShowText(Box), + ShowItem { + /// The identifier of the item. + /// + id: String, + /// The number of items in the item stack. + /// + count: u32, + /// The item's sNBT as you would use in /give. + /// + tag: String, + }, + ShowEntity { + #[serde(rename = "type", default)] + #[nbt(rename = "type")] + /// Identifier of entities type. + /// + entity_type: String, + /// The entities uuid. + /// + id: uuid::Uuid, + /// The entities custom name. + /// + name: Option, + }, +}