diff --git a/docs/memories/project/build-and-serve.md b/docs/memories/project/build-and-serve.md new file mode 100644 index 0000000..d64b787 --- /dev/null +++ b/docs/memories/project/build-and-serve.md @@ -0,0 +1,13 @@ +# Build and serve + +- `index.html` loads `/dist/main.js`. Nothing rebuilds automatically — a + source edit has zero effect in the browser until `tsc` runs. +- `npm run build` in `frontend/` compiles once. `npm run dev` is + `tsc --watch` — the better default for an interactive session. +- Source change appears to do nothing? Check `dist/` before debugging + the source — read the compiled file and confirm your change is + actually in it. +- `dist/` has the change but the browser doesn't? That's module + caching — hard-reload (Ctrl+Shift+R). +- `dist/` is gitignored, so a fresh clone renders nothing until the + first build. diff --git a/docs/memories/project/capture-rules.md b/docs/memories/project/capture-rules.md new file mode 100644 index 0000000..cb843e1 --- /dev/null +++ b/docs/memories/project/capture-rules.md @@ -0,0 +1,16 @@ +# Capture rules + +A piece is captured when all four cells of either pattern hold pieces of one single other player: + +- Orthogonal: N, S, E, W +- Diagonal: NE, NW, SE, SW + +These are the only two configurations. Only single pieces are captured; there are no group captures, and each victim is evaluated independently. + +Invariants that will hold for all future rules: + +- Captures are always triggered by the active player's own placements. Nothing is ever captured passively. A standing encirclement does not keep firing on later turns — the capturing player must actively place a stone completing the ring for it to fire. +- Captures resolve at end of turn, never on placement. Nothing commits until the player ends their turn, which keeps provisional placements fully retractable. +- 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. diff --git a/docs/tasks/2026-09-05/02-preview-pending-captures.md b/docs/tasks/2026-09-05/02-preview-pending-captures.md new file mode 100644 index 0000000..478d5fb --- /dev/null +++ b/docs/tasks/2026-09-05/02-preview-pending-captures.md @@ -0,0 +1,42 @@ +# Preview pending captures on the board + +Status: Not Started + +## Requirements + +- While a player is taking their turn, the board must visually indicate which pieces will be captured when that turn is ended. +- The indication must update live as the active player places and retracts pieces during their turn. +- The indication must be visually distinct from the existing provisional (dashed-outline) treatment used for retractable pieces. + +Nothing beyond these three is decided. In particular, none of the following are specified and should not be assumed: what the treatment actually looks like (reduced opacity, ghost outline, an overlaid marker, a colour shift), whether the four surrounding pieces forming the encirclement are also highlighted, whether the moment of capture at end-of-turn gets any animation, and whether captured pieces animate out or simply disappear on the next render. All of that belongs to the design conversation. + +## Relevant context + +This is a direct follow-up to the capture rule, which resolves captures at end of turn rather than immediately. That timing is what creates the need for this task: a player completes an encirclement, and without a preview nothing visibly happens until they commit the turn. + +State as of this task (see the files for the authoritative version — they will have moved on): + +- `game.ts` exposes `pendingCaptures(state)`, a pure query returning the pieces that will be removed when the current turn ends. It is already the single source of truth used by `endTurn`, so this task should consume it rather than re-deriving encirclement logic. +- `ui.ts` owns `toCanvasPieces(state)`, which maps game state into `canvas.Piece` view models and already derives the `provisional` flag from `game.isRetractable`. A pending-capture flag would be derived the same way, in the same place. +- `canvas.ts` owns `Piece` (`row`, `col`, `color`, `provisional`) and `drawStone`, which currently branches on `provisional` to pick line dash, stroke colour and line width. `canvas.ts` knows nothing about players, turns or rules and must stay that way — it receives a view model, not a game concept. +- `main.ts` is composition only. Its `render()` is the single re-render entry point and already runs after every state change, so no new listener or state should be needed. + +Read `docs/memories/project/separation-of-concerns.md` for the module boundaries this must respect, and `CLAUDE.md` for the repo conventions. + +## How to work this task + +This codebase is built by working the design out loud before writing code — using a separate design-focused session as a sounding board, and only implementing once the requesting user signs off. Continue that pattern: + +1. Before writing any implementation, brief a design-partner session with full context: these requirements, the relevant state noted above, and this repo's conventions file. Check `ListAgents` for a live design-partner session first; only start a fresh one if none exists. Don't assume the partner has memory of past sessions — give it everything it needs. +2. Let the design partner drive with questions and proposals rather than prescribing a shape yourself. Relay their questions to the user unless you can confidently answer them from context already established in this repo's own conventions. +3. Anything that is a genuine product/UX call rather than an architecture call belongs to the user. Use `AskUserQuestion` for those, including the design partner's reasoning so the user sees the trade-off, not just a bare question. +4. Do not start implementing until the user has explicitly approved a final design. Summarize the agreed shape concisely before asking for that go-ahead. +5. Once approved, implement, rebuild, and verify the result — visually via a connected browser tool if available, otherwise through the best structural check available, with a clear note about what's still unverified. + +## Open questions worth raising in that design conversation + +- A piece can be both provisional and doomed at once only if a player could encircle their own piece, which the capture rule forbids — so in practice the two states are mutually exclusive today. Is it worth `drawStone` handling them as one ordered branch, or should the view model make the exclusivity explicit? +- Does the preview belong on the victim, on the encircling ring, or both? Highlighting only the victim tells you what dies; highlighting the ring tells you why. +- `drawStone` currently branches on a single boolean. Adding a second boolean gives four combinations for three real states. Is a small union (`"normal" | "provisional" | "doomed"`) the better shape for `canvas.Piece`, and does that count as canvas.ts knowing too much? +- The vision in `docs/vision.md` points at an LED-strip display on a Raspberry Pi Pico, where subtle outline treatments may not survive. Should the preview be expressible as something cruder (a colour or a blink) so it ports, or is that a screen-only affordance? +- Captures are evaluated only for the active player's encirclements, so the preview only ever shows other players' pieces dying. Worth confirming that reads clearly to a player rather than looking like their own pieces are at risk. diff --git a/docs/tasks/2026-09-05/03-express-rules-as-board-state-transitions.md b/docs/tasks/2026-09-05/03-express-rules-as-board-state-transitions.md new file mode 100644 index 0000000..a247a43 --- /dev/null +++ b/docs/tasks/2026-09-05/03-express-rules-as-board-state-transitions.md @@ -0,0 +1,45 @@ +# Express rules as board state transitions + +Status: Not Started + +## Requirements + +- The game's rules should be expressed through a shared abstraction defined in terms of board state transitions, rather than each rule existing as a bespoke standalone function. + +That single sentence is the entire requirement, and it is deliberately open. Nothing about the shape of that abstraction is decided. In particular, none of the following should be assumed: whether it is a type, an interface, a list of functions, or a data-driven rule table; whether `place`, `retract`, `endTurn` and capture resolution all become instances of it or only some do; whether rules compose, run in a fixed order, or attach to named points in the turn cycle; whether `game.ts`'s public API changes at all; and whether this is a refactor of existing rules only or a foundation intended to carry future ones. + +This is a future-facing task raised by the user after reviewing the capture rule implementation. It is not a bug and nothing is currently broken. + +## Relevant context + +State as of this task (see `frontend/src/game.ts` for the authoritative version — it will have moved on): + +- `game.ts` currently exposes rules as individual functions with two different shapes. `place` and `retract` are guarded transforms returning `BoardState | null`, where `null` means the move was illegal. `endTurn` is an unguarded transform returning `BoardState`. `pendingCaptures` is a pure query returning the pieces that end-of-turn resolution will remove, consumed both by `endTurn` and by any future preview. +- Supporting pure queries: `pieceAt`, `remaining`, `isRetractable`, `currentPlayer`, plus internal `placedThisTurn`, `ringAt` and `isCapturingRing`. +- `game.ts` deliberately does not know the board dimensions. Capture correctness at the board edge falls out of a piece simply being absent, not from bounds checking. Any abstraction must preserve this. + +Two recorded constraints this task must not violate: + +- `docs/memories/project/separation-of-concerns.md` — `game.ts` owns rules and state and knows nothing about pixels, clicks or rendering; it returns new state and never describes what happened. +- `docs/memories/project/capture-rules.md` — captures resolve at end of turn, are always triggered by the active player's own placements, and never fire passively. Any abstraction still has to be able to express a rule with that timing and that trigger condition. + +Also relevant: `docs/tasks/2026-09-05/01-decouple-game-rules-from-ui-effects.md` removed a `MoveResult`/`Effect`/`Illegality` reporting layer from `game.ts` on the grounds that it had no consumer and encoded UI concerns into the rules module. + +## How to work this task + +This codebase is built by working the design out loud before writing code — using a separate design-focused session as a sounding board, and only implementing once the requesting user signs off. Continue that pattern: + +1. Before writing any implementation, brief a design-partner session with full context: these requirements, the relevant state noted above, and this repo's conventions file. Check `ListAgents` for a live design-partner session first; only start a fresh one if none exists. Don't assume the partner has memory of past sessions — give it everything it needs. +2. Let the design partner drive with questions and proposals rather than prescribing a shape yourself. Relay their questions to the user unless you can confidently answer them from context already established in this repo's own conventions. +3. Anything that is a genuine product/UX call rather than an architecture call belongs to the user. Use `AskUserQuestion` for those, including the design partner's reasoning so the user sees the trade-off, not just a bare question. +4. Do not start implementing until the user has explicitly approved a final design. Summarize the agreed shape concisely before asking for that go-ahead. +5. Once approved, implement, rebuild, and verify the result — visually via a connected browser tool if available, otherwise through the best structural check available, with a clear note about what's still unverified. + +## Open questions worth raising in that design conversation + +- The rules currently in `game.ts` are not all the same kind of thing. `place` and `retract` are guarded responses to a player's intent; capture resolution is an unprompted consequence that fires at a fixed point in the turn cycle. Is one abstraction meant to cover both, or does forcing them together obscure a real distinction? +- Does a transition take an intent as input (`(state, intent) => state | null`) or only state (`(state) => state`)? Those are different abstractions and the answer determines whether legality and consequence live in one mechanism or two. +- If rules become a collection, what determines their order, and does order matter? Capture resolution currently depends on running against the pre-advance state — an abstraction that lets rules be reordered freely could break that quietly. +- Does introducing a shared vocabulary for "a rule ran and changed the board" risk re-creating the `Effect`/`MoveResult` reporting layer that task 01 deliberately deleted? The recorded principle is no vocabulary without a live consumer; worth checking the proposed abstraction against it directly. +- `docs/vision.md` targets an eventual Raspberry Pi Pico implementation driving an LED strip. Does the abstraction need to be portable to a constrained environment, or is it explicitly a TypeScript-side convenience that the Pico version would not mirror? +- What concrete future rule is this meant to make easier? Ko and group captures have both been mentioned. Designing the abstraction against a real second rule rather than against the current one alone would test whether it actually generalises. diff --git a/frontend/src/game.ts b/frontend/src/game.ts index f3b7707..d52cb3a 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -31,9 +31,14 @@ export const currentPlayer = (state: BoardState): Player => const isSamePosition = (a: Position, b: Position): boolean => a.row === b.row && a.col === b.col; -export const isRetractable = (state: BoardState, piece: Piece): boolean => +// "Can the user undo this" and "did this placement arm a capture" coincide today +// but are different questions; they may diverge if retraction rules ever change. +const placedThisTurn = (state: BoardState, piece: Piece): boolean => piece.player === state.activePlayer && piece.turn === state.turn; +export const isRetractable = (state: BoardState, piece: Piece): boolean => + placedThisTurn(state, piece); + const placementsThisTurn = (state: BoardState): number => state.pieces.filter((piece) => isRetractable(state, piece)).length; @@ -46,11 +51,53 @@ export const pieceAt = (state: BoardState, position: Position): Piece | undefine const nextActivePlayer = (activePlayer: number, playerCount: number): number => (activePlayer + 1) % playerCount; -export const endTurn = (state: BoardState): BoardState => ({ - ...state, - activePlayer: nextActivePlayer(state.activePlayer, state.players.length), - turn: state.turn + 1, -}); +type Offset = { row: number; col: number }; + +const ORTHOGONAL: readonly Offset[] = [ + { row: -1, col: 0 }, { row: 1, col: 0 }, { row: 0, col: -1 }, { row: 0, col: 1 }, +]; + +const DIAGONAL: readonly Offset[] = [ + { row: -1, col: -1 }, { row: -1, col: 1 }, { row: 1, col: -1 }, { row: 1, col: 1 }, +]; + +const PATTERNS: readonly (readonly Offset[])[] = [ORTHOGONAL, DIAGONAL]; + +const ringAt = ( + state: BoardState, + position: Position, + pattern: readonly Offset[], +): readonly (Piece | undefined)[] => + pattern.map((offset) => + pieceAt(state, { row: position.row + offset.row, col: position.col + offset.col })); + +const isCapturingRing = ( + state: BoardState, + ring: readonly (Piece | undefined)[], +): boolean => { + const owned = ring.filter( + (piece): piece is Piece => piece !== undefined && piece.player === state.activePlayer, + ); + return owned.length === ring.length && owned.some((piece) => placedThisTurn(state, piece)); +}; + +export const pendingCaptures = (state: BoardState): Piece[] => + state.pieces.filter( + (piece) => + piece.player !== state.activePlayer && + PATTERNS.some((pattern) => + isCapturingRing(state, ringAt(state, piece.position, pattern))), + ); + +export const endTurn = (state: BoardState): BoardState => { + const captured = pendingCaptures(state); + return { + ...state, + pieces: state.pieces.filter((piece) => !captured.includes(piece)), + activePlayer: nextActivePlayer(state.activePlayer, state.players.length), + turn: state.turn + 1, + }; +}; export const place = (state: BoardState, position: Position): BoardState | null => { if (pieceAt(state, position) || remaining(state) <= 0) { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 5b19615..0ab45fd 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,7 +4,7 @@ import * as canvas from "./canvas.js"; import * as ui from "./ui.js"; import * as game from "./game.js"; -const BOARD: canvas.BoardConfig = { rows: 19, cols: 19 }; +const BOARD: canvas.BoardConfig = { rows: 32, cols: 32 }; const COLORS = ["black", "white", "#e63946", "#457b9d"];