diff --git a/Config.toml b/Config.toml index f8f432c5..d72457a3 100644 --- a/Config.toml +++ b/Config.toml @@ -144,4 +144,35 @@ max_bank_gold = 2000000000 # Maximum amount of an item a player can have in their inventory max_item = 2000000000 +[board] +# Maximum number of posts a board will hold before deleting old posts +max_posts = 20 + +# Maximum number of posts a user can have on a board at any one time +# Any less than 2 will not be enforced by the official client +max_user_posts = 6 + +# Maximum number of "recent" posts a user can have on a board +# Any less than 2 will not be enforced by the official client +max_recent_posts = 2 + +# Age of a post to be considered "recent" in minutes +recent_post_time = 30 + +# Maximum length of board post subjects +# Shouldn't be changed unless using a custom client which supports it +max_subject_length = 32 + +# Maximum length of board posts +max_post_length = 2048 + +# Adds "(x minutes ago)" text to every post on a board +date_posts = true + +# Board number that reports/requests are logged (1 to 8) +# This board can't be accessed by players if placed in-game +admin_board = 8 + +# Maximum number of posts that the AdminBoard can hold +admin_max_posts = 100 diff --git a/db-init/init.sql b/db-init/init.sql index b87beb60..35231bda 100644 --- a/db-init/init.sql +++ b/db-init/init.sql @@ -54,7 +54,27 @@ CREATE TABLE `Bank` ( `item_id` int NOT NULL, `quantity` int NOT NULL, PRIMARY KEY (`character_id`,`item_id`), - CONSTRAINT `bank_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `bank_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `BoardPost` +-- + +DROP TABLE IF EXISTS `BoardPost`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `BoardPost` ( + `id` int NOT NULL AUTO_INCREMENT, + `board_id` tinyint NOT NULL, + `character_id` int NOT NULL, + `subject` varchar(32) NOT NULL, + `body` varchar(2048) NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `board_id_idx` (`board_id`), + CONSTRAINT `board_post_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -89,7 +109,7 @@ CREATE TABLE `Character` ( PRIMARY KEY (`id`), UNIQUE KEY `name_UNIQUE` (`name`), KEY `account_id_idx` (`account_id`), - CONSTRAINT `character_account_id` FOREIGN KEY (`account_id`) REFERENCES `Account` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `character_account_id` FOREIGN KEY (`account_id`) REFERENCES `Account` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -124,7 +144,7 @@ CREATE TABLE `GuildRank` ( `rank` varchar(64) NOT NULL, PRIMARY KEY (`id`), KEY `guild_rank_guild_id` (`guild_id`), - CONSTRAINT `guild_rank_guild_id` FOREIGN KEY (`guild_id`) REFERENCES `Guild` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `guild_rank_guild_id` FOREIGN KEY (`guild_id`) REFERENCES `Guild` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -140,7 +160,7 @@ CREATE TABLE `Inventory` ( `item_id` int NOT NULL, `quantity` int NOT NULL, PRIMARY KEY (`character_id`,`item_id`), - CONSTRAINT `inventory_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `inventory_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -169,7 +189,7 @@ CREATE TABLE `Paperdoll` ( `bracer` int NOT NULL DEFAULT '0', `bracer2` int NOT NULL DEFAULT '0', PRIMARY KEY (`character_id`), - CONSTRAINT `paperdoll_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `paperdoll_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -189,7 +209,7 @@ CREATE TABLE `Position` ( `sitting` int NOT NULL DEFAULT '0', `hidden` int NOT NULL DEFAULT '0', PRIMARY KEY (`character_id`), - CONSTRAINT `position_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `position_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -205,7 +225,7 @@ CREATE TABLE `Spell` ( `spell_id` int NOT NULL, `level` int NOT NULL DEFAULT '0', PRIMARY KEY (`character_id`,`spell_id`), - CONSTRAINT `spell_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `spell_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -233,7 +253,7 @@ CREATE TABLE `Stats` ( `karma` int NOT NULL DEFAULT '1000', `usage` int NOT NULL DEFAULT '0', PRIMARY KEY (`character_id`), - CONSTRAINT `stats_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + CONSTRAINT `stats_character_id` FOREIGN KEY (`character_id`) REFERENCES `Character` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; diff --git a/src/handlers/board.rs b/src/handlers/board.rs new file mode 100644 index 00000000..80d9d539 --- /dev/null +++ b/src/handlers/board.rs @@ -0,0 +1,41 @@ +use eo::{ + data::{EOShort, StreamReader}, + protocol::PacketAction, +}; + +use crate::{map::MapHandle, player::PlayerHandle}; + +fn open(reader: StreamReader, player_id: EOShort, map: MapHandle) { + let board_id = reader.get_short(); + map.open_board(player_id, board_id); +} + +fn take(reader: StreamReader, player_id: EOShort, map: MapHandle) { + let _board_id = reader.get_short(); + let post_id = reader.get_short(); + map.view_board_post(player_id, post_id); +} + +pub async fn board(action: PacketAction, reader: StreamReader, player: PlayerHandle) { + let player_id = match player.get_player_id().await { + Ok(player_id) => player_id, + Err(e) => { + error!("Failed to get player id: {}", e); + return; + } + }; + + let map = match player.get_map().await { + Ok(map) => map, + Err(e) => { + error!("Failed to get map: {}", e); + return; + } + }; + + match action { + PacketAction::Open => open(reader, player_id, map), + PacketAction::Take => take(reader, player_id, map), + _ => error!("Unhandled packet Board_{:?}", action), + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 939c47e4..e99a274a 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -9,6 +9,9 @@ pub use attack::attack; mod bank; pub use bank::bank; +mod board; +pub use board::board; + mod chair; pub use chair::chair; diff --git a/src/map/command.rs b/src/map/command.rs index 34af88b3..51489a8a 100644 --- a/src/map/command.rs +++ b/src/map/command.rs @@ -118,6 +118,10 @@ pub enum Command { player_id: EOShort, npc_index: EOChar, }, + OpenBoard { + player_id: EOShort, + board_id: EOShort, + }, OpenChest { player_id: EOShort, coords: Coords, @@ -198,6 +202,10 @@ pub enum Command { player_id: EOShort, item_id: EOShort, }, + ViewBoardPost { + player_id: EOShort, + post_id: EOShort, + }, Walk { target_player_id: EOShort, direction: Direction, diff --git a/src/map/map.rs b/src/map/map.rs index 723d579a..8276d9cd 100644 --- a/src/map/map.rs +++ b/src/map/map.rs @@ -55,12 +55,14 @@ mod learn_skill; mod leave; mod level_stat; mod open_bank; +mod open_board; mod open_chest; mod open_door; mod open_locker; mod open_shop; mod open_skill_master; mod play_effect; +mod player_in_range_of_tile; mod recover_npcs; mod recover_players; mod request_paperdoll; @@ -83,6 +85,7 @@ mod take_locker_item; mod unequip; mod upgrade_locker; mod use_item; +mod view_board_post; mod walk; mod withdraw_gold; @@ -232,6 +235,11 @@ impl Map { npc_index, } => self.open_bank(player_id, npc_index).await, + Command::OpenBoard { + player_id, + board_id, + } => self.open_board(player_id, board_id), + Command::OpenChest { player_id, coords } => self.open_chest(player_id, coords), Command::OpenDoor { @@ -322,6 +330,10 @@ impl Map { Command::UseItem { player_id, item_id } => self.use_item(player_id, item_id), + Command::ViewBoardPost { player_id, post_id } => { + self.view_board_post(player_id, post_id).await + } + Command::Walk { target_player_id, direction, diff --git a/src/map/map/open_board.rs b/src/map/map/open_board.rs new file mode 100644 index 00000000..296fe800 --- /dev/null +++ b/src/map/map/open_board.rs @@ -0,0 +1,80 @@ +use eo::{ + data::{EOChar, EOShort, StreamBuilder, EO_BREAK_CHAR}, + protocol::{PacketAction, PacketFamily}, +}; +use mysql_async::{params, prelude::Queryable, Row}; + +use crate::{utils::get_board_tile_spec, SETTINGS}; + +use super::Map; + +struct BoardPost { + id: EOShort, + author: String, + subject: String, +} + +impl Map { + pub fn open_board(&mut self, player_id: EOShort, board_id: EOShort) { + let character = match self.characters.get(&player_id) { + Some(character) => character, + None => return, + }; + + let board_tile_spec = match get_board_tile_spec(board_id) { + Some(spec) => spec, + None => return, + }; + + if !self.player_in_range_of_tile(player_id, board_tile_spec) { + return; + } + + let player = match &character.player { + Some(player) => player.clone(), + None => return, + }; + + player.set_board_id(board_id); + + let pool = self.pool.clone(); + tokio::spawn(async move { + let mut builder = StreamBuilder::new(); + + let mut conn = pool.get_conn().await.unwrap(); + let limit = if board_id == SETTINGS.board.admin_board as EOShort { + SETTINGS.board.admin_max_posts + } else { + SETTINGS.board.max_posts + }; + + let posts = conn + .exec_map( + include_str!("../../sql/get_board_posts.sql"), + params! { + "board_id" => board_id, + "limit" => limit, + }, + |mut row: Row| BoardPost { + id: row.take("id").unwrap(), + author: row.take("author").unwrap(), + subject: row.take("subject").unwrap(), + }, + ) + .await + .unwrap(); + + builder.add_char(board_id as EOChar); + builder.add_char(posts.len() as EOChar); + + for post in posts { + builder.add_short(post.id); + builder.add_byte(EO_BREAK_CHAR); + builder.add_break_string(&post.author); + builder.add_break_string(&post.subject); + } + + player.send(PacketAction::Open, PacketFamily::Board, builder.get()); + }); + } +} diff --git a/src/map/map/player_in_range_of_tile.rs b/src/map/map/player_in_range_of_tile.rs new file mode 100644 index 00000000..b41fc646 --- /dev/null +++ b/src/map/map/player_in_range_of_tile.rs @@ -0,0 +1,30 @@ +use eo::{data::EOShort, protocol::Coords, pubs::EmfTileSpec}; + +use crate::utils::in_client_range; + +use super::Map; + +impl Map { + pub fn player_in_range_of_tile(&self, player_id: EOShort, spec_id: EmfTileSpec) -> bool { + let character = match self.characters.get(&player_id) { + Some(character) => character, + None => return false, + }; + + for row in &self.file.spec_rows { + for tile in row.tiles.iter().filter(|tile| tile.spec == spec_id) { + if in_client_range( + &character.coords, + &Coords { + x: tile.x, + y: row.y, + }, + ) { + return true; + } + } + } + + false + } +} diff --git a/src/map/map/view_board_post.rs b/src/map/map/view_board_post.rs new file mode 100644 index 00000000..905ea0e7 --- /dev/null +++ b/src/map/map/view_board_post.rs @@ -0,0 +1,64 @@ +use eo::{ + data::{EOShort, StreamBuilder}, + protocol::{PacketAction, PacketFamily}, +}; +use mysql_async::{params, prelude::Queryable, Row}; + +use crate::utils::get_board_tile_spec; + +use super::Map; + +impl Map { + pub async fn view_board_post(&mut self, player_id: EOShort, post_id: EOShort) { + let character = match self.characters.get(&player_id) { + Some(character) => character, + None => return, + }; + + let board_id = match character.player.as_ref().unwrap().get_board_id().await { + Some(board_id) => board_id, + None => return, + }; + + let board_tile_spec = match get_board_tile_spec(board_id) { + Some(spec) => spec, + None => return, + }; + + if !self.player_in_range_of_tile(player_id, board_tile_spec) { + return; + } + + let player = match &character.player { + Some(player) => player.clone(), + None => return, + }; + + let pool = self.pool.clone(); + tokio::spawn(async move { + let mut builder = StreamBuilder::new(); + + let mut conn = pool.get_conn().await.unwrap(); + + let row = conn + .exec_first( + include_str!("../../sql/get_board_post.sql"), + params! { + "board_id" => board_id, + "post_id" => post_id, + }, + ) + .await; + + let mut row: Row = match row { + Ok(Some(row)) => row, + _ => return, + }; + + builder.add_short(post_id); + builder.add_string(&row.take::("body").unwrap()); + + player.send(PacketAction::Player, PacketFamily::Board, builder.get()); + }); + } +} diff --git a/src/map/map_handle.rs b/src/map/map_handle.rs index bd39edb4..7088b403 100644 --- a/src/map/map_handle.rs +++ b/src/map/map_handle.rs @@ -213,6 +213,13 @@ impl MapHandle { }); } + pub fn open_board(&self, player_id: EOShort, board_id: EOShort) { + let _ = self.tx.send(Command::OpenBoard { + player_id, + board_id, + }); + } + pub fn open_chest(&self, player_id: EOShort, coords: Coords) { let _ = self.tx.send(Command::OpenChest { player_id, coords }); } @@ -351,6 +358,10 @@ impl MapHandle { let _ = self.tx.send(Command::UseItem { player_id, item_id }); } + pub fn view_board_post(&self, player_id: EOShort, post_id: EOShort) { + let _ = self.tx.send(Command::ViewBoardPost { player_id, post_id }); + } + pub fn walk( &self, target_player_id: EOShort, diff --git a/src/player/command.rs b/src/player/command.rs index a012a2ad..a098d594 100644 --- a/src/player/command.rs +++ b/src/player/command.rs @@ -27,6 +27,9 @@ pub enum Command { GetAccountId { respond_to: oneshot::Sender>, }, + GetBoardId { + respond_to: oneshot::Sender>, + }, GetCharacter { respond_to: oneshot::Sender, InvalidStateError>>, }, @@ -79,6 +82,7 @@ pub enum Command { }, Send(PacketAction, PacketFamily, Bytes), SetAccountId(EOInt), + SetBoardId(EOShort), SetBusy(bool), SetCharacter(Box), SetInteractNpcIndex(EOChar), diff --git a/src/player/handle_packet.rs b/src/player/handle_packet.rs index 76a0f175..28dc37ed 100644 --- a/src/player/handle_packet.rs +++ b/src/player/handle_packet.rs @@ -46,6 +46,7 @@ pub async fn handle_packet( } PacketFamily::Attack => handlers::attack(action, reader, player.clone()).await, PacketFamily::Bank => handlers::bank(action, reader, player.clone()).await, + PacketFamily::Board => handlers::board(action, reader, player.clone()).await, PacketFamily::Chair => handlers::chair(action, reader, player.clone()).await, PacketFamily::Character => { handlers::character(action, reader, player.clone(), world.clone()).await diff --git a/src/player/player.rs b/src/player/player.rs index 751e0b9d..d40ba745 100644 --- a/src/player/player.rs +++ b/src/player/player.rs @@ -35,6 +35,7 @@ pub struct Player { character: Option, session_id: Option, interact_npc_index: Option, + board_id: Option, warp_session: Option, } @@ -68,6 +69,7 @@ impl Player { warp_session: None, session_id: None, interact_npc_index: None, + board_id: None, } } @@ -97,6 +99,9 @@ impl Player { ))); } } + Command::GetBoardId { respond_to } => { + let _ = respond_to.send(self.board_id); + } Command::GetCharacter { respond_to } => { if let Some(character) = self.character.as_ref() { let _ = respond_to.send(Ok(Box::new(character.to_owned()))); @@ -225,6 +230,9 @@ impl Player { Command::SetAccountId(account_id) => { self.account_id = account_id; } + Command::SetBoardId(board_id) => { + self.board_id = Some(board_id); + } Command::SetBusy(busy) => { self.busy = busy; } diff --git a/src/player/player_handle.rs b/src/player/player_handle.rs index ea9f245c..e1a0e538 100644 --- a/src/player/player_handle.rs +++ b/src/player/player_handle.rs @@ -71,6 +71,15 @@ impl PlayerHandle { } } + pub async fn get_board_id(&self) -> Option { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(Command::GetBoardId { respond_to: tx }); + match rx.await { + Ok(board_id) => board_id, + Err(_) => None, + } + } + pub async fn get_character( &self, ) -> Result, Box> { @@ -159,11 +168,11 @@ impl PlayerHandle { } } - pub async fn get_interact_npc_index( - &self, - ) -> Option { + pub async fn get_interact_npc_index(&self) -> Option { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(Command::GetInteractNpcIndex { respond_to: tx }); + let _ = self + .tx + .send(Command::GetInteractNpcIndex { respond_to: tx }); match rx.await { Ok(index) => index, Err(_) => None, @@ -247,6 +256,10 @@ impl PlayerHandle { let _ = self.tx.send(Command::SetAccountId(account_id)); } + pub fn set_board_id(&self, board_id: EOShort) { + let _ = self.tx.send(Command::SetBoardId(board_id)); + } + pub fn set_busy(&self, busy: bool) { let _ = self.tx.send(Command::SetBusy(busy)); } diff --git a/src/settings.rs b/src/settings.rs index dade301f..6605d74f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -102,6 +102,19 @@ pub struct Limits { pub max_item: EOInt, } +#[derive(Debug, Deserialize)] +pub struct Board { + pub max_posts: EOInt, + pub max_user_posts: EOInt, + pub max_recent_posts: EOInt, + pub recent_post_time: EOInt, + pub max_subject_length: EOInt, + pub max_post_length: EOInt, + pub date_posts: bool, + pub admin_board: EOInt, + pub admin_max_posts: EOInt, +} + #[derive(Debug, Deserialize)] pub struct Settings { pub server: Server, @@ -114,6 +127,7 @@ pub struct Settings { pub sln: Sln, pub bank: Bank, pub limits: Limits, + pub board: Board, } impl Settings { diff --git a/src/sql/get_board_post.sql b/src/sql/get_board_post.sql new file mode 100644 index 00000000..91d6d31d --- /dev/null +++ b/src/sql/get_board_post.sql @@ -0,0 +1,3 @@ +SELECT `body` +FROM `BoardPost` +WHERE `board_id` = :board_id AND `id` = :post_id diff --git a/src/sql/get_board_posts.sql b/src/sql/get_board_posts.sql new file mode 100644 index 00000000..9e3854f7 --- /dev/null +++ b/src/sql/get_board_posts.sql @@ -0,0 +1,9 @@ +SELECT b.`id`, + c.`name` 'author', + b.`subject`, + b.`created_at` +FROM `BoardPost` b +INNER JOIN `Character` c + ON c.`id` = b.`character_id` +WHERE b.`board_id` = :board_id +ORDER BY b.`id` DESC LIMIT :limit; diff --git a/src/utils/get_board_tile_spec.rs b/src/utils/get_board_tile_spec.rs new file mode 100644 index 00000000..f3859d83 --- /dev/null +++ b/src/utils/get_board_tile_spec.rs @@ -0,0 +1,18 @@ +use eo::{data::EOShort, pubs::EmfTileSpec}; + +pub fn get_board_tile_spec(board_id: EOShort) -> Option { + match board_id { + 0 => Some(EmfTileSpec::Board1), + 1 => Some(EmfTileSpec::Board2), + 2 => Some(EmfTileSpec::Board3), + 3 => Some(EmfTileSpec::Board4), + 4 => Some(EmfTileSpec::Board5), + 5 => Some(EmfTileSpec::Board6), + 6 => Some(EmfTileSpec::Board7), + 7 => Some(EmfTileSpec::Board8), + _ => { + warn!("{} is not a valid board id", board_id); + None + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a6b668bf..aba04845 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,6 @@ mod in_range; pub use in_range::{get_distance, in_client_range, in_range}; +mod get_board_tile_spec; +pub use get_board_tile_spec::get_board_tile_spec; mod get_next_coords; pub use get_next_coords::get_next_coords;