feat(games): play moves - #202
Open
Utzig26 wants to merge 1 commit into
Open
Conversation
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.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements RFC-003. The game starts happening.
What lands
POST /games/:id/moveW → Aon the first oneGET /games/:id/movesValidation 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 /movein flight read the same state and the last one overwrote the first — the board lost a move.Turning on
optimisticConcurrencymakes mongoose carry__vinto the filter of everysave()and raiseVersionErrorwhen it no longer matches. The service translates that into409 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:
Two things the implementation forced
The move number does not come from the engine.
chess.jsreports 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 thisfinish()into the termination table.Verification
216 tests green, clean
stricttypecheck and lint.FwithBlack wins, the parallel move race, the legal move list narrowing as the position develops, and the player id never leaking