Engine PR 8: pawns — pushes, captures, en passant, promotion #67

Closed
opened 2026-05-16 16:00:49 +00:00 by root · 5 comments
root commented 2026-05-16 16:00:49 +00:00 (Migrated from gitlab.core-project.space)

Imported from GitHub amaurycoudr/chess-project#67, opened by @amaurycoudr on 2026-05-16 16:00 UTC.
Closed on GitHub on 2026-05-16 21:37 UTC as completed.


Problem Statement

The engine can generate legal moves for kings, knights, and sliders, but pawns are still missing. Without pawns, Game.legalMoves() cannot reproduce any realistic game, perft cannot be validated against standard positions, and the playable chess subset stays degenerate. Pawns are also the most rule-dense piece — single push, double push, diagonal capture, en passant, and promotion are all interlocked — so they cannot be added as just "one more attack mask."

This is step 8 of ADR 0004's engine slice plan. Step 7 (sliders) is already merged; pawns do not depend on sliders, so this slice is independent.

Solution

Add pawn move generation to the existing piece-agnostic legality skeleton, so that Game.legalMoves(), Game.apply(), and Game.status() handle pawns correctly — including the three special mechanics (double push setting an en passant target, en passant capture, and promotion). After this slice, a king + pawn (+ knight) game plays to completion with correct legality, and perft over pawn positions matches Stockfish.

User Stories

  1. As an engine consumer, I want Game.legalMoves() to include pawn single pushes, so that pawns can advance.
  2. As an engine consumer, I want a pawn on its starting rank to offer a double push, so that openings are playable.
  3. As an engine consumer, I want a double push to be offered only when both the intermediate and destination squares are empty, so that pawns cannot jump over a blocker.
  4. As an engine consumer, I want a pawn off its starting rank to never offer a double push, so that pawn movement obeys the rules.
  5. As an engine consumer, I want a pawn push to be blocked when the square ahead is occupied by any piece, so that pawns cannot capture forward.
  6. As an engine consumer, I want pawn diagonal captures onto enemy-occupied squares, so that pawns can take material.
  7. As an engine consumer, I want pawn diagonals onto empty (non-en-passant) squares to be excluded, so that pawns only move diagonally when capturing.
  8. As an engine consumer, I want pawn diagonals onto friendly-occupied squares excluded, so that pawns cannot capture their own pieces.
  9. As an engine consumer, I want a pawn reaching the last rank to expand into four moves (knight, bishop, rook, queen promotions), so that I can choose the promotion piece.
  10. As an engine consumer, I want promotion to apply equally to a straight push and to a diagonal capture onto the last rank, so that capture-promotions work.
  11. As an engine consumer, I want non-promoting pawn moves to carry no promotion field, so that the Move shape stays minimal (ADR 0005).
  12. As an engine consumer, I want a double push to set the en passant target square to the square the pawn skipped over, so that the opponent can reply with en passant.
  13. As an engine consumer, I want an en passant capture available when an enemy pawn is positioned to take the en passant target square, so that the rule is enforced.
  14. As an engine consumer, I want an en passant capture to remove the captured pawn from the square behind the destination (not the destination itself), so that the board ends up correct.
  15. As an engine consumer, I want the en passant target cleared after any move that is not a fresh double push, so that en passant is only available for one ply.
  16. As an engine consumer, I want white pawns to move toward rank 8 and black pawns toward rank 1, so that pawn direction is color-correct.
  17. As an engine consumer, I want Game.status() to report checkmate when a pawn delivers mate, so that pawn checks are recognised.
  18. As an engine consumer, I want a king forbidden from moving onto a square attacked by an enemy pawn's diagonal, so that king safety accounts for pawns.
  19. As an engine consumer, I want a pawn move that exposes its own king to be rejected as illegal, so that pinned pawns behave correctly.
  20. As an engine consumer, I want en passant rejected when it would expose my own king, so that the rare en-passant-discovered-check case is handled.
  21. As an engine consumer, I want the halfmove clock to reset on every pawn move, so that the 50-move rule (a later slice) has correct input.
  22. As an engine consumer, I want Game.apply() on a promotion move to place the chosen piece on the destination, so that the board reflects the promotion.
  23. As an engine maintainer, I want pawn perft node counts to match Stockfish for king+pawn and king+pawn+knight positions, so that pawn rules are validated end to end.
  24. As an engine maintainer, I want the perft positions to exercise double pushes, en passant, and promotion, so that the special mechanics are covered, not just quiet pushes.

Implementation Decisions

