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, };