From 7cca3bcf469ffbf1fbb9fff4971d2ef0f26a8115 Mon Sep 17 00:00:00 2001 From: Arcadia Rose Date: Mon, 7 Sep 2026 15:16:07 -0400 Subject: [PATCH] Open cuts and cut bonus placements --- .../feedback/design-partner-workflow.md | 27 ++ docs/memories/project/build-and-serve.md | 34 +++ docs/memories/project/capture-rules.md | 79 ++++- .../2026-09-07/12-open-cuts-and-cut-bonus.md | 114 ++++++++ .../13-place-into-pending-captures.md | 85 ++++++ frontend/src/canvas.ts | 20 +- frontend/src/game.ts | 275 ++++++++++++------ frontend/src/main.ts | 4 +- frontend/src/ui.ts | 26 +- 9 files changed, 548 insertions(+), 116 deletions(-) create mode 100644 docs/tasks/2026-09-07/12-open-cuts-and-cut-bonus.md create mode 100644 docs/tasks/2026-09-07/13-place-into-pending-captures.md diff --git a/docs/memories/feedback/design-partner-workflow.md b/docs/memories/feedback/design-partner-workflow.md index fe2272a..b571899 100644 --- a/docs/memories/feedback/design-partner-workflow.md +++ b/docs/memories/feedback/design-partner-workflow.md @@ -19,6 +19,16 @@ final design. 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. +- Don't let the word "tweak" override the substance test above. Arcadia + described a request to the coder session on 2026-09-07 as "one tweak I + want to make to the implementation," but its content — replacing the + `bonusPlacements` mechanic with same-turn translucent/placeable cut + cells — is new behavior/rules, not an adjustment of values within an + already-approved shape. The coder session proactively messaged Seer to + kick off the design conversation, which is the wrong move per the + bullet above: the user coordinates with Seer herself for a brand-new + rule change, even one she calls a "tweak." Classify by what the request + actually changes, not by the word used to introduce it. - That doesn't extend to small cosmetic refinements of a design already approved and mid-implementation — those are fine to just implement directly without routing back through Seer first. Confirmed 2026-09-06: mid-review on @@ -57,6 +67,23 @@ final design. Treat a mismatch between a Seer spec and a worked board position as evidence against the spec first. +- A Seer data-model spec can be internally correct and still leave the + feature unreachable through the UI, because Seer doesn't re-derive + `ui.ts`'s existing dispatch logic against the new data model. Found + 2026-09-07 on task 13 (placing into pending-capture cells): the spec fully + covered `game.ts`'s `place`/`retract`/`pieceAt` semantics but didn't + mention that `ui.ts`'s `handleClick` branched on `pieceAt(state, + position)` truthy -> retract, else -> place -- which, once a doomed cell + legitimately holds a piece, always routed a click there to retract + (silently failing, since the victim was never retractable) and could + never reach `place` to create the overlay. Fixed to `game.retract(state, + position) ?? game.place(state, position)`, composing correctly for every + case without inspecting `pieceAt` at all. The lesson: after implementing + a spec's data-model rules, trace the UI's existing input-dispatch code + against the new state shape before calling a feature done, even when the + spec doesn't ask you to touch that file -- "matches the spec" and + "reachable by a click" are different claims. + Stated as the working process in `docs/tasks/2026-09-05/01-decouple-game-rules-from-ui-effects.md`; confirmed 2026-09-05 when Seer proposed deleting `game.ts`'s `MoveResult`/`Effect`/`Illegality` diff --git a/docs/memories/project/build-and-serve.md b/docs/memories/project/build-and-serve.md index 23d53ed..8a4a7ea 100644 --- a/docs/memories/project/build-and-serve.md +++ b/docs/memories/project/build-and-serve.md @@ -22,6 +22,20 @@ "module"`, so Node treats a `.js` import from inside `frontend/` as CommonJS and fails on the compiled `export` syntax), then `import * as game from "./game.mjs"` from a throwaway Node script there. +- Two recurring traps when hand-building a `BoardState` fixture to test a + capture rule in isolation: (1) every non-home-base piece stamped with the + active player's own `turn` counts as "fresh" and toward + `placementsThisTurn` -- a wall built entirely of same-turn stones can blow + the placement budget and make `place`/`endTurn` reject for that reason + alone, unrelated to the rule under test. Stamp all but the one stone that + should actually trigger the rule with an earlier `turn` (e.g. `0`) so only + one placement is "this turn." (2) A wall shaped to trigger territory can + incidentally also complete a diagonal or orthogonal four-piece ring around + the same victim if any wall stone sits at Chebyshev distance 1 -- keep the + victim at least 2 cells from every wall stone to isolate a territory-only + test, and assert `pendingEncirclements(state).length === 0` in the test + itself so a future refactor can't silently reintroduce a ring capture + without the test noticing it stopped being a pure territory case. - Static assets (favicon, images, etc.) belong in `frontend/`, not `backend/` — `backend/src/server.ts` only ever does `express.static(frontendDir)`, so anything served at the root URL @@ -55,3 +69,23 @@ exclude both the board-fill color AND the grid-line ink color — an empty intersection samples as line ink, not background, so checking against background alone flags every empty cell as occupied. +- This repo's version control is **Jujutsu (`jj`)**, colocated with git + (`git status`/`git log` etc. also work and reflect the same history, + including a `main` bookmark). Arcadia works directly in `jj`'s + anonymous-branch model: `@` is the working-copy commit and edits + auto-amend into it as they happen, with no separate staging/commit + step. Consequences worth knowing before touching history: + - `jj new` starts a fresh empty child commit to work in next. If you + then `jj edit` away to an ancestor and that empty, description-less + commit has no other reason to stick around, `jj` silently abandons + it — don't assume an empty `jj new` commit you saw earlier is still + there once you've navigated elsewhere. + - To pull specific hunks out of a commit that ended up with two + unrelated changes mixed into it (e.g. edits auto-amended into + whatever `@` happened to be during a session), `jj split -i` needs + a TUI this tool can't drive. The reliable non-interactive path: + `jj edit `, manually revert the unwanted hunks in place + (if you made the edits yourself in the same session, the Edit tool + calls are known exactly and reversible), confirm with + `jj diff -r --summary`, then `jj new` on top and reapply + the extracted hunks there, and `jj describe` the new commit. diff --git a/docs/memories/project/capture-rules.md b/docs/memories/project/capture-rules.md index 43474c2..5e9fed4 100644 --- a/docs/memories/project/capture-rules.md +++ b/docs/memories/project/capture-rules.md @@ -33,25 +33,76 @@ Invariants that will hold for all future rules: ## Cutting -Arcadia's governing principle, stated directly: **cuts are only allowed through encirclements, not through arbitrary shapes.** +**Current principle (since 2026-09-07): a cut severs a line; it needs a line of at least two enemy stones, not a closed shape.** -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 branches on whether d is orthogonal or diagonal, and both branches go through an encirclement: +Superseded principle, in force until 2026-09-07: "cuts are only allowed through encirclements, not through arbitrary shapes." That rule made cutting a late-game tool — it could only fire once an encirclement or a territory wall already existed — so it never punished a forming attack, and the player who attacked first and kept attacking dominated play. Opening cuts to any line lets cut-power scale with how much the aggressor has built, so the defender has a live answer to an attack in progress, not just to a finished one. -- **Orthogonal d** pierces a complete diagonal four-piece ring centred on C: it fires when all four diagonal neighbours of C belong to one non-active player, and it captures the two ring members at Chebyshev distance 1 from the gap cell C+d — always exactly two. Untouched by territory: a diagonal ring never encloses its own centre, so this branch has no territory case to fold in. -- **Diagonal d** severs a joint in an enemy player's territory wall. Let A = C+(d.row, 0) and B = C+(0, d.col), the two orthogonal shoulders of the first step. It fires when A and B are stones of one non-active player P, AND C lies inside P's current territory — and captures A and B. A complete orthogonal ring around C is the special case where C is a one-cell territory by construction, so every case the old orthogonal-ring-only cut rule handled still fires identically; the only change is that the wall being cut may now be arbitrarily large. Two diagonally-adjacent enemy stones sitting in open space with nothing enclosed — Arcadia's "arbitrary shape" — capture nothing, even though the three cut cells line up the same way. A and B are always diagonally adjacent to each other by construction — A − B = (d.row, −d.col), and d is diagonal, so that difference is always a diagonal offset — which is why `diagonalCutCaptures` has no separate adjacency check; there is nothing for one to catch. -- **The diagonal cut also fires on a pair of an owner's own stones sitting INSIDE their territory, not only on the boundary wall itself.** An opponent can spend three placements deep inside your territory to take two of your interior stones, as long as they're diagonally adjacent to each other with an empty cell between them that's inside your territory. This is intended and known, not a bug to fix: it still satisfies Arcadia's principle (C is inside an encirclement — the owner's territory), and it's self-limiting, since it needs a genuine *pair* of diagonally-touching interior stones; a lone interior stone is never vulnerable this way. +A **link** is the unit a cut is built from. Given a fresh stone A (`placedThisTurn` — the active player's, stamped this turn; a home base is never fresh) and one of the 8 directions d, a link from A along d exists in exactly two forms: -Invariants: +- **Joint link** — the neighbour at A+d is itself fresh. Target is A+d. +- **Pierce link** — the neighbour at A+d is a stone of a player other than the active player, AND the cell at A+2d is fresh. Target is A+2d, victim is the stone at A+d. -- All three cut cells must be placed in the same turn. Pre-existing pieces never count toward a cut. -- A cut never takes more than two stones — the two ring members (orthogonal d) or the two shoulders (diagonal d) — so it never dismantles an entire ring or wall in one move. -- A single line can cut two different rings or walls, one at each endpoint. Both fire. +No other case is a link. This is what keeps two carried-over invariants true without a separate check: pre-existing own stones never participate (a joint needs the neighbour fresh; a pierce needs it enemy), and there's never an empty gap inside a cut line (both forms require the far cell occupied and fresh). + +A **cut** is two links along the same direction, chained: a link from fresh stone A along d, and a link from that link's target along the same d. Three fresh stones, two links. + +What each link captures, evaluated against its own source stone: + +- **Joint link, d orthogonal:** nothing — it only chains. +- **Joint link, d diagonal:** the other two corners of the unit square between source and target. If both hold stones, both belong to one player, and that player isn't the active player: both are captured. No separate "these are a line of 2" check is needed — the two victims are diagonally adjacent to each other by construction, so they satisfy the line requirement automatically. +- **Pierce link:** the victim is captured only if it has at least one 8-adjacent stone belonging to the same player as itself — the "line of 2+" requirement. A lone enemy stone can never be flanked this way, so cutting stays an anti-structure tool rather than a way to snipe isolated stones. The two cut cells flanking the victim are the active player's, so a same-owner neighbour is never on the cut line itself — no extra exclusion needed. + +`pendingCuts` returns the deduplicated union of captures across every cut found this way. Iterating every fresh stone as a possible start and all 8 directions covers both traversal orders of each axis; the dedup absorbs the resulting double count. + +Invariants, carried over and still true: + +- All cut cells must be placed in the same turn. Pre-existing pieces never count toward a cut. - Cuts, rings, and territory all resolve together at end of turn, computed against the same pre-capture board, applied as one batch. -- A wall is cuttable exactly at diagonal joints whose gap cell is empty. Filling a corner blocks the cut there, because the cutter's C+d cell must be unoccupied to be placed into — open-cornered rings/walls are cuttable at every corner, and filled-corner walls are not cuttable at all. Pairs with "solid shapes are immune to both ring patterns and to cutting" below: a solid or two-cell-thick wall has no gap to place C+d into regardless of direction. +- A wall is cuttable exactly where the cutter has empty cells to play into — the cutter needs somewhere legal to place the fresh stones that form the link chain. + +Invariants that changed: + +- **A cut is no longer capped at two stones.** A single cut line can cross a diagonal wall at more than one joint, and each diagonal crossing can capture a pair — so a cut can now take up to 4 stones (two diagonal crossings on one line), not a fixed 2. +- **The old orthogonal-d/diagonal-ring and diagonal-d/territory-wall branches are gone.** There is one geometric rule (link, then cut), not two hand-written cases keyed to encirclement type. `enemyRing`, `orthogonalCutCaptures`, `diagonalCutCaptures`, and the territory cache inside `pendingCuts` were deleted; `pendingCuts` no longer calls `enclosedCells` at all, which is also a real performance win (see the bonus section below). + +New strategic facts: + +- **Diagonal walls are now severable anywhere along their length**, not only where they bound territory — a meaningful nerf to "diagonal walls enclose roughly twice the area per stone of an orthogonal wall" below: that stone-efficiency advantage now comes with more exposure along the whole wall, not just at its corners. +- **Two-thick and solid walls remain cut-immune.** A pierce link requires the middle cell to hold exactly one enemy stone with a fresh stone immediately beyond it; two enemy stones in a row block that. Territory is still the only answer to solid matter. +- **Parallel walls spaced exactly 2 apart are the one shape that makes the bonus (below) net-positive** — one stone placed between them can complete a cut against each wall. This is an emergent strategic constraint to watch for, not a defect: don't build that shape. + +### Bonus placements + +Cutting refunds placements, within the same turn: + +```ts +export const bonusPlacements = (state: BoardState): number => + pendingCuts(state).filter((piece) => !piece.isHomeBase).length; +``` + +Home bases are excluded because they're never actually removed by a cut. `remaining` becomes `state.placementsPerTurn + bonusPlacements(state) - placementsThisTurn(state)`. It's derived, never stored — `BoardState` gains no field for it, and it's recomputed fresh from the provisional board on every placement. + +`remaining >= 0` is a turn-long invariant, maintained by a single guard in `retract`: a retraction is rejected if it would leave `remaining` negative. Placing more stones can only add pending cuts, never remove them, so `remaining` can only be driven negative by retraction — which is why the guard lives there and `endTurn` needs no budget check of its own. In practice a player retracts bonus stones before the stone that armed the cut, which naturally keeps the board in budget throughout. + +Bonus stones are themselves fresh, so a bonus stone can complete a further cut and earn further bonus — chaining is intended, is the whole point of the change, and terminates because every capture consumes a distinct enemy stone. It's shipped uncapped; if play shows it running away, the fix is a cap on `bonusPlacements`, not a change to the geometry. + +`remaining` can now exceed the number of empty cells on the board. `boardIsFull` is unaffected by this — no new *restriction* was added to `place` (it still rejects only out-of-bounds, occupied, no-placements-left, game-over, and the opening-adjacency rule), so "a legal move exists whenever any cell is empty" is still true and the long comment above `boardIsFull` in `game.ts` still holds. + +Dependency worth protecting, now for links rather than rings/territory: a link's pierce form depends on `placedThisTurn` distinguishing fresh stones from pre-existing ones, and on `pieceAt` returning `undefined` off-board and for empty cells. Anyone "simplifying" either of those would silently change what counts as a link. + +### Overlays (since 2026-09-07) + +A doomed piece — anything in `pendingCaptures` — stays on the board until `endTurn`, but as of this date it's also a legal `place` target: placing into it costs a placement (an ordinary one, or a bonus one, exactly as if the cell were empty) but does not grant the captured stone automatically. Arcadia's framing: this is a real choice, not a freebie — cement the slot to deny the opponent a rebuild, or leave it open and expand elsewhere. Applies to every capture rule (rings, cuts, territory), not cuts alone, because the economy is untouched either way: territory grants no bonus placements, so a large territory capture still costs one placement per cell claimed, same as a single cut. + +**An overlay is a piece whose position is also occupied by another, earlier piece.** The earlier piece is always the doomed one, since a victim necessarily predates any stone placed this turn. This makes the invariant "an overlay is inert for every rule during the turn it's placed" mostly free: + +- Everything that reads the board through `pieceAt` — `tryLink`, `ringAt` — already sees the victim, never the overlay, because `pieceAt`'s `.find` always resolves to the earliest-appended piece at a position. This is now load-bearing, not incidental: don't "fix" `pieceAt` into topmost-wins, or overlays start participating in rings and cuts. **`retract` is the sole exception, and the trap the next person will fall into:** it's the one caller that deliberately wants the *topmost* piece at a position, since a click on an overlaid cell has to retract the overlay the active player actually placed, never the victim underneath. It goes through a separate `overlayAt` helper (latest piece at a position, or `undefined` if there's only one occupant) and falls back to `pieceAt` when there's no overlay to prefer. This was a genuine gap in the original overlay design — earliest-wins was established as load-bearing without being followed through to this one caller — so if `retract` is ever refactored, check that it's still asking for the overlay first. +- Two places read `state.pieces` directly instead of through `pieceAt`, and both had to explicitly exclude overlays or they self-cancel: `pendingCuts`' fresh-stone scan (an overlay is fresh, so without the exclusion it becomes a link source on a cell `pieceAt` simultaneously reports as an enemy stone), and `territoryByPlayer`/`pendingTerritories`' blocker lists (an overlay is the active player's own stone, so without the exclusion it blocks its own cell in the `after` flood fill — un-dooming the very piece it was placed on). +- `pendingEncirclements` needed no change: its victim filter already drops the active player's own pieces, and its ring test goes through `pieceAt`. -Dependency worth protecting: cutting requires placing a stone INSIDE an enemy ring or territory. It works only because a standing ring or territory does not fire passively. Anyone "simplifying" that behaviour would silently break cutting, and the coupling is invisible from the code. +**Why captures still can't resolve eagerly, at placement time, even though placeability is now immediate:** `pendingCuts` (and every other pending-* rule) is recomputed from scratch off the live board on every call, not accumulated. Deleting a victim the instant it's doomed would make its own cut evaporate the moment something else on the board changes and the rule recomputes — there'd be nothing left at that position for the link to have pierced. Making that safe would require stashing removed pieces somewhere to reconstruct "what the board looked like when this fired," which is exactly the accumulated-history `BoardState.scores`'s own comment says to avoid until something concrete needs it. So "captures resolve at end of turn" survives as a fact about `state.pieces`, even though the visual (translucent) and the legality (placeable) are both immediate. -Intentional asymmetry, for the orthogonal-d/diagonal-ring case: 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. The diagonal-d/territory-wall case has no fixed re-forming cost, since walls vary in size. +`place` gained the overlay-acceptance rule (empty, or exactly one occupant that's currently doomed — never a second occupant, no stacking). `retract` gained an orphan guard alongside its existing budget guard: retracting the stone that armed a capture can leave an overlay sitting on a piece that's no longer doomed without ever driving `remaining` negative, so the budget guard alone doesn't catch it. `retract` also had to start preferring the *latest* piece at a position (the overlay) over `pieceAt`'s earliest-piece answer, specifically so a click on an overlaid cell retracts the overlay and not the victim underneath — the one place in the codebase that deliberately wants topmost-wins instead of `pieceAt`'s convention. `boardIsFull` switched from `pieces.length` to a count of distinct occupied positions, since an overlay and its victim share one cell but are two pieces. ## Territory @@ -74,9 +125,9 @@ Strategic axis worth watching in play: diagonal walls enclose roughly twice the These follow from the existing rules rather than being new rules. They were derived during the Territory design conversation and verified against `game.ts`. -**Solid shapes are immune to both ring patterns and to cutting.** Every stone in a solid rectangle — or in any wall two cells thick — has at least two orthogonal neighbours of its own colour, and its inward diagonals are its own colour as well. Neither ring can then be all-one-enemy around such a stone, and there is no gap for a cut to enter through. This was the main gap in the ruleset before territory shipped: territory has since become the answer to solid matter, since an enemy stone's own solidity does not block an opponent's flood fill — a solid blob fully enclosed by someone else's wall is captured wholesale like anything else inside it. See "Territory" above. +**Solid shapes are immune to both ring patterns and to cutting.** Every stone in a solid rectangle — or in any wall two cells thick — has at least two orthogonal neighbours of its own colour, and its inward diagonals are its own colour as well. Neither ring can then be all-one-enemy around such a stone. Since 2026-09-07, cutting works by chaining links rather than piercing an encirclement, but the immunity survives: a pierce link needs its middle cell to hold exactly one enemy stone with an empty (fresh-placeable) cell immediately beyond it, and a solid or two-thick wall has no such cell anywhere along it. This was the main gap in the ruleset before territory shipped: territory has since become the answer to solid matter, since an enemy stone's own solidity does not block an opponent's flood fill — a solid blob fully enclosed by someone else's wall is captured wholesale like anything else inside it. See "Territory" above. -**Hollow shapes are soft at their corners, and the hollow is why.** In a 3x3 ring with an empty centre, a corner stone's four diagonals are three cells outside the shape plus the hollow centre — all empty. An attacker takes it for 4 placements over two turns: three outside diagonals on turn one (within the 3-placement budget), then the centre stone on turn two, which completes the ring and fires that same turn. One centre stone is the inward diagonal for all four corners at once. Edge-midpoint stones are immune, because both their inward diagonals are wall. The same 3x3 ring cannot be cut, because a cut needs its C+d cell to be a gap in the ring and both patterns around the centre are occupied. +**Hollow shapes are soft at their corners, and the hollow is why.** In a 3x3 ring with an empty centre, a corner stone's four diagonals are three cells outside the shape plus the hollow centre — all empty. An attacker takes it for 4 placements over two turns: three outside diagonals on turn one (within the 3-placement budget), then the centre stone on turn two, which completes the ring and fires that same turn. One centre stone is the inward diagonal for all four corners at once. Edge-midpoint stones are immune, because both their inward diagonals are wall. Since 2026-09-07 this ring is independently cuttable too, wherever a fresh stone can reach an empty cell along a line that pierces one of its member stones — cutting no longer needs the ring itself to be a target of an encirclement. The defence is to fill the hole, which makes the shape solid and fully immune — but a filled shape encloses nothing. A shape is impenetrable exactly when it holds no space. diff --git a/docs/tasks/2026-09-07/12-open-cuts-and-cut-bonus.md b/docs/tasks/2026-09-07/12-open-cuts-and-cut-bonus.md new file mode 100644 index 0000000..b449194 --- /dev/null +++ b/docs/tasks/2026-09-07/12-open-cuts-and-cut-bonus.md @@ -0,0 +1,114 @@ +# Open cuts and cut bonus placements + +Status: Complete + +## Intent + +Play is dominated by whoever attacks first and keeps attacking. The attacker is the player who builds *shapes*; cuts destroy shapes. Today cutting only works through an encirclement or a territory wall, so it arrives too late to punish a forming attack. Opening cuts up to any line of stones makes cut-power scale with how much the aggressor has built, and refunding placements lets a defender who reads the board right keep dismantling within a single turn. + +## Change 1 — cuts work on any line, not only encirclements + +**This replaces both existing cut branches entirely.** Delete `enemyRing`, `orthogonalCutCaptures`, `diagonalCutCaptures`, and the territory cache inside `pendingCuts`. `pendingCuts` no longer calls `enclosedCells` at all — that is a real performance win, see "Cost" below. `pendingEncirclements` and `pendingTerritories` are untouched. + +### Definitions + +**Fresh** = a piece where `placedThisTurn(state, piece)` — the active player's, stamped with the current turn. A home base is never fresh (`PRE_GAME_TURN` sentinel). + +**Link.** Given a fresh stone A and a direction d (one of the 8 in `DIRECTIONS`), a link from A along d exists in exactly two forms: + +- **Joint link** — `pieceAt(A + d)` is fresh. Target is `A + d`. +- **Pierce link** — `pieceAt(A + d)` is a stone of a player *other than* the active player, AND `pieceAt(A + 2d)` is fresh. Target is `A + 2d`, victim is the stone at `A + d`. + +No other case is a link. This is what enforces the two carried-over invariants without a separate check: pre-existing own stones never participate (a joint needs the neighbour fresh; a pierce needs it enemy), and there are never empty cells inside a cut line. + +**Cut.** For each fresh stone A and each direction d: if a link exists from A along d, and a link exists from that link's target along the *same* d, those two links form a cut. Three fresh stones, two links. + +### What a cut captures + +- **Joint link, d orthogonal:** nothing. It only serves to chain. +- **Joint link, d diagonal:** let S1 = `{row: A.row + d.row, col: A.col}` and S2 = `{row: A.row, col: A.col + d.col}` — the other two corners of the unit square. If both hold stones, both belong to the same player, and that player is not the active player: capture both. +- **Pierce link:** capture the victim **if** the victim has at least one 8-adjacent stone belonging to the same player as itself. This is the "line of 2+" requirement Arcadia chose: a lone enemy stone can never be flanked, so cutting stays an anti-structure tool. Note the two cut cells flanking the victim are the active player's stones, so a same-owner neighbour is never on the cut line — no extra exclusion needed. + +The diagonal joint case needs no such test: its two victims are diagonally adjacent to each other, so they are a line of 2 by construction. State that in the memory doc — it's why the rule is one principle, not two. + +`pendingCuts` returns the **deduplicated** union of captures across every cut. Iterating all 8 directions covers both traversal orders of each axis; dedup absorbs the double count. Home base filtering stays where it is, in `pendingCaptures`. + +### Worked examples (Arcadia's own, use as tests) + +``` +A1 pierce A3 cross +. . R . . . . R +B B R B . B R . +. . R . . R B . +. . R . R . . B +``` +A1: B fresh at (1,0),(1,1),(1,3). Joint (1,0)→(1,1) captures nothing; pierce (1,1)→(1,3) takes R(1,2), which has same-owner neighbours R(0,2) and R(2,2). One capture. +A3: B fresh at (1,1),(2,2),(3,3). Joint (1,1)→(2,2) crosses R(1,2)/R(2,1) → both captured. Joint (2,2)→(3,3) crosses empty (2,3)/(3,2) → nothing. Two captures. + +## Change 2 — cuts refund placements, same turn + +Add: + +```ts +export const bonusPlacements = (state: BoardState): number => + pendingCuts(state).filter((piece) => !piece.isHomeBase).length; +``` + +Home bases are excluded because they are never actually removed. A piece claimed by both a cut and territory counts once (the set is deduped) and still counts — the player did cut it. + +Change `remaining`: + +``` +remaining = state.placementsPerTurn + bonusPlacements(state) - placementsThisTurn(state) +``` + +This is the whole mechanic. It stays a pure function of the provisional board, recomputed on every placement, so nothing new is stored on `BoardState` and captures still resolve only at end of turn. Placing more stones can only *add* pending cuts, never remove them, so `remaining` can only be driven negative by retraction — which is why: + +**`retract` gains one guard: reject the retraction if `remaining` of the resulting state would be < 0.** Compute the candidate state, check, return `null` if over budget. This keeps `remaining >= 0` a true invariant for the whole turn, which means `endTurn` needs no new guard at all. In practice the player retracts their bonus stones first, then the stone that armed the cut becomes retractable again — natural order, and the board is never in an over-budget state. + +`place` already rejects on `remaining <= 0`; leave it. + +### Chaining is intended + +Bonus stones are themselves fresh, so a bonus stone can complete a further cut and earn further bonus. This is the point of the change — one correct read cascades. It terminates: every capture consumes a distinct enemy stone, so the bonus is bounded by the board's population. + +It *is* net-positive against regularly spaced structures — parallel enemy walls spaced exactly 2 apart perpendicular to the cut line let one extra stone earn two captures. Ship it uncapped; record it in the memory doc as an emergent strategic constraint ("never build parallel walls spaced exactly 2 apart") rather than a defect. If play shows it running away, the fix is a cap on `bonusPlacements`, not a change to the geometry — don't add one pre-emptively. + +## UI (`ui.ts`) + +`statusText` computes `placed` as `placementsPerTurn - remaining`, which is now wrong. Export from `game.ts`: + +- `placementsThisTurn` (currently private) — or a `placementBudget(state) = placementsPerTurn + bonusPlacements(state)`; your call, pick one and don't export both. + +Then: `${placed} of ${budget} placed`, and when `bonusPlacements(state) > 0` append ` (+${bonus} from cuts)` so the player can see where the extra stones came from. The opening-turn branch is unchanged. + +## Cost + +`pendingCuts` drops from N flood fills to O(fresh stones × 8) pure lookups. `remaining` now calls `pendingCuts` and is called from `place`, `endTurn` and `statusText` — that is fine precisely because the flood fill is gone. `docs/tasks/2026-09-05/04-reduce-cost-of-capture-computation.md` stays the place to revisit cost properly. + +## Acceptance cases + +| # | Setup | Expected | +|---|---|---| +| A1 | R(0,2),(1,2),(2,2),(3,2). B fresh (1,0),(1,1),(1,3) | R(1,2) captured. `bonusPlacements` = 1 | +| A2 | R(1,2) alone. B fresh (1,0),(1,1),(1,3) | nothing — no same-owner neighbour, lone stones aren't cuttable | +| A3 | R(0,3),(1,2),(2,1),(3,0). B fresh (1,1),(2,2),(3,3) | R(1,2) and R(2,1) captured. bonus = 2 | +| A4 | A1 but B fresh only (1,1),(1,3) | nothing — a cut needs two links, i.e. three fresh stones | +| A5 | A1 but B(1,0) placed on a previous turn | nothing — pre-existing stones never link | +| A6 | R(1,2),(1,3) two-thick. B fresh (1,0),(1,1),(1,4) | nothing — no link over two enemy stones; two-thick walls stay cut-immune | +| A7 | B fresh (1,1),(2,2),(3,3); shoulders (1,2)=R, (2,1)=Blue | nothing from that joint — shoulders must share an owner | +| A8 | A1 but R(1,2) is R's home base | not captured, bonus = 0 — base filtering in `pendingCaptures` | +| A9 | B fresh (1,1),(1,2),(1,3) orthogonal, R above and below (1,2) | nothing — orthogonal joints never cross-capture | +| A10 | R(1,2),(2,1),(2,3),(3,2),(3,4),(4,3). B fresh (1,1),(2,2),(3,3) | 4 captured, bonus 4, `remaining` = 4. Then B places (4,4): 2 more captured, bonus 6, `remaining` = 5. Chaining works | +| A11 | From A10 after the first three stones, place 4 harmless bonus stones (`remaining` 0), then retract (2,2) | rejected — would leave `remaining` at −3. Retracting a harmless bonus stone is allowed | +| A12 | Opening turn | unchanged — no enemy is within reach, bonus is always 0 there | + +A2 and A6 are the load-bearing negatives; A11 is the one most likely to be got wrong. + +## Memory updates — `docs/memories/project/capture-rules.md` + +1. **Rewrite the entire Cutting section.** Arcadia's old principle — "cuts are only allowed through encirclements, not through arbitrary shapes" — is **superseded**. Record that it was the rule until 2026-09-07 and why it was dropped: it made cutting a late-game tool, so it never punished a forming attack, and aggression dominated. The new principle: **a cut severs a line; it needs a line of at least two enemy stones, not a closed shape.** +2. Replace these now-false invariants: "A cut requires... a complete four-piece encirclement / territory"; the two-branch orthogonal/diagonal statement; and "a cut never takes more than two stones" — a cut now takes up to 4 (two diagonal crossings on one line). +3. Keep and restate as still true: all cut cells placed this turn; cuts resolve at end of turn with everything else against the same pre-capture board; a wall is cuttable exactly where the cutter has empty cells to play into. +4. **New strategic facts.** Diagonal walls are now severable anywhere along their length, not only where they bound territory — a meaningful nerf to the "diagonal walls enclose twice the area per stone" line already in the doc; update it. Two-thick and solid walls remain cut-immune (A6), so territory is still the only answer to solid matter. Parallel walls spaced exactly 2 apart are the one shape that makes the bonus net-positive — do not build them. +5. **New section on the bonus.** It is derived, never stored; `BoardState` gains no field. `remaining >= 0` is a turn-long invariant maintained by the `retract` guard, and that is why `endTurn` needs no budget check. Note that `remaining` can now exceed the number of empty cells; `boardIsFull` is unaffected because no new *restriction* was added to `place` — re-read the long comment above `boardIsFull` in `game.ts` and update it if it now reads as stale. diff --git a/docs/tasks/2026-09-07/13-place-into-pending-captures.md b/docs/tasks/2026-09-07/13-place-into-pending-captures.md new file mode 100644 index 0000000..198d612 --- /dev/null +++ b/docs/tasks/2026-09-07/13-place-into-pending-captures.md @@ -0,0 +1,85 @@ +# Placing into pending-capture cells + +Status: Complete + +## Intent (Arcadia, verbatim) + +> A horizontal or vertical cut only captures one stone, so the player doing the cutting gets one additional stone. If they place it in the space they captured, that expends their free placement. They do not automatically get the captured stone. This presents the player with a choice to either cement their defenses by placing in the captured slot, preventing the opponent from rebuilding, or to be aggressive and expand outwards, leaving the space free. + +So: **no economy change at all.** `bonusPlacements`, `placementBudget`, `remaining` all stay exactly as you shipped them. The only change is that a doomed cell becomes a legal *target*, and taking it costs a bonus placement like any other. The mechanic is the choice, not the stone. + +## The overlay + +A doomed piece stays in `state.pieces` until `endTurn`, as you concluded. A stone placed onto its cell is appended alongside it, so that cell briefly holds two pieces. Call the newer one an **overlay**: + +> An overlay is a piece whose position is also occupied by another piece. + +**The governing invariant, and the thing to get right: an overlay is inert for every rule during the turn it is placed.** It reserves the square and consumes a placement; it contributes nothing to cuts, rings, or territory until `endTurn` resolves the capture beneath it. Without this, overlays are self-cancelling and unsound — see the two hazards below. + +### It's already inert everywhere that reads through `pieceAt` + +`pieceAt` uses `.find`, which returns the earliest-appended piece at a position. A victim is always an enemy piece, so it was placed on a strictly earlier turn than any overlay. The victim therefore always wins, and every rule reading the board through `pieceAt` — `tryLink`, `ringAt` — sees the doomed stone, not the overlay. **`pieceAt` needs no change.** Put a comment on it saying why, so nobody "fixes" it into returning the topmost piece. + +### Two hazards where code reads `state.pieces` directly + +These do *not* go through `pieceAt` and will see the overlay unless you exclude it: + +1. **`pendingTerritories` / `territoryByPlayer`** build blocker lists by filtering `state.pieces`. An overlay would enter the active player's blocker set, which removes its own cell from the `after` flood fill — **un-dooming the very piece it was placed on**. Self-cancelling. Exclude overlays from those position lists. +2. **`pendingCuts`** enumerates fresh stones as link sources. An overlay is fresh, so it would be enumerated — while `pieceAt` reports its cell as an enemy stone. A cell that is simultaneously a link source and an enemy stone is incoherent, and it would let cuts chain off freed cells. Exclude overlays from the fresh-stone enumeration. + +`pendingEncirclements` is already safe: its victim filter drops the active player's pieces, and its ring test goes through `pieceAt`. + +## Rule changes + +**`place`** — accept a position if it is empty, **or** if it holds exactly one piece and that piece is in `pendingCaptures(state)`. Reject if a second piece is already there (no stacking two overlays). No recursion risk: `pendingCaptures` never calls `place`. Home bases are already filtered out of `pendingCaptures`, so a base can never be overlaid — no separate guard needed. + +**`retract`** — keep the existing over-budget guard, and add a second: **reject a retraction that would leave an overlay sitting on a piece that is no longer in `pendingCaptures`.** The budget guard does *not* catch this. Worked example: cut takes 1, bonus 1, overlay placed, `remaining` = 3+1−4 = 0. Retract the arming stone: bonus → 0, placements → 3, `remaining` = 3+0−3 = 0. Not negative, so it passes the budget check while orphaning the overlay on a live enemy stone. The player retracts the overlay first, then the arming stone — same natural order as the budget guard. + +**`boardIsFull`** — `pieces.length === rows * cols` overcounts once overlays exist. Change it to count **distinct occupied positions**. Update the long comment above it: the "no legal plays ≡ board full" equivalence now has one stated exception, since doomed cells are playable even on a full board. + +## Scope: all pending captures, not just cuts + +Every doomed piece renders translucent and is placeable, whatever rule doomed it. This is safe *because* placing costs a placement: territory grants no bonus, so a large territory capture gives you nothing extra to spend, and you can only ever overlay as many cells as you have placements left. One rule instead of two, and the board never lies about which stones are about to disappear — this is the pending-capture preview from `docs/tasks/2026-09-05/02-preview-pending-captures.md` finally shipping. Tell Arcadia it's a one-line change to scope it to cuts only if she'd rather. + +## Rendering (`canvas.ts` / `ui.ts`) + +`canvas.Piece` gains a `doomed` flag, distinct from `provisional`. They are orthogonal: a doomed enemy stone is `doomed` and not `provisional`; an overlay is `provisional` and not `doomed`. `toCanvasPieces` sets `doomed` from `pendingCaptures(state)` — compute the set once outside the map, not per piece. + +Draw order is free: `toCanvasPieces` maps `state.pieces` in array order and overlays are appended later, so they already paint over their victims. Don't sort. + +## Acceptance cases + +Reuse the A1 board from task 12: R at (0,2),(1,2),(2,2),(3,2); B fresh at (1,0),(1,1),(1,3), so R(1,2) is doomed, bonus 1, `remaining` 1. + +| # | Action | Expected | +|---|---|---| +| B1 | B places (1,2) | accepted; `remaining` 0; R(1,2) still in `pieces` and still doomed. `endTurn` removes R(1,2), score +1, B's stone stays at (1,2) | +| B2 | B ends turn without placing (1,2) | R(1,2) removed, cell left empty — the aggressive branch of Arcadia's choice | +| B3 | after B1 | `pendingCuts` still returns exactly {R(1,2)} — the overlay created no new cut and is not a link source | +| B4 | a territory capture, then overlay a doomed cell | that cell is **still** in `pendingTerritories` — the self-cancelling bug is guarded | +| B5 | after B1, place (1,2) again | rejected — no stacking | +| B6 | after B1, retract (1,1) | rejected by the orphan guard, *not* the budget guard (`remaining` would be 0). Retract (1,2) first, then (1,1) — both allowed | +| B7 | overlay an enemy home base | impossible — bases are never in `pendingCaptures` | +| B8a | one cell empty, one overlay elsewhere | `boardIsFull` **false**; `gameStatus` still `"playing"`. This is the premature-game-over the fix prevents — the old `pieces.length` check returns true here | +| B8b | every cell occupied, then overlay a doomed cell | `boardIsFull` **true**. A placeable overlay coexists with a full board — the stated exception to "no legal plays ≡ board full" | +| B9 | after B1 | `pieceAt((1,2))` returns R, not the overlay | + +B4 and B6 are the two that will be got wrong. Write them first. + +**Revision note (2026-09-07):** the original B8 row was self-contradictory — "`boardIsFull` false" paired with "`pieces.length` is cells+1" described the *old*, buggy `pieces.length`-based check's behavior on the harmless failure mode (full board + one overlay: old says not-full, new says full — wrong but harmless), not the actual bug the fix exists for. Replaced with B8a (the real bug: `pieces.length` reads "full" with an empty cell still on the board, because an overlay elsewhere balances the count — this is the premature-game-over case) and B8b (the harmless case, now correctly `true` under distinct-position counting). + +**Implementation note not in the original spec:** `ui.ts`'s `handleClick` branched on `game.pieceAt(state, position)` truthy → retract, else → place. Once a doomed cell legitimately holds a piece, that branch always routed a click there to `retract` (which silently failed, since the victim was never retractable) and could never reach `place` to create the overlay — the feature was unreachable through the UI without a fix here. Changed to `game.retract(state, position) ?? game.place(state, position)`, which asks the rules layer both questions and takes whichever answers, rather than `ui.ts` encoding "occupied means retract" itself — this is arguably *more* correct against `separation-of-concerns.md`'s translate-vs-own-the-rules split, not just a workaround. Verified correct across all 8 reachable cases (empty, own-retractable, own-retractable-but-guard-blocked, own-stale, enemy-undoomed, enemy-doomed-unoverlaid, overlaid, off-board). + +Also not in the original spec: `retract` was resolving the piece to retract via `pieceAt`, which always returns the earliest (victim) piece at a position, never an overlay — so retracting an overlaid cell was silently failing at the `game.ts` level too, independent of the `ui.ts` issue above. Added `overlayAt`, which returns the *latest* piece at a position when there are two, and `retract` now tries that before falling back to `pieceAt`. + +## Memory updates — `docs/memories/project/capture-rules.md` + +1. **New subsection on overlays**, under Cutting or as a peer of "Bonus placements": the definition, the inert invariant, and both hazards named explicitly with the reason each one bites. +2. **Record why eager removal is impossible**, next to the existing "Dependency worth protecting" note: `pendingCuts` is recomputed from scratch off the board every time, so deleting a victim at placement time makes its own cut evaporate on the next recompute, and undo would need a stash of removed pieces on `BoardState` — the accumulated-history the `scores` comment exists to avoid. This is the load-bearing reason "captures resolve at end of turn" survives even though the *visual* and the *placeability* are now immediate. +3. Note that `pieceAt` returning the earliest-appended piece is now load-bearing, not incidental. + +## Out of scope, flag to Arcadia separately + +Pre-existing and not caused by this change: if the board fills while captures are pending, `endTurn` checks `gameStatus` first and returns `null`, so the game freezes with those captures unresolved. Rare at 19x19. Don't fix it here — raise it with her as its own task. + +Raise any acceptance case that doesn't reconcile with me before working around it. diff --git a/frontend/src/canvas.ts b/frontend/src/canvas.ts index d6f771b..78163ff 100644 --- a/frontend/src/canvas.ts +++ b/frontend/src/canvas.ts @@ -4,7 +4,14 @@ export type Point = { x: number; y: number }; export type Size = { width: number; height: number }; export type BoardConfig = { rows: number; cols: number }; export type Emblem = "crown" | "skull"; -export type Piece = { row: number; col: number; color: string; provisional: boolean; emblem?: Emblem }; +export type Piece = { + row: number; + col: number; + color: string; + provisional: boolean; + doomed: boolean; + emblem?: Emblem; +}; export type BoardLayout = { cellSize: number; marginX: number; marginY: number }; export type GridPoint = { row: number; col: number }; type Line = { from: Point; to: Point }; @@ -13,6 +20,7 @@ const BOARD_FILL = "#dcb35c"; const LINE_COLOR = "#3a2e1f"; const STONE_OUTLINE = "rgba(0, 0, 0, 0.4)"; const PROVISIONAL_OUTLINE = "rgba(255, 255, 255, 0.9)"; +const DOOMED_ALPHA = 0.35; const STONE_RADIUS_RATIO = 0.45; const EMBLEM_FILL = "#e8b923"; // The grid's own near-black, reused so the emblem's outline reads as part of @@ -174,6 +182,15 @@ const drawStone = ( radius: number, piece: Piece, ): void => { + // Doomed pieces render translucent -- captured but still legally + // occupying the board until endTurn resolves it -- via ctx.globalAlpha, + // which scales every subsequent draw (fill, stroke, and the emblem if + // any) uniformly, then gets restored so it never leaks into sibling + // stones drawn afterward. + const priorAlpha = ctx.globalAlpha; + if (piece.doomed) { + ctx.globalAlpha = priorAlpha * DOOMED_ALPHA; + } ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); ctx.fillStyle = piece.color; @@ -186,6 +203,7 @@ const drawStone = ( if (piece.emblem) { drawEmblem(ctx, center, radius, piece.emblem); } + ctx.globalAlpha = priorAlpha; }; const drawPieces = ( diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 03bb3c0..ebfe98a 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -215,9 +215,37 @@ export const currentPlayer = (state: BoardState): Player => const isSamePosition = (a: Position, b: Position): boolean => a.row === b.row && a.col === b.col; +// .find, not a topmost-wins lookup, is load-bearing since overlays shipped +// (see isOverlay below): a doomed piece is always appended strictly before +// any overlay placed on top of it, so this always reports the doomed piece, +// never the overlay. Every rule that reads the board through pieceAt -- +// tryLink, ringAt -- therefore sees the victim, not the overlay, and needs +// no special case to keep overlays inert. Don't "fix" this into +// topmost-wins; that would make overlays participate in cuts and rings. export const pieceAt = (state: BoardState, position: Position): Piece | undefined => state.pieces.find((piece) => isSamePosition(piece.position, position)); +// An overlay is a piece whose position is also occupied by another, +// earlier-placed piece -- the result of placing into a cell that +// pendingCaptures has already doomed (see `place`). Because pieceAt always +// resolves to the earlier piece, an overlay is never what pieceAt reports +// for its own cell. Code that reads state.pieces directly instead of +// through pieceAt -- pendingCuts' fresh-stone scan, territoryByPlayer's and +// pendingTerritories' blocker lists -- has to exclude overlays itself. +const isOverlay = (state: BoardState, piece: Piece): boolean => + pieceAt(state, piece.position) !== piece; + +// The overlay at a position, if that position currently holds one -- +// `pieceAt` can never find it (see above), so this is the one place that +// deliberately wants the LATEST piece at a position rather than the +// earliest. Retraction is the only caller: a click on a cell holding both a +// doomed piece and an overlay should retract the overlay (the piece the +// active player actually placed), never the victim underneath it. +const overlayAt = (state: BoardState, position: Position): Piece | undefined => { + const occupants = state.pieces.filter((piece) => isSamePosition(piece.position, position)); + return occupants.length > 1 ? occupants[occupants.length - 1] : undefined; +}; + const homeBaseOf = (state: BoardState, player: number): Piece | undefined => state.pieces.find((piece) => piece.isHomeBase && piece.player === player); @@ -232,8 +260,19 @@ export const isRetractable = (state: BoardState, piece: Piece): boolean => const placementsThisTurn = (state: BoardState): number => state.pieces.filter((piece) => isRetractable(state, piece)).length; +// Derived, never stored: BoardState gains no field for this. Home bases are +// excluded because they are never actually removed by a cut, so refunding a +// placement for "capturing" one would hand out a free stone. A piece claimed +// by both a cut and territory is deduped by pendingCuts already and still +// counts once -- the player did cut it. +export const bonusPlacements = (state: BoardState): number => + pendingCuts(state).filter((piece) => !piece.isHomeBase).length; + +export const placementBudget = (state: BoardState): number => + state.placementsPerTurn + bonusPlacements(state); + export const remaining = (state: BoardState): number => - state.placementsPerTurn - placementsThisTurn(state); + placementBudget(state) - placementsThisTurn(state); // Nobody can be eliminated during round one -- elimination requires a // completed enemy territory wall, which needs more than three stones to @@ -265,7 +304,12 @@ const territoryByPlayer = (state: BoardState): Set[] => state.players.map((_, player) => enclosedCells( state.size, - state.pieces.filter((piece) => piece.player === player).map((piece) => piece.position), + // Overlays excluded: an overlay sitting on a doomed enemy cell would + // otherwise block that cell for its own owner's flood fill, silently + // un-dooming the very piece it was placed on. + state.pieces + .filter((piece) => piece.player === player && !isOverlay(state, piece)) + .map((piece) => piece.position), )); const isEliminatedWithTerritories = ( @@ -296,19 +340,21 @@ export const eliminatedPlayers = (state: BoardState): readonly boolean[] => { export const isEliminated = (state: BoardState, player: number): boolean => eliminatedPlayers(state)[player]; -// Board is exactly full when every cell holds a piece -- home bases -// included, since they've been ordinary pieces occupying a cell since -// setup. This is the deadlock trigger (task 09), and the reason it's cheap -// (O(1) off `pieces.length`, no new stored state) is a property of the -// placement rules, not a coincidence: placement is unrestricted after the -// opening turn -- `place` rejects only out-of-bounds, occupied, -// no-placements-left, game-over, and the opening adjacency rule -- so a -// legal move exists whenever any cell is empty. "No legal plays" and "board -// full" are therefore the same fact. If placement ever gains a new -// restriction beyond those, that equivalence breaks and this check silently -// stops meaning what its name says. +// Board is exactly full when every cell holds at least one piece -- home +// bases included, since they've been ordinary pieces occupying a cell since +// setup. Counts DISTINCT positions, not `pieces.length`, because an overlay +// shares a cell with the doomed piece underneath it: two pieces, one +// occupied cell. This is the deadlock trigger (task 09). The old comment +// here claimed "no legal plays" and "board full" were the same fact, +// because placement was unrestricted whenever a cell was empty -- that +// stated equivalence now has one exception: a cell holding exactly one +// doomed piece is full (this function says so) but still a legal `place` +// target (an overlay). The board can therefore read "full" while a +// placement is still legal into it; `place`'s own guards, not this +// function, are the source of truth for legality. const boardIsFull = (state: BoardState): boolean => - state.pieces.length === state.size.rows * state.size.cols; + new Set(state.pieces.map((piece) => positionKey(piece.position))).size === + state.size.rows * state.size.cols; export type GameResult = | { kind: "playing" } @@ -414,96 +460,97 @@ export const pendingEncirclements = (state: BoardState): Piece[] => ); // --- Cutting ----------------------------------------------------------- - -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; +// +// A cut severs a line; it needs a line of at least two enemy stones, not a +// closed shape. See docs/memories/project/capture-rules.md for the +// principle this superseded on 2026-09-07. + +// A link is the smallest unit a cut is built from: a fresh stone A reaching +// one step further along direction d into another fresh stone (a joint), or +// two steps through a single enemy stone into a fresh stone beyond it (a +// pierce). No other case is a link -- which is what keeps pre-existing own +// stones and empty gaps out of a cut line without a separate check: a joint +// needs the neighbour fresh, a pierce needs it enemy. +type Link = + | { kind: "joint"; source: Position; direction: Offset; target: Position } + | { kind: "pierce"; source: Position; direction: Offset; target: Position; victim: Piece }; + +const tryLink = (state: BoardState, source: Position, direction: Offset): Link | null => { + const mid = shift(source, direction, 1); + const midPiece = pieceAt(state, mid); + if (midPiece && placedThisTurn(state, midPiece)) { + return { kind: "joint", source, direction, target: mid }; } - const owner = present[0].player; - if (owner === state.activePlayer) { - return null; + if (midPiece && midPiece.player !== state.activePlayer) { + const target = shift(source, direction, 2); + const targetPiece = pieceAt(state, target); + if (targetPiece && placedThisTurn(state, targetPiece)) { + return { kind: "pierce", source, direction, target, victim: midPiece }; + } } - return present.every((piece) => piece.player === owner) ? present : null; + return null; }; -// Orthogonal-direction cut: pierces a complete DIAGONAL ring centred on C. -// Unchanged by territory -- a diagonal ring doesn't enclose its own centre, -// so C is never territory here. -const orthogonalCutCaptures = ( - state: BoardState, - centre: Position, - direction: Offset, -): Piece[] => { - const gap = shift(centre, direction, 1); - if (!placedThisTurnAt(state, gap) || !placedThisTurnAt(state, shift(centre, direction, 2))) { - return []; +// A stone with at least one same-owner 8-neighbour -- the "line of 2+" +// requirement that keeps a lone enemy stone immune to a pierce. The two cut +// cells flanking the victim are the active player's, so a same-owner +// neighbour is never on the cut line itself. +const hasSameOwnerNeighbour = (state: BoardState, piece: Piece): boolean => + DIRECTIONS.some((offset) => { + const neighbour = pieceAt(state, shift(piece.position, offset, 1)); + return neighbour !== undefined && neighbour.player === piece.player; + }); + +// What a single link captures, evaluated against its own source stone and +// direction -- not the cut as a whole, since a cut's two links can each have +// a different source. +const linkCaptures = (state: BoardState, link: Link): Piece[] => { + if (link.kind === "pierce") { + return hasSameOwnerNeighbour(state, link.victim) ? [link.victim] : []; } - const ring = enemyRing(state, centre, DIAGONAL); - return ring ? ring.filter((piece) => chebyshev(piece.position, gap) === 1) : []; -}; - -// Diagonal-direction cut: severs a joint in an enemy player's territory -// wall. Arcadia's principle: cuts are only allowed through encirclements, -// not through arbitrary shapes -- so this requires C itself to sit inside -// the shoulders' owner's territory, not just that the two shoulders share -// an owner. -const diagonalCutCaptures = ( - state: BoardState, - centre: Position, - direction: Offset, - territoryOf: (player: number) => Set, -): Piece[] => { - const gap = shift(centre, direction, 1); - if (!placedThisTurnAt(state, gap) || !placedThisTurnAt(state, shift(centre, direction, 2))) { + if (!isDiagonalOffset(link.direction)) { return []; } - const shoulderA = pieceAt(state, { row: centre.row + direction.row, col: centre.col }); - const shoulderB = pieceAt(state, { row: centre.row, col: centre.col + direction.col }); + // The other two corners of the unit square between source and target: a + // diagonal joint crosses them, and they're diagonally adjacent to each + // other by construction, so no separate "line of 2" check is needed here. + const shoulderA = pieceAt(state, { row: link.source.row + link.direction.row, col: link.source.col }); + const shoulderB = pieceAt(state, { row: link.source.row, col: link.source.col + link.direction.col }); if ( - !shoulderA || !shoulderB || - shoulderA.player !== shoulderB.player || - shoulderA.player === state.activePlayer + shoulderA && shoulderB && + shoulderA.player === shoulderB.player && + shoulderA.player !== state.activePlayer ) { - return []; + return [shoulderA, shoulderB]; } - return territoryOf(shoulderA.player).has(positionKey(centre)) ? [shoulderA, shoulderB] : []; + return []; }; +// For each fresh stone and each of the 8 directions, two consecutive links +// along the same direction form a cut -- three fresh stones, two links. +// Iterating every fresh stone as a possible starting point, and all 8 +// directions, covers both traversal orders of each axis; the final dedup +// absorbs the resulting double count. export const pendingCuts = (state: BoardState): Piece[] => { - const territoryCache = new Map>(); - const territoryOf = (player: number): Set => { - const cached = territoryCache.get(player); - if (cached) { - return cached; - } - const positions = state.pieces - .filter((piece) => piece.player === player) - .map((piece) => piece.position); - const computed = enclosedCells(state.size, positions); - territoryCache.set(player, computed); - return computed; - }; - - return state.pieces - .filter((piece) => placedThisTurn(state, piece)) + // Overlays excluded from the fresh-stone scan: an overlay is fresh, but + // pieceAt reports its cell as the enemy stone beneath it, so treating it + // as a link source would let cuts chain off a cell that's simultaneously + // "mine, fresh" and "an enemy stone" -- incoherent. + const captures = state.pieces + .filter((piece) => placedThisTurn(state, piece) && !isOverlay(state, piece)) .flatMap((piece) => - DIRECTIONS.flatMap((direction) => - isDiagonalOffset(direction) - ? diagonalCutCaptures(state, piece.position, direction, territoryOf) - : orthogonalCutCaptures(state, piece.position, direction))); + DIRECTIONS.flatMap((direction) => { + const link1 = tryLink(state, piece.position, direction); + if (!link1) { + return []; + } + const link2 = tryLink(state, link1.target, direction); + if (!link2) { + return []; + } + return [...linkCaptures(state, link1), ...linkCaptures(state, link2)]; + })); + return [...new Set(captures)]; }; // --- Territory ----------------------------------------------------------- @@ -513,8 +560,12 @@ export const pendingCuts = (state: BoardState): Piece[] => { // This is what makes standing territory not re-fire, playing into standing // enemy territory safe, and a repaired wall re-capture everything inside. export const pendingTerritories = (state: BoardState): Piece[] => { + // Overlays excluded for the same self-cancelling reason as + // territoryByPlayer above: an overlay is the active player's own stone + // sitting on a doomed enemy cell, and counting it as a blocker would + // remove that very cell from `after`, un-dooming its own victim. const activePositions = state.pieces - .filter((piece) => piece.player === state.activePlayer) + .filter((piece) => piece.player === state.activePlayer && !isOverlay(state, piece)) .map((piece) => piece.position); const startOfTurnPositions = state.pieces .filter((piece) => piece.player === state.activePlayer && piece.turn < state.turn) @@ -569,7 +620,7 @@ export const endTurn = (state: BoardState): BoardState | null => { }; export const place = (state: BoardState, position: Position): BoardState | null => { - if (!isInBounds(state.size, position) || pieceAt(state, position) || remaining(state) <= 0) { + if (!isInBounds(state.size, position) || remaining(state) <= 0) { return null; } if (gameStatus(state).result.kind !== "playing") { @@ -578,14 +629,50 @@ export const place = (state: BoardState, position: Position): BoardState | null if (isOpeningTurn(state) && !isAdjacentToOwnBase(state, position)) { return null; } + // Empty is always legal. A cell with exactly one occupant is legal only + // if that occupant is doomed -- placing an overlay on it, which reserves + // the cell and costs a placement like any other, without granting the + // captured stone itself (Arcadia's choice: cement the slot, or leave it + // free and go aggressive elsewhere). A cell with two occupants already + // (a doomed piece plus an overlay) never accepts a third -- no stacking. + const occupants = state.pieces.filter((piece) => isSamePosition(piece.position, position)); + if (occupants.length > 1) { + return null; + } + if (occupants.length === 1 && !new Set(pendingCaptures(state)).has(occupants[0])) { + return null; + } const piece: Piece = { position, player: state.activePlayer, turn: state.turn, isHomeBase: false }; return { ...state, pieces: [...state.pieces, piece] }; }; +// remaining >= 0 is a turn-long invariant. Placing more stones can only add +// pending cuts, never remove them, so remaining can only be driven negative +// by retraction -- this guard is the one place that has to check for it, +// which is why endTurn needs no budget check of its own. +// +// A second, independent guard protects overlays: retracting the stone that +// armed a cut can leave an overlay sitting on a piece that's no longer +// doomed, without ever driving `remaining` negative (the budget guard alone +// doesn't catch this -- see docs/tasks/2026-09-07/13-place-into-pending-captures.md +// for the worked example). An orphaned overlay would leave a live enemy +// stone permanently un-clickable -- so reject that retraction outright. In +// practice the player retracts the overlay first, then the stone that +// armed the cut -- the same natural order the budget guard already relies +// on, and exactly what `overlayAt` below exists to make possible: a click +// on an overlaid cell must retract the overlay, not the victim beneath it. export const retract = (state: BoardState, position: Position): BoardState | null => { - const existing = pieceAt(state, position); + const existing = overlayAt(state, position) ?? pieceAt(state, position); if (!existing || !isRetractable(state, existing)) { return null; } - return { ...state, pieces: state.pieces.filter((piece) => piece !== existing) }; + const next = { ...state, pieces: state.pieces.filter((piece) => piece !== existing) }; + if (remaining(next) < 0) { + return null; + } + const stillDoomed = new Set(pendingCaptures(next)); + const orphaned = next.pieces.some( + (piece) => isOverlay(next, piece) && !stillDoomed.has(pieceAt(next, piece.position) as Piece), + ); + return orphaned ? null : next; }; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c25e2ce..f11b63b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,8 +4,8 @@ import * as canvas from "./canvas.js"; import * as ui from "./ui.js"; import * as game from "./game.js"; -const ROWS = 19; -const COLS = 19; +const ROWS = 16; +const COLS = 16; const BOARD: canvas.BoardConfig = { rows: ROWS, cols: COLS }; const COLORS = ["black", "white", "#e63946", "#457b9d"]; diff --git a/frontend/src/ui.ts b/frontend/src/ui.ts index bd0a0cd..f485ba7 100644 --- a/frontend/src/ui.ts +++ b/frontend/src/ui.ts @@ -71,8 +71,12 @@ const statusText = (state: game.BoardState, result: game.GameResult): string => if (game.isOpeningTurn(state) && game.remaining(state) > 0) { return `Opening: place all ${state.placementsPerTurn} beside your home base.`; } - const placed = state.placementsPerTurn - game.remaining(state); - return `${placed} of ${state.placementsPerTurn} placed`; + const budget = game.placementBudget(state); + const placed = budget - game.remaining(state); + const bonus = game.bonusPlacements(state); + return bonus > 0 + ? `${placed} of ${budget} placed (+${bonus} from cuts)` + : `${placed} of ${budget} placed`; }; // game.gameStatus runs the underlying flood fill once and hands back both @@ -91,6 +95,7 @@ export const showPanel = (elements: PanelElements, state: game.BoardState): void // board). export const toCanvasPieces = (state: game.BoardState): canvas.Piece[] => { const eliminated = game.eliminatedPlayers(state); + const doomed = new Set(game.pendingCaptures(state)); return state.pieces.map((piece) => { const player = state.players[piece.player]; return { @@ -98,6 +103,7 @@ export const toCanvasPieces = (state: game.BoardState): canvas.Piece[] => { col: piece.position.col, color: piece.isHomeBase ? player.homeColor : player.color, provisional: game.isRetractable(state, piece), + doomed: doomed.has(piece), emblem: piece.isHomeBase ? (eliminated[piece.player] ? "skull" : "crown") : undefined, }; }); @@ -113,9 +119,19 @@ export const handleClick = ( if (!position) { return null; } - return game.pieceAt(state, position) - ? game.retract(state, position) - : game.place(state, position); + // Try retract first, fall back to place. This can no longer branch on + // "is there a piece here" the way it used to: a doomed cell already has a + // piece (the victim) but the click should place an overlay there, not + // attempt to retract a piece the active player never placed. `game.pieceAt` + // always resolves to that victim, never a same-cell overlay (see game.ts), + // so it can't tell these cases apart either -- retract's own legality + // check is the only thing that can. + // + // Not a `??` trick: this is ui.ts asking game.ts's rules layer both + // questions -- "is this retractable" and, only if not, "is this + // placeable" -- and taking whichever one answers, rather than encoding a + // rule ("occupied means retract") in the translation layer itself. + return game.retract(state, position) ?? game.place(state, position); }; const clientPointToCanvasPoint = (canvasEl: HTMLCanvasElement, event: MouseEvent): canvas.Point => { -- 2.51.2