Modules to build or modify (interfaces only; no file paths or code):

  • pawnAttacks(square, color) — new, in the attacks module. Pure function returning a SquareSet of the (up to two) diagonal squares a pawn of the given color attacks. Color-dependent, unlike kingAttacks/knightAttacks. Models attacks only — non-capturing pushes are not attacks and are not produced here. Consumed by both move generation (capture targets) and isAttacked (king safety).
  • pseudoLegalPawnMoves(position) — new, in the movegen module. Returns Move[]. Generates single push, double push (starting-rank-only, both squares clear), diagonal captures (enemy-occupied target), en passant capture (target square equals position.ep), and promotion expansion (a move landing on the last rank fans into four Moves, one per n/b/r/q). Mirrors the existing pseudoLegalKnightMoves shape.
  • applyMove — modified, in the rules module. Three interlocked changes: (a) on a double push, set the next position's ep to the skipped-over square instead of the current hard-coded undefined; (b) on a move carrying promotion, the mover lands on the destination as the promotion piece's set rather than the pawn set; (c) on an en passant capture, remove the enemy pawn from the square behind the destination rather than from the destination.
  • isAttacked — modified, in the rules module. Add a pawn-attack scan so king safety and check detection account for pawns.
  • legalMoves — modified, in the rules module. Add pseudoLegalPawnMoves to the pseudo-legal candidate list.

No type changes: Move.promotion, the moveOf helper, and Game.apply's promotion-aware legality comparison already exist. Status is unchanged.

Architectural notes:

  • The legality filter from PR 5 is piece-agnostic; pawns plug into it like every prior piece. Pawn-specific logic lives in move generation and in applyMove's special-case handling.
  • En passant capture and promotion are detected structurally in applyMove (pawn mover + diagonal to empty ep square; pawn mover + last-rank destination) rather than tagged on the Move, consistent with ADR 0005's slim Move.

Testing Decisions

A good test verifies external behavior — the moves offered, the resulting position, the reported status — never internal representation. Tests are vertical TDD slices: one test, one implementation step, repeat.

Modules under test (per the agreed default — all of them):

  • pawnAttacks: geometry unit tests — diagonal targets for white and black, edge-file truncation.
  • pseudoLegalPawnMoves (via legalMoves): single push, blocked push, double push, blocked/off-rank double push, diagonal capture, blocked diagonal, en passant availability, promotion expansion into four moves, capture-promotion.
  • applyMove: en passant target set on double push and cleared otherwise; en passant capture removes the correct pawn; promotion places the chosen piece; halfmove clock resets on pawn moves.
  • statusOf: pawn-delivered checkmate; king cannot step onto a pawn-attacked square; pinned pawn excluded.
  • perft: king+pawn and king+pawn+knight positions, node counts verified against Stockfish 18 go perft N, chosen to exercise double pushes, en passant, and promotion.

Prior art: rules-knights.test.ts (geometry + legality + applyMove + statusOf structure) and perft.test.ts (the it.each depth/expected table, FEN-annotated fixtures via makePosition). New pawn tests follow these patterns directly. makePosition already accepts whitePawns/blackPawns/ep.

Out of Scope

  • Castling (ADR 0004 step 9).
  • Draw detection — 50-move, threefold, insufficient material (step 10). The halfmove clock is maintained here but no draw is reported.
  • PGN export (step 11) and SAN.
  • The bitboard swap (ADR 0003); SquareSet stays Uint8Array-backed.
  • Underpromotion preferences / move ordering — all four promotion pieces are generated, unordered.
  • Performance work on applyMove/perft; the O(branching^depth) perft helper is unchanged.

Further Notes

  • Step 7 (sliders) is merged; this slice is independent of it but builds on the same legality skeleton.
  • The ADR flags pawns as "rules interlocked" — the slice is expected to land more concept at once than a single-piece PR. Sub-issues should still be vertical and independently reviewable.
  • En-passant-discovered-check is rare but real; it falls out of the existing legality filter for free once applyMove removes the correct pawn, and should be covered by at least one test or perft position.
> _Imported from GitHub [amaurycoudr/chess-project#67](https://github.com/amaurycoudr/chess-project/issues/67), opened by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-05-16 16:00 UTC._ > _Closed on GitHub on 2026-05-16 21:37 UTC as completed._ <!-- github-issue:67 --> --- ## Problem Statement The engine can generate legal moves for kings, knights, and sliders, but pawns are still missing. Without pawns, `Game.legalMoves()` cannot reproduce any realistic game, perft cannot be validated against standard positions, and the playable chess subset stays degenerate. Pawns are also the most rule-dense piece — single push, double push, diagonal capture, en passant, and promotion are all interlocked — so they cannot be added as just "one more attack mask." This is step 8 of ADR 0004's engine slice plan. Step 7 (sliders) is already merged; pawns do not depend on sliders, so this slice is independent. ## Solution Add pawn move generation to the existing piece-agnostic legality skeleton, so that `Game.legalMoves()`, `Game.apply()`, and `Game.status()` handle pawns correctly — including the three special mechanics (double push setting an en passant target, en passant capture, and promotion). After this slice, a king + pawn (+ knight) game plays to completion with correct legality, and perft over pawn positions matches Stockfish. ## User Stories 1. As an engine consumer, I want `Game.legalMoves()` to include pawn single pushes, so that pawns can advance. 2. As an engine consumer, I want a pawn on its starting rank to offer a double push, so that openings are playable. 3. As an engine consumer, I want a double push to be offered only when both the intermediate and destination squares are empty, so that pawns cannot jump over a blocker. 4. As an engine consumer, I want a pawn off its starting rank to never offer a double push, so that pawn movement obeys the rules. 5. As an engine consumer, I want a pawn push to be blocked when the square ahead is occupied by any piece, so that pawns cannot capture forward. 6. As an engine consumer, I want pawn diagonal captures onto enemy-occupied squares, so that pawns can take material. 7. As an engine consumer, I want pawn diagonals onto empty (non-en-passant) squares to be excluded, so that pawns only move diagonally when capturing. 8. As an engine consumer, I want pawn diagonals onto friendly-occupied squares excluded, so that pawns cannot capture their own pieces. 9. As an engine consumer, I want a pawn reaching the last rank to expand into four moves (knight, bishop, rook, queen promotions), so that I can choose the promotion piece. 10. As an engine consumer, I want promotion to apply equally to a straight push and to a diagonal capture onto the last rank, so that capture-promotions work. 11. As an engine consumer, I want non-promoting pawn moves to carry no `promotion` field, so that the `Move` shape stays minimal (ADR 0005). 12. As an engine consumer, I want a double push to set the en passant target square to the square the pawn skipped over, so that the opponent can reply with en passant. 13. As an engine consumer, I want an en passant capture available when an enemy pawn is positioned to take the en passant target square, so that the rule is enforced. 14. As an engine consumer, I want an en passant capture to remove the captured pawn from the square behind the destination (not the destination itself), so that the board ends up correct. 15. As an engine consumer, I want the en passant target cleared after any move that is not a fresh double push, so that en passant is only available for one ply. 16. As an engine consumer, I want white pawns to move toward rank 8 and black pawns toward rank 1, so that pawn direction is color-correct. 17. As an engine consumer, I want `Game.status()` to report `checkmate` when a pawn delivers mate, so that pawn checks are recognised. 18. As an engine consumer, I want a king forbidden from moving onto a square attacked by an enemy pawn's diagonal, so that king safety accounts for pawns. 19. As an engine consumer, I want a pawn move that exposes its own king to be rejected as illegal, so that pinned pawns behave correctly. 20. As an engine consumer, I want en passant rejected when it would expose my own king, so that the rare en-passant-discovered-check case is handled. 21. As an engine consumer, I want the halfmove clock to reset on every pawn move, so that the 50-move rule (a later slice) has correct input. 22. As an engine consumer, I want `Game.apply()` on a promotion move to place the chosen piece on the destination, so that the board reflects the promotion. 23. As an engine maintainer, I want pawn perft node counts to match Stockfish for king+pawn and king+pawn+knight positions, so that pawn rules are validated end to end. 24. As an engine maintainer, I want the perft positions to exercise double pushes, en passant, and promotion, so that the special mechanics are covered, not just quiet pushes. ## Implementation Decisions Modules to build or modify (interfaces only; no file paths or code): - **`pawnAttacks(square, color)` — new, in the attacks module.** Pure function returning a `SquareSet` of the (up to two) diagonal squares a pawn of the given color attacks. Color-dependent, unlike `kingAttacks`/`knightAttacks`. Models *attacks* only — non-capturing pushes are not attacks and are not produced here. Consumed by both move generation (capture targets) and `isAttacked` (king safety). - **`pseudoLegalPawnMoves(position)` — new, in the movegen module.** Returns `Move[]`. Generates single push, double push (starting-rank-only, both squares clear), diagonal captures (enemy-occupied target), en passant capture (target square equals `position.ep`), and promotion expansion (a move landing on the last rank fans into four `Move`s, one per `n`/`b`/`r`/`q`). Mirrors the existing `pseudoLegalKnightMoves` shape. - **`applyMove` — modified, in the rules module.** Three interlocked changes: (a) on a double push, set the next position's `ep` to the skipped-over square instead of the current hard-coded `undefined`; (b) on a move carrying `promotion`, the mover lands on the destination as the promotion piece's set rather than the pawn set; (c) on an en passant capture, remove the enemy pawn from the square *behind* the destination rather than from the destination. - **`isAttacked` — modified, in the rules module.** Add a pawn-attack scan so king safety and check detection account for pawns. - **`legalMoves` — modified, in the rules module.** Add `pseudoLegalPawnMoves` to the pseudo-legal candidate list. No type changes: `Move.promotion`, the `moveOf` helper, and `Game.apply`'s promotion-aware legality comparison already exist. `Status` is unchanged. Architectural notes: - The legality filter from PR 5 is piece-agnostic; pawns plug into it like every prior piece. Pawn-specific logic lives in move generation and in `applyMove`'s special-case handling. - En passant capture and promotion are detected structurally in `applyMove` (pawn mover + diagonal to empty `ep` square; pawn mover + last-rank destination) rather than tagged on the `Move`, consistent with ADR 0005's slim `Move`. ## Testing Decisions A good test verifies external behavior — the moves offered, the resulting position, the reported status — never internal representation. Tests are vertical TDD slices: one test, one implementation step, repeat. Modules under test (per the agreed default — all of them): - **`pawnAttacks`**: geometry unit tests — diagonal targets for white and black, edge-file truncation. - **`pseudoLegalPawnMoves`** (via `legalMoves`): single push, blocked push, double push, blocked/off-rank double push, diagonal capture, blocked diagonal, en passant availability, promotion expansion into four moves, capture-promotion. - **`applyMove`**: en passant target set on double push and cleared otherwise; en passant capture removes the correct pawn; promotion places the chosen piece; halfmove clock resets on pawn moves. - **`statusOf`**: pawn-delivered checkmate; king cannot step onto a pawn-attacked square; pinned pawn excluded. - **perft**: king+pawn and king+pawn+knight positions, node counts verified against Stockfish 18 `go perft N`, chosen to exercise double pushes, en passant, and promotion. Prior art: `rules-knights.test.ts` (geometry + legality + `applyMove` + `statusOf` structure) and `perft.test.ts` (the `it.each` depth/expected table, FEN-annotated fixtures via `makePosition`). New pawn tests follow these patterns directly. `makePosition` already accepts `whitePawns`/`blackPawns`/`ep`. ## Out of Scope - Castling (ADR 0004 step 9). - Draw detection — 50-move, threefold, insufficient material (step 10). The halfmove clock is *maintained* here but no draw is *reported*. - PGN export (step 11) and SAN. - The bitboard swap (ADR 0003); `SquareSet` stays Uint8Array-backed. - Underpromotion *preferences* / move ordering — all four promotion pieces are generated, unordered. - Performance work on `applyMove`/perft; the O(branching^depth) perft helper is unchanged. ## Further Notes - Step 7 (sliders) is merged; this slice is independent of it but builds on the same legality skeleton. - The ADR flags pawns as "rules interlocked" — the slice is expected to land more concept at once than a single-piece PR. Sub-issues should still be vertical and independently reviewable. - En-passant-discovered-check is rare but real; it falls out of the existing legality filter for free once `applyMove` removes the correct pawn, and should be covered by at least one test or perft position.
root commented 2026-05-16 17:26:57 +00:00 (Migrated from gitlab.core-project.space)

Imported from a GitHub comment by @amaurycoudr on 2026-05-16 17:26 UTC.

Promoted to branch feat/engine-pr-8-1-pawns-pushes-captures-attack-mask-legality-wiring-68 (PR #72), not yet merged to main. A dependent slice should git fetch origin feat/engine-pr-8-1-pawns-pushes-captures-attack-mask-legality-wiring-68 and branch off it.

> _Imported from a [GitHub comment](https://github.com/amaurycoudr/chess-project/issues/67#issuecomment-4467607647) by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-05-16 17:26 UTC._ <!-- github-comment:4467607647 --> Promoted to branch `feat/engine-pr-8-1-pawns-pushes-captures-attack-mask-legality-wiring-68` (PR #72), not yet merged to main. A dependent slice should `git fetch origin feat/engine-pr-8-1-pawns-pushes-captures-attack-mask-legality-wiring-68` and branch off it.
root (Migrated from gitlab.core-project.space) closed this issue 2026-05-16 21:37:09 +00:00
root commented 2026-10-13 08:38:28 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #68

mentioned in issue #68
root commented 2026-10-13 08:38:29 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #69

mentioned in issue #69
root commented 2026-10-13 08:38:29 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #70

mentioned in issue #70
root commented 2026-10-13 08:38:30 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #71

mentioned in issue #71
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amaurycoudr/chess-project#67
No description provided.