implemented basic board game support (only in client-server mode)

feature/modern-browsers
Inga 🏳‍🌈 3 weeks ago
parent f96447c8d1
commit f71ca0a52a
  1. 2
      src/backend/app.ts
  2. 51
      src/backend/components/boardgame.tsx
  3. 19
      src/backend/main/boardgame-handler.ts
  4. 10
      src/backend/main/index.tsx
  5. 6
      src/backend/progressive-form.ts
  6. 16
      src/backend/utils.ts
  7. 5
      src/jsx-augmentations.ts
  8. 3
      src/shared/array-utils.ts
  9. 36
      src/shared/board.ts
  10. 92
      src/shared/boardgame-state.ts
  11. 75
      src/shared/datatypes.ts
  12. 3
      src/shared/solver.ts
  13. 5
      src/shared/utils.ts

@ -1,7 +1,7 @@
import path from "node:path"; import path from "node:path";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import express from "express"; import express from "express";
import { mainPageHandler } from "./main.tsx"; import { mainPageHandler } from "./main/index.tsx";
import { createProgressiveForm } from "./progressive-form.ts"; import { createProgressiveForm } from "./progressive-form.ts";
export const createApp = () => { export const createApp = () => {

@ -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 (
<button type="submit" name={key} value={state.serialize()} disabled>
{" "}
</button>
);
}
const squareState = state.board.get(row, column);
const nextGameState = squareState === SquareState.Unoccupied ? state.withMove(row, column) : state;
return (
<button
type="submit"
name={key}
value={nextGameState.serialize()}
disabled={squareState !== SquareState.Unoccupied}
>
{formatSquareState(squareState)}
</button>
);
};
export const getBoardgameHtml = (key: string, state: BoardgameStateType) => {
return (
<board-game>
<form method="post">
<p>Current player: {state.currentPlayerName}</p>
<table>
<tbody>
{sequence(state.rows).map((row) => (
<tr>
{sequence(state.columns).map((column) => (
<td>{getCellHtml(key, state, row, column)}</td>
))}
</tr>
))}
</tbody>
</table>
<button type="submit" name={key} value={state.withEmptyBoard().serialize()}>
Start game
</button>
</form>
</board-game>
);
};

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

@ -1,11 +1,18 @@
import type { RequestHandler } from "express"; 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) => { export const mainPageHandler: RequestHandler = (req, res) => {
const counters = { const counters = {
a: parseInt(safeGetQueryValue(req, "a") ?? "0", 10), a: parseInt(safeGetQueryValue(req, "a") ?? "0", 10),
b: parseInt(safeGetQueryValue(req, "b") ?? "0", 10), b: parseInt(safeGetQueryValue(req, "b") ?? "0", 10),
}; };
const board1 = handleBoardgame(req, res, "tictactoe1");
if (!board1) {
return;
}
sendHtml( sendHtml(
res, res,
<html> <html>
@ -60,6 +67,7 @@ export const mainPageHandler: RequestHandler = (req, res) => {
))} ))}
</ul> </ul>
</section> </section>
<section>{board1}</section>
</body> </body>
</html>, </html>,
); );

@ -1,11 +1,13 @@
import express, { RequestHandler } from "express"; import express, { RequestHandler } from "express";
import { rewriteQueryParams } from "./utils.ts"; import { rewriteQueryParamsWith } from "./utils.ts";
export const createProgressiveForm = (mainHandler: RequestHandler) => { export const createProgressiveForm = (mainHandler: RequestHandler) => {
const router = express.Router(); const router = express.Router();
router.get("/", mainHandler); router.get("/", mainHandler);
router.post("/", rewriteQueryParams); router.post("/", (req, res) => {
rewriteQueryParamsWith(req, res, (req.body || {}) as Record<string, unknown>);
});
return router; return router;
}; };

@ -1,4 +1,4 @@
import type { Request, RequestHandler, Response } from "express"; import type { Request, Response } from "express";
import type { VNode } from "preact"; import type { VNode } from "preact";
import { render } from "preact-render-to-string"; import { render } from "preact-render-to-string";
@ -18,7 +18,7 @@ export const safeGetQueryValue = (req: Request, name: string) => {
return result || null; return result || null;
}; };
export const rewriteQueryParams: RequestHandler = (req, res) => { export const rewriteQueryParamsWith = (req: Request, res: Response, newParams: Record<string, unknown>) => {
const newQuery = new URLSearchParams(); const newQuery = new URLSearchParams();
for (const [param, value] of Object.entries(req.query)) { for (const [param, value] of Object.entries(req.query)) {
@ -27,13 +27,13 @@ export const rewriteQueryParams: RequestHandler = (req, res) => {
} }
} }
if (req.body) { for (const [param, value] of Object.entries(newParams)) {
const body = req.body as Record<string, unknown>; if (typeof value !== "string") {
for (const [param, value] of Object.entries(body)) { res.status(400).send(`Parameter ${param} in POST body is not a string`);
if (typeof value === "string") { return;
newQuery.set(param, value);
}
} }
newQuery.set(param, value);
} }
res.redirect(`?${newQuery.toString()}`); res.redirect(`?${newQuery.toString()}`);

@ -7,5 +7,10 @@ declare module "preact/jsx-runtime" {
delta?: string; delta?: string;
track?: string; track?: string;
} }
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done
interface IntrinsicElements {
"board-game": HTMLAttributes;
}
} }
} }

@ -0,0 +1,3 @@
export const repeat = <T>(value: T, size: number) => [...(new Array(size) as unknown[])].map(() => value);
export const sequence = (size: number) => [...(new Array(size) as unknown[])].map((_, index) => index);

@ -1,5 +1,5 @@
import { BoardType, SquareState } from "./datatypes.ts"; import { repeat } from "./array-utils.ts";
import { unreachable } from "./utils.ts"; import { BoardType, SquareState, formatSquareState, parseSquareState } from "./datatypes.ts";
export class Board implements BoardType { export class Board implements BoardType {
// State should be immutable // State should be immutable
@ -36,8 +36,8 @@ export class Board implements BoardType {
} }
static createEmpty(rows: number, columns: number) { static createEmpty(rows: number, columns: number) {
const row = [...(new Array(columns) as unknown[])].map(() => SquareState.Unoccupied); const row = repeat(SquareState.Unoccupied, columns);
const boardState = [...(new Array(rows) as unknown[])].map(() => row); const boardState = repeat(row, rows);
return new Board(boardState); return new Board(boardState);
} }
@ -45,16 +45,12 @@ export class Board implements BoardType {
return new Board( return new Board(
serialized.split("|").map((line) => serialized.split("|").map((line) =>
line.split("").map((char) => { line.split("").map((char) => {
switch (char) { const squareState = parseSquareState(char);
case "_": if (!squareState) {
return SquareState.Unoccupied; throw new Error(`Unsupported square character: ${char}`);
case "O":
return SquareState.O;
case "X":
return SquareState.X;
default:
throw new Error(`Unsupported square character: ${char}`);
} }
return squareState;
}), }),
), ),
); );
@ -65,16 +61,12 @@ export class Board implements BoardType {
.map((row) => .map((row) =>
row row
.map((squareState) => { .map((squareState) => {
switch (squareState) { const char = formatSquareState(squareState);
case SquareState.Unoccupied: if (!char) {
return "_"; throw new Error(`Unsupported square state: ${squareState}`);
case SquareState.O:
return "O";
case SquareState.X:
return "X";
default:
throw new Error(`Unsupported square state: ${unreachable(squareState)}`);
} }
return char;
}) })
.join(""), .join(""),
) )

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

@ -1,3 +1,5 @@
import { unreachableNull, unreachableString } from "./utils.ts";
export enum SquareState { export enum SquareState {
Unoccupied = 1, // so that all SquareState values are truthy Unoccupied = 1, // so that all SquareState values are truthy
X, X,
@ -17,6 +19,8 @@ export enum Player {
O, O,
} }
export const FIRST_PLAYER = Player.X;
export enum CurrentOutcome { export enum CurrentOutcome {
Undecided = 201, Undecided = 201,
Draw, Draw,
@ -35,6 +39,19 @@ export type ExpectedOutcome = {
movesLeft: number; 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 = { export type GameRules = {
getBoardOutcome(board: BoardType): CurrentOutcome; getBoardOutcome(board: BoardType): CurrentOutcome;
}; };
@ -54,7 +71,7 @@ export const getExpectedOutcomeByCurrentOutcome = (
case CurrentOutcome.WinO: case CurrentOutcome.WinO:
return { finalOutcome: FinalOutcome.WinO, movesLeft: 0 }; return { finalOutcome: FinalOutcome.WinO, movesLeft: 0 };
default: 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: case Player.O:
return SquareState.O; return SquareState.O;
default: 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: case Player.O:
return Player.X; return Player.X;
default: 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: case Player.O:
return FinalOutcome.WinO; return FinalOutcome.WinO;
default: 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: case Player.O:
return FinalOutcome.WinX; return FinalOutcome.WinX;
default: 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;
} }
}; };

@ -3,6 +3,7 @@ import {
BoardType, BoardType,
CurrentOutcome, CurrentOutcome,
ExpectedOutcome, ExpectedOutcome,
FIRST_PLAYER,
FinalOutcome, FinalOutcome,
GameRules, GameRules,
Player, Player,
@ -111,7 +112,7 @@ export const computeAllSolutions = (rows: number, columns: number, rules: GameRu
return expectedOutcome; return expectedOutcome;
}; };
void getExpectedOutcomeForBoard(Board.createEmpty(rows, columns), Player.X); void getExpectedOutcomeForBoard(Board.createEmpty(rows, columns), FIRST_PLAYER);
return expectedOutcomesByBoard; return expectedOutcomesByBoard;
}; };

@ -1,2 +1,5 @@
// To simplify switches and provide some typecheck-time guarantees // 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;

Loading…
Cancel
Save