diff --git a/crates/pumpkin-protocol/src/java/client/play/set_time.rs b/crates/pumpkin-protocol/src/java/client/play/set_time.rs index 4f1e2a2fb..3d564b3d2 100644 --- a/crates/pumpkin-protocol/src/java/client/play/set_time.rs +++ b/crates/pumpkin-protocol/src/java/client/play/set_time.rs @@ -26,6 +26,20 @@ impl CUpdateTime { clock_updates: vec![(overworld_id, day_time, partial_tick, rate)], } } + + #[must_use] + pub fn new_clock( + game_time: i64, + clock_id: i32, + total_ticks: i64, + partial_tick: f32, + rate: f32, + ) -> Self { + Self { + game_time, + clock_updates: vec![(clock_id, total_ticks, partial_tick, rate)], + } + } } impl ClientPacket for CUpdateTime { diff --git a/crates/pumpkin-util/src/text/color.rs b/crates/pumpkin-util/src/text/color.rs index d1cdf31dd..02222ad03 100644 --- a/crates/pumpkin-util/src/text/color.rs +++ b/crates/pumpkin-util/src/text/color.rs @@ -329,6 +329,35 @@ impl NamedColor { Self::White => 'f', } } + + /// Returns the Minecraft string identifier of this named color (e.g. `"black"`, `"dark_blue"`). + #[must_use] + pub const fn name(&self) -> &'static str { + match self { + Self::Black => "black", + Self::DarkBlue => "dark_blue", + Self::DarkGreen => "dark_green", + Self::DarkAqua => "dark_aqua", + Self::DarkRed => "dark_red", + Self::DarkPurple => "dark_purple", + Self::Gold => "gold", + Self::Gray => "gray", + Self::DarkGray => "dark_gray", + Self::Blue => "blue", + Self::Green => "green", + Self::Aqua => "aqua", + Self::Red => "red", + Self::LightPurple => "light_purple", + Self::Yellow => "yellow", + Self::White => "white", + } + } +} + +impl std::fmt::Display for NamedColor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } } impl TryFrom<&str> for NamedColor { diff --git a/crates/pumpkin/src/command/args/hex_color.rs b/crates/pumpkin/src/command/args/hex_color.rs new file mode 100644 index 000000000..0db555b51 --- /dev/null +++ b/crates/pumpkin/src/command/args/hex_color.rs @@ -0,0 +1,132 @@ +use pumpkin_data::translation; +use pumpkin_protocol::java::client::play::{ArgumentType, CommandSuggestion, SuggestionProviders}; +use pumpkin_util::text::TextComponent; + +use crate::command::CommandSender; +use crate::command::args::{ + Arg, ArgumentConsumer, ConsumeResult, ConsumeResultWithSyntax, DefaultNameArgConsumer, FindArg, + GetClientSideArgParser, SuggestResult, +}; +use crate::command::dispatcher::CommandError; +use crate::command::errors::error_types::CommandErrorType; +use crate::command::tree::RawArgs; +use crate::server::Server; + +pub const INVALID_HEX_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::ARGUMENT_HEXCOLOR_INVALID, + translation::java::ARGUMENT_HEXCOLOR_INVALID, +); + +pub struct HexColorArgumentConsumer; + +fn parse_hex_color(color_str: &str) -> Option { + match color_str.len() { + 3 => { + let r = u32::from_str_radix(&color_str[0..1], 16).ok()? * 17; + let g = u32::from_str_radix(&color_str[1..2], 16).ok()? * 17; + let b = u32::from_str_radix(&color_str[2..3], 16).ok()? * 17; + Some((r << 16) | (g << 8) | b) + } + 6 => { + let r = u32::from_str_radix(&color_str[0..2], 16).ok()?; + let g = u32::from_str_radix(&color_str[2..4], 16).ok()?; + let b = u32::from_str_radix(&color_str[4..6], 16).ok()?; + Some((r << 16) | (g << 8) | b) + } + _ => None, + } +} + +impl GetClientSideArgParser for HexColorArgumentConsumer { + fn get_client_side_parser(&self) -> ArgumentType { + ArgumentType::HexColor + } + + fn get_client_side_suggestion_type_override(&self) -> Option { + None + } +} + +impl ArgumentConsumer for HexColorArgumentConsumer { + fn consume<'a, 'b>( + &'a self, + _sender: &'a CommandSender, + _server: &'a Server, + args: &'b mut RawArgs<'a>, + ) -> ConsumeResult<'a> { + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); + + let result = s_opt.and_then(|s| parse_hex_color(s).map(Arg::HexColor)); + + Box::pin(async move { result }) + } + + fn consume_with_syntax<'a>( + &'a self, + _sender: &'a CommandSender, + _server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { Ok(None) }); + }; + + let result = parse_hex_color(raw_arg.value) + .map(|color| Some(Arg::HexColor(color))) + .ok_or_else(|| { + INVALID_HEX_ERROR_TYPE.create_without_context(TextComponent::translate( + translation::java::ARGUMENT_HEXCOLOR_INVALID, + [TextComponent::text(raw_arg.value.to_string())], + )) + }); + + Box::pin(async move { result }) + } + + fn suggest<'a>( + &'a self, + _sender: &CommandSender, + _server: &'a Server, + _input: &'a str, + ) -> SuggestResult<'a> { + let suggestions = vec![ + CommandSuggestion::new("F00".to_string(), None), + CommandSuggestion::new("FF0000".to_string(), None), + ]; + Box::pin(async move { Ok(Some(suggestions)) }) + } +} + +impl DefaultNameArgConsumer for HexColorArgumentConsumer { + fn default_name(&self) -> &'static str { + "color" + } +} + +impl<'a> FindArg<'a> for HexColorArgumentConsumer { + type Data = u32; + + fn find_arg(args: &'a super::ConsumedArgs, name: &str) -> Result { + match args.get(name) { + Some(Arg::HexColor(color)) => Ok(*color), + _ => Err(CommandError::InvalidConsumption(Some(name.to_string()))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hex_color_works() { + assert_eq!(parse_hex_color("F00"), Some(0xFF0000)); + assert_eq!(parse_hex_color("0F0"), Some(0x00FF00)); + assert_eq!(parse_hex_color("00F"), Some(0x0000FF)); + assert_eq!(parse_hex_color("FF0000"), Some(0xFF0000)); + assert_eq!(parse_hex_color("123456"), Some(0x123456)); + assert_eq!(parse_hex_color("FFFFF"), None); + assert_eq!(parse_hex_color("F"), None); + assert_eq!(parse_hex_color("GGGGGG"), None); + } +} diff --git a/crates/pumpkin/src/command/args/mod.rs b/crates/pumpkin/src/command/args/mod.rs index 5ef21c39b..e0f50e6f6 100644 --- a/crates/pumpkin/src/command/args/mod.rs +++ b/crates/pumpkin/src/command/args/mod.rs @@ -41,6 +41,7 @@ pub mod entity; pub mod entity_anchor; pub mod gamemode; pub mod gameprofile; +pub mod hex_color; pub mod message; pub mod players; pub mod position_2d; @@ -54,6 +55,7 @@ pub mod slot; pub mod sound; pub mod sound_category; pub mod summonable_entities; +pub mod team_color; pub mod textcomponent; pub mod time; @@ -143,6 +145,8 @@ pub enum Arg<'a> { EntityAnchor(EntityAnchor), Slot(usize, String), Slots(&'static [usize], String), + TeamColor(pumpkin_util::text::color::NamedColor), + HexColor(u32), } /// see [`crate::command::tree::builder::argument`] and [`CommandTree::execute`]/[`crate::command::tree::builder::NonLeafNodeBuilder::execute`] diff --git a/crates/pumpkin/src/command/args/team_color.rs b/crates/pumpkin/src/command/args/team_color.rs new file mode 100644 index 000000000..194b9b17f --- /dev/null +++ b/crates/pumpkin/src/command/args/team_color.rs @@ -0,0 +1,130 @@ +use pumpkin_data::translation; +use pumpkin_protocol::java::client::play::{ArgumentType, CommandSuggestion, SuggestionProviders}; +use pumpkin_util::text::TextComponent; +use pumpkin_util::text::color::NamedColor; + +use crate::command::CommandSender; +use crate::command::args::{ + Arg, ArgumentConsumer, ConsumeResult, ConsumeResultWithSyntax, DefaultNameArgConsumer, FindArg, + GetClientSideArgParser, SuggestResult, +}; +use crate::command::dispatcher::CommandError; +use crate::command::errors::error_types::CommandErrorType; +use crate::command::tree::RawArgs; +use crate::server::Server; + +pub const INVALID_COLOR_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::ARGUMENT_COLOR_INVALID, + translation::java::ARGUMENT_COLOR_INVALID, +); + +const TEAM_COLORS: [&str; 16] = [ + "black", + "dark_blue", + "dark_green", + "dark_aqua", + "dark_red", + "dark_purple", + "gold", + "gray", + "dark_gray", + "blue", + "green", + "aqua", + "red", + "light_purple", + "yellow", + "white", +]; + +pub struct TeamColorArgumentConsumer; + +impl GetClientSideArgParser for TeamColorArgumentConsumer { + fn get_client_side_parser(&self) -> ArgumentType { + ArgumentType::Color + } + + fn get_client_side_suggestion_type_override(&self) -> Option { + Some(SuggestionProviders::AskServer) + } +} + +impl ArgumentConsumer for TeamColorArgumentConsumer { + fn consume<'a, 'b>( + &'a self, + _sender: &'a CommandSender, + _server: &'a Server, + args: &'b mut RawArgs<'a>, + ) -> ConsumeResult<'a> { + let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); + + let result = s_opt.and_then(|s| NamedColor::try_from(s).ok().map(Arg::TeamColor)); + + Box::pin(async move { result }) + } + + fn consume_with_syntax<'a>( + &'a self, + _sender: &'a CommandSender, + _server: &'a Server, + args: &mut RawArgs<'a>, + ) -> ConsumeResultWithSyntax<'a> { + let Some(raw_arg) = args.pop() else { + return Box::pin(async { Ok(None) }); + }; + + let result = NamedColor::try_from(raw_arg.value) + .map(|color| Some(Arg::TeamColor(color))) + .map_err(|()| { + INVALID_COLOR_ERROR_TYPE.create_without_context(TextComponent::translate( + translation::java::ARGUMENT_COLOR_INVALID, + [TextComponent::text(raw_arg.value.to_string())], + )) + }); + + Box::pin(async move { result }) + } + + fn suggest<'a>( + &'a self, + _sender: &CommandSender, + _server: &'a Server, + input: &'a str, + ) -> SuggestResult<'a> { + let suggestions: Vec = TEAM_COLORS + .iter() + .filter(|color| color.starts_with(input)) + .map(|color| CommandSuggestion::new((*color).to_string(), None)) + .collect(); + Box::pin(async move { Ok(Some(suggestions)) }) + } +} + +impl DefaultNameArgConsumer for TeamColorArgumentConsumer { + fn default_name(&self) -> &'static str { + "color" + } +} + +impl<'a> FindArg<'a> for TeamColorArgumentConsumer { + type Data = NamedColor; + + fn find_arg(args: &'a super::ConsumedArgs, name: &str) -> Result { + match args.get(name) { + Some(Arg::TeamColor(color)) => Ok(*color), + _ => Err(CommandError::InvalidConsumption(Some(name.to_string()))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_team_color() { + assert_eq!(NamedColor::try_from("red"), Ok(NamedColor::Red)); + assert_eq!(NamedColor::try_from("dark_blue"), Ok(NamedColor::DarkBlue)); + assert_eq!(NamedColor::try_from("invalid"), Err(())); + } +} diff --git a/crates/pumpkin/src/command/args/time.rs b/crates/pumpkin/src/command/args/time.rs index 5a89c5152..8bf3d625f 100644 --- a/crates/pumpkin/src/command/args/time.rs +++ b/crates/pumpkin/src/command/args/time.rs @@ -11,11 +11,32 @@ use crate::command::{ }; use crate::server::Server; -pub struct TimeArgumentConsumer; +#[derive(Clone, Copy, Debug)] +pub struct TimeArgumentConsumer { + min: i32, +} + +impl Default for TimeArgumentConsumer { + fn default() -> Self { + Self::new() + } +} + +impl TimeArgumentConsumer { + #[must_use] + pub const fn new() -> Self { + Self { min: 0 } + } + + #[must_use] + pub const fn min(min: i32) -> Self { + Self { min } + } +} impl GetClientSideArgParser for TimeArgumentConsumer { fn get_client_side_parser(&self) -> ArgumentType { - ArgumentType::Time { min: 0 } + ArgumentType::Time { min: self.min } } fn get_client_side_suggestion_type_override(&self) -> Option { @@ -34,23 +55,23 @@ impl ArgumentConsumer for TimeArgumentConsumer { let result: Option> = s_opt.and_then(|s| { let (num_str, unit) = s - .find(|c: char| c.is_alphabetic()) + .find(|c: char| c.is_alphabetic() && c != '-') .map_or((s, "t"), |pos| (&s[..pos], &s[pos..])); - let number = num_str.parse::().ok()?; // Replaces .ok()? + let number = num_str.parse::().ok()?; - if number < 0.0 { - return None; - } - - let ticks = match unit { - "d" => number * 24000.0, - "s" => number * 20.0, - "t" => number, + let factor = match unit { + "d" => 24000.0, + "s" => 20.0, + "t" | "" => 1.0, _ => return None, }; - let ticks = ticks.round() as i32; + let ticks = (number * factor).round() as i32; + + if ticks < self.min { + return None; + } Some(Arg::Time(ticks)) }); diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index 04fb3d1aa..dfe7d1e5f 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -77,6 +77,7 @@ mod title; mod tps; mod transfer; mod trigger; +mod waypoint; mod weather; mod whitelist; mod worldborder; @@ -150,6 +151,7 @@ pub async fn default_dispatcher( ); dispatcher.register(spectate::init_command_tree(), "minecraft:command.spectate"); dispatcher.register(data::init_command_tree(), "minecraft:command.data"); + dispatcher.register(waypoint::init_command_tree(), "minecraft:command.waypoint"); // Three dispatcher.register(deop::init_command_tree(), "minecraft:command.deop"); dispatcher.register(kick::init_command_tree(), "minecraft:command.kick"); @@ -281,6 +283,13 @@ fn register_level_2_permissions(registry: &mut PermissionRegistry) { PermissionDefault::Op(PermissionLvl::Two), )) .expect("Permission already registered"); + registry + .register_permission(Permission::new( + "minecraft:command.waypoint", + "List or modify waypoints", + PermissionDefault::Op(PermissionLvl::Two), + )) + .expect("Permission already registered"); registry .register_permission(Permission::new( "minecraft:command.give", diff --git a/crates/pumpkin/src/command/commands/team.rs b/crates/pumpkin/src/command/commands/team.rs index 9b27a391d..313b4accd 100644 --- a/crates/pumpkin/src/command/commands/team.rs +++ b/crates/pumpkin/src/command/commands/team.rs @@ -449,6 +449,50 @@ impl CommandExecutor for TeamListExecutor { } } +struct TeamModifyColorResetExecutor; + +impl CommandExecutor for TeamModifyColorResetExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let team_name = TeamArgumentType::get(context, ARG_TEAM)?; + + let world = context.world(); + let mut scoreboard = world.scoreboard.lock().await; + + let mut team = scoreboard + .get_teams() + .get(team_name) + .ok_or_else(|| { + TEAM_NOT_FOUND_ERROR + .create_without_context(TextComponent::text(team_name.to_string())) + })? + .clone(); + + if team.color == NamedColor::White { + return Err(COLOR_UNCHANGED_ERROR.create_without_context()); + } + + team.color = NamedColor::White; + let team_display_name = team.display_name.clone(); + scoreboard.update_team(world, team); + + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_TEAM_OPTION_COLOR_CLEAR_SUCCESS, + translation::java::COMMANDS_TEAM_OPTION_COLOR_CLEAR_SUCCESS, + [team_display_name], + ), + true, + ) + .await; + + Ok(1) + }) + } +} + struct TeamModifyColorExecutor; impl CommandExecutor for TeamModifyColorExecutor { @@ -483,10 +527,7 @@ impl CommandExecutor for TeamModifyColorExecutor { TextComponent::translate_cross( translation::java::COMMANDS_TEAM_OPTION_COLOR_SUCCESS, translation::java::COMMANDS_TEAM_OPTION_COLOR_SUCCESS, - [ - team_display_name, - TextComponent::text(format!("{new_color:?}").to_lowercase()), - ], + [team_display_name, TextComponent::text(new_color.name())], ), true, ) @@ -962,13 +1003,17 @@ fn list_branch() -> LiteralArgumentBuilder { .then(argument(ARG_TEAM, TeamArgumentType).executes(TeamListExecutor { has_team: true })) } +#[expect(clippy::too_many_lines)] fn modify_branch() -> LiteralArgumentBuilder { literal("modify").then( argument(ARG_TEAM, TeamArgumentType) .then( - literal("color").then( - argument(ARG_VALUE, TeamColorArgumentType).executes(TeamModifyColorExecutor), - ), + literal("color") + .then(literal("reset").executes(TeamModifyColorResetExecutor)) + .then( + argument(ARG_VALUE, TeamColorArgumentType) + .executes(TeamModifyColorExecutor), + ), ) .then( literal("displayName").then( diff --git a/crates/pumpkin/src/command/commands/time.rs b/crates/pumpkin/src/command/commands/time.rs index b60e921a8..fecacc1d1 100644 --- a/crates/pumpkin/src/command/commands/time.rs +++ b/crates/pumpkin/src/command/commands/time.rs @@ -2,14 +2,21 @@ use pumpkin_data::translation; use pumpkin_util::text::TextComponent; use crate::command::CommandResult; -use crate::command::args::{FindArg, time::TimeArgumentConsumer}; +use crate::command::args::{ + FindArg, bounded_num::BoundedNumArgumentConsumer, + resource_location::ResourceLocationArgumentConsumer, time::TimeArgumentConsumer, +}; use crate::command::dispatcher::CommandError; use crate::command::tree::builder::{argument, literal}; use crate::command::{CommandExecutor, CommandSender, ConsumedArgs, tree::CommandTree}; const NAMES: [&str; 1] = ["time"]; -const DESCRIPTION: &str = "Query the world time."; +const DESCRIPTION: &str = "Query or modify the world time and clocks."; const ARG_TIME: &str = "time"; +const ARG_RATE: &str = "rate"; +const ARG_CLOCK: &str = "clock"; + +const DEFAULT_CLOCK: &str = "minecraft:overworld"; #[derive(Clone, Copy)] enum PresetTime { @@ -31,18 +38,26 @@ impl PresetTime { } #[derive(Clone, Copy)] -enum Mode { - Add, +enum Action { Set(Option), + Add, + Pause, + Resume, + Rate, } #[derive(Clone, Copy)] enum QueryMode { - DayTime, + Time, GameTime, + DayTime, Day, } +const fn wrap_time(ticks: i64) -> i32 { + (ticks % 2_147_483_647) as i32 +} + struct QueryExecutor(QueryMode); impl CommandExecutor for QueryExecutor { @@ -50,42 +65,72 @@ impl CommandExecutor for QueryExecutor { &'a self, sender: &'a CommandSender, server: &'a crate::server::Server, - _args: &'a ConsumedArgs<'a>, + args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { + let clock_name = ResourceLocationArgumentConsumer::find_arg(args, ARG_CLOCK) + .unwrap_or(DEFAULT_CLOCK); let mode = self.0; - // TODO: Maybe ask player for world, or get the current world let worlds = server.worlds.load(); let world = worlds .first() .expect("There should always be at least one world"); let level_time = world.level_time.lock().await; - let curr_time = match mode { - QueryMode::DayTime => level_time.query_daytime(), - QueryMode::GameTime => level_time.query_gametime(), - QueryMode::Day => level_time.query_day(), - }; - let bedrock_key = match mode { - QueryMode::DayTime => translation::bedrock::COMMANDS_TIME_QUERY_DAYTIME, - QueryMode::GameTime => translation::bedrock::COMMANDS_TIME_QUERY_GAMETIME, - QueryMode::Day => translation::bedrock::COMMANDS_TIME_QUERY_DAY, - }; - sender - .send_message(TextComponent::translate_cross( - translation::java::COMMANDS_TIME_QUERY, - bedrock_key, - [TextComponent::text(curr_time.to_string())], - )) - .await; - Ok(curr_time as i32) + match mode { + QueryMode::GameTime => { + let game_time = level_time.query_gametime(); + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_QUERY_GAMETIME, + [TextComponent::text(game_time.to_string())], + )) + .await; + Ok(wrap_time(game_time)) + } + QueryMode::Time => { + let total_ticks = level_time.time_of_day; + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_QUERY_ABSOLUTE, + [ + TextComponent::text(clock_name.to_string()), + TextComponent::text(total_ticks.to_string()), + ], + )) + .await; + Ok(wrap_time(total_ticks)) + } + QueryMode::DayTime => { + let curr_time = level_time.query_daytime(); + sender + .send_message(TextComponent::translate_cross( + translation::java::COMMANDS_TIME_QUERY, + translation::bedrock::COMMANDS_TIME_QUERY_DAYTIME, + [TextComponent::text(curr_time.to_string())], + )) + .await; + Ok(curr_time as i32) + } + QueryMode::Day => { + let curr_time = level_time.query_day(); + sender + .send_message(TextComponent::translate_cross( + translation::java::COMMANDS_TIME_QUERY, + translation::bedrock::COMMANDS_TIME_QUERY_DAY, + [TextComponent::text(curr_time.to_string())], + )) + .await; + Ok(curr_time as i32) + } + } }) } } -struct ChangeExecutor(Mode); +struct ActionExecutor(Action); -impl CommandExecutor for ChangeExecutor { +impl CommandExecutor for ActionExecutor { fn execute<'a>( &'a self, sender: &'a CommandSender, @@ -93,81 +138,194 @@ impl CommandExecutor for ChangeExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let time_count = if let Mode::Set(Some(preset)) = &self.0 { - preset.to_ticks() - } else if let Ok(ticks) = TimeArgumentConsumer::find_arg(args, ARG_TIME) { - ticks - } else { - return Err(CommandError::CommandFailed(TextComponent::text( - "Invalid time specified.", - ))); - }; - - let mode = self.0; - // TODO: Maybe ask player for world, or get the current world + let clock_name = ResourceLocationArgumentConsumer::find_arg(args, ARG_CLOCK) + .unwrap_or(DEFAULT_CLOCK); + let action = self.0; let worlds = server.worlds.load(); let world = worlds .first() .expect("There should always be at least one world"); let mut level_time = world.level_time.lock().await; - match mode { - Mode::Add => { - // add - level_time.add_time(time_count.into()); - level_time.send_time(world).await; - let curr_time = level_time.query_daytime(); - sender - .send_message(TextComponent::translate_cross( - translation::java::COMMANDS_TIME_SET, - translation::bedrock::COMMANDS_TIME_SET, - [TextComponent::text(curr_time.to_string())], - )) - .await; - Ok(curr_time as i32) - } - Mode::Set(_) => { - // set + match action { + Action::Set(preset) => { + let time_count = if let Some(p) = preset { + p.to_ticks() + } else if let Ok(ticks) = TimeArgumentConsumer::find_arg(args, ARG_TIME) { + ticks + } else { + return Err(CommandError::CommandFailed(TextComponent::text( + "Invalid time specified.", + ))); + }; level_time.set_time(time_count.into()); level_time.send_time(world).await; sender - .send_message(TextComponent::translate_cross( - translation::java::COMMANDS_TIME_SET, - translation::bedrock::COMMANDS_TIME_SET, - [TextComponent::text(time_count.to_string())], + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_SET_ABSOLUTE, + [ + TextComponent::text(clock_name.to_string()), + TextComponent::text(time_count.to_string()), + ], )) .await; Ok(time_count) } + Action::Add => { + let time_count = TimeArgumentConsumer::find_arg(args, ARG_TIME)?; + level_time.add_time(time_count.into()); + level_time.send_time(world).await; + let total_ticks = level_time.time_of_day; + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_SET_ABSOLUTE, + [ + TextComponent::text(clock_name.to_string()), + TextComponent::text(total_ticks.to_string()), + ], + )) + .await; + Ok(wrap_time(total_ticks)) + } + Action::Pause => { + level_time.set_paused(true); + level_time.send_time(world).await; + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_PAUSE, + [TextComponent::text(clock_name.to_string())], + )) + .await; + Ok(1) + } + Action::Resume => { + level_time.set_paused(false); + level_time.send_time(world).await; + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_RESUME, + [TextComponent::text(clock_name.to_string())], + )) + .await; + Ok(1) + } + Action::Rate => { + let rate_res = BoundedNumArgumentConsumer::::find_arg(args, ARG_RATE)?; + let rate = match rate_res { + Ok(val) => val, + Err(err) => return Err(err.into()), + }; + level_time.set_rate(rate); + level_time.send_time(world).await; + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_TIME_RATE, + [ + TextComponent::text(clock_name.to_string()), + TextComponent::text(rate.to_string()), + ], + )) + .await; + Ok(1) + } } }) } } pub fn init_command_tree() -> CommandTree { - CommandTree::new(NAMES, DESCRIPTION) + let set_node = literal("set") + .then(literal("day").execute(ActionExecutor(Action::Set(Some(PresetTime::Day))))) + .then(literal("noon").execute(ActionExecutor(Action::Set(Some(PresetTime::Noon))))) + .then(literal("night").execute(ActionExecutor(Action::Set(Some(PresetTime::Night))))) + .then(literal("midnight").execute(ActionExecutor(Action::Set(Some(PresetTime::Midnight))))) .then( - literal("add") - .then(argument(ARG_TIME, TimeArgumentConsumer).execute(ChangeExecutor(Mode::Add))), + argument(ARG_TIME, TimeArgumentConsumer::new()) + .execute(ActionExecutor(Action::Set(None))), + ); + + let add_node = literal("add").then( + argument(ARG_TIME, TimeArgumentConsumer::min(i32::MIN)) + .execute(ActionExecutor(Action::Add)), + ); + + let pause_node = literal("pause").execute(ActionExecutor(Action::Pause)); + let resume_node = literal("resume").execute(ActionExecutor(Action::Resume)); + + let rate_node = literal("rate").then( + argument( + ARG_RATE, + BoundedNumArgumentConsumer::::new() + .min(1.0e-5) + .max(1000.0), ) - .then( - literal("query") - .then(literal("daytime").execute(QueryExecutor(QueryMode::DayTime))) - .then(literal("gametime").execute(QueryExecutor(QueryMode::GameTime))) - .then(literal("day").execute(QueryExecutor(QueryMode::Day))), - ) - .then( - literal("set") - .then(literal("day").execute(ChangeExecutor(Mode::Set(Some(PresetTime::Day))))) - .then(literal("noon").execute(ChangeExecutor(Mode::Set(Some(PresetTime::Noon))))) - .then(literal("night").execute(ChangeExecutor(Mode::Set(Some(PresetTime::Night))))) - .then( - literal("midnight") - .execute(ChangeExecutor(Mode::Set(Some(PresetTime::Midnight)))), - ) - .then( - argument(ARG_TIME, TimeArgumentConsumer) - .execute(ChangeExecutor(Mode::Set(None))), + .execute(ActionExecutor(Action::Rate)), + ); + + let query_node = literal("query") + .then(literal("time").execute(QueryExecutor(QueryMode::Time))) + .then(literal("gametime").execute(QueryExecutor(QueryMode::GameTime))) + .then(literal("daytime").execute(QueryExecutor(QueryMode::DayTime))) + .then(literal("day").execute(QueryExecutor(QueryMode::Day))); + + let of_clock_node = literal("of").then( + argument(ARG_CLOCK, ResourceLocationArgumentConsumer) + .then( + literal("set") + .then( + literal("day").execute(ActionExecutor(Action::Set(Some(PresetTime::Day)))), + ) + .then( + literal("noon") + .execute(ActionExecutor(Action::Set(Some(PresetTime::Noon)))), + ) + .then( + literal("night") + .execute(ActionExecutor(Action::Set(Some(PresetTime::Night)))), + ) + .then( + literal("midnight") + .execute(ActionExecutor(Action::Set(Some(PresetTime::Midnight)))), + ) + .then( + argument(ARG_TIME, TimeArgumentConsumer::new()) + .execute(ActionExecutor(Action::Set(None))), + ), + ) + .then( + literal("add").then( + argument(ARG_TIME, TimeArgumentConsumer::min(i32::MIN)) + .execute(ActionExecutor(Action::Add)), ), - ) + ) + .then(literal("pause").execute(ActionExecutor(Action::Pause))) + .then(literal("resume").execute(ActionExecutor(Action::Resume))) + .then( + literal("rate").then( + argument( + ARG_RATE, + BoundedNumArgumentConsumer::::new() + .min(1.0e-5) + .max(1000.0), + ) + .execute(ActionExecutor(Action::Rate)), + ), + ) + .then( + literal("query") + .then(literal("time").execute(QueryExecutor(QueryMode::Time))) + .then(literal("gametime").execute(QueryExecutor(QueryMode::GameTime))) + .then(literal("daytime").execute(QueryExecutor(QueryMode::DayTime))) + .then(literal("day").execute(QueryExecutor(QueryMode::Day))), + ), + ); + + CommandTree::new(NAMES, DESCRIPTION) + .then(set_node) + .then(add_node) + .then(pause_node) + .then(resume_node) + .then(rate_node) + .then(query_node) + .then(of_clock_node) } diff --git a/crates/pumpkin/src/command/commands/title.rs b/crates/pumpkin/src/command/commands/title.rs index 8f78ec3c5..02084395a 100644 --- a/crates/pumpkin/src/command/commands/title.rs +++ b/crates/pumpkin/src/command/commands/title.rs @@ -187,12 +187,15 @@ pub fn init_command_tree() -> CommandTree { .execute(TitleExecutor(TitleMode::ActionBar)), ), ) - .then(literal("times").then( - argument(ARG_FADE_IN, TimeArgumentConsumer).then( - argument(ARG_STAY, TimeArgumentConsumer).then( - argument(ARG_FADE_OUT, TimeArgumentConsumer).execute(TimesTitleExecutor), + .then( + literal("times").then( + argument(ARG_FADE_IN, TimeArgumentConsumer::new()).then( + argument(ARG_STAY, TimeArgumentConsumer::new()).then( + argument(ARG_FADE_OUT, TimeArgumentConsumer::new()) + .execute(TimesTitleExecutor), + ), ), ), - )), + ), ) } diff --git a/crates/pumpkin/src/command/commands/waypoint.rs b/crates/pumpkin/src/command/commands/waypoint.rs new file mode 100644 index 000000000..9b3382a22 --- /dev/null +++ b/crates/pumpkin/src/command/commands/waypoint.rs @@ -0,0 +1,164 @@ +use pumpkin_data::translation; +use pumpkin_util::text::TextComponent; + +use crate::command::CommandResult; +use crate::command::args::{ + FindArg, entity::EntityArgumentConsumer, hex_color::HexColorArgumentConsumer, + resource_location::ResourceLocationArgumentConsumer, team_color::TeamColorArgumentConsumer, +}; +use crate::command::tree::builder::{argument, literal}; +use crate::command::{CommandExecutor, CommandSender, ConsumedArgs, tree::CommandTree}; + +const NAMES: [&str; 1] = ["waypoint"]; +const DESCRIPTION: &str = "List or modify waypoints."; +const ARG_WAYPOINT: &str = "waypoint"; +const ARG_COLOR: &str = "color"; +const ARG_STYLE: &str = "style"; + +struct ListExecutor; + +impl CommandExecutor for ListExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + server: &'a crate::server::Server, + _args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let worlds = server.worlds.load(); + let world = worlds + .first() + .expect("There should always be at least one world"); + let dimension = world.dimension.minecraft_name.to_string(); + + // Currently no active waypoints are tracked in the level + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_WAYPOINT_LIST_EMPTY, + [TextComponent::text(dimension)], + )) + .await; + Ok(0) + }) + } +} + +enum ColorAction { + Named, + Hex, + Reset, +} + +struct ColorExecutor(ColorAction); + +impl CommandExecutor for ColorExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a crate::server::Server, + args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let _waypoint_entity = EntityArgumentConsumer::find_arg(args, ARG_WAYPOINT)?; + + match self.0 { + ColorAction::Named => { + let color = TeamColorArgumentConsumer::find_arg(args, ARG_COLOR)?; + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_WAYPOINT_MODIFY_COLOR, + [TextComponent::text(color.name()).color_named(color)], + )) + .await; + } + ColorAction::Hex => { + let color_val = HexColorArgumentConsumer::find_arg(args, ARG_COLOR)?; + let hex_str = format!("{:06X}", color_val & 0xFFFFFF); + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_WAYPOINT_MODIFY_COLOR, + [TextComponent::text(hex_str)], + )) + .await; + } + ColorAction::Reset => { + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_WAYPOINT_MODIFY_COLOR_RESET, + [], + )) + .await; + } + } + + Ok(0) + }) + } +} + +enum StyleAction { + Set, + Reset, +} + +struct StyleExecutor(StyleAction); + +impl CommandExecutor for StyleExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a crate::server::Server, + args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let _waypoint_entity = EntityArgumentConsumer::find_arg(args, ARG_WAYPOINT)?; + + match self.0 { + StyleAction::Set => { + let _style = ResourceLocationArgumentConsumer::find_arg(args, ARG_STYLE)?; + } + StyleAction::Reset => {} + } + + sender + .send_message(TextComponent::translate( + translation::java::COMMANDS_WAYPOINT_MODIFY_STYLE, + [], + )) + .await; + + Ok(0) + }) + } +} + +pub fn init_command_tree() -> CommandTree { + let color_node = literal("color") + .then( + argument(ARG_COLOR, TeamColorArgumentConsumer) + .execute(ColorExecutor(ColorAction::Named)), + ) + .then(literal("hex").then( + argument(ARG_COLOR, HexColorArgumentConsumer).execute(ColorExecutor(ColorAction::Hex)), + )) + .then(literal("reset").execute(ColorExecutor(ColorAction::Reset))); + + let style_node = literal("style") + .then(literal("reset").execute(StyleExecutor(StyleAction::Reset))) + .then( + literal("set").then( + argument(ARG_STYLE, ResourceLocationArgumentConsumer) + .execute(StyleExecutor(StyleAction::Set)), + ), + ); + + let modify_node = literal("modify").then( + argument(ARG_WAYPOINT, EntityArgumentConsumer) + .then(color_node) + .then(style_node), + ); + + CommandTree::new(NAMES, DESCRIPTION) + .then(literal("list").execute(ListExecutor)) + .then(modify_node) +} diff --git a/crates/pumpkin/src/command/commands/weather.rs b/crates/pumpkin/src/command/commands/weather.rs index 59f643320..f11bbc8bc 100644 --- a/crates/pumpkin/src/command/commands/weather.rs +++ b/crates/pumpkin/src/command/commands/weather.rs @@ -96,7 +96,7 @@ pub fn init_command_tree() -> CommandTree { .then( literal("clear") .then( - argument(ARG_DURATION, TimeArgumentConsumer).execute(Executor { + argument(ARG_DURATION, TimeArgumentConsumer::new()).execute(Executor { mode: WeatherMode::Clear, }), ) @@ -107,7 +107,7 @@ pub fn init_command_tree() -> CommandTree { .then( literal("rain") .then( - argument(ARG_DURATION, TimeArgumentConsumer).execute(Executor { + argument(ARG_DURATION, TimeArgumentConsumer::new()).execute(Executor { mode: WeatherMode::Rain, }), ) @@ -118,7 +118,7 @@ pub fn init_command_tree() -> CommandTree { .then( literal("thunder") .then( - argument(ARG_DURATION, TimeArgumentConsumer).execute(Executor { + argument(ARG_DURATION, TimeArgumentConsumer::new()).execute(Executor { mode: WeatherMode::Thunder, }), ) diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs index 53be210e8..822a2023d 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -2548,27 +2548,39 @@ impl Player { /// Sends the world time to only this player. pub async fn send_time(&self, world: &World) { + let advance_time = { + let lock = world.level_info.load(); + lock.game_rules.advance_time + }; + + let l_world = world.level_time.lock().await; if let Some((custom_time, relative)) = self.per_player_time.load() { let time_of_day = if relative { - let l_world = world.level_time.lock().await; (l_world.time_of_day as u64 + custom_time) as i64 } else { custom_time as i64 }; - let l_world = world.level_time.lock().await; + let paused = l_world.paused || !advance_time; + let rate = if paused { 0.0 } else { l_world.rate }; self.client .enqueue_packet_editioned( - &CUpdateTime::new(l_world.world_age, time_of_day, true), + &CUpdateTime::new_clock( + l_world.world_age, + 0, + time_of_day, + l_world.partial_tick, + rate, + ), &CSetTime::new(time_of_day as _), ) .await; return; } - let l_world = world.level_time.lock().await; + let (total_ticks, partial_tick, rate) = l_world.pack_network_state(advance_time); self.client .enqueue_packet_editioned( - &CUpdateTime::new(l_world.world_age, l_world.time_of_day, true), + &CUpdateTime::new_clock(l_world.world_age, 0, total_ticks, partial_tick, rate), &CSetTime::new(l_world.query_daytime() as _), ) .await; diff --git a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs index 2719efaf7..e3445da8a 100644 --- a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs +++ b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/args.rs @@ -81,7 +81,9 @@ impl OwnedArg { Arg::Enchantment(e) => Self::Enchantment(e), Arg::EntityAnchor(a) => Self::EntityAnchor(*a), Arg::Advancement(a) => Self::Advancement(a), - Arg::Slot(_, _) | Arg::Slots(_, _) => unreachable!(), + Arg::Slot(_, _) | Arg::Slots(_, _) | Arg::TeamColor(_) | Arg::HexColor(_) => { + unreachable!() + } } } } diff --git a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs index 32e9f22cf..71534b686 100644 --- a/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs +++ b/crates/pumpkin/src/plugin/loader/wasm/wasm_host/wit/v0_1/commands/mod.rs @@ -598,7 +598,7 @@ impl pumpkin::plugin::command::HostCommandNode for PluginHostState { ArgumentType::EntityAnchor => argument(name, EntityAnchorArgumentConsumer), ArgumentType::Gamemode => argument(name, GamemodeArgumentConsumer), ArgumentType::Difficulty => argument(name, DifficultyArgumentConsumer), - ArgumentType::Time(_) => argument(name, TimeArgumentConsumer), + ArgumentType::Time(min) => argument(name, TimeArgumentConsumer::min(min.unwrap_or(0))), _ => { return Err(wasmtime::Error::msg(format!( "Unimplemented argument type: {arg_type:?}" diff --git a/crates/pumpkin/src/world/mod.rs b/crates/pumpkin/src/world/mod.rs index 0ff27bca2..5fa31bcf1 100644 --- a/crates/pumpkin/src/world/mod.rs +++ b/crates/pumpkin/src/world/mod.rs @@ -1128,14 +1128,8 @@ impl World { async fn tick_environment(&self) { let (world_age, is_night, time_of_day) = { let mut level_time = self.level_time.lock().await; - let (advance_time, advance_weather) = { - let lock = self.level_info.load(); - ( - lock.game_rules.advance_time, - lock.game_rules.advance_weather, - ) - }; - level_time.tick_time(advance_time, advance_weather); + let advance_time = self.level_info.load().game_rules.advance_time; + level_time.tick(advance_time); // Auto-save logic if level_time.world_age % 100 == 0 { diff --git a/crates/pumpkin/src/world/time.rs b/crates/pumpkin/src/world/time.rs index e72c05a2f..f90904885 100644 --- a/crates/pumpkin/src/world/time.rs +++ b/crates/pumpkin/src/world/time.rs @@ -2,10 +2,85 @@ use pumpkin_protocol::{bedrock::client::set_time::CSetTime, java::client::play:: use super::World; +#[derive(Clone, Debug, PartialEq)] +pub struct ClockInstance { + pub total_ticks: i64, + pub partial_tick: f32, + pub rate: f32, + pub paused: bool, +} + +impl Default for ClockInstance { + fn default() -> Self { + Self::new() + } +} + +impl ClockInstance { + #[must_use] + pub const fn new() -> Self { + Self { + total_ticks: 0, + partial_tick: 0.0, + rate: 1.0, + paused: false, + } + } + + pub const fn load_from( + &mut self, + total_ticks: i64, + partial_tick: f32, + rate: f32, + paused: bool, + ) { + self.total_ticks = total_ticks; + self.partial_tick = partial_tick; + self.rate = rate; + self.paused = paused; + } + + pub fn tick(&mut self) { + if !self.paused { + self.partial_tick += self.rate; + let full_ticks = self.partial_tick.floor() as i32; + self.partial_tick -= full_ticks as f32; + self.total_ticks += full_ticks as i64; + } + } + + pub const fn set_total_ticks(&mut self, total_ticks: i64) { + self.total_ticks = total_ticks; + self.partial_tick = 0.0; + } + + pub fn add_ticks(&mut self, ticks: i64) { + self.total_ticks = (self.total_ticks + ticks).max(0); + } + + pub const fn set_paused(&mut self, paused: bool) { + self.paused = paused; + } + + pub const fn set_rate(&mut self, rate: f32) { + self.rate = rate; + } + + #[must_use] + pub const fn pack_network_state(&self, advance_time: bool) -> (i64, f32, f32) { + let paused = self.paused || !advance_time; + let rate = if paused { 0.0 } else { self.rate }; + (self.total_ticks, self.partial_tick, rate) + } +} + +#[derive(Clone, Debug)] pub struct LevelTime { - pub world_age: i64, pub time_of_day: i64, - pub rain_time: i64, + pub world_age: i64, + pub partial_tick: f32, + pub rate: f32, + pub paused: bool, } impl Default for LevelTime { @@ -18,19 +93,26 @@ impl LevelTime { #[must_use] pub const fn new() -> Self { Self { - world_age: 0, time_of_day: 0, - rain_time: 0, + world_age: 0, + partial_tick: 0.0, + rate: 1.0, + paused: false, } } - pub const fn tick_time(&mut self, advance_time: bool, advance_weather: bool) { + pub const fn load_from(&mut self, time_of_day: i64, world_age: i64) { + self.time_of_day = time_of_day; + self.world_age = world_age; + } + + pub fn tick(&mut self, advance_time: bool) { self.world_age += 1; - if advance_weather { - self.rain_time += 1; - } - if advance_time { - self.time_of_day += 1; + if advance_time && !self.paused { + self.partial_tick += self.rate; + let full_ticks = self.partial_tick.floor() as i32; + self.partial_tick -= full_ticks as f32; + self.time_of_day += full_ticks as i64; } } @@ -40,20 +122,38 @@ impl LevelTime { lock.game_rules.advance_time }; + let (total_ticks, partial_tick, rate) = self.pack_network_state(advance_time); + world .broadcast_editioned( - &CUpdateTime::new(self.world_age, self.time_of_day, advance_time), + &CUpdateTime::new_clock(self.world_age, 0, total_ticks, partial_tick, rate), &CSetTime::new(self.time_of_day as _), // TODO do we need to tell bedrock that time is frozen? ) .await; } - pub const fn add_time(&mut self, time: i64) { - self.time_of_day += time; + pub fn add_time(&mut self, time: i64) { + self.time_of_day = (self.time_of_day + time).max(0); } pub const fn set_time(&mut self, time: i64) { self.time_of_day = time; + self.partial_tick = 0.0; + } + + pub const fn set_paused(&mut self, paused: bool) { + self.paused = paused; + } + + pub const fn set_rate(&mut self, rate: f32) { + self.rate = rate; + } + + #[must_use] + pub const fn pack_network_state(&self, advance_time: bool) -> (i64, f32, f32) { + let paused = self.paused || !advance_time; + let rate = if paused { 0.0 } else { self.rate }; + (self.time_of_day, self.partial_tick, rate) } #[must_use] @@ -76,3 +176,56 @@ impl LevelTime { (self.time_of_day % 24000) >= 12000 && (self.time_of_day % 24000) <= 23999 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_instance_ticking() { + let mut clock = ClockInstance::new(); + assert_eq!(clock.total_ticks, 0); + assert_eq!(clock.partial_tick, 0.0); + assert_eq!(clock.rate, 1.0); + assert!(!clock.paused); + + // Standard tick at rate 1.0 + clock.tick(); + assert_eq!(clock.total_ticks, 1); + assert_eq!(clock.partial_tick, 0.0); + + // Half rate tick + clock.set_rate(0.5); + clock.tick(); + assert_eq!(clock.total_ticks, 1); + assert_eq!(clock.partial_tick, 0.5); + clock.tick(); + assert_eq!(clock.total_ticks, 2); + assert_eq!(clock.partial_tick, 0.0); + + // Double rate tick + clock.set_rate(2.0); + clock.tick(); + assert_eq!(clock.total_ticks, 4); + assert_eq!(clock.partial_tick, 0.0); + + // Paused clock + clock.set_paused(true); + clock.tick(); + assert_eq!(clock.total_ticks, 4); + } + + #[test] + fn level_time_set_and_add() { + let mut time = LevelTime::new(); + time.set_time(1000); + assert_eq!(time.time_of_day, 1000); + assert_eq!(time.partial_tick, 0.0); + + time.add_time(500); + assert_eq!(time.time_of_day, 1500); + + time.add_time(-2000); + assert_eq!(time.time_of_day, 0); + } +}