Multiplayer #1
4 changed files with 151 additions and 94 deletions
|
@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">");
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -1,21 +1,19 @@
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
function unprocessedAIProportion(processedProportion: number): string {
|
function unprocessedAIProportion(processedProportion: number): string {
|
||||||
return (100 * (1-processedProportion)).toFixed(0);
|
return (100 * (1 - processedProportion)).toFixed(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Menu(): React.JSX.Element {
|
export function Menu(): React.JSX.Element {
|
||||||
|
@ -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,94 +52,109 @@ 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}
|
||||||
</ol>
|
</ol>
|
||||||
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}
|
||||||
<button onClick={() => {
|
proportionDictionary={proportionDictionary}
|
||||||
const event = {
|
setProportionDictionary={setProportionDictionary}/>
|
||||||
type: "AddAI",
|
<button onClick={() => {
|
||||||
difficulty: {
|
const event = {
|
||||||
proportion: processedProportionDictionary,
|
type: "AddAI",
|
||||||
randomness: processedAIRandomness,
|
difficulty: {
|
||||||
}
|
proportion: processedProportionDictionary,
|
||||||
};
|
randomness: processedAIRandomness,
|
||||||
socket.send(JSON.stringify(event));
|
}
|
||||||
}}>
|
};
|
||||||
Add AI
|
socket.send(JSON.stringify(event));
|
||||||
</button>
|
}}>
|
||||||
</details>
|
Add AI
|
||||||
<button onClick={(e) => {
|
</button>
|
||||||
socket.close();
|
</details>
|
||||||
setSocket(null);
|
<div className="side-grid">
|
||||||
setPartyInfo(null);
|
<button onClick={() => {
|
||||||
}}>Disconnect</button>
|
socket.close();
|
||||||
{button_or_game}
|
setSocket(null);
|
||||||
</div>
|
setPartyInfo(null);
|
||||||
|
}}>Disconnect
|
||||||
|
</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
const event: ClientToServerMessage = {
|
||||||
|
type: "StartGame"
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify(event));
|
||||||
|
}}>Start Game
|
||||||
|
</button>
|
||||||
|
</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
|
||||||
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
|
disabled={!validSettings}
|
||||||
socket.addEventListener("message", (event) => {
|
onClick={() => {
|
||||||
const input: ServerToClientMessage = JSON.parse(event.data);
|
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
|
||||||
if(input.type == "RoomChange"){
|
socket.addEventListener("message", (event) => {
|
||||||
setPartyInfo(input.info);
|
const input: ServerToClientMessage = JSON.parse(event.data);
|
||||||
} else if(input.type == "GameEvent" && game == null){
|
if (input.type == "RoomChange") {
|
||||||
// start game
|
setPartyInfo(input.info);
|
||||||
setGame(<Game api={new WSAPI(socket)} settings={{
|
} else if (input.type == "GameEvent" && game == null) {
|
||||||
playerName: playerName,
|
// start game
|
||||||
trayLength: 7,
|
setGame(<Game api={new WSAPI(socket)} settings={{
|
||||||
}} end_game_fn={function(): void {
|
playerName: playerName,
|
||||||
socket.close();
|
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);
|
setSocket(null);
|
||||||
|
setPartyInfo(null);
|
||||||
setGame(null);
|
setGame(null);
|
||||||
} } />);
|
if (event.reason != null && event.reason.length > 0) {
|
||||||
}
|
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
|
||||||
console.log("Message from server ", event.data);
|
}
|
||||||
});
|
});
|
||||||
socket.addEventListener("close", (event) => {
|
setSocket(socket);
|
||||||
console.log({event});
|
}}>Connect
|
||||||
setSocket(null);
|
</button>
|
||||||
setGame(null);
|
</div>
|
||||||
if(event.reason != null && event.reason.length > 0) {
|
</dialog>;
|
||||||
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setSocket(socket);
|
|
||||||
}}>Connect
|
|
||||||
</button>
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
dialog {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
width: 50em;
|
||||||
|
|
||||||
.new-game {
|
.new-game {
|
||||||
width: 50em;
|
width: 50em;
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 3fr 2fr;
|
|
||||||
grid-column-gap: 1em;
|
|
||||||
grid-row-gap: 0.5em;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-buttons {
|
.selection-buttons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
Loading…
Reference in a new issue