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
.idea/
pkg/
.nvmrc

View file

@ -1,22 +1,8 @@
[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'."
[workspace]
members = ["wordgrid", "wasm", "server"]
resolver = "2"
[lib]
crate-type = ["cdylib"]
[dependencies]
csv = "1.2.2"
[workspace.dependencies]
serde_json = "1.0.132"
serde = { version = "1.0.213", features = ["derive"] }
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
RUBYTHROAT,0.0
RUBYTHROATS,0.0
RUC,-1.0
RUCHE,0.329741454406077
RUCHED,0.329741454406077
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": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"word_grid": "file:../pkg"
"word_grid": "file:../wasm/pkg"
},
"devDependencies": {
"@parcel/transformer-less": "^2.9.3",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
"parcel": "^2.9.3",
"parcel": "^2.12.0",
"process": "^0.11.10"
},
"scripts": {

View file

@ -1,16 +1,5 @@
import * as React from "react";
import {useEffect, useMemo, useReducer, useRef, useState} from "react";
import {
GameState,
GameWasm,
MyResult,
PlayedTile,
PlayerAndScore,
ScoreResult,
Tray,
TurnAction,
TurnAdvanceResult
} from "../../pkg/word_grid";
import {useEffect, useReducer, useRef, useState} from "react";
import {
Direction,
GRID_LENGTH,
@ -28,58 +17,75 @@ import {
} from "./utils";
import {TileExchangeModal} from "./TileExchange";
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) {
newItem = React.cloneElement(newItem, { key: existingLog.length })
newItem = React.cloneElement(newItem, {key: existingLog.length})
existingLog.push(newItem);
return existingLog.slice();
}
export function Game(props: {
wasm: GameWasm,
api: API,
settings: Settings,
end_game_fn: () => void,
}) {
const cellTypes = useMemo(() => {
return props.wasm.get_board_cell_types();
}, []);
const [isGameOver, setGameOver] = useState<boolean>(false);
const [api_state, setAPIState] = useState<APIState>(undefined);
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 newLetterData = [] as HighlightableLetterData[];
for(let i=0; i<GRID_LENGTH * GRID_LENGTH; i++) {
newLetterData.push(undefined);
for (let i = 0; i < GRID_LENGTH * GRID_LENGTH; i++) {
newLetterData.push(null);
}
return newLetterData;
});
function adjustGridArrow(existing: GridArrowData, update: GridArrowDispatchAction): GridArrowData {
console.log({update});
if(update.action == GridArrowDispatchActionType.CLEAR) {
if (update.action == GridArrowDispatchActionType.CLEAR) {
return null;
} else if (update.action == GridArrowDispatchActionType.CYCLE) {
// 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 {
direction: Direction.RIGHT, position: update.position
}
// if there's a right arrow, we shift to downwards
} else if(existing.direction == Direction.RIGHT) {
} else if (existing.direction == Direction.RIGHT) {
return {
direction: Direction.DOWN, position: existing.position
}
// if there's a down arrow, we clear it
} else if (existing.direction == Direction.DOWN){
} else if (existing.direction == Direction.DOWN) {
return null;
}
} else if (update.action == GridArrowDispatchActionType.SHIFT) {
if(existing == null) {
if (existing == null) {
// no arrow to shift
return null;
} else {
@ -88,7 +94,7 @@ export function Game(props: {
// we loop because we want to skip over letters that are already set
while (current_x < GRID_LENGTH && current_y < GRID_LENGTH) {
if(existing.direction == Direction.RIGHT) {
if (existing.direction == Direction.RIGHT) {
current_x += 1;
} else {
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 {
direction: existing.direction,
position: new_position,
@ -119,38 +125,36 @@ export function Game(props: {
}
function exchangeFunction(selectedArray: Array<boolean>) {
const result = props.api.exchange(selectedArray);
const result: MyResult<TurnAction | string> = props.wasm.exchange_tiles(selectedArray);
if(result.response_type === "ERR") {
logDispatch(<div><em>{(result.value as string)}</em></div>);
} else {
handlePlayerAction(result.value as TurnAction, props.settings.playerName);
setTurnCount(turnCount + 1);
if(result.game_state.type === "Ended") {
endGame(result.game_state);
}
}
result
.then(
(api_state) => {
setAPIState(api_state);
})
.catch((error) => {
console.error({error});
logDispatch(<div>{error}</div>);
});
}
function addWordFn(word: string) {
props.wasm.add_word(word);
}
const [gridArrow, gridArrowDispatch] = useReducer(adjustGridArrow, null);
const [logInfo, logDispatch] = useReducer(addLogInfo, []);
useEffect(() => {
props.api.load(false)
.then((api_state) => {
setAPIState(api_state);
});
}, []);
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 = [];
}
@ -160,13 +164,13 @@ export function Game(props: {
let startIndex = matchCoordinate(playerLetters, update.start);
let endIndex = matchCoordinate(playerLetters, update.end);
if(startIndex != null) {
if (startIndex != null) {
let startLetter = playerLetters[startIndex];
startLetter.location = update.end.location;
startLetter.index = update.end.index;
}
if(endIndex != null) {
if (endIndex != null) {
let endLetter = playerLetters[endIndex];
endLetter.location = update.start.location;
endLetter.index = update.start.index;
@ -178,7 +182,7 @@ export function Game(props: {
} else if (update.action === TileDispatchActionType.SET_BLANK) {
const blankLetter = playerLetters[update.blankIndex];
if(blankLetter.text !== update.newBlankValue) {
if (blankLetter.text !== update.newBlankValue) {
blankLetter.text = update.newBlankValue;
if (blankLetter.location == LocationType.GRID) {
setConfirmedScorePoints(-1);
@ -190,7 +194,7 @@ export function Game(props: {
return mergeTrays(playerLetters, playerLetters);
} else if (update.action === TileDispatchActionType.MOVE_TO_ARROW) {
// let's verify that the arrow is defined, otherwise do nothing
if(gridArrow != null) {
if (gridArrow != null) {
const end_position = {
location: LocationType.GRID,
index: gridArrow.position,
@ -215,35 +219,6 @@ export function Game(props: {
}
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 [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(false);
@ -255,65 +230,52 @@ export function Game(props: {
}
}, [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) {
if (action.type == "PlayTiles"){
if (action.type == "PlayTiles") {
const result = action.result;
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 a total of <strong>{result.total} points</strong> for their turn.</div>);
} else if(action.type == "ExchangeTiles") {
logDispatch(<div>{playerName} exchanged {action.tiles_exchanged} tile{action.tiles_exchanged > 1 ? 's' : ''} for their turn.</div>);
}
else if(action.type == "Pass"){
logDispatch(<div>{playerName} received a total of <strong>{result.total} points</strong> for their turn.
</div>);
} else if (action.type == "ExchangeTiles") {
logDispatch(
<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>);
} 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) {
if(state.type != "InProgress") {
setGameOver(true);
if (state.type != "InProgress") {
logDispatch(<h4>Scoring</h4>);
const scores = props.wasm.get_scores() as PlayerAndScore[];
const scores = api_state.public_information.players;
let pointsBonus = 0;
for(const playerAndScore of scores) {
for (const playerAndScore of scores) {
const name = playerAndScore.name;
if(name == state.finisher) {
if (name == state.finisher) {
// we'll do the finisher last
continue
}
const letters = state.remaining_tiles.get(name);
if(letters.length == 0) {
const letters = state.remaining_tiles[name];
if (letters.length == 0) {
logDispatch(<div>{name} has no remaining tiles.</div>);
} else {
let pointsLost = 0;
let letterListStr = '';
for(let i=0; i<letters.length; i++) {
for (let i = 0; i < letters.length; i++) {
const letter = letters[i];
const letterText = letter.is_blank ? 'a blank' : letter.text;
pointsLost += letter.points;
@ -321,13 +283,13 @@ export function Game(props: {
letterListStr += letterText;
// we're doing a list of 3 or more so add commas
if(letters.length > 2) {
if(i == letters.length - 2) {
if (letters.length > 2) {
if (i == letters.length - 2) {
letterListStr += ', and ';
} else if (i < letters.length - 2) {
letterListStr += ', ';
}
} else if (i == 0 && letters.length == 2){
} else if (i == 0 && letters.length == 2) {
// list of 2
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>);
}
@ -348,15 +310,14 @@ export function Game(props: {
.at(0);
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!";
} else if (playersAtHighest.length > 1 && state.finisher != null) {
// if there's a tie then the finisher gets the win
endGameMsg = `${playersAtHighest[0].name} won by finishing first!`;
}
else {
} else {
endGameMsg = `${playersAtHighest[0].name} won!`;
}
logDispatch(<h4>Game over - {endGameMsg}</h4>);
@ -367,34 +328,83 @@ export function Game(props: {
}
function runAI() {
const result: MyResult<TurnAdvanceResult> = props.wasm.advance_turn();
if(result.response_type === "OK" && result.value.type == "AIMove") {
handlePlayerAction(result.value.action, props.settings.aiName);
if(result.game_state.type === "Ended") {
endGame(result.game_state);
function updateBoardLetters(newLetters: Array<Letter>) {
const newLetterData = newLetters as HighlightableLetterData[];
for (let i = 0; i < newLetterData.length; i++) {
const newLetter = newLetterData[i];
if (newLetter != null) {
newLetter.highlight = false;
}
}
} else {
// this would be quite surprising
console.error({result});
// loop through the histories backwards until we reach our player
for (let j = api_state.public_information.history.length - 1; j >= 0; j--) {
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(() => {
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});
setConfirmedScorePoints(-1);
if(!isGameOver){
logDispatch(<h4>Turn {turnCount}</h4>);
logDispatch(<div>{playerTurnName}'s turn</div>);
if(playerTurnName != props.settings.playerName && !isGameOver) {
runAI();
updateBoardLetters(api_state.public_information.board);
}
}
}, [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 <>
<TileExchangeModal
@ -405,7 +415,7 @@ export function Game(props: {
/>
<div className="board-log">
<Grid
cellTypes={cellTypes}
cellTypes={api_state.public_information.cell_types}
playerLetters={playerLetters}
boardLetters={boardLetters}
tileDispatch={trayDispatch}
@ -431,32 +441,29 @@ export function Game(props: {
<div>
{remainingTiles} letters remaining
</div>
<div>
{props.settings.aiName} has {remainingAITiles} tiles
</div>
<button
disabled={remainingTiles == 0 || isGameOver}
disabled={remainingTiles == 0 || isGameOver || !isPlayersTurn}
onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
setIsTileExchangeOpen(true);
}}>Open Tile Exchange</button>
}}>Open Tile Exchange
</button>
</div>
<TileTray letters={playerLetters} trayLength={props.settings.trayLength} trayDispatch={trayDispatch}/>
<div className="player-controls">
<button
className="check"
disabled={isGameOver}
onClick={() => {
disabled={isGameOver || !isPlayersTurn}
onClick={async () => {
const playedTiles = playerLetters.map((i) => {
if (i === undefined) {
if (i == null) {
return null;
}
if (i.location === LocationType.GRID) {
let result: PlayedTile = {
index: i.index,
character: undefined
character: null
};
if (i.is_blank) {
result.character = i.text;
@ -468,38 +475,47 @@ export function Game(props: {
return null;
});
const result: MyResult<{ type: "PlayTiles"; result: ScoreResult } | string> = props.wasm.receive_play(playedTiles, confirmedScorePoints > -1);
console.log({result});
const committing = confirmedScorePoints > -1;
const result = props.api.play(playedTiles, committing);
if(result.response_type === "ERR") {
const message = result.value as string;
if (message.endsWith("is not a valid word")) {
// extract out word
const word = message.split(" ")[0];
logDispatch(<AddWordButton word={word} addWordFn={addWordFn} />);
} else {
logDispatch(<div><em>{message}</em></div>);
result
.then(
(api_state) => {
console.log("Testing45")
console.log({api_state});
const play_tiles: TurnAction = api_state.update.type;
if (play_tiles.type == "PlayTiles") {
setConfirmedScorePoints(play_tiles.result.total);
if (committing) {
setAPIState(api_state);
}
} else {
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);
console.error("Inaccessible branch!!!")
}
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>
<button
@ -507,31 +523,35 @@ export function Game(props: {
disabled={isGameOver}
onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN});
}}>Return Tiles</button>
}}>Return Tiles
</button>
<button
className="pass"
disabled={isGameOver}
disabled={isGameOver || !isPlayersTurn}
onClick={() => {
if (window.confirm("Are you sure you want to pass?")) {
const result = props.wasm.skip_turn() as MyResult<string>;
handlePlayerAction({type: "Pass"}, props.settings.playerName);
setTurnCount(turnCount + 1);
const result = props.api.pass();
if (result.game_state.type === "Ended") {
endGame(result.game_state);
result
.then(
(api_state) => {
setAPIState(api_state);
})
.catch((error) => {
console.error({error});
logDispatch(<div>{error}</div>);
});
}
}
}}>Pass</button>
}}>Pass
</button>
</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);
if (!isClicked) {
@ -549,7 +569,7 @@ function AddWordButton(props: {word: string, addWordFn: (x: string) => void}) {
</div>;
} else {
return <div>
<em>{props.word} was added to dictionary.</em>
<em>Adding {props.word} to dictionary.</em>
</div>;
}

View file

@ -1,8 +1,10 @@
import * as React from "react";
import {useState} from "react";
import {Difficulty, GameWasm} from '../../pkg/word_grid';
import {Settings} from "./utils";
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}) {
@ -22,46 +24,12 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
return <dialog open>
<div className="new-game">
<div className="grid">
<label htmlFor="proportion-dictionary">AI's proportion of dictionary:</label>
<input type="number"
name="proportion-dictionary"
value={proportionDictionary}
onInput={(e) => {
setProportionDictionary(parseInt(e.currentTarget.value));
}}
min={1}
max={100}/>
<label htmlFor="randomness">Level of randomness in AI:</label>
<input type="number"
name="randomness"
value={aiRandomness}
onInput={(e) => {
setAIRandomness(parseInt(e.currentTarget.value));
}}
min={0}
max={100}/>
</div>
<details>
<ul>
<li>
"AI's proportion of dictionary" controls what percent of the total AI dictionary
the AI can form words with. At 100%, it has access to its entire dictionary -
although this dictionary is still less than what the player has access to.</li>
<li>
<div>
"Level of randomness in AI" controls the degree to which the AI picks the optimal move
for each of its turns. At 0, it always picks the highest scoring move it can do using the
dictionary it has access to. At 1, it picks from its available moves at random.
</div>
<div>
Note that "Level of randomness in AI" is now mapped on a log scale.
Your current setting is equivalent to {(100*processedAIRandomness).toFixed(1)}% on the previous scale.
</div>
</li>
</ul>
</details>
<AISelection
aiRandomness={aiRandomness}
setAIRandomness={setAIRandomness}
proportionDictionary={proportionDictionary}
setProportionDictionary={setProportionDictionary}
/>
<div className="selection-buttons">
<button onClick={() => {
const seed = new Date().getTime();
@ -70,8 +38,8 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
proportion: processedProportionDictionary,
randomness: processedAIRandomness,
};
const game_wasm = new GameWasm(BigInt(seed), props.dictionary_text, difficulty);
const game = <Game settings={props.settings} wasm={game_wasm} key={seed} end_game_fn={() => setGame(null)}/>
const game_wasm: API = new WasmAPI(BigInt(seed), props.dictionary_text, difficulty);
const game = <Game settings={props.settings} api={game_wasm} key={seed} end_game_fn={() => setGame(null)}/>
setGame(game);
}}>New Game</button>
</div>

View file

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

View file

@ -1,8 +1,6 @@
import * as React from "react";
import {ChangeEvent, JSX} from "react";
import {PlayerAndScore} from "../../pkg/word_grid";
import {
CellType,
cellTypeToDetails,
CoordinateData,
GridArrowData,
@ -14,15 +12,16 @@ import {
TileDispatch,
TileDispatchActionType,
} from "./utils";
import {APIPlayer, CellType} from "./api";
export function TileSlot(props: {
tile?: React.JSX.Element | undefined,
tile?: React.JSX.Element | null,
location: CoordinateData,
tileDispatch: TileDispatch,
arrowDispatch?: GridArrowDispatch,
}): React.JSX.Element {
let isDraggable = props.tile !== undefined;
let isDraggable = props.tile != null;
function onDragStart(e: React.DragEvent<HTMLDivElement>) {
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 != '') {
onClick = () => {
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);
let tileElement: JSX.Element;
if (props.boardLetters[i] !== undefined) {
if (props.boardLetters[i] != null) {
tileElement = <Letter data={props.boardLetters[i]} />;
} else {
tileElement = <>
@ -234,11 +233,12 @@ export function Grid(props: {
</div>
}
export function Scores(props: {playerScores: Array<PlayerAndScore>}){
export function Scores(props: {playerScores: Array<APIPlayer>}){
let elements = props.playerScores.map((ps) => {
return <div key={ps.name}>
<h3>{ps.name}</h3>
<span>{ps.score}</span>
<div>{ps.score}</div>
<div>({ps.tray_tiles} tiles remaining)</div>
</div>;
});
@ -246,3 +246,61 @@ export function Scores(props: {playerScores: Array<PlayerAndScore>}){
{elements}
</div>
}
export function AISelection(props: {
aiRandomness: number,
setAIRandomness: (x: number) => void,
proportionDictionary: number,
setProportionDictionary: (x: number) => void,
}) {
// Can change log scale to control shape of curve using following equation:
// aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1
const logBase: number = 10000;
const processedAIRandomness = Math.log(1 + (logBase - 1)*props.aiRandomness/100) / Math.log(logBase);
//const processedProportionDictionary = 1.0 - props.proportionDictionary / 100;
return <>
<div className="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>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="style.less" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Word Grid</title>
</head>
<body>
<script src="index.tsx" type="module"></script>
<div id="root"></div>
</body>
</head>
<body>
<ul>
<li><a href="singleplayer.html">Singleplayer</a></li>
<li><a href="multiplayer.html">Multiplayer</a></li>
</ul>
</body>
</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 * as React from "react";
import {Menu} from "./Menu";
@ -50,8 +50,6 @@ async function run() {
root.render(<Menu dictionary_text={dictionary_text} settings={{
trayLength: 7,
playerName: 'Player',
aiName: 'AI',
}}/>);
}

View file

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

View file

@ -1,20 +1,10 @@
import {Letter as LetterData, Letter} from "../../pkg/word_grid";
import * as React from "react";
export enum CellType {
Normal = "Normal",
DoubleWord = "DoubleWord",
DoubleLetter = "DoubleLetter",
TripleLetter = "TripleLetter",
TripleWord = "TripleWord",
Start = "Start",
}
import {CellType, Letter as LetterData} from "./api";
export interface Settings {
trayLength: number;
playerName: string;
aiName: string;
}
export enum LocationType {
@ -68,7 +58,7 @@ export function matchCoordinate(playerLetters: PlayableLetterData[], coords: Coo
for (let i=0; i<playerLetters.length; 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;
}
}
@ -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);
@ -129,7 +119,7 @@ export function mergeTrays(existing: PlayableLetterData[], newer: (Letter | unde
}
existing.filter((x) => {
return x !== undefined && x !== null;
return x != null;
}).forEach((x) => {
if (x.location === LocationType.TRAY) {
freeSpots[x.index] = null;
@ -148,9 +138,9 @@ export function mergeTrays(existing: PlayableLetterData[], newer: (Letter | unde
}
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;
} else {
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::fmt;
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)]
pub enum Direction {
Row, Column
Row,
Column,
}
impl Direction {
pub fn invert(&self) -> Self {
match &self {
Direction::Row => {Direction::Column}
Direction::Column => {Direction::Row}
Direction::Row => Direction::Column,
Direction::Column => Direction::Row,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct Coordinates (pub u8, pub u8);
pub struct Coordinates(pub u8, pub u8);
impl Coordinates {
pub fn new_from_index(index: usize) -> Self {
let y = 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> {
let proposed = match direction {
Direction::Column => {(self.0 as i8, self.1 as i8+i)}
Direction::Row => {(self.0 as i8+i, self.1 as i8)}
Direction::Column => (self.0 as i8, self.1 as i8 + i),
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
} else{
} else {
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)
}
pub fn decrement(&self, direction: Direction) -> Option<Self>{
pub fn decrement(&self, direction: Direction) -> Option<Self> {
self.add(direction, -1)
}
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)]
#[tsify(from_wasm_abi)]
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Letter {
pub text: char,
pub points: u32,
@ -81,32 +83,27 @@ impl Letter {
pub fn new(text: Option<char>, points: u32) -> Letter {
match text {
None => {
Letter {
None => Letter {
text: ' ',
points,
ephemeral: true,
is_blank: true,
}
}
Some(text) => {
Letter {
},
Some(text) => Letter {
text,
points,
ephemeral: true,
is_blank: false,
}
}
},
}
}
pub fn partial_match(&self, other: &Letter) -> bool {
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 {
Normal,
DoubleWord,
@ -141,28 +138,25 @@ impl<'a> ToString for Word<'a> {
}
text
}
}
impl <'a> Word<'a> {
pub fn calculate_score(&self) -> u32{
impl<'a> Word<'a> {
pub fn calculate_score(&self) -> u32 {
let mut multiplier = 1;
let mut unmultiplied_score = 0;
for cell in self.cells.as_slice() {
let cell_value = cell.value.unwrap();
if cell_value.ephemeral {
let cell_multiplier =
match cell.cell_type {
CellType::Normal => {1}
let cell_multiplier = match cell.cell_type {
CellType::Normal => 1,
CellType::DoubleWord => {
multiplier *= 2;
1
}
CellType::DoubleLetter => {2}
CellType::TripleLetter => {3}
CellType::DoubleLetter => 2,
CellType::TripleLetter => 3,
CellType::TripleWord => {
multiplier *= 3;
1
@ -187,7 +181,6 @@ impl Board {
pub fn new() -> Self {
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
///
/// # Arguments
@ -200,7 +193,7 @@ impl Board {
GRID_LENGTH - x - 1
} else {
x
}
};
}
for i_orig in 0..GRID_LENGTH {
@ -221,12 +214,10 @@ impl Board {
}
// Double letters
if (i % 4 == 2) && (j % 4 == 2) && !(
i == 2 && j == 2
) {
if (i % 4 == 2) && (j % 4 == 2) && !(i == 2 && j == 2) {
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;
}
@ -245,11 +236,10 @@ impl Board {
value: None,
coordinates: Coordinates(j_orig, i_orig),
})
}
}
Board {cells}
Board { cells }
}
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 {
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 {
let index = coordinates.map_to_index();
Ok(self.cells.get_mut(index).unwrap())
}
}
pub fn calculate_scores(&self, dictionary: &DictionaryImpl) -> Result<(Vec<(Word, u32)>, u32), String> {
pub fn calculate_scores(
&self,
dictionary: &DictionaryImpl,
) -> Result<(Vec<(Word, u32)>, u32), Error> {
let (words, tiles_played) = self.find_played_words()?;
let mut words_and_scores = Vec::new();
let mut total_score = 0;
for word in words {
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();
@ -293,10 +287,9 @@ impl Board {
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
// Let's first establish what rows and columns tiles were played in
let mut rows_played = HashSet::with_capacity(15);
let mut columns_played = HashSet::with_capacity(15);
@ -320,9 +313,9 @@ impl Board {
}
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 {
return Err("Tiles need to be played on one row or column")
return Err(Error::TilesNotStraight);
}
let direction = if rows_played.len() > 1 {
@ -335,7 +328,9 @@ impl Board {
let starting_column = *columns_played.iter().min().unwrap();
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 observed_tiles_played = 0;
@ -359,25 +354,25 @@ impl Board {
// there are tiles not part of the main word
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
if main_word.cells.len() > 1 {
words.push(main_word);
} 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'
let mut anchored = false;
'outer: for word in words.as_slice() {
for cell in word.cells.as_slice() {
// 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;
break 'outer;
}
@ -387,23 +382,27 @@ impl Board {
if anchored {
Ok((words, tiles_played))
} 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 mut times_moved = 0;
loop {
let one_back = start_coords.add(direction, -times_moved);
match one_back {
None => { break }
None => break,
Some(new_coords) => {
let cell = self.get_cell(new_coords).unwrap();
if cell.value.is_some(){
if cell.value.is_some() {
times_moved += 1;
} else {
break
break;
}
}
}
@ -423,11 +422,11 @@ impl Board {
loop {
let position = start_coords.add(direction, cells.len() as i8);
match position {
None => {break}
None => break,
Some(x) => {
let cell = self.get_cell(x).unwrap();
match cell.value {
None => {break}
None => break,
Some(_) => {
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 {
{
let cell = match self.get_cell_mut(coords) {
Ok(cell) => {cell}
Err(e) => {return Err(e.to_string())}
};
let cell = self.get_cell_mut(coords)?;
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;
@ -476,7 +475,6 @@ impl Board {
impl fmt::Display for Board {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut str = String::new();
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 color = match cell.cell_type {
CellType::Normal => {normal}
CellType::DoubleWord => {double_word}
CellType::DoubleLetter => {double_letter}
CellType::TripleLetter => {triple_letter}
CellType::TripleWord => {triple_word}
CellType::Start => {double_word}
CellType::Normal => normal,
CellType::DoubleWord => double_word,
CellType::DoubleLetter => double_letter,
CellType::TripleLetter => triple_letter,
CellType::TripleWord => triple_word,
CellType::Start => double_word,
};
let content = match &cell.value {
None => {' '}
Some(letter) => {letter.text}
None => ' ',
Some(letter) => letter.text,
};
str.write_str(color).unwrap();
str.write_char(content).unwrap();
}
str.write_str("\x1b[0m\n").unwrap();
}
write!(f, "{}", str)
}
}
#[cfg(test)]
mod tests {
use crate::dictionary::Dictionary;
use super::*;
use crate::dictionary::Dictionary;
#[test]
fn test_cell_types() {
let board = Board::new();
assert!(matches!(board.get_cell(Coordinates(0, 0)).unwrap().cell_type, CellType::TripleWord));
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(0, 0)).unwrap().cell_type,
CellType::TripleWord
));
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!(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(13, 13)).unwrap().cell_type,
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!(board.get_cell(Coordinates(8, 6)).unwrap().cell_type, CellType::DoubleLetter));
assert!(matches!(board.get_cell(Coordinates(5, 9)).unwrap().cell_type, CellType::TripleLetter));
assert!(matches!(
board.get_cell(Coordinates(7, 7)).unwrap().cell_type,
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]
fn test_cell_coordinates() {
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(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(10, 8)).unwrap().value = Some(Letter::new_fixed('G', 0));
@ -584,7 +606,9 @@ mod tests {
println!("x is {}", x);
let first_word = board.find_word_at_position(Coordinates(8, x), Direction::Column);
match first_word {
None => { panic!("Expected to find word JOEL") }
None => {
panic!("Expected to find word JOEL")
}
Some(x) => {
assert_eq!(x.coords.0, 8);
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);
match single_letter_word {
None => { panic!("Expected to find letter L") }
None => {
panic!("Expected to find letter L")
}
Some(x) => {
assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 9);
@ -609,7 +635,9 @@ mod tests {
println!("x is {}", x);
let word = board.find_word_at_position(Coordinates(x, 0), Direction::Row);
match word {
None => { panic!("Expected to find word IS") }
None => {
panic!("Expected to find word IS")
}
Some(x) => {
assert_eq!(x.coords.0, 0);
assert_eq!(x.coords.1, 0);
@ -623,7 +651,9 @@ mod tests {
println!("x is {}", x);
let word = board.find_word_at_position(Coordinates(x, 0), Direction::Row);
match word {
None => { panic!("Expected to find word COOL") }
None => {
panic!("Expected to find word COOL")
}
Some(x) => {
assert_eq!(x.coords.0, 3);
assert_eq!(x.coords.1, 0);
@ -638,7 +668,9 @@ mod tests {
let word = board.find_word_at_position(Coordinates(10, 8), Direction::Row);
match word {
None => { panic!("Expected to find word EGG") }
None => {
panic!("Expected to find word EGG")
}
Some(x) => {
assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 8);
@ -659,10 +691,10 @@ mod tests {
is_blank: false,
});
match board.find_played_words() {
Ok(_) => {panic!("Expected error")}
Err(e) => {assert_eq!(e, "All words must be at least one letter");}
}
assert!(matches!(
board.find_played_words(),
Err(Error::OneLetterWord)
));
board.get_cell_mut(Coordinates(7, 7)).unwrap().value = Some(Letter {
text: 'I',
@ -726,10 +758,7 @@ mod tests {
board.get_cell_mut(Coordinates(8, 9)).unwrap().value = Some(make_letter('L', true));
let words = board.find_played_words();
match words {
Ok(_) => {panic!("Expected the not-anchored error")}
Err(x) => {assert_eq!(x, "Played tiles must be anchored to something")}
}
assert!(matches!(words, Err(Error::UnanchoredWord)));
// Adding anchor
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());
}
#[test]
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
@ -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, 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, 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, 12)).unwrap().value = Some(Letter::new_fixed('S', 0));
let words = board.find_played_words();
match words {
Ok(_) => {panic!("Expected to find an error!")}
Err(x) => {
assert_eq!(x, "Played tiles cannot have empty gap")
assert!(matches!(words, Err(Error::TilesHaveGap)));
}
}
}
#[test]
fn test_word_finding_whole_board() {
@ -795,15 +816,12 @@ mod tests {
}
let words = board.find_played_words();
match words {
Ok(_) => {panic!("Expected to find no words")}
Err(x) => {assert_eq!(x, "Tiles need to be played")}
}
assert!(matches!(words, Err(Error::NoTilesPlayed)));
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, 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(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));
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);
let words = board.find_played_words();
match words {
@ -827,7 +845,9 @@ mod tests {
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| {
@ -844,12 +864,21 @@ mod tests {
return direction;
};
board.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));
board
.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 {
None => {panic!("Expected to find word EGG")}
None => {
panic!("Expected to find word EGG")
}
Some(x) => {
assert_eq!(x.coords.0, 8);
assert_eq!(x.coords.1, 8);
@ -857,7 +886,6 @@ mod tests {
assert_eq!(x.to_string(), "EGG");
assert_eq!(x.calculate_score(), 2 + 2 + 2);
assert!(dictionary.is_word_valid(&x));
}
}
@ -876,20 +904,29 @@ mod tests {
assert_eq!(word.calculate_score(), 8 + 1 + 2 + 1);
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);
match scores {
Ok(_) => {panic!("Expected an error")}
Err(e) => {assert_eq!(e, "JOEL is not a valid word")}
Ok(_) => {
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();
alt_dictionary.insert("JOEL".to_string(), 0.5);
alt_dictionary.insert("EGG".to_string(), 0.5);
let scores = board.calculate_scores(&alt_dictionary);
match scores {
Ok((words, total_score)) => {
@ -906,18 +943,19 @@ mod tests {
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
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();
match words {
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") }
}
assert!(matches!(words, Err(Error::TilesNotStraight)));
}
// make a copy of the board now with x and y swapped
@ -934,7 +972,6 @@ mod tests {
cell_new.value = Some(*x);
}
}
}
}
@ -943,8 +980,5 @@ mod tests {
println!("Checking inverted board");
check_board(&mut inverted_board, true);
}
}

View file

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

View file

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

View file

@ -1,31 +1,90 @@
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::constants::{standard_tile_pool, TRAY_LENGTH};
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 rand::prelude::SliceRandom;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use serde::{Deserialize, Serialize, Serializer};
pub enum Player {
Human(String),
AI{
AI {
name: String,
difficulty: Difficulty,
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 {
pub fn get_name(&self) -> &str {
match &self {
Player::Human(name) => {name}
Player::AI { name, .. } => {name}
Player::Human(name) => name,
Player::AI { name, .. } => name,
}
}
}
@ -33,18 +92,20 @@ impl Player {
pub struct PlayerState {
pub player: Player,
pub score: u32,
pub tray: Tray
pub tray: Tray,
}
#[derive(Deserialize, Tsify, Copy, Clone, Debug)]
#[tsify(from_wasm_abi)]
#[derive(Deserialize, Copy, Clone, Debug)]
pub struct PlayedTile {
pub index: usize,
pub character: Option<char>, // we only set this if PlayedTile is a blank
}
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();
for (i, played_tile) in tray_tile_locations.iter().enumerate() {
if played_tile.is_some() {
@ -55,7 +116,9 @@ impl PlayedTile {
if letter.is_blank {
match played_tile.character {
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) => {
// TODO - check that x is a valid alphabet letter
@ -64,7 +127,6 @@ impl PlayedTile {
}
}
played_letters.push((letter, coord));
}
}
@ -72,15 +134,13 @@ impl PlayedTile {
}
}
#[derive(Debug, Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WordResult {
word: String,
score: u32,
}
#[derive(Debug, Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScoreResult {
words: Vec<WordResult>,
total: u32,
@ -88,7 +148,6 @@ pub struct ScoreResult {
pub struct PlayerStates(pub Vec<PlayerState>);
impl PlayerStates {
fn get_player_name_by_turn_id(&self, id: usize) -> &str {
let id_mod = id % self.0.len();
let state = self.0.get(id_mod).unwrap();
@ -97,37 +156,33 @@ impl PlayerStates {
}
pub fn get_player_state(&self, name: &str) -> Option<&PlayerState> {
self.0.iter()
self.0
.iter()
.filter(|state| state.player.get_name().eq(name))
.nth(0)
}
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))
.nth(0)
}
pub fn get_tray(&self, name: &str) -> Option<&Tray> {
let player = self.get_player_state(name)?;
Some(&player.tray)
}
pub fn get_tray_mut(&mut self, name: &str) -> Option<&mut Tray> {
let player = self.get_player_state_mut(name)?;
Some(&mut player.tray)
}
}
#[derive(Deserialize, Serialize, Tsify, Debug, Clone)]
#[tsify(from_wasm_abi)]
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum GameState {
InProgress,
@ -137,7 +192,7 @@ pub enum GameState {
},
}
pub struct Game{
pub struct Game {
pub tile_pool: Vec<Letter>,
rng: SmallRng,
board: Board,
@ -148,15 +203,17 @@ pub struct Game{
state: GameState,
}
impl Game {
pub fn new(seed: u64, dictionary_text: &str, player_names: Vec<String>, ai_difficulties: Vec<Difficulty>) -> Self {
let mut rng = SmallRng::seed_from_u64(seed);
pub fn new_specific(
mut rng: SmallRng,
dictionary: DictionaryImpl,
player_names: Vec<String>,
ai_difficulties: Vec<Difficulty>,
) -> Self {
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| {
let mut tray = Tray::new(TRAY_LENGTH);
tray.fill(&mut letters);
@ -174,7 +231,7 @@ impl Game {
let mut tray = Tray::new(TRAY_LENGTH);
tray.fill(&mut letters);
let ai_player_name = if ai_length > 1 {
format!("AI {}", i+1)
format!("AI {}", i + 1)
} else {
"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) {
self.board = new_board;
}
fn fill_trays(&mut self){
fn fill_trays(&mut self) {
for state in self.player_states.0.iter_mut() {
let tray = &mut state.tray;
tray.fill(&mut self.tile_pool);
@ -223,14 +292,18 @@ impl Game {
&self.dictionary
}
fn verify_game_in_progress(&self) -> Result<(), String> {
fn verify_game_in_progress(&self) -> Result<(), Error> {
if !matches!(self.state, GameState::InProgress) {
return Err("Moves cannot be made after a game has finished".to_string());
return Err(Error::GameFinished);
}
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()?;
let player = self.current_player_name();
@ -238,7 +311,8 @@ impl Game {
let mut board_instance = self.get_board().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() {
if played_tile.is_some() {
*tray.letters.get_mut(i).unwrap() = None;
@ -247,17 +321,14 @@ impl Game {
board_instance.receive_play(played_letters)?;
let x = board_instance.calculate_scores(self.get_dictionary())?;
let total_score = x.1;
let words: Vec<WordResult> = x.0.iter()
.map(|(word, score)| {
WordResult {
let words: Vec<WordResult> =
x.0.iter()
.map(|(word, score)| WordResult {
word: word.to_string(),
score: *score
}
score: *score,
})
.collect();
@ -276,28 +347,42 @@ impl Game {
// game is over
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 {
words,
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()?;
let player = self.current_player_name();
let tray = match self.player_states.get_tray_mut(&player) {
None => {return Err(format!("Player {} not found", player))}
Some(x) => {x}
None => return Err(Error::InvalidPlayer(player)),
Some(x) => x,
};
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;
@ -321,31 +406,33 @@ impl Game {
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();
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()?;
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;
if !played {
self.turns_not_played += 1;
// 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);
}
} else {
self.turns_not_played = 0;
}
@ -354,7 +441,6 @@ impl Game {
}
fn end_game(&mut self, finisher: Option<String>) {
let mut finished_letters_map = HashMap::new();
let mut points_forfeit = 0;
@ -376,25 +462,30 @@ impl Game {
}
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;
}
self.state = GameState::Ended {
finisher,
remaining_tiles: finished_letters_map
remaining_tiles: finished_letters_map,
};
}
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 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 best_move = object.find_best_move(tray, &self.board, &mut self.rng);
@ -407,105 +498,123 @@ impl Game {
match tile_spot {
None => {
to_exchange.push(false);
},
}
Some(_) => {
to_exchange.push(true);
}
}
}
if self.tile_pool.is_empty(){
if self.tile_pool.is_empty() {
let game_state = self.increment_turn(false);
Ok((TurnAdvanceResult::AIMove {
Ok((
TurnAdvanceResult::AIMove {
name: current_player,
action: TurnAction::Pass,
}, game_state.clone()))
},
game_state.clone(),
))
} else {
let (_, action, game_state) = self.exchange_tiles(to_exchange)?;
Ok((TurnAdvanceResult::AIMove {
Ok((
TurnAdvanceResult::AIMove {
name: current_player,
action,
}, game_state))
},
game_state,
))
}
}
Some(best_move) => {
let play = best_move.convert_to_play(tray);
let (action, game_state) = self.receive_play(play, true)?;
Ok((TurnAdvanceResult::AIMove {
Ok((
TurnAdvanceResult::AIMove {
name: current_player,
action,
}, game_state))
},
game_state,
))
}
}
} 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 {
self.tile_pool.len()
}
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_number_turns(&self) -> usize {
self.turn_order
}
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)]
#[tsify(from_wasm_abi)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum TurnAction {
Pass,
ExchangeTiles{
tiles_exchanged: usize
ExchangeTiles {
tiles_exchanged: usize,
},
PlayTiles{
result: ScoreResult
PlayTiles {
result: ScoreResult,
locations: Vec<usize>,
},
AddToDictionary {
word: String,
},
}
#[derive(Serialize, Deserialize, Tsify, Debug)]
#[tsify(from_wasm_abi)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum TurnAdvanceResult {
HumanInputRequired{
name: String
},
AIMove{
name: String,
action: TurnAction,
}
HumanInputRequired { name: String },
AIMove { name: String, action: TurnAction },
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::game::Game;
use crate::player_interaction::ai::Difficulty;
use std::fs;
#[test]
fn test_game() {
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 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();
println!("Current player is {current_player}");
@ -522,7 +631,5 @@ mod tests {
assert_eq!(game.current_player_name(), "Player");
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 serde::{Deserialize, Serialize};
pub mod ai;
#[derive(Debug, Serialize, Deserialize, Tsify, Clone)]
#[tsify(from_wasm_abi)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Tray {
pub letters: Vec<Option<Letter>>
pub letters: Vec<Option<Letter>>,
}
impl Tray {
@ -17,9 +14,7 @@ impl Tray {
for _ in 0..tray_length {
letters.push(None);
}
Tray {
letters
}
Tray { letters }
}
pub fn fill(&mut self, standard_tile_pool: &mut Vec<Letter>) {
@ -37,25 +32,20 @@ impl Tray {
}
pub fn count(&self) -> usize {
self.letters.iter()
.filter(|l| l.is_some())
.count()
self.letters.iter().filter(|l| l.is_some()).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tray() {
let mut letters = vec![
Letter::new(Some('E'), 3),
Letter::new(Some('O'), 2),
Letter::new(Some('J'), 1)
Letter::new(Some('J'), 1),
];
let mut tray = Tray::new(5);
@ -89,8 +79,5 @@ mod tests {
assert_eq!(tray.letters.get(2).unwrap().unwrap().text, 'E');
assert!(tray.letters.get(3).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::constants::GRID_LENGTH;
use crate::dictionary::DictionaryImpl;
use crate::game::PlayedTile;
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 {
direction: Direction,
@ -18,18 +20,13 @@ struct CoordinateLineMapper {
impl CoordinateLineMapper {
fn line_to_coord(&self, variable: u8) -> Coordinates {
match self.direction {
Direction::Row => {
Coordinates(variable, self.fixed)
}
Direction::Column => {
Coordinates(self.fixed, variable)
}
Direction::Row => Coordinates(variable, self.fixed),
Direction::Column => Coordinates(self.fixed, variable),
}
}
}
#[derive(Copy, Clone, Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)]
#[derive(Copy, Clone, Serialize, Deserialize, Debug)]
pub struct Difficulty {
pub proportion: f64,
pub randomness: f64,
@ -48,7 +45,6 @@ pub struct AI {
type MoveMap = HashMap<char, u32>;
type CrossTiles = Vec<Option<MoveMap>>;
#[derive(Debug, Eq, PartialEq, Hash)]
pub struct CompleteMove {
moves: Vec<MoveComponent>,
@ -56,10 +52,11 @@ pub struct CompleteMove {
}
impl CompleteMove {
pub fn convert_to_play(&self, tray: &Tray) -> Vec<Option<PlayedTile>> {
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()))
.collect::<Vec<Option<MoveComponent>>>();
@ -88,7 +85,7 @@ impl CompleteMove {
found_match = true;
break;
}
},
}
None => {}
}
}
@ -127,29 +124,28 @@ struct MoveComponent {
}
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 substrings = HashSet::new();
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());
substrings.insert(word.clone());
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());
}
}
}
}
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);
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 mut best_move: Option<(CompleteMove, f64)> = None;
for possible_move in move_set {
let move_score =
if self.difficulty.randomness > 0.0 {
(1.0 - self.difficulty.randomness) * (possible_move.score as f64) + self.difficulty.randomness * rng.gen_range(0.0..1.0)
let move_score = if self.difficulty.randomness > 0.0 {
(1.0 - self.difficulty.randomness) * (possible_move.score as f64)
+ self.difficulty.randomness * rng.gen_range(0.0..1.0)
} else {
possible_move.score as f64
};
@ -194,10 +195,9 @@ impl AI {
}
return match best_move {
None => {None}
Some((best_move, _)) => {Some(best_move)}
None => None,
Some((best_move, _)) => Some(best_move),
};
}
fn find_all_moves(&mut self, tray: &Tray, grid: &Board) -> HashSet<CompleteMove> {
@ -215,15 +215,17 @@ impl AI {
&self,
tray: &Tray,
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
let cross_tiles = match direction {
Direction::Column => {&self.row_cross_tiles}
Direction::Row => {&self.column_cross_tiles}
Direction::Column => &self.row_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())
.map(|letter| letter.unwrap())
.collect::<Vec<Letter>>();
@ -234,7 +236,7 @@ impl AI {
let coord_mapper = CoordinateLineMapper {
direction,
fixed: k
fixed: k,
};
for p in 0..GRID_LENGTH {
@ -246,13 +248,12 @@ impl AI {
for l in 0..GRID_LENGTH {
let coords = coord_mapper.line_to_coord(l);
let is_anchored = check_if_anchored(&self.board_view, coords, Direction::Row) ||
check_if_anchored(&self.board_view, coords, Direction::Column);
let is_anchored = check_if_anchored(&self.board_view, coords, Direction::Row)
|| check_if_anchored(&self.board_view, coords, Direction::Column);
if is_anchored &&
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
if is_anchored && 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
{
self.evaluate_spot_heading_left(
&line_letters,
&line_cross_letters,
@ -262,17 +263,11 @@ impl AI {
&Vec::new(),
1,
&MoveScoring::new(),
all_moves
all_moves,
);
}
}
}
}
}
fn evaluate_spot_heading_left(
@ -287,10 +282,8 @@ impl AI {
min_length: usize,
current_points: &MoveScoring,
all_moves: &mut HashSet<CompleteMove>
all_moves: &mut HashSet<CompleteMove>,
) {
if line_index < 0 || line_index >= GRID_LENGTH as i8 {
return;
}
@ -300,10 +293,13 @@ impl AI {
Some(_) => {
// there's a letter here; need to take a step left if we can
if !(line_index >= 1 &&
line_letters.get((line_index-1) as usize).unwrap().is_some() &&
min_length == 1
) {
if !(line_index >= 1
&& line_letters
.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,
// just stop. Other versions of the for-loops that call this function will have picked up that case.
self.evaluate_spot_heading_left(
@ -315,9 +311,8 @@ impl AI {
current_play,
min_length,
current_points,
all_moves
all_moves,
);
}
}
None => {
@ -331,10 +326,9 @@ impl AI {
min_length,
current_points,
coord_mapper,
&available_letters,
letter.clone(),
all_moves
all_moves,
);
}
@ -348,12 +342,10 @@ impl AI {
current_play,
min_length + 1,
current_points,
all_moves
all_moves,
);
}
}
}
fn evaluate_letter_at_spot(
@ -366,13 +358,10 @@ impl AI {
current_points: &MoveScoring,
coord_mapper: &CoordinateLineMapper,
available_letters: &Vec<Letter>,
letter: Letter,
all_moves: &mut HashSet<CompleteMove>
all_moves: &mut HashSet<CompleteMove>,
) {
if letter.is_blank {
// need to loop through alphabet
for alpha in ALPHABET {
@ -389,8 +378,7 @@ impl AI {
coord_mapper,
available_letters,
letter,
all_moves
all_moves,
);
}
} else {
@ -404,10 +392,9 @@ impl AI {
coord_mapper,
available_letters,
letter.clone(),
all_moves
all_moves,
);
}
}
fn evaluate_non_blank_letter_at_spot(
@ -419,15 +406,12 @@ impl AI {
min_length: usize,
current_points: &MoveScoring,
coord_mapper: &CoordinateLineMapper,
available_letters: &Vec<Letter>,
letter: Letter,
all_moves: &mut HashSet<CompleteMove>,
) {
// let's now assign the letter to this spot
let mut line_letters = line_letters.clone();
*line_letters.get_mut(line_index as usize).unwrap() = Some(letter);
@ -458,7 +442,11 @@ impl AI {
// making copy
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
if let Some(map) = cross_letters {
@ -508,7 +496,8 @@ impl AI {
current_points.main_scoring += letter.points * 2;
}
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
@ -516,7 +505,8 @@ impl AI {
if word.len() >= min_length && self.dictionary.contains(&word) {
let new_move = CompleteMove {
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);
}
@ -525,8 +515,7 @@ impl AI {
let mut new_available_letters = Vec::with_capacity(available_letters.len() - 1);
let mut skipped_one = false;
available_letters.iter()
.for_each(|in_tray| {
available_letters.iter().for_each(|in_tray| {
if skipped_one || !in_tray.partial_match(&letter) {
new_available_letters.push(*in_tray);
} else {
@ -539,15 +528,13 @@ impl AI {
self.evaluate_spot_heading_right(
&line_letters,
line_cross_letters,
line_index+1,
line_index + 1,
current_play,
min_length,
&current_points,
coord_mapper,
&new_available_letters,
all_moves
all_moves,
);
}
@ -560,13 +547,11 @@ impl AI {
min_length: usize,
current_points: &MoveScoring,
coord_mapper: &CoordinateLineMapper,
available_letters: &Vec<Letter>,
all_moves: &mut HashSet<CompleteMove>,
) {
// out-of-bounds check
if line_index < 0 || line_index >= GRID_LENGTH as i8 {
return;
@ -586,7 +571,7 @@ impl AI {
&current_points,
coord_mapper,
available_letters,
all_moves
all_moves,
);
}
None => {
@ -601,10 +586,8 @@ impl AI {
current_points,
coord_mapper,
available_letters,
letter.clone(),
all_moves
all_moves,
)
}
}
@ -625,16 +608,20 @@ impl AI {
}
let mut check_for_valid_moves = |coords: Coordinates| {
let cell = new.get_cell(coords).unwrap();
if cell.value.is_none() {
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;
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;
}
};
@ -652,10 +639,14 @@ impl AI {
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);
if !is_anchored {
return None;
@ -687,11 +678,9 @@ impl AI {
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
if coords.0 == GRID_LENGTH / 2 && coords.1 == GRID_LENGTH / 2 {
return true;
@ -699,7 +688,7 @@ fn check_if_anchored(grid: &Board, coords: Coordinates, direction: Direction) ->
let has_letter = |alt_coord: Option<Coordinates>| -> bool {
match alt_coord {
None => {false}
None => false,
Some(alt_coord) => {
let cell = grid.get_cell(alt_coord).unwrap();
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 {
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;
}
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();
line_letters[(start_word as usize)..((end_word + 1) as usize)]
.iter().for_each(|letter| {
.iter()
.for_each(|letter| {
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)]
mod tests {
use super::*;
use crate::dictionary::Dictionary;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use crate::dictionary::Dictionary;
use super::*;
fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) {
let cell = board.get_cell_mut(Coordinates(x, y)).unwrap();
@ -793,31 +787,31 @@ mod tests {
dictionary.insert("APPLE".to_string(), 0.9);
let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{
tray.letters[0] = Some(Letter {
text: 'A',
points: 5,
ephemeral: false,
is_blank: false,
});
tray.letters[1] = Some(Letter{
tray.letters[1] = Some(Letter {
text: 'P',
points: 4,
ephemeral: false,
is_blank: false,
});
tray.letters[2] = Some(Letter{
tray.letters[2] = Some(Letter {
text: 'P',
points: 4,
ephemeral: false,
is_blank: false,
});
tray.letters[3] = Some(Letter{
tray.letters[3] = Some(Letter {
text: 'L',
points: 3,
ephemeral: false,
is_blank: false,
});
tray.letters[4] = Some(Letter{
tray.letters[4] = Some(Letter {
text: 'E',
points: 4,
ephemeral: false,
@ -832,14 +826,12 @@ mod tests {
assert_eq!(moves.len(), 10);
tray.letters[4] = Some(
Letter {
tray.letters[4] = Some(Letter {
text: ' ',
points: 0,
ephemeral: false,
is_blank: true,
}
);
});
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, 10, 'T', 1);
let difficulty = Difficulty {
proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER
randomness: 0.0,
@ -869,25 +859,25 @@ mod tests {
dictionary.insert("SLAM".to_string(), 0.9);
let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{
tray.letters[0] = Some(Letter {
text: 'S',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[1] = Some(Letter{
tray.letters[1] = Some(Letter {
text: 'L',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[6] = Some(Letter{
tray.letters[6] = Some(Letter {
text: 'A',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[3] = Some(Letter{
tray.letters[3] = Some(Letter {
text: 'M',
points: 3,
ephemeral: false,
@ -898,7 +888,10 @@ mod tests {
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_eq!(end_of_boat.as_ref().unwrap().len(), 1);
@ -906,7 +899,6 @@ mod tests {
println!("Moves are {:?}", moves);
// 3 possible moves -
// 1. put 'S' at the end of 'BOAT' and form words 'SLAM' and 'BOATS'
// 2. Put 'S' at end of 'BOAT'
@ -920,7 +912,6 @@ mod tests {
let play = best_move.convert_to_play(&tray);
println!("Play is {:?}", play);
}
#[test]
@ -943,25 +934,30 @@ mod tests {
let above_cell_coords = Coordinates(7, 6);
let left_cell_cords = Coordinates(6, 7);
let row_cross_tiles = ai.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();
let row_cross_tiles = ai
.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!(column_cross_tiles.as_ref().unwrap().len(), 1);
let far_off_tiles = ai.row_cross_tiles.get(0).unwrap();
assert!(far_off_tiles.is_none());
}
#[test]
fn test_valid_moves() {
let mut board = Board::new();
set_cell(&mut board, 7-3, 7+3, 'Z', 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, 9+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, 7 - 3, 8 + 3, 'A', 1);
set_cell(&mut board, 7 - 3, 9 + 3, 'Z', 1);
let difficulty = Difficulty {
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);
let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{
tray.letters[0] = Some(Letter {
text: 'A',
points: 1,
ephemeral: false,
@ -992,50 +988,52 @@ mod tests {
fn test_starting_move() {
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();
dictionary.insert("TWEETED".to_string(), 0.2);
let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{
tray.letters[0] = Some(Letter {
text: 'I',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[1] = Some(Letter{
tray.letters[1] = Some(Letter {
text: 'R',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[2] = Some(Letter{
tray.letters[2] = Some(Letter {
text: 'W',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[3] = Some(Letter{
tray.letters[3] = Some(Letter {
text: 'I',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[4] = Some(Letter{
tray.letters[4] = Some(Letter {
text: 'T',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[5] = Some(Letter{
tray.letters[5] = Some(Letter {
text: 'E',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[6] = Some(Letter{
tray.letters[6] = Some(Letter {
text: 'D',
points: 1,
ephemeral: false,
@ -1048,7 +1046,5 @@ mod tests {
println!("Available moves are {:?}", all_moves);
assert!(all_moves.is_empty());
}
}