diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..9d04b61 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,113 @@ +// --------------- +// Module overview +// --------------- +// +// DiscordInfo: describes a Discord user's information +// +// ClientInfo: describes a client's identifying information, +// both for websocket and Discord connections. +// Webscoket connections have the option to store +// DiscordInfo as well for verified connections. +// +// Client: describes a client, holding ClientInfo and Rc + +use crate::room::Room; + +use std::cmp::{PartialEq, Eq}; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; +use std::cell::RefCell; +use simple_websockets::{Responder, Message}; + +pub struct DiscordInfo { + pub username: String, + pub discriminator: u16, + pub id: u64, +} + +impl PartialEq for DiscordInfo { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for DiscordInfo {} + +pub enum ClientInfo { + Ws { + id: u64, + responder: Responder, + discord_info: Option, + }, + Discord { + discord_info: DiscordInfo, + }, +} + +impl ClientInfo { + fn id(&self) -> u64 { + match self { + Self::Ws { id, discord_info, .. } => match discord_info { + // Discord-verified websocket connection + Some(discord_info) => discord_info.id, + // Anonymous websocket connection + None => *id, + }, + // Discord connection + Self::Discord { discord_info } => discord_info.id, + } + } +} + +impl PartialEq for ClientInfo { + fn eq(&self, other: &Self) -> bool { + self.id() == other.id() + } +} + +impl Eq for ClientInfo {} + +impl Hash for ClientInfo { + fn hash(&self, state: &mut H) { + self.id().hash(state) + } +} + +pub struct Client { + info: ClientInfo, + pub room: Rc>, +} + +impl Client { + pub fn new(info: ClientInfo, room: Rc>) -> Self { + Self { info, room } + } + + pub fn id(&self) -> u64 { + match self.info { + ClientInfo::Ws { id, .. } => id, + ClientInfo::Discord { .. } => unimplemented!("no id for Discord connections"), + } + } + + pub fn send(&self, message: Message) -> bool { + match &self.info { + ClientInfo::Ws { responder, .. } => responder.send(message), + ClientInfo::Discord { .. } => unimplemented!("no networking implementation for Discord connections"), + } + } +} + +impl PartialEq for Client { + fn eq(&self, other: &Self) -> bool { + self.info.eq(&other.info) + } +} + +impl Eq for Client {} + +impl Hash for Client { + fn hash(&self, state: &mut H) { + self.info.hash(state) + } +} diff --git a/src/database.rs b/src/database.rs index 19a3256..deb77b6 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,4 +1,4 @@ -use crate::Word; +use crate::word::Word; use derive_more::From; use std::path::PathBuf; @@ -15,16 +15,23 @@ pub enum DatabaseCreationError { IoError(std::io::Error), } +#[derive(Default)] +pub enum DatabaseType { + #[default] + InMemory, + OnDisk(String), +} + +#[derive(Default)] +pub struct DatabaseSettings { + pub db_type: DatabaseType, +} + impl Database { - pub fn new( - testing: bool, - ) -> Result { - let conn = if testing { - Connection::open_in_memory() - } else { - let path = PathBuf::from("shiritori.db"); - //fs::create_dir_all(path.parent().unwrap())?; - Connection::open(path) + pub fn new(settings: DatabaseSettings) -> Result { + let conn = match settings.db_type { + DatabaseType::InMemory => Connection::open_in_memory(), + DatabaseType::OnDisk(path) => Connection::open(PathBuf::from(path)), }?; conn.execute( "CREATE TABLE IF NOT EXISTS word ( diff --git a/src/dictionary.rs b/src/dictionary.rs index 6399e6c..836afc8 100644 --- a/src/dictionary.rs +++ b/src/dictionary.rs @@ -1,6 +1,6 @@ use wana_kana::{ConvertJapanese, IsJapaneseStr}; -pub fn lookup(input: &str) -> Option<&jisho::Entry> { +fn raw_lookup(input: &str) -> Option<&jisho::Entry> { let input = input.trim(); jisho::lookup(input).into_iter().find(|&word| ( // If input has no kanji, @@ -13,3 +13,28 @@ pub fn lookup(input: &str) -> Option<&jisho::Entry> { // For example, 照り焼き will be accepted but 照焼 won't. (input == word.kanji))) } + +pub fn lookup(query: &str) -> Option { + match raw_lookup(&query) { + Some(result) => Some(result.clone()), + None => { + if query.is_hiragana() { + // looking up ごりら doesn't return ゴリラ + // jisho::lookup for some reason refers to input string, + // so cloning is required + match raw_lookup(&query.to_katakana()) { + Some(entry) => Some(entry.clone()), + None => None, + } + } else if query.is_katakana() { + // looking up シリトリ doesn't return 尻取り + match raw_lookup(&query.to_hiragana()) { + Some(entry) => Some(entry.clone()), + None => None, + } + } else { + None + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 4fea4ce..4e0281a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,17 @@ +mod client; mod database; -pub use database::*; - mod dictionary; -pub use dictionary::*; - +mod response; +mod room; mod server; -pub use server::*; - +mod utils; mod word; -pub use word::*; + +use crate::server::{Server, ServerSettings}; fn main() { - const PORT: u16 = 8080; - let mut server = Server::new(PORT, true) - .unwrap_or_else(|error| panic!("Failed to start server at port {PORT}: {:?}", error)); + let settings = ServerSettings::default(); + let mut server = Server::new(&settings) + .unwrap_or_else(|error| panic!("Failed to start server at port {}: {:?}", settings.port, error)); server.run(); } diff --git a/src/response.rs b/src/response.rs new file mode 100644 index 0000000..6df3c5c --- /dev/null +++ b/src/response.rs @@ -0,0 +1,62 @@ +use crate::word::Word; + +use serde::Serialize; +use simple_websockets::Message; + +#[derive(Serialize)] +pub struct MessageResponse { + event: String, + data: MessageResponseData, +} + +impl MessageResponse { + pub fn to_message(&self) -> Message { + Message::Text(serde_json::to_string(&self).unwrap()) + } +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum MessageResponseData { + Greeting { + id: u64, + next_mora: Option, + }, + Word { + author: u64, + word: Word, + next_mora: String, + }, + History { + words: Vec, + }, + PlayerCount { + players: u64, + }, + Error { + message: String, + }, +} + +impl MessageResponseData { + pub fn get_name(&self) -> String { + String::from(match self { + Self::Greeting { .. } => "greeting", + Self::Word { .. } => "word", + Self::History { .. } => "history", + Self::PlayerCount { .. } => "playerCount", + Self::Error { .. } => "error", + }) + } + + pub fn into_response(self) -> MessageResponse { + MessageResponse { + event: self.get_name(), + data: self, + } + } + + pub fn into_message(self) -> Message { + self.into_response().to_message() + } +} diff --git a/src/room.rs b/src/room.rs new file mode 100644 index 0000000..85221c2 --- /dev/null +++ b/src/room.rs @@ -0,0 +1,92 @@ +// --------------- +// Module overview +// --------------- +// +// RoomSettings: Stores database settings for room. +// In future, could store passphrase, etc. +// +// Room: Game state abstracted away from any networking. +// Any events that aren't game-related are handled by Server. + +use crate::database::{Database, DatabaseCreationError, DatabaseSettings}; +use crate::dictionary::lookup; +use crate::response::MessageResponseData; +use crate::utils::{get_starting_mora, get_final_mora}; +use crate::word::Word; + +use wana_kana::{IsJapaneseStr, ConvertJapanese}; + +#[derive(Default)] +pub struct RoomSettings { + pub database_settings: DatabaseSettings, +} + +pub struct Room { + database: Database, + next_mora: Option, + last_client_id: Option, +} + +impl Room { + pub fn new(settings: RoomSettings) -> Result { + Ok(Self { + database: Database::new(settings.database_settings)?, + next_mora: None, + last_client_id: None, + }) + } + + pub fn next_mora(&self) -> &Option { + &self.next_mora + } + + pub fn get_history(&self) -> rusqlite::Result> { + self.database.load_words_before(self.database.last_word_id + 1) + } + + // Err(&str) will be converted to MessageResponseData::Error + pub fn handle_query(&mut self, query: &str, client_id: u64) -> Result { + // Ensure query isn't from last player + if Some(client_id) == self.last_client_id { + return Err("It's not your turn!"); + }; + + // Ensure query is in Japanese + let query = query.to_string(); + let query = if query.is_japanese() { + query + } else { + let kana = query.to_kana(); + if kana.is_japanese() { + kana + } else { + return Err("Not Japanese!"); + } + }; + + // Look up word in dictionary + let dictionary_result = lookup(&query); + + // Send result + match dictionary_result { + Some(entry) => { + if entry.reading.chars().last().unwrap().to_string().to_hiragana() == "ん" { + Err("Can't end with ん!") + } else if self.next_mora.is_none() || get_starting_mora(&entry.reading).eq(self.next_mora.as_deref().unwrap()) { + let word: Word = entry.clone().into(); + self.next_mora = Some(get_final_mora(&word.reading)); + self.database.add_word(&word).unwrap(); // TODO: replace .unwrap() with ? + self.last_client_id = Some(client_id); + Ok(MessageResponseData::Word { + author: client_id, + word, + next_mora: self.next_mora.as_deref().unwrap().to_owned(), + }) + } else { + Err("Wrong starting mora!") + } + } + None => Err("Not in dictionary!"), + } + } +} diff --git a/src/server.rs b/src/server.rs index c5d4f1f..45985b6 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,80 +1,14 @@ -use crate::DatabaseCreationError; -use crate::lookup; -use crate::Word; -use crate::Database; +use crate::client::{Client, ClientInfo}; +use crate::database::{DatabaseSettings, DatabaseType, DatabaseCreationError}; +use crate::response::MessageResponseData; +use crate::room::{Room, RoomSettings}; -use simple_websockets::{Event, Responder, Message, EventHub}; use std::collections::HashMap; -use serde::Serialize; -use wana_kana::{ConvertJapanese, IsJapaneseStr}; +use std::rc::Rc; +use std::cell::RefCell; +use simple_websockets::{Event, Responder, Message, EventHub}; use derive_more::From; -#[derive(Serialize)] -struct MessageResponse { - event: String, - data: MessageResponseData, -} - -impl MessageResponse { - fn to_message(&self) -> Message { - Message::Text(serde_json::to_string(&self).unwrap()) - } -} - -#[derive(Serialize)] -#[serde(untagged)] -enum MessageResponseData { - Greeting { - id: u64, - next_mora: Option, - }, - Word { - author: u64, - word: Word, - next_mora: String, - }, - History { - words: Vec, - }, - PlayerCount { - players: u64, - }, - Error { - message: String, - }, -} - -impl MessageResponseData { - fn get_name(&self) -> String { - String::from(match self { - Self::Greeting { .. } => "greeting", - Self::Word { .. } => "word", - Self::History { .. } => "history", - Self::PlayerCount { .. } => "playerCount", - Self::Error { .. } => "error", - }) - } - - fn into_response(self) -> MessageResponse { - MessageResponse { - event: self.get_name(), - data: self, - } - } - - fn into_message(self) -> Message { - self.into_response().to_message() - } -} - -pub struct Server { - event_hub: EventHub, - database: Database, - clients: HashMap, - next_mora: Option, - last_client_id: Option, -} - #[derive(From, Debug)] pub enum ServerCreationError { SimpleWebsocketsError(simple_websockets::Error), @@ -86,63 +20,49 @@ pub enum ServerError { DatabaseError(rusqlite::Error), } -fn get_starting_mora(word: &str) -> String { - if word.is_empty() { - return String::from(""); - } - let word = word.to_hiragana(); - let mut iter = word.chars(); - let starting_char = iter.next().unwrap(); - let second_char = iter.next().unwrap(); - match second_char { - 'ゃ' | 'ゅ' | 'ょ' => format!("{starting_char}{second_char}"), - _ => starting_char.to_string(), - } +pub struct ServerSettings { + pub port: u16, + pub testing: bool, } -// Trim off lengtheners and non-kana that are irrelevant to shiritori -// TODO: Use slices -fn trim_irrelevant_chars(word: &str) -> String { - let mut iter = word.chars().rev().peekable(); - while let Some(c) = iter.peek() { - if *c == 'ー' || !c.to_string().is_kana() { - iter.next(); - } else { - break; +impl Default for ServerSettings { + fn default() -> Self { + Self { + port: 8080, + testing: true, } } - iter.rev().collect() } -// Get final mora, which could be multiple chars e.g. しゃ -// TODO: Use slices -fn get_final_mora(word: &str) -> String { - let word = trim_irrelevant_chars(word).to_hiragana(); - if word.is_empty() { - return String::from(""); - } - let mut iter = word.chars().rev(); - let final_char = iter.next().unwrap(); - match final_char { - 'ゃ' | 'ゅ' | 'ょ' => format!("{}{final_char}", match iter.next() { - Some(c) => c.to_string(), - None => String::from(""), - }), - _ => final_char.to_string(), - } +pub struct Server { + event_hub: EventHub, + lobby: Rc>, + rooms: HashMap, + clients: HashMap, } impl Server { - pub fn new(port: u16, testing: bool) -> Result { - Ok(Server { - event_hub: simple_websockets::launch(port)?, - database: Database::new(testing)?, + pub fn new(settings: &ServerSettings) -> Result { + Ok(Self { + event_hub: simple_websockets::launch(settings.port)?, + lobby: Rc::new(RefCell::new(Room::new(RoomSettings { + database_settings: DatabaseSettings { + db_type: match settings.testing { + true => DatabaseType::InMemory, + false => DatabaseType::OnDisk("shiritori.sb".to_string()), + }, + }, + ..Default::default() + })?)), + rooms: HashMap::new(), clients: HashMap::new(), - next_mora: None, - last_client_id: None, }) } + pub fn get_client(&self, id: u64) -> Option<&Client> { + self.clients.get(&id) + } + pub fn run(&mut self) { loop { match match self.event_hub.poll_event() { @@ -156,126 +76,114 @@ impl Server { } } - fn broadcast_player_count(&self) { - let response = MessageResponseData::PlayerCount { players: self.clients.len() as u64 }.into_response(); - for (_client, responder) in self.clients.iter() { - responder.send(response.to_message()); - } + fn new_client(&mut self, client_id: u64, responder: Responder) -> &Client { + let client = Client::new(ClientInfo::Ws { + id: client_id, + responder, + discord_info: None, + }, self.lobby.clone()); + self.clients.insert(client_id, client); + self.clients.get(&client_id).unwrap() } fn handle_connection(&mut self, client_id: u64, responder: Responder) -> Result<(), ServerError> { + // Debug println!("A client connected with id #{}", client_id); - responder.send(MessageResponseData::Greeting { - id: client_id, - next_mora: self.next_mora.clone(), - }.into_message()); - responder.send(MessageResponseData::History { - words: self.database.load_words_before(self.database.last_word_id + 1)? - }.into_message()); - self.clients.insert(client_id, responder); - self.broadcast_player_count(); + + { + // Initialize client + let client = self.new_client(client_id, responder); + + // Get immutable access to room + let room = client.room.borrow(); + + // Send client greeting + client.send(MessageResponseData::Greeting { + id: client_id, + next_mora: room.next_mora().clone(), + }.into_message()); + + // Sent recent message history + client.send(MessageResponseData::History { + words: room.get_history()?, + }.into_message()); + } + + // Number of clients on Rc> reference counter will be one more + self.broadcast_player_count(&self.lobby); + Ok(()) } fn handle_disconnection(&mut self, client_id: u64) -> Result<(), ServerError> { + // Debug println!("Client #{} disconnected.", client_id); - self.clients.remove(&client_id); - self.broadcast_player_count(); + + // Remove client + // At this point, client should be dropped + let client = self.clients.remove(&client_id).unwrap(); + + // Get room + let room = client.room; + + // Number of clients on Rc> reference counter will be one less + self.broadcast_player_count(&room); + Ok(()) } + fn for_client_in_room(&self, room: &Rc>, mut closure: impl FnMut(&Client) -> ()) { + for (_id, client) in self.clients.iter().filter(|(_id, client)| Rc::>::ptr_eq(room, &client.room)) { + closure(client); + } + } + + fn client_count_in_room(&self, room: &Rc>) -> usize { + let mut count: usize = 0; + self.for_client_in_room(room, |_| count += 1); + count + } + + fn broadcast_player_count(&self, room: &Rc>) { + let response = MessageResponseData::PlayerCount { players: self.client_count_in_room(room) as u64 }.into_response(); + for (_id, client) in self.clients.iter() { + client.send(response.to_message()); + } + } + + fn announce_to_room(&self, room: &Rc>, response_data: MessageResponseData) { + let response = response_data.into_response(); + self.for_client_in_room(room, |client| { + client.send(response.to_message()); + }); + } + fn handle_message(&mut self, client_id: u64, message: Message) -> Result<(), ServerError> { // Ignore binary messages let message = match message { - Message::Text(message) => message, - Message::Binary(_) => return Ok(()), - }; + Message::Text(message) => { + // Debug + println!("Received a message from client #{}: {:?}", client_id, message); - // Debug - println!("Received a message from client #{}: {:?}", client_id, message); - - let response = if Some(client_id) == self.last_client_id { - MessageResponseData::Error { - message: String::from("It's not your turn!"), + message } - } else { - let query = if message.is_japanese() { - Some(message) - } else { - let kana = message.to_kana(); - if kana.is_japanese() { - Some(kana) - } else { - None - } - }; - // For some reason result will keep a reference to - // katakana_query and hiragana_query, meaning that - // these need to be kept around as a variable - // to prevent them from being dropped. - let (katakana_query, hiragana_query) = match &query { - Some(query) => ( - Some(query.to_katakana()), - Some(query.to_hiragana()) - ), - None => (None, None), - }; - let result = match &query { - Some(query) => match lookup(query) { - Some(result) => Some(result), - None => { - if query.is_hiragana() { - // looking up ごりら doesn't return ゴリラ - lookup(katakana_query.as_ref().unwrap()) - } else if query.is_katakana() { - // looking up シリトリ doesn't return 尻取り - lookup(hiragana_query.as_ref().unwrap()) - } else { - None - } - } - }, - None => None, - }; - match result { - Some(entry) => { - if entry.reading.chars().last().unwrap().to_string().to_hiragana() == "ん" { - MessageResponseData::Error { - message: String::from("Can't end with ん!"), - } - } else if self.next_mora.is_none() || get_starting_mora(&entry.reading).eq(self.next_mora.as_deref().unwrap()) { - let word: Word = entry.clone().into(); - self.next_mora = Some(get_final_mora(&word.reading)); - self.database.add_word(&word)?; - MessageResponseData::Word { - author: client_id, - word, - next_mora: self.next_mora.as_deref().unwrap().to_owned(), - } - } else { - MessageResponseData::Error { - message: String::from("Wrong starting mora!"), - } - } - } - None => MessageResponseData::Error { - message: String::from("Not in dictionary!"), - }, + Message::Binary(message) => { + // Debug + println!("Received a binary message from client #{}: {:?}", client_id, message); + + return Ok(()); } - }.into_response(); + }; - match response.data { + let client = self.get_client(client_id).unwrap(); + + match client.room.borrow_mut().handle_query(&message, client_id) { + // Broadcast new words to all clients in same room + Ok(response) => self.announce_to_room(&client.room, response), // Send errors to only this client - MessageResponseData::Error { .. } => { - self.clients.get(&client_id).unwrap().send(response.to_message()); - }, - // Broadcast everything else to all clients - _ => { - for (_client, responder) in self.clients.iter() { - responder.send(response.to_message()); - } - self.last_client_id = Some(client_id); - }, + Err(message) => { + client.send(MessageResponseData::Error { message: message.to_string() }.into_message()); + } }; Ok(()) } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..09fa480 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,47 @@ +use wana_kana::{ConvertJapanese, IsJapaneseStr}; + +pub fn get_starting_mora(word: &str) -> String { + if word.is_empty() { + return String::from(""); + } + let word = word.to_hiragana(); + let mut iter = word.chars(); + let starting_char = iter.next().unwrap(); + let second_char = iter.next().unwrap(); + match second_char { + 'ゃ' | 'ゅ' | 'ょ' => format!("{starting_char}{second_char}"), + _ => starting_char.to_string(), + } +} + +// Trim off lengtheners and non-kana that are irrelevant to shiritori +// TODO: Use slices +fn trim_irrelevant_chars(word: &str) -> String { + let mut iter = word.chars().rev().peekable(); + while let Some(c) = iter.peek() { + if *c == 'ー' || !c.to_string().is_kana() { + iter.next(); + } else { + break; + } + } + iter.rev().collect() +} + +// Get final mora, which could be multiple chars e.g. しゃ +// TODO: Use slices +pub fn get_final_mora(word: &str) -> String { + let word = trim_irrelevant_chars(word).to_hiragana(); + if word.is_empty() { + return String::from(""); + } + let mut iter = word.chars().rev(); + let final_char = iter.next().unwrap(); + match final_char { + 'ゃ' | 'ゅ' | 'ょ' => format!("{}{final_char}", match iter.next() { + Some(c) => c.to_string(), + None => String::from(""), + }), + _ => final_char.to_string(), + } +}