feat: WIP multiplayer support

This commit is contained in:
Joel Therrien 2024-12-02 15:39:27 -08:00
parent 00ff3bd7dd
commit 5f125dfb75
15 changed files with 586 additions and 134 deletions

View file

@ -5,3 +5,4 @@ resolver = "2"
[workspace.dependencies]
serde_json = "1.0.132"
serde = { version = "1.0.213", features = ["derive"] }
rand = {version = "0.8.5", features = ["small_rng"]}

View file

@ -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"

View file

@ -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::<String, Sender<String>>;
static DICTIONARY: LazyLock<DictionaryImpl> =
LazyLock::new(|| DictionaryImpl::create_from_path("../resources/dictionary.csv"));
#[get("/room/<id>")]
async fn chat(id: &str, ws: ws::WebSocket, rooms: &State<Mutex<RoomMap>>) -> 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::<String>(1024);
rooms.insert(id.to_string(), sender.clone());
#[derive(Clone, Serialize, Debug)]
struct PartyInfo {
ais: Vec<Difficulty>,
players: Vec<Player>,
}
(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<APIGame>,
sender: Sender<InnerRoomMessage>,
}
// 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<String, Weak<RwLock<Room>>>;
async fn incoming_message_handler<E: std::fmt::Display>(
message: Option<Result<Message, E>>,
sender: &Sender<InnerRoomMessage>,
player: &Player,
room: &Arc<RwLock<Room>>,
) -> 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<E: std::fmt::Debug>(
message: Result<InnerRoomMessage, E>,
_sender: &Sender<InnerRoomMessage>,
player: &Player,
room: &Arc<RwLock<Room>>,
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/<id>?<player_name>")]
async fn chat(
id: &str,
player_name: &str,
ws: ws::WebSocket,
rooms: &State<Mutex<RoomMap>>,
) -> 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])
}

View file

@ -1,5 +0,0 @@
WEBSOCKET ws://localhost:8000/echo
Some message
###

View file

