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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@ The nav's **Hmm** menu links to apps hosted off muchq.com (see the
list). Those are external links, not routes — their code lives in their own
repos, not here.

### The lobby, golf, castle, rummy, and thoughts
### The lobby, golf, castle, rummy, chess, and thoughts

`/games` is the lobby (MoonBase#1490): the thoughts world with a panel for the room, its
players and their tables, and the room's chat, all on one stream. A table of any game opens
over the world (MoonBase#1502); `GolfTable`, `CastleTable` and `RummyTable` (MoonBase#245)
are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state over the
room stream's game envelopes. Castle and rummy share the felt and the card faces
(`castle/components/Cards.tsx`, `CastleTable.module.css`). Share links are
(`castle/components/Cards.tsx`, `CastleTable.module.css`). `ChessTable` and `useChessTable`
are chess's: two seats, the board from the viewer's side, the moves the hub lists for the
side to move (the client holds no rules), and the clocks counted down from each view. Share links are
`/games/room/:roomId` and `/games/room/:roomId/table/:gameId`; the old `/golf` and `/castle`
links redirect to them, and `/thoughts` to `/games`. Hiding the panel is how the bare world
is asked for, so that choice is remembered across visits.
Expand Down
114 changes: 114 additions & 0 deletions src/apps/chess/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
describeResult,
formatClock,
lastMoveSquares,
movesTo,
pieceName,
readBoard,
squaresFor,
targetsFrom
} from '../rules'

describe('readBoard', () => {
it('reads the placement field into squares', () => {
const board = readBoard('7k/4P3/6K1/8/8/8/8/8 w - - 0 1')
expect(Object.fromEntries(board)).toEqual({ h8: 'k', e7: 'P', g6: 'K' })
})

it('reads an empty rank run across the whole board', () => {
const board = readBoard('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1')
expect(board.size).toBe(32)
expect(board.get('e4')).toBe('P')
expect(board.get('e2')).toBeUndefined()
expect(board.get('a8')).toBe('r')
expect(board.get('h1')).toBe('R')
})

it('reads nothing from nothing', () => {
expect(readBoard(undefined).size).toBe(0)
expect(readBoard('').size).toBe(0)
})
})

describe('squaresFor', () => {
it('puts White at the bottom: a8 first, h1 last', () => {
const squares = squaresFor('white')
expect(squares).toHaveLength(64)
expect(squares[0]).toBe('a8')
expect(squares[7]).toBe('h8')
expect(squares[63]).toBe('h1')
})

it('turns the board for Black: h1 first, a8 last', () => {
const squares = squaresFor('black')
expect(squares[0]).toBe('h1')
expect(squares[7]).toBe('a1')
expect(squares[63]).toBe('a8')
})
})

describe('moves from the legal list', () => {
const legal = ['e7e8b', 'e7e8n', 'e7e8q', 'e7e8r', 'g6f5', 'g6f6', 'g6f7', 'g6g5', 'g6h5', 'g6h6']

it('the targets of a square are the squares its moves reach, once each', () => {
expect(targetsFrom(legal, 'e7')).toEqual(['e8'])
expect(targetsFrom(legal, 'g6')).toEqual(['f5', 'f6', 'f7', 'g5', 'h5', 'h6'])
expect(targetsFrom(legal, 'h8')).toEqual([])
})

it('a square to a square is one move, or one per promotion piece', () => {
expect(movesTo(legal, 'g6', 'f7')).toEqual(['g6f7'])
expect(movesTo(legal, 'e7', 'e8')).toEqual(['e7e8b', 'e7e8n', 'e7e8q', 'e7e8r'])
expect(movesTo(legal, 'e7', 'e6')).toEqual([])
})
})

describe('lastMoveSquares', () => {
it('names the last move’s two squares, a promotion’s piece aside', () => {
expect(lastMoveSquares(['e2e4', 'e7e8q'])).toEqual(['e7', 'e8'])
expect(lastMoveSquares([])).toEqual([])
})
})

describe('formatClock', () => {
it('reads minutes and seconds, rounding up so zero means gone', () => {
expect(formatClock(180_000)).toBe('3:00')
expect(formatClock(61_001)).toBe('1:02')
expect(formatClock(10_000)).toBe('0:10')
})

it('shows tenths under ten seconds, still rounding up', () => {
expect(formatClock(9_950)).toBe('0:10')
expect(formatClock(9_900)).toBe('9.9')
expect(formatClock(420)).toBe('0.5')
expect(formatClock(50)).toBe('0.1')
expect(formatClock(0)).toBe('0.0')
expect(formatClock(-50)).toBe('0.0')
})
})

describe('pieceName', () => {
it('names a piece by its color and kind', () => {
expect(pieceName('K')).toBe('white king')
expect(pieceName('p')).toBe('black pawn')
expect(pieceName('N')).toBe('white knight')
})
})

describe('describeResult', () => {
it('speaks to the winner, the loser and a draw', () => {
const mate = { ending: 'checkmate' as const, winner: 'alice', winnerColor: 'white' as const }
expect(describeResult(mate, 'alice')).toBe('You won by checkmate')
expect(describeResult(mate, 'bob')).toBe('alice won by checkmate')
expect(describeResult({ ending: 'timeout', winner: 'bob', winnerColor: 'black' }, 'alice')).toBe('bob won on time')
expect(describeResult({ ending: 'resignation', winner: 'bob', winnerColor: 'black' }, 'bob')).toBe('You won by resignation')
expect(describeResult({ ending: 'abandoned', winner: 'bob', winnerColor: 'black' }, 'bob')).toBe('You won: your opponent left')
expect(describeResult({ ending: 'stalemate' }, 'alice')).toBe('Draw by stalemate')
expect(describeResult({ ending: 'insufficientMaterial' }, 'alice')).toBe('Draw: nobody can mate')
expect(describeResult({ ending: 'fiftyMoves' }, 'alice')).toBe('Draw by the fifty-move rule')
expect(describeResult({ ending: 'repetition' }, 'alice')).toBe('Draw by repetition')
// A flag against a bare king draws.
expect(describeResult({ ending: 'timeout' }, 'alice')).toBe('Draw: time ran out, and a bare king cannot win')
})
})
229 changes: 229 additions & 0 deletions src/apps/chess/components/ChessTable.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/* The board, the clocks and chess's own controls. The table's chrome —
title, header row, status line, buttons — is castle's
(CastleTable.module.css), as rummy's is. */

.table {
width: 100%;
max-width: 560px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
color: white;
}

.variant {
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
font-size: 1rem;
}

.danger {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
background: #c0392b;
color: white;
font-weight: 600;
touch-action: manipulation;
}

.danger:disabled {
opacity: 0.45;
cursor: default;
}

.waiting {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}

.seats {
margin: 0;
padding-left: 1.25rem;
}

.clockPick {
display: flex;
gap: 0.5rem;
align-items: center;
}

.play {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

/* The board fits the narrower of the column and the screen's height less
the header, both clocks and the actions (about 19rem), so the whole of
it is on screen; below 16rem the overlay scrolls instead of shrinking
the squares past a tap. The pieces are sized from the board, not the
screen, so they always fit their squares. */
.board {
container-type: inline-size;
display: grid;
grid-template-columns: repeat(8, 1fr);
width: max(16rem, min(100%, calc(100dvh - 19rem)));
aspect-ratio: 1;
align-self: center;
border: 2px solid rgba(0, 0, 0, 0.5);
border-radius: 4px;
overflow: hidden;
}

.square {
position: relative;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
cursor: pointer;
font-size: 9cqi;
line-height: 1;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}

.light {
background-color: #eeeed2;
}

.dark {
background-color: #769656;
}

.square[data-last='true'] {
box-shadow: inset 0 0 0 100vmax rgba(246, 246, 105, 0.45);
}

.square[aria-pressed='true'] {
box-shadow: inset 0 0 0 100vmax rgba(246, 246, 105, 0.8);
}

/* An image over the square's own colour, never in place of it. */
.square[data-check='true'] {
background-image: radial-gradient(circle, #ff3b30 0%, rgba(255, 59, 48, 0.45) 55%, transparent 75%);
}

/* A move's target: a dot on an empty square, a ring round a capture. */
.target::after {
content: '';
position: absolute;
width: 30%;
height: 30%;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
pointer-events: none;
}

.target:has(.whitePiece, .blackPiece)::after {
width: 88%;
height: 88%;
background: none;
border: 4px solid rgba(0, 0, 0, 0.4);
box-sizing: border-box;
}

.square:focus-visible {
outline: 3px solid #f0c96a;
outline-offset: -3px;
}

.whitePiece {
color: #fff;
text-shadow: 0 0 2px #000, 0 0 1px #000;
}

.blackPiece {
color: #111;
text-shadow: 0 0 1px rgba(255, 255, 255, 0.6);
}

/* The files along the bottom edge and the ranks down the left, from the
viewer's side, in the colour of the other square. */
.rank,
.file {
position: absolute;
font-size: 2.6cqi;
font-weight: 600;
line-height: 1;
pointer-events: none;
}

.rank {
top: 0.15em;
left: 0.2em;
}

.file {
bottom: 0.15em;
right: 0.25em;
}

.light .rank,
.light .file {
color: #769656;
}

.dark .rank,
.dark .file {
color: #eeeed2;
}

.clockRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0.75rem;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.3);
}

.running {
background: rgba(240, 201, 106, 0.25);
}

.clockName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.clock {
font-variant-numeric: tabular-nums;
font-size: 1.4rem;
font-weight: 600;
}

.low {
color: #ff6b5e;
}

.promotion,
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: center;
}

.ending {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}

.result {
margin: 0;
font-size: 1.15rem;
color: #f0c96a;
}
Loading
Loading