From 38cd58bbfe5c508e4aaf83619ec4e4c40f19fa0c Mon Sep 17 00:00:00 2001 From: Arcadia Rose Date: Sun, 6 Sep 2026 10:19:29 -0400 Subject: [PATCH] Second rule: Cutting A player may take two of another player's pieces by "cutting" an encirclement. A cut happens when three pieces are placed alongside one another either diagonally, horizontally or vertically such that one piece is outside the encirclement, one is along the boundary and one piece is inside it. This is like slicing the edge of something. --- .../memories/feedback/ascii-board-diagrams.md | 20 ++++++ .../feedback/design-partner-workflow.md | 8 +++ docs/memories/project/capture-rules.md | 20 ++++++ frontend/src/game.ts | 68 ++++++++++++++++++- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 docs/memories/feedback/ascii-board-diagrams.md diff --git a/docs/memories/feedback/ascii-board-diagrams.md b/docs/memories/feedback/ascii-board-diagrams.md new file mode 100644 index 0000000..0879462 --- /dev/null +++ b/docs/memories/feedback/ascii-board-diagrams.md @@ -0,0 +1,20 @@ +# ASCII board diagrams + +When the user (or Seer, relaying the user) illustrates a rule with before/placement/after +ASCII board panels, the panels are cropped to whatever cells are in play, not to a fixed +board viewport. A later panel can legitimately gain a row or column versus an earlier one +if a placement extends past what the earlier panel showed. + +Confirmed 2026-09-05 on the cutting-rule examples: the horizontal-cut example's "before" +panel was 3 columns and the "after" panels were 4, which read as a copy/paste +misalignment. It was flagged as a probable bug to the user. After deriving the geometry +formally against Seer's spec (Chebyshev distance 1 from the cut's gap cell — see +`capture-rules.md`), the extra column turned out to be exactly where the cut line's +centre placement (an endpoint of the 3-cell line, not its middle) extended the ring's +footprint by one cell. All three examples were internally consistent; the mismatch was +a misread, not a bug in the user's message. + +Before flagging a multi-panel ASCII diagram as inconsistent, try deriving the underlying +geometry/rule first and check whether the size change lines up with an in-bounds +placement. Only report it as a probable error if the geometry still doesn't reconcile +after that check. diff --git a/docs/memories/feedback/design-partner-workflow.md b/docs/memories/feedback/design-partner-workflow.md index b475eee..4f71bd2 100644 --- a/docs/memories/feedback/design-partner-workflow.md +++ b/docs/memories/feedback/design-partner-workflow.md @@ -11,6 +11,14 @@ final design. calls yourself. - A peer session's own recommendation is never the user's approval — wait for the user to explicitly say go. +- If the user describes a brand-new rule/feature directly in the coder session + (not yet a task doc), don't proactively message Seer to kick off the design + conversation — the user coordinates with Seer themselves in Seer's own session + and Seer will deliver the task doc here when ready. Confirmed 2026-09-05: user + interrupted a queued `SendMessage` to Seer to say "Seer is going to send you a + new feature to implement shortly." It's still fine to flag concrete problems + with the user's own request (e.g. an inconsistent example) directly to them + while waiting. Stated as the working process in `docs/tasks/2026-09-05/01-decouple-game-rules-from-ui-effects.md`; confirmed diff --git a/docs/memories/project/capture-rules.md b/docs/memories/project/capture-rules.md index cb843e1..6e5cb5e 100644 --- a/docs/memories/project/capture-rules.md +++ b/docs/memories/project/capture-rules.md @@ -14,3 +14,23 @@ Invariants that will hold for all future rules: - Only the active player captures. A player placing into an existing enemy encirclement is safe; that square stays playable until an opponent actively re-forms the ring around it. - All four surrounders must be the same player. Two opponents contributing two stones each does not capture. - Off-board is never a surrounder. An edge piece cannot be captured orthogonally; a corner piece cannot be captured at all. This falls out of absence rather than bounds checking, so game.ts still does not need to know the board dimensions — keep it that way. + +## Cutting + +A cut is three cells — C, C+d, C+2d — all placed in the same turn by the active player, where d is one of the eight directions. + +It fires when C+d is a gap in a complete four-piece enemy encirclement centred on C. The gaps of a ring are the cells of the OTHER pattern: a diagonal ring's gaps are its orthogonal neighbours, an orthogonal ring's gaps are its diagonal neighbours. + +It captures the two ring members at Chebyshev distance 1 from the gap cell. That is always exactly two, for both ring types. + +Invariants: + +- All three cut cells must be placed in the same turn. Pre-existing pieces never count toward a cut. +- A cut requires a complete four-piece encirclement to be in place. Other encirclement types may be added later. +- A cut never takes an entire encirclement. This needs no special-casing: a line crossing two gaps of the same ring has no cell outside the ring, so it fails the definition and captures nothing. +- A single line can cut two different rings, one at each endpoint. Both fire. +- Cuts and encirclements resolve together at end of turn, computed against the same pre-capture board, applied as one batch. + +Dependency worth protecting: cutting requires placing a stone INSIDE an enemy ring. It works only because a standing ring does not fire passively. Anyone "simplifying" that behaviour would silently break cutting, and the coupling is invisible from the code. + +Intentional asymmetry: a cut costs 3 placements and takes 2 pieces. Re-forming the broken ring costs 2 placements and takes 1 piece back (the cutter's centre stone). This exchange rate is deliberate, not an accident of the geometry. diff --git a/frontend/src/game.ts b/frontend/src/game.ts index d52cb3a..037c8be 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -81,7 +81,7 @@ const isCapturingRing = ( return owned.length === ring.length && owned.some((piece) => placedThisTurn(state, piece)); }; -export const pendingCaptures = (state: BoardState): Piece[] => +export const pendingEncirclements = (state: BoardState): Piece[] => state.pieces.filter( (piece) => piece.player !== state.activePlayer && @@ -89,11 +89,73 @@ export const pendingCaptures = (state: BoardState): Piece[] => isCapturingRing(state, ringAt(state, piece.position, pattern))), ); +const DIRECTIONS: readonly Offset[] = [...ORTHOGONAL, ...DIAGONAL]; + +const shift = (position: Position, offset: Offset, steps: number): Position => ({ + row: position.row + offset.row * steps, + col: position.col + offset.col * steps, +}); + +const isDiagonalOffset = (offset: Offset): boolean => + offset.row !== 0 && offset.col !== 0; + +const chebyshev = (a: Position, b: Position): number => + Math.max(Math.abs(a.row - b.row), Math.abs(a.col - b.col)); + +const placedThisTurnAt = (state: BoardState, position: Position): boolean => { + const piece = pieceAt(state, position); + return piece !== undefined && placedThisTurn(state, piece); +}; + +// The four pieces of a complete ring owned by a single player other than the +// active one, or null. +const enemyRing = ( + state: BoardState, + centre: Position, + pattern: readonly Offset[], +): Piece[] | null => { + const ring = ringAt(state, centre, pattern); + const present = ring.filter((piece): piece is Piece => piece !== undefined); + if (present.length !== ring.length) { + return null; + } + const owner = present[0].player; + if (owner === state.activePlayer) { + return null; + } + return present.every((piece) => piece.player === owner) ? present : null; +}; + +// A cut runs centre -> gap -> outside. A ring's gaps are the cells of the other +// pattern, so an orthogonal step cuts a diagonal ring and vice versa. +const cutCaptures = ( + state: BoardState, + centre: Position, + direction: Offset, +): Piece[] => { + const gap = shift(centre, direction, 1); + if (!placedThisTurnAt(state, gap) || !placedThisTurnAt(state, shift(centre, direction, 2))) { + return []; + } + const ring = enemyRing(state, centre, isDiagonalOffset(direction) ? ORTHOGONAL : DIAGONAL); + return ring ? ring.filter((piece) => chebyshev(piece.position, gap) === 1) : []; +}; + +export const pendingCuts = (state: BoardState): Piece[] => + state.pieces + .filter((piece) => placedThisTurn(state, piece)) + .flatMap((piece) => + DIRECTIONS.flatMap((direction) => cutCaptures(state, piece.position, direction))); + +export const pendingCaptures = (state: BoardState): Piece[] => [ + ...new Set([...pendingEncirclements(state), ...pendingCuts(state)]), +]; + export const endTurn = (state: BoardState): BoardState => { - const captured = pendingCaptures(state); + const captured = new Set(pendingCaptures(state)); return { ...state, - pieces: state.pieces.filter((piece) => !captured.includes(piece)), + pieces: state.pieces.filter((piece) => !captured.has(piece)), activePlayer: nextActivePlayer(state.activePlayer, state.players.length), turn: state.turn + 1, }; -- 2.51.2