Skip to content
Open
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
18 changes: 18 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
],
};
45 changes: 31 additions & 14 deletions docs/rfcs/001-chess-engine-adapter.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RFC-001: Chess engine adapter

**Status:** Proposto
**Status:** Implementado
**Depende de:** —

## Summary
Expand All @@ -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ê:
Expand All @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
195 changes: 195 additions & 0 deletions src/chess/chess-engine.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
});
Loading