From aabfa6b17823cd71cd413fa9b0e0c2bc7f2fc479 Mon Sep 17 00:00:00 2001 From: Gabriel Utzig Date: Fri, 31 Jul 2026 15:32:59 -0300 Subject: [PATCH] feat(chess): add the engine adapter behind a single boundary Implements RFC-001. The official chess.js replaces the personal fork the old code depended on, and ChessEngineService becomes the only place in the project allowed to import it. Position is exchanged as PGN. The service is stateless: every method takes the PGN and returns a described position, which is what lets threefold repetition and the fifty move rule come from the engine instead of being derived by hand. Those two rules depend on the history of positions, not on the current one, so replaying the PGN is the whole point of storing it. A test asserts exactly that: the same position reached by replay reports threefold repetition, and the same position loaded from its own FEN reports nothing. This refines the proposal, which had loadGame returning an opaque handle that the other calls would take. Passing the PGN removes the risk of leaking a Chess instance disguised as an opaque type, makes each method pure, and costs two reparses per move flow, which is microseconds against a Mongo round trip. The RFC records the change and the reason. An illegal move returns null rather than throwing. chess.js 1.x throws from move(), and converting that at the boundary keeps the business rules in RFC-003 free of try/catch. The boundary is enforced, not documented: a no-restricted-imports rule fails the lint if any file outside src/chess imports chess.js. Verified by feeding it a file that does. Arbitrary positions reach the tests through a PGN carrying a SetUp and FEN header, which chess.js honours, so mate, stalemate, insufficient material and the fifty move counter are all exercised without adding a FEN entry point to the public contract. RFC-005 will use the same trick. 26 unit tests, no database and no application. --- .eslintrc.js | 18 +++ docs/rfcs/001-chess-engine-adapter.md | 45 ++++-- docs/rfcs/README.md | 2 +- package-lock.json | 7 + package.json | 1 + src/chess/chess-engine.service.spec.ts | 195 ++++++++++++++++++++++++ src/chess/chess-engine.service.ts | 70 +++++++++ src/chess/chess.module.ts | 8 + src/chess/interfaces/chess.interface.ts | 21 +++ 9 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 src/chess/chess-engine.service.spec.ts create mode 100644 src/chess/chess-engine.service.ts create mode 100644 src/chess/chess.module.ts create mode 100644 src/chess/interfaces/chess.interface.ts diff --git a/.eslintrc.js b/.eslintrc.js index 259de13..9571306 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,5 +21,23 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'chess.js', + message: + 'Only src/chess may import the engine. Consume it through ChessEngineService.', + }, + ], + }, + ], }, + overrides: [ + { + files: ['src/chess/**/*.ts'], + rules: { 'no-restricted-imports': 'off' }, + }, + ], }; diff --git a/docs/rfcs/001-chess-engine-adapter.md b/docs/rfcs/001-chess-engine-adapter.md index 2bc4523..a7c4bfe 100644 --- a/docs/rfcs/001-chess-engine-adapter.md +++ b/docs/rfcs/001-chess-engine-adapter.md @@ -1,6 +1,6 @@ # RFC-001: Chess engine adapter -**Status:** Proposto +**Status:** Implementado **Depende de:** — ## Summary @@ -27,27 +27,37 @@ Sem esta RFC nada mais anda: as RFCs 002 a 006 dependem de saber o que é uma po src/chess/ ├── chess.module.ts ├── chess-engine.service.ts única importação de chess.js no projeto -├── interfaces/ -│ ├── position.interface.ts -│ └── move-result.interface.ts +├── interfaces/chess.interface.ts └── chess-engine.service.spec.ts ``` Contrato: ```ts -loadGame(pgn: string): LoadedGame -legalMoves(game: LoadedGame): string[] -applyMove(game: LoadedGame, san: string): MoveResult -outcome(game: LoadedGame): GameOutcome | null -turn(game: LoadedGame): 'w' | 'b' +readonly initialFen: string +position(pgn: string): ChessPosition +legalMoves(pgn: string): string[] +applyMove(pgn: string, san: string): AppliedMove | null ``` -`LoadedGame` é um tipo opaco do módulo. Nenhum consumidor recebe um `Chess`. - -`MoveResult` traz `{ san, fen, pgn, moveNumber, color }`. +`ChessPosition` é `{ fen, pgn, turn, moveNumber, outcome }`. +`AppliedMove` estende com `{ san, color }`. `GameOutcome` é `'checkmate' | 'stalemate' | 'insufficientMaterial' | 'threefoldRepetition' | 'fiftyMoveRule'`. +> **Refinamento na implementação.** A proposta original era `loadGame(pgn)` devolvendo um +> `LoadedGame` opaco que as outras chamadas receberiam. O serviço acabou **stateless, com o +> PGN como único parâmetro**: some o problema de vazar um `Chess` disfarçado de tipo opaco, +> cada método vira função pura e o teste não precisa montar handle nenhum. O custo é +> reparsear o PGN por chamada — um fluxo de lance faz dois parses, na casa dos +> microssegundos, contra um round-trip de Mongo. + +A fronteira não é convenção: uma regra `no-restricted-imports` no ESLint quebra o build se +qualquer arquivo fora de `src/chess` importar `chess.js`. + +Posições arbitrárias entram por PGN com cabeçalho `[SetUp "1"] [FEN "…"]`, que o `chess.js` +honra. É como os testes montam mate, afogamento e material insuficiente sem precisar de uma +entrada por FEN no contrato público — e é o que a RFC-005 vai usar. + ## Data model O que a partida persiste, e por quê: @@ -72,7 +82,9 @@ PGN vence — e um teste garante que não divergem. ## Behaviour - `applyMove` valida o SAN contra os lances legais da posição. Lance ilegal **não** lança: - devolve um resultado de erro tipado, e quem decide o status HTTP é a RFC-003. + devolve `null`, e quem decide o status HTTP é a RFC-003. O `chess.js` 1.x lança em + `move()` para lance ilegal — o adapter converte isso em valor de retorno, para que a + regra de negócio não dependa de `try/catch`. - O FEN inicial completo (`rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1`) passa a ser constante do módulo. O legado guardava só a parte de peças (§10.4 do legado), e qualquer cliente que confiasse nele antes do primeiro lance recebia FEN @@ -101,4 +113,9 @@ paga por isso: ## Open questions -Nenhuma. A decisão de motor e de persistência foi tomada; esta RFC só a registra. +1. **Ordem da tabela de terminação.** `insufficientMaterial` é avaliado antes de + `stalemate` e de `threefoldRepetition`, o que importa para a RFC-005: uma desistência + numa posição de material insuficiente vira empate. Xeque-mate vem antes de tudo, e há + teste para isso. +2. **Notação UCI.** O adapter é o lugar natural para aceitar `e2e4` além de SAN, mas + dobraria a matriz de teste da RFC-003. Fica para depois do básico. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 4d95257..96d2d94 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -8,7 +8,7 @@ esses problemas, e o documento registra tanto o que ela acertou quanto onde erro | # | Título | Status | Depende de | |---|---|---|---| -| [001](001-chess-engine-adapter.md) | Chess engine adapter | Proposto | — | +| [001](001-chess-engine-adapter.md) | Chess engine adapter | **Implementado** | — | | [002](002-game-lifecycle.md) | Game lifecycle | Proposto | 001 | | [003](003-moves.md) | Moves | Proposto | 001, 002 | | [004](004-clock.md) | Clock and time control | Proposto | 002, 003 | diff --git a/package-lock.json b/package-lock.json index f6fbf81..344b39c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/swagger": "^11.1.1", "@nestjs/throttler": "^6.5.0", "bcrypt": "^6.0.0", + "chess.js": "^1.4.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", @@ -4134,6 +4135,12 @@ "dev": true, "license": "MIT" }, + "node_modules/chess.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", + "integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==", + "license": "BSD-2-Clause" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", diff --git a/package.json b/package.json index 9d1cb92..1011dfa 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@nestjs/swagger": "^11.1.1", "@nestjs/throttler": "^6.5.0", "bcrypt": "^6.0.0", + "chess.js": "^1.4.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", diff --git a/src/chess/chess-engine.service.spec.ts b/src/chess/chess-engine.service.spec.ts new file mode 100644 index 0000000..ac19e77 --- /dev/null +++ b/src/chess/chess-engine.service.spec.ts @@ -0,0 +1,195 @@ +import { Chess } from 'chess.js'; +import { ChessEngineService, INITIAL_FEN } from './chess-engine.service'; + +describe('ChessEngineService', () => { + const engine = new ChessEngineService(); + + const pgnOf = (moves: string[]): string => { + const board = new Chess(); + moves.forEach((move) => board.move(move)); + + return board.pgn(); + }; + + const pgnFrom = (fen: string, moves: string[] = []): string => { + const board = new Chess(fen); + moves.forEach((move) => board.move(move)); + + return board.pgn(); + }; + + const foolsMate = ['f3', 'e5', 'g4', 'Qh4#']; + const repetition = ['Nf3', 'Nf6', 'Ng1', 'Ng8', 'Nf3', 'Nf6', 'Ng1', 'Ng8']; + + describe('starting position', () => { + it('exposes a complete and parseable initial fen', () => { + expect(engine.initialFen).toBe(INITIAL_FEN); + expect(() => new Chess(engine.initialFen)).not.toThrow(); + }); + + it('describes an empty pgn as the starting position', () => { + const position = engine.position(''); + + expect(position.fen).toBe(INITIAL_FEN); + expect(position.turn).toBe('w'); + expect(position.moveNumber).toBe(1); + expect(position.outcome).toBeNull(); + }); + + it('offers the twenty opening moves', () => { + expect(engine.legalMoves('')).toHaveLength(20); + }); + }); + + describe('applyMove', () => { + it('applies a legal move and hands the turn over', () => { + const move = engine.applyMove('', 'e4'); + + expect(move).not.toBeNull(); + expect(move?.san).toBe('e4'); + expect(move?.color).toBe('w'); + expect(move?.turn).toBe('b'); + }); + + it('returns null for a move that is not legal in this position', () => { + expect(engine.applyMove('', 'e5')).toBeNull(); + expect(engine.applyMove('', 'Ke2')).toBeNull(); + }); + + it('returns null for notation the engine cannot read', () => { + expect(engine.applyMove('', 'Xz9')).toBeNull(); + expect(engine.applyMove('', '')).toBeNull(); + expect(engine.applyMove('', 'e4e5e6e7')).toBeNull(); + }); + + it('leaves the given pgn untouched when the move is rejected', () => { + const pgn = pgnOf(['e4', 'e5']); + + engine.applyMove(pgn, 'Qxf7'); + + expect(engine.position(pgn).pgn).toBe(pgn); + }); + + it('grows the pgn so the next call sees the move', () => { + const afterFirst = engine.applyMove('', 'e4'); + const afterSecond = engine.applyMove(afterFirst?.pgn ?? '', 'e5'); + + expect(afterSecond?.moveNumber).toBe(2); + expect(afterSecond?.pgn).toContain('e4'); + expect(afterSecond?.pgn).toContain('e5'); + }); + }); + + describe('san notation', () => { + it.each([ + ['a pawn push', '', 'e4'], + ['a piece move', '', 'Nf3'], + ['a capture', pgnOf(['e4', 'd5']), 'exd5'], + ['a check', pgnOf(['e4', 'e5', 'Nf3', 'Nc6', 'Bc4', 'd6']), 'Ng5'], + ])('accepts %s', (_case, pgn, san) => { + expect(engine.applyMove(pgn, san)).not.toBeNull(); + }); + + it('accepts kingside castling', () => { + const pgn = pgnOf(['e4', 'e5', 'Nf3', 'Nc6', 'Bc4', 'Bc5']); + + expect(engine.applyMove(pgn, 'O-O')).not.toBeNull(); + }); + + it('accepts queenside castling', () => { + const pgn = pgnOf(['d4', 'd5', 'Nc3', 'Nc6', 'Bf4', 'Bf5', 'Qd2', 'Qd7']); + + expect(engine.applyMove(pgn, 'O-O-O')).not.toBeNull(); + }); + + it('accepts promotion', () => { + const move = engine.applyMove( + pgnFrom('8/P6k/8/8/8/8/8/K7 w - - 0 1'), + 'a8=Q', + ); + + expect(move?.san).toBe('a8=Q'); + }); + + it('accepts en passant', () => { + const move = engine.applyMove( + pgnFrom('4k3/8/8/3pP3/8/8/8/4K3 w - d6 0 2'), + 'exd6', + ); + + expect(move).not.toBeNull(); + expect(move?.fen).toContain('3P4'); + }); + }); + + describe('outcome', () => { + it('reports nothing while the game is alive', () => { + expect(engine.position(pgnOf(['e4', 'e5'])).outcome).toBeNull(); + }); + + it('detects checkmate', () => { + expect(engine.position(pgnOf(foolsMate)).outcome).toBe('checkmate'); + }); + + it('detects stalemate', () => { + const stalemate = pgnFrom('7k/5Q2/6K1/8/8/8/8/8 b - - 0 1'); + + expect(engine.position(stalemate).outcome).toBe('stalemate'); + }); + + it('detects insufficient material', () => { + const bareKings = pgnFrom('4k3/8/8/8/8/8/8/4K3 w - - 0 1'); + + expect(engine.position(bareKings).outcome).toBe('insufficientMaterial'); + }); + + it('detects the fifty move rule', () => { + const almostFifty = pgnFrom('7k/8/8/4K3/8/8/8/6R1 w - - 99 100', ['Rg2']); + + expect(engine.position(almostFifty).outcome).toBe('fiftyMoveRule'); + }); + + it('reports checkmate ahead of any draw condition', () => { + const mateWithBareBoard = pgnFrom('7k/8/6QK/8/8/8/8/8 w - - 0 1', [ + 'Qg7#', + ]); + + expect(engine.position(mateWithBareBoard).outcome).toBe('checkmate'); + }); + }); + + describe('threefold repetition', () => { + it('is reconstructed by replaying the pgn', () => { + const pgn = pgnOf(repetition); + + expect(engine.position(pgn).outcome).toBe('threefoldRepetition'); + }); + + it('is not reported before the third occurrence', () => { + const pgn = pgnOf(repetition.slice(0, 4)); + + expect(engine.position(pgn).outcome).toBeNull(); + }); + + it('cannot be derived from the fen alone, which is why the pgn is stored', () => { + const pgn = pgnOf(repetition); + const fenOnly = engine.position(pgn).fen; + + expect(engine.position(pgn).outcome).toBe('threefoldRepetition'); + expect(engine.position(pgnFrom(fenOnly)).outcome).toBeNull(); + }); + }); + + describe('derived fen', () => { + it('always matches the position the pgn replays to', () => { + const moves = ['e4', 'e5', 'Nf3', 'Nc6', 'Bb5', 'a6', 'Ba4', 'Nf6']; + const pgn = pgnOf(moves); + + const fromPgn = engine.position(pgn).fen; + const replayed = new Chess(); + moves.forEach((move) => replayed.move(move)); + + expect(fromPgn).toBe(replayed.fen()); + }); + }); +}); diff --git a/src/chess/chess-engine.service.ts b/src/chess/chess-engine.service.ts new file mode 100644 index 0000000..b5c80a3 --- /dev/null +++ b/src/chess/chess-engine.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { Chess } from 'chess.js'; +import { + AppliedMove, + ChessPosition, + GameOutcome, + PieceColor, +} from './interfaces/chess.interface'; + +export const INITIAL_FEN = + 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; + +@Injectable() +export class ChessEngineService { + readonly initialFen = INITIAL_FEN; + + position(pgn: string): ChessPosition { + return this.describe(this.load(pgn)); + } + + legalMoves(pgn: string): string[] { + return this.load(pgn).moves(); + } + + applyMove(pgn: string, san: string): AppliedMove | null { + const board = this.load(pgn); + + try { + const move = board.move(san); + + return { + ...this.describe(board), + san: move.san, + color: move.color, + }; + } catch { + return null; + } + } + + private load(pgn: string): Chess { + const board = new Chess(); + + if (pgn.trim().length > 0) { + board.loadPgn(pgn); + } + + return board; + } + + private describe(board: Chess): ChessPosition { + return { + fen: board.fen(), + pgn: board.pgn(), + turn: board.turn() as PieceColor, + moveNumber: board.moveNumber(), + outcome: this.outcomeOf(board), + }; + } + + private outcomeOf(board: Chess): GameOutcome | null { + if (board.isCheckmate()) return 'checkmate'; + if (board.isInsufficientMaterial()) return 'insufficientMaterial'; + if (board.isStalemate()) return 'stalemate'; + if (board.isThreefoldRepetition()) return 'threefoldRepetition'; + if (board.isDrawByFiftyMoves()) return 'fiftyMoveRule'; + + return null; + } +} diff --git a/src/chess/chess.module.ts b/src/chess/chess.module.ts new file mode 100644 index 0000000..78ee218 --- /dev/null +++ b/src/chess/chess.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ChessEngineService } from './chess-engine.service'; + +@Module({ + providers: [ChessEngineService], + exports: [ChessEngineService], +}) +export class ChessModule {} diff --git a/src/chess/interfaces/chess.interface.ts b/src/chess/interfaces/chess.interface.ts new file mode 100644 index 0000000..32f8691 --- /dev/null +++ b/src/chess/interfaces/chess.interface.ts @@ -0,0 +1,21 @@ +export type PieceColor = 'w' | 'b'; + +export type GameOutcome = + | 'checkmate' + | 'stalemate' + | 'insufficientMaterial' + | 'threefoldRepetition' + | 'fiftyMoveRule'; + +export interface ChessPosition { + fen: string; + pgn: string; + turn: PieceColor; + moveNumber: number; + outcome: GameOutcome | null; +} + +export interface AppliedMove extends ChessPosition { + san: string; + color: PieceColor; +}