Multiplayer #1
15 changed files with 586 additions and 134 deletions
|
@ -4,4 +4,5 @@ resolver = "2"
|
|||
|
||||
[workspace.dependencies]
|
||||
serde_json = "1.0.132"
|
||||
serde = { version = "1.0.213", features = ["derive"] }
|
||||
serde = { version = "1.0.213", features = ["derive"] }
|
||||
rand = {version = "0.8.5", features = ["small_rng"]}
|
|
@ -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"
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
WEBSOCKET ws://localhost:8000/echo
|
||||
|
||||
Some message
|
||||
|
||||
###
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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>
|
||||
</html>
|
||||
|
||||
</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
11
ui/src/multiplayer.html
Normal 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
117
ui/src/multiplayer.tsx
Normal 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
13
ui/src/singleplayer.html
Normal 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>
|
||||
|
|
@ -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 }
|
||||
serde = { workspace = true }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue