From cc78e203b96735009eedf87ad9c6cce08823c457 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:29:02 +0000 Subject: [PATCH] chess: premove a recapture onto the viewer's own piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off turn, a tap or drop on a square holding the viewer's own piece always picked that piece up, so the usual capture premove — retaking on a square the opponent is about to capture on — could not be queued. Now it queues when the picked piece covers that square on an empty board, and picks the other piece up otherwise. --- README.md | 2 +- src/apps/chess/__tests__/rules.test.ts | 29 ++++++++++++ src/apps/chess/components/ChessTable.tsx | 10 ++-- .../components/__tests__/ChessTable.test.tsx | 46 ++++++++++++++++++- src/apps/chess/rules.ts | 27 +++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 008e863f..0cd5c3b7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ rummy) or Chess (`catalog.ts`), and each game says what it is at its own table w those rather than naming games. The card games share the felt, the card faces and the seating (`apps/cards/`). `ChessTable` and `useChessTable` are chess's: two seats, the board from the viewer's side (tap a piece then its square, or drag it), one cancellable premove that is sent only if -the hub lists it when the turn arrives (the client holds no rules), a server-owned starting-position picker before each game, the clocks counted down from each view, and a +the hub lists it when the turn arrives (the client holds no rules; piece geometry only tells a recapture premove onto its own piece from picking that piece up), a server-owned starting-position picker before each game, the clocks counted down from each view, and a score notepad as the table plays on: Next game starts another there, sides swapped. Any chess table can be watched from the lobby by a member at none: the same board from White's side, read-only, until Stop watching, a seat taken, a reconnect, or the hub's `gameLeft` for a table gone before it started (MoonBase#1633). Alone at a diff --git a/src/apps/chess/__tests__/rules.test.ts b/src/apps/chess/__tests__/rules.test.ts index 43d2d880..da9522e9 100644 --- a/src/apps/chess/__tests__/rules.test.ts +++ b/src/apps/chess/__tests__/rules.test.ts @@ -3,6 +3,7 @@ import { imbalance, materialBalance, applyMove, + covers, analysisUrl, moveRows, nameOf, @@ -71,6 +72,34 @@ describe('moves from the legal list', () => { }) }) +// What a premove onto the viewer's own piece may recapture: each piece's +// reach on an empty board, and nothing it does not reach. +describe('covers', () => { + it('each piece takes where it moves, a pawn only forward diagonally', () => { + expect(covers('P', 'e4', 'd5')).toBe(true) + expect(covers('P', 'e4', 'f5')).toBe(true) + expect(covers('P', 'e4', 'e5')).toBe(false) + expect(covers('P', 'e4', 'd3')).toBe(false) + expect(covers('p', 'e5', 'd4')).toBe(true) + expect(covers('p', 'e5', 'd6')).toBe(false) + expect(covers('N', 'g1', 'f3')).toBe(true) + expect(covers('N', 'g1', 'g3')).toBe(false) + expect(covers('b', 'c8', 'h3')).toBe(true) + expect(covers('b', 'c8', 'c3')).toBe(false) + expect(covers('R', 'a1', 'a8')).toBe(true) + expect(covers('R', 'a1', 'b2')).toBe(false) + expect(covers('Q', 'd1', 'h5')).toBe(true) + expect(covers('Q', 'd1', 'd8')).toBe(true) + expect(covers('Q', 'd1', 'e3')).toBe(false) + expect(covers('K', 'g6', 'f7')).toBe(true) + expect(covers('K', 'g6', 'e7')).toBe(false) + }) + + it('nothing covers its own square', () => { + expect(covers('Q', 'd1', 'd1')).toBe(false) + }) +}) + describe('lastMoveSquares', () => { it('names the last move’s two squares, a promotion’s piece aside', () => { expect(lastMoveSquares(['e2e4', 'e7e8q'])).toEqual(['e7', 'e8']) diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index 74a62cb9..7f758434 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom' import type { ChessTableActions } from '@/hooks/useChessTable' import felt from '@/apps/cards/felt.module.css' import type { ChessColor, ChessView } from '../wire' -import { CLOCKS, applyMove, clockLabel, describeMove, describeResult, describeExtras, formatClock, imbalance, lastMoveSquares, materialBalance, movesTo, nameOf, pieceImage, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' +import { CLOCKS, applyMove, clockLabel, covers, describeMove, describeResult, describeExtras, formatClock, imbalance, lastMoveSquares, materialBalance, movesTo, nameOf, pieceImage, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' import styles from './ChessTable.module.css' import ScoreSheet from './ScoreSheet' import AboutGame from '@/apps/lobby/components/AboutGame' @@ -270,9 +270,9 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const piece = board.get(square) const movable = piece !== undefined && colorOfPiece(piece) === myColor && (!myTurn || targetsFrom(view.legalMoves, square).length > 0) - // Off turn, another own piece changes the intended mover instead of - // queueing a self-capture that the hub could never accept. - if (from !== null && !myTurn && movable) { + // Off turn, another own piece changes the intended mover — unless the + // one picked up covers it, which is a recapture queued. + if (from !== null && !myTurn && movable && !covers(board.get(from) ?? '', from, square)) { setPicked(square === from ? null : { view, square }) return } @@ -327,7 +327,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { dragged.current = true const square = squareAt(event.clientX, event.clientY) const piece = square === null ? undefined : board.get(square) - if (!myTurn && square !== null && piece !== undefined && colorOfPiece(piece) === myColor) { + if (!myTurn && square !== null && piece !== undefined && colorOfPiece(piece) === myColor && !covers(board.get(held.square) ?? '', held.square, square)) { setPicked({ view, square }) return } diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index c309146f..47ffe849 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -315,7 +315,7 @@ describe('ChessTable', () => { }) describe('premoving', () => { - const offTurn = () => view({ sideToMove: 'black', currentPlayerId: 'bob', legalMoves: ['h8g7', 'h8g8'] }) + const offTurn = (over: Partial = {}) => view({ sideToMove: 'black', currentPlayerId: 'bob', legalMoves: ['h8g7', 'h8g8'], ...over }) it('queues one move off turn and sends it when the hub says it is legal', () => { const { t, rerender } = mountWith(offTurn()) @@ -414,6 +414,50 @@ describe('ChessTable', () => { expect(t.play).not.toHaveBeenCalled() }) + it('queues a capture of the opponent’s piece', () => { + // Black Nf7 beside White Kg6, bob on turn. + const { t, rerender } = mountWith(offTurn({ fen: '7k/4Pn2/6K1/8/8/8/8/8 b - - 0 1' })) + fireEvent.click(square('g6')) + fireEvent.click(square('f7')) + expect(status()).toHaveTextContent('Premove queued: g6 to f7.') + rerender(view({ fen: '6k1/4Pn2/6K1/8/8/8/8/8 w - - 0 1', legalMoves: ['g6f7'] })) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) + + // A recapture: off turn the square still holds the viewer's own piece, + // the one the opponent is about to take. A piece that reaches it queues + // there; one that cannot picks the other piece up instead. + it('queues a recapture onto a square the viewer’s own piece stands on', () => { + // White Kg6, Pe7, Pf7 against Kh8. + const recapture = offTurn({ fen: '7k/4PP2/6K1/8/8/8/8/8 b - - 0 1' }) + const { t, rerender } = mountWith(recapture) + fireEvent.click(square('g6')) + fireEvent.click(square('f7')) + expect(status()).toHaveTextContent('Premove queued: g6 to f7.') + expect(square('f7')).toHaveAttribute('data-premove', 'to') + rerender(view({ fen: '8/4Pk2/6K1/8/8/8/8/8 w - - 0 1', legalMoves: ['g6f7'] })) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) + + it('queues a recapture by drag', () => { + mountWith(offTurn({ fen: '7k/4PP2/6K1/8/8/8/8/8 b - - 0 1' })) + document.elementFromPoint = () => square('f7') + fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 }) + fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 }) + fireEvent.pointerUp(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 }) + expect(status()).toHaveTextContent('Premove queued: g6 to f7.') + }) + + it('picks up an own piece dropped on that the dragged one cannot reach', () => { + mountWith(offTurn()) + document.elementFromPoint = () => square('e7') + fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 }) + fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 }) + fireEvent.pointerUp(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 }) + expect(square('e7')).toHaveAttribute('aria-pressed', 'true') + expect(screen.queryByRole('button', { name: 'Cancel premove' })).toBeNull() + }) + it('cannot queue a premove after the game has ended', () => { const { t } = mountWith(ended()) fireEvent.click(square('g6')) diff --git a/src/apps/chess/rules.ts b/src/apps/chess/rules.ts index 6da3f3b1..aa443713 100644 --- a/src/apps/chess/rules.ts +++ b/src/apps/chess/rules.ts @@ -60,6 +60,33 @@ export function movesTo(legal: string[], from: string, to: string): string[] { return legal.filter(uci => uci.slice(0, 2) === from && uci.slice(2, 4) === to) } +// Whether the piece on `from` could take on `to`, on an otherwise empty +// board: what a premove onto the viewer's own piece needs, since that +// square is a recapture only once the opponent takes there. Nothing +// blocks a slider; the hub decides when the turn comes. +export function covers(piece: string, from: string, to: string): boolean { + const df = to.charCodeAt(0) - from.charCodeAt(0) + const dr = Number(to[1]) - Number(from[1]) + const [x, y] = [Math.abs(df), Math.abs(dr)] + if (x === 0 && y === 0) return false + switch (piece.toLowerCase()) { + case 'p': + return x === 1 && dr === (piece === 'P' ? 1 : -1) + case 'n': + return x * y === 2 + case 'b': + return x === y + case 'r': + return x === 0 || y === 0 + case 'q': + return x === y || x === 0 || y === 0 + case 'k': + return x <= 1 && y <= 1 + default: + return false + } +} + // The board once `uci` lands, for drawing a move the hub has not yet // answered: its rook too when a king castles, the pawn beside when one // takes en passant. The hub's next view is the truth; this only bridges.