@ -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 <dialog open>
<div className="new-game">
<div className="grid">
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
<input type="number"
name="proportion-dictionary"
value={proportionDictionary}
onInput={(e) => {
setProportionDictionary(parseInt(e.currentTarget.value));
}}
min={1}
max={100}/>
<label htmlFor="randomness">Level of randomness in AI:</label>
<input type="number"
name="randomness"
value={aiRandomness}
onInput={(e) => {
setAIRandomness(parseInt(e.currentTarget.value));
}}
min={0}
max={100}/>
</div>
<details>
<ul>
<li>
"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.</li>
<li>
<div>
"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.
</div>
<div>
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.
</div>
</li>
</ul>
</details>
<AISelection
aiRandomness={aiRandomness}
setAIRandomness={setAIRandomness}
proportionDictionary={proportionDictionary}
setProportionDictionary={setProportionDictionary}
/>
<div className="selection-buttons">
<button onClick={() => {
const seed = new Date().getTime();

View file

@ -1,5 +1,5 @@
import * as React from "react";
import {ChangeEvent, JSX} from "react";
import {ChangeEvent, JSX, useState} from "react";
import {
cellTypeToDetails,
CoordinateData,
@ -12,7 +12,9 @@ import {
TileDispatch,
TileDispatchActionType,
} from "./utils";
import {APIPlayer, CellType} from "./api";
import {API, APIPlayer, CellType, Difficulty} from "./api";
import {GameWasm} from "./wasm";
import {Game} from "./Game";
export function TileSlot(props: {
@ -245,3 +247,61 @@ export function Scores(props: {playerScores: Array<APIPlayer>}){
{elements}
</div>
}
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 <>
<div className="grid">
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
<input type="number"
name="proportion-dictionary"
value={props.proportionDictionary}
onInput={(e) => {
props.setProportionDictionary(e.currentTarget.valueAsNumber);
}}
min={1}
max={100}/>
<label htmlFor="randomness">Level of randomness in AI:</label>
<input type="number"
name="randomness"
value={props.aiRandomness}
onInput={(e) => {
props.setAIRandomness(e.currentTarget.valueAsNumber);
}}
min={0}
max={100}/>
</div>
<details>
<ul>
<li>
"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.</li>
<li>
<div>
"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.
</div>
<div>
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.
</div>
</li>
</ul>
</details>
</>
}

View file

@ -1,13 +1,13 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="style.less" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Word Grid</title>
</head>
<body>
<script src="index.tsx" type="module"></script>
<div id="root"></div>
</body>
</head>
<body>
<ul>
<li><a href="singleplayer.html">Singleplayer</a></li>
<li><a href="multiplayer.html">Multiplayer</a></li>
</ul>
</body>
</html>

11
ui/src/multiplayer.html Normal file
View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Word Grid</title>
</head>
<body>
<script src="multiplayer.tsx" type="module"></script>
<div id="root"></div>
</body>
</html>

117
ui/src/multiplayer.tsx Normal file
View file

@ -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<string>("");
const [socket, setSocket] = useState<WebSocket>(null);
const [partyInfo, setPartyInfo] = useState<PartyInfo>(null);
const [playerName, setPlayerName] = useState<string>("");
const [aiRandomness, setAIRandomness] = useState<number>(6);
const [proportionDictionary, setProportionDictionary] = useState<number>(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 <div><p>Connecting to {roomName}</p></div>
} else if (partyInfo != null) {
const players = partyInfo.players.map((x) => {
return <li key={x.id}>{x.name}</li>;
});
const ais = partyInfo.ais.map((x, i) => {
return <li key={i}>Proportion: {x.proportion} / Randomness: {x.randomness}</li>
})
return <div>
<p>Connected to {roomName}</p>
Players: <ol>
{players}
</ol>
AIs: <ol>
{ais}
</ol>
<details>
<summary>Add AI</summary>
<AISelection aiRandomness={aiRandomness} setAIRandomness={setAIRandomness} proportionDictionary={proportionDictionary} setProportionDictionary={setProportionDictionary} />
<button onClick={() => {
const event = {
type: "AddAI",
difficulty: {
proportion: processedProportionDictionary,
randomness: processedAIRandomness,
}
};
socket.send(JSON.stringify(event));
}}>
Add AI
</button>
</details>
<button onClick={(e) => {
socket.close();
setSocket(null);
setPartyInfo(null);
}}>Disconnect</button>
</div>
} else {
return <div>
<div>
<label>
Room Name:
<input type="text" value={roomName} onChange={(e) => {
setRoomName(e.target.value)
}}></input>
</label>
</div>
<div>
<label>
Player Name:
<input type="text" value={playerName} onChange={(e) => {
setPlayerName(e.target.value)
}}></input>
</label>
</div>
<button onClick={(e) => {
let socket = new WebSocket(`ws://localhost:8000/room/${roomName}?player_name=${playerName}`)
socket.addEventListener("message", (event) => {
const input: { info: PartyInfo } = JSON.parse(event.data);
setPartyInfo(input.info);
console.log("Message from server ", event.data);
});
setSocket(socket);
}}>Connect
</button>
</div>;
}
}
async function run() {
const root = createRoot(document.getElementById("root"));
root.render(<Menu/>);
}
run();

13
ui/src/singleplayer.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="style.less" />
<title>Word Grid</title>
</head>
<body>
<script src="singleplayer.tsx" type="module"></script>
<div id="root"></div>
</body>
</html>

View file

@ -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 }

View file

@ -156,9 +156,9 @@ impl APIGame {
Ok(self.build_result(tray, Some(game_state), Some(update)))
}
pub fn load(&mut self, player: String) -> Result<ApiState, Error> {
if !self.player_exists(&player) {
Err(Error::InvalidPlayer(player))
pub fn load(&mut self, player: &str) -> Result<ApiState, Error> {
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))
}
}

View file

@ -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<String>,
ai_difficulties: Vec<Difficulty>,
) -> 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<PlayerState> = player_names
.iter()
.map(|name| {
@ -264,6 +262,18 @@ impl Game {
}
}
pub fn new(
seed: u64,
dictionary_text: &str,
player_names: Vec<String>,
ai_difficulties: Vec<Difficulty>,
) -> 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
}