diff --git a/examples/wasm-modules/console/src/console/page.rs b/examples/wasm-modules/console/src/console/page.rs index 33e3b29..aa7478a 100644 --- a/examples/wasm-modules/console/src/console/page.rs +++ b/examples/wasm-modules/console/src/console/page.rs @@ -44,7 +44,7 @@ impl Console { name: "test".to_string(), usage: "test".to_string(), description: "test".to_string(), - cmd: Rc::new(|_, _, _| Ok("".to_string())), + cmd: Rc::new(|_, _, _, _| Ok("".to_string())), args: vec![], allows_partial_cmds: false, }), @@ -52,7 +52,7 @@ impl Console { name: "test2".to_string(), usage: "test2".to_string(), description: "test2".to_string(), - cmd: Rc::new(|_, _, _| Ok("".to_string())), + cmd: Rc::new(|_, _, _, _| Ok("".to_string())), args: vec![], allows_partial_cmds: false, }), @@ -60,7 +60,7 @@ impl Console { name: "test3".to_string(), usage: "test3".to_string(), description: "test3".to_string(), - cmd: Rc::new(|_, _, _| Ok("".to_string())), + cmd: Rc::new(|_, _, _, _| Ok("".to_string())), args: vec![], allows_partial_cmds: false, }), diff --git a/game/api-state/src/lib.rs b/game/api-state/src/lib.rs index 56a85db..eb71619 100644 --- a/game/api-state/src/lib.rs +++ b/game/api-state/src/lib.rs @@ -16,7 +16,7 @@ use game_interface::ghosts::GhostResult; use game_interface::interface::{ GameStateCreate, GameStateCreateOptions, GameStateStaticInfo, MAX_MAP_NAME_LEN, }; -use game_interface::rcon_commands::ExecRconCommand; +use game_interface::rcon_entries::ExecRconInput; use game_interface::settings::GameStateSettings; use game_interface::tick_result::TickResult; use game_interface::types::character_info::NetworkCharacterInfo; @@ -148,8 +148,8 @@ impl GameStateInterface for ApiState { fn rcon_command( &mut self, player_id: Option, - cmd: ExecRconCommand, - ) -> Vec> { + cmd: ExecRconInput, + ) -> Vec, NetworkString<65536>>> { } #[guest_func_call_from_host_auto(option)] diff --git a/game/client-console/src/console/local_console.rs b/game/client-console/src/console/local_console.rs index 331dc51..fe48ad4 100644 --- a/game/client-console/src/console/local_console.rs +++ b/game/client-console/src/console/local_console.rs @@ -106,7 +106,7 @@ impl LocalConsoleBuilder { name: "push".into(), usage: "push ".into(), description: "Push a new item to a config variable of type array.".into(), - cmd: Rc::new(|config_engine, config_game, path| { + cmd: Rc::new(|config_engine, config_game, _, path| { let path = syn_vec_to_config_val(path).unwrap_or_default(); if config_engine .try_set_from_str(path.clone(), None, None, None, ConfigFromStrOperation::Push) @@ -135,7 +135,7 @@ impl LocalConsoleBuilder { name: "pop".into(), usage: "pop ".into(), description: "Pop the last item of a config variable of type array.".into(), - cmd: Rc::new(|config_engine, config_game, path| { + cmd: Rc::new(|config_engine, config_game, _, path| { let path = syn_vec_to_config_val(path).unwrap_or_default(); if config_engine .try_set_from_str(path.clone(), None, None, None, ConfigFromStrOperation::Pop) @@ -164,7 +164,7 @@ impl LocalConsoleBuilder { name: "rem".into(), usage: "rem [key]".into(), description: "Remove an item from a config variable of type object.".into(), - cmd: Rc::new(|config_engine, config_game, path| { + cmd: Rc::new(|config_engine, config_game, _, path| { let path = syn_vec_to_config_val(path).unwrap_or_default(); if config_engine .try_set_from_str(path.clone(), None, None, None, ConfigFromStrOperation::Rem) @@ -193,7 +193,7 @@ impl LocalConsoleBuilder { name: "reset".into(), usage: "reset ".into(), description: "Reset the value of a config variable to its default.".into(), - cmd: Rc::new(|config_engine, config_game, path| { + cmd: Rc::new(|config_engine, config_game, _, path| { let path = syn_vec_to_config_val(path).unwrap_or_default(); if path.is_empty() { return Err(anyhow::anyhow!("You cannot reset the whole config at once")); @@ -286,7 +286,7 @@ impl LocalConsoleBuilder { name: "toggle".into(), usage: "toggle ".into(), description: "Toggle a config variable between two args.".into(), - cmd: Rc::new(|config_engine, config_game, path| { + cmd: Rc::new(|config_engine, config_game, _, path| { toggle(config_engine, config_game, path) }), args: vec![CommandArg { @@ -301,7 +301,7 @@ impl LocalConsoleBuilder { description: "Toggle a config variable between two args until the pressed key is released again." .into(), - cmd: Rc::new(|config_engine, config_game, path| { + cmd: Rc::new(|config_engine, config_game, _, path| { toggle(config_engine, config_game, path) }), args: vec![CommandArg { @@ -320,7 +320,7 @@ impl LocalConsoleBuilder { name: name.to_string(), usage: format!("triggers a player action: {}", name), description: format!("Triggers the player action: {}", name), - cmd: Rc::new(move |_config_engine, _config_game, _path| { + cmd: Rc::new(move |_config_engine, _config_game, _, _path| { events.push(LocalConsoleEvent::LocalPlayerAction(action)); Ok(String::default()) }), @@ -446,7 +446,7 @@ impl LocalConsoleBuilder { name: "bind".to_string(), usage: "dummy".to_string(), description: "dummy".to_string(), - cmd: Rc::new(|_, _, _| Ok("".into())), + cmd: Rc::new(|_, _, _, _| Ok("".into())), args: vec![keys_arg.clone()], allows_partial_cmds: false, })]), @@ -575,7 +575,7 @@ impl LocalConsoleBuilder { name: "bind".into(), usage: "bind ".into(), description: "Binds commands to a single key or key chain.".into(), - cmd: Rc::new(move |_config_engine, config_game, path| { + cmd: Rc::new(move |_config_engine, config_game, _, path| { bind( config_game.profiles.main as usize, false, @@ -608,7 +608,7 @@ impl LocalConsoleBuilder { usage: "bind_dummy ".into(), description: "Binds commands to a single key or key chain for the dummy profile." .into(), - cmd: Rc::new(move |_config_engine, config_game, path| { + cmd: Rc::new(move |_config_engine, config_game, _, path| { bind( config_game.profiles.dummy.index as usize, true, @@ -639,7 +639,7 @@ impl LocalConsoleBuilder { name: "unbind".into(), usage: "unbind ".into(), description: "Unbinds commands from a single key or key chain.".into(), - cmd: Rc::new(move |_config_engine, config_game, path| { + cmd: Rc::new(move |_config_engine, config_game, _, path| { unbind( config_game.profiles.main as usize, false, @@ -662,7 +662,7 @@ impl LocalConsoleBuilder { usage: "unbind_dummy ".into(), description: "Unbinds commands from a single key or key chain for the dummy profile." .into(), - cmd: Rc::new(move |_config_engine, config_game, path| { + cmd: Rc::new(move |_config_engine, config_game, _, path| { unbind( config_game.profiles.dummy.index as usize, true, @@ -683,7 +683,7 @@ impl LocalConsoleBuilder { name: "exec".into(), usage: "exec ".into(), description: "Executes a file of command lines.".into(), - cmd: Rc::new(move |_, _, path| { + cmd: Rc::new(move |_, _, _, path| { let Syn::Text(file_path_str) = &path[0].0 else { panic!("Command parser returned a non requested command arg"); }; @@ -703,7 +703,7 @@ impl LocalConsoleBuilder { name: "echo".into(), usage: "echo ".into(), description: "Echos text to the console and a client component.".into(), - cmd: Rc::new(move |_, _, path| { + cmd: Rc::new(move |_, _, _, path| { let Syn::Text(text) = &path[0].0 else { panic!("Command parser returned a non requested command arg"); }; @@ -723,7 +723,7 @@ impl LocalConsoleBuilder { name: "connect".into(), usage: "connect ".into(), description: "Connects to a server of the given ip & port.".into(), - cmd: Rc::new(move |_, _, path| { + cmd: Rc::new(move |_, _, _, path| { let (Syn::Text(text), _) = path .first() .ok_or_else(|| anyhow!("expected ip & port, but found nothing"))? @@ -754,7 +754,7 @@ impl LocalConsoleBuilder { name: "change_dummy".into(), usage: "change_dummy ".into(), description: "Switches to a dummy, or the main player (index 0).".into(), - cmd: Rc::new(move |_, _, path| { + cmd: Rc::new(move |_, _, _, path| { let (Syn::Number(index), _) = path .first() .ok_or_else(|| anyhow!("expected an index, but found nothing"))? @@ -783,7 +783,7 @@ impl LocalConsoleBuilder { name: "toggle_dummy".into(), usage: "toggle_dummy".into(), description: "Toggles between a dummy and the main player.".into(), - cmd: Rc::new(move |_, _, _| { + cmd: Rc::new(move |_, _, _, _| { console_events_cmd.push(LocalConsoleEvent::ToggleDummy); Ok("".to_string()) }), @@ -795,7 +795,7 @@ impl LocalConsoleBuilder { name: "quit".into(), usage: "quit the client".into(), description: "Closes the client.".into(), - cmd: Rc::new(move |_, _, _| { + cmd: Rc::new(move |_, _, _, _| { console_events.push(LocalConsoleEvent::Quit); Ok("Bye bye".to_string()) }), diff --git a/game/client-console/src/console/remote_console.rs b/game/client-console/src/console/remote_console.rs index ef5ec8e..94be4f9 100644 --- a/game/client-console/src/console/remote_console.rs +++ b/game/client-console/src/console/remote_console.rs @@ -4,7 +4,7 @@ use base::network_string::NetworkString; use client_types::console::{ConsoleEntry, ConsoleEntryCmd}; use command_parser::parser::{format_args, CommandArg, CommandArgType}; use egui::Color32; -use game_interface::rcon_commands::RconCommand; +use game_interface::rcon_entries::RconEntry; use hiarc::{hiarc_safer_rc_refcell, Hiarc}; use ui_base::ui::UiCreator; @@ -12,7 +12,12 @@ use super::console::ConsoleRender; #[derive(Debug, Hiarc)] pub enum RemoteConsoleEvent { - Exec { name: String, args: String }, + Exec { + /// The raw ident text (with all modifiers as is) + ident_text: String, + /// The args of the command + args: String, + }, } #[hiarc_safer_rc_refcell] @@ -71,11 +76,14 @@ impl RemoteConsole { usage } - pub fn fill_entries(&mut self, cmds: HashMap, RconCommand>) { + pub fn fill_entries( + &mut self, + cmds: HashMap, RconEntry>, + vars: HashMap, RconEntry>, + ) { self.entries.clear(); - for (name, cmd) in cmds { + for (name, cmd) in cmds.into_iter().chain(vars.into_iter()) { let cmds = self.user.clone(); - let name_clone = name.clone(); self.entries.push(ConsoleEntry::Cmd(ConsoleEntryCmd { name: name.to_string(), usage: if cmd.usage.is_empty() { @@ -84,12 +92,12 @@ impl RemoteConsole { cmd.usage.to_string() }, description: cmd.description.to_string(), - cmd: Rc::new(move |_config_engine, _config_game, path| { + cmd: Rc::new(move |_config_engine, _config_game, ident_text, path| { cmds.push(RemoteConsoleEvent::Exec { - name: name_clone.to_string(), + ident_text: ident_text.to_string(), args: format_args(path), }); - Ok(format!("{name_clone} {}", format_args(path))) + Ok(format!("{ident_text} {}", format_args(path))) }), args: cmd.args, allows_partial_cmds: true, diff --git a/game/client-types/src/console.rs b/game/client-types/src/console.rs index b934611..8347ede 100644 --- a/game/client-types/src/console.rs +++ b/game/client-types/src/console.rs @@ -28,8 +28,16 @@ impl Debug for ConsoleEntryVariable { } } +/// A function that takes the config vars, +/// the raw & unparsed ident (so with all modifiers) +/// and the arguments and their range. pub type ConsoleCmdCb = Rc< - dyn Fn(&mut ConfigEngine, &mut ConfigGame, &[(Syn, Range)]) -> anyhow::Result, + dyn Fn( + &mut ConfigEngine, + &mut ConfigGame, + &str, + &[(Syn, Range)], + ) -> anyhow::Result, >; #[derive(Clone)] diff --git a/game/client-ui/src/console/utils.rs b/game/client-ui/src/console/utils.rs index 27d237b..af5e9bf 100644 --- a/game/client-ui/src/console/utils.rs +++ b/game/client-ui/src/console/utils.rs @@ -527,7 +527,7 @@ pub fn run_command( }) { let cmd = cmd.unwrap_full_or_partial_cmd_ref(); - match (entry_cmd.cmd)(config_engine, config_game, &cmd.args) { + match (entry_cmd.cmd)(config_engine, config_game, &cmd.cmd_text, &cmd.args) { Ok(msg) => Ok(msg), Err(err) => Err(format!("Parsing error: {}\n", err)), } diff --git a/game/client-ui/src/ingame_menu/call_vote/main_frame.rs b/game/client-ui/src/ingame_menu/call_vote/main_frame.rs index 8c50b5f..dd44241 100644 --- a/game/client-ui/src/ingame_menu/call_vote/main_frame.rs +++ b/game/client-ui/src/ingame_menu/call_vote/main_frame.rs @@ -50,7 +50,9 @@ pub fn render(ui: &mut egui::Ui, pipe: &mut UiRenderPipe, ui_state: &m "Map" => super::map::render(ui, pipe, ui_state), "Player" => super::players::render(ui, pipe), // Misc - _ => {} + _ => { + super::misc::render(ui, pipe); + } } }, ); diff --git a/game/client-ui/src/ingame_menu/call_vote/misc.rs b/game/client-ui/src/ingame_menu/call_vote/misc.rs new file mode 100644 index 0000000..0a7ee6c --- /dev/null +++ b/game/client-ui/src/ingame_menu/call_vote/misc.rs @@ -0,0 +1,240 @@ +use std::collections::BTreeSet; + +use egui::{Frame, ScrollArea, Sense, Shadow}; +use egui_extras::{Column, Size, StripBuilder, TableBuilder}; +use game_base::server_browser::{SortDir, TableSort}; +use game_config::config::Config; +use game_interface::votes::{MiscVote, MiscVoteCategoryKey, MiscVoteKey}; +use ui_base::{ + components::{ + clearable_edit_field::clearable_edit_field, + menu_top_button::{menu_top_button, MenuTopButtonProps}, + }, + style::{bg_frame_color, topbar_buttons}, + types::UiRenderPipe, + utils::{add_margins, get_margin}, +}; + +use crate::{events::UiEvent, ingame_menu::user_data::UserData, sort::sortable_header}; + +const MISC_VOTE_DIR_STORAGE_NAME: &str = "misc-vote-sort-dir"; + +fn render_table( + ui: &mut egui::Ui, + misc_infos: &[(usize, &(MiscVoteKey, MiscVote))], + index: usize, + config: &mut Config, +) { + let mut table = TableBuilder::new(ui).auto_shrink([false, false]); + table = table.column(Column::auto().at_least(150.0)); + + table + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .striped(true) + .sense(Sense::click()) + .header(30.0, |mut row| { + let names = vec!["Name"]; + sortable_header(&mut row, MISC_VOTE_DIR_STORAGE_NAME, config, &names); + }) + .body(|body| { + body.rows(25.0, misc_infos.len(), |mut row| { + let (original_index, (misc, _)) = &misc_infos[row.index()]; + row.set_selected(index == *original_index); + row.col(|ui| { + ui.label(misc.display_name.as_str()); + }); + if row.response().clicked() { + config + .engine + .ui + .path + .query + .insert("vote-misc-index".to_string(), original_index.to_string()); + } + }) + }); +} + +pub fn render(ui: &mut egui::Ui, pipe: &mut UiRenderPipe) { + let config = &mut *pipe.user_data.browser_menu.config; + + let sort_dir = config.storage::(MISC_VOTE_DIR_STORAGE_NAME); + + let path = &mut config.engine.ui.path; + + let mut misc_search = path + .query + .entry("vote-misc-search".to_string()) + .or_default() + .clone(); + + let mut category = path + .query + .entry("vote-misc-category".to_string()) + .or_default() + .as_str() + .try_into() + .unwrap_or_default(); + + pipe.user_data.votes.request_misc_votes(); + let mut misc_votes = pipe.user_data.votes.collect_misc_votes(); + + let mut categories: Vec<_> = misc_votes.keys().cloned().collect(); + categories.sort(); + let mut vote_category = misc_votes.remove(&category); + + if vote_category.is_none() { + if let Some((name, votes)) = categories.first().and_then(|c| misc_votes.remove_entry(c)) { + category = name; + vote_category = Some(votes); + } + } + + let mut misc_infos: Vec<(_, _)> = vote_category + .map(|votes| votes.into_iter().collect()) + .unwrap_or_default(); + + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] + enum MiscSorting { + Name, + } + + let mut sortings: BTreeSet = Default::default(); + sortings.insert(MiscSorting::Name); + + let cur_sort = MiscSorting::Name; + + misc_infos.sort_by(|(i1k, _), (i2k, _)| { + let cmp = match cur_sort { + MiscSorting::Name => i1k.display_name.cmp(&i2k.display_name), + }; + if matches!(sort_dir.sort_dir, SortDir::Desc) { + cmp.reverse() + } else { + cmp + } + }); + + let category = category.to_string(); + + let index_entry = path + .query + .entry("vote-misc-index".to_string()) + .or_default() + .clone(); + let index: usize = index_entry.parse().unwrap_or_default(); + + Frame::default() + .fill(bg_frame_color()) + .inner_margin(get_margin(ui)) + .shadow(Shadow::NONE) + .show(ui, |ui| { + let mut builder = StripBuilder::new(ui); + + let has_multi_categories = categories.len() > 1; + if has_multi_categories { + builder = builder.size(Size::exact(20.0)); + builder = builder.size(Size::exact(2.0)); + } + + builder + .size(Size::remainder()) + .size(Size::exact(20.0)) + .vertical(|mut strip| { + if has_multi_categories { + strip.cell(|ui| { + ui.style_mut().wrap_mode = None; + ScrollArea::horizontal().show(ui, |ui| { + ui.horizontal(|ui| { + ui.set_style(topbar_buttons()); + for category_name in categories { + if menu_top_button( + ui, + |_, _| None, + MenuTopButtonProps::new( + &category_name, + &Some(category.clone()), + ), + ) + .clicked() + { + config.engine.ui.path.query.insert( + "vote-misc-category".to_string(), + category_name.to_string(), + ); + } + } + }); + }); + }); + strip.empty(); + } + strip.cell(|ui| { + ui.style_mut().wrap_mode = None; + ui.style_mut().spacing.item_spacing.y = 0.0; + StripBuilder::new(ui) + .size(Size::remainder()) + .size(Size::exact(20.0)) + .vertical(|mut strip| { + strip.cell(|ui| { + ui.style_mut().wrap_mode = None; + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 0.0, + bg_frame_color(), + ); + ui.set_clip_rect(ui.available_rect_before_wrap()); + add_margins(ui, |ui| { + let misc_infos: Vec<_> = misc_infos + .iter() + .enumerate() + .filter(|(_, (key, _))| { + key.display_name + .as_str() + .to_lowercase() + .contains(&misc_search.to_lowercase()) + }) + .collect(); + render_table(ui, &misc_infos, index, config); + }); + }); + strip.cell(|ui| { + ui.style_mut().wrap_mode = None; + ui.horizontal_centered(|ui| { + // Search + ui.label("\u{1f50d}"); + clearable_edit_field( + ui, + &mut misc_search, + Some(200.0), + None, + ); + }); + }); + }); + }); + strip.cell(|ui| { + ui.style_mut().wrap_mode = None; + ui.horizontal(|ui| { + if ui.button("Vote").clicked() { + if let Some((vote_key, _)) = misc_infos.get(index) { + pipe.user_data.browser_menu.events.push(UiEvent::VoteMisc( + MiscVoteCategoryKey { + category: category.as_str().try_into().unwrap(), + vote_key: vote_key.clone(), + }, + )); + } + } + }); + }); + }); + }); + + config + .engine + .ui + .path + .query + .insert("vote-misc-search".to_string(), misc_search); +} diff --git a/game/client-ui/src/ingame_menu/call_vote/mod.rs b/game/client-ui/src/ingame_menu/call_vote/mod.rs index 10bb8d2..064b848 100644 --- a/game/client-ui/src/ingame_menu/call_vote/mod.rs +++ b/game/client-ui/src/ingame_menu/call_vote/mod.rs @@ -1,3 +1,4 @@ pub mod main_frame; pub mod map; +pub mod misc; pub mod players; diff --git a/game/client-ui/src/ingame_menu/votes.rs b/game/client-ui/src/ingame_menu/votes.rs index 1023902..03fc395 100644 --- a/game/client-ui/src/ingame_menu/votes.rs +++ b/game/client-ui/src/ingame_menu/votes.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use base::network_string::NetworkString; -use game_interface::votes::{MapVote, MapVoteKey, MAX_CATEGORY_NAME_LEN}; +use game_interface::votes::{MapVote, MapVoteKey, MiscVote, MiscVoteKey, MAX_CATEGORY_NAME_LEN}; use hiarc::{hiarc_safer_rc_refcell, Hiarc}; use url::Url; @@ -12,6 +12,9 @@ pub struct Votes { has_unfinished_map_votes: bool, need_map_votes: bool, thumbnail_server_resource_download_url: Option, + + misc_votes: BTreeMap, BTreeMap>, + need_misc_votes: bool, } #[hiarc_safer_rc_refcell] @@ -53,4 +56,28 @@ impl Votes { pub fn thumbnail_server_resource_download_url(&self) -> Option { self.thumbnail_server_resource_download_url.clone() } + + pub fn request_misc_votes(&mut self) { + self.need_misc_votes = true; + } + + /// Automatically resets the "need" state, so + /// another [`Votes::request_misc_votes`] has to + /// be called. + pub fn needs_misc_votes(&mut self) -> bool { + std::mem::replace(&mut self.need_misc_votes, false) + } + + pub fn fill_misc_votes( + &mut self, + misc_votes: BTreeMap, BTreeMap>, + ) { + self.misc_votes = misc_votes; + } + + pub fn collect_misc_votes( + &self, + ) -> BTreeMap, BTreeMap> { + self.misc_votes.clone() + } } diff --git a/game/game-config/src/config.rs b/game/game-config/src/config.rs index f119fab..78ac12e 100644 --- a/game/game-config/src/config.rs +++ b/game/game-config/src/config.rs @@ -668,6 +668,9 @@ pub struct ConfigServer { /// and local servers). #[default = false] pub auto_map_votes: bool, + /// Path to the map votes file. + #[default = "map_votes.json"] + pub map_votes_path: String, /// Whether to allow spatial chat on this server. /// Note that spatial chat causes lot of network /// traffic. diff --git a/game/game-interface/src/interface.rs b/game/game-interface/src/interface.rs index 91608f2..babd865 100644 --- a/game/game-interface/src/interface.rs +++ b/game/game-interface/src/interface.rs @@ -22,7 +22,7 @@ use crate::{ client_commands::ClientCommand, events::{EventClientInfo, GameEvents}, ghosts::GhostResult, - rcon_commands::{ExecRconCommand, RconCommands}, + rcon_entries::{ExecRconInput, RconEntries}, settings::GameStateSettings, tick_result::TickResult, types::{ @@ -61,6 +61,15 @@ pub struct GameStateCreateOptions { /// If `None`, then no config was found. pub config: Option>, + /// Since the server also takes arguments on startup, + /// these are the initial rcon input that _can_ be handled + /// by the mod. + /// + /// The evaluation priority of [`RconEntries`] is the same. + /// + /// The implementation should automatically skip invalid input without failing. + pub initial_rcon_input: Vec, + /// Which kind of database holds the account information pub account_db: Option, } @@ -125,7 +134,7 @@ pub struct GameStateStaticInfo { /// Chat commands supported by the mod pub chat_commands: ChatCommands, /// Rcon commands supported by the mod - pub rcon_commands: RconCommands, + pub rcon_commands: RconEntries, /// A config file for this mod. /// On a server this config is sent to all clients, @@ -135,6 +144,10 @@ pub struct GameStateStaticInfo { /// should be written to disk, leave this `None`. pub config: Option>, + /// The response of executing the + /// [`GameStateCreateOptions::initial_rcon_input`], if any. + pub initial_rcon_response: Vec, NetworkString<65536>>>, + /// The name of the mod (this name is usually used inside the server browser/info) pub mod_name: NetworkString, /// A version of this mod as string that is shown in server browser. @@ -284,8 +297,8 @@ pub trait GameStateInterface: GameStateCreate { fn rcon_command( &mut self, player_id: Option, - cmd: ExecRconCommand, - ) -> Vec>; + cmd: ExecRconInput, + ) -> Vec, NetworkString<65536>>>; /// The result of a vote that the game implementation should be aware of. fn vote_command(&mut self, cmd: VoteCommand) -> VoteCommandResult; diff --git a/game/game-interface/src/lib.rs b/game/game-interface/src/lib.rs index 0eb06d4..92d7667 100644 --- a/game/game-interface/src/lib.rs +++ b/game/game-interface/src/lib.rs @@ -8,7 +8,7 @@ pub mod events; pub mod ghosts; pub mod interface; pub mod pooling; -pub mod rcon_commands; +pub mod rcon_entries; pub mod settings; pub mod tick_result; pub mod types; diff --git a/game/game-interface/src/rcon_commands.rs b/game/game-interface/src/rcon_commands.rs deleted file mode 100644 index 1c4cd53..0000000 --- a/game/game-interface/src/rcon_commands.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::collections::HashMap; - -use base::network_string::NetworkString; -use command_parser::parser::CommandArg; -use hiarc::Hiarc; -use serde::{Deserialize, Serialize}; - -/// A single rcon command. -#[derive(Debug, Hiarc, Default, Clone, Serialize, Deserialize)] -pub struct RconCommand { - pub args: Vec, - pub usage: NetworkString<65536>, - pub description: NetworkString<65536>, -} - -/// Commands supported by the server. -#[derive(Debug, Hiarc, Default, Clone, Serialize, Deserialize)] -pub struct RconCommands { - /// list of commands and their required args - pub cmds: HashMap, RconCommand>, -} - -#[derive(Debug, Hiarc, Default, Clone, Copy, Serialize, Deserialize)] -pub enum AuthLevel { - #[default] - None, - Moderator, - Admin, -} - -/// A remote console command that a mod might support. -/// Note that some rcon commands are already processed -/// by the server implementation directly, like -/// changing a map. -#[derive(Debug, Hiarc, Clone, Serialize, Deserialize)] -pub struct ExecRconCommand { - /// the raw unprocessed command string. - pub raw: NetworkString<{ 65536 * 2 + 1 }>, - /// The auth level the client has for this command. - pub auth_level: AuthLevel, -} diff --git a/game/game-interface/src/rcon_entries.rs b/game/game-interface/src/rcon_entries.rs new file mode 100644 index 0000000..6122838 --- /dev/null +++ b/game/game-interface/src/rcon_entries.rs @@ -0,0 +1,57 @@ +use std::collections::HashMap; + +use base::network_string::NetworkString; +use command_parser::parser::CommandArg; +use hiarc::Hiarc; +use serde::{Deserialize, Serialize}; + +/// A single rcon command. +#[derive(Debug, Hiarc, Default, Clone, Serialize, Deserialize)] +pub struct RconEntry { + pub args: Vec, + pub usage: NetworkString<65536>, + pub description: NetworkString<65536>, +} + +/// Rcon entries supported by the mod. +/// +/// Contains a list of commands & config variables and their required args. +/// +/// Entry collisions with the server are evaluated in the following order: +/// - Server config variables (highest priority) +/// - Mod rcon commands & variables (this struct field) +/// - Server rcon commands +/// +/// This implies that a mod should usually not use with common prefixes +/// for config variables such as `sv` or `net`, since the server might have +/// variables with that name already. +/// Furthermore this also means that a mod can _override_ rcon commands +/// of the server. +#[derive(Debug, Hiarc, Default, Clone, Serialize, Deserialize)] +pub struct RconEntries { + pub cmds: HashMap, RconEntry>, + pub vars: HashMap, RconEntry>, +} + +#[derive(Debug, Hiarc, Default, Clone, Copy, Serialize, Deserialize)] +pub enum AuthLevel { + #[default] + None, + Moderator, + Admin, +} + +/// A remote console input for the mod to execute. +/// +/// Note that some rcon entries for config variables +/// can collide with the ones from the server +/// and the server has higher priority here. +/// +/// Please see [`RconEntries`] for more information. +#[derive(Debug, Hiarc, Clone, Serialize, Deserialize)] +pub struct ExecRconInput { + /// The raw unprocessed input string. + pub raw: NetworkString<{ 65536 * 2 + 1 }>, + /// The auth level the client has for this execution. + pub auth_level: AuthLevel, +} diff --git a/game/game-network/src/messages.rs b/game/game-network/src/messages.rs index 5bba397..17b3e14 100644 --- a/game/game-network/src/messages.rs +++ b/game/game-network/src/messages.rs @@ -5,11 +5,15 @@ use std::{ }; use base::network_string::{NetworkReducedAsciiString, NetworkString}; +use game_base::network::messages::{ + MsgClAddLocalPlayer, MsgClChatMsg, MsgClInputs, MsgClLoadVotes, MsgClReady, MsgClReadyResponse, + MsgClSnapshotAck, MsgSvAddLocalPlayerResponse, MsgSvChatMsg, MsgSvServerInfo, +}; use game_interface::{ account_info::{AccountInfo, MAX_ACCOUNT_NAME_LEN}, client_commands::{ClientCameraMode, JoinStage}, events::GameEvents, - rcon_commands::RconCommands, + rcon_entries::RconEntries, types::{ character_info::NetworkCharacterInfo, emoticons::EmoticonType, @@ -25,10 +29,6 @@ use game_interface::{ }; use pool::mt_datatypes::PoolCow; use serde::{Deserialize, Serialize}; -use game_base::network::messages::{ - MsgClAddLocalPlayer, MsgClChatMsg, MsgClInputs, MsgClLoadVotes, MsgClReady, MsgClReadyResponse, - MsgClSnapshotAck, MsgSvAddLocalPlayerResponse, MsgSvChatMsg, MsgSvServerInfo, -}; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct MsgSvInputAck { @@ -52,6 +52,13 @@ pub enum MsgSvLoadVotes { }, } +/// Type of votes to reset. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum MsgSvResetVotes { + Map, + Misc, +} + /// Vote result of vote started by a client. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum MsgSvStartVoteResult { @@ -127,12 +134,13 @@ pub enum ServerToClientMessage<'a> { /// A value of `None` must be interpreted as no vote active. StartVoteRes(MsgSvStartVoteResult), Vote(Option), - LoadVote(MsgSvLoadVotes), - RconCommands(RconCommands), + LoadVotes(MsgSvLoadVotes), + ResetVotes(MsgSvResetVotes), + RconEntries(RconEntries), RconExecResult { - /// Since multiple commands could have been executed, - /// this returns a list of strings - results: Vec>, + /// Since multiple commands or confi vars could have been executed/changed, + /// this returns a list of strings. + results: Vec, NetworkString<65536>>>, }, /// If `Ok` returns the new name. AccountRenameRes(Result, NetworkString<1024>>), @@ -165,7 +173,9 @@ pub enum ClientToServerPlayerMessage<'a> { info: Box, }, RconExec { - name: NetworkString<65536>, + /// The raw ident text (with all modifiers as is) + ident_text: NetworkString<65536>, + /// The input args args: NetworkString<65536>, }, } diff --git a/game/game-server/src/map_votes.rs b/game/game-server/src/map_votes.rs index 686c1e8..fd6bf0b 100644 --- a/game/game-server/src/map_votes.rs +++ b/game/game-server/src/map_votes.rs @@ -1,5 +1,6 @@ use std::{ collections::{BTreeMap, HashMap}, + path::Path, sync::Arc, }; @@ -30,9 +31,12 @@ pub struct MapVotes { } impl MapVotes { - pub async fn new(fs: &Arc) -> anyhow::Result { + pub async fn new( + fs: &Arc, + map_votes_file_path: &Path, + ) -> anyhow::Result { let votes_file: MapVotesFile = - serde_json::from_slice(&fs.read_file("map_votes.json".as_ref()).await?)?; + serde_json::from_slice(&fs.read_file(map_votes_file_path).await?)?; Ok(Self { votes: ServerMapVotes { categories: votes_file diff --git a/game/game-server/src/rcon.rs b/game/game-server/src/rcon.rs index cf8a9b6..dde5ded 100644 --- a/game/game-server/src/rcon.rs +++ b/game/game-server/src/rcon.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use base_io::io::Io; use game_interface::{ - rcon_commands::AuthLevel, + rcon_entries::AuthLevel, types::player_info::{AccountId, PlayerUniqueId}, }; use rand::Rng; @@ -57,7 +57,7 @@ impl Rcon { } } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum ServerRconCommand { BanId, KickId, @@ -68,5 +68,7 @@ pub enum ServerRconCommand { Exec, /// Loads server config from a specific path Load, + AddMiscVote, + RemoveMiscVote, RecordDemo, } diff --git a/game/game-server/src/server.rs b/game/game-server/src/server.rs index 6b2504e..67fe536 100644 --- a/game/game-server/src/server.rs +++ b/game/game-server/src/server.rs @@ -104,7 +104,7 @@ use game_interface::{ client_commands::ClientCommand, events::EventClientInfo, interface::{GameStateCreateOptions, GameStateInterface, MAX_MAP_NAME_LEN}, - rcon_commands::{AuthLevel, ExecRconCommand, RconCommand, RconCommands}, + rcon_entries::{AuthLevel, ExecRconInput, RconEntries, RconEntry}, tick_result::TickEvent, types::{ game::{GameEntityId, GameTickType}, @@ -128,7 +128,7 @@ use game_network::{ game_event_generator::{GameEventGenerator, GameEvents}, messages::{ ClientToServerMessage, ClientToServerPlayerMessage, MsgSvInputAck, MsgSvLoadVotes, - MsgSvStartVoteResult, ServerToClientMessage, + MsgSvResetVotes, MsgSvStartVoteResult, ServerToClientMessage, }, }; @@ -165,7 +165,7 @@ enum GameServerDb { Account(GameServerDbAccount), } -type ReponsesAndCmds = (Vec>, Vec); +type ReponsesAndSkipped = (Vec>, Vec); pub struct Server { pub clients: Clients, @@ -220,7 +220,7 @@ pub struct Server { map_votes: ServerMapVotes, map_votes_hash: Hash, misc_votes: BTreeMap, BTreeMap>, - misc_votes_hash: Hash, + misc_votes_hash: Option, // database db: Option>, @@ -355,16 +355,19 @@ impl Server { } fn new_rcon_cmd_chain() -> CommandChain { - let mut rcon_cmds = vec![ + let rcon_cmds = vec![ ( "ban_id".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: vec![CommandArg { ty: CommandArgType::Number, user_ty: Some("PLAYER_ID".try_into().unwrap()), }], - description: "Ban a user with the given player id".try_into().unwrap(), + description: "Ban a user with \ + the given player id" + .try_into() + .unwrap(), usage: "ban_id ".try_into().unwrap(), }, cmd: ServerRconCommand::BanId, @@ -373,12 +376,15 @@ impl Server { ( "kick_id".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: vec![CommandArg { ty: CommandArgType::Number, user_ty: Some("PLAYER_ID".try_into().unwrap()), }], - description: "Kick a user with the given player id".try_into().unwrap(), + description: "Kick a user with \ + the given player id" + .try_into() + .unwrap(), usage: "kick_id ".try_into().unwrap(), }, cmd: ServerRconCommand::KickId, @@ -387,12 +393,12 @@ impl Server { ( "status".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: Default::default(), - description: - "List information about this player such as the connected clients" - .try_into() - .unwrap(), + description: "List information about this player \ + such as the connected clients" + .try_into() + .unwrap(), usage: "status".try_into().unwrap(), }, cmd: ServerRconCommand::Status, @@ -401,7 +407,7 @@ impl Server { ( "record_demo".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: Default::default(), description: "Start to record a server side demo.".try_into().unwrap(), usage: "record_demo".try_into().unwrap(), @@ -412,7 +418,7 @@ impl Server { ( "exec".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: vec![CommandArg { ty: CommandArgType::Text, user_ty: None, @@ -426,7 +432,7 @@ impl Server { ( "load".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: vec![CommandArg { ty: CommandArgType::Text, user_ty: None, @@ -437,15 +443,66 @@ impl Server { cmd: ServerRconCommand::Load, }, ), + ( + "add_vote".try_into().unwrap(), + Command { + rcon: RconEntry { + args: vec![ + CommandArg { + ty: CommandArgType::Text, + user_ty: None, + }, + CommandArg { + ty: CommandArgType::Text, + user_ty: None, + }, + CommandArg { + ty: CommandArgType::Text, + user_ty: None, + }, + ], + description: "Adds a vote to the misc votes taking a \ + category & name, aswell as a rcon command." + .try_into() + .unwrap(), + usage: "add_vote ".try_into().unwrap(), + }, + cmd: ServerRconCommand::AddMiscVote, + }, + ), + ( + "rem_vote".try_into().unwrap(), + Command { + rcon: RconEntry { + args: vec![ + CommandArg { + ty: CommandArgType::Text, + user_ty: None, + }, + CommandArg { + ty: CommandArgType::Text, + user_ty: None, + }, + ], + description: "Removes a vote from the misc votes \ + taking a category & name." + .try_into() + .unwrap(), + usage: "rem_vote ".try_into().unwrap(), + }, + cmd: ServerRconCommand::RemoveMiscVote, + }, + ), ]; + let mut rcon_vars: Vec<_> = Default::default(); config::parsing::parse_conf_values_as_str_list( "sv".into(), &mut |add, _| { - rcon_cmds.push(( + rcon_vars.push(( add.name.try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: add.args, usage: add.usage.as_str().try_into().unwrap(), description: add.description.as_str().try_into().unwrap(), @@ -459,7 +516,10 @@ impl Server { Default::default(), ); - CommandChain::new(rcon_cmds.into_iter().collect()) + CommandChain::new( + rcon_cmds.into_iter().collect(), + rcon_vars.into_iter().collect(), + ) } fn handle_load_config( @@ -478,8 +538,9 @@ impl Server { config: &mut ConfigGame, io: &Io, rcon_chain: &CommandChain, + cache: &mut ParserCache, cmd: parser::Command, - ) -> anyhow::Result { + ) -> anyhow::Result { let Syn::Text(file_path) = &cmd.args[0].0 else { panic!("Command parser returned a non requested command arg"); }; @@ -499,77 +560,78 @@ impl Server { config, io, rcon_chain, + cache, cmds_file.lines().map(|s| s.to_string()).collect(), )) } /// Handles config variable from the cmd lines. /// Returns the reponses of the cmds and - /// returns all cmds that are not related to config variables. + /// returns all lines that are not directly related to + /// __applying__ config variable values. fn handle_config_cmd_lines( config: &mut ConfigGame, io: &Io, rcon_chain: &CommandChain, + cache: &mut ParserCache, lines: Vec, - ) -> ReponsesAndCmds { - let mut remaining_cmds = Vec::default(); + ) -> ReponsesAndSkipped { + let mut skipped_lines = Vec::default(); let mut responses = Vec::default(); for line in lines.into_iter().filter(|l| !l.is_empty()) { - let cmds = - command_parser::parser::parse(&line, &rcon_chain.parser, &mut Default::default()); - - for cmd in cmds { - let handle_cmd = || match cmd { - CommandType::Full(cmd) => { - let Some(chain_cmd) = rcon_chain.cmds.get(&cmd.ident) else { - return Err(anyhow!("Command {} not found", cmd.ident)); - }; + let cmds = command_parser::parser::parse(&line, &rcon_chain.parser, cache); - match chain_cmd.cmd { - ServerRconCommand::ConfVariable => { - handle_config_variable_cmd(&cmd, config).map(|msg| { - format!("Updated value for {}: {}", cmd.cmd_text, msg) - }) - } - ServerRconCommand::Exec => { - match Self::handle_exec(config, io, rcon_chain, cmd) { - Ok((mut res, mut res_remaining_cmds)) => { - responses.append(&mut res); - remaining_cmds.append(&mut res_remaining_cmds); - Ok("".to_string()) + if cmds + .iter() + .any(|cmd| matches!(cmd, CommandType::Partial(_))) + { + // Partial commands are not allowed during intial command arguments + skipped_lines.push(line); + } else { + for cmd in cmds { + let handle_cmd = || match cmd { + CommandType::Full(cmd) => { + let Some(chain_cmd) = rcon_chain.by_ident(&cmd.ident) else { + return Err(anyhow!("Command {} not found", cmd.ident)); + }; + + match chain_cmd.cmd { + ServerRconCommand::ConfVariable => { + handle_config_variable_cmd(&cmd, config).map(|msg| { + format!("Updated value for {}: {}", cmd.cmd_text, msg) + }) + } + ServerRconCommand::Exec => { + match Self::handle_exec(config, io, rcon_chain, cache, cmd) { + Ok((mut res, mut res_skipped_lines)) => { + responses.append(&mut res); + skipped_lines.append(&mut res_skipped_lines); + Ok("".to_string()) + } + Err(err) => Err(err), } - Err(err) => Err(err), } - } - ServerRconCommand::Load => Self::handle_load_config(config, io, cmd), - _ => { - remaining_cmds.push(cmd); - Ok("".to_string()) + ServerRconCommand::Load => { + Self::handle_load_config(config, io, cmd) + } + _ => { + skipped_lines.push(cmd.to_string()); + Ok("".to_string()) + } } } - } - CommandType::Partial(cmd) => { - let Some(cmd) = cmd.ref_cmd_partial() else { - return Err(anyhow!("This command was invalid: {cmd}")); - }; - let Some(chain_cmd) = rcon_chain.cmds.get(&cmd.ident) else { - return Err(anyhow!("Command {} not found", cmd.ident)); - }; - - if let ServerRconCommand::ConfVariable = chain_cmd.cmd { - handle_config_variable_cmd(cmd, config) - .map(|msg| format!("Current value for {}: {}", cmd.cmd_text, msg)) - } else { - Err(anyhow!("Failed to handle config variable: {cmd}")) + CommandType::Partial(_) => { + // cannot happen, bcs of above check + unreachable!(); } - } - }; + }; - let cmd_res = handle_cmd(); - responses.push(cmd_res.map_err(|err| err.to_string())); + let cmd_res = handle_cmd(); + responses.push(cmd_res.map_err(|err| err.to_string())); + } } } - (responses, remaining_cmds) + (responses, skipped_lines) } pub fn new( @@ -584,6 +646,8 @@ impl Server { thread_pool: Arc, io: Io, rcon_chain: CommandChain, + cache: ParserCache, + raw_rcon_input: &[String], ) -> anyhow::Result { let config_db = config_game.sv.db.clone(); let accounts_enabled = !config_db.enable_accounts.is_empty(); @@ -593,9 +657,12 @@ impl Server { let fs = io.fs.clone(); io.rt.spawn(async move { AutoMapVotes::new(&fs).await }) }); + + let map_votes_file_path = config_game.sv.map_votes_path.clone(); let map_votes_file = { let fs = io.fs.clone(); - io.rt.spawn(async move { MapVotes::new(&fs).await }) + io.rt + .spawn(async move { MapVotes::new(&fs, map_votes_file_path.as_ref()).await }) }; let fs = io.fs.clone(); @@ -825,7 +892,7 @@ impl Server { max_players_all_clients: config_game.sv.max_players as usize, rcon_chain, - cache: Default::default(), + cache, network: network_server, connection_bans, @@ -841,11 +908,21 @@ impl Server { &render_mod_name, &render_mod_hash.try_into().unwrap_or_default(), render_mod_required, - config_mod, + GameStateCreateOptions { + hint_max_characters: Some(config_game.sv.max_players as usize), + config: config_mod, + initial_rcon_input: raw_rcon_input + .iter() + .map(|line| ExecRconInput { + raw: NetworkString::new_lossy(line), + auth_level: AuthLevel::Admin, + }) + .collect(), + account_db: accounts.as_ref().map(|a| a.kind), + }, &thread_pool, &io, &game_db, - accounts.as_ref().map(|a| a.kind), config_game.sv.spatial_chat, config_game.sv.download_server_port_v4, config_game.sv.download_server_port_v6, @@ -875,7 +952,7 @@ impl Server { map_votes, map_votes_hash, misc_votes: Default::default(), - misc_votes_hash: generate_hash_for(&[]), + misc_votes_hash: None, // database db, @@ -1111,14 +1188,27 @@ impl Server { None } + fn broadcast_in_order_filtered( + &self, + packet: ServerToClientMessage<'_>, + channel: NetworkInOrderChannel, + f: impl Fn(&(&NetworkConnectionId, &ServerClient)) -> bool, + ) { + self.clients + .clients + .iter() + .filter(f) + .for_each(|(send_con_id, _)| { + self.network.send_in_order_to(&packet, send_con_id, channel); + }); + } + fn broadcast_in_order( &self, packet: ServerToClientMessage<'_>, channel: NetworkInOrderChannel, ) { - self.clients.clients.keys().for_each(|send_con_id| { - self.network.send_in_order_to(&packet, send_con_id, channel); - }); + self.broadcast_in_order_filtered(packet, channel, |_| true); } fn send_vote(&self, vote_state: Option, start_time: Duration) { @@ -1604,36 +1694,15 @@ impl Server { .game .try_overwrite_player_character_info(player_id, &info, version); } - ClientToServerPlayerMessage::RconExec { name, args } => { + ClientToServerPlayerMessage::RconExec { ident_text, args } => { let auth_level = player.auth.level; if matches!(auth_level, AuthLevel::Moderator | AuthLevel::Admin) { - let res = if self - .game_server - .game - .info - .rcon_commands - .cmds - .contains_key(name.as_str()) - { - self.game_server.game.rcon_command( - Some(*player_id), - ExecRconCommand { - raw: format!("{} {}", name.as_str(), args.as_str()) - .as_str() - .try_into() - .unwrap(), - auth_level, - }, - ) - } else { - // if not a mod rcon, try to execute it inside the server - let cmds = command_parser::parser::parse( - &format!("{} {}", name.as_str(), args.as_str()), - &self.rcon_chain.parser, - &mut self.cache, - ); - self.handle_rcon_commands(Some(player_id), auth_level, cmds) - }; + let res = self.handle_rcon_commands( + Some(player_id), + auth_level, + &format!("{} {}", ident_text.as_str(), args.as_str()), + false, + ); self.network.send_in_order_to( &ServerToClientMessage::RconExecResult { results: res }, con_id, @@ -1680,20 +1749,33 @@ impl Server { } fn send_rcon_commands(&self, con_id: &NetworkConnectionId) { - let mut rcon_commands = RconCommands { - cmds: self + // Server variables have highest prio + let mut rcon_entries = RconEntries { + vars: self .rcon_chain - .cmds + .var_list() .iter() .map(|(name, cmd)| (name.clone(), cmd.rcon.clone())) .collect(), + cmds: Default::default(), }; - // mod rcon commands have higher prio - rcon_commands + // Mod rcon variables & commands next + rcon_entries + .vars + .extend(self.game_server.game.info.rcon_commands.vars.clone()); + rcon_entries .cmds .extend(self.game_server.game.info.rcon_commands.cmds.clone()); + // Then server commands + rcon_entries.cmds.extend( + self.rcon_chain + .cmd_list() + .iter() + .map(|(name, cmd)| (name.clone(), cmd.rcon.clone())), + ); + self.network.send_in_order_to( - &ServerToClientMessage::RconCommands(rcon_commands), + &ServerToClientMessage::RconEntries(rcon_entries), con_id, NetworkInOrderChannel::Custom( 7302, // reads as "rcon" @@ -1704,161 +1786,275 @@ impl Server { fn handle_cmd_full( &mut self, cmd: parser::Command, + player_id: Option<&PlayerId>, + auth: AuthLevel, responses: &mut Vec>, - remaining_cmds: &mut Vec, + skipped_lines: &mut Vec, + ignore_mod_cmds: bool, ) -> anyhow::Result { - let Some(chain_cmd) = self.rcon_chain.cmds.get(&cmd.ident) else { - return Err(anyhow!("Command {} not found", cmd.ident)); - }; - - fn ban_or_kick( - cmd: &parser::Command, - game_server: &ServerGame, - clients: &mut Clients, - process: impl FnOnce(&mut ServerClient, NetworkConnectionId), - ) -> anyhow::Result<()> { - let Syn::Number(num) = &cmd.args[0].0 else { - panic!("Command parser returned a non requested command arg"); - }; - let ban_id: GameEntityId = num.parse()?; - let ban_id: PlayerId = ban_id.into(); - if let Some((client, network_id)) = - game_server.players.get(&ban_id).and_then(|player| { - clients - .clients - .get_mut(&player.network_id) - .map(|c| (c, player.network_id)) - }) - { - process(client, network_id); + if self + .game_server + .game + .info + .rcon_commands + .cmds + .contains_key(&cmd.ident) + || self + .game_server + .game + .info + .rcon_commands + .vars + .contains_key(&cmd.ident) + { + // This _if_ is purposely after the ident check, + // because the server commands with same ident + // should be ignored too. + if !ignore_mod_cmds { + responses.extend( + self.game_server + .game + .rcon_command( + player_id.copied(), + ExecRconInput { + raw: NetworkString::new_lossy(cmd.to_string()), + auth_level: auth, + }, + ) + .into_iter() + .map(|r| match r { + Ok(r) => Ok(r.into()), + Err(err) => Err(err.into()), + }), + ); } + Ok("".into()) + } else { + let Some(chain_cmd) = self.rcon_chain.by_ident(&cmd.ident) else { + return Err(anyhow!("Command {} not found", cmd.ident)); + }; - Ok(()) - } + fn ban_or_kick( + cmd: &parser::Command, + game_server: &ServerGame, + clients: &mut Clients, + process: impl FnOnce(&mut ServerClient, NetworkConnectionId), + ) -> anyhow::Result<()> { + let Syn::Number(num) = &cmd.args[0].0 else { + panic!("Command parser returned a non requested command arg"); + }; + let ban_id: GameEntityId = num.parse()?; + let ban_id: PlayerId = ban_id.into(); + if let Some((client, network_id)) = + game_server.players.get(&ban_id).and_then(|player| { + clients + .clients + .get_mut(&player.network_id) + .map(|c| (c, player.network_id)) + }) + { + process(client, network_id); + } - match chain_cmd.cmd { - ServerRconCommand::BanId => { - let mut res = String::new(); - ban_or_kick(&cmd, &self.game_server, &mut self.clients, |client, _| { - let ty = BanType::Admin; - let until = None; + Ok(()) + } - client.drop_reason = Some(PlayerDropReason::Banned { - reason: PlayerBanReason::Rcon, - until, - }); + match chain_cmd.cmd { + ServerRconCommand::BanId => { + let mut res = String::new(); + ban_or_kick(&cmd, &self.game_server, &mut self.clients, |client, _| { + let ty = BanType::Admin; + let until = None; - // ban the player - let ids = self.connection_bans.ban_ip(client.ip, ty.clone(), until); - for id in &ids { - self.network.kick( - id, - KickType::Ban(Banned { - msg: ty.clone(), - until, - }), - ); - } - let text: String = ids - .into_iter() - .map(|id| id.to_string()) - .collect::>() - .join(", "); - res = format!("Banned the following id(s): {}", text); - })?; - anyhow::Ok(res) - } - ServerRconCommand::KickId => { - let mut res = String::new(); - ban_or_kick( - &cmd, - &self.game_server, - &mut self.clients, - |c, network_id| { - c.drop_reason = Some(PlayerDropReason::Kicked(PlayerKickReason::Rcon)); - - self.network - .kick(&network_id, KickType::Kick("by admin".to_string())); - let text: String = c - .players - .keys() + client.drop_reason = Some(PlayerDropReason::Banned { + reason: PlayerBanReason::Rcon, + until, + }); + + // ban the player + let ids = self.connection_bans.ban_ip(client.ip, ty.clone(), until); + for id in &ids { + self.network.kick( + id, + KickType::Ban(Banned { + msg: ty.clone(), + until, + }), + ); + } + let text: String = ids + .into_iter() .map(|id| id.to_string()) .collect::>() .join(", "); - res = format!("Kicked the following id(s): {}", text); - }, - )?; - anyhow::Ok(res) - } - ServerRconCommand::Status => { - let mut res: Vec = Default::default(); - for client in self.clients.clients.values() { - res.push(format!("client ip: {}", client.ip)); - for (player_id, player) in client.players.iter() { - res.push(format!( - " player_id: {}, client_id: {}", - player_id, player.id - )); + res = format!("Banned the following id(s): {}", text); + })?; + anyhow::Ok(res) + } + ServerRconCommand::KickId => { + let mut res = String::new(); + ban_or_kick( + &cmd, + &self.game_server, + &mut self.clients, + |c, network_id| { + c.drop_reason = Some(PlayerDropReason::Kicked(PlayerKickReason::Rcon)); + + self.network + .kick(&network_id, KickType::Kick("by admin".to_string())); + let text: String = c + .players + .keys() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + res = format!("Kicked the following id(s): {}", text); + }, + )?; + anyhow::Ok(res) + } + ServerRconCommand::Status => { + let mut res: Vec = Default::default(); + for client in self.clients.clients.values() { + res.push(format!("client ip: {}", client.ip)); + for (player_id, player) in client.players.iter() { + res.push(format!( + " player_id: {}, client_id: {}", + player_id, player.id + )); + } } + Ok(res.join("\n")) } - Ok(res.join("\n")) - } - ServerRconCommand::ConfVariable => { - handle_config_variable_cmd(&cmd, &mut self.config_game) - .map(|msg| format!("Updated value for {}: {}", cmd.cmd_text, msg)) - } - ServerRconCommand::Exec => { - match Self::handle_exec(&mut self.config_game, &self.io, &self.rcon_chain, cmd) { - Ok((mut res, mut res_remaining_cmds)) => { - responses.append(&mut res); - remaining_cmds.append(&mut res_remaining_cmds); - Ok("".to_string()) + ServerRconCommand::ConfVariable => { + handle_config_variable_cmd(&cmd, &mut self.config_game) + .map(|msg| format!("Updated value for {}: {}", cmd.cmd_text, msg)) + } + ServerRconCommand::Exec => { + match Self::handle_exec( + &mut self.config_game, + &self.io, + &self.rcon_chain, + &mut self.cache, + cmd, + ) { + Ok((mut res, mut res_skipped_lines)) => { + responses.append(&mut res); + skipped_lines.append(&mut res_skipped_lines); + Ok("".to_string()) + } + Err(err) => Err(err), } - Err(err) => Err(err), } - } - ServerRconCommand::Load => { - Self::handle_load_config(&mut self.config_game, &self.io, cmd) - } - ServerRconCommand::RecordDemo => { - let had_demo_recorder = self.demo_recorder.is_some(); - self.demo_recorder = Some(DemoRecorder::new( - DemoRecorderCreateProps { - base: DemoRecorderCreatePropsBase { - map: self.game_server.map.name.as_str().try_into().unwrap(), - map_hash: generate_hash_for(&self.game_server.map.map_file), - game_options: GameStateCreateOptions { - hint_max_characters: Some(self.config_game.sv.max_players as usize), - account_db: None, - config: self.game_server.game.info.config.clone(), + ServerRconCommand::Load => { + Self::handle_load_config(&mut self.config_game, &self.io, cmd) + } + ServerRconCommand::AddMiscVote => { + let Syn::Text(category) = &cmd.args[0].0 else { + panic!("Command parser returned a non requested command arg"); + }; + let Syn::Text(name) = &cmd.args[1].0 else { + panic!("Command parser returned a non requested command arg"); + }; + let Syn::Text(cmd) = &cmd.args[2].0 else { + panic!("Command parser returned a non requested command arg"); + }; + let category = category.as_str().try_into()?; + let display_name = name.as_str().try_into()?; + let command = cmd.as_str().try_into()?; + let res = format!("Added vote {name} in {category}"); + self.misc_votes + .entry(category) + .or_default() + .insert(MiscVoteKey { display_name }, MiscVote { command }); + self.misc_votes_hash = None; + + self.broadcast_in_order_filtered( + ServerToClientMessage::ResetVotes(MsgSvResetVotes::Misc), + NetworkInOrderChannel::Custom(7013), // This number reads as "vote". + |(_, client)| client.loaded_misc_votes, + ); + self.clients + .clients + .values_mut() + .for_each(|c| c.loaded_misc_votes = false); + Ok(res) + } + ServerRconCommand::RemoveMiscVote => { + let Syn::Text(category) = &cmd.args[0].0 else { + panic!("Command parser returned a non requested command arg"); + }; + let Syn::Text(name) = &cmd.args[1].0 else { + panic!("Command parser returned a non requested command arg"); + }; + + let display_name = name.as_str().try_into()?; + + let res = format!("Remove vote {name} from {category}"); + if let Some(votes) = self.misc_votes.get_mut(category) { + votes.remove(&MiscVoteKey { display_name }); + + if votes.is_empty() { + self.misc_votes.remove(category); + } + } + + self.misc_votes_hash = None; + + self.broadcast_in_order_filtered( + ServerToClientMessage::ResetVotes(MsgSvResetVotes::Misc), + NetworkInOrderChannel::Custom(7013), // This number reads as "vote". + |(_, client)| client.loaded_misc_votes, + ); + self.clients + .clients + .values_mut() + .for_each(|c| c.loaded_misc_votes = false); + Ok(res) + } + ServerRconCommand::RecordDemo => { + let had_demo_recorder = self.demo_recorder.is_some(); + self.demo_recorder = Some(DemoRecorder::new( + DemoRecorderCreateProps { + base: DemoRecorderCreatePropsBase { + map: self.game_server.map.name.as_str().try_into().unwrap(), + map_hash: generate_hash_for(&self.game_server.map.map_file), + game_options: GameStateCreateOptions { + hint_max_characters: Some( + self.config_game.sv.max_players as usize, + ), + account_db: None, + config: self.game_server.game.info.config.clone(), + initial_rcon_input: Default::default(), + }, + required_resources: self.game_server.required_resources.clone(), + client_local_infos: Default::default(), + physics_module: self.game_server.game_mod.clone(), + render_module: self.game_server.render_mod.clone(), + physics_group_name: self + .game_server + .game + .info + .options + .physics_group_name + .clone(), }, - required_resources: self.game_server.required_resources.clone(), - client_local_infos: Default::default(), - physics_module: self.game_server.game_mod.clone(), - render_module: self.game_server.render_mod.clone(), - physics_group_name: self - .game_server - .game - .info - .options - .physics_group_name - .clone(), + io: self.io.clone(), + in_memory: None, }, - io: self.io.clone(), - in_memory: None, - }, - self.game_server.game.info.ticks_in_a_second, - Some("server_demos".as_ref()), - None, - )); - Ok(format!( - "Started demo recording.{}", - if had_demo_recorder { - "\nA previous recording was stopped in that process." - } else { - "" - } - )) + self.game_server.game.info.ticks_in_a_second, + Some("server_demos".as_ref()), + None, + )); + Ok(format!( + "Started demo recording.{}", + if had_demo_recorder { + "\nA previous recording was stopped in that process." + } else { + "" + } + )) + } } } } @@ -1866,61 +2062,151 @@ impl Server { fn handle_cmd( &mut self, cmd: parser::CommandType, + player_id: Option<&PlayerId>, + auth: AuthLevel, responses: &mut Vec>, - remaining_cmds: &mut Vec, + skipped_lines: &mut Vec, + ignore_mod_cmds: bool, ) -> anyhow::Result { match cmd { - CommandType::Full(cmd) => self.handle_cmd_full(cmd, responses, remaining_cmds), + CommandType::Full(cmd) => self.handle_cmd_full( + cmd, + player_id, + auth, + responses, + skipped_lines, + ignore_mod_cmds, + ), CommandType::Partial(cmd) => { let Some(cmd) = cmd.ref_cmd_partial() else { return Err(anyhow!("This command was invalid: {cmd}")); }; - let Some(chain_cmd) = self.rcon_chain.cmds.get(&cmd.ident) else { - return Err(anyhow!("Command {} not found", cmd.ident)); - }; - - if let ServerRconCommand::ConfVariable = chain_cmd.cmd { - handle_config_variable_cmd(cmd, &mut self.config_game) - .map(|msg| format!("Current value for {}: {}", cmd.cmd_text, msg)) + if self + .game_server + .game + .info + .rcon_commands + .cmds + .contains_key(&cmd.ident) + || self + .game_server + .game + .info + .rcon_commands + .vars + .contains_key(&cmd.ident) + { + // This _if_ is purposely after the ident check, + // because the server commands with same ident + // should be ignored too. + if !ignore_mod_cmds { + responses.extend( + self.game_server + .game + .rcon_command( + player_id.copied(), + ExecRconInput { + raw: NetworkString::new_lossy(cmd.to_string()), + auth_level: auth, + }, + ) + .into_iter() + .map(|r| match r { + Ok(r) => Ok(r.into()), + Err(err) => Err(err.into()), + }), + ); + } + Ok("".into()) } else { - Err(anyhow!("This command was invalid: {cmd}")) + let Some(chain_cmd) = self.rcon_chain.by_ident(&cmd.ident) else { + return Err(anyhow!("Command {} not found", cmd.ident)); + }; + + if let ServerRconCommand::ConfVariable = chain_cmd.cmd { + handle_config_variable_cmd(cmd, &mut self.config_game) + .map(|msg| format!("Current value for {}: {}", cmd.cmd_text, msg)) + } else { + Err(anyhow!("This command was invalid: {cmd}")) + } } } } } + /// Returns the responses of the executed commands fn handle_rcon_commands( &mut self, - _player_id: Option<&PlayerId>, - _auth: AuthLevel, - cmds: Vec, - ) -> Vec> { - let mut remaining_cmds = Vec::default(); + player_id: Option<&PlayerId>, + auth: AuthLevel, + line: &str, + ignore_mod_cmds: bool, + ) -> Vec, NetworkString<65536>>> { + let parser_entries = self.game_server.parser.get_or_insert_with(|| { + self.rcon_chain + .cmd_list() + .clone() + .into_iter() + .map(|(key, val)| (key, val.rcon.args)) + .chain( + self.game_server + .game + .info + .rcon_commands + .cmds + .clone() + .into_iter() + .map(|(key, val)| (key, val.args)) + .chain( + self.game_server + .game + .info + .rcon_commands + .vars + .clone() + .into_iter() + .map(|(key, val)| (key, val.args)), + ), + ) + .chain( + self.rcon_chain + .var_list() + .clone() + .into_iter() + .map(|(key, val)| (key, val.rcon.args)), + ) + .collect() + }); + let cmds = command_parser::parser::parse(line, parser_entries, &mut self.cache); + let mut skipped_lines = Vec::default(); let mut responses = Vec::default(); - let mut res: Vec> = Default::default(); + let mut res: Vec, NetworkString<65536>>> = Default::default(); for cmd in cmds { - match self.handle_cmd(cmd, &mut responses, &mut remaining_cmds) { + match self.handle_cmd( + cmd, + player_id, + auth, + &mut responses, + &mut skipped_lines, + ignore_mod_cmds, + ) { Ok(msg) => { - res.push(NetworkString::new_lossy(msg)); + res.push(Ok(NetworkString::new_lossy(msg))); } Err(err) => { - res.push(NetworkString::new_lossy(err.to_string())); + res.push(Err(NetworkString::new_lossy(err.to_string()))); } } // directly add the current reponses after command handling - res.extend(responses.drain(..).map(|s| { - NetworkString::new_lossy(match s { - Ok(s) => s, - Err(s) => s, - }) + res.extend(responses.drain(..).map(|s| match s { + Ok(s) => Ok(NetworkString::new_lossy(s)), + Err(s) => Err(NetworkString::new_lossy(s)), })); } - if !remaining_cmds.is_empty() { - res.append(&mut self.handle_rcon_commands( - _player_id, - _auth, - remaining_cmds.into_iter().map(CommandType::Full).collect(), - )); + if !skipped_lines.is_empty() { + for line in skipped_lines { + res.append(&mut self.handle_rcon_commands(player_id, auth, &line, ignore_mod_cmds)); + } } res } @@ -2221,7 +2507,7 @@ impl Server { if cached_votes.is_none_or(|hash| hash != self.map_votes_hash) { self.network.send_unordered_to( - &ServerToClientMessage::LoadVote(MsgSvLoadVotes::Map { + &ServerToClientMessage::LoadVotes(MsgSvLoadVotes::Map { categories: self.map_votes.categories.clone(), has_unfinished_map_votes: self .map_votes @@ -2236,9 +2522,21 @@ impl Server { if !client.loaded_misc_votes { client.loaded_misc_votes = true; - if cached_votes.is_none_or(|hash| hash != self.misc_votes_hash) { + if self.misc_votes_hash.is_none() { + self.misc_votes_hash = Some(generate_hash_for( + &bincode::serde::encode_to_vec( + &self.misc_votes, + bincode::config::standard(), + ) + .unwrap(), + )); + } + + if cached_votes + .is_none_or(|hash| Some(hash) != self.misc_votes_hash) + { self.network.send_unordered_to( - &ServerToClientMessage::LoadVote(MsgSvLoadVotes::Misc { + &ServerToClientMessage::LoadVotes(MsgSvLoadVotes::Misc { votes: self.misc_votes.clone(), }), con_id, @@ -3236,11 +3534,15 @@ impl Server { &render_mod_name, &render_mod_hash.try_into().unwrap_or_default(), render_mod_required, - config, + GameStateCreateOptions { + hint_max_characters: Some(self.config_game.sv.max_players as usize), + config, + initial_rcon_input: Default::default(), + account_db: self.accounts.as_ref().map(|a| a.kind), + }, &self.thread_pool, &self.io, &self.game_db, - self.accounts.as_ref().map(|a| a.kind), self.config_game.sv.spatial_chat, self.config_game.sv.download_server_port_v4, self.config_game.sv.download_server_port_v6, @@ -3369,12 +3671,21 @@ pub fn ddnet_server_main( let rcon_chain = Server::new_rcon_cmd_chain(); - let (msgs, remaining_cmds) = - Server::handle_config_cmd_lines(&mut config_game, &io, &rcon_chain, args); + let mut cache: ParserCache = Default::default(); + let (msgs, skipped_lines) = + Server::handle_config_cmd_lines(&mut config_game, &io, &rcon_chain, &mut cache, args); for msg in msgs { match msg { - Ok(msg) => log::info!("{msg}"), - Err(err) => log::error!("{err}"), + Ok(msg) => { + if !msg.is_empty() { + log::info!("{msg}"); + } + } + Err(err) => { + if !err.is_empty() { + log::error!("{err}"); + } + } } } @@ -3399,13 +3710,39 @@ pub fn ddnet_server_main( thread_pool, io, rcon_chain, + cache, + &skipped_lines, )?; // Handle remaining args after the server started. - for cmd in remaining_cmds { - match server.handle_cmd_full(cmd, &mut Default::default(), &mut Default::default()) { - Ok(res) => log::info!("{res}"), - Err(err) => log::error!("{err}"), + for line in skipped_lines { + for res in server.handle_rcon_commands(None, AuthLevel::Admin, &line, true) { + match res { + Ok(res) => { + if !res.is_empty() { + log::info!("{res}"); + } + } + Err(err) => { + if !err.is_empty() { + log::error!("{err}"); + } + } + } + } + } + for msg in &server.game_server.game.info.initial_rcon_response { + match msg { + Ok(res) => { + if !res.is_empty() { + log::info!("{res}"); + } + } + Err(err) => { + if !err.is_empty() { + log::error!("{err}"); + } + } } } diff --git a/game/game-server/src/server_game.rs b/game/game-server/src/server_game.rs index 3522b0d..cc6430d 100644 --- a/game/game-server/src/server_game.rs +++ b/game/game-server/src/server_game.rs @@ -7,13 +7,14 @@ use anyhow::anyhow; use base::{ hash::{fmt_hash, name_and_hash, Hash}, linked_hash_map_view::FxLinkedHashMap, - network_string::NetworkReducedAsciiString, + network_string::{NetworkReducedAsciiString, NetworkString}, }; use base_http::http_server::HttpDownloadServer; use base_io::io::Io; use base_io_traits::fs_traits::FileSystemWatcherItemInterface; use cache::Cache; -use game_database::traits::{DbInterface, DbKind}; +use command_parser::parser::CommandArg; +use game_database::traits::DbInterface; use game_state_wasm::game::state_wasm_manager::{ GameStateMod, GameStateWasmManager, STATE_MODS_PATH, @@ -22,9 +23,13 @@ use map::map::{resources::MapResourceMetaData, Map}; use network::network::connection::NetworkConnectionId; use pool::{datatypes::PoolFxLinkedHashMap, pool::Pool}; +use game_base::{ + network::messages::{GameModification, RenderModification, RequiredResources}, + player_input::PlayerInput, +}; use game_interface::{ interface::{GameStateCreateOptions, GameStateInterface, MAX_MAP_NAME_LEN}, - rcon_commands::AuthLevel, + rcon_entries::AuthLevel, types::{ emoticons::EmoticonType, game::GameTickType, @@ -35,10 +40,6 @@ use game_interface::{ }, votes::{VoteState, Voted}, }; -use game_base::{ - network::messages::{GameModification, RenderModification, RequiredResources}, - player_input::PlayerInput, -}; use crate::spatial_chat::SpatialWorld; @@ -385,6 +386,9 @@ pub struct ServerGame { pub cached_character_infos: PoolFxLinkedHashMap, + // command parsing + pub parser: Option, Vec>>, + // pools pub(crate) inps_pool: Pool>, } @@ -396,11 +400,10 @@ impl ServerGame { render_mod: &str, render_mod_hash: &[u8; 32], render_mod_required: bool, - config: Option>, + create_options: GameStateCreateOptions, runtime_thread_pool: &Arc, io: &Io, db: &Arc, - account_db: Option, spatial_chat: bool, download_server_port_v4: u16, download_server_port_v6: u16, @@ -461,11 +464,7 @@ impl ServerGame { game_state_mod, map.map_file.clone(), map.name.clone(), - GameStateCreateOptions { - hint_max_characters: None, // TODO: - config, - account_db, - }, + create_options, io, db.clone(), )?; @@ -560,6 +559,8 @@ impl ServerGame { spatial_world: spatial_chat.then(SpatialWorld::default), + parser: None, + cached_character_infos: PoolFxLinkedHashMap::new_without_pool(), inps_pool: Pool::with_capacity(2), diff --git a/game/game-state-wasm/src/game/state_wasm.rs b/game/game-state-wasm/src/game/state_wasm.rs index 9bc7c37..2c0b7a8 100644 --- a/game/game-state-wasm/src/game/state_wasm.rs +++ b/game/game-state-wasm/src/game/state_wasm.rs @@ -19,7 +19,7 @@ pub mod state_wasm { use game_interface::interface::{ GameStateCreate, GameStateCreateOptions, GameStateStaticInfo, MAX_MAP_NAME_LEN, }; - use game_interface::rcon_commands::ExecRconCommand; + use game_interface::rcon_entries::ExecRconInput; use game_interface::settings::GameStateSettings; use game_interface::tick_result::TickResult; use game_interface::types::character_info::NetworkCharacterInfo; @@ -146,8 +146,8 @@ pub mod state_wasm { fn rcon_command( &mut self, player_id: Option, - cmd: ExecRconCommand, - ) -> Vec> { + cmd: ExecRconInput, + ) -> Vec, NetworkString<65536>>> { } #[wasm_func_auto_call] diff --git a/game/game-state-wasm/src/game/state_wasm_manager.rs b/game/game-state-wasm/src/game/state_wasm_manager.rs index bd287bb..f39b022 100644 --- a/game/game-state-wasm/src/game/state_wasm_manager.rs +++ b/game/game-state-wasm/src/game/state_wasm_manager.rs @@ -19,7 +19,7 @@ use game_interface::interface::{ GameStateCreate, GameStateCreateOptions, GameStateServerOptions, GameStateStaticInfo, MAX_MAP_NAME_LEN, }; -use game_interface::rcon_commands::ExecRconCommand; +use game_interface::rcon_entries::ExecRconInput; use game_interface::settings::GameStateSettings; use game_interface::tick_result::TickResult; use game_interface::types::character_info::NetworkCharacterInfo; @@ -140,6 +140,8 @@ impl GameStateWasmManager { mod_name: "unknown".try_into().unwrap(), version: "".try_into().unwrap(), options: GameStateServerOptions::default(), + + initial_rcon_response: Default::default(), }; let state = StateWasm::new( map, @@ -261,8 +263,8 @@ impl GameStateInterface for GameStateWasmManager { fn rcon_command( &mut self, player_id: Option, - cmd: ExecRconCommand, - ) -> Vec> { + cmd: ExecRconInput, + ) -> Vec, NetworkString<65536>>> { self.state.as_mut().rcon_command(player_id, cmd) } diff --git a/game/vanilla/src/command_chain.rs b/game/vanilla/src/command_chain.rs index fd1c83f..40c1633 100644 --- a/game/vanilla/src/command_chain.rs +++ b/game/vanilla/src/command_chain.rs @@ -2,26 +2,54 @@ use std::collections::HashMap; use base::network_string::NetworkString; use command_parser::parser::CommandArg; -use game_interface::rcon_commands::RconCommand; +use game_interface::rcon_entries::RconEntry; -#[derive(Debug)] +/// A command entry which usually triggers +/// to add a cmd of type `T` to be added to a +/// handler queue. +/// +/// For config variables this is usually +/// one enum variant for all vars. +#[derive(Debug, Clone)] pub struct Command { - pub rcon: RconCommand, + pub rcon: RconEntry, pub cmd: T, } +/// All commands & config variables together build +/// a command chain for the parser and evaluation. #[derive(Debug)] pub struct CommandChain { - pub cmds: HashMap, Command>, + cmds: HashMap, Command>, + vars: HashMap, Command>, pub parser: HashMap, Vec>, } impl CommandChain { - pub fn new(cmds: HashMap, Command>) -> Self { + pub fn new( + cmds: HashMap, Command>, + vars: HashMap, Command>, + ) -> Self { let parser = cmds .iter() .map(|(name, cmd)| (name.clone(), cmd.rcon.args.clone())) + .chain( + vars.iter() + .map(|(name, cmd)| (name.clone(), cmd.rcon.args.clone())), + ) .collect(); - Self { cmds, parser } + Self { cmds, vars, parser } + } + + pub fn by_ident(&self, ident: &str) -> Option<&Command> { + self.cmds.get(ident).or_else(|| self.vars.get(ident)) + } + + pub fn cmd_list(&self) -> &HashMap, Command> { + &self.cmds + } + + pub fn var_list(&self) -> &HashMap, Command> { + &self.vars } } diff --git a/game/vanilla/src/state.rs b/game/vanilla/src/state.rs index 023cc0c..39b3091 100644 --- a/game/vanilla/src/state.rs +++ b/game/vanilla/src/state.rs @@ -9,7 +9,7 @@ pub mod state { use base::linked_hash_map_view::FxLinkedHashMap; use base::network_string::{NetworkReducedAsciiString, NetworkString}; use base_io::runtime::{IoRuntime, IoRuntimeTask}; - use command_parser::parser::{CommandArg, CommandArgType, CommandType, ParserCache, Syn}; + use command_parser::parser::{self, CommandArg, CommandArgType, CommandType, ParserCache, Syn}; use config::parsing::parse_conf_values_as_str_list; use config::traits::ConfigInterface; use ddnet_accounts_types::account_id::AccountId; @@ -26,7 +26,7 @@ pub mod state { }; use game_interface::ghosts::GhostResult; use game_interface::pooling::GamePooling; - use game_interface::rcon_commands::{AuthLevel, ExecRconCommand, RconCommand, RconCommands}; + use game_interface::rcon_entries::{AuthLevel, ExecRconInput, RconEntries, RconEntry}; use game_interface::settings::GameStateSettings; use game_interface::tick_result::TickResult; use game_interface::types::character_info::{ @@ -245,6 +245,65 @@ pub mod state { matches!(conf, ConfigGameType::Ctf) } + /// Returns the unhandled commands + fn handle_initial_args( + lines: Vec, + config: &mut ConfigVanilla, + rcon_chain: &CommandChain, + cache: &mut ParserCache, + ) -> ( + Vec, NetworkString<65536>>>, + Vec, + ) { + let mut res: Vec, NetworkString<65536>>> = + Default::default(); + let mut res_cmds: Vec = Default::default(); + for line in lines { + let cmds = command_parser::parser::parse(&line, &rcon_chain.parser, cache); + for cmd in cmds { + let handle_cmd = || match cmd { + CommandType::Full(cmd) => { + let Some(chain_cmd) = rcon_chain.by_ident(&cmd.ident) else { + return Err(anyhow!("Rcon command {} was not found", cmd.ident)); + }; + + match chain_cmd.cmd { + VanillaRconCommand::ConfVariable => { + let mut van_config = ConfigVanillaWrapper { + vanilla: config.clone(), + }; + match handle_config_variable_cmd(&cmd, &mut van_config).map( + |msg| { + format!("Updated value for {}: {}", cmd.cmd_text, msg) + }, + ) { + Ok(res) => { + *config = van_config.vanilla; + Ok(res) + } + Err(err) => Err(err), + } + } + _ => { + res_cmds.push(cmd); + Ok("".into()) + } + } + } + CommandType::Partial(cmd) => Err(anyhow!( + "Initial args expect full command, but was invalid: {cmd}" + )), + }; + + match handle_cmd() { + Ok(msg) => res.push(Ok(NetworkString::new_lossy(msg))), + Err(err) => res.push(Err(NetworkString::new_lossy(err.to_string()))), + } + } + } + (res, res_cmds) + } + fn new_impl( map: Vec, map_name: NetworkReducedAsciiString, @@ -255,90 +314,11 @@ pub mod state { where Self: Sized, { - let db_task = io_rt.spawn(async move { - if !db.kinds().is_empty() { - if let Err(err) = save::setup(db.clone()).await { - log::warn!( - target: "sql", - "failed to setup databases: {}", err - ); - return Err(err); - } - - let acc_info = AccountInfo::new(db.clone(), options.account_db).await; - if let Err(err) = &acc_info { - log::warn!( - target: "sql", - "failed to prepare account info sql: {}", err); - } - - let account_created = match AccountCreated::new(db, options.account_db).await { - Ok(account_created) => Some(account_created), - Err(err) => { - log::warn!( - target: "sql", - "failed to prepare account_created sql: {}", err); - None - } - }; - - let statements = - account_created.map(|account_created| GameStatements { account_created }); - - Ok(statements.zip(acc_info.ok())) - } else { - Err(anyhow!("Databases not active.")) - } - }); - - let physics_group = Map::read_physics_group(&map)?; - - let w = physics_group.attr.width.get() as u32; - let h = physics_group.attr.height.get() as u32; - - let tiles = physics_group.get_game_layer_tiles(); - - let collision = Collision::new(&physics_group, true)?; - let game_objects = GameObjectDefinitions::new(tiles, w, h); - - let mut spawns: Vec = Default::default(); - let mut spawns_red: Vec = Default::default(); - let mut spawns_blue: Vec = Default::default(); - tiles.iter().enumerate().for_each(|(index, tile)| { - let x = index % w as usize; - let y = index / w as usize; - let pos = vec2::new(x as f32 * 32.0 + 16.0, y as f32 * 32.0 + 16.0); - if tile.index == EEntityTiles::Spawn as u8 { - spawns.push(pos); - } else if tile.index == EEntityTiles::SpawnRed as u8 { - spawns_red.push(pos); - } else if tile.index == EEntityTiles::SpawnBlue as u8 { - spawns_blue.push(pos); - } - }); - let id_generator = IdGenerator::new(); - - let config: ConfigVanilla = options - .config - .and_then(|config| serde_json::from_slice(&config).ok()) - .unwrap_or_default(); - - let game_type = Self::get_game_type_from_conf(config.game_type); - - let (statements, account_info) = db_task.get_storage().ok().flatten().unzip(); - - let chat_commands = ChatCommands { - cmds: vec![("account_info".try_into().unwrap(), vec![])] - .into_iter() - .collect(), - prefixes: vec!['/'], - }; - - let mut rcon_cmds = vec![ + let rcon_cmds = vec![ ( "info".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: Default::default(), description: "Prints information about this modification" .try_into() @@ -351,7 +331,7 @@ pub mod state { ( "cheats.all_weapons".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: Default::default(), description: "Gives the player all weapons (cheat)".try_into().unwrap(), usage: "".try_into().unwrap(), @@ -362,7 +342,7 @@ pub mod state { ( "cheats.tune".try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { description: "Tunes a physics value to a given value" .try_into() .unwrap(), @@ -397,13 +377,15 @@ pub mod state { }, ), ]; + + let mut rcon_vars: Vec<_> = Default::default(); config::parsing::parse_conf_values_as_str_list( "".into(), &mut |add, _| { - rcon_cmds.push(( + rcon_vars.push(( add.name.try_into().unwrap(), Command { - rcon: RconCommand { + rcon: RconEntry { args: add.args, usage: add.usage.as_str().try_into().unwrap(), description: add.description.as_str().try_into().unwrap(), @@ -417,18 +399,118 @@ pub mod state { Default::default(), ); - let rcon_chain = CommandChain::new(rcon_cmds.into_iter().collect()); + let rcon_chain = CommandChain::new( + rcon_cmds.into_iter().collect(), + rcon_vars.into_iter().collect(), + ); - let has_accounts = account_info.is_some(); + let mut cache = Default::default(); - let rcon_commands = RconCommands { + let rcon_commands = RconEntries { cmds: rcon_chain - .cmds + .cmd_list() + .iter() + .map(|(name, cmd)| (name.clone(), cmd.rcon.clone())) + .collect(), + vars: rcon_chain + .var_list() .iter() .map(|(name, cmd)| (name.clone(), cmd.rcon.clone())) .collect(), }; + let mut config: ConfigVanilla = options + .config + .and_then(|config| serde_json::from_slice(&config).ok()) + .unwrap_or_default(); + + let (mut initial_args_res, remaining_cmds) = Self::handle_initial_args( + options + .initial_rcon_input + .into_iter() + .map(|r| r.raw.into()) + .collect(), + &mut config, + &rcon_chain, + &mut cache, + ); + + let db_task = io_rt.spawn(async move { + if !db.kinds().is_empty() { + if let Err(err) = save::setup(db.clone()).await { + log::warn!( + target: "sql", + "failed to setup databases: {}", err + ); + return Err(err); + } + + let acc_info = AccountInfo::new(db.clone(), options.account_db).await; + if let Err(err) = &acc_info { + log::warn!( + target: "sql", + "failed to prepare account info sql: {}", err); + } + + let account_created = match AccountCreated::new(db, options.account_db).await { + Ok(account_created) => Some(account_created), + Err(err) => { + log::warn!( + target: "sql", + "failed to prepare account_created sql: {}", err); + None + } + }; + + let statements = + account_created.map(|account_created| GameStatements { account_created }); + + Ok(statements.zip(acc_info.ok())) + } else { + Err(anyhow!("Databases not active.")) + } + }); + + let physics_group = Map::read_physics_group(&map)?; + + let w = physics_group.attr.width.get() as u32; + let h = physics_group.attr.height.get() as u32; + + let tiles = physics_group.get_game_layer_tiles(); + + let collision = Collision::new(&physics_group, true)?; + let game_objects = GameObjectDefinitions::new(tiles, w, h); + + let mut spawns: Vec = Default::default(); + let mut spawns_red: Vec = Default::default(); + let mut spawns_blue: Vec = Default::default(); + tiles.iter().enumerate().for_each(|(index, tile)| { + let x = index % w as usize; + let y = index / w as usize; + let pos = vec2::new(x as f32 * 32.0 + 16.0, y as f32 * 32.0 + 16.0); + if tile.index == EEntityTiles::Spawn as u8 { + spawns.push(pos); + } else if tile.index == EEntityTiles::SpawnRed as u8 { + spawns_red.push(pos); + } else if tile.index == EEntityTiles::SpawnBlue as u8 { + spawns_blue.push(pos); + } + }); + let id_generator = IdGenerator::new(); + + let game_type = Self::get_game_type_from_conf(config.game_type); + + let (statements, account_info) = db_task.get_storage().ok().flatten().unzip(); + + let has_accounts = account_info.is_some(); + + let chat_commands = ChatCommands { + cmds: vec![("account_info".try_into().unwrap(), vec![])] + .into_iter() + .collect(), + prefixes: vec!['/'], + }; + let mut game = Self { game: Game { stages: Default::default(), @@ -468,7 +550,7 @@ pub mod state { game_options: GameOptions::new(game_type, config.clone()), chat_commands: chat_commands.clone(), rcon_chain, - cache: Default::default(), + cache, map_name, // db @@ -494,13 +576,25 @@ pub mod state { snap_shot_manager: SnapshotManager::new(&Default::default()), }; game.stage_0_id = game.add_stage(Default::default(), ubvec4::new(0, 0, 0, 0)); + + for cmd in remaining_cmds { + match game.handle_full_command(None, cmd) { + Ok(res) => initial_args_res.push(Ok(NetworkString::new_lossy(res))), + Err(err) => { + initial_args_res.push(Err(NetworkString::new_lossy(err.to_string()))) + } + } + } + Ok(( game, GameStateStaticInfo { ticks_in_a_second: NonZero::new(TICKS_PER_SECOND).unwrap(), chat_commands, rcon_commands, + config: serde_json::to_vec(&config).ok(), + initial_rcon_response: initial_args_res, mod_name: Self::get_mod_name_from_conf(config.game_type), version: "pre-alpha".try_into().unwrap(), @@ -920,121 +1014,118 @@ pub mod state { } } - fn handle_rcon_commands( + fn handle_full_command( &mut self, player_id: Option<&PlayerId>, - _auth: AuthLevel, - cmds: Vec, - ) -> Vec> { - let mut res: Vec> = Default::default(); - for cmd in cmds { - let handle_cmd = || match cmd { - CommandType::Full(mut cmd) => { - let Some(chain_cmd) = self.rcon_chain.cmds.get(&cmd.ident) else { - return Err(anyhow!("Rcon command {} was not found", cmd.ident)); + mut cmd: parser::Command, + ) -> anyhow::Result { + let Some(chain_cmd) = self.rcon_chain.by_ident(&cmd.ident) else { + return Err(anyhow!("Rcon command {} was not found", cmd.ident)); + }; + + match chain_cmd.cmd { + VanillaRconCommand::Info => { + self.game + .stages + .get(&self.stage_0_id) + .unwrap() + .game_pending_events + .push(GameWorldEvent::Notification( + GameWorldNotificationEvent::System(GameWorldSystemMessage::Custom({ + let mut s = self.game_pools.mt_network_string_common_pool.new(); + s.try_set("You are playing vanilla.").unwrap(); + s + })), + )); + anyhow::Ok("You are playing vanilla.".to_string()) + } + VanillaRconCommand::Cheats(cheat) => match cheat { + VanillaRconCommandCheat::WeaponsAll => { + let Some(player_id) = player_id else { + return Err(anyhow!( + "Weapon cheat command must be executed by an actual player" + )); + }; + let Some(character_info) = self.game.players.player(player_id) else { + return Err(anyhow!("The given player was not found in this game")); }; + if let Some(character) = self + .game + .stages + .get_mut(&character_info.stage_id()) + .and_then(|stage| stage.world.characters.get_mut(player_id)) + { + let reusable_core = &mut character.reusable_core; + let gun = Weapon { + cur_ammo: Some(10), + next_ammo_regeneration_tick: 0.into(), + }; + reusable_core.weapons.insert(WeaponType::Gun, gun); + reusable_core.weapons.insert(WeaponType::Shotgun, gun); + reusable_core.weapons.insert(WeaponType::Grenade, gun); + reusable_core.weapons.insert(WeaponType::Laser, gun); - match chain_cmd.cmd { - VanillaRconCommand::Info => { - self.game - .stages - .get(&self.stage_0_id) - .unwrap() - .game_pending_events - .push(GameWorldEvent::Notification( - GameWorldNotificationEvent::System( - GameWorldSystemMessage::Custom({ - let mut s = self - .game_pools - .mt_network_string_common_pool - .new(); - s.try_set("You are playing vanilla.").unwrap(); - s - }), - ), - )); - anyhow::Ok("You are playing vanilla.".to_string()) - } - VanillaRconCommand::Cheats(cheat) => match cheat { - VanillaRconCommandCheat::WeaponsAll => { - let Some(player_id) = player_id else { - return Err(anyhow!("Weapon cheat command must be executed by an actual player")); - }; - let Some(character_info) = self.game.players.player(player_id) - else { - return Err(anyhow!( - "The given player was not found in this game" - )); - }; - if let Some(character) = self - .game - .stages - .get_mut(&character_info.stage_id()) - .and_then(|stage| stage.world.characters.get_mut(player_id)) - { - let reusable_core = &mut character.reusable_core; - let gun = Weapon { - cur_ammo: Some(10), - next_ammo_regeneration_tick: 0.into(), - }; - reusable_core.weapons.insert(WeaponType::Gun, gun); - reusable_core.weapons.insert(WeaponType::Shotgun, gun); - reusable_core.weapons.insert(WeaponType::Grenade, gun); - reusable_core.weapons.insert(WeaponType::Laser, gun); - - Ok("Cheated all weapons!".to_string()) - } else { - Err(anyhow!("The given player was not found in this game")) - } - } - VanillaRconCommandCheat::Tune => { - let Some(Syn::Float(val)) = - cmd.args.pop().map(|(name, _)| name) - else { - panic!("Expected a float, this is an implementation bug"); - }; - let Some(Syn::Text(path)) = - cmd.args.pop().map(|(name, _)| name) - else { - panic!("Expected a text, this is an implementation bug"); - }; + Ok("Cheated all weapons!".to_string()) + } else { + Err(anyhow!("The given player was not found in this game")) + } + } + VanillaRconCommandCheat::Tune => { + let Some(Syn::Float(val)) = cmd.args.pop().map(|(name, _)| name) else { + panic!("Expected a float, this is an implementation bug"); + }; + let Some(Syn::Text(path)) = cmd.args.pop().map(|(name, _)| name) else { + panic!("Expected a text, this is an implementation bug"); + }; - match self.collision.tune_zones[0].try_set_from_str( - path, - None, - Some(val), - None, - Default::default(), - ) { - Ok(res) => Ok(res), - Err(err) => { - log::error!("{err}"); - Err(err.into()) - } - } - } - }, - VanillaRconCommand::ConfVariable => { - let mut config = ConfigVanillaWrapper { - vanilla: self.game_options.config_clone(), - }; - match handle_config_variable_cmd(&cmd, &mut config).map(|msg| { - format!("Updated value for {}: {}", cmd.cmd_text, msg) - }) { - Ok(res) => { - self.game_options.replace_conf(config.vanilla); - Ok(res) - } - Err(err) => Err(err), - } + match self.collision.tune_zones[0].try_set_from_str( + path, + None, + Some(val), + None, + Default::default(), + ) { + Ok(res) => Ok(res), + Err(err) => { + log::error!("{err}"); + Err(err.into()) } } } + }, + VanillaRconCommand::ConfVariable => { + let mut config = ConfigVanillaWrapper { + vanilla: self.game_options.config_clone(), + }; + match handle_config_variable_cmd(&cmd, &mut config) + .map(|msg| format!("Updated value for {}: {}", cmd.cmd_text, msg)) + { + Ok(res) => { + self.game_options.replace_conf(config.vanilla); + Ok(res) + } + Err(err) => Err(err), + } + } + } + } + + fn handle_rcon_commands( + &mut self, + player_id: Option<&PlayerId>, + _auth: AuthLevel, + cmds: Vec, + ) -> Vec, NetworkString<65536>>> { + let mut res: Vec, NetworkString<65536>>> = + Default::default(); + for cmd in cmds { + let handle_cmd = || match cmd { + CommandType::Full(cmd) => self.handle_full_command(player_id, cmd), CommandType::Partial(cmd) => { let Some(cmd) = cmd.ref_cmd_partial() else { return Err(anyhow!("This command was invalid: {cmd}")); }; - let Some(chain_cmd) = self.rcon_chain.cmds.get(&cmd.ident) else { + let Some(chain_cmd) = self.rcon_chain.by_ident(&cmd.ident) else { return Err(anyhow!("Command {} not found", cmd.ident)); }; @@ -1058,8 +1149,8 @@ pub mod state { }; match handle_cmd() { - Ok(msg) => res.push(NetworkString::new_lossy(msg)), - Err(err) => res.push(NetworkString::new_lossy(err.to_string())), + Ok(msg) => res.push(Ok(NetworkString::new_lossy(msg))), + Err(err) => res.push(Err(NetworkString::new_lossy(err.to_string()))), } } res @@ -2511,8 +2602,8 @@ pub mod state { fn rcon_command( &mut self, player_id: Option, - cmd: ExecRconCommand, - ) -> Vec> { + cmd: ExecRconInput, + ) -> Vec, NetworkString<65536>>> { if !matches!(cmd.auth_level, AuthLevel::None) { let cmds = command_parser::parser::parse( &cmd.raw, @@ -2521,9 +2612,9 @@ pub mod state { ); self.handle_rcon_commands(player_id.as_ref(), cmd.auth_level, cmds) } else { - vec!["Only moderators or admins can execute rcon commands" + vec![Err("Only moderators or admins can execute rcon commands" .try_into() - .unwrap()] + .unwrap())] } } diff --git a/src/client/client.rs b/src/client/client.rs index 584746f..07f6e86 100644 --- a/src/client/client.rs +++ b/src/client/client.rs @@ -1883,15 +1883,15 @@ impl ClientNativeImpl { let events = game.remote_console.get_events(); for event in events { match event { - RemoteConsoleEvent::Exec { name, args } => { + RemoteConsoleEvent::Exec { ident_text, args } => { if let Some((player_id, _)) = game.game_data.local.active_local_player() { - if let (Ok(name), Ok(args)) = - (name.as_str().try_into(), args.as_str().try_into()) + if let (Ok(ident_text), Ok(args)) = + (ident_text.as_str().try_into(), args.as_str().try_into()) { game.network.send_in_order_to_server( &ClientToServerMessage::PlayerMsg(( *player_id, - ClientToServerPlayerMessage::RconExec { name, args }, + ClientToServerPlayerMessage::RconExec { ident_text, args }, )), NetworkInOrderChannel::Custom( 7302, // reads as "rcon" @@ -2879,6 +2879,17 @@ impl FromNativeImpl for ClientNativeImpl { game.resource_download_server.clone(), ); } + if self.votes.needs_misc_votes() { + if !game.misc_votes_loaded { + game.misc_votes_loaded = true; + game.network + .send_unordered_to_server(&ClientToServerMessage::LoadVotes( + MsgClLoadVotes::Misc { cached_votes: None }, + )); + } + self.votes + .fill_misc_votes(game.game_data.misc_votes.clone()); + } if has_input { let evs = self.inp_manager.handle_player_binds( diff --git a/src/client/game.rs b/src/client/game.rs index b716b24..fb1341f 100644 --- a/src/client/game.rs +++ b/src/client/game.rs @@ -677,6 +677,7 @@ impl Game { events: events_pool.new(), map_votes_loaded: Default::default(), + misc_votes_loaded: Default::default(), render_players_pool: Pool::with_capacity(64), render_observers_pool: Pool::with_capacity(2), @@ -816,6 +817,7 @@ impl Game { hint_max_characters: None, // TODO: get from server config: info.mod_config, account_db: None, + initial_rcon_input: Default::default(), }, render_props, info.spatial_chat @@ -911,6 +913,7 @@ impl Game { hint_max_characters: None, // TODO: get from server config: info.mod_config, account_db: None, + initial_rcon_input: Default::default(), }, render_props, info.spatial_chat diff --git a/src/client/game/active.rs b/src/client/game/active.rs index 661816c..741bd3d 100644 --- a/src/client/game/active.rs +++ b/src/client/game/active.rs @@ -32,7 +32,8 @@ use game_interface::{ }, }; use game_network::messages::{ - ClientToServerMessage, MsgSvLoadVotes, MsgSvStartVoteResult, ServerToClientMessage, + ClientToServerMessage, MsgSvLoadVotes, MsgSvResetVotes, MsgSvStartVoteResult, + ServerToClientMessage, }; use game_server::server::Server; use game_state_wasm::game::state_wasm_manager::GameStateWasmManager; @@ -77,6 +78,7 @@ pub struct ActiveGame { pub events: PoolBTreeMap<(GameTickType, bool), GameEvents>, pub map_votes_loaded: bool, + pub misc_votes_loaded: bool, pub render_players_pool: Pool>, pub render_observers_pool: Pool>, @@ -678,7 +680,7 @@ impl ActiveGame { self.game_data.vote = vote_state.map(|v| (PoolRc::from_item_without_pool(v), voted, *timestamp)); } - ServerToClientMessage::LoadVote(votes) => match votes { + ServerToClientMessage::LoadVotes(votes) => match votes { MsgSvLoadVotes::Map { categories, has_unfinished_map_votes, @@ -690,12 +692,33 @@ impl ActiveGame { self.game_data.misc_votes = votes; } }, - ServerToClientMessage::RconCommands(cmds) => { - self.remote_console.fill_entries(cmds.cmds); + ServerToClientMessage::ResetVotes(votes) => match votes { + MsgSvResetVotes::Map => { + self.game_data.map_votes.clear(); + self.game_data.has_unfinished_map_votes = false; + self.map_votes_loaded = false; + } + MsgSvResetVotes::Misc => { + self.game_data.misc_votes.clear(); + self.misc_votes_loaded = false; + } + }, + ServerToClientMessage::RconEntries(cmds) => { + self.remote_console.fill_entries(cmds.cmds, cmds.vars); } ServerToClientMessage::RconExecResult { results } => { - self.remote_console_logs.push_str(&results.join("\n")); - if !results.is_empty() { + let has_results = !results.is_empty(); + self.remote_console_logs.push_str( + &results + .into_iter() + .map(|r| match r { + Ok(r) => r.into(), + Err(err) => err.into(), + }) + .collect::>() + .join("\n"), + ); + if has_results { self.remote_console_logs.push('\n'); } }