diff --git a/docs/rfcs/004-clock.md b/docs/rfcs/004-clock.md index 2d8c69d..a495cf5 100644 --- a/docs/rfcs/004-clock.md +++ b/docs/rfcs/004-clock.md @@ -1,6 +1,6 @@ # RFC-004: Clock and time control -**Status:** Proposto +**Status:** Implementado **Depende de:** RFC-002, RFC-003 ## Summary @@ -114,6 +114,24 @@ torna o mecanismo testável — mais um motivo para a função ser pura. - um `POST /move` depois do estouro devolve 409 e persiste o fim; - criar com `timeControl` fora da faixa → 400. +## Notas de implementação + +**A projeção mora na camada de view.** `projectGame(game, now)` é função pura em +`game-projection.ts`, e quem a chama é o `GameResponseDto` no construtor. Controllers e +service não precisam saber que existe uma projeção — quem serializa uma partida recebe os +relógios já descontados, e não há caminho de leitura que possa esquecer de projetar. + +**O 409 de tempo esgotado não carrega o resultado.** A RFC dizia que "a resposta traz o +resultado", mas o envelope de erro não tem `data` — inventar um seria abrir exceção no +contrato justamente na camada que existe para não ter exceção. A mutação persiste o fim e +devolve `409 Time is up`; a leitura seguinte mostra `F`, o `result` e o `finishedAt`. Há +teste para os dois lados. + +**O teste de "GET não escreve" precisa de um relógio manipulável, não de espera.** O e2e +recua `startedAt` e os timestamps do histórico direto no documento, e só então lê. Nenhum +teste dorme, e a asserção que fecha a dívida do §10.6 é comparar o `updatedAt` gravado +antes e depois de três leituras. + ## Open questions 1. **Ninguém é notificado do timeout.** Como não há timer, o fim por tempo só se materializa diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 5180e94..035735a 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -11,7 +11,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 | **Implementado** | 001, 002 | -| [004](004-clock.md) | Clock and time control | Proposto | 002, 003 | +| [004](004-clock.md) | Clock and time control | **Implementado** | 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 | | [007](007-game-queries.md) | Game queries | Proposto | 002 | diff --git a/src/games/dto/game-response.dto.ts b/src/games/dto/game-response.dto.ts index f73d3d7..bea4670 100644 --- a/src/games/dto/game-response.dto.ts +++ b/src/games/dto/game-response.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { PieceColor } from '../../chess/interfaces/chess.interface'; import { GameResultValue, GameStateValue } from '../interfaces/game.interface'; import { GameDocument } from '../schema/game.schema'; +import { projectGame } from '../game-projection'; class GameStatusDto { @ApiProperty({ enum: ['WP', 'W', 'A', 'F'], example: 'WP' }) @@ -76,7 +77,9 @@ export class GameResponseDto { @ApiProperty({ example: '2026-07-28T12:00:00.000Z' }) readonly createdAt: Date; - constructor(game: GameDocument) { + constructor(game: GameDocument, now: Date = new Date()) { + const projection = projectGame(game, now); + this.id = game.resourceId; this.whitePlayer = game.whitePlayer?.username ?? null; this.blackPlayer = game.blackPlayer?.username ?? null; @@ -88,17 +91,8 @@ export class GameResponseDto { moveNumber: move.moveNumber, timestamp: move.timestamp, })); - this.timeControl = { - white: game.timeControl.white, - black: game.timeControl.black, - increment: game.timeControl.increment, - }; - this.status = { - state: game.status.state, - result: game.status.result, - additionalInfo: game.status.additionalInfo, - finishedAt: game.status.finishedAt, - }; + this.timeControl = projection.timeControl; + this.status = projection.status; this.createdAt = game.get('createdAt'); } } diff --git a/src/games/game-projection.spec.ts b/src/games/game-projection.spec.ts new file mode 100644 index 0000000..a0c05f4 --- /dev/null +++ b/src/games/game-projection.spec.ts @@ -0,0 +1,166 @@ +import { + clockAfterMove, + opposite, + projectGame, + spentSince, +} from './game-projection'; +import { GameState } from './interfaces/game.interface'; +import { GameDocument } from './schema/game.schema'; + +describe('game projection', () => { + const start = new Date('2026-07-28T12:00:00.000Z'); + const secondsLater = (seconds: number) => + new Date(start.getTime() + seconds * 1000); + + const activeGame = (overrides: Record = {}) => + ({ + turn: 'w', + timeControl: { white: 300, black: 300, increment: 5, turnTime: 300 }, + history: [{ san: 'e5', moveNumber: 1, timestamp: start }], + status: { + state: GameState.active, + result: null, + additionalInfo: 'Game in progress', + finishedAt: null, + }, + ...overrides, + }) as unknown as GameDocument; + + describe('the clock only runs for the side to move', () => { + it('discounts the elapsed time from the side holding the turn', () => { + const projection = projectGame(activeGame(), secondsLater(30)); + + expect(projection.timeControl.white).toBe(270); + }); + + it('leaves the other clock untouched', () => { + const projection = projectGame(activeGame(), secondsLater(30)); + + expect(projection.timeControl.black).toBe(300); + }); + + it('discounts from black when black is to move', () => { + const projection = projectGame( + activeGame({ turn: 'b' }), + secondsLater(45), + ); + + expect(projection.timeControl.black).toBe(255); + expect(projection.timeControl.white).toBe(300); + }); + }); + + describe('the clock does not run before the first move', () => { + it.each([ + GameState.waitingForPlayers, + GameState.waitingForFirstMove, + GameState.finished, + ])('keeps the stored clocks in %s', (state) => { + const projection = projectGame( + activeGame({ + status: { state, result: null, additionalInfo: '', finishedAt: null }, + }), + secondsLater(3600), + ); + + expect(projection.timeControl).toEqual({ + white: 300, + black: 300, + increment: 5, + }); + expect(projection.expiredFor).toBeNull(); + }); + + it('keeps the stored clocks when no move has been recorded yet', () => { + const projection = projectGame( + activeGame({ history: [] }), + secondsLater(3600), + ); + + expect(projection.timeControl.white).toBe(300); + expect(projection.expiredFor).toBeNull(); + }); + }); + + describe('running out of time', () => { + it('reports the side to move as expired', () => { + const projection = projectGame(activeGame(), secondsLater(301)); + + expect(projection.expiredFor).toBe('w'); + expect(projection.timeControl.white).toBe(0); + }); + + it('gives the win to the other side', () => { + const projection = projectGame(activeGame(), secondsLater(301)); + + expect(projection.status).toMatchObject({ + state: GameState.finished, + result: 'Black wins', + additionalInfo: 'Win on time', + }); + }); + + it('gives white the win when black runs out', () => { + const projection = projectGame( + activeGame({ turn: 'b' }), + secondsLater(301), + ); + + expect(projection.status.result).toBe('White wins'); + }); + + it('expires exactly at zero, not a moment before', () => { + expect( + projectGame(activeGame(), secondsLater(299.9)).expiredFor, + ).toBeNull(); + expect(projectGame(activeGame(), secondsLater(300)).expiredFor).toBe('w'); + }); + }); + + describe('projection is pure', () => { + it('never writes back to the document', () => { + const game = activeGame(); + const before = + JSON.stringify(game.timeControl) + JSON.stringify(game.status); + + projectGame(game, secondsLater(30)); + projectGame(game, secondsLater(400)); + + expect( + JSON.stringify(game.timeControl) + JSON.stringify(game.status), + ).toBe(before); + }); + + it('answers differently for different instants from the same document', () => { + const game = activeGame(); + + expect(projectGame(game, secondsLater(10)).timeControl.white).toBe(290); + expect(projectGame(game, secondsLater(60)).timeControl.white).toBe(240); + }); + }); + + describe('charging the clock after a move', () => { + it('subtracts the time spent and adds the increment', () => { + expect(clockAfterMove(300, 12, 5)).toBe(293); + }); + + it('adds nothing when the increment is zero', () => { + expect(clockAfterMove(300, 12, 0)).toBe(288); + }); + + it('never goes below zero', () => { + expect(clockAfterMove(10, 40, 5)).toBe(0); + }); + + it('measures the time spent between two instants', () => { + expect(spentSince(start, secondsLater(12.5))).toBe(12.5); + }); + }); + + describe('opposite', () => { + it('flips the colour', () => { + expect(opposite('w')).toBe('b'); + expect(opposite('b')).toBe('w'); + }); + }); +}); diff --git a/src/games/game-projection.ts b/src/games/game-projection.ts new file mode 100644 index 0000000..abdf849 --- /dev/null +++ b/src/games/game-projection.ts @@ -0,0 +1,93 @@ +import { PieceColor } from '../chess/interfaces/chess.interface'; +import { + GameResult, + GameResultValue, + GameState, + GameStateValue, + StatusMessage, +} from './interfaces/game.interface'; +import { GameDocument } from './schema/game.schema'; + +export interface ProjectedClock { + white: number; + black: number; + increment: number; +} + +export interface ProjectedStatus { + state: GameStateValue; + result: GameResultValue | null; + additionalInfo: string; + finishedAt: Date | null; +} + +export interface GameProjection { + timeControl: ProjectedClock; + status: ProjectedStatus; + expiredFor: PieceColor | null; +} + +const round = (seconds: number): number => + Math.max(0, Math.round(seconds * 10) / 10); + +export const opposite = (color: PieceColor): PieceColor => + color === 'w' ? 'b' : 'w'; + +export function projectGame(game: GameDocument, now: Date): GameProjection { + const { white, black, increment } = game.timeControl; + const stored: GameProjection = { + timeControl: { white, black, increment }, + status: { + state: game.status.state, + result: game.status.result, + additionalInfo: game.status.additionalInfo, + finishedAt: game.status.finishedAt, + }, + expiredFor: null, + }; + + if (game.status.state !== GameState.active) { + return stored; + } + + const since = game.history[game.history.length - 1]?.timestamp; + if (!since) { + return stored; + } + + const remaining = + game.timeControl.turnTime - (now.getTime() - since.getTime()) / 1000; + + if (remaining > 0) { + stored.timeControl[game.turn === 'w' ? 'white' : 'black'] = + round(remaining); + + return stored; + } + + return { + timeControl: { + ...stored.timeControl, + [game.turn === 'w' ? 'white' : 'black']: 0, + }, + status: { + state: GameState.finished, + result: game.turn === 'w' ? GameResult.blackWins : GameResult.whiteWins, + additionalInfo: StatusMessage.wonOnTime, + finishedAt: now, + }, + expiredFor: game.turn, + }; +} + +export function spentSince(reference: Date, at: Date): number { + return (at.getTime() - reference.getTime()) / 1000; +} + +export function clockAfterMove( + turnTime: number, + spent: number, + increment: number, +): number { + return round(turnTime - spent + increment); +} diff --git a/src/games/games.service.spec.ts b/src/games/games.service.spec.ts index f87051d..ca0cd1c 100644 --- a/src/games/games.service.spec.ts +++ b/src/games/games.service.spec.ts @@ -60,6 +60,8 @@ describe('GamesService', () => { whitePlayer: { id: creator.id, username: creator.username }, blackPlayer: null, status: { state: GameState.waitingForPlayers, additionalInfo: '' }, + timeControl: { white: 600, black: 600, increment: 5, turnTime: 600 }, + history: [], startedAt: null, save: jest.fn().mockImplementation(function (this: unknown) { return this; @@ -246,6 +248,8 @@ describe('GamesService', () => { pgn: '', fen: '', history: [], + startedAt: new Date(), + timeControl: { white: 600, black: 600, increment: 5, turnTime: 600 }, status: { state: GameState.waitingForFirstMove, additionalInfo: '', @@ -372,6 +376,71 @@ describe('GamesService', () => { }, ); + it('charges the first white move against the moment the game started', async () => { + const startedAt = new Date(Date.now() - 12_000); + const game = playableGame({ startedAt }); + findReturning(game); + + await service.move('brave-crimson-knight-e4', creator, 'e4'); + + expect(game.timeControl.white).toBeCloseTo(593, 0); + expect(game.timeControl.black).toBe(600); + expect(game.timeControl.turnTime).toBe(600); + }); + + it('charges a later move against the previous move of the same side', async () => { + const now = Date.now(); + const game = playableGame({ + turn: 'b', + pgn: '1. e4', + startedAt: new Date(now - 40_000), + history: [ + { san: 'e4', moveNumber: 1, timestamp: new Date(now - 30_000) }, + ], + status: { + state: GameState.active, + additionalInfo: '', + result: null, + finishedAt: null, + }, + }); + findReturning(game); + + await service.move('brave-crimson-knight-e4', opponent, 'e5'); + + expect(game.timeControl.black).toBeCloseTo(575, 0); + expect(game.timeControl.turnTime).toBe(game.timeControl.white); + }); + + it('finishes the game on time instead of accepting the move', async () => { + const now = Date.now(); + const game = playableGame({ + pgn: '1. e4 e5', + startedAt: new Date(now - 700_000), + history: [ + { san: 'e4', moveNumber: 1, timestamp: new Date(now - 700_000) }, + { san: 'e5', moveNumber: 1, timestamp: new Date(now - 601_000) }, + ], + status: { + state: GameState.active, + additionalInfo: '', + result: null, + finishedAt: null, + }, + }); + findReturning(game); + + await expect( + service.move('brave-crimson-knight-e4', creator, 'Nf3'), + ).rejects.toThrow('Time is up'); + + expect(game.status.state).toBe(GameState.finished); + expect(game.status.result).toBe(GameResult.blackWins); + expect(game.status.additionalInfo).toBe(StatusMessage.wonOnTime); + expect(game.timeControl.white).toBe(0); + expect(game.save).toHaveBeenCalled(); + }); + it('reports a conflict when the game changed underneath the request', async () => { findReturning( playableGame({ diff --git a/src/games/games.service.ts b/src/games/games.service.ts index a5cd806..76b8da9 100644 --- a/src/games/games.service.ts +++ b/src/games/games.service.ts @@ -18,6 +18,7 @@ import { OutcomeMessage, StatusMessage, } from './interfaces/game.interface'; +import { clockAfterMove, projectGame, spentSince } from './game-projection'; import { ChessEngineService } from '../chess/chess-engine.service'; import { GameOutcome, PieceColor } from '../chess/interfaces/chess.interface'; import { AuthenticatedUser } from '../auth/interfaces/jwt-payload.interface'; @@ -99,6 +100,12 @@ export class GamesService { 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'); + } + const applied = this.chessEngine.applyMove(game.pgn, san); if (!applied) { throw new UnprocessableEntityException('Illegal move'); @@ -107,8 +114,9 @@ export class GamesService { game.history.push({ san: applied.san, moveNumber: Math.floor(game.history.length / 2) + 1, - timestamp: new Date(), + timestamp: now, }); + this.chargeClock(game, applied.color, now); game.fen = applied.fen; game.pgn = applied.pgn; game.turn = applied.turn; @@ -158,6 +166,38 @@ export class GamesService { return colors; } + private chargeClock(game: GameDocument, movedBy: PieceColor, at: Date): void { + const previous = + game.history.length >= 2 + ? game.history[game.history.length - 2].timestamp + : game.startedAt; + + if (!previous) return; + + const side = movedBy === 'w' ? 'white' : 'black'; + const other = movedBy === 'w' ? 'black' : 'white'; + + game.timeControl[side] = clockAfterMove( + game.timeControl.turnTime, + spentSince(previous, at), + game.timeControl.increment, + ); + game.timeControl.turnTime = game.timeControl[other]; + } + + private async finishOnTime(game: GameDocument, at: Date): Promise { + const projection = projectGame(game, at); + + game.timeControl.white = projection.timeControl.white; + game.timeControl.black = projection.timeControl.black; + game.status.state = projection.status.state; + game.status.result = projection.status.result; + game.status.additionalInfo = projection.status.additionalInfo; + game.status.finishedAt = projection.status.finishedAt; + + await this.persist(game); + } + private finish( game: GameDocument, outcome: GameOutcome, diff --git a/src/games/interfaces/game.interface.ts b/src/games/interfaces/game.interface.ts index 1d706e8..4672e3e 100644 --- a/src/games/interfaces/game.interface.ts +++ b/src/games/interfaces/game.interface.ts @@ -19,6 +19,7 @@ export const StatusMessage = { waitingForPlayers: 'Waiting for the players to connect', waitingForFirstMove: 'Waiting for the first move', inProgress: 'Game in progress', + wonOnTime: 'Win on time', } as const; export const OutcomeMessage = { diff --git a/test/clock.e2e-spec.ts b/test/clock.e2e-spec.ts new file mode 100644 index 0000000..d9b7501 --- /dev/null +++ b/test/clock.e2e-spec.ts @@ -0,0 +1,187 @@ +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('Clock (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 play = (token: string, id: string, move: string) => + request(server()) + .post(`/games/${id}/move`) + .set('Authorization', `Bearer ${token}`) + .send({ move }); + + const readMoves = (id: string) => request(server()).get(`/games/${id}/moves`); + + const openTable = async (control = timeControl) => { + 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: control }) + .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 rewind = async (id: string, seconds: number) => { + const game = await games.findOne({ resourceId: id }).exec(); + if (!game) throw new Error('game vanished'); + + const shift = seconds * 1000; + game.startedAt = new Date(game.startedAt!.getTime() - shift); + game.history.forEach((move) => { + move.timestamp = new Date(move.timestamp.getTime() - shift); + }); + + await game.save(); + }; + + describe('before the first move', () => { + it('does not run the clock while the game waits', async () => { + const table = await openTable(); + await rewind(table.id, 120); + + const { body } = await readMoves(table.id).expect(200); + + expect(body.data.game.timeControl).toEqual(timeControl); + }); + }); + + describe('after a move', () => { + it('charges the mover and adds the increment', async () => { + const table = await openTable(); + await rewind(table.id, 20); + + const { body } = await play(table.white, table.id, 'e4').expect(200); + + expect(body.data.timeControl.white).toBeLessThan(600); + expect(body.data.timeControl.white).toBeGreaterThan(580); + expect(body.data.timeControl.black).toBe(600); + }); + + it('counts down for the side that now has to move', async () => { + const table = await openTable(); + await play(table.white, table.id, 'e4').expect(200); + await rewind(table.id, 30); + + const { body } = await readMoves(table.id).expect(200); + + expect(body.data.game.timeControl.black).toBeLessThan(575); + expect(body.data.game.timeControl.black).toBeGreaterThan(565); + }); + }); + + describe('reading never writes', () => { + it('leaves updatedAt alone across repeated reads', async () => { + const table = await openTable(); + await play(table.white, table.id, 'e4').expect(200); + + const before = (await games.findOne({ resourceId: table.id }).exec())!; + const stamp = before.get('updatedAt').getTime(); + + await readMoves(table.id).expect(200); + await readMoves(table.id).expect(200); + await readMoves(table.id).expect(200); + + const after = (await games.findOne({ resourceId: table.id }).exec())!; + expect(after.get('updatedAt').getTime()).toBe(stamp); + }); + + it('shows a different remaining time on each read of the same document', async () => { + const table = await openTable(); + await play(table.white, table.id, 'e4').expect(200); + + const first = await readMoves(table.id).expect(200); + await rewind(table.id, 40); + const second = await readMoves(table.id).expect(200); + + expect(second.body.data.game.timeControl.black).toBeLessThan( + first.body.data.game.timeControl.black, + ); + }); + }); + + describe('running out of time', () => { + it('reports the game as finished on a read, without persisting it', async () => { + const table = await openTable({ white: 600, black: 10, increment: 0 }); + await play(table.white, table.id, 'e4').expect(200); + await rewind(table.id, 30); + + const { body } = await readMoves(table.id).expect(200); + + expect(body.data.game.status).toMatchObject({ + state: 'F', + result: 'White wins', + additionalInfo: 'Win on time', + }); + expect(body.data.game.timeControl.black).toBe(0); + + const stored = (await games.findOne({ resourceId: table.id }).exec())!; + expect(stored.status.state).toBe('A'); + }); + + it('persists the loss when a move is attempted after the flag fell', async () => { + const table = await openTable({ white: 600, black: 10, increment: 0 }); + await play(table.white, table.id, 'e4').expect(200); + await rewind(table.id, 30); + + const response = await play(table.black, table.id, 'e5').expect(409); + expect(response.body.message).toBe('Time is up'); + + const stored = (await games.findOne({ resourceId: table.id }).exec())!; + expect(stored.status.state).toBe('F'); + expect(stored.status.result).toBe('White wins'); + expect(stored.timeControl.black).toBe(0); + }); + + it('refuses any further move once the loss is persisted', async () => { + const table = await openTable({ white: 600, black: 10, increment: 0 }); + await play(table.white, table.id, 'e4').expect(200); + await rewind(table.id, 30); + await play(table.black, table.id, 'e5').expect(409); + + const response = await play(table.black, table.id, 'e5').expect(409); + expect(response.body.message).toBe('This game is not accepting moves'); + }); + }); +});