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
20 changes: 19 additions & 1 deletion docs/rfcs/004-clock.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RFC-004: Clock and time control

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

## Summary
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
18 changes: 6 additions & 12 deletions src/games/dto/game-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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;
Expand All @@ -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');
}
}
166 changes: 166 additions & 0 deletions src/games/game-projection.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
({
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');
});
});
});
93 changes: 93 additions & 0 deletions src/games/game-projection.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading