From 54d45b5fb815d984da65cdf7a27e81819b2b0973 Mon Sep 17 00:00:00 2001 From: Gabriel Utzig Date: Fri, 31 Jul 2026 17:16:00 -0300 Subject: [PATCH] feat(games): end games by resignation and by agreement Implements RFC-005. Adds resign, offer a draw and withdraw the offer, and turns termination into one table that every path goes through. The draw keeps the shape the old code had, and it is worth keeping: two booleans, one per colour. Offering is a POST, accepting is the same POST from the other side, and the game ends once both flags are up. It removes an endpoint, removes the intermediate state of an offer pending an answer, and is idempotent for free. Withdrawing clears only your own flag. Detection still beats the requested reason. Before applying what the route asked for, the engine is consulted, so resigning into a position that is already drawn by insufficient material is recorded as that draw rather than as a resignation. The position was already decided before anyone clicked, and the result should not depend on who clicked first. What changed is where that lives. It was an eighty five line switch in a loose function at the bottom of the old service; it is now a pure function of three arguments that returns the result and the message, or nothing when there is nothing to end. The finish() that RFC-003 had introduced is gone, absorbed by the same call with no requested reason. The winner of a checkmate is derived from the side to move rather than from the side that moved. In both entry points the mated side is the one on turn, so a single rule covers the mating move and a resignation in an already mated position, which is what let the two paths collapse into one function. Every mutation now goes through the same gate: resolve the game, check the caller holds a colour, check the clock. Without it, resigning a game whose time had already run out would have been recorded as a resignation instead of a loss on time. A player holding both colours can resign against themselves and can agree a draw in two calls, the offer alternating between the colours on its own. That falls out of the two flag model without a special case. 13 unit tests over the termination table alone, and 22 end-to-end covering the draw cycle, the finished game refusing all four mutations, and playing yourself. --- docs/rfcs/005-game-termination.md | 24 ++- docs/rfcs/README.md | 2 +- src/games/games.controller.ts | 121 +++++++++++ src/games/games.service.ts | 145 ++++++++++--- src/games/interfaces/game.interface.ts | 6 + src/games/termination.spec.ts | 84 ++++++++ src/games/termination.ts | 58 +++++ test/termination.e2e-spec.ts | 288 +++++++++++++++++++++++++ 8 files changed, 698 insertions(+), 30 deletions(-) create mode 100644 src/games/termination.spec.ts create mode 100644 src/games/termination.ts create mode 100644 test/termination.e2e-spec.ts diff --git a/docs/rfcs/005-game-termination.md b/docs/rfcs/005-game-termination.md index d4843d4..3abee5a 100644 --- a/docs/rfcs/005-game-termination.md +++ b/docs/rfcs/005-game-termination.md @@ -1,6 +1,6 @@ # RFC-005: Game termination -**Status:** Proposto +**Status:** Implementado **Depende de:** RFC-001, RFC-003, RFC-004 ## Summary @@ -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 diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 035735a..a3b6536 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -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 | diff --git a/src/games/games.controller.ts b/src/games/games.controller.ts index 62a81e5..1658190 100644 --- a/src/games/games.controller.ts +++ b/src/games/games.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, @@ -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 { + 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 { + 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 { + return new GameResponseDto( + await this.gamesService.withdrawDraw(resourceId, user), + ); + } } diff --git a/src/games/games.service.ts b/src/games/games.service.ts index 76b8da9..3567818 100644 --- a/src/games/games.service.ts +++ b/src/games/games.service.ts @@ -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; @@ -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( @@ -91,20 +99,14 @@ export class GamesService { san: string, ): Promise { 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) { @@ -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 { + 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 { + 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 { + 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[] }> { @@ -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 { + 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 { diff --git a/src/games/interfaces/game.interface.ts b/src/games/interfaces/game.interface.ts index 4672e3e..041fe39 100644 --- a/src/games/interfaces/game.interface.ts +++ b/src/games/interfaces/game.interface.ts @@ -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', diff --git a/src/games/termination.spec.ts b/src/games/termination.spec.ts new file mode 100644 index 0000000..b688c1f --- /dev/null +++ b/src/games/termination.spec.ts @@ -0,0 +1,84 @@ +import { resolveTermination } from './termination'; + +describe('resolveTermination', () => { + describe('nothing to record', () => { + it('returns nothing while the game is alive and nobody asked to end it', () => { + expect(resolveTermination(null, 'w', null)).toBeNull(); + }); + }); + + describe('the board decides on its own', () => { + it('gives checkmate to the side that is not to move', () => { + expect(resolveTermination('checkmate', 'b', null)).toEqual({ + result: 'White wins', + additionalInfo: 'Checkmate', + }); + }); + + it('gives checkmate to black when white is mated', () => { + expect(resolveTermination('checkmate', 'w', null)?.result).toBe( + 'Black wins', + ); + }); + + it.each([ + ['stalemate', 'Stalemate'], + ['insufficientMaterial', 'Draw by insufficient material'], + ['threefoldRepetition', 'Draw by threefold repetition'], + ['fiftyMoveRule', 'Draw by the fifty move rule'], + ] as const)('draws on %s', (outcome, message) => { + expect(resolveTermination(outcome, 'w', null)).toEqual({ + result: 'Draw', + additionalInfo: message, + }); + }); + }); + + describe('a requested end', () => { + it('gives the win to the side that did not resign', () => { + expect( + resolveTermination(null, 'w', { type: 'resignation', by: 'w' }), + ).toEqual({ result: 'Black wins', additionalInfo: 'White resigned' }); + }); + + it('records black resigning', () => { + expect( + resolveTermination(null, 'b', { type: 'resignation', by: 'b' }), + ).toEqual({ result: 'White wins', additionalInfo: 'Black resigned' }); + }); + + it('draws on agreement', () => { + expect(resolveTermination(null, 'w', { type: 'agreement' })).toEqual({ + result: 'Draw', + additionalInfo: 'Draw by agreement', + }); + }); + }); + + describe('the board overrules the request', () => { + it('records a resignation into a dead position as the draw it already was', () => { + expect( + resolveTermination('insufficientMaterial', 'w', { + type: 'resignation', + by: 'w', + }), + ).toEqual({ + result: 'Draw', + additionalInfo: 'Draw by insufficient material', + }); + }); + + it('records a resignation in a mated position as the mate', () => { + expect( + resolveTermination('checkmate', 'w', { type: 'resignation', by: 'b' }), + ).toEqual({ result: 'Black wins', additionalInfo: 'Checkmate' }); + }); + + it('keeps the engine reason over an agreed draw', () => { + expect( + resolveTermination('threefoldRepetition', 'b', { type: 'agreement' }) + ?.additionalInfo, + ).toBe('Draw by threefold repetition'); + }); + }); +}); diff --git a/src/games/termination.ts b/src/games/termination.ts new file mode 100644 index 0000000..0dfe589 --- /dev/null +++ b/src/games/termination.ts @@ -0,0 +1,58 @@ +import { GameOutcome, PieceColor } from '../chess/interfaces/chess.interface'; +import { + GameResult, + GameResultValue, + OutcomeMessage, + TerminationMessage, +} from './interfaces/game.interface'; + +export type RequestedTermination = + | { type: 'resignation'; by: PieceColor } + | { type: 'agreement' }; + +export interface Termination { + result: GameResultValue; + additionalInfo: string; +} + +const winnerAgainst = (loser: PieceColor): GameResultValue => + loser === 'w' ? GameResult.blackWins : GameResult.whiteWins; + +export function resolveTermination( + outcome: GameOutcome | null, + sideToMove: PieceColor, + requested: RequestedTermination | null, +): Termination | null { + if (outcome === 'checkmate') { + return { + result: winnerAgainst(sideToMove), + additionalInfo: OutcomeMessage.checkmate, + }; + } + + if (outcome) { + return { + result: GameResult.draw, + additionalInfo: OutcomeMessage[outcome], + }; + } + + if (!requested) { + return null; + } + + if (requested.type === 'resignation') { + return { + result: winnerAgainst(requested.by), + additionalInfo: + requested.by === 'w' + ? TerminationMessage.whiteResigned + : TerminationMessage.blackResigned, + }; + } + + return { + result: GameResult.draw, + additionalInfo: TerminationMessage.agreement, + }; +} diff --git a/test/termination.e2e-spec.ts b/test/termination.e2e-spec.ts new file mode 100644 index 0000000..2b595fa --- /dev/null +++ b/test/termination.e2e-spec.ts @@ -0,0 +1,288 @@ +import { INestApplication } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import request from 'supertest'; +import { createTestApp, TestApp } from './helpers/create-test-app'; +import { Game, GameDocument } from '../src/games/schema/game.schema'; + +describe('Game termination (e2e)', () => { + let app: INestApplication; + let context: TestApp; + let games: Model; + + const timeControl = { white: 600, black: 600, increment: 5 }; + + beforeAll(async () => { + context = await createTestApp(); + app = context.app; + games = app.get(getModelToken(Game.name)); + }); + + afterAll(async () => { + await context?.close(); + }); + + beforeEach(async () => { + await context.reset(); + }); + + const server = () => app.getHttpServer(); + + const tokenFor = async (username: string): Promise => { + const { body } = await request(server()) + .post('/auth/signup') + .send({ username, password: 'a-valid-password' }) + .expect(201); + + return body.data.accessToken; + }; + + const openTable = async () => { + const white = await tokenFor('magnus'); + const black = await tokenFor('hikaru'); + + const { body } = await request(server()) + .post('/games') + .set('Authorization', `Bearer ${white}`) + .send({ pieces: 'w', timeControl }) + .expect(201); + + await request(server()) + .post(`/games/${body.data.id}/join`) + .set('Authorization', `Bearer ${black}`) + .expect(200); + + return { id: body.data.id as string, white, black }; + }; + + const resign = (token: string, id: string) => + request(server()) + .post(`/games/${id}/resign`) + .set('Authorization', `Bearer ${token}`); + + const offerDraw = (token: string, id: string) => + request(server()) + .post(`/games/${id}/draw`) + .set('Authorization', `Bearer ${token}`); + + const withdrawDraw = (token: string, id: string) => + request(server()) + .delete(`/games/${id}/draw`) + .set('Authorization', `Bearer ${token}`); + + const play = (token: string, id: string, move: string) => + request(server()) + .post(`/games/${id}/move`) + .set('Authorization', `Bearer ${token}`) + .send({ move }); + + const forcePosition = async (id: string, fen: string) => { + const game = await games.findOne({ resourceId: id }).exec(); + if (!game) throw new Error('game vanished'); + + game.pgn = `[SetUp "1"]\n[FEN "${fen}"]\n\n*`; + await game.save(); + }; + + describe('POST /games/:id/resign', () => { + it('hands the win to the other side', async () => { + const table = await openTable(); + + const response = await resign(table.white, table.id).expect(200); + + expect(response.body.message).toBe('Game resigned'); + expect(response.body.data.status).toMatchObject({ + state: 'F', + result: 'Black wins', + additionalInfo: 'White resigned', + }); + expect(response.body.data.status.finishedAt).toEqual(expect.any(String)); + }); + + it('records black resigning', async () => { + const table = await openTable(); + + const response = await resign(table.black, table.id).expect(200); + + expect(response.body.data.status.result).toBe('White wins'); + }); + + it('can be given away out of turn', async () => { + const table = await openTable(); + await play(table.white, table.id, 'e4').expect(200); + + await resign(table.white, table.id).expect(200); + }); + + it('records a resignation into a dead position as the draw it already was', async () => { + const table = await openTable(); + await forcePosition(table.id, '4k3/8/8/8/8/8/8/4K3 w - - 0 1'); + + const response = await resign(table.white, table.id).expect(200); + + expect(response.body.data.status).toMatchObject({ + result: 'Draw', + additionalInfo: 'Draw by insufficient material', + }); + }); + + it('rejects a resignation from someone who is not playing', async () => { + const table = await openTable(); + const spectator = await tokenFor('fabiano'); + + const response = await resign(spectator, table.id).expect(403); + + expect(response.body.message).toBe('You are not a player in this game'); + }); + + it('rejects a second resignation', async () => { + const table = await openTable(); + await resign(table.white, table.id).expect(200); + + await resign(table.black, table.id).expect(409); + }); + + it('rejects a game that does not exist', async () => { + const token = await tokenFor('magnus'); + + await resign(token, 'ghost-game-id-a1').expect(404); + }); + + it('rejects a resignation without a token', async () => { + const table = await openTable(); + + await request(server()).post(`/games/${table.id}/resign`).expect(401); + }); + }); + + describe('the draw is two flags, not a negotiation', () => { + it('leaves the game running on a single offer', async () => { + const table = await openTable(); + + const response = await offerDraw(table.white, table.id).expect(200); + + expect(response.body.message).toBe('Draw offered'); + expect(response.body.data.status.state).toBe('W'); + expect(response.body.data.status.result).toBeNull(); + }); + + it('does not end the game when the same side offers twice', async () => { + const table = await openTable(); + + await offerDraw(table.white, table.id).expect(200); + const response = await offerDraw(table.white, table.id).expect(200); + + expect(response.body.data.status.state).toBe('W'); + }); + + it('ends the game once both sides have offered', async () => { + const table = await openTable(); + + await offerDraw(table.white, table.id).expect(200); + const response = await offerDraw(table.black, table.id).expect(200); + + expect(response.body.data.status).toMatchObject({ + state: 'F', + result: 'Draw', + additionalInfo: 'Draw by agreement', + }); + }); + + it('lets a side take its own offer back', async () => { + const table = await openTable(); + await offerDraw(table.white, table.id).expect(200); + + const response = await withdrawDraw(table.white, table.id).expect(200); + expect(response.body.message).toBe('Draw offer withdrawn'); + + await offerDraw(table.black, table.id).expect(200); + const stored = (await games.findOne({ resourceId: table.id }).exec())!; + expect(stored.status.state).toBe('W'); + }); + + it('rejects a withdrawal when nothing was offered', async () => { + const table = await openTable(); + + const response = await withdrawDraw(table.white, table.id).expect(409); + + expect(response.body.message).toBe('You have no draw offer to withdraw'); + }); + + it('rejects a withdrawal of the other side offer', async () => { + const table = await openTable(); + await offerDraw(table.white, table.id).expect(200); + + await withdrawDraw(table.black, table.id).expect(409); + }); + + it('rejects an offer from someone who is not playing', async () => { + const table = await openTable(); + const spectator = await tokenFor('fabiano'); + + await offerDraw(spectator, table.id).expect(403); + }); + + it('rejects an offer without a token', async () => { + const table = await openTable(); + + await request(server()).post(`/games/${table.id}/draw`).expect(401); + }); + }); + + describe('a finished game accepts nothing', () => { + it.each([ + ['a move', (t: string, id: string) => play(t, id, 'e4')], + ['a resignation', (t: string, id: string) => resign(t, id)], + ['a draw offer', (t: string, id: string) => offerDraw(t, id)], + ['a withdrawal', (t: string, id: string) => withdrawDraw(t, id)], + ])('refuses %s', async (_case, act) => { + const table = await openTable(); + await resign(table.white, table.id).expect(200); + + await act(table.black, table.id).expect(409); + }); + }); + + describe('playing yourself', () => { + it('can resign against itself', async () => { + const solo = await tokenFor('magnus'); + const { body } = await request(server()) + .post('/games') + .set('Authorization', `Bearer ${solo}`) + .send({ pieces: 'w', timeControl }) + .expect(201); + await request(server()) + .post(`/games/${body.data.id}/join`) + .set('Authorization', `Bearer ${solo}`) + .expect(200); + + const response = await resign(solo, body.data.id).expect(200); + + expect(response.body.data.status.state).toBe('F'); + expect(response.body.data.status.result).toBe('Black wins'); + }); + + it('can agree a draw with itself in two calls', async () => { + const solo = await tokenFor('magnus'); + const { body } = await request(server()) + .post('/games') + .set('Authorization', `Bearer ${solo}`) + .send({ pieces: 'w', timeControl }) + .expect(201); + await request(server()) + .post(`/games/${body.data.id}/join`) + .set('Authorization', `Bearer ${solo}`) + .expect(200); + + const first = await offerDraw(solo, body.data.id).expect(200); + expect(first.body.data.status.state).toBe('W'); + + const second = await offerDraw(solo, body.data.id).expect(200); + expect(second.body.data.status).toMatchObject({ + state: 'F', + result: 'Draw', + additionalInfo: 'Draw by agreement', + }); + }); + }); +});