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 rand::prelude::SmallRng;
use rand::SeedableRng;
use rocket::fs::FileServer;
use rocket::futures::{SinkExt, StreamExt};
use rocket::tokio::sync::broadcast::Sender;
use rocket::tokio::sync::{Mutex, RwLock};
use rocket::tokio::time::interval;
use rocket::{tokio, State};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Weak};
use rocket::fs::FileServer;
use std::time::Duration;
use tokio::select;
use word_grid::api::{APIGame, ApiState, Update};
use word_grid::dictionary::{Dictionary, DictionaryImpl};
@ -24,6 +26,15 @@ use ws::Message;
static DICTIONARY: LazyLock<DictionaryImpl> =
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)]
struct Player {
name: String,
@ -132,13 +143,12 @@ async fn incoming_message_handler<E: std::fmt::Display>(
Some(message) => {
match message {
Ok(message) => {
if let Message::Ping(_) = message {
println!("Received ping from player {player:#?}");
if matches!(message, Message::Ping(_)) || matches!(message, Message::Pong(_)) {
return false;
}
let message = message.to_text().unwrap();
if message.len() == 0 {
println!("Websocket closed");
println!("Received message of length zero");
println!("Player {player:#?} is leaving");
let mut room = room.write().await;
@ -372,8 +382,11 @@ async fn room(
ws: ws::WebSocket,
rooms: &State<Mutex<RoomMap>>,
) -> ws::Channel<'static> {
let id = escape_characters(id);
let player_name = escape_characters(player_name);
let mut rooms = rooms.lock().await;
let room = rooms.get(id);
let room = rooms.get(&id);
let player = Player {
name: player_name.to_string(),
@ -465,23 +478,38 @@ async fn room(
ws.channel(move |mut stream| {
Box::pin(async move {
let mut interval = interval(Duration::from_secs(10));
loop {
let incoming_message = stream.next();
let room_message = receiver.recv();
let ping_tick = interval.tick();
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, &mut stream).await {
println!("incoming returned true");
return Ok(())
}
},
message = room_message => {
if outgoing_message_handler(message, &sender, &player, &room, &mut stream).await {
println!("outgoing returned true");
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;
return <>
<div className="grid">
<div className="ai-grid">
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
<input type="number"
name="proportion-dictionary"

View file

@ -1,21 +1,19 @@
import * as React from "react";
import {useRef, useState} from "react";
import {useState} from "react";
import {createRoot} from "react-dom/client";
import {AISelection} from "./UI";
import {ClientToServerMessage, WSAPI, PartyInfo, ServerToClientMessage} from "./ws_api";
import {Game} from "./Game";
import {Settings} from "./utils";
const LOGBASE = 10000;
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)
}
function unprocessedAIProportion(processedProportion: number): string {
return (100 * (1-processedProportion)).toFixed(0);
return (100 * (1 - processedProportion)).toFixed(0);
}
export function Menu(): React.JSX.Element {
@ -29,30 +27,23 @@ export function Menu(): React.JSX.Element {
const [proportionDictionary, setProportionDictionary] = useState<number>(7);
const [game, setGame] = useState<React.JSX.Element>(null);
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;
}
const validSettings = roomName.length > 0 && !roomName.includes("/") && playerName.length > 0 && !playerName.includes("?") && !playerName.includes("&");
// 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 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;
if(socket != null && partyInfo == null) {
if (game) {
return game;
} else 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.name}>{x.name}</li>;
});
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>
<button onClick={() => {
const event = {
@ -61,94 +52,109 @@ export function Menu(): React.JSX.Element {
};
socket.send(JSON.stringify(event));
}
}>X
}>Delete
</button>
</li>
});
return <div>
return <dialog open className="multiplayer-lobby">
<p>Connected to {roomName}</p>
Players: <ol>
{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>
{button_or_game}
</div>
<details className="multiplayer-ai-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>
<div className="side-grid">
<button onClick={() => {
socket.close();
setSocket(null);
setPartyInfo(null);
}}>Disconnect
</button>
<button onClick={() => {
const event: ClientToServerMessage = {
type: "StartGame"
};
socket.send(JSON.stringify(event));
}}>Start Game
</button>
</div>
</dialog>
} else {
return <div>
<div>
return <dialog open>
<div className="multiplayer-inputs-grid">
<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={() => {
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
socket.addEventListener("message", (event) => {
const input: ServerToClientMessage = JSON.parse(event.data);
if(input.type == "RoomChange"){
setPartyInfo(input.info);
} else if(input.type == "GameEvent" && game == null){
// start game
setGame(<Game api={new WSAPI(socket)} settings={{
playerName: playerName,
trayLength: 7,
}} end_game_fn={function(): void {
socket.close();
<button
disabled={!validSettings}
onClick={() => {
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
socket.addEventListener("message", (event) => {
const input: ServerToClientMessage = JSON.parse(event.data);
if (input.type == "RoomChange") {
setPartyInfo(input.info);
} else if (input.type == "GameEvent" && game == null) {
// start game
setGame(<Game api={new WSAPI(socket)} settings={{
playerName: playerName,
trayLength: 7,
}} end_game_fn={function (): void {
socket.close();
setSocket(null);
setPartyInfo(null);
setGame(null);
}}/>);
}
console.log("Message from server ", event.data);
});
socket.addEventListener("close", (event) => {
console.log({event});
setSocket(null);
setPartyInfo(null);
setGame(null);
} } />);
}
console.log("Message from server ", event.data);
});
socket.addEventListener("close", (event) => {
console.log({event});
setSocket(null);
setGame(null);
if(event.reason != null && event.reason.length > 0) {
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
}
});
setSocket(socket);
}}>Connect
</button>
</div>;
if (event.reason != null && event.reason.length > 0) {
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
}
});
setSocket(socket);
}}>Connect
</button>
</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;
label {
display: grid;
grid-template-columns: 3fr 2fr;
}
}
.ai-grid{
display: grid;
grid-template-columns: 3fr 2fr;
grid-column-gap: 1em;
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;
.grid {
display: grid;
grid-template-columns: 3fr 2fr;
grid-column-gap: 1em;
grid-row-gap: 0.5em;
}
.selection-buttons {
display: grid;
grid-template-columns: 1fr 1fr;