From f71ca0a52aeb04f8c7cbf6a05c8afc541a7428a2 Mon Sep 17 00:00:00 2001 From: Inga Date: Mon, 18 Nov 2024 18:35:32 +0000 Subject: [PATCH] implemented basic board game support (only in client-server mode) --- src/backend/app.ts | 2 +- src/backend/components/boardgame.tsx | 51 +++++++++++++ src/backend/main/boardgame-handler.ts | 19 +++++ src/backend/{main.tsx => main/index.tsx} | 10 ++- src/backend/progressive-form.ts | 6 +- src/backend/utils.ts | 16 ++--- src/jsx-augmentations.ts | 5 ++ src/shared/array-utils.ts | 3 + src/shared/board.ts | 36 ++++------ src/shared/boardgame-state.ts | 92 ++++++++++++++++++++++++ src/shared/datatypes.ts | 75 +++++++++++++++++-- src/shared/solver.ts | 3 +- src/shared/utils.ts | 5 +- 13 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 src/backend/components/boardgame.tsx create mode 100644 src/backend/main/boardgame-handler.ts rename src/backend/{main.tsx => main/index.tsx} (89%) create mode 100644 src/shared/array-utils.ts create mode 100644 src/shared/boardgame-state.ts diff --git a/src/backend/app.ts b/src/backend/app.ts index 1f4f353..d3fbe73 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -1,7 +1,7 @@ import path from "node:path"; import bodyParser from "body-parser"; import express from "express"; -import { mainPageHandler } from "./main.tsx"; +import { mainPageHandler } from "./main/index.tsx"; import { createProgressiveForm } from "./progressive-form.ts"; export const createApp = () => { diff --git a/src/backend/components/boardgame.tsx b/src/backend/components/boardgame.tsx new file mode 100644 index 0000000..5e29329 --- /dev/null +++ b/src/backend/components/boardgame.tsx @@ -0,0 +1,51 @@ +import { sequence } from "../../shared/array-utils.ts"; +import { BoardgameStateType, SquareState, formatSquareState } from "../../shared/datatypes.ts"; + +const getCellHtml = (key: string, state: BoardgameStateType, row: number, column: number) => { + if (!state.board) { + return ( + + ); + } + + const squareState = state.board.get(row, column); + const nextGameState = squareState === SquareState.Unoccupied ? state.withMove(row, column) : state; + return ( + + ); +}; + +export const getBoardgameHtml = (key: string, state: BoardgameStateType) => { + return ( + +
+

Current player: {state.currentPlayerName}

+ + + + {sequence(state.rows).map((row) => ( + + {sequence(state.columns).map((column) => ( + + ))} + + ))} + +
{getCellHtml(key, state, row, column)}
+ + +
+
+ ); +}; diff --git a/src/backend/main/boardgame-handler.ts b/src/backend/main/boardgame-handler.ts new file mode 100644 index 0000000..7438017 --- /dev/null +++ b/src/backend/main/boardgame-handler.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from "express"; +import { rewriteQueryParamsWith, safeGetQueryValue } from "../utils.ts"; +import { BoardgameState } from "../../shared/boardgame-state.ts"; +import { getBoardgameHtml } from "../components/boardgame.tsx"; + +// Returns nothing if query parameter is uninitialized and a redirect was made, +// or component if query parameter is initialized. +export const handleBoardgame = (req: Request, res: Response, key: string) => { + const serializedState = safeGetQueryValue(req, key); + const state = BoardgameState.fromSerialized(serializedState); + + if (!state) { + const newState = BoardgameState.createWithoutBoard(3, 3); + rewriteQueryParamsWith(req, res, { [key]: newState.serialize() }); + return; + } + + return getBoardgameHtml(key, state); +}; diff --git a/src/backend/main.tsx b/src/backend/main/index.tsx similarity index 89% rename from src/backend/main.tsx rename to src/backend/main/index.tsx index 366965a..da90ab0 100644 --- a/src/backend/main.tsx +++ b/src/backend/main/index.tsx @@ -1,11 +1,18 @@ import type { RequestHandler } from "express"; -import { safeGetQueryValue, sendHtml } from "./utils.ts"; +import { safeGetQueryValue, sendHtml } from "../utils.ts"; +import { handleBoardgame } from "./boardgame-handler.ts"; export const mainPageHandler: RequestHandler = (req, res) => { const counters = { a: parseInt(safeGetQueryValue(req, "a") ?? "0", 10), b: parseInt(safeGetQueryValue(req, "b") ?? "0", 10), }; + + const board1 = handleBoardgame(req, res, "tictactoe1"); + if (!board1) { + return; + } + sendHtml( res, @@ -60,6 +67,7 @@ export const mainPageHandler: RequestHandler = (req, res) => { ))} +
{board1}
, ); diff --git a/src/backend/progressive-form.ts b/src/backend/progressive-form.ts index 9d89222..75221c1 100644 --- a/src/backend/progressive-form.ts +++ b/src/backend/progressive-form.ts @@ -1,11 +1,13 @@ import express, { RequestHandler } from "express"; -import { rewriteQueryParams } from "./utils.ts"; +import { rewriteQueryParamsWith } from "./utils.ts"; export const createProgressiveForm = (mainHandler: RequestHandler) => { const router = express.Router(); router.get("/", mainHandler); - router.post("/", rewriteQueryParams); + router.post("/", (req, res) => { + rewriteQueryParamsWith(req, res, (req.body || {}) as Record); + }); return router; }; diff --git a/src/backend/utils.ts b/src/backend/utils.ts index ce6f601..c97040f 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -1,4 +1,4 @@ -import type { Request, RequestHandler, Response } from "express"; +import type { Request, Response } from "express"; import type { VNode } from "preact"; import { render } from "preact-render-to-string"; @@ -18,7 +18,7 @@ export const safeGetQueryValue = (req: Request, name: string) => { return result || null; }; -export const rewriteQueryParams: RequestHandler = (req, res) => { +export const rewriteQueryParamsWith = (req: Request, res: Response, newParams: Record) => { const newQuery = new URLSearchParams(); for (const [param, value] of Object.entries(req.query)) { @@ -27,13 +27,13 @@ export const rewriteQueryParams: RequestHandler = (req, res) => { } } - if (req.body) { - const body = req.body as Record; - for (const [param, value] of Object.entries(body)) { - if (typeof value === "string") { - newQuery.set(param, value); - } + for (const [param, value] of Object.entries(newParams)) { + if (typeof value !== "string") { + res.status(400).send(`Parameter ${param} in POST body is not a string`); + return; } + + newQuery.set(param, value); } res.redirect(`?${newQuery.toString()}`); diff --git a/src/jsx-augmentations.ts b/src/jsx-augmentations.ts index af76d51..730edf6 100644 --- a/src/jsx-augmentations.ts +++ b/src/jsx-augmentations.ts @@ -7,5 +7,10 @@ declare module "preact/jsx-runtime" { delta?: string; track?: string; } + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done + interface IntrinsicElements { + "board-game": HTMLAttributes; + } } } diff --git a/src/shared/array-utils.ts b/src/shared/array-utils.ts new file mode 100644 index 0000000..a0740e0 --- /dev/null +++ b/src/shared/array-utils.ts @@ -0,0 +1,3 @@ +export const repeat = (value: T, size: number) => [...(new Array(size) as unknown[])].map(() => value); + +export const sequence = (size: number) => [...(new Array(size) as unknown[])].map((_, index) => index); diff --git a/src/shared/board.ts b/src/shared/board.ts index 6a0d4a0..76afbf2 100644 --- a/src/shared/board.ts +++ b/src/shared/board.ts @@ -1,5 +1,5 @@ -import { BoardType, SquareState } from "./datatypes.ts"; -import { unreachable } from "./utils.ts"; +import { repeat } from "./array-utils.ts"; +import { BoardType, SquareState, formatSquareState, parseSquareState } from "./datatypes.ts"; export class Board implements BoardType { // State should be immutable @@ -36,8 +36,8 @@ export class Board implements BoardType { } static createEmpty(rows: number, columns: number) { - const row = [...(new Array(columns) as unknown[])].map(() => SquareState.Unoccupied); - const boardState = [...(new Array(rows) as unknown[])].map(() => row); + const row = repeat(SquareState.Unoccupied, columns); + const boardState = repeat(row, rows); return new Board(boardState); } @@ -45,16 +45,12 @@ export class Board implements BoardType { return new Board( serialized.split("|").map((line) => line.split("").map((char) => { - switch (char) { - case "_": - return SquareState.Unoccupied; - case "O": - return SquareState.O; - case "X": - return SquareState.X; - default: - throw new Error(`Unsupported square character: ${char}`); + const squareState = parseSquareState(char); + if (!squareState) { + throw new Error(`Unsupported square character: ${char}`); } + + return squareState; }), ), ); @@ -65,16 +61,12 @@ export class Board implements BoardType { .map((row) => row .map((squareState) => { - switch (squareState) { - case SquareState.Unoccupied: - return "_"; - case SquareState.O: - return "O"; - case SquareState.X: - return "X"; - default: - throw new Error(`Unsupported square state: ${unreachable(squareState)}`); + const char = formatSquareState(squareState); + if (!char) { + throw new Error(`Unsupported square state: ${squareState}`); } + + return char; }) .join(""), ) diff --git a/src/shared/boardgame-state.ts b/src/shared/boardgame-state.ts new file mode 100644 index 0000000..61012ed --- /dev/null +++ b/src/shared/boardgame-state.ts @@ -0,0 +1,92 @@ +import { Board } from "./board.ts"; +import { + BoardType, + BoardgameStateType, + FIRST_PLAYER, + Player, + formatPlayer, + getNextPlayer, + getOccupiedStateByPlayer, + parsePlayer, +} from "./datatypes.ts"; + +const parseDimensions = (dimensionsSerialized: string | undefined) => { + if (!dimensionsSerialized) { + return null; + } + + const [rowsSerialized, columnsSerialized] = dimensionsSerialized.split("x"); + + if (!rowsSerialized || !columnsSerialized) { + // TODO: better error handling + throw new Error("Incorrect dimensions"); + } + + return [parseInt(rowsSerialized, 10), parseInt(columnsSerialized, 10)] as const; +}; + +const parseBoard = (boardSerialized: string | undefined) => { + if (!boardSerialized) { + return null; + } + + return Board.fromSerialized(boardSerialized); +}; + +const serializeBoard = (board: BoardType | null) => { + if (!board) { + return ""; + } + + return board.serialize(); +}; + +export class BoardgameState implements BoardgameStateType { + constructor( + readonly rows: number, + readonly columns: number, + readonly currentPlayer: Player | null, + readonly board: BoardType | null, + ) {} + + get currentPlayerName() { + return this.currentPlayer ? formatPlayer(this.currentPlayer) : ""; + } + + static createWithoutBoard(rows: number, columns: number) { + return new BoardgameState(rows, columns, null, null); + } + + withEmptyBoard() { + return new BoardgameState(this.rows, this.columns, FIRST_PLAYER, Board.createEmpty(this.rows, this.columns)); + } + + withMove(row: number, column: number) { + if (!this.currentPlayer || !this.board) { + throw new Error("Game is not started"); + } + + const nextBoard = this.board.with(row, column, getOccupiedStateByPlayer(this.currentPlayer)); + const nextPlayer = getNextPlayer(this.currentPlayer); + return new BoardgameState(this.rows, this.columns, nextPlayer, nextBoard); + } + + static fromSerialized(serialized: string | null) { + const [dimensionsSerialized, currentPlayerSerialized, boardSerialized] = (serialized ?? "").split("."); + + const dimensions = parseDimensions(dimensionsSerialized); + if (!dimensions) { + return null; + } + + const [rows, columns] = dimensions; + const currentPlayer = currentPlayerSerialized ? parsePlayer(currentPlayerSerialized) : null; + const board = parseBoard(boardSerialized); + + return new BoardgameState(rows, columns, currentPlayer, board); + } + + serialize() { + return `${this.rows}x${this.columns}.${this.currentPlayerName}.${serializeBoard(this.board)}`; + } +} diff --git a/src/shared/datatypes.ts b/src/shared/datatypes.ts index d86dab2..2f9e4ca 100644 --- a/src/shared/datatypes.ts +++ b/src/shared/datatypes.ts @@ -1,3 +1,5 @@ +import { unreachableNull, unreachableString } from "./utils.ts"; + export enum SquareState { Unoccupied = 1, // so that all SquareState values are truthy X, @@ -17,6 +19,8 @@ export enum Player { O, } +export const FIRST_PLAYER = Player.X; + export enum CurrentOutcome { Undecided = 201, Draw, @@ -35,6 +39,19 @@ export type ExpectedOutcome = { movesLeft: number; }; +export type BoardgameStateType = { + readonly rows: number; + readonly columns: number; + readonly currentPlayer: Player | null; + readonly board: BoardType | null; + + readonly currentPlayerName: string; + + withEmptyBoard(): BoardgameStateType; + withMove(row: number, column: number): BoardgameStateType; + serialize(): string; +}; + export type GameRules = { getBoardOutcome(board: BoardType): CurrentOutcome; }; @@ -54,7 +71,7 @@ export const getExpectedOutcomeByCurrentOutcome = ( case CurrentOutcome.WinO: return { finalOutcome: FinalOutcome.WinO, movesLeft: 0 }; default: - throw new Error(`Unsupported current outcome: ${currentOutcome}`); + throw new Error(`Unsupported current outcome: ${unreachableString(currentOutcome)}`); } }; @@ -65,7 +82,7 @@ export const getOccupiedStateByPlayer = (player: Player) => { case Player.O: return SquareState.O; default: - throw new Error(`Unsupported player: ${player}`); + throw new Error(`Unsupported player: ${unreachableString(player)}`); } }; @@ -76,7 +93,7 @@ export const getNextPlayer = (player: Player) => { case Player.O: return Player.X; default: - throw new Error(`Unsupported player: ${player}`); + throw new Error(`Unsupported player: ${unreachableString(player)}`); } }; @@ -87,7 +104,7 @@ export const getDesiredFinalOutcomeByPlayer = (player: Player) => { case Player.O: return FinalOutcome.WinO; default: - throw new Error(`Unsupported player: ${player}`); + throw new Error(`Unsupported player: ${unreachableString(player)}`); } }; @@ -98,6 +115,54 @@ export const getUndesiredFinalOutcomeByPlayer = (player: Player) => { case Player.O: return FinalOutcome.WinX; default: - throw new Error(`Unsupported player: ${player}`); + throw new Error(`Unsupported player: ${unreachableString(player)}`); + } +}; + +export const formatSquareState = (squareState: SquareState) => { + switch (squareState) { + case SquareState.Unoccupied: + return "_"; + case SquareState.O: + return "O"; + case SquareState.X: + return "X"; + default: + return unreachableNull(squareState); + } +}; + +export const parseSquareState = (squareStateString: string) => { + switch (squareStateString) { + case "_": + return SquareState.Unoccupied; + case "O": + return SquareState.O; + case "X": + return SquareState.X; + default: + return null; + } +}; + +export const formatPlayer = (player: Player) => { + switch (player) { + case Player.X: + return "X"; + case Player.O: + return "O"; + default: + throw new Error(`Unsupported player: ${unreachableString(player)}`); + } +}; + +export const parsePlayer = (playerSerialized: string) => { + switch (playerSerialized) { + case "X": + return Player.X; + case "O": + return Player.O; + default: + return null; } }; diff --git a/src/shared/solver.ts b/src/shared/solver.ts index d1f972c..8857c22 100644 --- a/src/shared/solver.ts +++ b/src/shared/solver.ts @@ -3,6 +3,7 @@ import { BoardType, CurrentOutcome, ExpectedOutcome, + FIRST_PLAYER, FinalOutcome, GameRules, Player, @@ -111,7 +112,7 @@ export const computeAllSolutions = (rows: number, columns: number, rules: GameRu return expectedOutcome; }; - void getExpectedOutcomeForBoard(Board.createEmpty(rows, columns), Player.X); + void getExpectedOutcomeForBoard(Board.createEmpty(rows, columns), FIRST_PLAYER); return expectedOutcomesByBoard; }; diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 237cc75..1bfb952 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,2 +1,5 @@ // To simplify switches and provide some typecheck-time guarantees -export const unreachable = (value: never) => value as unknown as string; +export const unreachableString = (value: never) => value as unknown as string; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- We don't care about the value here, but we do care that from TS point of view it has type `never` +export const unreachableNull = (_value: never) => null;