Skip to content

feat(games): play moves - #202

Open
Utzig26 wants to merge 1 commit into
chore/close-audit-advisoriesfrom
feat/moves
Open

feat(games): play moves#202
Utzig26 wants to merge 1 commit into
chore/close-audit-advisoriesfrom
feat/moves

Conversation

@Utzig26

@Utzig26 Utzig26 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements RFC-003. The game starts happening.

Stacked on #201#200#199#198.

What lands

Route Effect
🔒 POST /games/:id/move Plays a move in SAN, W → A on the first one
🔓 GET /games/:id/moves Lists every legal move in the position

Validation order decides the status

The game must exist (404), be accepting moves (409), the caller must hold a colour (403), it must be their turn (409), and only then does the engine decide legality (422).

422 and not 400 for an illegal move: the body is well formed, and it is the position that refuses it.

The SAN regex is gone

The old code validated notation with a regex before reaching the engine. It accepted moves the position did not allow and rejected nothing the engine would have let through — duplicated validation where the duplicate was the one that lied.

The DTO now only bounds the length. Legality has a single source.

Simultaneous moves

The old code read, validated and saved with no transaction and no versioning, so two POST /move in flight read the same state and the last one overwrote the first — the board lost a move.

Turning on optimisticConcurrency makes mongoose carry __v into the filter of every save() and raise VersionError when it no longer matches. The service translates that into 409 Game state changed, retry. No automatic retry: two simultaneous moves in one game mean one of them was not that player's turn, and the client needs to know.

The test that proves it:

const [first, second] = await Promise.all([
  play(table.white, table.id, 'e4'),
  play(table.white, table.id, 'd4'),
]);

expect([first.status, second.status].sort()).toEqual([200, 409]);
expect(body.data.game.history).toHaveLength(1);

Two things the implementation forced

The move number does not come from the engine. chess.js reports the number of the position, so it still reads 1 after white plays and 2 after black replies. Using it directly would number black's move as if it belonged to the next pair. It is derived from the history instead, and a test pins the sequence to [1, 1, 2].

Automatic termination had to land here, not wait for RFC-005. A mating move has to finish the game in the same save() or the response and the database disagree. Resign and draw stay with RFC-005, which will absorb this finish() into the termination table.

Verification

216 tests green, clean strict typecheck and lint.

  • 113 unit, 31 new: turn handover, the scoresheet numbering, illegal moves not touching the database, non-players, out of turn, both colours held by one player, mate finishing the game, every refusing state, and the version conflict
  • 103 end-to-end, 21 new: a full game played through to Fool's mate ending in F with Black wins, the parallel move race, the legal move list narrowing as the position develops, and the player id never leaking

Implements RFC-003. POST /games/:id/move plays a move in standard algebraic
notation, GET /games/:id/moves lists what is legal in the current position,
and the game moves from W to A on the first move.

Validation runs in a fixed order so each failure gets the status it
deserves: the game must exist, be accepting moves, the caller must hold a
colour, it must be their turn, and only then does the engine decide
legality. An illegal move is 422 rather than 400, because the body is well
formed and it is the position that refuses it.

The SAN regex the old code ran before reaching the engine is gone. It
accepted moves the position did not allow and rejected nothing the engine
would have let through, so it was duplicated validation where the duplicate
was the one that lied. The DTO now only bounds the length; legality has a
single source.

Simultaneous moves are handled, which the old code had no protection for at
all. Turning on optimisticConcurrency makes mongoose carry __v into the
filter of every save and raise VersionError when it no longer matches, and
the service translates that into a 409 telling the client to retry. An
end-to-end test fires two moves at the same game in parallel and asserts one
200, one 409, and exactly one move in the history.

The move number is derived from the history rather than taken from the
engine. chess.js reports the number of the position, so it still reads 1
after white plays and 2 after black replies, which would number black's move
as if it belonged to the next pair.

Automatic termination lands here too, because a mating move has to finish
the game in the same save or the response and the database disagree. Resign
and draw stay with RFC-005, which will absorb this into the termination
table.

31 unit tests and 21 end-to-end, including a full game played through to
mate and a game where one player holds both colours and moves each side.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant