Skip to content

Commit b7b9474

Browse files
authored
feat(web): add interactive boards for all four MVP games (#99)
1 parent ea115cf commit b7b9474

22 files changed

Lines changed: 920 additions & 83 deletions

File tree

‎.dependency-cruiser.cjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module.exports = {
2525
},
2626
],
2727
options: {
28+
exclude: { path: "(^|/)(dist|node_modules)/" },
2829
doNotFollow: { path: "node_modules" },
2930
tsConfig: { fileName: "tsconfig.json" },
3031
tsPreCompilationDeps: true,

‎.prettierignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
web/**/dist/

‎pnpm-lock.yaml‎

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/browser/scenes.spec.mjs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { expect, test } from "@playwright/test";
2+
const site = "http://127.0.0.1:18081";
3+
4+
async function open(page, request, game, extra = {}) {
5+
const response = await request.post("/v1/matches", { data: { game_id: game, seed: 2, include_info: false, ...extra } });
6+
expect(response.ok()).toBe(true);
7+
const { match_id: id } = await response.json();
8+
await page.goto(site + "/#/matches/" + id + "?seat=0&hotseat=1");
9+
const board = page.getByRole("group", { name: /interactive board/ });
10+
await expect(board).toHaveAttribute("data-ready", "true");
11+
await expect(page.getByLabel("Move notation")).toBeEnabled();
12+
return { id, board };
13+
}
14+
async function ready(page, turn) {
15+
await expect(page.locator(".turn-counter")).toHaveText("Turn " + turn);
16+
await expect(page.getByLabel("Move notation")).toBeEnabled();
17+
}
18+
async function cell(board, col, row, n) {
19+
const canvas = board.locator("canvas");
20+
const bounds = await canvas.boundingBox();
21+
await canvas.click({ position: { x: (40 + (col + .5) * 520 / n) * bounds.width / 600, y: (25 + (row + .5) * 520 / n) * bounds.height / 600 } });
22+
}
23+
24+
test("Phaser loads on demand and Tic-Tac-Toe supports keyboard and legal-only clicks", async ({ page, request }) => {
25+
const loaded = [];
26+
page.on("request", request => loaded.push(request.url()));
27+
await page.goto(site);
28+
await expect(page.getByRole("heading", { name: /A playground/ })).toBeVisible();
29+
expect(loaded.some(url => /phaser\.js/.test(url))).toBe(false);
30+
const { id, board } = await open(page, request, "tictactoe");
31+
await board.focus();
32+
await board.press("Enter");
33+
await ready(page, 1);
34+
const submissions = [];
35+
page.on("request", request => { if (request.method() === "POST" && request.url().endsWith("/actions")) submissions.push(request); });
36+
await cell(board, 0, 0, 3);
37+
await board.press("ArrowDown");
38+
await board.press("Enter");
39+
await ready(page, 2);
40+
expect(submissions).toHaveLength(1);
41+
for (const [turn, keys] of [[3, ["ArrowUp", "ArrowRight"]], [4, ["ArrowDown"]], [5, ["ArrowUp", "ArrowRight"]]]) {
42+
for (const key of keys) await board.press(key);
43+
await board.press("Enter");
44+
if (turn < 5) await ready(page, turn);
45+
}
46+
await expect(page.getByTestId("match-status")).toHaveText("X wins");
47+
const final = await (await request.get(`/v1/matches/${id}/state?seat=0`)).json();
48+
expect(final.returns).toEqual([1, -1]);
49+
});
50+
51+
test("Connect Four keyboard columns follow gravity through a full game", async ({ page, request }) => {
52+
const { id, board } = await open(page, request, "connect4");
53+
for (const [index, key] of ["1", "2", "1", "2", "1", "2", "1"].entries()) {
54+
await board.press(key);
55+
if (index < 6) await ready(page, index + 1);
56+
}
57+
await expect(page.getByTestId("match-status")).toHaveText("Red wins");
58+
const final = await (await request.get(`/v1/matches/${id}/state?seat=0`)).json();
59+
expect(final.observation.json.rows[5].slice(0, 2)).toEqual([0, 1]);
60+
expect(final.observation.json.rows[2][0]).toBe(0);
61+
});
62+
63+
test("chess board uses server promotion choices", async ({ page, request }) => {
64+
const { id, board } = await open(page, request, "chess", { start: { position: "7k/P7/8/8/8/8/8/7K w - - 0 1" } });
65+
await cell(board, 0, 1, 8);
66+
await cell(board, 0, 0, 8);
67+
await expect(page.locator(".turn-counter")).toHaveText("Turn 0");
68+
await board.press("q");
69+
await ready(page, 1);
70+
const final = await (await request.get(`/v1/matches/${id}/state?seat=0`)).json();
71+
expect(final.observation.json.pieces.a8).toBe("Q");
72+
});
73+
74+
test("mobile Sudoku protects givens and supports notes and touch digit entry", async ({ page, request }) => {
75+
await page.setViewportSize({ width: 360, height: 780 });
76+
const { id, board } = await open(page, request, "sudoku", { config: { size: 4 }, start: { position: "123434122143432." } });
77+
await cell(board, 0, 0, 4);
78+
await board.press("2");
79+
await expect(page.locator(".turn-counter")).toHaveText("Turn 0");
80+
await cell(board, 3, 3, 4);
81+
await board.press("n");
82+
await expect(page.getByRole("button", { name: "Notes", exact: true })).toHaveAttribute("aria-pressed", "true");
83+
await board.press("1");
84+
await ready(page, 1);
85+
const noted = await (await request.get(`/v1/matches/${id}/state?seat=0`)).json();
86+
expect(noted.observation.json.notes[15]).toBe(1);
87+
await page.getByRole("button", { name: "Notes", exact: true }).click();
88+
await page.getByRole("button", { name: "1", exact: true }).click();
89+
await expect(page.getByTestId("match-status")).toContainText("completed the puzzle");
90+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
91+
const bounds = await board.boundingBox();
92+
expect(bounds.width).toBeLessThanOrEqual(360);
93+
});
94+
95+
96+
test("renderer loading failures preserve playable text controls", async ({ page, request }) => {
97+
await page.route("**/game-kit/src/board-host.tsx*", route => route.abort());
98+
const response = await request.post("/v1/matches", { data: { game_id: "tictactoe", include_info: false } });
99+
const { match_id: id } = await response.json();
100+
await page.goto(site + "/#/matches/" + id + "?seat=0&hotseat=1");
101+
await expect(page.getByText("The visual board is unavailable. Use the text board below.")).toBeVisible();
102+
await page.getByLabel("Legal move", { exact: true }).selectOption("r2c2");
103+
await ready(page, 1);
104+
});

‎web/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,20 @@ resignation and draw controls. Engine hints default off and must be allowed
5858
when creating the match. Retries of an uncertain move reuse its request key,
5959
turn and seat; mutations never retry automatically. Local mode treats this
6060
browser as the match owner; seat selection is a view, not authentication.
61+
62+
## Visual boards
63+
64+
Tic-Tac-Toe, Connect Four, chess and Sudoku have Phaser scene packages. The game
65+
kit loads Phaser and each scene on demand; library and guide pages do not load
66+
the renderer. Scenes draw only server observations and submit actions from the
67+
server's legal list. They contain no game rules or network requests.
68+
69+
Click/tap a cell, or focus the board and use arrow keys and Enter. Connect Four
70+
also accepts column keys 1–7. Chess highlights legal destinations and asks for a
71+
promotion piece. Sudoku supports notes (N), digit keys and an accessible touch
72+
keypad; givens cannot be changed. The text board and notation input remain
73+
available, including when a renderer cannot load.
74+
75+
Add a scene in `packages/game-<id>` using `BoardScene` from `@gfa/game-kit/scene`,
76+
then add its lazy loader to `packages/game-kit/src/registry.ts`. The site uses
77+
only the game-kit interface. Unregistered games keep the generic text renderer.

‎web/apps/site/src/match.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
33
import type { FormEvent } from "react";
44
import { ApiError } from "@gfa/api-client";
55
import type { MatchState, Schemas } from "@gfa/api-client";
6-
import { TextBoard } from "@gfa/game-kit";
6+
import { GameBoard, TextBoard } from "@gfa/game-kit";
77
import { api } from "./api";
88
import { Failure, Loading } from "./feedback";
99
import { MatchEvents } from "./match-events";
@@ -180,6 +180,15 @@ export function MatchPage({
180180
</h2>
181181
<span className="turn-counter">Turn {current.turn}</span>
182182
</div>
183+
<GameBoard
184+
gameId={metadata.data.game_id}
185+
label={game.data.name}
186+
observation={current.observation.json}
187+
perspective={seat}
188+
legalActions={current.legal_actions.map((action) => action.string)}
189+
onAction={submit}
190+
disabled={!canAct}
191+
/>
183192
<TextBoard
184193
text={current.observation.text}
185194
legalActions={current.legal_actions.map((action) => action.string)}

‎web/apps/site/src/style.css‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,3 +749,42 @@ pre {
749749
flex-basis: 100%;
750750
}
751751
}
752+
753+
.visual-board {
754+
width: 100%;
755+
max-width: 600px;
756+
aspect-ratio: 1;
757+
margin: 16px auto;
758+
position: relative;
759+
border-radius: 10px;
760+
overflow: hidden;
761+
touch-action: manipulation;
762+
}
763+
.visual-board canvas {
764+
display: block;
765+
}
766+
.visual-board:focus-visible {
767+
outline: 3px solid var(--accent);
768+
outline-offset: 3px;
769+
}
770+
.board-instructions {
771+
color: var(--muted);
772+
font-size: 12px;
773+
line-height: 1.65;
774+
}
775+
.board-keypad {
776+
display: grid;
777+
grid-template-columns: repeat(auto-fit, minmax(44px, 1fr));
778+
gap: 6px;
779+
}
780+
.board-keypad button {
781+
min-height: 44px;
782+
min-width: 0;
783+
padding: 6px 2px;
784+
font-size: 12px;
785+
}
786+
787+
.board-keypad button[aria-pressed="true"] {
788+
border-color: var(--accent);
789+
color: var(--accent);
790+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@gfa/game-chess",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"exports": "./src/index.ts",
7+
"dependencies": {
8+
"@gfa/game-kit": "workspace:*"
9+
}
10+
}

0 commit comments

Comments
 (0)