Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/apps/chess/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
imbalance,
materialBalance,
applyMove,
covers,
analysisUrl,
moveRows,
nameOf,
Expand Down Expand Up @@ -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'])
Expand Down
10 changes: 5 additions & 5 deletions src/apps/chess/components/ChessTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
46 changes: 45 additions & 1 deletion src/apps/chess/components/__tests__/ChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ describe('ChessTable', () => {
})

describe('premoving', () => {
const offTurn = () => view({ sideToMove: 'black', currentPlayerId: 'bob', legalMoves: ['h8g7', 'h8g8'] })
const offTurn = (over: Partial<ChessView> = {}) => 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())
Expand Down Expand Up @@ -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'))
Expand Down
27 changes: 27 additions & 0 deletions src/apps/chess/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading