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
24 changes: 23 additions & 1 deletion docs/rfcs/005-game-termination.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RFC-005: Game termination

**Status:** Proposto
**Status:** Implementado
**Depende de:** RFC-001, RFC-003, RFC-004

## Summary
Expand Down Expand Up @@ -94,6 +94,28 @@ uma posição montada; ciclo completo de oferta → retirada → oferta → acei
espectador → 403; desistir duas vezes → 409; **self-play empata consigo mesmo**; todo
encerramento traz `finishedAt` e `result` no envelope.

## Notas de implementação

**A tabela virou uma função pura de três argumentos.** `resolveTermination(outcome,
sideToMove, requested)` recebe o que o motor detectou, de quem é a vez e o motivo pedido, e
devolve `{ result, additionalInfo }` — ou `null` quando não há nada a encerrar. O `finish()`
que a RFC-003 tinha criado foi absorvido: o fluxo de lance agora chama a mesma função com
`requested: null`.

**O vencedor do xeque-mate sai da vez, não de quem jogou.** Nas duas entradas a posição de
mate tem o lado matado na vez, então `winnerAgainst(sideToMove)` serve tanto para o lance
que dá mate quanto para uma desistência numa posição já matada. Isso é o que permitiu
unificar os dois caminhos numa função só.

**Toda mutação passa pelo mesmo portão.** `openMutation` resolve partida, valida jogador e
checa o relógio antes de qualquer coisa — usado por lance, desistência, oferta e retirada.
Sem isso, desistir de uma partida com o tempo já estourado registraria desistência em vez
de derrota por tempo.

**Contra si mesmo, a oferta alterna sozinha.** Quem tem as duas cores marca primeiro a flag
da cor da vez e depois a outra, então duas chamadas encerram em empate. É a leitura direta
do modelo de dois booleanos, sem caso especial.

## Open questions

1. **Empate por material insuficiente com tempo esgotado.** A regra FIDE diz que se o lado
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ esses problemas, e o documento registra tanto o que ela acertou quanto onde erro
| [002](002-game-lifecycle.md) | Game lifecycle | **Implementado** | 001 |
| [003](003-moves.md) | Moves | **Implementado** | 001, 002 |
| [004](004-clock.md) | Clock and time control | **Implementado** | 002, 003 |
| [005](005-game-termination.md) | Game termination | Proposto | 001, 003, 004 |
| [005](005-game-termination.md) | Game termination | **Implementado** | 001, 003, 004 |
| [006](006-realtime-stream.md) | Real time stream | Proposto | 002, 003, 005 |
| [007](007-game-queries.md) | Game queries | Proposto | 002 |

Expand Down
121 changes: 121 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,
Delete,
Get,
HttpCode,
HttpStatus,
Expand Down Expand Up @@ -176,4 +177,124 @@ export class GamesController {

return new LegalMovesResponseDto(game, moves);
}

@Post(':id/resign')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@WrapMessage('Game resigned')
@ApiOperation({
summary: 'Give the game up',
description:
'A position that is already decided keeps its own result: resigning into a dead drawn position is recorded as the draw it already was.',
})
@ApiParam({ name: 'id', example: 'brave-crimson-knight-e4' })
@ApiEnvelopeResponse({
status: HttpStatus.OK,
description: 'The game is finished',
type: GameResponseDto,
})
@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 already finished, or the clock had run out',
})
async resign(
@Param('id') resourceId: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<GameResponseDto> {
return new GameResponseDto(
await this.gamesService.resign(resourceId, user),
);
}

@Post(':id/draw')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@WrapMessage('Draw offered')
@ApiOperation({
summary: 'Offer a draw, or accept the one on the table',
description:
'There is no separate route to accept. The offer is a flag per colour, and the game ends in a draw once both are raised.',
})
@ApiParam({ name: 'id', example: 'brave-crimson-knight-e4' })
@ApiEnvelopeResponse({
status: HttpStatus.OK,
description: 'The offer is on the table, or the game ended in a draw',
type: GameResponseDto,
})
@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 already finished, or the clock had run out',
})
async offerDraw(
@Param('id') resourceId: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<GameResponseDto> {
return new GameResponseDto(
await this.gamesService.offerDraw(resourceId, user),
);
}

@Delete(':id/draw')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@WrapMessage('Draw offer withdrawn')
@ApiOperation({ summary: 'Take back your own draw offer' })
@ApiParam({ name: 'id', example: 'brave-crimson-knight-e4' })
@ApiEnvelopeResponse({
status: HttpStatus.OK,
description: 'The offer is off the table',
type: GameResponseDto,
})
@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:
'There is no offer of yours to withdraw, the game is finished, or the clock had run out',
})
async withdrawDraw(
@Param('id') resourceId: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<GameResponseDto> {
return new GameResponseDto(
await this.gamesService.withdrawDraw(resourceId, user),
);
}
}
145 changes: 117 additions & 28 deletions src/games/games.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@ import { MongoServerError } from 'mongodb';
import { Game, GameDocument, GamePlayer } from './schema/game.schema';
import { CreateGameDto } from './dto/create-game.dto';
import { generateResourceId } from './resource-id';
import { GameState, StatusMessage } from './interfaces/game.interface';
import {
GameResult,
GameState,
OutcomeMessage,
StatusMessage,
} from './interfaces/game.interface';
import { clockAfterMove, projectGame, spentSince } from './game-projection';
clockAfterMove,
opposite,
projectGame,
spentSince,
} from './game-projection';
import {
RequestedTermination,
Termination,
resolveTermination,
} from './termination';
import { ChessEngineService } from '../chess/chess-engine.service';
import { GameOutcome, PieceColor } from '../chess/interfaces/chess.interface';
import { PieceColor } from '../chess/interfaces/chess.interface';
import { AuthenticatedUser } from '../auth/interfaces/jwt-payload.interface';

const RESOURCE_ID_ATTEMPTS = 5;
Expand All @@ -30,6 +35,9 @@ const PLAYABLE_STATES: string[] = [
GameState.active,
];

const sideKey = (color: PieceColor): 'white' | 'black' =>
color === 'w' ? 'white' : 'black';

@Injectable()
export class GamesService {
constructor(
Expand Down Expand Up @@ -91,20 +99,14 @@ export class GamesService {
san: string,
): Promise<GameDocument> {
const game = await this.findPlayableGame(resourceId);
const colors = this.colorsOf(game, user.id);
const colors = this.assertPlayer(game, user.id);

if (colors.length === 0) {
throw new ForbiddenException('You are not a player in this game');
}
if (!colors.includes(game.turn)) {
throw new ConflictException('It is not your turn');
}

const now = new Date();
if (projectGame(game, now).expiredFor) {
await this.finishOnTime(game, now);
throw new ConflictException('Time is up');
}
await this.assertClockAlive(game, now);

const applied = this.chessEngine.applyMove(game.pgn, san);
if (!applied) {
Expand All @@ -123,13 +125,60 @@ export class GamesService {
game.status.state = GameState.active;
game.status.additionalInfo = StatusMessage.inProgress;

if (applied.outcome) {
this.finish(game, applied.outcome, applied.color);
const termination = resolveTermination(applied.outcome, applied.turn, null);
if (termination) {
this.terminate(game, termination, now);
}

return this.persist(game);
}

async resign(
resourceId: string,
user: AuthenticatedUser,
): Promise<GameDocument> {
const { game, colors, now } = await this.openMutation(resourceId, user);
const by = colors.length === 1 ? colors[0] : game.turn;

this.terminate(game, this.resolve(game, { type: 'resignation', by }), now);

return this.persist(game);
}

async offerDraw(
resourceId: string,
user: AuthenticatedUser,
): Promise<GameDocument> {
const { game, colors, now } = await this.openMutation(resourceId, user);
const by = colors.length === 1 ? colors[0] : this.nextUnofferedSide(game);

game.drawOffer[sideKey(by)] = true;

if (game.drawOffer.white && game.drawOffer.black) {
this.terminate(game, this.resolve(game, { type: 'agreement' }), now);
}

return this.persist(game);
}

async withdrawDraw(
resourceId: string,
user: AuthenticatedUser,
): Promise<GameDocument> {
const { game, colors } = await this.openMutation(resourceId, user);
const offered = colors.filter((color) => game.drawOffer[sideKey(color)]);

if (offered.length === 0) {
throw new ConflictException('You have no draw offer to withdraw');
}

offered.forEach((color) => {
game.drawOffer[sideKey(color)] = false;
});

return this.persist(game);
}

async legalMoves(
resourceId: string,
): Promise<{ game: GameDocument; moves: string[] }> {
Expand Down Expand Up @@ -198,20 +247,60 @@ export class GamesService {
await this.persist(game);
}

private finish(
private async openMutation(
resourceId: string,
user: AuthenticatedUser,
): Promise<{ game: GameDocument; colors: PieceColor[]; now: Date }> {
const game = await this.findPlayableGame(resourceId);
const colors = this.assertPlayer(game, user.id);
const now = new Date();

await this.assertClockAlive(game, now);

return { game, colors, now };
}

private assertPlayer(game: GameDocument, userId: string): PieceColor[] {
const colors = this.colorsOf(game, userId);

if (colors.length === 0) {
throw new ForbiddenException('You are not a player in this game');
}

return colors;
}

private async assertClockAlive(game: GameDocument, now: Date): Promise<void> {
if (!projectGame(game, now).expiredFor) return;

await this.finishOnTime(game, now);
throw new ConflictException('Time is up');
}

private nextUnofferedSide(game: GameDocument): PieceColor {
return game.drawOffer[sideKey(game.turn)] ? opposite(game.turn) : game.turn;
}

private resolve(
game: GameDocument,
requested: RequestedTermination,
): Termination {
return resolveTermination(
this.chessEngine.position(game.pgn).outcome,
game.turn,
requested,
) as Termination;
}

private terminate(
game: GameDocument,
outcome: GameOutcome,
movedBy: PieceColor,
termination: Termination,
at: Date,
): void {
game.status.state = GameState.finished;
game.status.finishedAt = new Date();
game.status.additionalInfo = OutcomeMessage[outcome];
game.status.result =
outcome === 'checkmate'
? movedBy === 'w'
? GameResult.whiteWins
: GameResult.blackWins
: GameResult.draw;
game.status.result = termination.result;
game.status.additionalInfo = termination.additionalInfo;
game.status.finishedAt = at;
}

private async persist(game: GameDocument): Promise<GameDocument> {
Expand Down
6 changes: 6 additions & 0 deletions src/games/interfaces/game.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export const StatusMessage = {
wonOnTime: 'Win on time',
} as const;

export const TerminationMessage = {
whiteResigned: 'White resigned',
blackResigned: 'Black resigned',
agreement: 'Draw by agreement',
} as const;

export const OutcomeMessage = {
checkmate: 'Checkmate',
stalemate: 'Stalemate',
Expand Down
Loading