Multiplayer #1

Merged
joel merged 19 commits from multiplayer into main 2024-12-26 18:38:24 +00:00
13 changed files with 115 additions and 148 deletions
Showing only changes of commit f0552fee32 - Show all commits

View file

@ -13,7 +13,7 @@ use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Weak};
use tokio::select;
use uuid::Uuid;
use word_grid::api::{APIGame, ApiState};
use word_grid::api::{APIGame, ApiState, Update};
use word_grid::dictionary::{Dictionary, DictionaryImpl};
use word_grid::game::{Error, Game, PlayedTile};
use word_grid::player_interaction::ai::Difficulty;
@ -73,7 +73,6 @@ enum RoomEvent {
#[serde(tag = "type")]
enum GameEvent {
TurnAction { state: ApiState, committed: bool },
WordAdded { word: String, player: Player },
}
#[derive(Clone, Serialize, Debug)]
@ -81,7 +80,6 @@ enum GameEvent {
enum ServerToClientMessage {
RoomChange { event: RoomEvent, info: PartyInfo },
GameEvent { event: GameEvent },
WordAdded { word: String },
GameError { error: Error },
Invalid { reason: String },
}
@ -115,7 +113,7 @@ enum ClientToServerMessage {
#[derive(Clone, Debug)]
enum InnerRoomMessage {
PassThrough(ServerToClientMessage),
GameEvent,
GameEvent(Option<Update>),
}
type RoomMap = HashMap<String, Weak<RwLock<Room>>>;
@ -134,6 +132,10 @@ async fn incoming_message_handler<E: std::fmt::Display>(
Some(message) => {
match message {
Ok(message) => {
if let Message::Ping(_) = message {
println!("Received ping from player {player:#?}");
return false;
}
let message = message.to_text().unwrap();
if message.len() == 0 {
println!("Websocket closed");
@ -173,7 +175,7 @@ async fn incoming_message_handler<E: std::fmt::Display>(
println!("Received {message:#?} from client {}", player.id);
match message {
ClientToServerMessage::Load => {
return !game_load(player, room, stream).await
return !game_load(player, None, room, stream).await
}
ClientToServerMessage::StartGame => {
let mut room = room.write().await;
@ -200,7 +202,7 @@ async fn incoming_message_handler<E: std::fmt::Display>(
let game = APIGame::new(game);
room.game = Some(game);
sender.send(InnerRoomMessage::GameEvent).unwrap();
sender.send(InnerRoomMessage::GameEvent(None)).unwrap();
}
}
ClientToServerMessage::GameMove { r#move } => {
@ -242,8 +244,10 @@ async fn incoming_message_handler<E: std::fmt::Display>(
}
};
match result {
Ok(_) => {
sender.send(InnerRoomMessage::GameEvent).unwrap();
Ok(event) => {
sender
.send(InnerRoomMessage::GameEvent(event.update))
.unwrap();
}
Err(error) => {
let event = ServerToClientMessage::GameError { error };
@ -292,11 +296,17 @@ async fn incoming_message_handler<E: std::fmt::Display>(
false
}
async fn game_load(player: &Player, room: &Arc<RwLock<Room>>, stream: &mut DuplexStream) -> bool {
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 state = room.game.as_mut().unwrap().load(&player.name).unwrap();
let mut state = room.game.as_mut().unwrap().load(&player.name).unwrap();
state.update = update;
let event = ServerToClientMessage::GameEvent {
event: GameEvent::TurnAction {
state,
@ -325,7 +335,7 @@ async fn outgoing_message_handler<E: std::fmt::Debug>(
let x = stream.send(text.into()).await;
x.is_err()
}
InnerRoomMessage::GameEvent => !game_load(player, room, stream).await,
InnerRoomMessage::GameEvent(update) => !game_load(player, update, room, stream).await,
};
}

View file

@ -32,7 +32,6 @@ export function Game(props: {
}) {
const [api_state, setAPIState] = useState<APIState>(undefined);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1);
const currentTurnNumber = useRef<number>(-1);
const historyProcessedNumber = useRef<number>(0);
@ -44,15 +43,11 @@ export function Game(props: {
function waitForUpdate() {
// setAPIState(undefined);
// setIsLoading(true);
setIsLoading(true);
const result = props.api.load(true);
result.then(
(state) => {
setAPIState(state);
setIsLoading(false);
}
)
.catch((error) => {
@ -136,7 +131,6 @@ export function Game(props: {
.then(
(api_state) => {
setAPIState(api_state);
waitForUpdate();
})
.catch((error) => {
console.error({error});
@ -149,14 +143,11 @@ export function Game(props: {
const [logInfo, logDispatch] = useReducer(addLogInfo, []);
useEffect(() => {
props.api.register_log_dispatch(logDispatch);
props.api.load(false)
.then((api_state) => {
setAPIState(api_state);
setIsLoading(false);
});
}, [])
}, []);
function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) {
if (update.action === TileDispatchActionType.RETRIEVE) {
@ -255,10 +246,12 @@ export function Game(props: {
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) {
@ -363,18 +356,24 @@ export function Game(props: {
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);
gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR});
trayDispatch({action: TileDispatchActionType.RETRIEVE});
setConfirmedScorePoints(-1);
updateBoardLetters(api_state.public_information.board);
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);
updateBoardLetters(api_state.public_information.board);
}
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>TTurn {update.turn_number}</h4>);
const playerAtTurn = api_state.public_information.players[(update.turn_number-1) % api_state.public_information.players.length].name;
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>);
}
@ -384,9 +383,7 @@ export function Game(props: {
historyProcessedNumber.current = api_state.public_information.history.length;
if (!isGameOver) {
console.log("In state: ", api_state.public_information.current_turn_number);
console.log("In ref: ", currentTurnNumber.current);
if(api_state.public_information.current_turn_number >= currentTurnNumber.current){
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;
@ -395,24 +392,19 @@ export function Game(props: {
endGame(api_state.public_information.game_state);
}
if(api_state.public_information.current_player != props.settings.playerName) {
waitForUpdate();
}
}
}, [api_state]);
if (isLoading) {
if(api_state == null){
return <div>Still loading</div>;
}
const playerAndScores = api_state.public_information.players;
const remainingTiles = api_state.public_information.remaining_tiles;
let remainingAITiles = null;
for (let player of playerAndScores) {
if (player.name == 'AI') {
remainingAITiles = player.tray_tiles;
break;
}
}
const isPlayersTurn = api_state.public_information.current_player == props.settings.playerName;
return <>
<TileExchangeModal
@ -449,11 +441,8 @@ 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);
@ -464,7 +453,7 @@ export function Game(props: {
<div className="player-controls">
<button
className="check"
disabled={isGameOver}
disabled={isGameOver || !isPlayersTurn}
onClick={async () => {
const playedTiles = playerLetters.map((i) => {
if (i === undefined) {
@ -492,6 +481,8 @@ export function Game(props: {
result
.then(
(api_state) => {
console.log("Testing45")
console.log({api_state});
const play_tiles: TurnAction = api_state.update.type;
if (play_tiles.type == "PlayTiles") {
@ -499,7 +490,6 @@ export function Game(props: {
if (committing) {
setAPIState(api_state);
waitForUpdate();
}
} else {
@ -514,7 +504,12 @@ export function Game(props: {
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)}/>);
addWordFn={(word) => {
props.api.add_to_dictionary(word)
.then((api_state) => {
setAPIState(api_state);
})
}}/>);
} else {
logDispatch(<div>{error}</div>);
}
@ -532,7 +527,7 @@ export function Game(props: {
</button>
<button
className="pass"
disabled={isGameOver}
disabled={isGameOver || !isPlayersTurn}
onClick={() => {
if (window.confirm("Are you sure you want to pass?")) {
const result = props.api.pass();
@ -541,7 +536,6 @@ export function Game(props: {
.then(
(api_state) => {
setAPIState(api_state);
waitForUpdate();
})
.catch((error) => {
console.error({error});

View file

@ -3,7 +3,7 @@ import {useState} from "react";
import {Settings} from "./utils";
import {Game} from "./Game";
import {API, Difficulty} from "./api";
import {GameWasm} from "./wasm_api";
import {WasmAPI} from "./wasm_api";
import {AISelection} from "./UI";
export function Menu(props: {settings: Settings, dictionary_text: string}) {
@ -38,7 +38,7 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
proportion: processedProportionDictionary,
randomness: processedAIRandomness,
};
const game_wasm: API = new GameWasm(BigInt(seed), props.dictionary_text, difficulty);
const game_wasm: API = new WasmAPI(BigInt(seed), props.dictionary_text, difficulty);
const game = <Game settings={props.settings} api={game_wasm} key={seed} end_game_fn={() => setGame(null)}/>
setGame(game);
}}>New Game</button>

View file

@ -1,5 +1,5 @@
import * as React from "react";
import {ChangeEvent, JSX, useState} from "react";
import {ChangeEvent, JSX} from "react";
import {
cellTypeToDetails,
CoordinateData,
@ -12,9 +12,7 @@ import {
TileDispatch,
TileDispatchActionType,
} from "./utils";
import {API, APIPlayer, CellType, Difficulty} from "./api";
import {GameWasm} from "./wasm";
import {Game} from "./Game";
import {APIPlayer, CellType} from "./api";
export function TileSlot(props: {
@ -182,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 = <>
@ -239,7 +237,8 @@ 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>;
});

View file

@ -30,7 +30,7 @@ export interface Letter {
}
export type TurnAction = { type: "Pass" } | { type: "ExchangeTiles"; tiles_exchanged: number } | { type: "PlayTiles"; result: ScoreResult; locations: number[] };
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",
@ -85,8 +85,7 @@ 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<void>;
add_to_dictionary: (word: string) => Promise<APIState>;
load: (wait: boolean) => Promise<APIState>;
register_log_dispatch: (fn: (x: any) => void) => void;
}

View file

@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.less" />
<title>Word Grid</title>
</head>
<body>

View file

@ -1,8 +1,10 @@
import * as React from "react";
import {useState} from "react";
import {useRef, useState} from "react";
import {createRoot} from "react-dom/client";
import {AISelection} from "./UI";
import {PartyInfo, ServerToClientMessage} from "./ws_api";
import {ClientToServerMessage, WSAPI, PartyInfo, ServerToClientMessage} from "./ws_api";
import {Game} from "./Game";
import {Settings} from "./utils";
@ -25,8 +27,19 @@ export function Menu(): React.JSX.Element {
const [aiRandomness, setAIRandomness] = useState<number>(6);
const [proportionDictionary, setProportionDictionary] = useState<number>(7);
const [game, setGame] = useState<React.JSX.Element>(null);
let button_or_game = <button onClick={() => {
const event: ClientToServerMessage = {
type: "StartGame"
};
socket.send(JSON.stringify(event));
}}>Start Game</button>;
if(game){
button_or_game = game;
}
// 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);
@ -53,6 +66,7 @@ export function Menu(): React.JSX.Element {
</li>
});
return <div>
<p>Connected to {roomName}</p>
Players: <ol>
@ -82,7 +96,9 @@ export function Menu(): React.JSX.Element {
setSocket(null);
setPartyInfo(null);
}}>Disconnect</button>
{button_or_game}
</div>
} else {
return <div>
<div>
@ -108,6 +124,16 @@ export function Menu(): React.JSX.Element {
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);
setGame(null);
} } />);
}
console.log("Message from server ", event.data);
});

View file

@ -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,6 +213,7 @@
div {
margin-left: 10px;
margin-right: 10px;
flex: 1;
}
}
}

View file

@ -5,7 +5,6 @@ import {CellType, Letter as LetterData} from "./api";
export interface Settings {
trayLength: number;
playerName: string;
aiName: string;
}
export enum LocationType {

View file

@ -1,24 +1,22 @@
import {API, APIState, Difficulty, PlayedTile, Result, is_ok} from "./api";
import {WasmAPI} from 'word_grid';
import {WasmAPI as RawAPI} from 'word_grid';
export class GameWasm implements API{
private wasm: WasmAPI;
private log_dispatch: (x: any) => void | null;
export class WasmAPI implements API{
private wasm: RawAPI;
constructor(seed: bigint, dictionary_text: string, difficulty: Difficulty) {
this.wasm = new WasmAPI(seed, dictionary_text, difficulty);
this.log_dispatch = null;
this.wasm = new RawAPI(seed, dictionary_text, difficulty);
}
add_to_dictionary(word: string): Promise<void> {
return new Promise((resolve, _) => {
this.wasm.add_to_dictionary(word);
if(this.log_dispatch != null) {
this.log_dispatch(<div>{word} was added to dictionary</div>);
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 {
console.error("log_dispatch was unexpectedly null");
reject(api_state.Err);
}
resolve()
});
}
@ -70,8 +68,4 @@ export class GameWasm implements API{
});
}
register_log_dispatch(fn: (x: any) => void): void {
this.log_dispatch = fn;
}
}

View file

@ -2,7 +2,6 @@ import {API, APIState, Difficulty, PlayedTile} from "./api";
export interface Player {
name: string
id: string
}
export interface AI {
@ -30,10 +29,6 @@ export type GameEvent = {
type: "TurnAction"
state: APIState
committed: boolean
} | {
type: "WordAdded"
word: string
player: Player
}
export type ServerToClientMessage = {
@ -50,9 +45,6 @@ export type ServerToClientMessage = {
} | {
type: "Invalid"
reason: string
} | {
type: "WordAdded"
word: string
}
type GameMove = {
@ -69,7 +61,7 @@ type GameMove = {
word: string
}
type ClientToServerMessage = {
export type ClientToServerMessage = {
type: "Load" | "StartGame"
} | {
type: "GameMove"
@ -87,14 +79,12 @@ interface PromiseInput {
reject: (error: any) => void
}
export class GameWS implements API{
export class WSAPI implements API{
private socket: WebSocket;
private currentPromiseInput: PromiseInput | null;
private log_dispatch: (x: any) => void | null;
constructor(socket: WebSocket) {
this.socket = socket;
this.log_dispatch = null;
this.currentPromiseInput = null;
this.socket.addEventListener("message", (event) => {
let data: ServerToClientMessage = JSON.parse(event.data);
@ -120,14 +110,6 @@ export class GameWS implements API{
});
}
private log_new_word(word: string, player: string) {
if(this.log_dispatch != null) {
this.log_dispatch(<div>Player {player} added {word} to the dictionary</div>);
} else {
console.error("Unable to log new word ", word, " from player ", player);
}
}
private register_promise(resolve: (value: GameEvent) => void, reject: (value: any) => void) {
if(this.currentPromiseInput != null) {
console.error("We are setting a new promise before the current one has resolved")
@ -139,7 +121,7 @@ export class GameWS implements API{
};
}
add_to_dictionary(word: string): Promise<void> {
add_to_dictionary(word: string): Promise<APIState> {
return new Promise((resolve, reject) => {
this.register_promise(resolve, reject);
let event: ClientToServerMessage = {
@ -152,13 +134,7 @@ export class GameWS implements API{
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
if(game_event.type == "WordAdded"){
this.log_new_word(game_event.word, game_event.player.name);
} else {
console.error("We received the wrong kind of response back!")
console.error({game_event});
return Promise.reject("We received the wrong kind of response back!");
}
return game_event.state;
});
}
@ -174,13 +150,7 @@ export class GameWS implements API{
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
if(game_event.type == "TurnAction") {
return game_event.state;
} else {
console.error("We received the wrong kind of response back!")
console.error({game_event});
return Promise.reject("We received the wrong kind of response back!");
}
return game_event.state;
});
}
@ -194,13 +164,7 @@ export class GameWS implements API{
this.socket.send(JSON.stringify(event));
}
}).then((game_event: GameEvent) => {
if(game_event.type == "TurnAction") {
return game_event.state;
} else {
// need to handle this case; we'll deal with it by returning a new promise again
this.log_new_word(game_event.word, game_event.player.name);
return this.load(wait);
}
return game_event.state;
});
}
@ -215,13 +179,7 @@ export class GameWS implements API{
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
if(game_event.type == "TurnAction") {
return game_event.state;
} else {
console.error("We received the wrong kind of response back!")
console.error({game_event});
return Promise.reject("We received the wrong kind of response back!");
}
return game_event.state;
});
}
@ -238,18 +196,8 @@ export class GameWS implements API{
};
this.socket.send(JSON.stringify(event));
}).then((game_event: GameEvent) => {
if(game_event.type == "TurnAction") {
return game_event.state;
} else {
console.error("We received the wrong kind of response back!")
console.error({game_event});
return Promise.reject("We received the wrong kind of response back!");
}
return game_event.state;
});
}
register_log_dispatch(fn: (x: any) => void): void {
this.log_dispatch = fn;
}
}

View file

@ -47,7 +47,7 @@ pub struct PublicInformation {
pub struct ApiState {
public_information: PublicInformation,
tray: Tray,
update: Option<Update>,
pub update: Option<Update>,
}
pub struct APIGame(pub Game, Vec<Update>);