Skip to content

feat(chess): add the engine adapter behind a single boundary - #199

Open
Utzig26 wants to merge 1 commit into
feat/refresh-token-rotationfrom
feat/chess-engine-adapter
Open

feat(chess): add the engine adapter behind a single boundary#199
Utzig26 wants to merge 1 commit into
feat/refresh-token-rotationfrom
feat/chess-engine-adapter

Conversation

@Utzig26

@Utzig26 Utzig26 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements RFC-001. First piece of the chess domain.

Stacked on #198. Retarget to master once that merges.

What lands

The official chess.js replaces the personal fork github:Utzig26/chess.js#dev that the old code depended on, and ChessEngineService becomes the only place in the project allowed to import it.

readonly initialFen: string
position(pgn: string): ChessPosition
legalMoves(pgn: string): string[]
applyMove(pgn: string, san: string): AppliedMove | null

Why the PGN is the exchange format

Threefold repetition and the fifty move rule depend on the history of positions, not on the current one. A FEN cannot know that a position already occurred twice. Replaying the PGN lets the engine reconstruct that itself, instead of us deriving it by hand from the move list.

There is a test that asserts exactly this, and it is the one that justifies the storage decision:

it('cannot be derived from the fen alone, which is why the pgn is stored', () => {
  const pgn = pgnOf(repetition);
  const fenOnly = engine.position(pgn).fen;

  expect(engine.position(pgn).outcome).toBe('threefoldRepetition');
  expect(engine.position(pgnFrom(fenOnly)).outcome).toBeNull();
});

Refinement against the RFC

The proposal had loadGame(pgn) returning an opaque LoadedGame that the other calls would take. The service ended up stateless, with the PGN as the only parameter.

Passing the PGN removes the risk of leaking a Chess instance disguised as an opaque type, makes every method pure, and costs two reparses per move flow — microseconds against a Mongo round trip. The RFC is updated with the change and the reason rather than left describing something the code does not do.

The boundary is enforced, not documented

A no-restricted-imports rule fails the lint if any file outside src/chess imports chess.js. Verified by feeding it a file that does:

src/users/boundary-probe.ts
  1:1  error  'chess.js' import is restricted from being used.
              Only src/chess may import the engine. Consume it through ChessEngineService

Other decisions

An illegal move returns null rather than throwing. chess.js 1.x throws from move(), and converting that at the boundary keeps the business rules in RFC-003 free of try/catch.

The initial FEN is now complete (... w KQkq - 0 1). The old schema stored only the piece placement, so any client trusting it before the first move received an invalid string.

Arbitrary positions reach the tests through a PGN carrying [SetUp "1"] and [FEN "..."] headers, which chess.js honours. Mate, stalemate, insufficient material and the fifty move counter are all exercised without adding a FEN entry point to the public contract. RFC-005 will use the same trick.

Verification

26 unit tests for this module, no database and no application. Full suite green: 82 unit, 58 end-to-end, clean strict typecheck and lint.

Covered: the twenty opening moves, illegal and unparseable notation, every SAN form the old regex tried to validate (pawn, piece, capture, check, both castles, promotion, en passant), all five terminal outcomes, mate winning over any draw condition, threefold repetition reconstructed by replay, and the derived FEN always matching what the PGN replays to.

Implements RFC-001. The official chess.js replaces the personal fork the old
code depended on, and ChessEngineService becomes the only place in the
project allowed to import it.

Position is exchanged as PGN. The service is stateless: every method takes
the PGN and returns a described position, which is what lets threefold
repetition and the fifty move rule come from the engine instead of being
derived by hand. Those two rules depend on the history of positions, not on
the current one, so replaying the PGN is the whole point of storing it. A
test asserts exactly that: the same position reached by replay reports
threefold repetition, and the same position loaded from its own FEN reports
nothing.

This refines the proposal, which had loadGame returning an opaque handle
that the other calls would take. Passing the PGN removes the risk of leaking
a Chess instance disguised as an opaque type, makes each method pure, and
costs two reparses per move flow, which is microseconds against a Mongo
round trip. The RFC records the change and the reason.

An illegal move returns null rather than throwing. chess.js 1.x throws from
move(), and converting that at the boundary keeps the business rules in
RFC-003 free of try/catch.

The boundary is enforced, not documented: a no-restricted-imports rule fails
the lint if any file outside src/chess imports chess.js. Verified by feeding
it a file that does.

Arbitrary positions reach the tests through a PGN carrying a SetUp and FEN
header, which chess.js honours, so mate, stalemate, insufficient material
and the fifty move counter are all exercised without adding a FEN entry
point to the public contract. RFC-005 will use the same trick.

26 unit tests, no database and no application.
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