feat: WIP multiplayer support
This commit is contained in:
parent
00ff3bd7dd
commit
5f125dfb75
15 changed files with 586 additions and 134 deletions
|
@ -5,3 +5,4 @@ resolver = "2"
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde_json = "1.0.132"
|
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]
|
[dependencies]
|
||||||
itertools = "0.13.0"
|
itertools = "0.13.0"
|
||||||
rocket = { version = "0.5.1", features = ["json"] }
|
rocket = { version = "0.5.1", features = ["json"] }
|
||||||
#word_grid = { path="../wordgrid" }
|
word_grid = { path="../wordgrid" }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde = { 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" }
|
ws = { package = "rocket_ws", version = "0.1.1" }
|
||||||
|
rand = { workspace = true }
|
||||||
#futures = "0.3.30"
|
#futures = "0.3.30"
|
||||||
|
|
|
@ -1,87 +1,364 @@
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate rocket;
|
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 std::collections::HashMap;
|
||||||
use rocket::futures::{pin_mut, FutureExt, StreamExt, SinkExt};
|
use std::sync::{Arc, LazyLock, Weak};
|
||||||
use rocket::futures::stream::FusedStream;
|
use tokio::select;
|
||||||
use rocket::State;
|
use uuid::Uuid;
|
||||||
use std::time::Duration;
|
use word_grid::api::{APIGame, ApiState};
|
||||||
use rocket::tokio::select;
|
use word_grid::dictionary::{Dictionary, DictionaryImpl};
|
||||||
use rocket::tokio::sync::broadcast::{channel, Sender};
|
use word_grid::game::Game;
|
||||||
use rocket::tokio::sync::Mutex;
|
use word_grid::player_interaction::ai::Difficulty;
|
||||||
use rocket::tokio::time::interval;
|
use ws::stream::DuplexStream;
|
||||||
use ws::Message;
|
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>")]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
async fn chat(id: &str, ws: ws::WebSocket, rooms: &State<Mutex<RoomMap>>) -> ws::Channel<'static> {
|
struct Player {
|
||||||
let mut rooms = rooms.lock().await;
|
name: String,
|
||||||
let (sender, mut receiver) = if rooms.contains_key(id) {
|
id: Uuid,
|
||||||
let sender = rooms.get(id).unwrap();
|
}
|
||||||
(sender.clone(), sender.subscribe())
|
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Debug)]
|
||||||
|
struct PartyInfo {
|
||||||
|
ais: Vec<Difficulty>,
|
||||||
|
players: Vec<Player>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartyInfo {
|
||||||
|
fn new(host: Player) -> Self {
|
||||||
|
Self {
|
||||||
|
ais: Vec::new(),
|
||||||
|
players: vec![host],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Room {
|
||||||
|
party_info: PartyInfo,
|
||||||
|
game: Option<APIGame>,
|
||||||
|
sender: Sender<InnerRoomMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Room {
|
||||||
|
fn new(host: Player) -> Self {
|
||||||
|
Self {
|
||||||
|
party_info: PartyInfo::new(host),
|
||||||
|
game: None,
|
||||||
|
sender: Sender::new(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
// 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!("Received {message}");
|
||||||
|
let message: ClientToServerMessage = serde_json::from_str(message).unwrap();
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
let (sender, receiver) = channel::<String>(1024);
|
let rng = SmallRng::from_entropy();
|
||||||
rooms.insert(id.to_string(), sender.clone());
|
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, receiver)
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Received some kind of error {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.channel(move |mut stream| Box::pin(async move {
|
let _ = stream.send(message.into()).await;
|
||||||
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
|
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! {
|
select! {
|
||||||
message = ws_incoming => {
|
// Rust formatter can't reach into this macro, hence we broke out the logic
|
||||||
if message.is_none() {
|
// into sub-functions
|
||||||
println!("Websocket closed");
|
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(())
|
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(())
|
})
|
||||||
|
})
|
||||||
}))
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// 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]
|
#[launch]
|
||||||
fn rocket() -> _ {
|
fn rocket() -> _ {
|
||||||
rocket::build().manage(Mutex::new(RoomMap::new()))
|
rocket::build()
|
||||||
|
.manage(Mutex::new(RoomMap::new()))
|
||||||
.mount("/", routes![chat])
|
.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 {Game} from "./Game";
|
||||||
import {API, Difficulty} from "./api";
|
import {API, Difficulty} from "./api";
|
||||||
import {GameWasm} from "./wasm";
|
import {GameWasm} from "./wasm";
|
||||||
|
import {AISelection} from "./UI";
|
||||||
|
|
||||||
export function Menu(props: {settings: Settings, dictionary_text: string}) {
|
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>
|
return <dialog open>
|
||||||
<div className="new-game">
|
<div className="new-game">
|
||||||
<div className="grid">
|
<AISelection
|
||||||
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
|
aiRandomness={aiRandomness}
|
||||||
<input type="number"
|
setAIRandomness={setAIRandomness}
|
||||||
name="proportion-dictionary"
|
proportionDictionary={proportionDictionary}
|
||||||
value={proportionDictionary}
|
setProportionDictionary={setProportionDictionary}
|
||||||
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>
|
|
||||||
<div className="selection-buttons">
|
<div className="selection-buttons">
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const seed = new Date().getTime();
|
const seed = new Date().getTime();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {ChangeEvent, JSX} from "react";
|
import {ChangeEvent, JSX, useState} from "react";
|
||||||
import {
|
import {
|
||||||
cellTypeToDetails,
|
cellTypeToDetails,
|
||||||
CoordinateData,
|
CoordinateData,
|
||||||
|
@ -12,7 +12,9 @@ import {
|
||||||
TileDispatch,
|
TileDispatch,
|
||||||
TileDispatchActionType,
|
TileDispatchActionType,
|
||||||
} from "./utils";
|
} 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: {
|
export function TileSlot(props: {
|
||||||
|
@ -245,3 +247,61 @@ export function Scores(props: {playerScores: Array<APIPlayer>}){
|
||||||
{elements}
|
{elements}
|
||||||
</div>
|
</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>
|
<!DOCTYPE html>
|
||||||
<html lang="en-US">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="style.less" />
|
|
||||||
<title>Word Grid</title>
|
<title>Word Grid</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="index.tsx" type="module"></script>
|
<ul>
|
||||||
<div id="root"></div>
|
<li><a href="singleplayer.html">Singleplayer</a></li>
|
||||||
</body>
|
<li><a href="multiplayer.html">Multiplayer</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
</html>
|
</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>
|
||||||
|
|
|
@ -9,7 +9,7 @@ description = "A (WIP) package for playing 'WordGrid'."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
csv = "1.3.0"
|
csv = "1.3.0"
|
||||||
rand = {version = "0.8.5", features = ["small_rng"]}
|
|
||||||
getrandom = {version = "0.2", features = ["js"]}
|
getrandom = {version = "0.2", features = ["js"]}
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
|
|
@ -156,9 +156,9 @@ impl APIGame {
|
||||||
Ok(self.build_result(tray, Some(game_state), Some(update)))
|
Ok(self.build_result(tray, Some(game_state), Some(update)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&mut self, player: String) -> Result<ApiState, Error> {
|
pub fn load(&mut self, player: &str) -> Result<ApiState, Error> {
|
||||||
if !self.player_exists(&player) {
|
if !self.player_exists(player) {
|
||||||
Err(Error::InvalidPlayer(player))
|
Err(Error::InvalidPlayer(player.to_string()))
|
||||||
} else {
|
} else {
|
||||||
while self.is_ai_turn() {
|
while self.is_ai_turn() {
|
||||||
let (result, _) = self.0.advance_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))
|
Ok(self.build_result(tray, None, None))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use crate::player_interaction::ai::{Difficulty, AI};
|
||||||
use crate::player_interaction::Tray;
|
use crate::player_interaction::Tray;
|
||||||
use rand::prelude::SliceRandom;
|
use rand::prelude::SliceRandom;
|
||||||
use rand::rngs::SmallRng;
|
use rand::rngs::SmallRng;
|
||||||
use rand::SeedableRng;
|
use rand::{Rng, SeedableRng};
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
|
|
||||||
pub enum Player {
|
pub enum Player {
|
||||||
|
@ -204,17 +204,15 @@ pub struct Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
pub fn new(
|
|
||||||
seed: u64,
|
pub fn new_specific(
|
||||||
dictionary_text: &str,
|
mut rng: SmallRng,
|
||||||
|
dictionary: DictionaryImpl,
|
||||||
player_names: Vec<String>,
|
player_names: Vec<String>,
|
||||||
ai_difficulties: Vec<Difficulty>,
|
ai_difficulties: Vec<Difficulty>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut rng = SmallRng::seed_from_u64(seed);
|
|
||||||
let mut letters = standard_tile_pool(Some(&mut rng));
|
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
|
let mut player_states: Vec<PlayerState> = player_names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| {
|
.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 {
|
pub fn get_board(&self) -> &Board {
|
||||||
&self.board
|
&self.board
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue