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
21 changes: 20 additions & 1 deletion docs/rfcs/003-moves.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RFC-003: Moves

**Status:** Proposto
**Status:** Implementado
**Depende de:** RFC-001, RFC-002

## Summary
Expand Down Expand Up @@ -93,6 +93,25 @@ fora da vez → 409; SAN inválido (`Xz9`, `""`, `"e4e5e6e7e8"`) → 400 ou 422;
partida em `WP` → 409; **dois `POST /move` disparados em paralelo: um 200 e um 409, e o
histórico tem exatamente um lance a mais**.

## Notas de implementação

**A concorrência otimista saiu de graça.** Em vez de condicionar o update ao `__v` na mão,
basta `optimisticConcurrency: true` nas opções do `@Schema`: o Mongoose passa a incluir o
`__v` no filtro de todo `save()` e lança `VersionError` quando ele não bate. O service só
traduz isso para 409. Vale para o `join` também, onde duas entradas simultâneas na mesma
vaga passam a ser resolvidas pelo banco.

**A numeração do lance não vem do motor.** O `moveNumber()` do chess.js descreve a
*posição*, então depois de `1. e4` ele ainda devolve 1, e depois de `1... e5` devolve 2 —
usar isso direto numeraria o lance das pretas como se fosse o par seguinte. O número do
registro é derivado do próprio histórico: `floor(history.length / 2) + 1`. Um teste fixa a
sequência esperada em `[1, 1, 2]`.

**A terminação automática entrou junto.** Um lance que dá mate precisa encerrar a partida
no mesmo `save()`, senão o estado fica inconsistente entre a resposta e o banco. Desistência
e empate por acordo continuam com a RFC-005, que vai absorver este `finish()` na tabela de
terminação.

## Open questions

1. **Notação UCI** (`e2e4`) como alternativa ao SAN. O legado só falava SAN. Aceitar as
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ esses problemas, e o documento registra tanto o que ela acertou quanto onde erro
|---|---|---|---|
| [001](001-chess-engine-adapter.md) | Chess engine adapter | **Implementado** | — |
| [002](002-game-lifecycle.md) | Game lifecycle | **Implementado** | 001 |
| [003](003-moves.md) | Moves | Proposto | 001, 002 |
| [003](003-moves.md) | Moves | **Implementado** | 001, 002 |
| [004](004-clock.md) | Clock and time control | Proposto | 002, 003 |
| [005](005-game-termination.md) | Game termination | Proposto | 001, 003, 004 |
| [006](006-realtime-stream.md) | Real time stream | Proposto | 002, 003, 005 |
Expand Down
20 changes: 20 additions & 0 deletions src/games/dto/legal-moves-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { GameResponseDto } from './game-response.dto';
import { GameDocument } from '../schema/game.schema';

export class LegalMovesResponseDto {
@ApiProperty({ type: GameResponseDto })
readonly game: GameResponseDto;

@ApiProperty({
type: [String],
description: 'Every move that is legal in the current position',
example: ['e4', 'e3', 'Nf3', 'Nc3'],
})
readonly moves: string[];

constructor(game: GameDocument, moves: string[]) {
this.game = new GameResponseDto(game);
this.moves = moves;
}
}
15 changes: 15 additions & 0 deletions src/games/dto/move.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length } from 'class-validator';

export class MoveDto {
@ApiProperty({
description:
'The move in standard algebraic notation. Legality is decided by the engine, not by this shape.',
minLength: 2,
maxLength: 10,
example: 'Nf3',
})
@Length(2, 10)
@IsString()
readonly move: string;
}
81 changes: 81 additions & 0 deletions src/games/games.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Expand All @@ -16,6 +17,8 @@ import {
import { GamesService } from './games.service';
import { CreateGameDto } from './dto/create-game.dto';
import { GameResponseDto } from './dto/game-response.dto';
import { LegalMovesResponseDto } from './dto/legal-moves-response.dto';
import { MoveDto } from './dto/move.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AuthenticatedUser } from '../auth/interfaces/jwt-payload.interface';
Expand Down Expand Up @@ -95,4 +98,82 @@ export class GamesController {
): Promise<GameResponseDto> {
return new GameResponseDto(await this.gamesService.join(resourceId, user));
}

@Post(':id/move')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@WrapMessage('Move played')
@ApiOperation({
summary: 'Play a move in standard algebraic notation',
description:
'Legality is decided by the engine. A player holding both colours moves for whichever side has the turn.',
})
@ApiParam({ name: 'id', example: 'brave-crimson-knight-e4' })
@ApiEnvelopeResponse({
status: HttpStatus.OK,
description: 'Move played, the game reflects the new position',
type: GameResponseDto,
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.BAD_REQUEST,
description: 'The move is missing or outside the accepted length',
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Missing, malformed or expired access token',
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.FORBIDDEN,
description: 'The caller holds neither colour in this game',
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.NOT_FOUND,
description: 'No game carries this id',
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.CONFLICT,
description:
'The game is not accepting moves, it is not the caller turn, or the game changed while the request was in flight',
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.UNPROCESSABLE_ENTITY,
description: 'The notation is well formed but the move is not legal here',
})
async move(
@Param('id') resourceId: string,
@CurrentUser() user: AuthenticatedUser,
@Body() moveDto: MoveDto,
): Promise<GameResponseDto> {
return new GameResponseDto(
await this.gamesService.move(resourceId, user, moveDto.move),
);
}

@Get(':id/moves')
@WrapMessage('Legal moves listed')
@ApiOperation({
summary: 'List every move that is legal in the current position',
})
@ApiParam({ name: 'id', example: 'brave-crimson-knight-e4' })
@ApiEnvelopeResponse({
status: HttpStatus.OK,
description: 'The game and the moves available to the side to play',
type: LegalMovesResponseDto,
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.NOT_FOUND,
description: 'No game carries this id',
})
@ApiEnvelopeErrorResponse({
status: HttpStatus.CONFLICT,
description: 'The game is not accepting moves',
})
async legalMoves(
@Param('id') resourceId: string,
): Promise<LegalMovesResponseDto> {
const { game, moves } = await this.gamesService.legalMoves(resourceId);

return new LegalMovesResponseDto(game, moves);
}
}
Loading