Skip to content

feat(games): end games by resignation and by agreement - #204

Open
Utzig26 wants to merge 1 commit into
feat/clockfrom
feat/game-termination
Open

feat(games): end games by resignation and by agreement#204
Utzig26 wants to merge 1 commit into
feat/clockfrom
feat/game-termination

Conversation

@Utzig26

@Utzig26 Utzig26 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements RFC-005.

Stacked on #203#202#201#200#199#198.

What lands

Route Effect
🔒 POST /games/:id/resign Gives the game up
🔒 POST /games/:id/draw Offers a draw, or accepts the one on the table
🔒 DELETE /games/:id/draw Takes back your own offer

The draw is two flags, not a negotiation

Kept from the old code, and worth keeping. drawOffer is { white: boolean, black: boolean }. Offering is a POST, accepting is the same POST from the other side, and the game ends once both flags are up. Withdrawing clears only your own.

It removes an endpoint, removes the intermediate state of "offer pending an answer", and is idempotent for free — offering twice does nothing.

Detection still beats the requested reason

Before applying what the route asked for, the engine is consulted. Resigning into a position already drawn by insufficient material is recorded as that draw, not as a resignation:

it('records a resignation into a dead position as the draw it already was', async () => {
  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',
  });
});

The position was already decided before anyone clicked, and the result should not depend on who clicked first.

Where that logic now lives

It was an eighty five line switch in a loose function at the bottom of the old service, outside dependency injection and untestable on its own.

It is now resolveTermination(outcome, sideToMove, requested) — a pure function returning { result, additionalInfo }, or null when there is nothing to end. The finish() RFC-003 had introduced is gone, absorbed by the same call with requested: null.

The checkmate winner is derived from the side to move, not from the side that moved. In both entry points the mated side is the one on turn, so one rule covers the mating move and a resignation in an already mated position. That is what let the two paths collapse into a single function.

One gate for every mutation

openMutation resolves the game, checks the caller holds a colour, and checks the clock — used by move, resign, offer and withdraw alike. Without it, resigning a game whose time had already run out would have been recorded as a resignation instead of a loss on time.

Playing yourself

Resigns against itself, and agrees a draw in two calls with the offer alternating between the colours on its own. That falls out of the two flag model with no special case.

Verification

280 tests green, clean strict typecheck and lint.

  • 147 unit, 13 of them over the termination table alone: every engine outcome, both resignations, agreement, and the three cases where the board overrules the request
  • 133 end-to-end, 22 new: the full offer → withdraw → offer → accept cycle, a finished game refusing all four mutations, resigning out of turn, and self-play

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.
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