Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3d1ed4a
fix(web): enforce player ownership on board interaction
sayed710 Oct 3, 2026
6068e0b
docs: record M15 Increment 87 player board ownership
sayed710 Oct 3, 2026
8245eea
fix(web): drop the trailing click of a gesture cut short by an owner …
sayed710 Oct 3, 2026
a623b11
docs: record the Increment 87 review correction and its validation
sayed710 Oct 3, 2026
98fb8b9
fix(web): clear click suppression when the release that set it makes …
sayed710 Oct 3, 2026
631b373
docs: record the Greptile correction and Linux backend validation for…
sayed710 Oct 3, 2026
38e1bdd
docs: correct Increment 87 over-claims and record the exact-head review
sayed710 Oct 3, 2026
2ae8589
fix(web): tie click suppression to the pointer that produced the click
sayed710 Oct 3, 2026
79686a5
docs: record the pointer-matched suppression fix and its Linux valida…
sayed710 Oct 3, 2026
1bbb5bf
fix(web): bound the no-pointer-id click fallback and ignore other poi…
sayed710 Oct 3, 2026
066f046
docs: record the bounded click fallback and its Linux validation
sayed710 Oct 3, 2026
f98a8b9
fix(web): end a drag on its own pointercancel and on a new press
sayed710 Oct 3, 2026
dd52355
docs: record the drag cancellation fix and its Linux validation
sayed710 Oct 3, 2026
59362b9
fix(web): a new press abandons another pointer's gesture without acti…
sayed710 Oct 3, 2026
b1441a4
docs: record the abandoned-gesture fix and its Linux validation
sayed710 Oct 3, 2026
d569298
fix(web): track abandoned-gesture waits and click suppressions per po…
sayed710 Oct 3, 2026
3bd6965
docs: record per-pointer gesture tracking and its Linux validation
sayed710 Oct 3, 2026
5f7eb72
fix(web): tie a pointer-less click to the last released pointer
sayed710 Oct 3, 2026
8314d11
docs: record the last-release click fallback and its Linux validation
sayed710 Oct 3, 2026
b6246cd
fix(web): bound click records and put abandoned clicks first on engin…
sayed710 Oct 3, 2026
39c2f9d
docs: record bounded click records and the safety-first pointer-less …
sayed710 Oct 3, 2026
13a05c9
fix(web): only recent releases can claim a pointer-less click
sayed710 Oct 3, 2026
242ad1c
docs: record time-bounded pointer-less click matching and its Linux v…
sayed710 Oct 3, 2026
2b52090
test(web): pin the pointer-less click window from both sides
sayed710 Oct 3, 2026
6f49620
docs: record the pinned click-window tests and their Linux validation
sayed710 Oct 3, 2026
2e878c5
merge origin/main (#89) into claude/player-board-ownership
sayed710 Oct 3, 2026
431df30
docs: record the merge with #89 and its Linux validation
sayed710 Oct 3, 2026
fcb91b9
fix(web): an abandoned release swallows every pointer-less click in i…
sayed710 Oct 3, 2026
5793636
docs(web): say when a suppressed click keeps its record
sayed710 Oct 3, 2026
cbb06a2
docs: record the abandoned-click fix and its Linux validation
sayed710 Oct 4, 2026
33736e6
docs: keep one current Increment 88 line in the project-state header
sayed710 Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 209 additions & 1 deletion docs/PROJECT_STATE.md

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions packages/web/e2e/board-ownership.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { expect, test, type APIRequestContext, type BrowserContext, type Locator, type Page } from '@playwright/test';
import { randomUUID } from 'node:crypto';

test.skip(!process.env['GAMBIT_E2E_BACKEND'], 'requires running backend');

interface Registered {
readonly handle: string;
readonly user: { readonly id: string };
readonly tokens: { readonly refreshToken: string };
}

async function register(request: APIRequestContext, prefix: string): Promise<Registered> {
const handle = `${prefix}-${randomUUID().replaceAll('-', '').slice(0, 10)}`;
const registration = await request.post('/v1/auth/register', {
data: { handle, password: 'test-password-123', email: `${handle}@example.test` },
});
expect(registration.ok()).toBeTruthy();
return { handle, ...(await registration.json()) };
}

async function signIn(context: BrowserContext, auth: Registered): Promise<void> {
await context.addCookies([{
name: 'gambit_refresh',
value: auth.tokens.refreshToken,
domain: 'localhost',
path: '/v1/auth',
httpOnly: true,
secure: false,
sameSite: 'Strict',
}]);
await context.addInitScript(({ userHandle, userId }) => {
localStorage.setItem('gambit-session', JSON.stringify({ handle: userHandle, userId }));
}, { userHandle: auth.handle, userId: auth.user.id });
}

/** Every `move` frame the page sends over any WebSocket: the only way a move reaches the server. */
function recordMoveFrames(page: Page): string[] {
const moves: string[] = [];
page.on('websocket', (socket) => {
socket.on('framesent', ({ payload }) => {
const frame = JSON.parse(String(payload)) as { t?: string; uci?: string };
if (frame.t === 'move' && frame.uci) moves.push(frame.uci);
});
});
return moves;
}

/** Keyboard select `from`, then activate `to` (both by focusing the cell directly). */
async function keyboardMove(page: Page, board: Locator, from: string, to: string): Promise<void> {
await board.locator(`[data-square="${from}"]`).focus();
await page.keyboard.press('Enter');
await board.locator(`[data-square="${to}"]`).focus();
await page.keyboard.press('Enter');
}

/** Try every gesture kind on `from`→`to`: click, keyboard (Enter and Space), and drag. */
async function tryAllGestures(page: Page, board: Locator, from: string, to: string): Promise<void> {
await board.locator(`[data-square="${from}"]`).click();
await board.locator(`[data-square="${to}"]`).click();
await keyboardMove(page, board, from, to);
await board.locator(`[data-square="${from}"]`).focus();
await page.keyboard.press('Space');
await board.locator(`[data-square="${to}"]`).focus();
await page.keyboard.press('Space');
await board.locator(`[data-square="${from}"]`).dragTo(board.locator(`[data-square="${to}"]`));
}

async function expectUntouched(board: Locator, squares: Readonly<Record<string, string>>): Promise<void> {
await expect(board.locator('[aria-selected="true"]')).toHaveCount(0);
await expect(board.locator('[aria-description*="premove"]')).toHaveCount(0);
for (const [sq, label] of Object.entries(squares)) {
await expect(board.locator(`[data-square="${sq}"]`)).toHaveAttribute('aria-label', label);
}
}

test('players move and premove only their own colour; a spectator moves nothing by any gesture', async ({ browser, request }) => {
const white = await register(request, 'e2e-own-w');
const black = await register(request, 'e2e-own-b');
const gameResponse = await request.post('/e2e/games', { data: { whiteId: white.user.id, blackId: black.user.id } });
expect(gameResponse.ok()).toBeTruthy();
const game = await gameResponse.json();

const whiteContext = await browser.newContext();
const blackContext = await browser.newContext();
const spectatorContext = await browser.newContext();
try {
await signIn(whiteContext, white);
await signIn(blackContext, black);

const whitePage = await whiteContext.newPage();
const whiteMoves = recordMoveFrames(whitePage);
await whitePage.goto(`/game/${game.gameId}`);
const whiteBoard = whitePage.locator('.cb-board');
const whiteStatus = whitePage.locator('#status');
await expect(whiteStatus).toHaveText(/your move/i, { timeout: 15_000 });

const blackPage = await blackContext.newPage();
const blackMoves = recordMoveFrames(blackPage);
await blackPage.goto(`/game/${game.gameId}`);
const blackBoard = blackPage.locator('.cb-board');
await expect(blackPage.locator('#meta-role')).toHaveText(/black/i, { timeout: 15_000 });

// Black, off-turn: White is the side to move, but White's pieces are not Black's.
await tryAllGestures(blackPage, blackBoard, 'e2', 'e4');
await expectUntouched(blackBoard, { e2: 'e2, white pawn', e4: 'e4, empty' });
await expect(blackPage.locator('#move-feedback')).toBeEmpty();
expect(blackMoves).toEqual([]);

// A spectator: every gesture on either colour does nothing, and is not called illegal.
const spectator = await spectatorContext.newPage();
const spectatorMoves = recordMoveFrames(spectator);
await spectator.goto(`/game/${game.gameId}`);
await expect(spectator.locator('#meta-role')).toHaveText(/spectat/i, { timeout: 15_000 });
const spectatorBoard = spectator.locator('.cb-board');
for (const [from, to] of [['e2', 'e4'], ['e7', 'e5']] as const) {
await tryAllGestures(spectator, spectatorBoard, from, to);
}
await expectUntouched(spectatorBoard, { e2: 'e2, white pawn', e4: 'e4, empty', e7: 'e7, black pawn', e5: 'e5, empty' });
await expect(spectator.locator('#move-feedback')).toBeEmpty();
// Read-only, not dead: keyboard navigation still moves focus around the grid.
await spectatorBoard.locator('[data-square="e2"]').focus();
await spectator.keyboard.press('ArrowUp');
await expect(spectatorBoard.locator('[data-square="e3"]')).toBeFocused();
expect(spectatorMoves).toEqual([]);

// White, on turn: Black's pieces are not selectable; the legal keyboard move commits.
await whiteBoard.locator('[data-square="e7"]').click();
await expect(whiteBoard.locator('[aria-selected="true"]')).toHaveCount(0);
await keyboardMove(whitePage, whiteBoard, 'e2', 'e4');
await expect(whiteStatus).toHaveText(/black to move/i, { timeout: 15_000 });
expect(whiteMoves).toEqual(['e2e4']);

// White, off-turn: Black is now the side to move, and still not White's to touch.
await tryAllGestures(whitePage, whiteBoard, 'e7', 'e5');
await expectUntouched(whiteBoard, { e7: 'e7, black pawn', e5: 'e5, empty' });
// An own-colour premove still queues, and is not sent.
await keyboardMove(whitePage, whiteBoard, 'd2', 'd4');
await expect(whiteBoard.locator('[data-square="d4"]')).toHaveAttribute('aria-description', /premove/);
expect(whiteMoves).toEqual(['e2e4']);

// Black, on turn: White's pieces still are not Black's; Black's legal move commits.
await expect(blackBoard.locator('[data-square="e4"]')).toHaveAttribute('aria-label', 'e4, white pawn', { timeout: 15_000 });
await blackBoard.locator('[data-square="d2"]').click();
await expect(blackBoard.locator('[aria-selected="true"]')).toHaveCount(0);
await keyboardMove(blackPage, blackBoard, 'e7', 'e5');
await expect(blackBoard.locator('[data-square="e5"]')).toHaveAttribute('aria-label', 'e5, black pawn', { timeout: 15_000 });
expect(blackMoves).toEqual(['e7e5']);

// The spectator saw both moves arrive and is still read-only after the updates.
await expect(spectatorBoard.locator('[data-square="e5"]')).toHaveAttribute('aria-label', 'e5, black pawn', { timeout: 15_000 });
await tryAllGestures(spectator, spectatorBoard, 'g1', 'f3');
await tryAllGestures(spectator, spectatorBoard, 'g8', 'f6');
await expectUntouched(spectatorBoard, { g1: 'g1, white knight', f3: 'f3, empty', g8: 'g8, black knight', f6: 'f6, empty' });
expect(spectatorMoves).toEqual([]);
} finally {
await spectatorContext.close();
await blackContext.close();
await whiteContext.close();
}
});
12 changes: 6 additions & 6 deletions packages/web/e2e/illegal-move-feedback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,14 @@ test('a keyboard player hears a rejected move, nothing is sent, and the next leg
await page.click('#confirm-resign-yes');
await expect(status).toHaveText(/resignation/i, { timeout: 15_000 });
const finalStatus = await status.textContent();
// It ended on Black's turn; off-turn the board offers the side to move, so try Black's pawn.
await board.locator('[data-square="e7"]').focus();
// It ended on Black's turn, so a live board would take White's own off-turn premove; this one must not.
await board.locator('[data-square="d2"]').focus();
await page.keyboard.press('Enter');
await expect(board.locator('[data-square="e7"]')).toHaveAttribute('aria-selected', 'false');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await expect(board.locator('[data-square="d2"]')).toHaveAttribute('aria-selected', 'false');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
await expect(board.locator('[data-square="e5"]')).not.toHaveAttribute('aria-description', /premove/);
await expect(board.locator('[data-square="d4"]')).not.toHaveAttribute('aria-description', /premove/);
await expect(status).toHaveText(finalStatus ?? '');
await expect(feedback).toBeEmpty();
expect(sentMoves).toEqual(['e2e4']);
Expand Down
1 change: 1 addition & 0 deletions packages/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const backendSpecs = [
'account-security-sessions.spec.ts',
'achievements.spec.ts',
'analysis.spec.ts',
'board-ownership.spec.ts',
'forum.spec.ts',
'game-actions.spec.ts',
'game-keyboard.spec.ts',
Expand Down
20 changes: 19 additions & 1 deletion packages/web/src/app/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { LegalMoveOracle } from '../ports/move-oracle.js';
import { applyMove } from '../core/mover.js';
import { STARTING_FEN } from '../core/position.js';
import type { Premove } from '../core/premove.js';
import type { Color } from '../core/board.js';
import { createI18nManager, type I18nManager } from '../i18n/manager.js';
import { createLtrElement } from '../i18n/bidi.js';

Expand Down Expand Up @@ -63,6 +64,11 @@ export interface MountBoardOptions {
readonly onMove?: (uci: string) => void;
/** Localization manager for board status copy; optional for test resilience. */
readonly i18n?: I18nManager;
/**
* The colour this client may move, or `null` for none until {@link MountedBoard.setPlayerColor}
* says otherwise. Omit only for a board without players, which moves whichever side is to move.
*/
readonly playerColor?: Color | null;
}

/** Handle to the mounted board. */
Expand All @@ -76,6 +82,8 @@ export interface MountedBoard {
setTurn: (myTurn: boolean) => void;
/** Accept or ignore move input (off once the game is over). */
setInputEnabled: (enabled: boolean) => void;
/** Change whose pieces may be moved (`null`: nobody, e.g. a spectator). */
setPlayerColor: (color: Color | null) => void;
/** Set the board orientation ('white' or 'black' perspective). */
setOrientation: (orientation: 'white' | 'black') => void;
/**
Expand Down Expand Up @@ -133,7 +141,11 @@ export function mountBoard(
let fen = STARTING_FEN;
const oracle = options?.oracle ?? new NullMoveOracle();
const onMove = options?.onMove;
const interaction = new BoardInteraction({ oracle, myTurn: true });
const interaction = new BoardInteraction({
oracle,
myTurn: true,
...(options?.playerColor !== undefined ? { playerColor: options.playerColor } : {}),
});

const i18n = options?.i18n ?? createI18nManager();
type StatusKey = 'board.status.played' | 'board.status.premoveSet';
Expand Down Expand Up @@ -256,6 +268,12 @@ export function mountBoard(
clearFeedback();
view.setInputEnabled(enabled);
},
// Same churn guard as input: the game route re-asserts the owner on every action-state update.
setPlayerColor: (color: Color | null) => {
if (color === interaction.playerColor) return;
clearFeedback();
view.setPlayerColor(color);
},
setOrientation: (orientation: 'white' | 'black') => {
if (view.orientationColor !== orientation) view.flip();
},
Expand Down
19 changes: 17 additions & 2 deletions packages/web/src/app/game-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,9 +1189,23 @@ export function mountGame(deps: GameMountDependencies): MountedGame {
controller.submitMove(uci);
},
i18n,
// Nobody owns the pieces until the server says who we are.
playerColor: null,
},
);

// Move input belongs to a joined player, for their own colour, while the game is live. Both inputs
// are read from one authoritative snapshot, so whichever of onColor / onActionState reports first
// cannot open a window between them; and an ended game never goes live again.
let boardFinished = false;
const syncBoardOwnership = (): void => {
const { myColor, status } = gameSync.getState();
if (status?.over === true) boardFinished = true;
const owner = boardFinished || myColor === null ? null : myColor === 'w' ? 'white' : 'black';
board.setPlayerColor(owner);
board.setInputEnabled(owner !== null);
};

const renderMetadata = (state: GameMetadataState): void => {
let liveAnnouncement = '';

Expand Down Expand Up @@ -1458,6 +1472,7 @@ export function mountGame(deps: GameMountDependencies): MountedGame {
},
onColor: (color) => {
if (color === 'b') board.setOrientation('black');
syncBoardOwnership();
},
onMetadata: (state) => {
lastMetadataState = state;
Expand All @@ -1475,8 +1490,8 @@ export function mountGame(deps: GameMountDependencies): MountedGame {
},
onActionState: (state) => {
lastActionState = state;
// A finished board takes no moves, premoves or rejections; its only gesture was submitting.
board.setInputEnabled(!state.isOver);
// A finished or spectated board takes no moves, premoves or rejections.
syncBoardOwnership();
renderActionState(state);
},
},
Expand Down
48 changes: 40 additions & 8 deletions packages/web/src/core/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,20 @@ export type GestureResult =
*/
| { readonly kind: 'illegal'; readonly from: Square; readonly to: Square };

/**
* Whose pieces gestures may pick up: one colour (a player), `null` (nobody: a spectator, or a player
* whose colour the server has not confirmed yet), or `'side-to-move'` (a standalone board with no
* players, such as analysis or a study).
*/
export type BoardOwner = Color | null | 'side-to-move';

export interface BoardInteractionOptions {
readonly oracle: LegalMoveOracle;
/** The side this client may move. Omit to allow whichever side is to move. */
readonly playerColor?: Color;
/**
* The side this client may move, or `null` for none. Omit only on a board without players: it then
* moves whichever side is to move.
*/
readonly playerColor?: Color | null;
/** Whether it is currently this client's turn to move. Default true. */
readonly myTurn?: boolean;
/** Max chained premoves (see PremoveQueue). Default 1. */
Expand All @@ -61,7 +71,7 @@ interface Pending {

export class BoardInteraction {
private readonly oracle: LegalMoveOracle;
private readonly playerColor: Color | undefined;
private owner: BoardOwner;
private readonly premoves: PremoveQueue;

private pieces = new Map<Square, Piece>();
Expand All @@ -75,7 +85,7 @@ export class BoardInteraction {

constructor(options: BoardInteractionOptions) {
this.oracle = options.oracle;
this.playerColor = options.playerColor;
this.owner = options.playerColor === undefined ? 'side-to-move' : options.playerColor;
this.myTurn = options.myTurn ?? true;
this.premoves = new PremoveQueue(
options.premoveDepth !== undefined ? { maxDepth: options.premoveDepth } : {},
Expand Down Expand Up @@ -119,6 +129,22 @@ export class BoardInteraction {
this.premoves.clear();
}

/**
* Change whose pieces this client may move. A gesture, promotion or premove begun for the previous
* owner is dropped, so nothing made under one colour completes under another — or under none.
*/
setPlayerColor(color: Color | null): void {
if (color === this.owner) return;
this.owner = color;
this.clearSelection();
this.pending = null;
this.premoves.clear();
}

get playerColor(): BoardOwner {
return this.owner;
}

get acceptsInput(): boolean {
return this.inputEnabled;
}
Expand All @@ -133,14 +159,15 @@ export class BoardInteraction {

// ---- queries --------------------------------------------------------------

/** The colour this client is allowed to move right now. */
private movableColor(): Color {
return this.playerColor ?? this.sideToMove;
/** The colour this client is allowed to move right now, or `null` for none. */
private movableColor(): Color | null {
return this.owner === 'side-to-move' ? this.sideToMove : this.owner;
}

private isOwnPiece(sq: Square): boolean {
const p = this.pieces.get(sq);
return p !== undefined && (p.color === (this.movableColor() === 'white' ? 'w' : 'b'));
const color = this.movableColor();
return p !== undefined && color !== null && p.color === (color === 'white' ? 'w' : 'b');
}

private isPromotion(from: Square, to: Square): boolean {
Expand Down Expand Up @@ -179,6 +206,11 @@ export class BoardInteraction {
this.clearSelection();
return { kind: 'deselect' };
}
// A drop normally follows a successful dragStart, but the owner can change in between.
if (!this.isOwnPiece(from)) {
this.clearSelection();
return { kind: 'none' };
}
this.setSelection(from);
return this.attempt(to);
}
Expand Down
Loading
Loading