feat: WIP multiplayer support

This commit is contained in:
Joel Therrien 2024-12-02 15:39:27 -08:00
parent 00ff3bd7dd
commit 5f125dfb75
15 changed files with 586 additions and 134 deletions

View file

@ -4,4 +4,5 @@ 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"]}

View file

@ -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"

View file

@ -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())
} else { #[derive(Clone, Serialize, Debug)]
let (sender, receiver) = channel::<String>(1024); struct PartyInfo {
rooms.insert(id.to_string(), sender.clone()); 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 { struct Room {
let mut interval = interval(Duration::from_secs(10)); party_info: PartyInfo,
while !stream.is_terminated(){ // always seems to return true? game: Option<APIGame>,
let ws_incoming = stream.next(); sender: Sender<InnerRoomMessage>,
let other_incoming = receiver.recv(); }
let ping_tick = interval.tick();
// 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! { #[derive(Clone, Serialize, Debug)]
message = ws_incoming => { #[serde(tag = "type")]
if message.is_none() { 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"); 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"); println!("Received {message}");
let message = message.unwrap()?; let message: ClientToServerMessage = serde_json::from_str(message).unwrap();
if let ws::Message::Close(close_frame) = &message { // TODO
println!("Received close message"); println!("Received {message:#?} from client {}", player.id);
println!("{close_frame:?}") match message {
} else if let ws::Message::Text(text) = &message { ClientToServerMessage::RoomChange => {}
println!("Received text {text:?}"); ClientToServerMessage::Load => {}
sender.send(text.to_string()).unwrap(); ClientToServerMessage::StartGame => {
} else { let mut room = room.write().await;
println!("Received non-text message: {message:?}") 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 => { Err(e) => {
println!("ping_tick"); println!("Received some kind of error {e}")
let message = Message::Ping(Vec::new());
let _ = stream.send(message.into()).await;
} }
} }
} }
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] #[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])
} }

View file

@ -1,5 +0,0 @@
WEBSOCKET ws://localhost:8000/echo
Some message
###

View file

@ -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();

View file

@ -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>
</>
}

View file

@ -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>
</html> </ul>
</body>
</html>

11
ui/src/multiplayer.html Normal file
View 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
View 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
View 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>

View file

@ -11,4 +11,4 @@ serde-wasm-bindgen = "0.6.5"
wasm-bindgen = "0.2.92" wasm-bindgen = "0.2.92"
word_grid = { version = "0.1.0", path = "../wordgrid" } word_grid = { version = "0.1.0", path = "../wordgrid" }
serde_json = { workspace = true } serde_json = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View file

@ -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 }

View file

@ -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))
} }
} }

View file

@ -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
} }