From 5f125dfb752d681a753b28e89a1d74ec6f06cd4c Mon Sep 17 00:00:00 2001 From: Joel Therrien Date: Mon, 2 Dec 2024 15:39:27 -0800 Subject: [PATCH] feat: WIP multiplayer support --- Cargo.toml | 3 +- server/Cargo.toml | 5 +- server/src/main.rs | 395 +++++++++++++++++++++---- server/src/test.http | 5 - ui/src/Menu.tsx | 47 +-- ui/src/UI.tsx | 64 +++- ui/src/index.html | 24 +- ui/src/multiplayer.html | 11 + ui/src/multiplayer.tsx | 117 ++++++++ ui/src/singleplayer.html | 13 + ui/src/{index.tsx => singleplayer.tsx} | 0 wasm/Cargo.toml | 2 +- wordgrid/Cargo.toml | 2 +- wordgrid/src/api.rs | 8 +- wordgrid/src/game.rs | 24 +- 15 files changed, 586 insertions(+), 134 deletions(-) delete mode 100644 server/src/test.http create mode 100644 ui/src/multiplayer.html create mode 100644 ui/src/multiplayer.tsx create mode 100644 ui/src/singleplayer.html rename ui/src/{index.tsx => singleplayer.tsx} (100%) diff --git a/Cargo.toml b/Cargo.toml index 8844b4e..b445ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,5 @@ resolver = "2" [workspace.dependencies] serde_json = "1.0.132" -serde = { version = "1.0.213", features = ["derive"] } \ No newline at end of file +serde = { version = "1.0.213", features = ["derive"] } +rand = {version = "0.8.5", features = ["small_rng"]} \ No newline at end of file diff --git a/server/Cargo.toml b/server/Cargo.toml index 9731046..57358ee 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,9 +6,10 @@ edition = "2021" [dependencies] itertools = "0.13.0" rocket = { version = "0.5.1", features = ["json"] } -#word_grid = { path="../wordgrid" } +word_grid = { path="../wordgrid" } serde_json = { workspace = true } serde = { workspace = true } -uuid = { version = "1.11.0", features = ["v4"] } +uuid = { version = "1.11.0", features = ["serde", "v4"] } ws = { package = "rocket_ws", version = "0.1.1" } +rand = { workspace = true } #futures = "0.3.30" diff --git a/server/src/main.rs b/server/src/main.rs index d3b6292..2ceaaa6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,87 +1,364 @@ - #[macro_use] extern crate rocket; +use itertools::Itertools; +use rand::prelude::SmallRng; +use rand::SeedableRng; +use rocket::futures::{SinkExt, StreamExt}; +use rocket::tokio::sync::broadcast::Sender; +use rocket::tokio::sync::{Mutex, RwLock}; +use rocket::{tokio, State}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use rocket::futures::{pin_mut, FutureExt, StreamExt, SinkExt}; -use rocket::futures::stream::FusedStream; -use rocket::State; -use std::time::Duration; -use rocket::tokio::select; -use rocket::tokio::sync::broadcast::{channel, Sender}; -use rocket::tokio::sync::Mutex; -use rocket::tokio::time::interval; +use std::sync::{Arc, LazyLock, Weak}; +use tokio::select; +use uuid::Uuid; +use word_grid::api::{APIGame, ApiState}; +use word_grid::dictionary::{Dictionary, DictionaryImpl}; +use word_grid::game::Game; +use word_grid::player_interaction::ai::Difficulty; +use ws::stream::DuplexStream; use ws::Message; -type RoomMap = HashMap::>; +static DICTIONARY: LazyLock = + LazyLock::new(|| DictionaryImpl::create_from_path("../resources/dictionary.csv")); -#[get("/room/")] -async fn chat(id: &str, ws: ws::WebSocket, rooms: &State>) -> ws::Channel<'static> { - let mut rooms = rooms.lock().await; - let (sender, mut receiver) = if rooms.contains_key(id) { - let sender = rooms.get(id).unwrap(); - (sender.clone(), sender.subscribe()) +#[derive(Clone, Debug, Serialize)] +struct Player { + name: String, + id: Uuid, +} - } else { - let (sender, receiver) = channel::(1024); - rooms.insert(id.to_string(), sender.clone()); +#[derive(Clone, Serialize, Debug)] +struct PartyInfo { + ais: Vec, + players: Vec, +} - (sender, receiver) - }; +impl PartyInfo { + fn new(host: Player) -> Self { + Self { + ais: Vec::new(), + players: vec![host], + } + } +} - ws.channel(move |mut stream| Box::pin(async move { - let mut interval = interval(Duration::from_secs(10)); - while !stream.is_terminated(){ // always seems to return true? - let ws_incoming = stream.next(); - let other_incoming = receiver.recv(); - let ping_tick = interval.tick(); +struct Room { + party_info: PartyInfo, + game: Option, + sender: Sender, +} - // pin_mut!(ws_incoming, other_incoming); // no clue what this does +impl Room { + fn new(host: Player) -> Self { + Self { + party_info: PartyInfo::new(host), + game: None, + sender: Sender::new(5), + } + } +} - select! { - message = ws_incoming => { - if message.is_none() { +#[derive(Clone, Serialize, Debug)] +#[serde(tag = "type")] +enum RoomEvent { + PlayerJoined(Player), + PlayerLeft(Player), + AIJoined(Difficulty), + //AILeft(Difficulty), +} + +#[derive(Clone, Serialize, Debug)] +#[serde(tag = "type")] +enum ServerToClientMessage { + RoomChange { event: RoomEvent, info: PartyInfo }, + GameEvent { state: ApiState }, +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "type")] +enum ClientToServerMessage { + RoomChange, + Load, + StartGame, + GameMove, + AddAI { difficulty: Difficulty }, +} + +#[derive(Clone, Debug)] +enum InnerRoomMessage { + PassThrough(ServerToClientMessage), + GameEvent, +} + +type RoomMap = HashMap>>; + +async fn incoming_message_handler( + message: Option>, + sender: &Sender, + player: &Player, + room: &Arc>, +) -> bool { + match message { + None => { + panic!("Not sure when this happens") + } + Some(message) => { + match message { + Ok(message) => { + let message = message.to_text().unwrap(); + if message.len() == 0 { println!("Websocket closed"); - return Ok(()) + // TODO need to handle updating Players, etc. + println!("Player {player:#?} is leaving"); + let mut room = room.write().await; + if room.game.is_some() { + unimplemented!("Need to handle mid-game someone leaving") + } + + let new_vec = room + .party_info + .players + .iter() + .filter(|p| !p.id.eq(&player.id)) + .map(|p| p.clone()) + .collect_vec(); + room.party_info.players = new_vec; + + let event = ServerToClientMessage::RoomChange { + event: RoomEvent::PlayerLeft(player.clone()), + info: room.party_info.clone(), + }; + sender.send(InnerRoomMessage::PassThrough(event)).unwrap(); + + // TODO - handle case where there are no players left + + return true; } - println!("websocket received a websocket message"); - let message = message.unwrap()?; + println!("Received {message}"); + let message: ClientToServerMessage = serde_json::from_str(message).unwrap(); - if let ws::Message::Close(close_frame) = &message { - println!("Received close message"); - println!("{close_frame:?}") - } else if let ws::Message::Text(text) = &message { - println!("Received text {text:?}"); - sender.send(text.to_string()).unwrap(); - } else { - println!("Received non-text message: {message:?}") + // TODO + println!("Received {message:#?} from client {}", player.id); + match message { + ClientToServerMessage::RoomChange => {} + ClientToServerMessage::Load => {} + ClientToServerMessage::StartGame => { + let mut room = room.write().await; + if room.game.is_some() { + eprintln!( + "Player {} is trying to start an already started game", + player.name + ); + } else { + let rng = SmallRng::from_entropy(); + let dictionary = DICTIONARY.clone(); + let player_names = room + .party_info + .players + .iter() + .map(|p| p.name.clone()) + .collect_vec(); + let game = Game::new_specific( + rng, + dictionary, + player_names, + room.party_info.ais.clone(), + ); + let game = APIGame::new(game); + room.game = Some(game); + + sender.send(InnerRoomMessage::GameEvent).unwrap(); + } + } + ClientToServerMessage::GameMove => {} + ClientToServerMessage::AddAI { difficulty } => { + let mut room = room.write().await; + room.party_info.ais.push(difficulty.clone()); + + let event = ServerToClientMessage::RoomChange { + event: RoomEvent::AIJoined(difficulty), + info: room.party_info.clone(), + }; + sender.send(InnerRoomMessage::PassThrough(event)).unwrap(); + } } - }, - message = other_incoming => { - let message = message.unwrap(); - println!("Sending message \"{message}\" via websocket"); - - - - let _ = stream.send(message.into()).await; // always seems to return Ok(()), even after a disconnection - //println!("Message sent: {blat:?}"); } - _ = ping_tick => { - println!("ping_tick"); - let message = Message::Ping(Vec::new()); - let _ = stream.send(message.into()).await; + Err(e) => { + println!("Received some kind of error {e}") } } } - Ok(()) + } - })) + false +} +async fn outgoing_message_handler( + message: Result, + _sender: &Sender, + player: &Player, + room: &Arc>, + stream: &mut DuplexStream, +) -> bool { + let message = message.unwrap(); + let message = match message { + InnerRoomMessage::PassThrough(event) => serde_json::to_string(&event).unwrap(), + InnerRoomMessage::GameEvent => { + // The game object was modified; we need to trigger a load from this player's perspective + let mut room = room.write().await; + + let state = room.game.as_mut().unwrap().load(&player.name).unwrap(); + let event = ServerToClientMessage::GameEvent { state }; + + serde_json::to_string(&event).unwrap() + } + }; + + let _ = stream.send(message.into()).await; + + false +} + +#[get("/room/?")] +async fn chat( + id: &str, + player_name: &str, + ws: ws::WebSocket, + rooms: &State>, +) -> ws::Channel<'static> { + let mut rooms = rooms.lock().await; + let room = rooms.get(id); + + // TODO extract from cookies + let player = Player { + name: player_name.to_string(), + id: Uuid::new_v4(), + }; + + fn make_join_event(room: &Room, player: &Player) -> ServerToClientMessage { + ServerToClientMessage::RoomChange { + event: RoomEvent::PlayerJoined(player.clone()), + info: room.party_info.clone(), + } + } + + let (room, mut receiver, sender) = if room.is_none_or(|x| x.strong_count() == 0) { + println!("Creating new room"); + let room = Room::new(player.clone()); + let event = make_join_event(&room, &player); + + let sender = room.sender.clone(); + let receiver = sender.subscribe(); + + let arc = Arc::new(RwLock::new(room)); + + rooms.insert(id.to_string(), Arc::downgrade(&arc)); + sender.send(InnerRoomMessage::PassThrough(event)).unwrap(); + + (arc, receiver, sender) + } else { + let a = room.unwrap(); + let b = a.clone(); + let c = b.upgrade(); + let d = c.unwrap(); + + // need to add player to group + let (sender, event) = { + let mut room = d.write().await; + room.party_info.players.push(player.clone()); + let sender = room.sender.clone(); + let event = make_join_event(&room, &player); + + (sender, event) + }; + let receiver = sender.subscribe(); + sender.send(InnerRoomMessage::PassThrough(event)).unwrap(); + + (d, receiver, sender) + }; + + ws.channel(move |mut stream| { + Box::pin(async move { + loop { + let incoming_message = stream.next(); + let room_message = receiver.recv(); + + select! { + // Rust formatter can't reach into this macro, hence we broke out the logic + // into sub-functions + message = incoming_message => { + if incoming_message_handler(message, &sender, &player, &room).await { + return Ok(()) + } + }, + message = room_message => { + if outgoing_message_handler(message, &sender, &player, &room, &mut stream).await { + return Ok(()) + } + + } + } + } + }) + }) + + // + // ws.channel(move |mut stream| Box::pin(async move { + // let mut interval = interval(Duration::from_secs(10)); + // while !stream.is_terminated(){ // always seems to return true? + // let ws_incoming = stream.next(); + // let other_incoming = receiver.recv(); + // let ping_tick = interval.tick(); + // + // // pin_mut!(ws_incoming, other_incoming); // no clue what this does + // + // select! { + // message = ws_incoming => { + // if message.is_none() { + // println!("Websocket closed"); + // return Ok(()) + // } + // + // println!("websocket received a websocket message"); + // let message = message.unwrap()?; + // + // if let ws::Message::Close(close_frame) = &message { + // println!("Received close message"); + // println!("{close_frame:?}") + // } else if let ws::Message::Text(text) = &message { + // println!("Received text {text:?}"); + // sender.send(text.to_string()).unwrap(); + // } else { + // println!("Received non-text message: {message:?}") + // } + // }, + // message = other_incoming => { + // let message = message.unwrap(); + // println!("Sending message \"{message}\" via websocket"); + // + // + // + // let _ = stream.send(message.into()).await; // always seems to return Ok(()), even after a disconnection + // //println!("Message sent: {blat:?}"); + // } + // _ = ping_tick => { + // println!("ping_tick"); + // let message = Message::Ping(Vec::new()); + // let _ = stream.send(message.into()).await; + // } + // } + // } + // Ok(()) + // + // })) } #[launch] fn rocket() -> _ { - rocket::build().manage(Mutex::new(RoomMap::new())) + rocket::build() + .manage(Mutex::new(RoomMap::new())) .mount("/", routes![chat]) -} \ No newline at end of file +} diff --git a/server/src/test.http b/server/src/test.http deleted file mode 100644 index 2aca275..0000000 --- a/server/src/test.http +++ /dev/null @@ -1,5 +0,0 @@ -WEBSOCKET ws://localhost:8000/echo - -Some message - -### diff --git a/ui/src/Menu.tsx b/ui/src/Menu.tsx index 6d0578a..08613ee 100644 --- a/ui/src/Menu.tsx +++ b/ui/src/Menu.tsx @@ -4,6 +4,7 @@ import {Settings} from "./utils"; import {Game} from "./Game"; import {API, Difficulty} from "./api"; import {GameWasm} from "./wasm"; +import {AISelection} from "./UI"; export function Menu(props: {settings: Settings, dictionary_text: string}) { @@ -23,46 +24,12 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) { return
-
- - { - setProportionDictionary(parseInt(e.currentTarget.value)); - }} - min={1} - max={100}/> - - { - setAIRandomness(parseInt(e.currentTarget.value)); - }} - min={0} - max={100}/> -
-
-
    -
  • - "AI's proportion of dictionary" controls what percent of the total AI dictionary - the AI can form words with. At 100%, it has access to its entire dictionary - - although this dictionary is still less than what the player has access to.
  • -
  • -
    - "Level of randomness in AI" controls the degree to which the AI picks the optimal move - for each of its turns. At 0, it always picks the highest scoring move it can do using the - dictionary it has access to. At 1, it picks from its available moves at random. -
    -
    - Note that "Level of randomness in AI" is now mapped on a log scale. - Your current setting is equivalent to {(100*processedAIRandomness).toFixed(1)}% on the previous scale. -
    -
  • - -
-
+
} + +export function AISelection(props: { + aiRandomness: number, + setAIRandomness: (x: number) => void, + proportionDictionary: number, + setProportionDictionary: (x: number) => void, +}) { + + + // Can change log scale to control shape of curve using following equation: + // aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1 + const logBase: number = 10000; + const processedAIRandomness = Math.log(1 + (logBase - 1)*props.aiRandomness/100) / Math.log(logBase); + //const processedProportionDictionary = 1.0 - props.proportionDictionary / 100; + + return <> +
+ + { + props.setProportionDictionary(e.currentTarget.valueAsNumber); + }} + min={1} + max={100}/> + + { + props.setAIRandomness(e.currentTarget.valueAsNumber); + }} + min={0} + max={100}/> +
+
+
    +
  • + "AI's proportion of dictionary" controls what percent of the total AI dictionary + the AI can form words with. At 100%, it has access to its entire dictionary - + although this dictionary is still less than what the player has access to.
  • +
  • +
    + "Level of randomness in AI" controls the degree to which the AI picks the optimal move + for each of its turns. At 0, it always picks the highest scoring move it can do using the + dictionary it has access to. At 1, it picks from its available moves at random. +
    +
    + Note that "Level of randomness in AI" is now mapped on a log scale. + Your current setting is equivalent to {(100*processedAIRandomness).toFixed(1)}% on the previous scale. +
    +
  • + +
