Multiplayer #1

Merged
joel merged 19 commits from multiplayer into main 2024-12-26 18:38:24 +00:00
4 changed files with 151 additions and 94 deletions
Showing only changes of commit 81cb9772e3 - Show all commits

View file

@ -4,14 +4,16 @@ extern crate rocket;
use itertools::Itertools; use itertools::Itertools;
use rand::prelude::SmallRng; use rand::prelude::SmallRng;
use rand::SeedableRng; use rand::SeedableRng;
use rocket::fs::FileServer;
use rocket::futures::{SinkExt, StreamExt}; use rocket::futures::{SinkExt, StreamExt};
use rocket::tokio::sync::broadcast::Sender; use rocket::tokio::sync::broadcast::Sender;
use rocket::tokio::sync::{Mutex, RwLock}; use rocket::tokio::sync::{Mutex, RwLock};
use rocket::tokio::time::interval;
use rocket::{tokio, State}; use rocket::{tokio, State};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Weak}; use std::sync::{Arc, LazyLock, Weak};
use rocket::fs::FileServer; use std::time::Duration;
use tokio::select; use tokio::select;
use word_grid::api::{APIGame, ApiState, Update}; use word_grid::api::{APIGame, ApiState, Update};
use word_grid::dictionary::{Dictionary, DictionaryImpl}; use word_grid::dictionary::{Dictionary, DictionaryImpl};
@ -24,6 +26,15 @@ use ws::Message;
static DICTIONARY: LazyLock<DictionaryImpl> = static DICTIONARY: LazyLock<DictionaryImpl> =
LazyLock::new(|| DictionaryImpl::create_from_path("../resources/dictionary.csv")); LazyLock::new(|| DictionaryImpl::create_from_path("../resources/dictionary.csv"));
fn escape_characters(x: &str) -> String {
let x = x
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;");
x
}
#[derive(Clone, Debug, Serialize, PartialEq)] #[derive(Clone, Debug, Serialize, PartialEq)]
struct Player { struct Player {
name: String, name: String,
@ -132,13 +143,12 @@ async fn incoming_message_handler<E: std::fmt::Display>(
Some(message) => { Some(message) => {
match message { match message {
Ok(message) => { Ok(message) => {
if let Message::Ping(_) = message { if matches!(message, Message::Ping(_)) || matches!(message, Message::Pong(_)) {
println!("Received ping from player {player:#?}");
return false; return false;
} }
let message = message.to_text().unwrap(); let message = message.to_text().unwrap();
if message.len() == 0 { if message.len() == 0 {
println!("Websocket closed"); println!("Received message of length zero");
println!("Player {player:#?} is leaving"); println!("Player {player:#?} is leaving");
let mut room = room.write().await; let mut room = room.write().await;
@ -372,8 +382,11 @@ async fn room(
ws: ws::WebSocket, ws: ws::WebSocket,
rooms: &State<Mutex<RoomMap>>, rooms: &State<Mutex<RoomMap>>,
) -> ws::Channel<'static> { ) -> ws::Channel<'static> {
let id = escape_characters(id);
let player_name = escape_characters(player_name);
let mut rooms = rooms.lock().await; let mut rooms = rooms.lock().await;
let room = rooms.get(id); let room = rooms.get(&id);
let player = Player { let player = Player {
name: player_name.to_string(), name: player_name.to_string(),
@ -465,23 +478,38 @@ async fn room(
ws.channel(move |mut stream| { ws.channel(move |mut stream| {
Box::pin(async move { Box::pin(async move {
let mut interval = interval(Duration::from_secs(10));
loop { loop {
let incoming_message = stream.next(); let incoming_message = stream.next();
let room_message = receiver.recv(); let room_message = receiver.recv();
let ping_tick = interval.tick();
select! { select! {
// Rust formatter can't reach into this macro, hence we broke out the logic // Rust formatter can't reach into this macro, hence we broke out the logic
// into sub-functions // into sub-functions
message = incoming_message => { message = incoming_message => {
if incoming_message_handler(message, &sender, &player, &room, &mut stream).await { if incoming_message_handler(message, &sender, &player, &room, &mut stream).await {
println!("incoming returned true");
return Ok(()) return Ok(())
} }
}, },
message = room_message => { message = room_message => {
if outgoing_message_handler(message, &sender, &player, &room, &mut stream).await { if outgoing_message_handler(message, &sender, &player, &room, &mut stream).await {
println!("outgoing returned true");
return Ok(()) return Ok(())
} }
},
_ = ping_tick => {
// send keep-alive
let message = Message::Ping(Vec::new());
let result = stream.send(message.into()).await;
// if it fails, disconnect
if let Err(e) = result {
println!("Failed to send ping for player {player:#?}; disconnecting");
println!("Received error {e}");
return Ok(())
}
} }
} }
} }

View file

@ -262,7 +262,7 @@ export function AISelection(props: {
//const processedProportionDictionary = 1.0 - props.proportionDictionary / 100; //const processedProportionDictionary = 1.0 - props.proportionDictionary / 100;
return <> return <>
<div className="grid"> <div className="ai-grid">
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label> <label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
<input type="number" <input type="number"
name="proportion-dictionary" name="proportion-dictionary"

View file

@ -1,14 +1,12 @@
import * as React from "react"; import * as React from "react";
import {useRef, useState} from "react"; import {useState} from "react";
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import {AISelection} from "./UI"; import {AISelection} from "./UI";
import {ClientToServerMessage, WSAPI, PartyInfo, ServerToClientMessage} from "./ws_api"; import {ClientToServerMessage, WSAPI, PartyInfo, ServerToClientMessage} from "./ws_api";
import {Game} from "./Game"; import {Game} from "./Game";
import {Settings} from "./utils";
const LOGBASE = 10000; const LOGBASE = 10000;
function unprocessAIRandomness(processedAIRandomness: number): string { function unprocessAIRandomness(processedAIRandomness: number): string {
const x = 100 * (LOGBASE ** processedAIRandomness - 1) / (LOGBASE - 1) const x = 100 * (LOGBASE ** processedAIRandomness - 1) / (LOGBASE - 1)
return x.toFixed(0) return x.toFixed(0)
@ -29,30 +27,23 @@ export function Menu(): React.JSX.Element {
const [proportionDictionary, setProportionDictionary] = useState<number>(7); const [proportionDictionary, setProportionDictionary] = useState<number>(7);
const [game, setGame] = useState<React.JSX.Element>(null); const [game, setGame] = useState<React.JSX.Element>(null);
const validSettings = roomName.length > 0 && !roomName.includes("/") && playerName.length > 0 && !playerName.includes("?") && !playerName.includes("&");
let button_or_game = <button onClick={() => {
const event: ClientToServerMessage = {
type: "StartGame"
};
socket.send(JSON.stringify(event));
}}>Start Game</button>;
if(game){
button_or_game = game;
}
// Can change log scale to control shape of curve using following equation: // 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 // aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1
const processedAIRandomness = Math.log(1 + (LOGBASE - 1) * aiRandomness / 100) / Math.log(LOGBASE); const processedAIRandomness = Math.log(1 + (LOGBASE - 1) * aiRandomness / 100) / Math.log(LOGBASE);
const processedProportionDictionary = 1.0 - proportionDictionary / 100; const processedProportionDictionary = 1.0 - proportionDictionary / 100;
if(socket != null && partyInfo == null) { if (game) {
return game;
} else if (socket != null && partyInfo == null) {
return <div><p>Connecting to {roomName}</p></div> return <div><p>Connecting to {roomName}</p></div>
} else if (partyInfo != null) { } else if (partyInfo != null) {
const players = partyInfo.players.map((x) => { const players = partyInfo.players.map((x) => {
return <li key={x.name}>{x.name}</li>; return <li key={x.name}>{x.name}</li>;
}); });
const ais = partyInfo.ais.map((x, i) => { const ais = partyInfo.ais.map((x, i) => {
return <li key={i}> return <li key={i} className="side-grid">
<span>Proportion: {unprocessedAIProportion(x.proportion)} / Randomness: {unprocessAIRandomness(x.randomness)}</span> <span>Proportion: {unprocessedAIProportion(x.proportion)} / Randomness: {unprocessAIRandomness(x.randomness)}</span>
<button onClick={() => { <button onClick={() => {
const event = { const event = {
@ -61,13 +52,13 @@ export function Menu(): React.JSX.Element {
}; };
socket.send(JSON.stringify(event)); socket.send(JSON.stringify(event));
} }
}>X }>Delete
</button> </button>
</li> </li>
}); });
return <div> return <dialog open className="multiplayer-lobby">
<p>Connected to {roomName}</p> <p>Connected to {roomName}</p>
Players: <ol> Players: <ol>
{players} {players}
@ -75,9 +66,11 @@ export function Menu(): React.JSX.Element {
AIs: <ol> AIs: <ol>
{ais} {ais}
</ol> </ol>
<details> <details className="multiplayer-ai-details">
<summary>Add AI</summary> <summary>Add AI</summary>
<AISelection aiRandomness={aiRandomness} setAIRandomness={setAIRandomness} proportionDictionary={proportionDictionary} setProportionDictionary={setProportionDictionary} /> <AISelection aiRandomness={aiRandomness} setAIRandomness={setAIRandomness}
proportionDictionary={proportionDictionary}
setProportionDictionary={setProportionDictionary}/>
<button onClick={() => { <button onClick={() => {
const event = { const event = {
type: "AddAI", type: "AddAI",
@ -91,34 +84,44 @@ export function Menu(): React.JSX.Element {
Add AI Add AI
</button> </button>
</details> </details>
<button onClick={(e) => { <div className="side-grid">
<button onClick={() => {
socket.close(); socket.close();
setSocket(null); setSocket(null);
setPartyInfo(null); setPartyInfo(null);
}}>Disconnect</button> }}>Disconnect
{button_or_game} </button>
<button onClick={() => {
const event: ClientToServerMessage = {
type: "StartGame"
};
socket.send(JSON.stringify(event));
}}>Start Game
</button>
</div> </div>
</dialog>
} else { } else {
return <div> return <dialog open>
<div> <div className="multiplayer-inputs-grid">
<label> <label>
Room Name: Room Name:
<input type="text" value={roomName} onChange={(e) => { <input type="text" value={roomName} onChange={(e) => {
setRoomName(e.target.value) setRoomName(e.target.value)
}}></input> }}></input>
</label> </label>
</div>
<div>
<label> <label>
Player Name: Player Name:
<input type="text" value={playerName} onChange={(e) => { <input type="text" value={playerName} onChange={(e) => {
setPlayerName(e.target.value) setPlayerName(e.target.value)
}}></input> }}></input>
</label> </label>
</div>
<button onClick={() => { <button
disabled={!validSettings}
onClick={() => {
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`) let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
socket.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
const input: ServerToClientMessage = JSON.parse(event.data); const input: ServerToClientMessage = JSON.parse(event.data);
@ -132,6 +135,7 @@ export function Menu(): React.JSX.Element {
}} end_game_fn={function (): void { }} end_game_fn={function (): void {
socket.close(); socket.close();
setSocket(null); setSocket(null);
setPartyInfo(null);
setGame(null); setGame(null);
}}/>); }}/>);
} }
@ -140,6 +144,7 @@ export function Menu(): React.JSX.Element {
socket.addEventListener("close", (event) => { socket.addEventListener("close", (event) => {
console.log({event}); console.log({event});
setSocket(null); setSocket(null);
setPartyInfo(null);
setGame(null); setGame(null);
if (event.reason != null && event.reason.length > 0) { if (event.reason != null && event.reason.length > 0) {
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`); alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
@ -148,7 +153,8 @@ export function Menu(): React.JSX.Element {
setSocket(socket); setSocket(socket);
}}>Connect }}>Connect
</button> </button>
</div>; </div>
</dialog>;
} }
} }

View file

@ -218,22 +218,45 @@
} }
} }
.multiplayer-inputs-grid {
display: grid;
//grid-template-columns: 3fr 2fr;
//grid-column-gap: 1em;
grid-row-gap: 0.5em;
dialog { label {
border-radius: 10px; display: grid;
z-index: 1; grid-template-columns: 3fr 2fr;
}
}
.new-game { .ai-grid{
width: 50em;
.grid {
display: grid; display: grid;
grid-template-columns: 3fr 2fr; grid-template-columns: 3fr 2fr;
grid-column-gap: 1em; grid-column-gap: 1em;
grid-row-gap: 0.5em; grid-row-gap: 0.5em;
} }
.side-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 1em;
}
.multiplayer-ai-details {
button {
margin: 1em;
}
}
dialog {
border-radius: 10px;
z-index: 1;
width: 50em;
.new-game {
width: 50em;
.selection-buttons { .selection-buttons {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;