Multiplayer #1

Merged
joel merged 19 commits from multiplayer into main 2024-12-26 18:38:24 +00:00
39 changed files with 4128 additions and 1934 deletions

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
/target target/
/Cargo.lock /Cargo.lock
.idea/ .idea/
pkg/ pkg/
.nvmrc

View file

@ -1,22 +1,8 @@
[package] [workspace]
name = "word_grid" members = ["wordgrid", "wasm", "server"]
version = "0.1.0" resolver = "2"
edition = "2021"
authors = ["Joel Therrien"]
repository = "https://git.joeltherrien.ca/joel/WordGrid"
license = "AGPL-3"
description = "A (WIP) package for playing 'WordGrid'."
[lib] [workspace.dependencies]
crate-type = ["cdylib"] serde_json = "1.0.132"
serde = { version = "1.0.213", features = ["derive"] }
[dependencies]
csv = "1.2.2"
rand = {version = "0.8.5", features = ["small_rng"]} rand = {version = "0.8.5", features = ["small_rng"]}
getrandom = {version = "0.2", features = ["js"]}
wasm-bindgen = { version = "0.2.87", features = ["serde-serialize"] }
serde_json = "1.0"
serde = { version = "=1.0.171", features = ["derive"] }
serde-wasm-bindgen = "0.4.5"
tsify = { version = "0.4.5", features = ["js"] }

45
Dockerfile Normal file
View file