+
+ +} \ No newline at end of file diff --git a/ui/src/index.html b/ui/src/index.html index af80563..9242345 100644 --- a/ui/src/index.html +++ b/ui/src/index.html @@ -1,13 +1,13 @@ - - - - - + + + + Word Grid - - - -
- - - + + + + + \ No newline at end of file diff --git a/ui/src/multiplayer.html b/ui/src/multiplayer.html new file mode 100644 index 0000000..0356cc6 --- /dev/null +++ b/ui/src/multiplayer.html @@ -0,0 +1,11 @@ + + + + + Word Grid + + + +
+ + \ No newline at end of file diff --git a/ui/src/multiplayer.tsx b/ui/src/multiplayer.tsx new file mode 100644 index 0000000..fb65bb9 --- /dev/null +++ b/ui/src/multiplayer.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import {useState} from "react"; +import {createRoot} from "react-dom/client"; +import {AISelection} from "./UI"; + +interface Player { + name: string + id: string +} + +interface AI { + proportion: number + randomness: number +} + +interface PartyInfo { + ais: AI[] + players: Player[] +} + +export function Menu(): React.JSX.Element { + + const [roomName, setRoomName] = useState(""); + const [socket, setSocket] = useState(null); + const [partyInfo, setPartyInfo] = useState(null); + const [playerName, setPlayerName] = useState(""); + + const [aiRandomness, setAIRandomness] = useState(6); + const [proportionDictionary, setProportionDictionary] = useState(7); + + + // Can change log scale to control shape of curve using following equation: + // aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1 + const logBase: number = 10000; + const processedAIRandomness = Math.log(1 + (logBase - 1)*aiRandomness/100) / Math.log(logBase); + const processedProportionDictionary = 1.0 - proportionDictionary / 100; + + if(socket != null && partyInfo == null) { + return

Connecting to {roomName}

+ } else if (partyInfo != null) { + const players = partyInfo.players.map((x) => { + return
  • {x.name}
  • ; + }); + const ais = partyInfo.ais.map((x, i) => { + return
  • Proportion: {x.proportion} / Randomness: {x.randomness}
  • + }) + + return
    +

    Connected to {roomName}

    + Players:
      + {players} +
    + AIs:
      + {ais} +
    +
    + Add AI + + +
    + +
    + } else { + return
    +
    + +
    +
    + +
    + + +
    ; + } +} + +async function run() { + const root = createRoot(document.getElementById("root")); + root.render(); + +} + +run(); \ No newline at end of file diff --git a/ui/src/singleplayer.html b/ui/src/singleplayer.html new file mode 100644 index 0000000..be7a73d --- /dev/null +++ b/ui/src/singleplayer.html @@ -0,0 +1,13 @@ + + + + + + Word Grid + + + +
    + + + diff --git a/ui/src/index.tsx b/ui/src/singleplayer.tsx similarity index 100% rename from ui/src/index.tsx rename to ui/src/singleplayer.tsx diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 1590234..5633243 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -11,4 +11,4 @@ serde-wasm-bindgen = "0.6.5" wasm-bindgen = "0.2.92" word_grid = { version = "0.1.0", path = "../wordgrid" } serde_json = { workspace = true } -serde = { workspace = true } \ No newline at end of file +serde = { workspace = true } diff --git a/wordgrid/Cargo.toml b/wordgrid/Cargo.toml index e44d62c..e76913f 100644 --- a/wordgrid/Cargo.toml +++ b/wordgrid/Cargo.toml @@ -9,7 +9,7 @@ description = "A (WIP) package for playing 'WordGrid'." [dependencies] csv = "1.3.0" -rand = {version = "0.8.5", features = ["small_rng"]} getrandom = {version = "0.2", features = ["js"]} serde_json = { workspace = true } serde = { workspace = true } +rand = { workspace = true } diff --git a/wordgrid/src/api.rs b/wordgrid/src/api.rs index e992817..12966e7 100644 --- a/wordgrid/src/api.rs +++ b/wordgrid/src/api.rs @@ -156,9 +156,9 @@ impl APIGame { Ok(self.build_result(tray, Some(game_state), Some(update))) } - pub fn load(&mut self, player: String) -> Result { - if !self.player_exists(&player) { - Err(Error::InvalidPlayer(player)) + pub fn load(&mut self, player: &str) -> Result { + if !self.player_exists(player) { + Err(Error::InvalidPlayer(player.to_string())) } else { while self.is_ai_turn() { let (result, _) = self.0.advance_turn()?; @@ -173,7 +173,7 @@ impl APIGame { } } - let tray = self.0.player_states.get_tray(&player).unwrap().clone(); + let tray = self.0.player_states.get_tray(player).unwrap().clone(); Ok(self.build_result(tray, None, None)) } } diff --git a/wordgrid/src/game.rs b/wordgrid/src/game.rs index b29784e..59f848c 100644 --- a/wordgrid/src/game.rs +++ b/wordgrid/src/game.rs @@ -8,7 +8,7 @@ use crate::player_interaction::ai::{Difficulty, AI}; use crate::player_interaction::Tray; use rand::prelude::SliceRandom; use rand::rngs::SmallRng; -use rand::SeedableRng; +use rand::{Rng, SeedableRng}; use serde::{Deserialize, Serialize, Serializer}; pub enum Player { @@ -204,17 +204,15 @@ pub struct Game { } impl Game { - pub fn new( - seed: u64, - dictionary_text: &str, + + pub fn new_specific( + mut rng: SmallRng, + dictionary: DictionaryImpl, player_names: Vec, ai_difficulties: Vec, ) -> Self { - let mut rng = SmallRng::seed_from_u64(seed); let mut letters = standard_tile_pool(Some(&mut rng)); - let dictionary = DictionaryImpl::create_from_str(dictionary_text); - let mut player_states: Vec = player_names .iter() .map(|name| { @@ -264,6 +262,18 @@ impl Game { } } + pub fn new( + seed: u64, + dictionary_text: &str, + player_names: Vec, + ai_difficulties: Vec, + ) -> Self { + let rng = SmallRng::seed_from_u64(seed); + let dictionary = DictionaryImpl::create_from_str(dictionary_text); + + Self::new_specific(rng, dictionary, player_names, ai_difficulties) + } + pub fn get_board(&self) -> &Board { &self.board }