@ -0,0 +1,45 @@
FROM docker.io/library/ubuntu:latest as build-stage
LABEL authors="joel"
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install --no-install-recommends -y rustup npm
RUN rustup default stable
RUN apt-get install --no-install-recommends -y gcc
RUN apt-get install --no-install-recommends -y gcc-multilib
RUN cargo install wasm-pack
RUN mkdir /build
WORKDIR /build/
COPY Cargo.toml Cargo.lock /build/
COPY --exclude=*/target/* wordgrid /build/wordgrid/
COPY --exclude=*/target/* --exclude=*/pkg/* wasm /build/wasm/
COPY --exclude=*/target/* server /build/server/
COPY --exclude=*/node_modules/* --exclude=*/dist/* ui /build/ui/
COPY resources /build/resources/
RUN cd wasm && ~/.cargo/bin/wasm-pack build --target=web
RUN cd ui && npm install
RUN cd ui && npm run build
RUN cd server && cargo build --release
FROM docker.io/library/ubuntu:latest as final-image
LABEL authors="joel"
WORKDIR /srv/
COPY --from=build-stage /build/ui/dist /srv/static
COPY --from=build-stage /build/target/release/server server
RUN cp /srv/static/*.csv dictionary.csv
RUN echo '{"dictionary_path": "dictionary.csv", "static_path": "static"}' > config.json
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=8000
ENTRYPOINT ["./server"]
EXPOSE 8000

View file

@ -209609,7 +209609,6 @@ RUBYING,0.0
RUBYLIKE,0.0 RUBYLIKE,0.0
RUBYTHROAT,0.0 RUBYTHROAT,0.0
RUBYTHROATS,0.0 RUBYTHROATS,0.0
RUC,-1.0
RUCHE,0.329741454406077 RUCHE,0.329741454406077
RUCHED,0.329741454406077 RUCHED,0.329741454406077
RUCHES,0.0 RUCHES,0.0

Can't render this file because it is too large.

15
server/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
itertools = "0.13.0"
rocket = { version = "0.5.1", features = ["json"] }
word_grid = { path="../wordgrid" }
serde_json = { workspace = true }
serde = { workspace = true }
uuid = { version = "1.11.0", features = ["serde", "v4"] }
ws = { package = "rocket_ws", version = "0.1.1" }
rand = { workspace = true }
#futures = "0.3.30"

4
server/config.json Normal file
View file

@ -0,0 +1,4 @@
{
"dictionary_path": "../resources/dictionary.csv",
"static_path": "../ui/dist"
}

130
server/src/lib.rs Normal file
View file

@ -0,0 +1,130 @@
use rocket::serde::{Deserialize, Serialize};
use rocket::tokio::sync::broadcast::Sender;
use rocket::tokio::sync::RwLock;
use std::collections::HashMap;
use std::sync::Weak;
use word_grid::api::{APIGame, ApiState, Update};
use word_grid::game::{Error, PlayedTile};
use word_grid::player_interaction::ai::Difficulty;
use ws::frame::{CloseCode, CloseFrame};
pub fn escape_characters(x: &str) -> String {
let x = x
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;");
x
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Player {
pub name: String,
}
#[derive(Clone, Serialize, Debug)]
pub struct PartyInfo {
pub ais: Vec<Difficulty>,
pub players: Vec<Player>,
}
impl PartyInfo {
fn new(host: Player) -> Self {
Self {
ais: Vec::new(),
players: vec![host],
}
}
}
pub struct Room {
pub party_info: PartyInfo,
pub game: Option<APIGame>,
pub sender: Sender<(Option<Player>, InnerRoomMessage)>,
}
impl Room {
pub fn new(host: Player) -> Self {
Self {
party_info: PartyInfo::new(host),
game: None,
sender: Sender::new(5),
}
}
}
#[derive(Clone, Serialize, Debug)]
#[serde(tag = "type")]
pub enum RoomEvent {
PlayerJoined { player: Player },
PlayerLeft { player: Player },
AIJoined { difficulty: Difficulty },
AILeft { index: usize },
}
#[derive(Clone, Serialize, Debug)]
#[serde(tag = "type")]
pub enum GameEvent {
TurnAction { state: ApiState, committed: bool },
}
#[derive(Clone, Serialize, Debug)]
#[serde(tag = "type")]
pub enum ServerToClientMessage {
RoomChange { event: RoomEvent, info: PartyInfo },
GameEvent { event: GameEvent },
GameError { error: Error },
Invalid { reason: String },
}
#[derive(Deserialize, Debug)]
#[serde(tag = "type")]
pub enum GameMove {
Pass,
Exchange {
tiles: Vec<bool>,
},
Play {
played_tiles: Vec<Option<PlayedTile>>,
commit_move: bool,
},
AddToDictionary {
word: String,
},
}
#[derive(Deserialize, Debug)]
#[serde(tag = "type")]
pub enum ClientToServerMessage {
Load,
StartGame,
GameMove { r#move: GameMove },
AddAI { difficulty: Difficulty },
RemoveAI { index: usize },
}
#[derive(Clone, Debug)]
pub enum InnerRoomMessage {
PassThrough(ServerToClientMessage),
GameEvent(Option<Update>),
}
pub type RoomMap = HashMap<String, Weak<RwLock<Room>>>;
pub fn reject_websocket_with_reason(
ws: ws::WebSocket,
close_code: CloseCode,
reason: String,
) -> ws::Channel<'static> {
ws.channel(move |mut stream| {
Box::pin(async move {
let closeframe = CloseFrame {
code: close_code,
reason: reason.into(),
};
let _ = stream.close(Some(closeframe)).await;
Ok(())
})
})
}

419
server/src/main.rs Normal file
View file

@ -0,0 +1,419 @@
#[macro_use]
extern crate rocket;
use itertools::Itertools;
use rand::prelude::SmallRng;
use rand::SeedableRng;
use rocket::fs::FileServer;
use rocket::futures::{SinkExt, StreamExt};
use rocket::serde::Deserialize;
use rocket::tokio::sync::broadcast::Sender;
use rocket::tokio::sync::{Mutex, RwLock};
use rocket::tokio::time::interval;
use rocket::{tokio, State};
use server::{
escape_characters, reject_websocket_with_reason, ClientToServerMessage, GameEvent, GameMove,
InnerRoomMessage, Player, Room, RoomEvent, RoomMap, ServerToClientMessage,
};
use std::fs;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::select;
use word_grid::api::{APIGame, Update};
use word_grid::dictionary::{Dictionary, DictionaryImpl};
use word_grid::game::Game;
use ws::frame::CloseCode;
use ws::stream::DuplexStream;
use ws::Message;
async fn incoming_message_handler<E: std::fmt::Display>(
message: Option<Result<Message, E>>,
sender: &Sender<(Option<Player>, InnerRoomMessage)>,
player: &Player,
room: &Arc<RwLock<Room>>,
stream: &mut DuplexStream,
) -> bool {
match message {
None => {
panic!("Not sure when this happens")
}
Some(message) => {
match message {
Ok(message) => {
if matches!(message, Message::Ping(_)) || matches!(message, Message::Pong(_)) {
return false;
}
let message = message.to_text().unwrap();
if message.len() == 0 {
println!("Received message of length zero");
println!("Player {player:#?} is leaving");
let mut room = room.write().await;
let new_vec = room
.party_info
.players
.iter()
.filter(|p| !p.name.eq(&player.name))
.map(|p| p.clone())
.collect_vec();
room.party_info.players = new_vec;
let event = ServerToClientMessage::RoomChange {
event: RoomEvent::PlayerLeft {
player: player.clone(),
},
info: room.party_info.clone(),
};
sender
.send((None, 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.name);
match message {
ClientToServerMessage::Load => {
return !game_load(player, None, room, stream).await
}
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.get().unwrap().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((None, InnerRoomMessage::GameEvent(None)))
.unwrap();
}
}
ClientToServerMessage::GameMove { r#move } => {
let mut room = room.write().await;
if room.game.is_none() {
let event = ServerToClientMessage::Invalid {
reason: format!("Game hasn't been started yet"),
};
let event = serde_json::to_string(&event).unwrap();
let _ = stream.send(event.into()).await;
} else {
let game = room.game.as_mut().unwrap();
let result = match r#move {
GameMove::Pass => game.pass(&player.name),
GameMove::Exchange { tiles } => {
game.exchange(&player.name, tiles)
}
GameMove::Play {
played_tiles,
commit_move,
} => {
let result =
game.play(&player.name, played_tiles, commit_move);
if result.is_ok() & !commit_move {
let event = ServerToClientMessage::GameEvent {
event: GameEvent::TurnAction {
state: result.unwrap(),
committed: false,
},
};
let event = serde_json::to_string(&event).unwrap();
let _ = stream.send(event.into()).await;
return false;
}
result
}
GameMove::AddToDictionary { word } => {
game.add_to_dictionary(&player.name, &word)
}
};
match result {
Ok(event) => {
sender
.send((None, InnerRoomMessage::GameEvent(event.update)))
.unwrap();
}
Err(error) => {
let event = ServerToClientMessage::GameError { error };
let event = serde_json::to_string(&event).unwrap();
let _ = stream.send(event.into()).await;
}
}
}
}
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((None, InnerRoomMessage::PassThrough(event)))
.unwrap();
}
ClientToServerMessage::RemoveAI { index } => {
let mut room = room.write().await;
if index < room.party_info.ais.len() {
room.party_info.ais.remove(index);
let event = ServerToClientMessage::RoomChange {
event: RoomEvent::AILeft { index },
info: room.party_info.clone(),
};
sender
.send((None, InnerRoomMessage::PassThrough(event)))
.unwrap();
} else {
let event = ServerToClientMessage::Invalid {
reason: format!("{index} is out of bounds"),
};
let event = serde_json::to_string(&event).unwrap();
let _ = stream.send(event.into()).await;
}
}
}
}
Err(e) => {
println!("Received some kind of error {e}")
}
}
}
}
false
}
async fn game_load(
player: &Player,
update: Option<Update>,
room: &Arc<RwLock<Room>>,
stream: &mut DuplexStream,
) -> bool {
// The game object was modified; we need to trigger a load from this player's perspective
let mut room = room.write().await;
let mut state = room.game.as_mut().unwrap().load(&player.name).unwrap();
state.update = update;
let event = ServerToClientMessage::GameEvent {
event: GameEvent::TurnAction {
state,
committed: true,
},
};
let text = serde_json::to_string(&event).unwrap();
let x = stream.send(text.into()).await;
x.is_ok()
}
async fn outgoing_message_handler<E: std::fmt::Debug>(
message: Result<(Option<Player>, InnerRoomMessage), E>,
_sender: &Sender<(Option<Player>, InnerRoomMessage)>,
player: &Player,
room: &Arc<RwLock<Room>>,
stream: &mut DuplexStream,
) -> bool {
let (target, message) = message.unwrap();
println!("Inner room message - {:#?}", message);
if target.is_none() || target.unwrap() == *player {
match message {
InnerRoomMessage::PassThrough(event) => {
let text = serde_json::to_string(&event).unwrap();
let x = stream.send(text.into()).await;
x.is_err()
}
InnerRoomMessage::GameEvent(update) => !game_load(player, update, room, stream).await,
}
} else {
false
}
}
#[get("/room/<id>?<player_name>")]
async fn room(
id: &str,
player_name: &str,
ws: ws::WebSocket,
rooms: &State<Mutex<RoomMap>>,
) -> ws::Channel<'static> {
let id = escape_characters(id);
let player_name = escape_characters(player_name);
let mut rooms = rooms.lock().await;
let room = rooms.get(&id);
let player = Player {
name: player_name.to_string(),
};
fn make_join_event(room: &Room, player: &Player) -> ServerToClientMessage {
ServerToClientMessage::RoomChange {
event: RoomEvent::PlayerJoined {
player: player.clone(),
},
info: room.party_info.clone(),
}
}
let (room, mut receiver, sender, trigger_game_load) =
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((None, InnerRoomMessage::PassThrough(event)))
.unwrap();
(arc, receiver, sender, false)
} else {
let a = room.unwrap();
let b = a.clone();
let c = b.upgrade();
let d = c.unwrap();
let mut trigger_game_load = false;
// need to add player to group
let (sender, event) = {
let mut room = d.write().await;
// check if player is already in the room. If they are, don't allow the new connection
if room.party_info.players.contains(&player) {
return reject_websocket_with_reason(
ws,
CloseCode::Protocol,
format!("{} is already in the room", player.name),
);
}
if let Some(game) = &room.game {
if game
.0
.player_states
.get_player_state(&player.name)
.is_none()
{
// Game is in progress and our new player isn't a member
return reject_websocket_with_reason(
ws,
CloseCode::Protocol,
"The game is already in-progress".to_string(),
);
}
trigger_game_load = true;
}
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((None, InnerRoomMessage::PassThrough(event)))
.unwrap();
(d, receiver, sender, trigger_game_load)
};
if trigger_game_load {
sender
.send((Some(player.clone()), InnerRoomMessage::GameEvent(None)))
.unwrap();
}
ws.channel(move |mut stream| {
Box::pin(async move {
let mut interval = interval(Duration::from_secs(10));
loop {
let incoming_message = stream.next();
let room_message = receiver.recv();
let ping_tick = interval.tick();
select! {
// Rust formatter can't reach into this macro, hence we broke out the logic
// into sub-functions
message = incoming_message => {
if incoming_message_handler(message, &sender, &player, &room, &mut stream).await {
println!("incoming returned true");
return Ok(())
}
},
message = room_message => {
if outgoing_message_handler(message, &sender, &player, &room, &mut stream).await {
println!("outgoing returned true");
return Ok(())
}
},
_ = ping_tick => {
// send keep-alive
let message = Message::Ping(Vec::new());
let result = stream.send(message.into()).await;
// if it fails, disconnect
if let Err(e) = result {
println!("Failed to send ping for player {player:#?}; disconnecting");
println!("Received error {e}");
return Ok(())
}
}
}
}
})
})
}
#[derive(Debug, Deserialize)]
struct Config {
dictionary_path: String,
static_path: String,
}
pub static DICTIONARY: OnceLock<DictionaryImpl> = OnceLock::new();
#[launch]
fn rocket() -> _ {
let config_str = fs::read_to_string("config.json").unwrap();
let config: Config = serde_json::from_str(&config_str).unwrap();
let dictionary = DictionaryImpl::create_from_path(&config.dictionary_path);
DICTIONARY.set(dictionary).unwrap();
rocket::build()
.manage(Mutex::new(RoomMap::new()))
.mount("/", FileServer::from(config.static_path))
.mount("/", routes![room])
}

View file

@ -1,21 +0,0 @@
use wasm_bindgen::prelude::wasm_bindgen;
pub mod constants;
pub mod board;
pub mod dictionary;
pub mod player_interaction;
pub mod game;
pub mod wasm;
#[wasm_bindgen]
extern {
pub fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name));
}

View file

@ -1,213 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_wasm_bindgen::Error;
use tsify::Tsify;
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::wasm_bindgen;
use crate::board::{CellType, Letter};
use crate::game::{Game, GameState, PlayedTile};
use crate::player_interaction::ai::Difficulty;
#[wasm_bindgen]
pub struct GameWasm(Game);
#[derive(Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)]
pub enum ResponseType {
OK,
ERR,
}
#[derive(Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)]
pub struct MyResult<E: Serialize> {
response_type: ResponseType,
value: E,
game_state: Option<GameState>,
}
#[wasm_bindgen]
impl GameWasm {
#[wasm_bindgen(constructor)]
pub fn new(seed: u64, dictionary_text: &str, ai_difficulty: JsValue) -> GameWasm {
let difficulty: Difficulty = serde_wasm_bindgen::from_value(ai_difficulty).unwrap();
GameWasm(Game::new(seed, dictionary_text, vec!["Player".to_string()], vec![difficulty]))
}
pub fn get_tray(&self, name: &str) -> Result<JsValue, Error> {
let tray = self.0.player_states.get_tray(name);
serde_wasm_bindgen::to_value(&tray)
}
pub fn get_board_cell_types(&self) -> Result<JsValue, Error> {
let board = self.0.get_board();
let cell_types: Vec<CellType> = board.cells.iter().map(|cell| -> CellType {
cell.cell_type.clone()
}).collect();
serde_wasm_bindgen::to_value(&cell_types)
}
pub fn get_board_letters(&self) -> Result<JsValue, Error> {
let board = self.0.get_board();
let letters: Vec<Option<Letter>> = board.cells.iter().map(|cell| -> Option<Letter> {
cell.value.clone()
}).collect();
serde_wasm_bindgen::to_value(&letters)
}
pub fn receive_play(&mut self, tray_tile_locations: JsValue, commit_move: bool) -> Result<JsValue, Error> {
let tray_tile_locations: Vec<Option<PlayedTile>> = serde_wasm_bindgen::from_value(tray_tile_locations)?;
let result = self.0.receive_play(tray_tile_locations, commit_move);
match result {
Ok((x, game_state)) => {
serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK,
value: x,
game_state: Some(game_state)
})
},
Err(e) => {
serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::ERR,
value: e,
game_state: None,
})
}
}
}
pub fn get_scores(&self) -> Result<JsValue, JsValue> {
#[derive(Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)]
pub struct PlayerAndScore {
name: String,
score: u32,
}
let scores: Vec<PlayerAndScore> = self.0.player_states.0.iter()
.map(|player_state| {
PlayerAndScore {
name: player_state.player.get_name().to_string(),
score: player_state.score,
}
})
.collect();
Ok(serde_wasm_bindgen::to_value(&scores)?)
}
pub fn exchange_tiles(&mut self, tray_tile_locations: JsValue) -> Result<JsValue, Error>{
let tray_tile_locations: Vec<bool> = serde_wasm_bindgen::from_value(tray_tile_locations)?;
match self.0.exchange_tiles(tray_tile_locations) {
Ok((_, turn_action, state)) => {
serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK,
value: turn_action,
game_state: Some(state),
})
},
Err(e) => {
serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::ERR,
value: e,
game_state: None,
})
}
}
}
pub fn add_word(&mut self, word: String) {
self.0.add_word(word);
}
pub fn skip_turn(&mut self) -> Result<JsValue, Error>{
let result = self.0.pass();
match result {
Ok(game_state) => {
Ok(serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK,
value: "Turn passed",
game_state: Some(game_state),
})?)
},
Err(e) => {
Ok(serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::ERR,
value: e,
game_state: None,
})?)
}
}
}
pub fn advance_turn(&mut self) -> Result<JsValue, Error> {
let result = self.0.advance_turn();
match result {
Ok((turn_advance_result, game_state)) => {
Ok(serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK,
value: turn_advance_result,
game_state: Some(game_state),
})?)
},
Err(e) => {
Ok(serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::ERR,
value: e,
game_state: None,
})?)
}
}
}
pub fn get_current_player(&self) -> String {
self.0.current_player_name()
}
pub fn get_remaining_tiles(&self) -> usize {
self.0.get_remaining_tiles()
}
pub fn get_player_tile_count(&self, player: &str) -> Result<JsValue, Error> {
match self.0.get_player_tile_count(player) {
Ok(count) => {
Ok(serde_wasm_bindgen::to_value(&MyResult{
response_type: ResponseType::OK,
value: count,
game_state: None,
})?)
},
Err(msg) => {
Ok(serde_wasm_bindgen::to_value(&MyResult{
response_type: ResponseType::OK,
value: msg,
game_state: None,
})?)
}
}
}
}

1919
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,13 @@
"dependencies": { "dependencies": {
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"word_grid": "file:../pkg" "word_grid": "file:../wasm/pkg"
}, },
"devDependencies": { "devDependencies": {
"@parcel/transformer-less": "^2.9.3", "@parcel/transformer-less": "^2.9.3",
"@types/react": "^18.2.18", "@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"parcel": "^2.9.3", "parcel": "^2.12.0",
"process": "^0.11.10" "process": "^0.11.10"
}, },
"scripts": { "scripts": {

View file

@ -1,16 +1,5 @@
import * as React from "react"; import * as React from "react";
import {useEffect, useMemo, useReducer, useRef, useState} from "react"; import {useEffect, useReducer, useRef, useState} from "react";
import {
GameState,
GameWasm,
MyResult,
PlayedTile,
PlayerAndScore,
ScoreResult,
Tray,
TurnAction,
TurnAdvanceResult
} from "../../pkg/word_grid";
import { import {
Direction, Direction,
GRID_LENGTH, GRID_LENGTH,
@ -28,58 +17,75 @@ import {
} from "./utils"; } from "./utils";
import {TileExchangeModal} from "./TileExchange"; import {TileExchangeModal} from "./TileExchange";
import {Grid, Scores, TileTray} from "./UI"; import {Grid, Scores, TileTray} from "./UI";
import {API, APIState, GameState, TurnAction, Tray, PlayedTile, Letter} from "./api";
function addLogInfo(existingLog: React.JSX.Element[], newItem: React.JSX.Element) { function addLogInfo(existingLog: React.JSX.Element[], newItem: React.JSX.Element) {
newItem = React.cloneElement(newItem, { key: existingLog.length }) newItem = React.cloneElement(newItem, {key: existingLog.length})
existingLog.push(newItem); existingLog.push(newItem);
return existingLog.slice(); return existingLog.slice();
} }
export function Game(props: { export function Game(props: {
wasm: GameWasm, api: API,
settings: Settings, settings: Settings,
end_game_fn: () => void, end_game_fn: () => void,
}) { }) {
const cellTypes = useMemo(() => { const [api_state, setAPIState] = useState<APIState>(undefined);
return props.wasm.get_board_cell_types();
}, []);
const [isGameOver, setGameOver] = useState<boolean>(false);
const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1); const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1);
const currentTurnNumber = useRef<number>(-1);
const historyProcessedNumber = useRef<number>(0);
let isGameOver = false;
if (api_state != null) {
isGameOver = api_state.public_information.game_state.type === "Ended";
}
function waitForUpdate() {
const result = props.api.load(true);
result.then(
(state) => {
setAPIState(state);
}
)
.catch((error) => {
console.log("waitForUpdate() failed")
console.log(error);
});
}
const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => { const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => {
const newLetterData = [] as HighlightableLetterData[]; const newLetterData = [] as HighlightableLetterData[];
for(let i=0; i<GRID_LENGTH * GRID_LENGTH; i++) { for (let i = 0; i < GRID_LENGTH * GRID_LENGTH; i++) {
newLetterData.push(undefined); newLetterData.push(null);
} }
return newLetterData; return newLetterData;
}); });
function adjustGridArrow(existing: GridArrowData, update: GridArrowDispatchAction): GridArrowData { function adjustGridArrow(existing: GridArrowData, update: GridArrowDispatchAction): GridArrowData {
console.log({update}); if (update.action == GridArrowDispatchActionType.CLEAR) {
if(update.action == GridArrowDispatchActionType.CLEAR) {
return null; return null;
} else if (update.action == GridArrowDispatchActionType.CYCLE) { } else if (update.action == GridArrowDispatchActionType.CYCLE) {
// if there's nothing where the user clicked, we create a right arrow. // if there's nothing where the user clicked, we create a right arrow.
if(existing == null || existing.position != update.position) { if (existing == null || existing.position != update.position) {
return { return {
direction: Direction.RIGHT, position: update.position direction: Direction.RIGHT, position: update.position
} }
// if there's a right arrow, we shift to downwards // if there's a right arrow, we shift to downwards
} else if(existing.direction == Direction.RIGHT) { } else if (existing.direction == Direction.RIGHT) {
return { return {
direction: Direction.DOWN, position: existing.position direction: Direction.DOWN, position: existing.position
} }
// if there's a down arrow, we clear it // if there's a down arrow, we clear it
} else if (existing.direction == Direction.DOWN){ } else if (existing.direction == Direction.DOWN) {
return null; return null;
} }
} else if (update.action == GridArrowDispatchActionType.SHIFT) { } else if (update.action == GridArrowDispatchActionType.SHIFT) {
if(existing == null) { if (existing == null) {
// no arrow to shift // no arrow to shift
return null; return null;
} else { } else {
@ -88,7 +94,7 @@ export function Game(props: {
// we loop because we want to skip over letters that are already set // we loop because we want to skip over letters that are already set
while (current_x < GRID_LENGTH && current_y < GRID_LENGTH) { while (current_x < GRID_LENGTH && current_y < GRID_LENGTH) {
if(existing.direction == Direction.RIGHT) { if (existing.direction == Direction.RIGHT) {
current_x += 1; current_x += 1;
} else { } else {
current_y += 1; current_y += 1;
@ -103,7 +109,7 @@ export function Game(props: {
} }
} }
if(current_x < GRID_LENGTH && current_y < GRID_LENGTH && boardLetters[new_position] == null && !tray_letter_at_position) { if (current_x < GRID_LENGTH && current_y < GRID_LENGTH && boardLetters[new_position] == null && !tray_letter_at_position) {
return { return {
direction: existing.direction, direction: existing.direction,
position: new_position, position: new_position,
@ -119,38 +125,36 @@ export function Game(props: {
} }
function exchangeFunction(selectedArray: Array<boolean>) { function exchangeFunction(selectedArray: Array<boolean>) {
const result = props.api.exchange(selectedArray);
const result: MyResult<TurnAction | string> = props.wasm.exchange_tiles(selectedArray); result
.then(
if(result.response_type === "ERR") { (api_state) => {
logDispatch(<div><em>{(result.value as string)}</em></div>); setAPIState(api_state);
} else { })
handlePlayerAction(result.value as TurnAction, props.settings.playerName); .catch((error) => {
setTurnCount(turnCount + 1); console.error({error});
logDispatch(<div>{error}</div>);
if(result.game_state.type === "Ended") { });
endGame(result.game_state);
}
}
} }
function addWordFn(word: string) {
props.wasm.add_word(word);
}
const [gridArrow, gridArrowDispatch] = useReducer(adjustGridArrow, null); const [gridArrow, gridArrowDispatch] = useReducer(adjustGridArrow, null);
const [logInfo, logDispatch] = useReducer(addLogInfo, []); const [logInfo, logDispatch] = useReducer(addLogInfo, []);
useEffect(() => {
props.api.load(false)
.then((api_state) => {
setAPIState(api_state);
});
}, []);
function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) { function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) {
if (update.action === TileDispatchActionType.RETRIEVE) {
if(update.action === TileDispatchActionType.RETRIEVE) { let tray: Tray = api_state.tray;
let tray: Tray = props.wasm.get_tray("Player"); if (update.override) {
if(update.override) {
playerLetters = []; playerLetters = [];
} }
@ -160,13 +164,13 @@ export function Game(props: {
let startIndex = matchCoordinate(playerLetters, update.start); let startIndex = matchCoordinate(playerLetters, update.start);
let endIndex = matchCoordinate(playerLetters, update.end); let endIndex = matchCoordinate(playerLetters, update.end);
if(startIndex != null) { if (startIndex != null) {
let startLetter = playerLetters[startIndex]; let startLetter = playerLetters[startIndex];
startLetter.location = update.end.location; startLetter.location = update.end.location;
startLetter.index = update.end.index; startLetter.index = update.end.index;
} }
if(endIndex != null) { if (endIndex != null) {
let endLetter = playerLetters[endIndex]; let endLetter = playerLetters[endIndex];
endLetter.location = update.start.location; endLetter.location = update.start.location;
endLetter.index = update.start.index; endLetter.index = update.start.index;
@ -178,7 +182,7 @@ export function Game(props: {
} else if (update.action === TileDispatchActionType.SET_BLANK) { } else if (update.action === TileDispatchActionType.SET_BLANK) {
const blankLetter = playerLetters[update.blankIndex]; const blankLetter = playerLetters[update.blankIndex];
if(blankLetter.text !== update.newBlankValue) { if (blankLetter.text !== update.newBlankValue) {
blankLetter.text = update.newBlankValue; blankLetter.text = update.newBlankValue;
if (blankLetter.location == LocationType.GRID) { if (blankLetter.location == LocationType.GRID) {
setConfirmedScorePoints(-1); setConfirmedScorePoints(-1);
@ -190,7 +194,7 @@ export function Game(props: {
return mergeTrays(playerLetters, playerLetters); return mergeTrays(playerLetters, playerLetters);
} else if (update.action === TileDispatchActionType.MOVE_TO_ARROW) { } else if (update.action === TileDispatchActionType.MOVE_TO_ARROW) {
// let's verify that the arrow is defined, otherwise do nothing // let's verify that the arrow is defined, otherwise do nothing
if(gridArrow != null) { if (gridArrow != null) {
const end_position = { const end_position = {
location: LocationType.GRID, location: LocationType.GRID,
index: gridArrow.position, index: gridArrow.position,
@ -215,35 +219,6 @@ export function Game(props: {
} }
const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []); const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []);
const [turnCount, setTurnCount] = useState<number>(1);
const playerAndScores: PlayerAndScore[] = useMemo(() => {
return props.wasm.get_scores();
}, [turnCount, isGameOver]);
useEffect(() => {
const newLetterData = props.wasm.get_board_letters() as HighlightableLetterData[];
// we need to go through and set 'highlight' field to either true or false
// it will always be false if the player that just went was the AI
// TODO - build in support for multiple other players
for(let i=0; i<newLetterData.length; i++) {
const newLetter = newLetterData[i];
if(boardLetters[i] === undefined && newLetter !== undefined && playerTurnName == props.settings.playerName) {
newLetter.highlight = true;
} else if(newLetter !== undefined) {
newLetter.highlight = false;
}
}
setBoardLetters(newLetterData);
}, [turnCount]);
const playerTurnName = useMemo(() => {
return props.wasm.get_current_player();
}, [turnCount]);
const logDivRef = useRef(null); const logDivRef = useRef(null);
const [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(false); const [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(false);
@ -255,65 +230,52 @@ export function Game(props: {
} }
}, [logInfo]); }, [logInfo]);
const remainingTiles = useMemo(() => {
return props.wasm.get_remaining_tiles();
}, [turnCount, isGameOver]);
const remainingAITiles = useMemo(() => {
let result = props.wasm.get_player_tile_count(props.settings.aiName) as MyResult<number | String>;
if(result.response_type == "OK") {
return result.value as number;
} else {
console.error(result.value);
return -1;
}
}, [turnCount, isGameOver]);
function handlePlayerAction(action: TurnAction, playerName: string) { function handlePlayerAction(action: TurnAction, playerName: string) {
if (action.type == "PlayTiles"){ if (action.type == "PlayTiles") {
const result = action.result; const result = action.result;
result.words.sort((a, b) => b.score - a.score); result.words.sort((a, b) => b.score - a.score);
for(let word of result.words) { for (let word of result.words) {
logDispatch(<div>{playerName} received {word.score} points for playing '{word.word}.'</div>); logDispatch(<div>{playerName} received {word.score} points for playing '{word.word}.'</div>);
} }
logDispatch(<div>{playerName} received a total of <strong>{result.total} points</strong> for their turn.</div>); logDispatch(<div>{playerName} received a total of <strong>{result.total} points</strong> for their turn.
} else if(action.type == "ExchangeTiles") { </div>);
logDispatch(<div>{playerName} exchanged {action.tiles_exchanged} tile{action.tiles_exchanged > 1 ? 's' : ''} for their turn.</div>); } else if (action.type == "ExchangeTiles") {
} logDispatch(
else if(action.type == "Pass"){ <div>{playerName} exchanged {action.tiles_exchanged} tile{action.tiles_exchanged > 1 ? 's' : ''} for
their turn.</div>);
} else if (action.type == "Pass") {
logDispatch(<div>{playerName} passed.</div>); logDispatch(<div>{playerName} passed.</div>);
} else if (action.type == "AddToDictionary") {
logDispatch(<div>{playerName} added {action.word} to the dictionary.</div>)
} else {
console.error("Received unknown turn action: ", action);
} }
// Clear any on-screen arrows
gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR});
} }
function endGame(state: GameState) { function endGame(state: GameState) {
if(state.type != "InProgress") { if (state.type != "InProgress") {
setGameOver(true);
logDispatch(<h4>Scoring</h4>); logDispatch(<h4>Scoring</h4>);
const scores = props.wasm.get_scores() as PlayerAndScore[]; const scores = api_state.public_information.players;
let pointsBonus = 0; let pointsBonus = 0;
for(const playerAndScore of scores) { for (const playerAndScore of scores) {
const name = playerAndScore.name; const name = playerAndScore.name;
if(name == state.finisher) { if (name == state.finisher) {
// we'll do the finisher last // we'll do the finisher last
continue continue
} }
const letters = state.remaining_tiles.get(name); const letters = state.remaining_tiles[name];
if(letters.length == 0) { if (letters.length == 0) {
logDispatch(<div>{name} has no remaining tiles.</div>); logDispatch(<div>{name} has no remaining tiles.</div>);
} else { } else {
let pointsLost = 0; let pointsLost = 0;
let letterListStr = ''; let letterListStr = '';
for(let i=0; i<letters.length; i++) { for (let i = 0; i < letters.length; i++) {
const letter = letters[i]; const letter = letters[i];
const letterText = letter.is_blank ? 'a blank' : letter.text; const letterText = letter.is_blank ? 'a blank' : letter.text;
pointsLost += letter.points; pointsLost += letter.points;
@ -321,13 +283,13 @@ export function Game(props: {
letterListStr += letterText; letterListStr += letterText;
// we're doing a list of 3 or more so add commas // we're doing a list of 3 or more so add commas
if(letters.length > 2) { if (letters.length > 2) {
if(i == letters.length - 2) { if (i == letters.length - 2) {
letterListStr += ', and '; letterListStr += ', and ';
} else if (i < letters.length - 2) { } else if (i < letters.length - 2) {
letterListStr += ', '; letterListStr += ', ';
} }
} else if (i == 0 && letters.length == 2){ } else if (i == 0 && letters.length == 2) {
// list of 2 // list of 2
letterListStr += ' and '; letterListStr += ' and ';
} }
@ -338,7 +300,7 @@ export function Game(props: {
} }
} }
if(state.finisher != null) { if (state.finisher != null) {
logDispatch(<div>{state.finisher} receives {pointsBonus} bonus for completing first.</div>); logDispatch(<div>{state.finisher} receives {pointsBonus} bonus for completing first.</div>);
} }
@ -348,15 +310,14 @@ export function Game(props: {
.at(0); .at(0);
const playersAtHighest = scores.filter((score) => score.score == highestScore); const playersAtHighest = scores.filter((score) => score.score == highestScore);
let endGameMsg: string = ''; let endGameMsg: string;
if(playersAtHighest.length > 1 && state.finisher == null) { if (playersAtHighest.length > 1 && state.finisher == null) {
endGameMsg = "Tie game!"; endGameMsg = "Tie game!";
} else if (playersAtHighest.length > 1 && state.finisher != null) { } else if (playersAtHighest.length > 1 && state.finisher != null) {
// if there's a tie then the finisher gets the win // if there's a tie then the finisher gets the win
endGameMsg = `${playersAtHighest[0].name} won by finishing first!`; endGameMsg = `${playersAtHighest[0].name} won by finishing first!`;
} } else {
else {
endGameMsg = `${playersAtHighest[0].name} won!`; endGameMsg = `${playersAtHighest[0].name} won!`;
} }
logDispatch(<h4>Game over - {endGameMsg}</h4>); logDispatch(<h4>Game over - {endGameMsg}</h4>);
@ -367,34 +328,83 @@ export function Game(props: {
} }
function runAI() { function updateBoardLetters(newLetters: Array<Letter>) {
const result: MyResult<TurnAdvanceResult> = props.wasm.advance_turn(); const newLetterData = newLetters as HighlightableLetterData[];
if(result.response_type === "OK" && result.value.type == "AIMove") {
handlePlayerAction(result.value.action, props.settings.aiName); for (let i = 0; i < newLetterData.length; i++) {
if(result.game_state.type === "Ended") { const newLetter = newLetterData[i];
endGame(result.game_state); if (newLetter != null) {
newLetter.highlight = false;
}
} }
} else { // loop through the histories backwards until we reach our player
// this would be quite surprising for (let j = api_state.public_information.history.length - 1; j >= 0; j--) {
console.error({result}); const update = api_state.public_information.history[j];
if (update.player == props.settings.playerName) {
break
}
if (update.type.type === "PlayTiles") {
for (let i of update.type.locations) {
newLetterData[i].highlight = true;
}
}
} }
setTurnCount(turnCount + 1); setBoardLetters(newLetterData);
} }
useEffect(() => { useEffect(() => {
if (api_state) {
console.log("In state: ", api_state.public_information.current_turn_number);
console.log("In ref: ", currentTurnNumber.current);
console.debug(api_state);
if(currentTurnNumber.current < api_state.public_information.current_turn_number){
// We only clear everything if there's a chance the board changed
// We may have gotten a dictionary update event which doesn't count
gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR});
trayDispatch({action: TileDispatchActionType.RETRIEVE}); trayDispatch({action: TileDispatchActionType.RETRIEVE});
setConfirmedScorePoints(-1); setConfirmedScorePoints(-1);
if(!isGameOver){ updateBoardLetters(api_state.public_information.board);
logDispatch(<h4>Turn {turnCount}</h4>);
logDispatch(<div>{playerTurnName}'s turn</div>);
if(playerTurnName != props.settings.playerName && !isGameOver) {
runAI();
} }
}
}, [turnCount]);
for (let i = historyProcessedNumber.current; i < api_state.public_information.history.length; i++) {
const update = api_state.public_information.history[i];
if (update.turn_number > currentTurnNumber.current) {
currentTurnNumber.current = update.turn_number;
logDispatch(<h4>Turn {update.turn_number + 1}</h4>);
const playerAtTurn = api_state.public_information.players[(update.turn_number) % api_state.public_information.players.length].name;
logDispatch(<div>{playerAtTurn}'s turn</div>);
}
handlePlayerAction(update.type, update.player);
}
historyProcessedNumber.current = api_state.public_information.history.length;
if (!isGameOver) {
if(api_state.public_information.current_turn_number > currentTurnNumber.current){
logDispatch(<h4>Turn {api_state.public_information.current_turn_number + 1}</h4>);
logDispatch(<div>{api_state.public_information.current_player}'s turn</div>);
currentTurnNumber.current = api_state.public_information.current_turn_number;
}
} else {
endGame(api_state.public_information.game_state);
}
if(api_state.public_information.current_player != props.settings.playerName) {
waitForUpdate();
}
}
}, [api_state]);
if(api_state == null){
return <div>Still loading</div>;
}
const playerAndScores = api_state.public_information.players;
const remainingTiles = api_state.public_information.remaining_tiles;
const isPlayersTurn = api_state.public_information.current_player == props.settings.playerName;
return <> return <>
<TileExchangeModal <TileExchangeModal
@ -405,7 +415,7 @@ export function Game(props: {
/> />
<div className="board-log"> <div className="board-log">
<Grid <Grid
cellTypes={cellTypes} cellTypes={api_state.public_information.cell_types}
playerLetters={playerLetters} playerLetters={playerLetters}
boardLetters={boardLetters} boardLetters={boardLetters}
tileDispatch={trayDispatch} tileDispatch={trayDispatch}
@ -431,32 +441,29 @@ export function Game(props: {
<div> <div>
{remainingTiles} letters remaining {remainingTiles} letters remaining
</div> </div>
<div>
{props.settings.aiName} has {remainingAITiles} tiles
</div>
<button <button
disabled={remainingTiles == 0 || isGameOver} disabled={remainingTiles == 0 || isGameOver || !isPlayersTurn}
onClick={() => { onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
setIsTileExchangeOpen(true); setIsTileExchangeOpen(true);
}}>Open Tile Exchange</button> }}>Open Tile Exchange
</button>
</div> </div>
<TileTray letters={playerLetters} trayLength={props.settings.trayLength} trayDispatch={trayDispatch}/> <TileTray letters={playerLetters} trayLength={props.settings.trayLength} trayDispatch={trayDispatch}/>
<div className="player-controls"> <div className="player-controls">
<button <button
className="check" className="check"
disabled={isGameOver} disabled={isGameOver || !isPlayersTurn}
onClick={() => { onClick={async () => {
const playedTiles = playerLetters.map((i) => { const playedTiles = playerLetters.map((i) => {
if (i === undefined) { if (i == null) {
return null; return null;
} }
if (i.location === LocationType.GRID) { if (i.location === LocationType.GRID) {
let result: PlayedTile = { let result: PlayedTile = {
index: i.index, index: i.index,
character: undefined character: null
}; };
if (i.is_blank) { if (i.is_blank) {
result.character = i.text; result.character = i.text;
@ -468,38 +475,47 @@ export function Game(props: {
return null; return null;
}); });
const result: MyResult<{ type: "PlayTiles"; result: ScoreResult } | string> = props.wasm.receive_play(playedTiles, confirmedScorePoints > -1); const committing = confirmedScorePoints > -1;
console.log({result}); const result = props.api.play(playedTiles, committing);
if(result.response_type === "ERR") { result
const message = result.value as string; .then(
if (message.endsWith("is not a valid word")) { (api_state) => {
// extract out word console.log("Testing45")
const word = message.split(" ")[0]; console.log({api_state});
logDispatch(<AddWordButton word={word} addWordFn={addWordFn} />);
} else { const play_tiles: TurnAction = api_state.update.type;
logDispatch(<div><em>{message}</em></div>); if (play_tiles.type == "PlayTiles") {
setConfirmedScorePoints(play_tiles.result.total);
if (committing) {
setAPIState(api_state);
} }
} else { } else {
console.error("Inaccessible branch!!!")
const score_result = (result.value as { type: "PlayTiles"; result: ScoreResult }).result;
const total_points = score_result.total;
if (confirmedScorePoints > -1) {
handlePlayerAction({type: "PlayTiles", result: score_result}, props.settings.playerName);
setTurnCount(turnCount + 1);
} }
if (result.game_state.type === "Ended") {
endGame(result.game_state);
} }
)
.catch((error) => {
console.error({error});
setConfirmedScorePoints(total_points); if (error.endsWith(" is not a valid word")) {
const word = error.split(' ')[0];
// For whatever reason I can't pass props.api.add_to_dictionary directly
logDispatch(<AddWordButton word={word}
addWordFn={(word) => {
props.api.add_to_dictionary(word)
.then((api_state) => {
setAPIState(api_state);
})
}}/>);
} else {
logDispatch(<div>{error}</div>);
} }
});
}}>{confirmedScorePoints > -1 ? `Score ${confirmedScorePoints} points` : "Check"}</button> }}>{confirmedScorePoints > -1 ? `Score ${confirmedScorePoints} points` : "Check"}</button>
<button <button
@ -507,31 +523,35 @@ export function Game(props: {
disabled={isGameOver} disabled={isGameOver}
onClick={() => { onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); trayDispatch({action: TileDispatchActionType.RETURN});
}}>Return Tiles</button> }}>Return Tiles
</button>
<button <button
className="pass" className="pass"
disabled={isGameOver} disabled={isGameOver || !isPlayersTurn}
onClick={() => { onClick={() => {
if (window.confirm("Are you sure you want to pass?")) { if (window.confirm("Are you sure you want to pass?")) {
const result = props.wasm.skip_turn() as MyResult<string>; const result = props.api.pass();
handlePlayerAction({type: "Pass"}, props.settings.playerName);
setTurnCount(turnCount + 1);
if (result.game_state.type === "Ended") { result
endGame(result.game_state); .then(
(api_state) => {
setAPIState(api_state);
})
.catch((error) => {
console.error({error});
logDispatch(<div>{error}</div>);
});
} }
} }}>Pass
}}>Pass</button> </button>
</div> </div>
</div> </div>
</>; </>;
} }
function AddWordButton(props: {word: string, addWordFn: (x: string) => void}) { function AddWordButton(props: { word: string, addWordFn: (x: string) => void }) {
const [isClicked, setIsClicked] = useState<boolean>(false); const [isClicked, setIsClicked] = useState<boolean>(false);
if (!isClicked) { if (!isClicked) {
@ -549,7 +569,7 @@ function AddWordButton(props: {word: string, addWordFn: (x: string) => void}) {
</div>; </div>;
} else { } else {
return <div> return <div>
<em>{props.word} was added to dictionary.</em> <em>Adding {props.word} to dictionary.</em>
</div>; </div>;
} }

View file

@ -1,8 +1,10 @@
import * as React from "react"; import * as React from "react";
import {useState} from "react"; import {useState} from "react";
import {Difficulty, GameWasm} from '../../pkg/word_grid';
import {Settings} from "./utils"; import {Settings} from "./utils";
import {Game} from "./Game"; import {Game} from "./Game";
import {API, Difficulty} from "./api";
import {WasmAPI} from "./wasm_api";
import {AISelection} from "./UI";
export function Menu(props: {settings: Settings, dictionary_text: string}) { export function Menu(props: {settings: Settings, dictionary_text: string}) {
@ -22,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();
@ -70,8 +38,8 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
proportion: processedProportionDictionary, proportion: processedProportionDictionary,
randomness: processedAIRandomness, randomness: processedAIRandomness,
}; };
const game_wasm = new GameWasm(BigInt(seed), props.dictionary_text, difficulty); const game_wasm: API = new WasmAPI(BigInt(seed), props.dictionary_text, difficulty);
const game = <Game settings={props.settings} wasm={game_wasm} key={seed} end_game_fn={() => setGame(null)}/> const game = <Game settings={props.settings} api={game_wasm} key={seed} end_game_fn={() => setGame(null)}/>
setGame(game); setGame(game);
}}>New Game</button> }}>New Game</button>
</div> </div>

View file

@ -1,8 +1,8 @@
import {addNTimes, PlayableLetterData} from "./utils"; import {addNTimes, PlayableLetterData} from "./utils";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {Modal} from "./Modal"; import {Modal} from "./Modal";
import {Letter as LetterData} from "../../pkg/word_grid";
import * as React from "react"; import * as React from "react";
import {Letter} from "./api";
export function TileExchangeModal(props: { export function TileExchangeModal(props: {
playerLetters: PlayableLetterData[], playerLetters: PlayableLetterData[],
@ -105,7 +105,7 @@ function TilesExchangedTray(props: {
} }
function DummyExchangeTile(props: { function DummyExchangeTile(props: {
letter: LetterData, letter: Letter,
isSelected: boolean, isSelected: boolean,
onClick: () => void, onClick: () => void,
}){ }){

View file

@ -1,8 +1,6 @@
import * as React from "react"; import * as React from "react";
import {ChangeEvent, JSX} from "react"; import {ChangeEvent, JSX} from "react";
import {PlayerAndScore} from "../../pkg/word_grid";
import { import {
CellType,
cellTypeToDetails, cellTypeToDetails,
CoordinateData, CoordinateData,
GridArrowData, GridArrowData,
@ -14,15 +12,16 @@ import {
TileDispatch, TileDispatch,
TileDispatchActionType, TileDispatchActionType,
} from "./utils"; } from "./utils";
import {APIPlayer, CellType} from "./api";
export function TileSlot(props: { export function TileSlot(props: {
tile?: React.JSX.Element | undefined, tile?: React.JSX.Element | null,
location: CoordinateData, location: CoordinateData,
tileDispatch: TileDispatch, tileDispatch: TileDispatch,
arrowDispatch?: GridArrowDispatch, arrowDispatch?: GridArrowDispatch,
}): React.JSX.Element { }): React.JSX.Element {
let isDraggable = props.tile !== undefined; let isDraggable = props.tile != null;
function onDragStart(e: React.DragEvent<HTMLDivElement>) { function onDragStart(e: React.DragEvent<HTMLDivElement>) {
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
@ -49,7 +48,7 @@ export function TileSlot(props: {
} else if(props.location.location == LocationType.TRAY && props.tile != null && props.tile.props.data.text != ' ' && props.tile.props.data.text != '') { } else if(props.location.location == LocationType.TRAY && props.tile != null && props.tile.props.data.text != ' ' && props.tile.props.data.text != '') {
onClick = () => { onClick = () => {
props.tileDispatch({ props.tileDispatch({
action: TileDispatchActionType.MOVE_TO_ARROW, end: undefined, start: props.location, action: TileDispatchActionType.MOVE_TO_ARROW, end: null, start: props.location,
}); });
} }
} }
@ -181,7 +180,7 @@ export function Grid(props: {
const {className, text} = cellTypeToDetails(ct); const {className, text} = cellTypeToDetails(ct);
let tileElement: JSX.Element; let tileElement: JSX.Element;
if (props.boardLetters[i] !== undefined) { if (props.boardLetters[i] != null) {
tileElement = <Letter data={props.boardLetters[i]} />; tileElement = <Letter data={props.boardLetters[i]} />;
} else { } else {
tileElement = <> tileElement = <>
@ -234,11 +233,12 @@ export function Grid(props: {
</div> </div>
} }
export function Scores(props: {playerScores: Array<PlayerAndScore>}){ export function Scores(props: {playerScores: Array<APIPlayer>}){
let elements = props.playerScores.map((ps) => { let elements = props.playerScores.map((ps) => {
return <div key={ps.name}> return <div key={ps.name}>
<h3>{ps.name}</h3> <h3>{ps.name}</h3>
<span>{ps.score}</span> <div>{ps.score}</div>
<div>({ps.tray_tiles} tiles remaining)</div>
</div>; </div>;
}); });
@ -246,3 +246,61 @@ export function Scores(props: {playerScores: Array<PlayerAndScore>}){
{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="ai-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>
</>
}

91
ui/src/api.ts Normal file
View file

@ -0,0 +1,91 @@
export interface Tray {
letters: (Letter | null)[];
}
export interface ScoreResult {
words: WordResult[];
total: number;
}
export interface WordResult {
word: string;
score: number;
}
export interface PlayedTile {
index: number;
character?: string;
}
export interface Difficulty {
proportion: number;
randomness: number;
}
export interface Letter {
text: string;
points: number;
ephemeral: boolean;
is_blank: boolean;
}
export type TurnAction = { type: "Pass" } | { type: "ExchangeTiles"; tiles_exchanged: number } | { type: "PlayTiles"; result: ScoreResult; locations: number[] } | {type: "AddToDictionary"; word: string;};
export enum CellType {
Normal = "Normal",
DoubleWord = "DoubleWord",
DoubleLetter = "DoubleLetter",
TripleLetter = "TripleLetter",
TripleWord = "TripleWord",
Start = "Start",
}
export type GameState = { type: "InProgress" } | { type: "Ended"; finisher?: string; remaining_tiles: {[id: string]: Letter[]} };
export interface APIPlayer {
name: string;
tray_tiles: number;
score: number
}
export interface PublicInformation {
game_state: GameState;
board: Array<Letter>;
cell_types: Array<CellType>;
current_player: string;
players: Array<APIPlayer>;
remaining_tiles: number;
history: Array<Update>;
current_turn_number: number;
}
export interface Update {
type: TurnAction,
player: string;
turn_number: number;
}
export interface APIState {
public_information: PublicInformation;
tray: Tray;
update?: Update;
}
export interface Result<A, B> {
"Ok"?: A;
"Err"?: B;
}
export function is_ok<A, B>(result: Result<A, B>): boolean {
return result["Ok"] != null;
}
export interface API {
exchange: (selection: Array<boolean>) => Promise<APIState>;
pass: () => Promise<APIState>;
play: (tiles: Array<PlayedTile>, commit: boolean) => Promise<APIState>;
add_to_dictionary: (word: string) => Promise<APIState>;
load: (wait: boolean) => Promise<APIState>;
}

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

12
ui/src/multiplayer.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.less" />
<title>Word Grid</title>
</head>
<body>
<script src="multiplayer.tsx" type="module"></script>
<div id="root"></div>
</body>
</html>

167
ui/src/multiplayer.tsx Normal file
View file

@ -0,0 +1,167 @@
import * as React from "react";
import {useState} from "react";
import {createRoot} from "react-dom/client";
import {AISelection} from "./UI";
import {ClientToServerMessage, WSAPI, PartyInfo, ServerToClientMessage} from "./ws_api";
import {Game} from "./Game";
const LOGBASE = 10000;
function unprocessAIRandomness(processedAIRandomness: number): string {
const x = 100 * (LOGBASE ** processedAIRandomness - 1) / (LOGBASE - 1)
return x.toFixed(0)
}
function unprocessedAIProportion(processedProportion: number): string {
return (100 * (1 - processedProportion)).toFixed(0);
}
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);
const [game, setGame] = useState<React.JSX.Element>(null);
const validSettings = roomName.length > 0 && !roomName.includes("/") && playerName.length > 0 && !playerName.includes("?") && !playerName.includes("&");
// Can change log scale to control shape of curve using following equation:
// aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1
const processedAIRandomness = Math.log(1 + (LOGBASE - 1) * aiRandomness / 100) / Math.log(LOGBASE);
const processedProportionDictionary = 1.0 - proportionDictionary / 100;
if (game) {
return game;
} else if (socket != null && partyInfo == null) {
return <div><p>Connecting to {roomName}</p></div>
} else if (partyInfo != null) {
const players = partyInfo.players.map((x) => {
return <li key={x.name}>{x.name}</li>;
});
const ais = partyInfo.ais.map((x, i) => {
return <li key={i} className="side-grid">
<span>Proportion: {unprocessedAIProportion(x.proportion)} / Randomness: {unprocessAIRandomness(x.randomness)}</span>
<button onClick={() => {
const event = {
type: "RemoveAI",
index: i
};
socket.send(JSON.stringify(event));
}
}>Delete
</button>
</li>
});
return <dialog open className="multiplayer-lobby">
<p>Connected to {roomName}</p>
Players: <ol>
{players}
</ol>
AIs: <ol>
{ais}
</ol>
<details className="multiplayer-ai-details">
<summary>Add AI</summary>
<AISelection aiRandomness={aiRandomness} setAIRandomness={setAIRandomness}
proportionDictionary={proportionDictionary}
setProportionDictionary={setProportionDictionary}/>
<button onClick={() => {
const event = {
type: "AddAI",
difficulty: {
proportion: processedProportionDictionary,
randomness: processedAIRandomness,
}
};
socket.send(JSON.stringify(event));
}}>
Add AI
</button>
</details>
<div className="side-grid">
<button onClick={() => {
socket.close();
setSocket(null);
setPartyInfo(null);
}}>Disconnect
</button>
<button onClick={() => {
const event: ClientToServerMessage = {
type: "StartGame"
};
socket.send(JSON.stringify(event));
}}>Start Game
</button>
</div>
</dialog>
} else {
return <dialog open>
<div className="multiplayer-inputs-grid">
<label>
Room Name:
<input type="text" value={roomName} onChange={(e) => {
setRoomName(e.target.value)
}}></input>
</label>
<label>
Player Name:
<input type="text" value={playerName} onChange={(e) => {
setPlayerName(e.target.value)
}}></input>
</label>
<button
disabled={!validSettings}
onClick={() => {
let socket = new WebSocket(`/room/${roomName}?player_name=${playerName}`)
socket.addEventListener("message", (event) => {
const input: ServerToClientMessage = JSON.parse(event.data);
if (input.type == "RoomChange") {
setPartyInfo(input.info);
} else if (input.type == "GameEvent" && game == null) {
// start game
setGame(<Game api={new WSAPI(socket)} settings={{
playerName: playerName,
trayLength: 7,
}} end_game_fn={function (): void {
socket.close();
setSocket(null);
setPartyInfo(null);
setGame(null);
}}/>);
}
console.log("Message from server ", event.data);
});
socket.addEventListener("close", (event) => {
console.log({event});
setSocket(null);
setPartyInfo(null);
setGame(null);
if (event.reason != null && event.reason.length > 0) {
alert(`Disconnected with reason "${event.reason} & code ${event.code}"`);
}
});
setSocket(socket);
}}>Connect
</button>
</div>
</dialog>;
}
}
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

@ -1,4 +1,4 @@
import init from '../../pkg/word_grid.js'; import init from 'word_grid';
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import * as React from "react"; import * as React from "react";
import {Menu} from "./Menu"; import {Menu} from "./Menu";
@ -50,8 +50,6 @@ async function run() {
root.render(<Menu dictionary_text={dictionary_text} settings={{ root.render(<Menu dictionary_text={dictionary_text} settings={{
trayLength: 7, trayLength: 7,
playerName: 'Player', playerName: 'Player',
aiName: 'AI',
}}/>); }}/>);
} }

View file

@ -204,9 +204,7 @@
.scoring { .scoring {
text-align: center; text-align: center;
display: grid; display: flex;
grid-template-columns: 1fr 1fr;
grid-template-rows: none;
span { span {
font-size: 20px; font-size: 20px;
@ -215,25 +213,49 @@
div { div {
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
flex: 1;
} }
} }
} }
.multiplayer-inputs-grid {
display: grid;
//grid-template-columns: 3fr 2fr;
//grid-column-gap: 1em;
grid-row-gap: 0.5em;
dialog { label {
border-radius: 10px; display: grid;
z-index: 1; grid-template-columns: 3fr 2fr;
}
}
.new-game { .ai-grid{
width: 50em;
.grid {
display: grid; display: grid;
grid-template-columns: 3fr 2fr; grid-template-columns: 3fr 2fr;
grid-column-gap: 1em; grid-column-gap: 1em;
grid-row-gap: 0.5em; grid-row-gap: 0.5em;
}
.side-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 1em;
}
.multiplayer-ai-details {
button {
margin: 1em;
} }
}
dialog {
border-radius: 10px;
z-index: 1;
width: 50em;
.new-game {
width: 50em;
.selection-buttons { .selection-buttons {
display: grid; display: grid;

View file

@ -1,20 +1,10 @@
import {Letter as LetterData, Letter} from "../../pkg/word_grid";
import * as React from "react"; import * as React from "react";
import {CellType, Letter as LetterData} from "./api";
export enum CellType {
Normal = "Normal",
DoubleWord = "DoubleWord",
DoubleLetter = "DoubleLetter",
TripleLetter = "TripleLetter",
TripleWord = "TripleWord",
Start = "Start",
}
export interface Settings { export interface Settings {
trayLength: number; trayLength: number;
playerName: string; playerName: string;
aiName: string;
} }
export enum LocationType { export enum LocationType {
@ -68,7 +58,7 @@ export function matchCoordinate(playerLetters: PlayableLetterData[], coords: Coo
for (let i=0; i<playerLetters.length; i++){ for (let i=0; i<playerLetters.length; i++){
let letter = playerLetters[i]; let letter = playerLetters[i];
if (letter !== undefined && letter.location === coords.location && letter.index === coords.index) { if (letter != null && letter.location === coords.location && letter.index === coords.index) {
return i; return i;
} }
} }
@ -118,7 +108,7 @@ export function addNTimes<T>(array: T[], toAdd: T, times: number) {
} }
} }
export function mergeTrays(existing: PlayableLetterData[], newer: (Letter | undefined)[]): PlayableLetterData[] { export function mergeTrays(existing: PlayableLetterData[], newer: (LetterData | null)[]): PlayableLetterData[] {
let trayLength = Math.max(existing.length, newer.length); let trayLength = Math.max(existing.length, newer.length);
@ -129,7 +119,7 @@ export function mergeTrays(existing: PlayableLetterData[], newer: (Letter | unde
} }
existing.filter((x) => { existing.filter((x) => {
return x !== undefined && x !== null; return x != null;
}).forEach((x) => { }).forEach((x) => {
if (x.location === LocationType.TRAY) { if (x.location === LocationType.TRAY) {
freeSpots[x.index] = null; freeSpots[x.index] = null;
@ -148,9 +138,9 @@ export function mergeTrays(existing: PlayableLetterData[], newer: (Letter | unde
} }
return newer.map((ld, i) => { return newer.map((ld, i) => {
if (ld !== undefined) { if (ld != null) {
if (existing[i] !== undefined && existing[i] !== null && existing[i].location === LocationType.TRAY) { if (existing[i] != null && existing[i].location === LocationType.TRAY) {
ld["index"] = existing[i].index; ld["index"] = existing[i].index;
} else { } else {
ld["index"] = firstNotNull(); ld["index"] = firstNotNull();

71
ui/src/wasm_api.tsx Normal file
View file

@ -0,0 +1,71 @@
import {API, APIState, Difficulty, PlayedTile, Result, is_ok} from "./api";
import {WasmAPI as RawAPI} from 'word_grid';
export class WasmAPI implements API{
private wasm: RawAPI;
constructor(seed: bigint, dictionary_text: string, difficulty: Difficulty) {
this.wasm = new RawAPI(seed, dictionary_text, difficulty);
}
add_to_dictionary(word: string): Promise<APIState> {
return new Promise((resolve, reject) => {
let api_state: Result<APIState, any> = this.wasm.add_to_dictionary(word);
if(is_ok(api_state)) {
resolve(api_state.Ok);
} else {
reject(api_state.Err);
}
});
}
exchange(selection: Array<boolean>): Promise<APIState> {
return new Promise((resolve, reject) => {
let api_state: Result<APIState, any> = this.wasm.exchange(selection);
if(is_ok(api_state)) {
resolve(api_state.Ok);
} else {
reject(api_state.Err);
}
});
}
load(_wait: boolean): Promise<APIState> {
return new Promise((resolve, reject) => {
let api_state: Result<APIState, any> = this.wasm.load();
if(is_ok(api_state)) {
resolve(api_state.Ok);
} else {
reject(api_state.Err);
}
});
}
pass(): Promise<APIState> {
return new Promise((resolve, reject) => {
let api_state: Result<APIState, any> = this.wasm.pass();
if(is_ok(api_state)) {
resolve(api_state.Ok);
} else {
reject(api_state.Err);
}
});
}
play(tiles: Array<PlayedTile>, commit: boolean): Promise<APIState> {
return new Promise((resolve, reject) => {
let api_state: Result<APIState, any> = this.wasm.play(tiles, commit);
if(is_ok(api_state)) {
resolve(api_state.Ok);
} else {
reject(api_state.Err);
}
});
}
}

211
ui/src/ws_api.tsx Normal file
View file

@ -0,0 +1,211 @@
import {API, APIState, Difficulty, PlayedTile} from "./api";
export interface Player {
name: string
}
export interface AI {
proportion: number
randomness: number
}
export interface PartyInfo {
ais: AI[]
players: Player[]
}
export type RoomEvent = {
type: "PlayerJoined" | "PlayerLeft"
player: Player
} | {
type: "AIJoined",
difficulty: Difficulty
} | {
type: "AILeft"
index: number
}
export type GameEvent = {
type: "TurnAction"
state: APIState
committed: boolean
}
export type ServerToClientMessage = {
type: "RoomChange"
event: RoomEvent
info: PartyInfo
} | {
type: "GameEvent"
event: GameEvent
} | {
type: "GameError"
error: any
} | {
type: "Invalid"
reason: string
}
type GameMove = {
type: "Pass"
} | {
type: "Exchange"
tiles: Array<boolean>
} | {
type: "Play"
played_tiles: Array<PlayedTile>
commit_move: boolean
} | {
type: "AddToDictionary"
word: string
}
export type ClientToServerMessage = {
type: "Load" | "StartGame"
} | {
type: "GameMove"
move: GameMove
} | {
type: "AddAI"
difficulty: Difficulty
} | {
type: "RemoveAI"
index: number
}
interface PromiseInput {
resolve: (value: GameEvent) => void
reject: (error: any) => void
type: string // recorded for debug purposes
}
export class WSAPI implements API{
private socket: WebSocket;
private currentPromiseInput: PromiseInput | null;
constructor(socket: WebSocket) {
this.socket = socket;
this.currentPromiseInput = null;
this.socket.addEventListener("message", (event) => {
let data: ServerToClientMessage = JSON.parse(event.data);
console.log("Message from server ", event.data);
if(data.type == "GameEvent") {
if(this.currentPromiseInput != null) {
this.currentPromiseInput.resolve(data.event);
this.currentPromiseInput = null;
} else {
console.error("Received game data but no promise is queued");
console.error({data});
}
} else if(data.type == "GameError") {
if(this.currentPromiseInput != null) {
this.currentPromiseInput.reject(data.error);
this.currentPromiseInput = null;
} else {
console.error("Received error game data but no promise is queued");
console.error({data});
}
}
});
}
private register_promise(resolve: (value: GameEvent) => void, reject: (value: any) => void, type: string) {
const newPromise = {
resolve: resolve,
reject: reject,
type: type,
};
if(this.currentPromiseInput != null) {
console.error("We are setting a new promise before the current one has resolved")
console.error("Current promise: ", this.currentPromiseInput);
console.error("New promise: ", newPromise);
// Some of the rejects take statements from the server and log them; maybe don't send this to reject
//this.currentPromiseInput.reject("New promise was registered");
}
this.currentPromiseInput = newPromise;
}
add_to_dictionary(word: string): Promise<APIState> {
return new Promise((resolve, reject) => {
this.register_promise(resolve, reject, "AddToDictionary");
let event: ClientToServerMessage = {
type: "GameMove",
move: {
type: "AddToDictionary",
word
}
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
return game_event.state;
});
}
exchange(selection: Array<boolean>): Promise<APIState> {
return new Promise((resolve, reject) => {
this.register_promise(resolve, reject, "Exchange");
let event: ClientToServerMessage = {
type: "GameMove",
move: {
type: "Exchange",
tiles: selection
}
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
return game_event.state;
});
}
load(wait: boolean): Promise<APIState> {
return new Promise((resolve, reject) => {
this.register_promise(resolve, reject, `Load with wait=${wait}`);
if(!wait) {
let event: ClientToServerMessage = {
type: "Load"
}
this.socket.send(JSON.stringify(event));
}
}).then((game_event: GameEvent) => {
return game_event.state;
});
}
pass(): Promise<APIState> {
return new Promise((resolve, reject) => {
this.register_promise(resolve, reject, "Pass");
let event: ClientToServerMessage = {
type: "GameMove",
move: {
type: "Pass",
}
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
return game_event.state;
});
}
play(tiles: Array<PlayedTile>, commit: boolean): Promise<APIState> {
return new Promise((resolve, reject) => {
this.register_promise(resolve, reject, "Play");
let event: ClientToServerMessage = {
type: "GameMove",
move: {
type: "Play",
played_tiles: tiles,
commit_move: commit,
}
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
return game_event.state;
});
}
}

289
wasm/Cargo.lock generated Normal file
View file

@ -0,0 +1,289 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "csv"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
dependencies = [
"memchr",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "memchr"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "proc-macro2"
version = "1.0.84"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec96c6a92621310b51366f1e28d05ef11489516e93be030060e5fc12024a49d6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "serde"
version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_derive"
version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "syn"
version = "2.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm"
version = "0.1.0"
dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"wasm-bindgen",
"word_grid",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "word_grid"
version = "0.1.0"
dependencies = [
"csv",
"getrandom",
"rand",
"serde",
"serde_json",
]

14
wasm/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
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 }

60
wasm/src/lib.rs Normal file
View file

@ -0,0 +1,60 @@
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsValue;
use word_grid::api::APIGame;
use word_grid::game::{Game, PlayedTile};
use word_grid::player_interaction::ai::Difficulty;
const PLAYER_NAME: &str = "Player";
#[wasm_bindgen]
pub struct WasmAPI(APIGame);
#[wasm_bindgen]
impl WasmAPI {
#[wasm_bindgen(constructor)]
pub fn new(seed: u64, dictionary_text: &str, ai_difficulty: JsValue) -> Self {
let difficulty: Difficulty = serde_wasm_bindgen::from_value(ai_difficulty).unwrap();
let game = Game::new(
seed,
dictionary_text,
vec![PLAYER_NAME.to_string()],
vec![difficulty],
);
WasmAPI(APIGame::new(game))
}
pub fn exchange(&mut self, selection: JsValue) -> JsValue {
let selection: Vec<bool> = serde_wasm_bindgen::from_value(selection).unwrap();
let result = self.0.exchange(PLAYER_NAME, selection);
serde_wasm_bindgen::to_value(&result).unwrap()
}
pub fn pass(&mut self) -> JsValue {
let result = self.0.pass(PLAYER_NAME);
serde_wasm_bindgen::to_value(&result).unwrap()
}
pub fn load(&mut self) -> JsValue {
let result = self.0.load(PLAYER_NAME);
serde_wasm_bindgen::to_value(&result).unwrap()
}
pub fn play(&mut self, tray_tile_locations: JsValue, commit_move: bool) -> JsValue {
let tray_tile_locations: Vec<Option<PlayedTile>> =
serde_wasm_bindgen::from_value(tray_tile_locations).unwrap();
let result = self.0.play(PLAYER_NAME, tray_tile_locations, commit_move);
serde_wasm_bindgen::to_value(&result).unwrap()
}
pub fn add_to_dictionary(&mut self, word: &str) -> JsValue {
let result = self.0.add_to_dictionary(PLAYER_NAME, word);
serde_wasm_bindgen::to_value(&result).unwrap()
}
}

267
wordgrid/Cargo.lock generated Normal file
View file

@ -0,0 +1,267 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "csv"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
dependencies = [
"memchr",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "memchr"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "proc-macro2"
version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "serde"
version = "1.0.202"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.202"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "syn"
version = "2.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "word_grid"
version = "0.1.0"
dependencies = [
"csv",
"getrandom",
"rand",
"serde",
"serde_json",
]

15
wordgrid/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "word_grid"
version = "0.1.0"
edition = "2021"
authors = ["Joel Therrien"]
repository = "https://git.joeltherrien.ca/joel/WordGrid"
license = "AGPL-3"
description = "A (WIP) package for playing 'WordGrid'."
[dependencies]
csv = "1.3.0"
getrandom = {version = "0.2", features = ["js"]}
serde_json = { workspace = true }
serde = { workspace = true }
rand = { workspace = true }

241
wordgrid/src/api.rs Normal file
View file

@ -0,0 +1,241 @@
use crate::board::{CellType, Letter};
use crate::game::{
Error, Game, GameState, PlayedTile, Player, PlayerState, TurnAction, TurnAdvanceResult,
};
use crate::player_interaction::Tray;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiPlayer {
name: String,
tray_tiles: usize,
score: u32,
}
impl ApiPlayer {
fn from(player_state: &PlayerState) -> ApiPlayer {
ApiPlayer {
name: player_state.player.get_name().to_string(),
tray_tiles: player_state.tray.count(),
score: player_state.score,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Update {
r#type: TurnAction,
player: String,
turn_number: usize,
}
pub type ApiBoard = Vec<Option<Letter>>;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PublicInformation {
game_state: GameState,
board: ApiBoard,
cell_types: Vec<CellType>,
current_player: String,
players: Vec<ApiPlayer>,
remaining_tiles: usize,
history: Vec<Update>,
current_turn_number: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiState {
public_information: PublicInformation,
tray: Tray,
pub update: Option<Update>,
}
pub struct APIGame(pub Game, Vec<Update>);
impl APIGame {
pub fn new(game: Game) -> APIGame {
APIGame(game, vec![])
}
fn is_players_turn(&self, player: &str) -> bool {
self.0.current_player_name().eq(player)
}
fn player_exists(&self, player: &str) -> bool {
self.0.player_states.get_player_state(player).is_some()
}
fn player_exists_and_turn(&self, player: &str) -> Result<(), Error> {
if !self.player_exists(&player) {
Err(Error::InvalidPlayer(player.to_string()))
} else if !self.is_players_turn(&player) {
Err(Error::WrongTurn(player.to_string()))
} else {
Ok(())
}
}
fn build_result(
&self,
tray: Tray,
game_state: Option<GameState>,
update: Option<Update>,
) -> ApiState {
ApiState {
public_information: self.build_public_information(game_state, &self.1),
tray,
update,
}
}
fn build_public_information(
&self,
game_state: Option<GameState>,
history: &Vec<Update>,
) -> PublicInformation {
let game_state = game_state.unwrap_or_else(|| self.0.get_state());
let cell_types = self
.0
.get_board()
.cells
.iter()
.map(|cell| -> CellType { cell.cell_type })
.collect::<Vec<CellType>>();
let board: Vec<Option<Letter>> = self
.0
.get_board()
.cells
.iter()
.map(|cell| -> Option<Letter> { cell.value.clone() })
.collect();
let players = self
.0
.player_states
.0
.iter()
.map(|p| ApiPlayer::from(p))
.collect::<Vec<ApiPlayer>>();
PublicInformation {
game_state,
board,
cell_types,
current_player: self.0.current_player_name(),
players,
remaining_tiles: self.0.get_remaining_tiles(),
history: history.clone(),
current_turn_number: self.0.get_number_turns(),
}
}
pub fn exchange(&mut self, player: &str, tray_tiles: Vec<bool>) -> Result<ApiState, Error> {
self.player_exists_and_turn(player)?;
let turn_number = self.0.get_number_turns();
let (tray, turn_action, game_state) = self.0.exchange_tiles(tray_tiles)?;
let update = Update {
r#type: turn_action,
player: player.to_string(),
turn_number,
};
self.1.push(update.clone());
Ok(self.build_result(tray, Some(game_state), Some(update)))
}
pub fn pass(&mut self, player: &str) -> Result<ApiState, Error> {
self.player_exists_and_turn(player)?;
let turn_number = self.0.get_number_turns();
let game_state = self.0.pass()?;
let tray = self.0.player_states.get_tray(player).unwrap().clone();
let update = Update {
r#type: TurnAction::Pass,
player: player.to_string(),
turn_number,
};
self.1.push(update.clone());
Ok(self.build_result(tray, Some(game_state), Some(update)))
}
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 turn_number = self.0.get_number_turns();
let (result, _) = self.0.advance_turn()?;
if let TurnAdvanceResult::AIMove { name, action } = result {
self.1.push(Update {
r#type: action,
player: name,
turn_number,
});
} else {
unreachable!("We already checked that the current player is AI");
}
}
let tray = self.0.player_states.get_tray(player).unwrap().clone();
Ok(self.build_result(tray, None, None))
}
}
pub fn play(
&mut self,
player: &str,
tray_tile_locations: Vec<Option<PlayedTile>>,
commit_move: bool,
) -> Result<ApiState, Error> {
self.player_exists_and_turn(&player)?;
let turn_number = self.0.get_number_turns();
let (turn_action, game_state) = self.0.receive_play(tray_tile_locations, commit_move)?;
let tray = self.0.player_states.get_tray(&player).unwrap().clone();
let update = Update {
r#type: turn_action,
player: player.to_string(),
turn_number,
};
if commit_move {
self.1.push(update.clone())
}
Ok(self.build_result(tray, Some(game_state), Some(update)))
}
pub fn add_to_dictionary(&mut self, player: &str, word: &str) -> Result<ApiState, Error> {
let word = word.to_uppercase();
self.0.add_word(&word);
let update = Update {
r#type: TurnAction::AddToDictionary { word },
player: player.to_string(),
turn_number: self.0.get_number_turns(),
};
self.1.push(update.clone());
let tray = self.0.player_states.get_tray(&player).unwrap().clone();
Ok(self.build_result(tray, None, Some(update)))
}
pub fn is_ai_turn(&self) -> bool {
let current_player = self.0.current_player_name();
matches!(
self.0
.player_states
.get_player_state(&current_player)
.unwrap()
.player,
Player::AI { .. }
)
}
}

View file

@ -1,32 +1,31 @@
use crate::constants::{ALL_LETTERS_BONUS, GRID_LENGTH, TRAY_LENGTH};
use crate::dictionary::DictionaryImpl;
use crate::game::Error;
use serde::{Deserialize, Serialize};
use std::borrow::BorrowMut;
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt; use std::fmt;
use std::fmt::{Formatter, Write}; use std::fmt::{Formatter, Write};
use std::borrow::BorrowMut;
use serde::{Deserialize, Serialize};
use tsify::Tsify;
use crate::constants::{ALL_LETTERS_BONUS, GRID_LENGTH, TRAY_LENGTH};
use crate::dictionary::DictionaryImpl;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub enum Direction { pub enum Direction {
Row, Column Row,
Column,
} }
impl Direction { impl Direction {
pub fn invert(&self) -> Self { pub fn invert(&self) -> Self {
match &self { match &self {
Direction::Row => {Direction::Column} Direction::Row => Direction::Column,
Direction::Column => {Direction::Row} Direction::Column => Direction::Row,
} }
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct Coordinates (pub u8, pub u8); pub struct Coordinates(pub u8, pub u8);
impl Coordinates { impl Coordinates {
pub fn new_from_index(index: usize) -> Self { pub fn new_from_index(index: usize) -> Self {
let y = index / GRID_LENGTH as usize; let y = index / GRID_LENGTH as usize;
let x = index % GRID_LENGTH as usize; let x = index % GRID_LENGTH as usize;
@ -36,32 +35,35 @@ impl Coordinates {
fn add(&self, direction: Direction, i: i8) -> Option<Self> { fn add(&self, direction: Direction, i: i8) -> Option<Self> {
let proposed = match direction { let proposed = match direction {
Direction::Column => {(self.0 as i8, self.1 as i8+i)} Direction::Column => (self.0 as i8, self.1 as i8 + i),
Direction::Row => {(self.0 as i8+i, self.1 as i8)} Direction::Row => (self.0 as i8 + i, self.1 as i8),
}; };
if proposed.0 < 0 || proposed.0 >= GRID_LENGTH as i8 || proposed.1 < 0 || proposed.1 >= GRID_LENGTH as i8 { if proposed.0 < 0
|| proposed.0 >= GRID_LENGTH as i8
|| proposed.1 < 0
|| proposed.1 >= GRID_LENGTH as i8
{
None None
} else{ } else {
Some(Coordinates(proposed.0 as u8, proposed.1 as u8)) Some(Coordinates(proposed.0 as u8, proposed.1 as u8))
} }
} }
pub fn increment(&self, direction: Direction) -> Option<Self>{ pub fn increment(&self, direction: Direction) -> Option<Self> {
self.add(direction, 1) self.add(direction, 1)
} }
pub fn decrement(&self, direction: Direction) -> Option<Self>{ pub fn decrement(&self, direction: Direction) -> Option<Self> {
self.add(direction, -1) self.add(direction, -1)
} }
pub fn map_to_index(&self) -> usize { pub fn map_to_index(&self) -> usize {
(self.0 + GRID_LENGTH*self.1) as usize (self.0 + GRID_LENGTH * self.1) as usize
} }
} }
#[derive(Debug, Copy, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq, Hash)] #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[tsify(from_wasm_abi)]
pub struct Letter { pub struct Letter {
pub text: char, pub text: char,
pub points: u32, pub points: u32,
@ -81,32 +83,27 @@ impl Letter {
pub fn new(text: Option<char>, points: u32) -> Letter { pub fn new(text: Option<char>, points: u32) -> Letter {
match text { match text {
None => { None => Letter {
Letter {
text: ' ', text: ' ',
points, points,
ephemeral: true, ephemeral: true,
is_blank: true, is_blank: true,
} },
} Some(text) => Letter {
Some(text) => {
Letter {
text, text,
points, points,
ephemeral: true, ephemeral: true,
is_blank: false, is_blank: false,
} },
}
} }
} }
pub fn partial_match(&self, other: &Letter) -> bool { pub fn partial_match(&self, other: &Letter) -> bool {
self == other || (self.is_blank && other.is_blank && self.points == other.points) self == other || (self.is_blank && other.is_blank && self.points == other.points)
} }
} }
#[derive(Debug, Copy, Clone, Serialize)] #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum CellType { pub enum CellType {
Normal, Normal,
DoubleWord, DoubleWord,
@ -141,28 +138,25 @@ impl<'a> ToString for Word<'a> {
} }
text text
} }
} }
impl <'a> Word<'a> { impl<'a> Word<'a> {
pub fn calculate_score(&self) -> u32 {
pub fn calculate_score(&self) -> u32{
let mut multiplier = 1; let mut multiplier = 1;
let mut unmultiplied_score = 0; let mut unmultiplied_score = 0;
for cell in self.cells.as_slice() { for cell in self.cells.as_slice() {
let cell_value = cell.value.unwrap(); let cell_value = cell.value.unwrap();
if cell_value.ephemeral { if cell_value.ephemeral {
let cell_multiplier = let cell_multiplier = match cell.cell_type {
match cell.cell_type { CellType::Normal => 1,
CellType::Normal => {1}
CellType::DoubleWord => { CellType::DoubleWord => {
multiplier *= 2; multiplier *= 2;
1 1
} }
CellType::DoubleLetter => {2} CellType::DoubleLetter => 2,
CellType::TripleLetter => {3} CellType::TripleLetter => 3,
CellType::TripleWord => { CellType::TripleWord => {
multiplier *= 3; multiplier *= 3;
1 1
@ -187,7 +181,6 @@ impl Board {
pub fn new() -> Self { pub fn new() -> Self {
let mut cells = Vec::new(); let mut cells = Vec::new();
/// Since the board is symmetrical in both directions for the purposes of our logic we can keep our coordinates in one corner /// Since the board is symmetrical in both directions for the purposes of our logic we can keep our coordinates in one corner
/// ///
/// # Arguments /// # Arguments
@ -200,7 +193,7 @@ impl Board {
GRID_LENGTH - x - 1 GRID_LENGTH - x - 1
} else { } else {
x x
} };
} }
for i_orig in 0..GRID_LENGTH { for i_orig in 0..GRID_LENGTH {
@ -221,12 +214,10 @@ impl Board {
} }
// Double letters // Double letters
if (i % 4 == 2) && (j % 4 == 2) && !( if (i % 4 == 2) && (j % 4 == 2) && !(i == 2 && j == 2) {
i == 2 && j == 2
) {
typee = CellType::DoubleLetter; typee = CellType::DoubleLetter;
} }
if (i.min(j) == 0 && i.max(j) == 3) || (i.min(j)==3 && i.max(j) == 7) { if (i.min(j) == 0 && i.max(j) == 3) || (i.min(j) == 3 && i.max(j) == 7) {
typee = CellType::DoubleLetter; typee = CellType::DoubleLetter;
} }
@ -245,11 +236,10 @@ impl Board {
value: None, value: None,
coordinates: Coordinates(j_orig, i_orig), coordinates: Coordinates(j_orig, i_orig),
}) })
} }
} }
Board {cells} Board { cells }
} }
pub fn get_cell(&self, coordinates: Coordinates) -> Result<&Cell, &str> { pub fn get_cell(&self, coordinates: Coordinates) -> Result<&Cell, &str> {
@ -261,24 +251,28 @@ impl Board {
} }
} }
pub fn get_cell_mut(&mut self, coordinates: Coordinates) -> Result<&mut Cell, &str> { pub fn get_cell_mut(&mut self, coordinates: Coordinates) -> Result<&mut Cell, Error> {
if coordinates.0 >= GRID_LENGTH || coordinates.1 >= GRID_LENGTH { if coordinates.0 >= GRID_LENGTH || coordinates.1 >= GRID_LENGTH {
Err("x & y must be within the board's coordinates") Err(Error::Other(
"x & y must be within the board's coordinates".to_string(),
))
} else { } else {
let index = coordinates.map_to_index(); let index = coordinates.map_to_index();
Ok(self.cells.get_mut(index).unwrap()) Ok(self.cells.get_mut(index).unwrap())
} }
} }
pub fn calculate_scores(
pub fn calculate_scores(&self, dictionary: &DictionaryImpl) -> Result<(Vec<(Word, u32)>, u32), String> { &self,
dictionary: &DictionaryImpl,
) -> Result<(Vec<(Word, u32)>, u32), Error> {
let (words, tiles_played) = self.find_played_words()?; let (words, tiles_played) = self.find_played_words()?;
let mut words_and_scores = Vec::new(); let mut words_and_scores = Vec::new();
let mut total_score = 0; let mut total_score = 0;
for word in words { for word in words {
if !dictionary.contains_key(&word.to_string()) { if !dictionary.contains_key(&word.to_string()) {
return Err(format!("{} is not a valid word", word.to_string())); return Err(Error::InvalidWord(word.to_string()));
} }
let score = word.calculate_score(); let score = word.calculate_score();
@ -293,10 +287,9 @@ impl Board {
Ok((words_and_scores, total_score)) Ok((words_and_scores, total_score))
} }
pub fn find_played_words(&self) -> Result<(Vec<Word>, u8), &str> { pub fn find_played_words(&self) -> Result<(Vec<Word>, u8), Error> {
// We don't assume that the move is valid, so let's first establish that // We don't assume that the move is valid, so let's first establish that
// Let's first establish what rows and columns tiles were played in // Let's first establish what rows and columns tiles were played in
let mut rows_played = HashSet::with_capacity(15); let mut rows_played = HashSet::with_capacity(15);
let mut columns_played = HashSet::with_capacity(15); let mut columns_played = HashSet::with_capacity(15);
@ -320,9 +313,9 @@ impl Board {
} }
if rows_played.is_empty() { if rows_played.is_empty() {
return Err("Tiles need to be played") return Err(Error::NoTilesPlayed);
} else if rows_played.len() > 1 && columns_played.len() > 1 { } else if rows_played.len() > 1 && columns_played.len() > 1 {
return Err("Tiles need to be played on one row or column") return Err(Error::TilesNotStraight);
} }
let direction = if rows_played.len() > 1 { let direction = if rows_played.len() > 1 {
@ -335,7 +328,9 @@ impl Board {
let starting_column = *columns_played.iter().min().unwrap(); let starting_column = *columns_played.iter().min().unwrap();
let starting_coords = Coordinates(starting_row, starting_column); let starting_coords = Coordinates(starting_row, starting_column);
let main_word = self.find_word_at_position(starting_coords, direction).unwrap(); let main_word = self
.find_word_at_position(starting_coords, direction)
.unwrap();
let mut words = Vec::new(); let mut words = Vec::new();
let mut observed_tiles_played = 0; let mut observed_tiles_played = 0;
@ -359,25 +354,25 @@ impl Board {
// there are tiles not part of the main word // there are tiles not part of the main word
if observed_tiles_played != tiles_played { if observed_tiles_played != tiles_played {
return Err("Played tiles cannot have empty gap"); return Err(Error::TilesHaveGap);
} }
// don't want the case of a single letter word // don't want the case of a single letter word
if main_word.cells.len() > 1 { if main_word.cells.len() > 1 {
words.push(main_word); words.push(main_word);
} else if words.is_empty() { } else if words.is_empty() {
return Err("All words must be at least one letter"); return Err(Error::OneLetterWord);
} }
// need to verify that the play is 'anchored' // need to verify that the play is 'anchored'
let mut anchored = false; let mut anchored = false;
'outer: for word in words.as_slice() { 'outer: for word in words.as_slice() {
for cell in word.cells.as_slice() { for cell in word.cells.as_slice() {
// either one of the letters // either one of the letters
if !cell.value.as_ref().unwrap().ephemeral || (cell.coordinates.0 == GRID_LENGTH / 2 && cell.coordinates.1 == GRID_LENGTH / 2){ if !cell.value.as_ref().unwrap().ephemeral
|| (cell.coordinates.0 == GRID_LENGTH / 2
&& cell.coordinates.1 == GRID_LENGTH / 2)
{
anchored = true; anchored = true;
break 'outer; break 'outer;
} }
@ -387,23 +382,27 @@ impl Board {
if anchored { if anchored {
Ok((words, tiles_played)) Ok((words, tiles_played))
} else { } else {
return Err("Played tiles must be anchored to something") Err(Error::UnanchoredWord)
} }
} }
pub fn find_word_at_position(&self, mut start_coords: Coordinates, direction: Direction) -> Option<Word> { pub fn find_word_at_position(
&self,
mut start_coords: Coordinates,
direction: Direction,
) -> Option<Word> {
// let's see how far we can backtrack to the start of the word // let's see how far we can backtrack to the start of the word
let mut times_moved = 0; let mut times_moved = 0;
loop { loop {
let one_back = start_coords.add(direction, -times_moved); let one_back = start_coords.add(direction, -times_moved);
match one_back { match one_back {
None => { break } None => break,
Some(new_coords) => { Some(new_coords) => {
let cell = self.get_cell(new_coords).unwrap(); let cell = self.get_cell(new_coords).unwrap();
if cell.value.is_some(){ if cell.value.is_some() {
times_moved += 1; times_moved += 1;
} else { } else {
break break;
} }
} }
} }
@ -423,11 +422,11 @@ impl Board {
loop { loop {
let position = start_coords.add(direction, cells.len() as i8); let position = start_coords.add(direction, cells.len() as i8);
match position { match position {
None => {break} None => break,
Some(x) => { Some(x) => {
let cell = self.get_cell(x).unwrap(); let cell = self.get_cell(x).unwrap();
match cell.value { match cell.value {
None => {break} None => break,
Some(_) => { Some(_) => {
cells.push(cell); cells.push(cell);
} }
@ -442,16 +441,16 @@ impl Board {
}) })
} }
pub fn receive_play(&mut self, play: Vec<(Letter, Coordinates)>) -> Result<(), String> { pub fn receive_play(&mut self, play: Vec<(Letter, Coordinates)>) -> Result<(), Error> {
for (mut letter, coords) in play { for (mut letter, coords) in play {
{ {
let cell = match self.get_cell_mut(coords) { let cell = self.get_cell_mut(coords)?;
Ok(cell) => {cell}
Err(e) => {return Err(e.to_string())}
};
if cell.value.is_some() { if cell.value.is_some() {
return Err(format!("There's already a letter at {:?}", coords)); return Err(Error::Other(format!(
"There's already a letter at {:?}",
coords
)));
} }
letter.ephemeral = true; letter.ephemeral = true;
@ -476,7 +475,6 @@ impl Board {
impl fmt::Display for Board { impl fmt::Display for Board {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut str = String::new(); let mut str = String::new();
let normal = "\x1b[48;5;174m\x1b[38;5;0m"; let normal = "\x1b[48;5;174m\x1b[38;5;0m";
@ -493,57 +491,82 @@ impl fmt::Display for Board {
let cell = self.get_cell(coords).unwrap(); let cell = self.get_cell(coords).unwrap();
let color = match cell.cell_type { let color = match cell.cell_type {
CellType::Normal => {normal} CellType::Normal => normal,
CellType::DoubleWord => {double_word} CellType::DoubleWord => double_word,
CellType::DoubleLetter => {double_letter} CellType::DoubleLetter => double_letter,
CellType::TripleLetter => {triple_letter} CellType::TripleLetter => triple_letter,
CellType::TripleWord => {triple_word} CellType::TripleWord => triple_word,
CellType::Start => {double_word} CellType::Start => double_word,
}; };
let content = match &cell.value { let content = match &cell.value {
None => {' '} None => ' ',
Some(letter) => {letter.text} Some(letter) => letter.text,
}; };
str.write_str(color).unwrap(); str.write_str(color).unwrap();
str.write_char(content).unwrap(); str.write_char(content).unwrap();
} }
str.write_str("\x1b[0m\n").unwrap(); str.write_str("\x1b[0m\n").unwrap();
} }
write!(f, "{}", str) write!(f, "{}", str)
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::dictionary::Dictionary;
use super::*; use super::*;
use crate::dictionary::Dictionary;
#[test] #[test]
fn test_cell_types() { fn test_cell_types() {
let board = Board::new(); let board = Board::new();
assert!(matches!(board.get_cell(Coordinates(0, 0)).unwrap().cell_type, CellType::TripleWord)); assert!(matches!(
assert!(matches!(board.get_cell(Coordinates(1, 0)).unwrap().cell_type, CellType::Normal)); board.get_cell(Coordinates(0, 0)).unwrap().cell_type,
assert!(matches!(board.get_cell(Coordinates(0, 1)).unwrap().cell_type, CellType::Normal)); CellType::TripleWord
assert!(matches!(board.get_cell(Coordinates(1, 1)).unwrap().cell_type, CellType::DoubleWord)); ));
assert!(matches!(
board.get_cell(Coordinates(1, 0)).unwrap().cell_type,
CellType::Normal
));
assert!(matches!(
board.get_cell(Coordinates(0, 1)).unwrap().cell_type,
CellType::Normal
));
assert!(matches!(
board.get_cell(Coordinates(1, 1)).unwrap().cell_type,
CellType::DoubleWord
));
assert!(matches!(board.get_cell(Coordinates(13, 13)).unwrap().cell_type, CellType::DoubleWord)); assert!(matches!(
assert!(matches!(board.get_cell(Coordinates(14, 14)).unwrap().cell_type, CellType::TripleWord)); board.get_cell(Coordinates(13, 13)).unwrap().cell_type,
assert!(matches!(board.get_cell(Coordinates(11, 14)).unwrap().cell_type, CellType::DoubleLetter)); CellType::DoubleWord
));
assert!(matches!(
board.get_cell(Coordinates(14, 14)).unwrap().cell_type,
CellType::TripleWord
));
assert!(matches!(
board.get_cell(Coordinates(11, 14)).unwrap().cell_type,
CellType::DoubleLetter
));
assert!(matches!(board.get_cell(Coordinates(7, 7)).unwrap().cell_type, CellType::Start)); assert!(matches!(
assert!(matches!(board.get_cell(Coordinates(8, 6)).unwrap().cell_type, CellType::DoubleLetter)); board.get_cell(Coordinates(7, 7)).unwrap().cell_type,
assert!(matches!(board.get_cell(Coordinates(5, 9)).unwrap().cell_type, CellType::TripleLetter)); CellType::Start
));
assert!(matches!(
board.get_cell(Coordinates(8, 6)).unwrap().cell_type,
CellType::DoubleLetter
));
assert!(matches!(
board.get_cell(Coordinates(5, 9)).unwrap().cell_type,
CellType::TripleLetter
));
} }
#[test] #[test]
fn test_cell_coordinates() { fn test_cell_coordinates() {
let board = Board::new(); let board = Board::new();
@ -576,7 +599,6 @@ mod tests {
board.get_cell_mut(Coordinates(5, 0)).unwrap().value = Some(Letter::new_fixed('O', 0)); board.get_cell_mut(Coordinates(5, 0)).unwrap().value = Some(Letter::new_fixed('O', 0));
board.get_cell_mut(Coordinates(6, 0)).unwrap().value = Some(Letter::new_fixed('L', 0)); board.get_cell_mut(Coordinates(6, 0)).unwrap().value = Some(Letter::new_fixed('L', 0));
board.get_cell_mut(Coordinates(9, 8)).unwrap().value = Some(Letter::new_fixed('G', 0)); board.get_cell_mut(Coordinates(9, 8)).unwrap().value = Some(Letter::new_fixed('G', 0));
board.get_cell_mut(Coordinates(10, 8)).unwrap().value = Some(Letter::new_fixed('G', 0)); board.get_cell_mut(Coordinates(10, 8)).unwrap().value = Some(Letter::new_fixed('G', 0));
@ -584,7 +606,9 @@ mod tests {
println!("x is {}", x); println!("x is {}", x);
let first_word = board.find_word_at_position(Coordinates(8, x), Direction::Column); let first_word = board.find_word_at_position(Coordinates(8, x), Direction::Column);
match first_word { match first_word {
None => { panic!("Expected to find word JOEL") } None => {
panic!("Expected to find word JOEL")
}
Some(x) => { Some(x) => {
assert_eq!(x.coords.0, 8); assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 6); assert_eq!(x.coords.1, 6);
@ -596,7 +620,9 @@ mod tests {
let single_letter_word = board.find_word_at_position(Coordinates(8, 9), Direction::Row); let single_letter_word = board.find_word_at_position(Coordinates(8, 9), Direction::Row);
match single_letter_word { match single_letter_word {
None => { panic!("Expected to find letter L") } None => {
panic!("Expected to find letter L")
}
Some(x) => { Some(x) => {
assert_eq!(x.coords.0, 8); assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 9); assert_eq!(x.coords.1, 9);
@ -609,7 +635,9 @@ mod tests {
println!("x is {}", x); println!("x is {}", x);
let word = board.find_word_at_position(Coordinates(x, 0), Direction::Row); let word = board.find_word_at_position(Coordinates(x, 0), Direction::Row);
match word { match word {
None => { panic!("Expected to find word IS") } None => {
panic!("Expected to find word IS")
}
Some(x) => { Some(x) => {
assert_eq!(x.coords.0, 0); assert_eq!(x.coords.0, 0);
assert_eq!(x.coords.1, 0); assert_eq!(x.coords.1, 0);
@ -623,7 +651,9 @@ mod tests {
println!("x is {}", x); println!("x is {}", x);
let word = board.find_word_at_position(Coordinates(x, 0), Direction::Row); let word = board.find_word_at_position(Coordinates(x, 0), Direction::Row);
match word { match word {
None => { panic!("Expected to find word COOL") } None => {
panic!("Expected to find word COOL")
}
Some(x) => { Some(x) => {
assert_eq!(x.coords.0, 3); assert_eq!(x.coords.0, 3);
assert_eq!(x.coords.1, 0); assert_eq!(x.coords.1, 0);
@ -638,7 +668,9 @@ mod tests {
let word = board.find_word_at_position(Coordinates(10, 8), Direction::Row); let word = board.find_word_at_position(Coordinates(10, 8), Direction::Row);
match word { match word {
None => { panic!("Expected to find word EGG") } None => {
panic!("Expected to find word EGG")
}
Some(x) => { Some(x) => {
assert_eq!(x.coords.0, 8); assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 8); assert_eq!(x.coords.1, 8);
@ -659,10 +691,10 @@ mod tests {
is_blank: false, is_blank: false,
}); });
match board.find_played_words() { assert!(matches!(
Ok(_) => {panic!("Expected error")} board.find_played_words(),
Err(e) => {assert_eq!(e, "All words must be at least one letter");} Err(Error::OneLetterWord)
} ));
board.get_cell_mut(Coordinates(7, 7)).unwrap().value = Some(Letter { board.get_cell_mut(Coordinates(7, 7)).unwrap().value = Some(Letter {
text: 'I', text: 'I',
@ -726,10 +758,7 @@ mod tests {
board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(make_letter('L', true)); board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(make_letter('L', true));
let words = board.find_played_words(); let words = board.find_played_words();
match words { assert!(matches!(words, Err(Error::UnanchoredWord)));
Ok(_) => {panic!("Expected the not-anchored error")}
Err(x) => {assert_eq!(x, "Played tiles must be anchored to something")}
}
// Adding anchor // Adding anchor
board.get_cell_mut(Coordinates(7, 6)).unwrap().value = Some(make_letter('I', false)); board.get_cell_mut(Coordinates(7, 6)).unwrap().value = Some(make_letter('I', false));
@ -746,7 +775,6 @@ mod tests {
assert!(board.find_played_words().is_ok()); assert!(board.find_played_words().is_ok());
} }
#[test] #[test]
fn test_word_finding_with_break() { fn test_word_finding_with_break() {
// Verify that if I play my tiles on one row or column but with a break in-between I get an error // Verify that if I play my tiles on one row or column but with a break in-between I get an error
@ -765,21 +793,14 @@ mod tests {
board.get_cell_mut(Coordinates(8, 6)).unwrap().value = Some(Letter::new_fixed('J', 0)); board.get_cell_mut(Coordinates(8, 6)).unwrap().value = Some(Letter::new_fixed('J', 0));
board.get_cell_mut(Coordinates(8, 7)).unwrap().value = Some(make_letter('O', true)); board.get_cell_mut(Coordinates(8, 7)).unwrap().value = Some(make_letter('O', true));
board.get_cell_mut(Coordinates(8, 8)).unwrap().value = Some(make_letter('E', true)); board.get_cell_mut(Coordinates(8, 8)).unwrap().value = Some(make_letter('E', true));
board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(Letter::new_fixed( 'L', 0)); board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(Letter::new_fixed('L', 0));
board.get_cell_mut(Coordinates(8, 11)).unwrap().value = Some(make_letter('I', true)); board.get_cell_mut(Coordinates(8, 11)).unwrap().value = Some(make_letter('I', true));
board.get_cell_mut(Coordinates(8, 12)).unwrap().value = Some(Letter::new_fixed('S', 0)); board.get_cell_mut(Coordinates(8, 12)).unwrap().value = Some(Letter::new_fixed('S', 0));
let words = board.find_played_words(); let words = board.find_played_words();
match words { assert!(matches!(words, Err(Error::TilesHaveGap)));
Ok(_) => {panic!("Expected to find an error!")}
Err(x) => {
assert_eq!(x, "Played tiles cannot have empty gap")
} }
}
}
#[test] #[test]
fn test_word_finding_whole_board() { fn test_word_finding_whole_board() {
@ -795,15 +816,12 @@ mod tests {
} }
let words = board.find_played_words(); let words = board.find_played_words();
match words { assert!(matches!(words, Err(Error::NoTilesPlayed)));
Ok(_) => {panic!("Expected to find no words")}
Err(x) => {assert_eq!(x, "Tiles need to be played")}
}
board.get_cell_mut(Coordinates(8, 6)).unwrap().value = Some(Letter::new_fixed('J', 8)); board.get_cell_mut(Coordinates(8, 6)).unwrap().value = Some(Letter::new_fixed('J', 8));
board.get_cell_mut(Coordinates(8, 7)).unwrap().value = Some(make_letter('O', true, 1)); board.get_cell_mut(Coordinates(8, 7)).unwrap().value = Some(make_letter('O', true, 1));
board.get_cell_mut(Coordinates(8, 8)).unwrap().value = Some(make_letter('E', true, 1)); board.get_cell_mut(Coordinates(8, 8)).unwrap().value = Some(make_letter('E', true, 1));
board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(Letter::new_fixed( 'L', 1)); board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(Letter::new_fixed('L', 1));
board.get_cell_mut(Coordinates(0, 0)).unwrap().value = Some(Letter::new_fixed('I', 1)); board.get_cell_mut(Coordinates(0, 0)).unwrap().value = Some(Letter::new_fixed('I', 1));
board.get_cell_mut(Coordinates(1, 0)).unwrap().value = Some(Letter::new_fixed('S', 1)); board.get_cell_mut(Coordinates(1, 0)).unwrap().value = Some(Letter::new_fixed('S', 1));
@ -814,7 +832,7 @@ mod tests {
board.get_cell_mut(Coordinates(6, 0)).unwrap().value = Some(Letter::new_fixed('L', 1)); board.get_cell_mut(Coordinates(6, 0)).unwrap().value = Some(Letter::new_fixed('L', 1));
fn check_board(board: &mut Board, inverted: bool) { fn check_board(board: &mut Board, inverted: bool) {
let dictionary = DictionaryImpl::create_from_path("resources/dictionary.csv"); let dictionary = DictionaryImpl::create_from_path("../resources/dictionary.csv");
println!("{}", board); println!("{}", board);
let words = board.find_played_words(); let words = board.find_played_words();
match words { match words {
@ -827,7 +845,9 @@ mod tests {
assert_eq!(word.calculate_score(), 8 + 1 + 2 + 1); assert_eq!(word.calculate_score(), 8 + 1 + 2 + 1);
} }
Err(e) => { panic!("Expected to find a word to play; found error {}", e) } Err(e) => {
panic!("Expected to find a word to play; found error {}", e)
}
} }
let maybe_invert = |coords: Coordinates| { let maybe_invert = |coords: Coordinates| {
@ -844,12 +864,21 @@ mod tests {
return direction; return direction;
}; };
board.get_cell_mut(maybe_invert(Coordinates(9, 8))).unwrap().value = Some(Letter::new_fixed('G', 2)); board
board.get_cell_mut(maybe_invert(Coordinates(10, 8))).unwrap().value = Some(Letter::new_fixed('G', 2)); .get_cell_mut(maybe_invert(Coordinates(9, 8)))
.unwrap()
.value = Some(Letter::new_fixed('G', 2));
board
.get_cell_mut(maybe_invert(Coordinates(10, 8)))
.unwrap()
.value = Some(Letter::new_fixed('G', 2));
let word = board.find_word_at_position(Coordinates(8, 8), maybe_invert_direction(Direction::Row)); let word = board
.find_word_at_position(Coordinates(8, 8), maybe_invert_direction(Direction::Row));
match word { match word {
None => {panic!("Expected to find word EGG")} None => {
panic!("Expected to find word EGG")
}
Some(x) => { Some(x) => {
assert_eq!(x.coords.0, 8); assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 8); assert_eq!(x.coords.1, 8);
@ -857,7 +886,6 @@ mod tests {
assert_eq!(x.to_string(), "EGG"); assert_eq!(x.to_string(), "EGG");
assert_eq!(x.calculate_score(), 2 + 2 + 2); assert_eq!(x.calculate_score(), 2 + 2 + 2);
assert!(dictionary.is_word_valid(&x)); assert!(dictionary.is_word_valid(&x));
} }
} }
@ -876,20 +904,29 @@ mod tests {
assert_eq!(word.calculate_score(), 8 + 1 + 2 + 1); assert_eq!(word.calculate_score(), 8 + 1 + 2 + 1);
assert!(!dictionary.is_word_valid(word)); assert!(!dictionary.is_word_valid(word));
} }
Err(e) => { panic!("Expected to find a word to play; found error {}", e) } Err(e) => {
panic!("Expected to find a word to play; found error {}", e)
}
} }
let scores = board.calculate_scores(&dictionary); let scores = board.calculate_scores(&dictionary);
match scores { match scores {
Ok(_) => {panic!("Expected an error")} Ok(_) => {
Err(e) => {assert_eq!(e, "JOEL is not a valid word")} panic!("Expected an error")
}
Err(e) => {
if let Error::InvalidWord(w) = e {
assert_eq!(w, "JOEL");
} else {
panic!("Expected an InvalidPlay error")
}
}
} }
let mut alt_dictionary = DictionaryImpl::new(); let mut alt_dictionary = DictionaryImpl::new();
alt_dictionary.insert("JOEL".to_string(), 0.5); alt_dictionary.insert("JOEL".to_string(), 0.5);
alt_dictionary.insert("EGG".to_string(), 0.5); alt_dictionary.insert("EGG".to_string(), 0.5);
let scores = board.calculate_scores(&alt_dictionary); let scores = board.calculate_scores(&alt_dictionary);
match scores { match scores {
Ok((words, total_score)) => { Ok((words, total_score)) => {
@ -906,18 +943,19 @@ mod tests {
assert_eq!(total_score, 18); assert_eq!(total_score, 18);
} }
Err(e) => {panic!("Wasn't expecting to encounter error {e}")} Err(e) => {
panic!("Wasn't expecting to encounter error {e}")
}
} }
// replace one of the 'G' in EGG with an ephemeral to trigger an error // replace one of the 'G' in EGG with an ephemeral to trigger an error
board.get_cell_mut(maybe_invert(Coordinates(9, 8))).unwrap().value = Some(make_letter('G', true, 2)); board
.get_cell_mut(maybe_invert(Coordinates(9, 8)))
.unwrap()
.value = Some(make_letter('G', true, 2));
let words = board.find_played_words(); let words = board.find_played_words();
match words { assert!(matches!(words, Err(Error::TilesNotStraight)));
Ok(_) => { panic!("Expected error as we played tiles in multiple rows and columns") }
Err(e) => { assert_eq!(e, "Tiles need to be played on one row or column") }
}
} }
// make a copy of the board now with x and y swapped // make a copy of the board now with x and y swapped
@ -934,7 +972,6 @@ mod tests {
cell_new.value = Some(*x); cell_new.value = Some(*x);
} }
} }
} }
} }
@ -943,8 +980,5 @@ mod tests {
println!("Checking inverted board"); println!("Checking inverted board");
check_board(&mut inverted_board, true); check_board(&mut inverted_board, true);
} }
} }

View file

@ -1,6 +1,6 @@
use rand::prelude::SliceRandom;
use rand::{Rng};
use crate::board::Letter; use crate::board::Letter;
use rand::prelude::SliceRandom;
use rand::Rng;
pub const GRID_LENGTH: u8 = 15; pub const GRID_LENGTH: u8 = 15;
pub const TRAY_LENGTH: u8 = 7; pub const TRAY_LENGTH: u8 = 7;
@ -49,5 +49,4 @@ pub fn standard_tile_pool<R: Rng>(rng: Option<&mut R>) -> Vec<Letter> {
} }
letters letters
} }

View file

@ -1,10 +1,9 @@
use crate::board::Word;
use csv::Reader;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::str::FromStr; use std::str::FromStr;
use csv::Reader;
use crate::board::Word;
pub trait Dictionary { pub trait Dictionary {
fn create_from_reader<T: std::io::Read>(reader: Reader<T>) -> Self; fn create_from_reader<T: std::io::Read>(reader: Reader<T>) -> Self;
fn create_from_path(path: &str) -> Self; fn create_from_path(path: &str) -> Self;
fn create_from_str(data: &str) -> Self; fn create_from_str(data: &str) -> Self;
@ -14,8 +13,7 @@ pub trait Dictionary {
} }
pub type DictionaryImpl = HashMap<String, f64>; pub type DictionaryImpl = HashMap<String, f64>;
impl Dictionary for DictionaryImpl{ impl Dictionary for DictionaryImpl {
fn create_from_reader<T: std::io::Read>(mut reader: Reader<T>) -> Self { fn create_from_reader<T: std::io::Read>(mut reader: Reader<T>) -> Self {
let mut map = HashMap::new(); let mut map = HashMap::new();
@ -27,7 +25,6 @@ impl Dictionary for DictionaryImpl{
let score = f64::from_str(score).unwrap(); let score = f64::from_str(score).unwrap();
map.insert(word, score); map.insert(word, score);
} }
map map
@ -57,7 +54,6 @@ impl Dictionary for DictionaryImpl{
} }
map map
} }
fn substring_set(&self) -> HashSet<&str> { fn substring_set(&self) -> HashSet<&str> {
@ -65,11 +61,10 @@ impl Dictionary for DictionaryImpl{
for (word, _score) in self.iter() { for (word, _score) in self.iter() {
for j in 0..word.len() { for j in 0..word.len() {
for k in (j+1)..(word.len()+1) { for k in (j + 1)..(word.len() + 1) {
set.insert(&word[j..k]); set.insert(&word[j..k]);
} }
} }
} }
set set
@ -85,10 +80,9 @@ impl Dictionary for DictionaryImpl{
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_dictionary() { fn test_dictionary() {
let dictionary = HashMap::create_from_path("resources/dictionary.csv"); let dictionary = HashMap::create_from_path("../resources/dictionary.csv");
assert_eq!(dictionary.len(), 279429); assert_eq!(dictionary.len(), 279429);
@ -96,7 +90,6 @@ mod tests {
assert!(dictionary.contains_key("AARDVARK")); assert!(dictionary.contains_key("AARDVARK"));
assert!((dictionary.get("AARDVARK").unwrap() - 0.5798372).abs() < 0.0001) assert!((dictionary.get("AARDVARK").unwrap() - 0.5798372).abs() < 0.0001)
} }
#[test] #[test]
@ -131,11 +124,8 @@ mod tests {
assert!(set.contains("JOH")); assert!(set.contains("JOH"));
assert!(set.contains("OHN")); assert!(set.contains("OHN"));
assert!(!set.contains("XY")); assert!(!set.contains("XY"));
assert!(!set.contains("JH")); assert!(!set.contains("JH"));
assert!(!set.contains("JE")); assert!(!set.contains("JE"));
} }
} }

View file

@ -1,31 +1,90 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use rand::prelude::SliceRandom;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use serde::{Deserialize, Serialize};
use tsify::Tsify;
use crate::board::{Board, Coordinates, Letter}; use crate::board::{Board, Coordinates, Letter};
use crate::constants::{standard_tile_pool, TRAY_LENGTH}; use crate::constants::{standard_tile_pool, TRAY_LENGTH};
use crate::dictionary::{Dictionary, DictionaryImpl}; use crate::dictionary::{Dictionary, DictionaryImpl};
use crate::player_interaction::ai::{AI, CompleteMove, Difficulty}; use crate::player_interaction::ai::{Difficulty, AI};
use crate::player_interaction::Tray; use crate::player_interaction::Tray;
use rand::prelude::SliceRandom;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use serde::{Deserialize, Serialize, Serializer};
pub enum Player { pub enum Player {
Human(String), Human(String),
AI{ AI {
name: String, name: String,
difficulty: Difficulty, difficulty: Difficulty,
object: AI, object: AI,
},
}
#[derive(Debug, Clone)]
pub enum Error {
InvalidPlayer(String),
WrongTurn(String),
Other(String),
InvalidWord(String),
GameFinished,
NoTilesPlayed,
TilesNotStraight,
TilesHaveGap,
OneLetterWord,
UnanchoredWord,
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match &self {
Error::InvalidPlayer(player) => {
write!(f, "{player} doesn't exist")
}
Error::WrongTurn(player) => {
write!(f, "It's not {player}'s turn")
}
Error::Other(msg) => {
write!(f, "Other error: {msg}")
}
Error::InvalidWord(word) => {
write!(f, "{word} is not a valid word")
}
Error::GameFinished => {
write!(f, "Moves cannot be made after a game has finished")
}
Error::NoTilesPlayed => {
write!(f, "Tiles need to be played")
}
Error::TilesNotStraight => {
write!(f, "Tiles need to be played on one row or column")
}
Error::TilesHaveGap => {
write!(f, "Played tiles cannot have empty gap")
}
Error::OneLetterWord => {
write!(f, "All words must be at least two letters")
}
Error::UnanchoredWord => {
write!(f, "Played tiles must be anchored to something")
}
}
}
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
} }
} }
impl Player { impl Player {
pub fn get_name(&self) -> &str { pub fn get_name(&self) -> &str {
match &self { match &self {
Player::Human(name) => {name} Player::Human(name) => name,
Player::AI { name, .. } => {name} Player::AI { name, .. } => name,
} }
} }
} }
@ -33,18 +92,20 @@ impl Player {
pub struct PlayerState { pub struct PlayerState {
pub player: Player, pub player: Player,
pub score: u32, pub score: u32,
pub tray: Tray pub tray: Tray,
} }
#[derive(Deserialize, Tsify, Copy, Clone, Debug)] #[derive(Deserialize, Copy, Clone, Debug)]
#[tsify(from_wasm_abi)]
pub struct PlayedTile { pub struct PlayedTile {
pub index: usize, pub index: usize,
pub character: Option<char>, // we only set this if PlayedTile is a blank pub character: Option<char>, // we only set this if PlayedTile is a blank
} }
impl PlayedTile { impl PlayedTile {
pub fn convert_tray(tray_tile_locations: &Vec<Option<PlayedTile>>, tray: &Tray) -> Vec<(Letter, Coordinates)> { pub fn convert_tray(
tray_tile_locations: &Vec<Option<PlayedTile>>,
tray: &Tray,
) -> Vec<(Letter, Coordinates)> {
let mut played_letters: Vec<(Letter, Coordinates)> = Vec::new(); let mut played_letters: Vec<(Letter, Coordinates)> = Vec::new();
for (i, played_tile) in tray_tile_locations.iter().enumerate() { for (i, played_tile) in tray_tile_locations.iter().enumerate() {
if played_tile.is_some() { if played_tile.is_some() {
@ -55,7 +116,9 @@ impl PlayedTile {
if letter.is_blank { if letter.is_blank {
match played_tile.character { match played_tile.character {
None => { None => {
panic!("You can't play a blank character without providing a letter value") panic!(
"You can't play a blank character without providing a letter value"
)
} }
Some(x) => { Some(x) => {
// TODO - check that x is a valid alphabet letter // TODO - check that x is a valid alphabet letter
@ -64,7 +127,6 @@ impl PlayedTile {
} }
} }
played_letters.push((letter, coord)); played_letters.push((letter, coord));
} }
} }
@ -72,15 +134,13 @@ impl PlayedTile {
} }
} }
#[derive(Debug, Serialize, Deserialize, Tsify)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[tsify(from_wasm_abi)]
pub struct WordResult { pub struct WordResult {
word: String, word: String,
score: u32, score: u32,
} }
#[derive(Debug, Serialize, Deserialize, Tsify)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[tsify(from_wasm_abi)]
pub struct ScoreResult { pub struct ScoreResult {
words: Vec<WordResult>, words: Vec<WordResult>,
total: u32, total: u32,
@ -88,7 +148,6 @@ pub struct ScoreResult {
pub struct PlayerStates(pub Vec<PlayerState>); pub struct PlayerStates(pub Vec<PlayerState>);
impl PlayerStates { impl PlayerStates {
fn get_player_name_by_turn_id(&self, id: usize) -> &str { fn get_player_name_by_turn_id(&self, id: usize) -> &str {
let id_mod = id % self.0.len(); let id_mod = id % self.0.len();
let state = self.0.get(id_mod).unwrap(); let state = self.0.get(id_mod).unwrap();
@ -97,37 +156,33 @@ impl PlayerStates {
} }
pub fn get_player_state(&self, name: &str) -> Option<&PlayerState> { pub fn get_player_state(&self, name: &str) -> Option<&PlayerState> {
self.0.iter() self.0
.iter()
.filter(|state| state.player.get_name().eq(name)) .filter(|state| state.player.get_name().eq(name))
.nth(0) .nth(0)
} }
pub fn get_player_state_mut(&mut self, name: &str) -> Option<&mut PlayerState> { pub fn get_player_state_mut(&mut self, name: &str) -> Option<&mut PlayerState> {
self.0.iter_mut() self.0
.iter_mut()
.filter(|state| state.player.get_name().eq(name)) .filter(|state| state.player.get_name().eq(name))
.nth(0) .nth(0)
} }
pub fn get_tray(&self, name: &str) -> Option<&Tray> { pub fn get_tray(&self, name: &str) -> Option<&Tray> {
let player = self.get_player_state(name)?; let player = self.get_player_state(name)?;
Some(&player.tray) Some(&player.tray)
} }
pub fn get_tray_mut(&mut self, name: &str) -> Option<&mut Tray> { pub fn get_tray_mut(&mut self, name: &str) -> Option<&mut Tray> {
let player = self.get_player_state_mut(name)?; let player = self.get_player_state_mut(name)?;
Some(&mut player.tray) Some(&mut player.tray)
} }
} }
#[derive(Deserialize, Serialize, Debug, Clone)]
#[derive(Deserialize, Serialize, Tsify, Debug, Clone)]
#[tsify(from_wasm_abi)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum GameState { pub enum GameState {
InProgress, InProgress,
@ -137,7 +192,7 @@ pub enum GameState {
}, },
} }
pub struct Game{ pub struct Game {
pub tile_pool: Vec<Letter>, pub tile_pool: Vec<Letter>,
rng: SmallRng, rng: SmallRng,
board: Board, board: Board,
@ -148,15 +203,17 @@ pub struct Game{
state: GameState, state: GameState,
} }
impl Game { impl Game {
pub fn new(seed: u64, dictionary_text: &str, player_names: Vec<String>, ai_difficulties: Vec<Difficulty>) -> Self { pub fn new_specific(
let mut rng = SmallRng::seed_from_u64(seed); mut rng: SmallRng,
dictionary: DictionaryImpl,
player_names: Vec<String>,
ai_difficulties: Vec<Difficulty>,
) -> Self {
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
.iter()
let mut player_states: Vec<PlayerState> = player_names.iter()
.map(|name| { .map(|name| {
let mut tray = Tray::new(TRAY_LENGTH); let mut tray = Tray::new(TRAY_LENGTH);
tray.fill(&mut letters); tray.fill(&mut letters);
@ -174,7 +231,7 @@ impl Game {
let mut tray = Tray::new(TRAY_LENGTH); let mut tray = Tray::new(TRAY_LENGTH);
tray.fill(&mut letters); tray.fill(&mut letters);
let ai_player_name = if ai_length > 1 { let ai_player_name = if ai_length > 1 {
format!("AI {}", i+1) format!("AI {}", i + 1)
} else { } else {
"AI".to_string() "AI".to_string()
}; };
@ -204,15 +261,27 @@ 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} pub fn get_board(&self) -> &Board {
&self.board
}
pub fn set_board(&mut self, new_board: Board) { pub fn set_board(&mut self, new_board: Board) {
self.board = new_board; self.board = new_board;
} }
fn fill_trays(&mut self){ fn fill_trays(&mut self) {
for state in self.player_states.0.iter_mut() { for state in self.player_states.0.iter_mut() {
let tray = &mut state.tray; let tray = &mut state.tray;
tray.fill(&mut self.tile_pool); tray.fill(&mut self.tile_pool);
@ -223,14 +292,18 @@ impl Game {
&self.dictionary &self.dictionary
} }
fn verify_game_in_progress(&self) -> Result<(), String> { fn verify_game_in_progress(&self) -> Result<(), Error> {
if !matches!(self.state, GameState::InProgress) { if !matches!(self.state, GameState::InProgress) {
return Err("Moves cannot be made after a game has finished".to_string()); return Err(Error::GameFinished);
} }
Ok(()) Ok(())
} }
pub fn receive_play(&mut self, tray_tile_locations: Vec<Option<PlayedTile>>, commit_move: bool) -> Result<(TurnAction, GameState), String> { pub fn receive_play(
&mut self,
tray_tile_locations: Vec<Option<PlayedTile>>,
commit_move: bool,
) -> Result<(TurnAction, GameState), Error> {
self.verify_game_in_progress()?; self.verify_game_in_progress()?;
let player = self.current_player_name(); let player = self.current_player_name();
@ -238,7 +311,8 @@ impl Game {
let mut board_instance = self.get_board().clone(); let mut board_instance = self.get_board().clone();
let mut tray = self.player_states.get_tray(&player).unwrap().clone(); let mut tray = self.player_states.get_tray(&player).unwrap().clone();
let played_letters: Vec<(Letter, Coordinates)> = PlayedTile::convert_tray(&tray_tile_locations, &tray); let played_letters: Vec<(Letter, Coordinates)> =
PlayedTile::convert_tray(&tray_tile_locations, &tray);
for (i, played_tile) in tray_tile_locations.iter().enumerate() { for (i, played_tile) in tray_tile_locations.iter().enumerate() {
if played_tile.is_some() { if played_tile.is_some() {
*tray.letters.get_mut(i).unwrap() = None; *tray.letters.get_mut(i).unwrap() = None;
@ -247,17 +321,14 @@ impl Game {
board_instance.receive_play(played_letters)?; board_instance.receive_play(played_letters)?;
let x = board_instance.calculate_scores(self.get_dictionary())?; let x = board_instance.calculate_scores(self.get_dictionary())?;
let total_score = x.1; let total_score = x.1;
let words: Vec<WordResult> = x.0.iter() let words: Vec<WordResult> =
.map(|(word, score)| { x.0.iter()
WordResult { .map(|(word, score)| WordResult {
word: word.to_string(), word: word.to_string(),
score: *score score: *score,
}
}) })
.collect(); .collect();
@ -276,28 +347,42 @@ impl Game {
// game is over // game is over
self.end_game(Some(player)); self.end_game(Some(player));
} }
} }
Ok((TurnAction::PlayTiles { let locations = tray_tile_locations
.iter()
.filter_map(|x| x.as_ref())
.map(|x| x.index)
.collect::<Vec<usize>>();
Ok((
TurnAction::PlayTiles {
result: ScoreResult { result: ScoreResult {
words, words,
total: total_score, total: total_score,
}, },
}, self.state.clone())) locations,
},
self.get_state(),
))
} }
pub fn exchange_tiles(&mut self, tray_tile_locations: Vec<bool>) -> Result<(Tray, TurnAction, GameState), String> { pub fn exchange_tiles(
&mut self,
tray_tile_locations: Vec<bool>,
) -> Result<(Tray, TurnAction, GameState), Error> {
self.verify_game_in_progress()?; self.verify_game_in_progress()?;
let player = self.current_player_name(); let player = self.current_player_name();
let tray = match self.player_states.get_tray_mut(&player) { let tray = match self.player_states.get_tray_mut(&player) {
None => {return Err(format!("Player {} not found", player))} None => return Err(Error::InvalidPlayer(player)),
Some(x) => {x} Some(x) => x,
}; };
if tray.letters.len() != tray_tile_locations.len() { if tray.letters.len() != tray_tile_locations.len() {
return Err("Incoming tray and existing tray have different lengths".to_string()); return Err(Error::Other(
"Incoming tray and existing tray have different lengths".to_string(),
));
} }
let tile_pool = &mut self.tile_pool; let tile_pool = &mut self.tile_pool;
@ -321,31 +406,33 @@ impl Game {
let state = self.increment_turn(false); let state = self.increment_turn(false);
Ok((tray, TurnAction::ExchangeTiles { tiles_exchanged }, state.clone())) Ok((
tray,
TurnAction::ExchangeTiles { tiles_exchanged },
state.clone(),
))
} }
pub fn add_word(&mut self, word: String) { pub fn add_word(&mut self, word: &str) {
let word = word.to_uppercase(); let word = word.to_uppercase();
self.dictionary.insert(word, -1.0); self.dictionary.insert(word, -1.0);
} }
pub fn pass(&mut self) -> Result<GameState, String> { pub fn pass(&mut self) -> Result<GameState, Error> {
self.verify_game_in_progress()?; self.verify_game_in_progress()?;
Ok(self.increment_turn(false).clone()) Ok(self.increment_turn(false).clone())
} }
fn increment_turn(&mut self, played: bool) -> &GameState{ fn increment_turn(&mut self, played: bool) -> &GameState {
self.turn_order += 1; self.turn_order += 1;
if !played { if !played {
self.turns_not_played += 1; self.turns_not_played += 1;
// check if game has ended due to passing // check if game has ended due to passing
if self.turns_not_played >= 2*self.player_states.0.len() { if self.turns_not_played >= 2 * self.player_states.0.len() {
self.end_game(None); self.end_game(None);
} }
} else { } else {
self.turns_not_played = 0; self.turns_not_played = 0;
} }
@ -354,7 +441,6 @@ impl Game {
} }
fn end_game(&mut self, finisher: Option<String>) { fn end_game(&mut self, finisher: Option<String>) {
let mut finished_letters_map = HashMap::new(); let mut finished_letters_map = HashMap::new();
let mut points_forfeit = 0; let mut points_forfeit = 0;
@ -376,25 +462,30 @@ impl Game {
} }
if let Some(finisher) = &finisher { if let Some(finisher) = &finisher {
let mut state = self.player_states.get_player_state_mut(finisher).unwrap(); let state = self.player_states.get_player_state_mut(finisher).unwrap();
state.score += points_forfeit; state.score += points_forfeit;
} }
self.state = GameState::Ended { self.state = GameState::Ended {
finisher, finisher,
remaining_tiles: finished_letters_map remaining_tiles: finished_letters_map,
}; };
} }
pub fn current_player_name(&self) -> String { pub fn current_player_name(&self) -> String {
self.player_states.get_player_name_by_turn_id(self.turn_order).to_string() self.player_states
.get_player_name_by_turn_id(self.turn_order)
.to_string()
} }
pub fn advance_turn(&mut self) -> Result<(TurnAdvanceResult, GameState), String> { pub fn advance_turn(&mut self) -> Result<(TurnAdvanceResult, GameState), Error> {
let current_player = self.current_player_name(); let current_player = self.current_player_name();
let state = self.player_states.get_player_state_mut(&current_player).ok_or("There should be a player available")?; let state = self
.player_states
.get_player_state_mut(&current_player)
.ok_or(Error::InvalidPlayer(current_player.clone()))?;
if let Player::AI {object, .. } = &mut state.player { if let Player::AI { object, .. } = &mut state.player {
let tray = &mut state.tray; let tray = &mut state.tray;
let best_move = object.find_best_move(tray, &self.board, &mut self.rng); let best_move = object.find_best_move(tray, &self.board, &mut self.rng);
@ -407,105 +498,123 @@ impl Game {
match tile_spot { match tile_spot {
None => { None => {
to_exchange.push(false); to_exchange.push(false);
}, }
Some(_) => { Some(_) => {
to_exchange.push(true); to_exchange.push(true);
} }
} }
} }
if self.tile_pool.is_empty(){ if self.tile_pool.is_empty() {
let game_state = self.increment_turn(false); let game_state = self.increment_turn(false);
Ok((TurnAdvanceResult::AIMove { Ok((
TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action: TurnAction::Pass, action: TurnAction::Pass,
}, game_state.clone())) },
game_state.clone(),
))
} else { } else {
let (_, action, game_state) = self.exchange_tiles(to_exchange)?; let (_, action, game_state) = self.exchange_tiles(to_exchange)?;
Ok((TurnAdvanceResult::AIMove { Ok((
TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action, action,
}, game_state)) },
game_state,
))
} }
} }
Some(best_move) => { Some(best_move) => {
let play = best_move.convert_to_play(tray); let play = best_move.convert_to_play(tray);
let (action, game_state) = self.receive_play(play, true)?; let (action, game_state) = self.receive_play(play, true)?;
Ok((TurnAdvanceResult::AIMove { Ok((
TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action, action,
}, game_state)) },
game_state,
))
} }
} }
} else { } else {
Ok((TurnAdvanceResult::HumanInputRequired{name: self.current_player_name()}, self.state.clone())) Ok((
TurnAdvanceResult::HumanInputRequired {
name: self.current_player_name(),
},
self.get_state(),
))
} }
} }
pub fn get_remaining_tiles(&self) -> usize { pub fn get_remaining_tiles(&self) -> usize {
self.tile_pool.len() self.tile_pool.len()
} }
pub fn get_player_tile_count(&self, player: &str) -> Result<usize, String> { pub fn get_number_turns(&self) -> usize {
let tray = match self.player_states.get_tray(&player) { self.turn_order
None => {return Err(format!("Player {} not found", player))}
Some(x) => {x}
};
Ok(
tray.count()
)
} }
pub fn get_player_tile_count(&self, player: &str) -> Result<usize, String> {
let tray = match self.player_states.get_tray(&player) {
None => return Err(format!("Player {} not found", player)),
Some(x) => x,
};
Ok(tray.count())
}
pub fn get_state(&self) -> GameState {
self.state.clone()
}
} }
#[derive(Serialize, Deserialize, Tsify, Debug)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[tsify(from_wasm_abi)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum TurnAction { pub enum TurnAction {
Pass, Pass,
ExchangeTiles{ ExchangeTiles {
tiles_exchanged: usize tiles_exchanged: usize,
}, },
PlayTiles{ PlayTiles {
result: ScoreResult result: ScoreResult,
locations: Vec<usize>,
},
AddToDictionary {
word: String,
}, },
} }
#[derive(Serialize, Deserialize, Tsify, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[tsify(from_wasm_abi)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum TurnAdvanceResult { pub enum TurnAdvanceResult {
HumanInputRequired{ HumanInputRequired { name: String },
name: String AIMove { name: String, action: TurnAction },
},
AIMove{
name: String,
action: TurnAction,
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs;
use crate::game::Game; use crate::game::Game;
use crate::player_interaction::ai::Difficulty; use crate::player_interaction::ai::Difficulty;
use std::fs;
#[test] #[test]
fn test_game() { fn test_game() {
let seed = 124; let seed = 124;
let dictionary_path = "resources/dictionary.csv"; let dictionary_path = "../resources/dictionary.csv";
let dictionary_string = fs::read_to_string(dictionary_path).unwrap(); let dictionary_string = fs::read_to_string(dictionary_path).unwrap();
let mut game = Game::new(seed, &dictionary_string, vec!["Player".to_string()], vec![Difficulty{proportion: 0.5, randomness: 0.0}]); let mut game = Game::new(
seed,
&dictionary_string,
vec!["Player".to_string()],
vec![Difficulty {
proportion: 0.5,
randomness: 0.0,
}],
);
let current_player = game.current_player_name(); let current_player = game.current_player_name();
println!("Current player is {current_player}"); println!("Current player is {current_player}");
@ -522,7 +631,5 @@ mod tests {
assert_eq!(game.current_player_name(), "Player"); assert_eq!(game.current_player_name(), "Player");
assert_eq!(0, game.turns_not_played); assert_eq!(0, game.turns_not_played);
} }
} }

6
wordgrid/src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod api;
pub mod board;
pub mod constants;
pub mod dictionary;
pub mod game;
pub mod player_interaction;

View file

@ -1,14 +1,11 @@
use serde::{Deserialize, Serialize};
use tsify::Tsify;
use crate::board::Letter; use crate::board::Letter;
use serde::{Deserialize, Serialize};
pub mod ai; pub mod ai;
#[derive(Debug, Serialize, Deserialize, Tsify, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[tsify(from_wasm_abi)]
pub struct Tray { pub struct Tray {
pub letters: Vec<Option<Letter>> pub letters: Vec<Option<Letter>>,
} }
impl Tray { impl Tray {
@ -17,9 +14,7 @@ impl Tray {
for _ in 0..tray_length { for _ in 0..tray_length {
letters.push(None); letters.push(None);
} }
Tray { Tray { letters }
letters
}
} }
pub fn fill(&mut self, standard_tile_pool: &mut Vec<Letter>) { pub fn fill(&mut self, standard_tile_pool: &mut Vec<Letter>) {
@ -37,25 +32,20 @@ impl Tray {
} }
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.letters.iter() self.letters.iter().filter(|l| l.is_some()).count()
.filter(|l| l.is_some())
.count()
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_tray() { fn test_tray() {
let mut letters = vec![ let mut letters = vec![
Letter::new(Some('E'), 3), Letter::new(Some('E'), 3),
Letter::new(Some('O'), 2), Letter::new(Some('O'), 2),
Letter::new(Some('J'), 1) Letter::new(Some('J'), 1),
]; ];
let mut tray = Tray::new(5); let mut tray = Tray::new(5);
@ -89,8 +79,5 @@ mod tests {
assert_eq!(tray.letters.get(2).unwrap().unwrap().text, 'E'); assert_eq!(tray.letters.get(2).unwrap().unwrap().text, 'E');
assert!(tray.letters.get(3).unwrap().is_none()); assert!(tray.letters.get(3).unwrap().is_none());
assert!(tray.letters.get(4).unwrap().is_none()); assert!(tray.letters.get(4).unwrap().is_none());
} }
} }

View file

@ -1,14 +1,16 @@
use std::collections::{HashMap, HashSet};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tsify::Tsify;
use crate::board::{Board, CellType, Coordinates, Direction, Letter}; use crate::board::{Board, CellType, Coordinates, Direction, Letter};
use crate::constants::GRID_LENGTH; use crate::constants::GRID_LENGTH;
use crate::dictionary::DictionaryImpl; use crate::dictionary::DictionaryImpl;
use crate::game::PlayedTile; use crate::game::PlayedTile;
use crate::player_interaction::Tray; use crate::player_interaction::Tray;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
const ALPHABET: [char; 26] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; const ALPHABET: [char; 26] = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
];
struct CoordinateLineMapper { struct CoordinateLineMapper {
direction: Direction, direction: Direction,
@ -18,18 +20,13 @@ struct CoordinateLineMapper {
impl CoordinateLineMapper { impl CoordinateLineMapper {
fn line_to_coord(&self, variable: u8) -> Coordinates { fn line_to_coord(&self, variable: u8) -> Coordinates {
match self.direction { match self.direction {
Direction::Row => { Direction::Row => Coordinates(variable, self.fixed),
Coordinates(variable, self.fixed) Direction::Column => Coordinates(self.fixed, variable),
}
Direction::Column => {
Coordinates(self.fixed, variable)
}
} }
} }
} }
#[derive(Copy, Clone, Serialize, Deserialize, Tsify)] #[derive(Copy, Clone, Serialize, Deserialize, Debug)]
#[tsify(from_wasm_abi)]
pub struct Difficulty { pub struct Difficulty {
pub proportion: f64, pub proportion: f64,
pub randomness: f64, pub randomness: f64,
@ -48,7 +45,6 @@ pub struct AI {
type MoveMap = HashMap<char, u32>; type MoveMap = HashMap<char, u32>;
type CrossTiles = Vec<Option<MoveMap>>; type CrossTiles = Vec<Option<MoveMap>>;
#[derive(Debug, Eq, PartialEq, Hash)] #[derive(Debug, Eq, PartialEq, Hash)]
pub struct CompleteMove { pub struct CompleteMove {
moves: Vec<MoveComponent>, moves: Vec<MoveComponent>,
@ -56,10 +52,11 @@ pub struct CompleteMove {
} }
impl CompleteMove { impl CompleteMove {
pub fn convert_to_play(&self, tray: &Tray) -> Vec<Option<PlayedTile>> { pub fn convert_to_play(&self, tray: &Tray) -> Vec<Option<PlayedTile>> {
let mut played_tiles = Vec::with_capacity(tray.letters.len()); let mut played_tiles = Vec::with_capacity(tray.letters.len());
let mut moves = self.moves.iter() let mut moves = self
.moves
.iter()
.map(|m| Some(m.clone())) .map(|m| Some(m.clone()))
.collect::<Vec<Option<MoveComponent>>>(); .collect::<Vec<Option<MoveComponent>>>();
@ -88,7 +85,7 @@ impl CompleteMove {
found_match = true; found_match = true;
break; break;
} }
}, }
None => {} None => {}
} }
} }
@ -127,29 +124,28 @@ struct MoveComponent {
} }
impl AI { impl AI {
pub fn new(difficulty: Difficulty, dictionary: &DictionaryImpl) -> Self {
pub fn new(difficulty: Difficulty, dictionary: &DictionaryImpl) -> Self{
let mut ai_dictionary = HashSet::new(); let mut ai_dictionary = HashSet::new();
let mut substrings = HashSet::new(); let mut substrings = HashSet::new();
for (word, score) in dictionary.iter() { for (word, score) in dictionary.iter() {
if *score >= difficulty.proportion { // TODO - may need to reverse if *score >= difficulty.proportion {
// TODO - may need to reverse
ai_dictionary.insert(word.clone()); ai_dictionary.insert(word.clone());
substrings.insert(word.clone()); substrings.insert(word.clone());
for i in 0..word.len() { for i in 0..word.len() {
for j in i+1..word.len() { for j in i + 1..word.len() {
substrings.insert(word[i..j].to_string()); substrings.insert(word[i..j].to_string());
} }
} }
} }
} }
let board_view = Board::new(); let board_view = Board::new();
let mut column_cross_tiles = CrossTiles::with_capacity((GRID_LENGTH * GRID_LENGTH) as usize); let mut column_cross_tiles =
CrossTiles::with_capacity((GRID_LENGTH * GRID_LENGTH) as usize);
let mut row_cross_tiles = CrossTiles::with_capacity((GRID_LENGTH * GRID_LENGTH) as usize); let mut row_cross_tiles = CrossTiles::with_capacity((GRID_LENGTH * GRID_LENGTH) as usize);
board_view.cells.iter().for_each(|_| { board_view.cells.iter().for_each(|_| {
@ -168,15 +164,20 @@ impl AI {
} }
} }
pub fn find_best_move<R: Rng>(&mut self, tray: &Tray, grid: &Board, rng: &mut R) -> Option<CompleteMove>{ pub fn find_best_move<R: Rng>(
&mut self,
tray: &Tray,
grid: &Board,
rng: &mut R,
) -> Option<CompleteMove> {
let move_set = self.find_all_moves(tray, grid); let move_set = self.find_all_moves(tray, grid);
let mut best_move: Option<(CompleteMove, f64)> = None; let mut best_move: Option<(CompleteMove, f64)> = None;
for possible_move in move_set { for possible_move in move_set {
let move_score = let move_score = if self.difficulty.randomness > 0.0 {
if self.difficulty.randomness > 0.0 { (1.0 - self.difficulty.randomness) * (possible_move.score as f64)
(1.0 - self.difficulty.randomness) * (possible_move.score as f64) + self.difficulty.randomness * rng.gen_range(0.0..1.0) + self.difficulty.randomness * rng.gen_range(0.0..1.0)
} else { } else {
possible_move.score as f64 possible_move.score as f64
}; };
@ -194,10 +195,9 @@ impl AI {
} }
return match best_move { return match best_move {
None => {None} None => None,
Some((best_move, _)) => {Some(best_move)} Some((best_move, _)) => Some(best_move),
}; };
} }
fn find_all_moves(&mut self, tray: &Tray, grid: &Board) -> HashSet<CompleteMove> { fn find_all_moves(&mut self, tray: &Tray, grid: &Board) -> HashSet<CompleteMove> {
@ -215,15 +215,17 @@ impl AI {
&self, &self,
tray: &Tray, tray: &Tray,
direction: Direction, direction: Direction,
all_moves: &mut HashSet<CompleteMove>) { all_moves: &mut HashSet<CompleteMove>,
) {
// If you're building a word in one direction, you need to form valid words in the cross-direction // If you're building a word in one direction, you need to form valid words in the cross-direction
let cross_tiles = match direction { let cross_tiles = match direction {
Direction::Column => {&self.row_cross_tiles} Direction::Column => &self.row_cross_tiles,
Direction::Row => {&self.column_cross_tiles} Direction::Row => &self.column_cross_tiles,
}; };
let tray_letters = tray.letters.iter() let tray_letters = tray
.letters
.iter()
.filter(|letter| letter.is_some()) .filter(|letter| letter.is_some())
.map(|letter| letter.unwrap()) .map(|letter| letter.unwrap())
.collect::<Vec<Letter>>(); .collect::<Vec<Letter>>();
@ -234,7 +236,7 @@ impl AI {
let coord_mapper = CoordinateLineMapper { let coord_mapper = CoordinateLineMapper {
direction, direction,
fixed: k fixed: k,
}; };
for p in 0..GRID_LENGTH { for p in 0..GRID_LENGTH {
@ -246,13 +248,12 @@ impl AI {
for l in 0..GRID_LENGTH { for l in 0..GRID_LENGTH {
let coords = coord_mapper.line_to_coord(l); let coords = coord_mapper.line_to_coord(l);
let is_anchored = check_if_anchored(&self.board_view, coords, Direction::Row) || let is_anchored = check_if_anchored(&self.board_view, coords, Direction::Row)
check_if_anchored(&self.board_view, coords, Direction::Column); || check_if_anchored(&self.board_view, coords, Direction::Column);
if is_anchored && if is_anchored && line_letters.get(l as usize).unwrap().is_none()
line_letters.get(l as usize).unwrap().is_none() // it's duplicate work to check here when we'll already check at either free side // it's duplicate work to check here when we'll already check at either free side
{ {
self.evaluate_spot_heading_left( self.evaluate_spot_heading_left(
&line_letters, &line_letters,
&line_cross_letters, &line_cross_letters,
@ -262,17 +263,11 @@ impl AI {
&Vec::new(), &Vec::new(),
1, 1,
&MoveScoring::new(), &MoveScoring::new(),
all_moves,
all_moves
); );
}
} }
} }
}
} }
fn evaluate_spot_heading_left( fn evaluate_spot_heading_left(
@ -287,10 +282,8 @@ impl AI {
min_length: usize, min_length: usize,
current_points: &MoveScoring, current_points: &MoveScoring,
all_moves: &mut HashSet<CompleteMove> all_moves: &mut HashSet<CompleteMove>,
) { ) {
if line_index < 0 || line_index >= GRID_LENGTH as i8 { if line_index < 0 || line_index >= GRID_LENGTH as i8 {
return; return;
} }
@ -300,10 +293,13 @@ impl AI {
Some(_) => { Some(_) => {
// there's a letter here; need to take a step left if we can // there's a letter here; need to take a step left if we can
if !(line_index >= 1 && if !(line_index >= 1
line_letters.get((line_index-1) as usize).unwrap().is_some() && && line_letters
min_length == 1 .get((line_index - 1) as usize)
) { .unwrap()
.is_some()
&& min_length == 1)
{
// if-statement is basically saying that if we're at the start of the process (min_length==1) and there's a word still to our left, // if-statement is basically saying that if we're at the start of the process (min_length==1) and there's a word still to our left,
// just stop. Other versions of the for-loops that call this function will have picked up that case. // just stop. Other versions of the for-loops that call this function will have picked up that case.
self.evaluate_spot_heading_left( self.evaluate_spot_heading_left(
@ -315,9 +311,8 @@ impl AI {
current_play, current_play,
min_length, min_length,
current_points, current_points,
all_moves all_moves,
); );
} }
} }
None => { None => {
@ -331,10 +326,9 @@ impl AI {
min_length, min_length,
current_points, current_points,
coord_mapper, coord_mapper,
&available_letters, &available_letters,
letter.clone(), letter.clone(),
all_moves all_moves,
); );
} }
@ -348,12 +342,10 @@ impl AI {
current_play, current_play,
min_length + 1, min_length + 1,
current_points, current_points,
all_moves all_moves,
); );
} }
} }
} }
fn evaluate_letter_at_spot( fn evaluate_letter_at_spot(
@ -366,13 +358,10 @@ impl AI {
current_points: &MoveScoring, current_points: &MoveScoring,
coord_mapper: &CoordinateLineMapper, coord_mapper: &CoordinateLineMapper,
available_letters: &Vec<Letter>, available_letters: &Vec<Letter>,
letter: Letter, letter: Letter,
all_moves: &mut HashSet<CompleteMove> all_moves: &mut HashSet<CompleteMove>,
) { ) {
if letter.is_blank { if letter.is_blank {
// need to loop through alphabet // need to loop through alphabet
for alpha in ALPHABET { for alpha in ALPHABET {
@ -389,8 +378,7 @@ impl AI {
coord_mapper, coord_mapper,
available_letters, available_letters,
letter, letter,
all_moves all_moves,
); );
} }
} else { } else {
@ -404,10 +392,9 @@ impl AI {
coord_mapper, coord_mapper,
available_letters, available_letters,
letter.clone(), letter.clone(),
all_moves all_moves,
); );
} }
} }
fn evaluate_non_blank_letter_at_spot( fn evaluate_non_blank_letter_at_spot(
@ -419,15 +406,12 @@ impl AI {
min_length: usize, min_length: usize,
current_points: &MoveScoring, current_points: &MoveScoring,
coord_mapper: &CoordinateLineMapper, coord_mapper: &CoordinateLineMapper,
available_letters: &Vec<Letter>, available_letters: &Vec<Letter>,
letter: Letter, letter: Letter,
all_moves: &mut HashSet<CompleteMove>, all_moves: &mut HashSet<CompleteMove>,
) { ) {
// let's now assign the letter to this spot // let's now assign the letter to this spot
let mut line_letters = line_letters.clone(); let mut line_letters = line_letters.clone();
*line_letters.get_mut(line_index as usize).unwrap() = Some(letter); *line_letters.get_mut(line_index as usize).unwrap() = Some(letter);
@ -458,7 +442,11 @@ impl AI {
// making copy // making copy
let mut current_points = current_points.clone(); let mut current_points = current_points.clone();
let cell_type = self.board_view.get_cell(coord_mapper.line_to_coord(line_index as u8)).unwrap().cell_type; let cell_type = self
.board_view
.get_cell(coord_mapper.line_to_coord(line_index as u8))
.unwrap()
.cell_type;
// first we score cross letters // first we score cross letters
if let Some(map) = cross_letters { if let Some(map) = cross_letters {
@ -508,7 +496,8 @@ impl AI {
current_points.main_scoring += letter.points * 2; current_points.main_scoring += letter.points * 2;
} }
CellType::TripleLetter => { CellType::TripleLetter => {
current_points.main_scoring += letter.points * 3;} current_points.main_scoring += letter.points * 3;
}
}; };
// finally, while we know that we're in a valid substring we should check if this is a valid word or not // finally, while we know that we're in a valid substring we should check if this is a valid word or not
@ -516,7 +505,8 @@ impl AI {
if word.len() >= min_length && self.dictionary.contains(&word) { if word.len() >= min_length && self.dictionary.contains(&word) {
let new_move = CompleteMove { let new_move = CompleteMove {
moves: current_play.clone(), moves: current_play.clone(),
score: current_points.cross_scoring + current_points.main_scoring*current_points.multiplier, score: current_points.cross_scoring
+ current_points.main_scoring * current_points.multiplier,
}; };
all_moves.insert(new_move); all_moves.insert(new_move);
} }
@ -525,8 +515,7 @@ impl AI {
let mut new_available_letters = Vec::with_capacity(available_letters.len() - 1); let mut new_available_letters = Vec::with_capacity(available_letters.len() - 1);
let mut skipped_one = false; let mut skipped_one = false;
available_letters.iter() available_letters.iter().for_each(|in_tray| {
.for_each(|in_tray| {
if skipped_one || !in_tray.partial_match(&letter) { if skipped_one || !in_tray.partial_match(&letter) {
new_available_letters.push(*in_tray); new_available_letters.push(*in_tray);
} else { } else {
@ -539,15 +528,13 @@ impl AI {
self.evaluate_spot_heading_right( self.evaluate_spot_heading_right(
&line_letters, &line_letters,
line_cross_letters, line_cross_letters,
line_index+1, line_index + 1,
current_play, current_play,
min_length, min_length,
&current_points, &current_points,
coord_mapper, coord_mapper,
&new_available_letters, &new_available_letters,
all_moves,
all_moves
); );
} }
@ -560,13 +547,11 @@ impl AI {
min_length: usize, min_length: usize,
current_points: &MoveScoring, current_points: &MoveScoring,
coord_mapper: &CoordinateLineMapper, coord_mapper: &CoordinateLineMapper,
available_letters: &Vec<Letter>, available_letters: &Vec<Letter>,
all_moves: &mut HashSet<CompleteMove>, all_moves: &mut HashSet<CompleteMove>,
) { ) {
// out-of-bounds check // out-of-bounds check
if line_index < 0 || line_index >= GRID_LENGTH as i8 { if line_index < 0 || line_index >= GRID_LENGTH as i8 {
return; return;
@ -586,7 +571,7 @@ impl AI {
&current_points, &current_points,
coord_mapper, coord_mapper,
available_letters, available_letters,
all_moves all_moves,
); );
} }
None => { None => {
@ -601,10 +586,8 @@ impl AI {
current_points, current_points,
coord_mapper, coord_mapper,
available_letters, available_letters,
letter.clone(), letter.clone(),
all_moves all_moves,
) )
} }
} }
@ -625,16 +608,20 @@ impl AI {
} }
let mut check_for_valid_moves = |coords: Coordinates| { let mut check_for_valid_moves = |coords: Coordinates| {
let cell = new.get_cell(coords).unwrap(); let cell = new.get_cell(coords).unwrap();
if cell.value.is_none() { if cell.value.is_none() {
let valid_row_moves = self.get_letters_that_make_words(new, Direction::Row, coords); let valid_row_moves = self.get_letters_that_make_words(new, Direction::Row, coords);
let valid_column_moves = self.get_letters_that_make_words(new, Direction::Column, coords); let valid_column_moves =
self.get_letters_that_make_words(new, Direction::Column, coords);
let existing_row_moves = self.row_cross_tiles.get_mut(coords.map_to_index()).unwrap(); let existing_row_moves =
self.row_cross_tiles.get_mut(coords.map_to_index()).unwrap();
*existing_row_moves = valid_row_moves; *existing_row_moves = valid_row_moves;
let existing_column_moves = self.column_cross_tiles.get_mut(coords.map_to_index()).unwrap(); let existing_column_moves = self
.column_cross_tiles
.get_mut(coords.map_to_index())
.unwrap();
*existing_column_moves = valid_column_moves; *existing_column_moves = valid_column_moves;
} }
}; };
@ -652,10 +639,14 @@ impl AI {
check_for_valid_moves(coords); check_for_valid_moves(coords);
} }
}); });
} }
fn get_letters_that_make_words(&self, new: &Board, direction: Direction, coords: Coordinates) -> Option<MoveMap> { fn get_letters_that_make_words(
&self,
new: &Board,
direction: Direction,
coords: Coordinates,
) -> Option<MoveMap> {
let is_anchored = check_if_anchored(new, coords, direction); let is_anchored = check_if_anchored(new, coords, direction);
if !is_anchored { if !is_anchored {
return None; return None;
@ -687,11 +678,9 @@ impl AI {
Some(new_map) Some(new_map)
} }
} }
fn check_if_anchored(grid: &Board, coords: Coordinates, direction: Direction) -> bool{ fn check_if_anchored(grid: &Board, coords: Coordinates, direction: Direction) -> bool {
// Middle tile is always considered anchored // Middle tile is always considered anchored
if coords.0 == GRID_LENGTH / 2 && coords.1 == GRID_LENGTH / 2 { if coords.0 == GRID_LENGTH / 2 && coords.1 == GRID_LENGTH / 2 {
return true; return true;
@ -699,7 +688,7 @@ fn check_if_anchored(grid: &Board, coords: Coordinates, direction: Direction) ->
let has_letter = |alt_coord: Option<Coordinates>| -> bool { let has_letter = |alt_coord: Option<Coordinates>| -> bool {
match alt_coord { match alt_coord {
None => {false} None => false,
Some(alt_coord) => { Some(alt_coord) => {
let cell = grid.get_cell(alt_coord).unwrap(); let cell = grid.get_cell(alt_coord).unwrap();
cell.value.is_some() cell.value.is_some()
@ -719,7 +708,11 @@ fn find_word_on_line(line_letters: &Vec<Option<Letter>>, line_index: i8) -> Stri
if start_word < 1 { if start_word < 1 {
break; break;
} }
if line_letters.get((start_word - 1) as usize).unwrap().is_none() { if line_letters
.get((start_word - 1) as usize)
.unwrap()
.is_none()
{
break; break;
} }
start_word -= 1; start_word -= 1;
@ -738,7 +731,8 @@ fn find_word_on_line(line_letters: &Vec<Option<Letter>>, line_index: i8) -> Stri
let mut str = String::new(); let mut str = String::new();
line_letters[(start_word as usize)..((end_word + 1) as usize)] line_letters[(start_word as usize)..((end_word + 1) as usize)]
.iter().for_each(|letter| { .iter()
.for_each(|letter| {
str.push(letter.unwrap().text); str.push(letter.unwrap().text);
}); });
@ -747,10 +741,10 @@ fn find_word_on_line(line_letters: &Vec<Option<Letter>>, line_index: i8) -> Stri
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use crate::dictionary::Dictionary;
use rand::rngs::SmallRng; use rand::rngs::SmallRng;
use rand::SeedableRng; use rand::SeedableRng;
use crate::dictionary::Dictionary;
use super::*;
fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) { fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) {
let cell = board.get_cell_mut(Coordinates(x, y)).unwrap(); let cell = board.get_cell_mut(Coordinates(x, y)).unwrap();
@ -793,31 +787,31 @@ mod tests {
dictionary.insert("APPLE".to_string(), 0.9); dictionary.insert("APPLE".to_string(), 0.9);
let mut tray = Tray::new(7); let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{ tray.letters[0] = Some(Letter {
text: 'A', text: 'A',
points: 5, points: 5,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[1] = Some(Letter{ tray.letters[1] = Some(Letter {
text: 'P', text: 'P',
points: 4, points: 4,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[2] = Some(Letter{ tray.letters[2] = Some(Letter {
text: 'P', text: 'P',
points: 4, points: 4,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[3] = Some(Letter{ tray.letters[3] = Some(Letter {
text: 'L', text: 'L',
points: 3, points: 3,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[4] = Some(Letter{ tray.letters[4] = Some(Letter {
text: 'E', text: 'E',
points: 4, points: 4,
ephemeral: false, ephemeral: false,
@ -832,14 +826,12 @@ mod tests {
assert_eq!(moves.len(), 10); assert_eq!(moves.len(), 10);
tray.letters[4] = Some( tray.letters[4] = Some(Letter {
Letter {
text: ' ', text: ' ',
points: 0, points: 0,
ephemeral: false, ephemeral: false,
is_blank: true, is_blank: true,
} });
);
let moves = ai.find_all_moves(&tray, &board); let moves = ai.find_all_moves(&tray, &board);
@ -857,8 +849,6 @@ mod tests {
set_cell(&mut board, 7, 9, 'A', 1); set_cell(&mut board, 7, 9, 'A', 1);
set_cell(&mut board, 7, 10, 'T', 1); set_cell(&mut board, 7, 10, 'T', 1);
let difficulty = Difficulty { let difficulty = Difficulty {
proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER
randomness: 0.0, randomness: 0.0,
@ -869,25 +859,25 @@ mod tests {
dictionary.insert("SLAM".to_string(), 0.9); dictionary.insert("SLAM".to_string(), 0.9);
let mut tray = Tray::new(7); let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{ tray.letters[0] = Some(Letter {
text: 'S', text: 'S',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[1] = Some(Letter{ tray.letters[1] = Some(Letter {
text: 'L', text: 'L',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[6] = Some(Letter{ tray.letters[6] = Some(Letter {
text: 'A', text: 'A',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[3] = Some(Letter{ tray.letters[3] = Some(Letter {
text: 'M', text: 'M',
points: 3, points: 3,
ephemeral: false, ephemeral: false,
@ -898,7 +888,10 @@ mod tests {
ai.update_state(&board); ai.update_state(&board);
let end_of_boat = ai.column_cross_tiles.get(Coordinates(7, 11).map_to_index()).unwrap(); let end_of_boat = ai
.column_cross_tiles
.get(Coordinates(7, 11).map_to_index())
.unwrap();
assert!(end_of_boat.is_some()); assert!(end_of_boat.is_some());
assert_eq!(end_of_boat.as_ref().unwrap().len(), 1); assert_eq!(end_of_boat.as_ref().unwrap().len(), 1);
@ -906,7 +899,6 @@ mod tests {
println!("Moves are {:?}", moves); println!("Moves are {:?}", moves);
// 3 possible moves - // 3 possible moves -
// 1. put 'S' at the end of 'BOAT' and form words 'SLAM' and 'BOATS' // 1. put 'S' at the end of 'BOAT' and form words 'SLAM' and 'BOATS'
// 2. Put 'S' at end of 'BOAT' // 2. Put 'S' at end of 'BOAT'
@ -920,7 +912,6 @@ mod tests {
let play = best_move.convert_to_play(&tray); let play = best_move.convert_to_play(&tray);
println!("Play is {:?}", play); println!("Play is {:?}", play);
} }
#[test] #[test]
@ -943,25 +934,30 @@ mod tests {
let above_cell_coords = Coordinates(7, 6); let above_cell_coords = Coordinates(7, 6);
let left_cell_cords = Coordinates(6, 7); let left_cell_cords = Coordinates(6, 7);
let row_cross_tiles = ai.row_cross_tiles.get(left_cell_cords.map_to_index()).unwrap(); let row_cross_tiles = ai
let column_cross_tiles = ai.column_cross_tiles.get(above_cell_coords.map_to_index()).unwrap(); .row_cross_tiles
.get(left_cell_cords.map_to_index())
.unwrap();
let column_cross_tiles = ai
.column_cross_tiles
.get(above_cell_coords.map_to_index())
.unwrap();
assert_eq!(row_cross_tiles.as_ref().unwrap().len(), 1); assert_eq!(row_cross_tiles.as_ref().unwrap().len(), 1);
assert_eq!(column_cross_tiles.as_ref().unwrap().len(), 1); assert_eq!(column_cross_tiles.as_ref().unwrap().len(), 1);
let far_off_tiles = ai.row_cross_tiles.get(0).unwrap(); let far_off_tiles = ai.row_cross_tiles.get(0).unwrap();
assert!(far_off_tiles.is_none()); assert!(far_off_tiles.is_none());
} }
#[test] #[test]
fn test_valid_moves() { fn test_valid_moves() {
let mut board = Board::new(); let mut board = Board::new();
set_cell(&mut board, 7-3, 7+3, 'Z', 1); set_cell(&mut board, 7 - 3, 7 + 3, 'Z', 1);
set_cell(&mut board, 6-3, 8+3, 'A', 1); set_cell(&mut board, 6 - 3, 8 + 3, 'A', 1);
set_cell(&mut board, 7-3, 8+3, 'A', 1); set_cell(&mut board, 7 - 3, 8 + 3, 'A', 1);
set_cell(&mut board, 7-3, 9+3, 'Z', 1); set_cell(&mut board, 7 - 3, 9 + 3, 'Z', 1);
let difficulty = Difficulty { let difficulty = Difficulty {
proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER
@ -972,7 +968,7 @@ mod tests {
dictionary.insert("AA".to_string(), 0.5); dictionary.insert("AA".to_string(), 0.5);
let mut tray = Tray::new(7); let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{ tray.letters[0] = Some(Letter {
text: 'A', text: 'A',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
@ -992,50 +988,52 @@ mod tests {
fn test_starting_move() { fn test_starting_move() {
let mut board = Board::new(); let mut board = Board::new();
let difficulty = Difficulty{proportion: 0.1, randomness: 0.0}; let difficulty = Difficulty {
proportion: 0.1,
randomness: 0.0,
};
let mut dictionary = DictionaryImpl::new(); let mut dictionary = DictionaryImpl::new();
dictionary.insert("TWEETED".to_string(), 0.2); dictionary.insert("TWEETED".to_string(), 0.2);
let mut tray = Tray::new(7); let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{ tray.letters[0] = Some(Letter {
text: 'I', text: 'I',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[1] = Some(Letter{ tray.letters[1] = Some(Letter {
text: 'R', text: 'R',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[2] = Some(Letter{ tray.letters[2] = Some(Letter {
text: 'W', text: 'W',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[3] = Some(Letter{ tray.letters[3] = Some(Letter {
text: 'I', text: 'I',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[4] = Some(Letter{ tray.letters[4] = Some(Letter {
text: 'T', text: 'T',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[5] = Some(Letter{ tray.letters[5] = Some(Letter {
text: 'E', text: 'E',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
is_blank: false, is_blank: false,
}); });
tray.letters[6] = Some(Letter{ tray.letters[6] = Some(Letter {
text: 'D', text: 'D',
points: 1, points: 1,
ephemeral: false, ephemeral: false,
@ -1048,7 +1046,5 @@ mod tests {
println!("Available moves are {:?}", all_moves); println!("Available moves are {:?}", all_moves);
assert!(all_moves.is_empty()); assert!(all_moves.is_empty());
} }
} }