From 05e284ae9063ab17740d68ba3c83d2d25d7b50ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:25:43 +0000 Subject: [PATCH 01/16] Add the Kill-All Go implementation plan Planning document for a new Kill-All Go engine: rules (Tromp-Taylor captures, positional superko), Benson pass-alive detection, the manual seki claim protocol, the five opening protocols, engine/state/notation design, metadata, localization keys, tests and milestones. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- KILL-ALL-GO-PLAN.md | 430 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 KILL-ALL-GO-PLAN.md diff --git a/KILL-ALL-GO-PLAN.md b/KILL-ALL-GO-PLAN.md new file mode 100644 index 00000000..187d1fe4 --- /dev/null +++ b/KILL-ALL-GO-PLAN.md @@ -0,0 +1,430 @@ +# Kill-All Go — implementation plan + +Status: **planning document, no engine code yet.** Branch `claude/kill-all-go-planning-o4ysfq`, based on +upstream `AbstractPlay/gameslib` `develop` (commit `9ecf63c`). This file is a working artefact for the +implementation sessions that follow; drop it from the tree (or squash it away) before the upstream PR is opened. + +Reference material that shaped this plan: the existing Go-family engines in this repo +(`src/games/go.ts`, `src/games/atarigo.ts`), Unlur and Pippinzip (openings where players place a shared +colour until someone claims a side), Cross Control / Go (`custom-colours` + `getPlayerColour`), the +front-end sources (`AbstractPlay/front`: `resolveEffectivePalette.js`, `MoveEntry.js`, `NewChallengeModal.js`), +the renderer schema (`AreaButtonBar`, annotations, markers), Sensei's Library (Kill-All Game, Pass-Alive / +Benson, Positional Superko), the Tromp-Taylor rules, and `lightvector/goscorer` (Python + JS sources). + +--- + +## 1. Terminology + +| In this document and in code comments / identifiers | In user-facing strings (i18n) | +|---|---| +| **Red** — the side trying to kill everything. Board colour `1`, the default colour of AP seat 1. | **Attacker** | +| **Blue** — the side trying to make one living string. Board colour `2`, the default colour of AP seat 2. | **Defender** | +| **string** — a maximal orthogonally connected set of same-coloured stones (well defined; all rules and code operate on strings). | "string" is fine in help text; never rely on "group". | +| **seat** — AP Player 1 / Player 2. Seats are *not* colours: which seat plays Red is decided by the opening protocol and stored in state. | Never say "switch"; say "choose". | + +Colour language stays out of user-facing text entirely ("Attacker stones", "Defender to move", "Play as Defender"). +Board colours are fixed (Red stones always render in palette slot 1, Blue in slot 2); what varies is the seat→colour +mapping returned by `getPlayerColour()`. + +--- + +## 2. Decisions taken (and the ones you should confirm) + +| # | Topic | Decision | Notes | +|---|---|---|---| +| D1 | uid / name | `killallgo` / "Kill-All Go" | Description: "Go variant where the Defender must make one unconditionally alive string and the Attacker must kill everything." | +| D2 | Boards | 9×9, 13×13, 19×19; **19×19 default** (`#board` sentinel) | Same `size-N` scheme as `go.ts`; more sizes can be appended later without a version bump. | +| D3 | Ko | **Positional superko (PSK)** over every grid colouring reached so far, including setup positions and the intermediate positions inside multi-stone plies. | Passes never create a position; a single-stone suicide is therefore always illegal (it would repeat the position). | +| D4 | Suicide | **Tromp-Taylor: multi-stone suicide is legal** (own stones without liberties are removed after opponent captures). | Your instruction was "if in doubt, Tromp-Taylor". This is the one place the choice has a knock-on effect: Benson's vital-region test must be strengthened (§4.4). AP's `go.ts` forbids suicide, so if you would rather match it, flip one flag and use classic Benson. **Please confirm.** | +| D5 | Passing | Either side may pass during normal play. **Two consecutive passes end the game with a Red win** (Blue had every chance to claim life and did not). | Alternatives considered: forbid Blue passes (rejected: un-Go-like), draw (rejected: rewards stalling). Blue's pass gets a warning message. **Please confirm.** | +| D6 | Immediate Blue win | After any board change, if any Blue string is pass-alive (Benson, exact), the game ends, Blue wins, all pass-alive Blue stones get `enter` annotations. | Checked after every stone, including each stone inside a Red refutation ply (§4.5.6). | +| D7 | Seki | **Manual claim protocol** exactly as you described (§4.5), no automatic verdicts. goscorer-style detection is not sound enough to end games (§6); it can be a later display-only hint. | | +| D8 | Ko-alive | Ignored by the engine: Blue must keep playing. No engine draw offer. | AP already has a per-move "include draw offer" checkbox, so players can agree a draw themselves. Reliable "alive in ko" detection needs search; deferred. | +| D9 | Openings | Radio group `opening`: `#opening` = alternating placement (default), `classic` (19×19 only), `pie`, `hoctaph`. Handicap is a second radio group `handicap` (`#handicap` = none, `handicap-1` … `handicap-12`), enabled only with the default opening. | Handicap needs a designated giver: **Player 1 is the handicap giver (Uwate)**; the challenger uses the challenge form's seating option ("I play first" / "I play second") to seat the stronger player first. There is no other way for the engine to know who gives the handicap, so this convention goes into the variant description and the notes. **Please confirm the 1–12 range and the seat convention.** | +| D10 | Turn model | Plain `GameBase`, strict alternation everywhere. Every "choose a side" action that would otherwise give the same seat two plies in a row is bundled into one ply (§5.6). | No `GameBaseSequenced`, no pass padding. | +| D11 | Buttons | Role / claim buttons are rendered by the game as an `areas: [{type: "buttonBar"}]` with structured labels from `apgames.json`; clicks arrive as `piece === "_btn_"`. `custom-buttons` is used only for `pass` (front already has that label). | Avoids a `front` PR for new button labels. | +| D12 | Colour before roles exist | `getPlayerColour(seat)` returns a neutral grey (`"#999999"`) until Red has been claimed/chosen, then `1` / `2`. | Front renders the seat chip with `renderglyph("piece", colour)` and only uses numeric returns for palette-slot logic, so a hex string is safe. Verify in the playground during M3. | +| D13 | Community credit | The four non-classic protocols get `fans: true` and a `people` entry on the variant (schema now supports both). | Fill in the names you want credited (you for the alternating/handicap/simple-pie designs, "Hoctaph" for the generalized pie?). | +| D14 | Flags | `["experimental", "custom-colours", "custom-buttons"]`. **No `pie` flag** (AP's built-in seat swap would fight the protocols). | | + +--- + +## 3. Rules specification (engine level) + +### 3.1 Board and stones +Square grid, `vertex` renderer style, `RectGrid(size, size)` + `SquareOrthGraph` for adjacency as in `go.ts`. +Board is `Map`; `1` = Red, `2` = Blue. Algebraic coordinates as everywhere else in gameslib: +columns `a…s` left→right (letters are consecutive, `i` is *not* skipped), rows `1…19` bottom→top, so `a1` is bottom-left. + +### 3.2 A placement (all phases) +1. Target must be empty and, in a refutation ply, not a protected (marked) point unless it is the final stone (§4.5). +2. Put the stone down; remove every *opponent* string with no liberties (capture); then remove every *own* string with + no liberties (Tromp-Taylor suicide, D4). During setup phases only Red stones exist, so "suicide" there simply means the + placement is illegal (a setup stone may never leave a Red string without liberties). +3. PSK: the resulting colouring must differ from every colouring already reached (D3). Reject otherwise. +4. Run the pass-alive check for Blue (D6). + +### 3.3 Passing and game end +* `pass` is legal only in the `play` phase (never during setup, never inside a refutation ply). +* Two consecutive passes → game over, Red wins, `eog.reason = "double-pass"` (D5). +* Resign / timeout / draw-agreed are handled by `GameBase`. + +### 3.4 Blue wins +* Any Blue string pass-alive (§4.4) → immediate win, `eog.reason = "pass-alive"`, highlight every stone of every pass-alive Blue string. +* Red concedes a life claim → win, `eog.reason = "claim-upheld"`, highlight the claimed string. + +### 3.5 Red wins +* Red captures the claimed string during a refutation ply → `eog.reason = "claim-refuted"`. +* Double pass → `eog.reason = "double-pass"`. +* (Capturing *every* Blue stone is not itself an end condition — Blue may keep placing new stones; the game ends by double pass or a successful claim/pass-alive as usual.) + +--- + +## 4. Life + +### 4.4 Pass-alive strings — Benson's algorithm (exact, cheap) + +Definitions for colour c (= Blue): +* **chain** = string of colour c. +* **region** = maximal orthogonally connected set of points that are *not* c (empty or Red). Its neighbours are all c stones. +* A region R is **vital** to chain x when every point of R that could ever be empty is a liberty of x, i.e. + * classic Benson (suicide illegal): every **empty** point of R is adjacent to x; + * **strict** (suicide legal, our D4 default): **every** point of R, empty or Red-occupied, is adjacent to x. + Reason: with multi-stone suicide Red can clear its own stones out of a region and then refill it leaving a hole at a point that is not a liberty of x; the prototype below shows a classic-vital region dying that way. +* A region is **X-enclosed** when every chain adjacent to it belongs to the current candidate set X. + +Algorithm: +``` +X := all Blue chains +R := all regions +repeat + for each chain x in X: if fewer than 2 regions in R are vital to x, remove x from X + for each region r in R: if some chain adjacent to r is not in X, remove r from R +until nothing changed +chains left in X are pass-alive +``` +Complexity: a handful of flood fills per iteration, at most (#chains) iterations — negligible on 19×19, so run it after every stone. + +A 90-line JavaScript prototype of exactly this (both vital tests) was written and checked during planning against these positions; they become the fixtures of `test/games/killallgo-benson.test.ts` (`X` = Blue, `O` = Red, `.` = empty, top row first): + +| Position | classic | strict | +|---|---|---| +| `X.X.X.` / `XXXXX.` / `......` two one-point eyes on the edge | alive | alive | +| `X.XX..` / `XXXX..` / `......` one eye | dead | dead | +| `X..XX.X` / `XXXXXXX` / `.......` a two-point eye plus a one-point eye | alive | alive | +| `X.X.O.O` / `XXXXOOO` / `.......` two groups with one eye each (seki-ish) | dead | dead | +| `XXXXXXX` / `X...X.X` / `X...XXX` / `X...X..` / `XXXXX..` 3×3 empty eye + one-point eye | dead | dead | +| same, with a Red stone in the centre of the 3×3 | alive | **dead** | +| `XX.O.` / `XXXO.` / `OOOO.` / `.....` two shared liberties, no eyes | dead | dead | +| `XX.XX` / `X.X.X` / `XX.XX` / `.....` two chains sharing two eyes (mutual life) | alive | alive | +| `X.XO.` / `XX.O.` / `OOOO.` / `.....` false eye at a cutting point | dead | dead | +| `XXXX.` / `XO.X.` / `XXXX.` / `X..X.` / `XXXX.` eye containing a Red stone on a liberty point | alive | alive | + +Implementation lives in `src/games/killallgo/benson.ts` as pure functions over `(board, size, colour, {suicideAllowed})` returning the pass-alive chains as `string[][]`, so it is unit-testable without the engine. + +### 4.5 Life claims (seki protocol) — precise state machine + +**4.5.1 Making a claim (Blue's ply, replaces a move).** Blue names one Blue string S (any one of its stones) and a set M +of currently empty *protected points* (possibly empty). Meaning: "S cannot be captured unless the Attacker first plays on +a point of M, and if they do I get to answer." Move text: `claim:[:,,…]`. + +**4.5.2 Refutation (Red's next ply, mandatory).** Red plays any number of consecutive stones (each a normal placement per +§3.2, but never on a point of M), then finishes the ply in exactly one of three ways: +1. **S captured** (by any stone of the sequence, protected or not): the ply ends there, game over, Red wins (`claim-refuted`). +2. **Final stone on a protected point**: the ply ends, all Red stones placed stay, phase returns to `play` with **Blue to move**. +3. **`concede`**: game over, Blue wins (`claim-upheld`), highlight S. +Move text: `[,,…,]` or `[,…,]concede`. A ply that has free placements but no terminator is +*incomplete* (`complete: -1`), so the front will not offer Submit until Red either plays a protected point, concedes, or +captures S — no accidental concessions. + +**4.5.3 Other consequences during a refutation ply.** Captures of other Blue strings are ordinary captures. PSK applies to +every stone. If a Red stone makes some Blue string pass-alive (possible when capturing a Blue stone merges regions), +Blue wins on the spot (D6) — the string really is unconditionally alive. + +**4.5.4 After case 2.** Normal alternation; Blue may claim again immediately (same string, any marks). Termination is +guaranteed: every cycle either ends the game or adds a Red stone to a formerly protected point, and PSK plus a finite +board bound the number of positions. + +**4.5.5 Validation of a claim.** S must be a Blue string; every mark must be empty; marks may be any size (an empty set is +allowed but pointless — Benson would already have declared S alive; the validation message says so). + +**4.5.6 State while a claim is pending.** +```ts +claim?: { stone: string; stones: string[]; marks: string[] } // on the stack entry after Blue's claim ply +phase: "refute" +``` +Render: `dots` on the marks, a low-opacity `flood` (or `outline`) marker on the claimed string, sidebar line +"Life claim: {Defender} says the string at d4 lives unless the Attacker plays e5, f6". + +**4.5.7 Why the marks matter.** In a real seki Blue must protect every point whose occupation by Red changes the count +(shared liberties, Red's outside liberties, approach points). Marking too little loses; marking too much only costs +the free Red moves elsewhere — which is exactly the deterrent against frivolous claims that you wanted. + +--- + +## 5. Opening protocols + +Common facts: state carries `redSeat?: 1 | 2` (undefined until decided) and `phase`. Until `redSeat` is set, +`getPlayerColour()` returns the neutral grey (D12), the sidebar shows "Attacker: not yet chosen", and every stone on +the board is a Red stone rendered in slot 1. When the game reaches `play`, **Blue always has the first move**. + +### 5.1 `#opening` — alternating placement (default) +1. `phase = "alt-place"`, `currplayer = 1`, empty board. +2. Ply: place one Red stone (`d4`) **or** take the Red stones (`attacker`). +3. When a seat takes Red: `redSeat = that seat`, `phase = "play"`, the other seat moves next as Blue. +Taking Red on the very first ply is legal (and silly). + +### 5.2 Handicap (`handicap-n`, only with 5.1) +Player 1 is Uwate (gives). If Player 1 takes Red: `attacker`, game starts. If Player 2 takes Red: the claim ply is +`attacker:c1,…,cn` with **exactly n** extra Red stones (built by clicks; `complete` becomes 1 at n). Then Blue (Player 1) moves. + +### 5.3 `classic` — the 17-stone setup (19×19 only, `enabledWhen: { board: ["#board"] }`) +Fixed initial board, no choosing. Blue = **Player 1** moves first, `redSeat = 2` from the start (so Player 1 moves first as +on every other AP game; AP's seat assignment supplies the randomisation you asked for). SGF `AB[…]` converted +(`` with SGF row index r → AP row `20 − r`): + +``` +jb→j18 cc→c17 qc→q17 dd→d16 jd→j16 pd→p16 +bj→b10 dj→d10 jj→j10 pj→p10 rj→r10 +dp→d4 jp→j4 pp→p4 cq→c3 qq→q3 jr→j2 +``` +(symmetric under both mirrors, as the diagram is). + +### 5.4 `pie` — simple pie +1. Player 1 (Slicer), `phase = "pie-slice"`: places **0 or more** Red stones in one ply (`d4,e5,…` built by clicks, or `pass` for none). +2. Player 2 (Chooser), `phase = "pie-choose"`: `attacker` → `redSeat = 2`, Player 1 is Blue and moves next; + or `defender:` → `redSeat = 1`, the click on an empty point is Blue's first stone (bundled to keep alternation; the + "Play as Defender" button just puts the player into that partial move and asks for the stone). + +### 5.5 `hoctaph` — generalized Hoctaph's pie +Let `p = size²`. +1. Player 1: `slice:a,b` typed in the move box (like Go's komi entry), with `a ≥ 1, b ≥ 1, a + b ≤ p − 2, b ≤ 2a, a ≤ 2b`. +2. Player 2 chooses an option: + * `iplace:c1,…,ca` — option 1: Chooser places the `a` stones (bundled). Then Player 1 chooses the colour. + * `youplace` — option 2: Slicer places the `a` stones on the next ply (`c1,…,ca`). Then Player 2 chooses the colour. +3. Colour choice by seat Y: `attacker:c1,…,cb` (takes Red and places the `b` stones, bundled), after which the other seat + moves as Blue; or `defender` (standalone ply), after which the other seat places `b` stones as Red (`c1,…,cb`) and then Y + moves as Blue. +Batches must contain exactly `a` / `b` stones, all legal setup placements. + +### 5.6 Ply tables (strict alternation holds in every branch) +``` +alt-place : P1 place, P2 place, …, Pk attacker[:handicap] → other seat (Blue) moves +classic : P1 (Blue) moves first +pie : P1 slice | P2 attacker → P1 Blue move | P2 defender:x → P1 Red move +hoctaph : P1 slice:a,b + P2 youplace | P1 batch a | P2 attacker:batch b → P1 Blue | P2 defender → P1 batch b → P2 Blue + P2 iplace:batch a | P1 attacker:batch b → P2 Blue | P1 defender → P2 batch b → P1 Blue +``` + +--- + +## 6. Automatic seki detection — assessment (goscorer) + +goscorer (MIT, Python + JS ports of the same algorithm, ~1 400 lines) is a *scoring* tool: given a finished position and +player-supplied dead-stone marks, it builds reachability regions, chains, "macrochains", potential eyes and eye values, +and flags a region as `belongs_to_seki_group` when its total eye value is ≤ 1. Its own documentation says seki detection +"might not be perfect", that failures should be "rare and exotic", and that eye values "will NOT be tactically accurate +outside of finished game positions". Kill-All positions are mid-game by definition and the verdict would decide the game, +so it must not be used as a win condition. Verdict: **manual protocol now (D7)**; optionally later, run goscorer's region/eye +analysis on the current position as a *hint* ("this string looks alive in seki — consider claiming"), clearly labelled as +advisory. The manual protocol needs no algorithm at all, only the string/liberty/capture machinery we already need. + +--- + +## 7. Engine design + +### 7.1 Files +``` +src/games/killallgo.ts engine (class KillAllGoGame extends GameBase) +src/games/killallgo/benson.ts pass-alive (pure) +src/games/killallgo/board.ts strings, liberties, capture/suicide application, board signature (pure helpers) +test/games/killallgo-benson.test.ts fixtures from §4.4 +test/games/killallgo.test.ts engine behaviour (§9) +locales/en/apgames.json, locales/en/apresults.json strings (§7.9); eo via the $ap-eo skill in the implementation session +``` +Subfolders under `src/games/` are established practice (`armadas/`, `cifra/`, `homeworlds/`, …) and the registry +generator only picks up game classes, so helper modules there are safe. + +### 7.2 State +```ts +type Phase = "alt-place" | "pie-slice" | "pie-choose" | "hoc-slice" | "hoc-option" | "hoc-batch-a" + | "hoc-choose" | "hoc-batch-b" | "play" | "refute"; + +interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; // 1 = Red, 2 = Blue + lastmove?: string; + phase: Phase; + redSeat?: playerid; // undefined until Red is claimed / chosen + setup?: { // protocol bookkeeping, cleared when phase becomes "play" + a?: number; b?: number; // hoctaph + batchBy?: playerid; // who owes the pending batch and how big + batchSize?: number; + handicapOwed?: number; // alt-place with handicap: n (only used when Player 2 takes Red) + }; + claim?: { stone: string; stones: string[]; marks: string[] }; // pending life claim + interim: string[]; // board signatures of intermediate positions inside this ply (PSK) + alive?: string[]; // stones highlighted at EOG +} +``` +`interim` is what makes PSK exact for multi-stone plies; single-stone plies leave it empty. (If long refutation plies ever +make states heavy, switch to 64-bit Zobrist hashes without changing any rule.) + +### 7.3 Move grammar +``` +cell := [a-z]+[0-9]+ +cells := cell ("," cell)* +placement := cell alt-place (Red), play (mover's colour) +batch := cells | "pass" pie-slice (pass = zero stones); hoc-batch-a/b (exact size, no pass) +attacker := "attacker" [":" cells] take/choose Red; cells = handicap stones or Hoctaph's b-batch +defender := "defender" [":" cell] choose Blue; the cell (simple pie only) is Blue's first stone +slice := "slice:" int "," int hoctaph a,b +option := "iplace" [":" cells] | "youplace" hoctaph chooser +pass := "pass" play only +claim := "claim:" cell [":" cells] Blue, play phase +refute := [cells ","] (cell | "concede") Red, refute phase; last cell must be a protected point +``` +Everything is lower-cased and whitespace-stripped in `move()` as in the template. `:` separates an action from its cells, +`,` separates cells — no other separators. + +### 7.4 Behaviour of the standard hooks +* **`moves()`** — enumerates what is enumerable: single placements (+ `pass`, + `attacker`, + `defender:` per empty + cell in `pie-choose`, + `concede` and each legal protected point in `refute`). Claims, batches and `slice` are combinatorial + and are *not* enumerated; `move()` skips the failsafe for those shapes (Go does the same for its komi turn). No `no-moves` flag. +* **`validateMove()`** — the authority for every shape above; returns `complete: -1 / 0 / 1` per §7.5 and `canrender: true` + for anything that changes the board (partial batches, partial refutations, partial claims). +* **`handleClick(move, row, col, piece)`** — + `piece === "_btn_attacker" | "_btn_defender" | "_btn_iplace" | "_btn_youplace" | "_btn_concede"` from the button bar; + otherwise a board click: in `play` an empty point = placement, a Blue stone (when Blue is on move) = start `claim:`, + while a claim is being built an empty point toggles a mark (re-click removes, Pippinzip style); in `refute` an empty point + appends to the sequence; in batch phases an empty point toggles membership. +* **`move(m, {partial})`** — applies the whole string from the ply's base state on every call (so partial re-renders are + consistent), running capture/suicide, PSK against the stack *and* the ply's `interim`, and the Benson check after each stone. + Sets `phase`, `redSeat`, `setup`, `claim` transitions; `checkEOG()`; `saveState()`. +* **`getPlayerColour(seat)`** — `redSeat === undefined ? "#999999" : (seat === redSeat ? 1 : 2)`. +* **`getButtons()`** — `[{label: "pass", move: "pass"}]` in `play`, else `[]`. +* **`sidebarStatuses()`** — Attacker / Defender (via `seatStatusValue`, or "not yet chosen"), Phase, Handicap (when set), + Hoctaph `a`/`b` and who owes the next batch, pending claim summary. +* **`collectChatLogLine()`** — `place` (with `what: "setup"` for opening stones), `capture` (count), `pass`, + `claim` lines, `eog` reasons; everything else to `super`. +* **`render()`** — `vertex` board; legend `A` = `{name: "piece", colour: 1}`, `B` = colour 2 (fixed); `enter` for stones placed + this ply, `exit` for captures; while a claim is pending/being built: `dots` on marks + `flood`/`outline` on the claimed + string; at EOG with a Blue win: `enter` on every stone in `alive`; `areas: [buttonBar]` with the context buttons of §7.6. + +### 7.5 Completeness rules (`validateMove.complete`) +| Shape | −1 | 0 | 1 | +|---|---|---|---| +| batch (pie-slice) | — | ≥ 1 stone | `pass` | +| batch (hoctaph, size k) | < k stones | — | exactly k | +| `attacker:` with owed stones | fewer than owed | — | exactly owed | +| `defender` in pie-choose | no cell yet | — | cell given | +| `claim:` | — | stone chosen, any number of marks | — (submit when ready) | +| refute | free stones only | — | ends with protected point, `concede`, or S captured | + +### 7.6 Button bar (rendered by the game, labels via `neutralAreaLabel("apgames:validation.killallgo.BTN_…")`) +| Phase | Buttons (`value`) | +|---|---| +| alt-place | Take the Attacker stones (`attacker`) | +| pie-choose / hoc-choose | Play as Attacker (`attacker`), Play as Defender (`defender`) | +| hoc-option | I place the first batch (`iplace`), Opponent places the first batch (`youplace`) | +| refute | Concede the claim (`concede`) | +| play | none (pass is a `custom-buttons` button; claims start by clicking a Defender stone) | + +### 7.7 Results and EOG reasons +* `{type: "place", where, what: "setup"}` for opening stones; `{type: "place", where}` in play. +* `{type: "capture", where: "a1,b2", count}` per captured string (as `go.ts`). +* `{type: "claim", how: "attacker" | "defender"}` for side choices; `{type: "claim", how: "life", where: stone, what: marks}`; + `{type: "claim", how: "concede"}`; `{type: "announce", payload: [a, b]}` for the slice; `{type: "select", what: "iplace" | "youplace"}`. +* `{type: "eog", reason: "pass-alive" | "claim-upheld" | "claim-refuted" | "double-pass"}` + `winners`. +All of these exist in `src/schemas/moveresults.json`; no schema change needed. + +### 7.8 `gameinfo` +```ts +name: "Kill-All Go", uid: "killallgo", playercounts: [2], version: "", +description: "apgames:descriptions.killallgo", +urls: ["https://senseis.xmp.net/?KillAllGame", "https://senseis.xmp.net/?ShapeGame"], +people: [ + { type: "designer", name: "Traditional" }, + { type: "coder", name: "", apid: "" }, +], +variants: [ + { uid: "size-9", group: "board" }, + { uid: "size-13", group: "board" }, + { uid: "#board" }, // 19×19 + { uid: "#opening" }, // alternating placement (default) + { uid: "classic", group: "opening", enabledWhen: { board: ["#board"] } }, + { uid: "pie", group: "opening", fans: true, people: [ … ] }, + { uid: "hoctaph", group: "opening", fans: true, people: [ … ] }, + { uid: "#handicap" }, // no handicap + { uid: "handicap-1", group: "handicap", enabledWhen: { opening: ["#opening"] }, fans: true }, … "handicap-12", +], +categories: ["goal>annihilate", "mechanic>place", "mechanic>capture", "mechanic>enclose", "mechanic>asymmetry", + "board>shape>rect", "board>connect>rect", "components>simple>1per"], +flags: ["experimental", "custom-colours", "custom-buttons"], +customizations: [ + { num: 1, default: 1, explanation: "Colour of the Attacker's stones" }, + { num: 2, default: 2, explanation: "Colour of the Defender's stones" }, +], // no `player` tags: the seat→slot mapping is dynamic, exactly like Go's swapped colours +``` +The default opening is also a fan design, but a `#group` sentinel cannot carry `fans`/`people`; credit it in `notes` instead +(or make it an explicit uid with `default: true` if the chip matters to you). + +### 7.9 Localization (English; Esperanto through `$ap-eo` during implementation) +* `names.killallgo`, `descriptions.killallgo`, `notes.killallgo` (rules summary: roles, life claims, double pass, openings, + handicap seat convention, suicide/PSK statement). +* `variants.killallgo.{size-9,size-13,#board,#opening,classic,pie,hoctaph,#handicap,handicap-1…12}` — `name` (+ `description` + for the openings and for the handicap group's seat convention). Keep names neutral (the front shows the community chip). +* `validation.killallgo.*` — instructions per phase, `BTN_*` button labels, errors: OCCUPIED (general), SELF_CAPTURE_SETUP, + KO_PSK, PROTECTED_POINT, BATCH_SIZE, HANDICAP_COUNT, SLICE_FORMAT / SLICE_RANGE / SLICE_RATIO, CLAIM_NOT_OWN_STONE, + CLAIM_MARK_NOT_EMPTY, CLAIM_EMPTY_MARKS (warning), REFUTE_INCOMPLETE, PASS_WARNING (Defender), INVALID_PASS, NOT_YOUR_PHASE. +* `status.killallgo.{ATTACKER, DEFENDER, UNDECIDED, PHASE, HANDICAP, SLICE, CLAIM}`. +* `apresults`: `PLACE.killallgo_setup`, `CLAIM.killallgo_attacker`, `CLAIM.killallgo_defender`, `CLAIM.killallgo_life`, + `CLAIM.killallgo_concede`, `ANNOUNCE.killallgo_slice` (or reuse a generic key), `SELECT.killallgo_option`, + `EOG.killallgo_pass_alive`, `EOG.killallgo_claim_upheld`, `EOG.killallgo_claim_refuted`, `EOG.killallgo_double_pass`. +Do not touch any locale other than `en` (and `eo` via the skill); `check-game-names-locale` will report the managed locales as +missing until upstream CI seeds them — expected. + +--- + +## 8. Tests (`test/games/`) +* **benson**: the ten fixtures of §4.4 in both modes; a 19×19 position with several chains; corner/edge eyes; region containing + Red stones adjacent vs non-adjacent to the chain. +* **board helpers**: capture of one/many strings, multi-stone suicide removal, single-stone suicide rejected by PSK, PSK across + plies and across `interim` positions inside one ply. +* **classic**: initial 17 stones at the listed cells; Player 1 (Blue) to move; `getPlayerColour(1) === 2`. +* **alt-place**: alternation; `attacker` on either seat sets `redSeat` and hands Blue the move; handicap: Player 1 takes → no + stones; Player 2 takes → exactly n stones required; sidebar strings. +* **pie / hoctaph**: every branch of the ply tables in §5.6, batch size enforcement, `slice` validation (range, ratio, a+b ≤ p−2), + `getRounds()` has no duplicate actors, `getPlayerColour` neutral until decided. +* **play**: pass-alive win with highlight; Blue win triggered by a Red move; double pass → Red wins; Blue pass warning text. +* **claims**: refuted (S captured mid-sequence, and captured by the final protected stone); upheld by concede; continued after a + protected point with all Red stones kept and Blue to move; free stones cannot touch marks; incomplete without terminator; + re-claim after continuation; pass-alive arising during a refutation. +* **serialization**: `serialize()` → `GameFactory` round trip in every phase; `clone()` mid-partial. +* **chat**: `assertChatLogParity` for a full game (needs the golden-fixture flow described in `docs/testing.md`). + +--- + +## 9. Milestones and validation +1. **M1** `benson.ts` + `board.ts` + their tests (pure, fast; no engine yet). +2. **M2** Engine with the `classic` opening only: play phase, PSK, TT captures, pass-alive win, double pass, render, chat, + English strings, `npm run generate-registry`, playground smoke test (verify the grey colour chip and the button bar there). +3. **M3** Life-claim protocol + tests. +4. **M4** Alternating placement + handicap; simple pie; Hoctaph; tests for §5.6. +5. **M5** Polish: notes/help text, Esperanto (`$ap-eo`), chat-log parity, `npm run lint`, `npm test`, final read-through of + every user-facing string for colour language. +Commands: `npm run generate-registry`, `npm run typecheck`, `npx mocha --require tsx/cjs --extension ts test/games/killallgo*.test.ts` +(the repo's `.mocharc` also runs everything via `npm test`), `npm run lint`. + +--- + +## 10. Open questions for you (answers change small, well-contained parts) +1. D4 suicide: Tromp-Taylor (legal, strict Benson) as planned, or forbid it like AP's Go? +2. D5 double pass = Attacker wins? +3. D9 handicap: 1–12 as the offered range, and "Player 1 gives; use challenge seating" as the convention? +4. Hoctaph: `a, b ≥ 1` (I excluded 0 so the ratio is defined), exact batch sizes, and are "I place the first batch" / + "Opponent places the first batch" acceptable wordings for the two options? +5. Names/AP ids to credit in `people` for the fan protocols (and for "Hoctaph"). +6. Anything you want shown differently while a claim is pending (marker style, sidebar wording)? +7. `classic` seats the Defender as Player 1 (so Player 1 keeps the first move, as in every other AP game) and the Attacker as Player 2, i.e. the Attacker is *not* the seat whose default colour is red there. Fine, or would you rather have the Attacker as Player 1 with Player 2 moving first? From 030c232ccedf21e1c789fcd4f7393a176a9a9b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:28:19 +0000 Subject: [PATCH 02/16] Kill-All Go plan: apply the author's decisions Handicap becomes a member of the opening group with a typed n in [1, floor(p/2)] chosen by Player 1, Hoctaph batch sizes need a minimum of 1, credits go into the variant descriptions, and the open questions are recorded as confirmed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- KILL-ALL-GO-PLAN.md | 105 ++++++++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/KILL-ALL-GO-PLAN.md b/KILL-ALL-GO-PLAN.md index 187d1fe4..fc7daeca 100644 --- a/KILL-ALL-GO-PLAN.md +++ b/KILL-ALL-GO-PLAN.md @@ -1,6 +1,6 @@ # Kill-All Go — implementation plan -Status: **planning document, no engine code yet.** Branch `claude/kill-all-go-planning-o4ysfq`, based on +Status: **planning document, no engine code yet; design decisions confirmed by the author on 2026-09-21 (§10).** Branch `claude/kill-all-go-planning-o4ysfq`, based on upstream `AbstractPlay/gameslib` `develop` (commit `9ecf63c`). This file is a working artefact for the implementation sessions that follow; drop it from the tree (or squash it away) before the upstream PR is opened. @@ -35,16 +35,16 @@ mapping returned by `getPlayerColour()`. | D1 | uid / name | `killallgo` / "Kill-All Go" | Description: "Go variant where the Defender must make one unconditionally alive string and the Attacker must kill everything." | | D2 | Boards | 9×9, 13×13, 19×19; **19×19 default** (`#board` sentinel) | Same `size-N` scheme as `go.ts`; more sizes can be appended later without a version bump. | | D3 | Ko | **Positional superko (PSK)** over every grid colouring reached so far, including setup positions and the intermediate positions inside multi-stone plies. | Passes never create a position; a single-stone suicide is therefore always illegal (it would repeat the position). | -| D4 | Suicide | **Tromp-Taylor: multi-stone suicide is legal** (own stones without liberties are removed after opponent captures). | Your instruction was "if in doubt, Tromp-Taylor". This is the one place the choice has a knock-on effect: Benson's vital-region test must be strengthened (§4.4). AP's `go.ts` forbids suicide, so if you would rather match it, flip one flag and use classic Benson. **Please confirm.** | -| D5 | Passing | Either side may pass during normal play. **Two consecutive passes end the game with a Red win** (Blue had every chance to claim life and did not). | Alternatives considered: forbid Blue passes (rejected: un-Go-like), draw (rejected: rewards stalling). Blue's pass gets a warning message. **Please confirm.** | +| D4 | Suicide | **Tromp-Taylor: multi-stone suicide is legal** (own stones without liberties are removed after opponent captures). | Your instruction was "if in doubt, Tromp-Taylor". This is the one place the choice has a knock-on effect: Benson's vital-region test must be strengthened (§4.4). AP's `go.ts` forbids suicide; we deliberately differ. **Confirmed.** | +| D5 | Passing | Either side may pass during normal play. **Two consecutive passes end the game with a Red win** (Blue had every chance to claim life and did not). | Alternatives considered: forbid Blue passes (rejected: un-Go-like), draw (rejected: rewards stalling). Blue's pass gets a warning message. **Confirmed.** | | D6 | Immediate Blue win | After any board change, if any Blue string is pass-alive (Benson, exact), the game ends, Blue wins, all pass-alive Blue stones get `enter` annotations. | Checked after every stone, including each stone inside a Red refutation ply (§4.5.6). | | D7 | Seki | **Manual claim protocol** exactly as you described (§4.5), no automatic verdicts. goscorer-style detection is not sound enough to end games (§6); it can be a later display-only hint. | | | D8 | Ko-alive | Ignored by the engine: Blue must keep playing. No engine draw offer. | AP already has a per-move "include draw offer" checkbox, so players can agree a draw themselves. Reliable "alive in ko" detection needs search; deferred. | -| D9 | Openings | Radio group `opening`: `#opening` = alternating placement (default), `classic` (19×19 only), `pie`, `hoctaph`. Handicap is a second radio group `handicap` (`#handicap` = none, `handicap-1` … `handicap-12`), enabled only with the default opening. | Handicap needs a designated giver: **Player 1 is the handicap giver (Uwate)**; the challenger uses the challenge form's seating option ("I play first" / "I play second") to seat the stronger player first. There is no other way for the engine to know who gives the handicap, so this convention goes into the variant description and the notes. **Please confirm the 1–12 range and the seat convention.** | +| D9 | Openings | One radio group `opening`: `#opening` = alternating placement (default), `handicap` = alternating placement with a handicap, `classic` (19×19 only), `pie`, `hoctaph`. | Handicap: **Player 1 is Uwate (the giver)** and chooses `n`; the challenger seats the stronger player first with the challenge form's seating option ("I play first" / "I play second"). `n` is typed on the first ply, any integer with `1 ≤ n ≤ floor(p/2)` where `p` is the number of points (180 on 19×19, 84 on 13×13, 40 on 9×9); no variant list. Player 2 (Shitate) then makes the first placement of the alternating protocol, or claims the Attacker stones straight away. **Confirmed.** | | D10 | Turn model | Plain `GameBase`, strict alternation everywhere. Every "choose a side" action that would otherwise give the same seat two plies in a row is bundled into one ply (§5.6). | No `GameBaseSequenced`, no pass padding. | | D11 | Buttons | Role / claim buttons are rendered by the game as an `areas: [{type: "buttonBar"}]` with structured labels from `apgames.json`; clicks arrive as `piece === "_btn_"`. `custom-buttons` is used only for `pass` (front already has that label). | Avoids a `front` PR for new button labels. | | D12 | Colour before roles exist | `getPlayerColour(seat)` returns a neutral grey (`"#999999"`) until Red has been claimed/chosen, then `1` / `2`. | Front renders the seat chip with `renderglyph("piece", colour)` and only uses numeric returns for palette-slot logic, so a hex string is safe. Verify in the playground during M3. | -| D13 | Community credit | The four non-classic protocols get `fans: true` and a `people` entry on the variant (schema now supports both). | Fill in the names you want credited (you for the alternating/handicap/simple-pie designs, "Hoctaph" for the generalized pie?). | +| D13 | Community credit | Non-traditional protocols carry `fans: true`; credits go into the variant *descriptions* (§7.8). Alternating placement and its handicap form: OGS-forum community protocols of unknown origin (they predate the similar openings of other AP games). Classic 17-stone setup: traditional, origin unknown. Simple pie: the standard balancing device, nobody to credit. Generalized Hoctaph's pie: Hoctaph (basic structure and insight, OGS forums) and the author of this plan (generalisation to every board size by letting the slicer pick `a` and `b` under incentives instead of splitting a fixed total). `people` on the `hoctaph` variant lists both. | **Confirmed.** Fill in your display name / AP id where the plan says ``. | | D14 | Flags | `["experimental", "custom-colours", "custom-buttons"]`. **No `pie` flag** (AP's built-in seat swap would fight the protocols). | | --- @@ -169,14 +169,19 @@ Common facts: state carries `redSeat?: 1 | 2` (undefined until decided) and `pha the board is a Red stone rendered in slot 1. When the game reaches `play`, **Blue always has the first move**. ### 5.1 `#opening` — alternating placement (default) -1. `phase = "alt-place"`, `currplayer = 1`, empty board. +1. `phase = "alt-place"`, `currplayer = 1`, empty board (in the `handicap` opening Player 2 makes the first placement instead, see 5.2). 2. Ply: place one Red stone (`d4`) **or** take the Red stones (`attacker`). 3. When a seat takes Red: `redSeat = that seat`, `phase = "play"`, the other seat moves next as Blue. Taking Red on the very first ply is legal (and silly). -### 5.2 Handicap (`handicap-n`, only with 5.1) -Player 1 is Uwate (gives). If Player 1 takes Red: `attacker`, game starts. If Player 2 takes Red: the claim ply is -`attacker:c1,…,cn` with **exactly n** extra Red stones (built by clicks; `complete` becomes 1 at n). Then Blue (Player 1) moves. +### 5.2 `handicap` — alternating placement with a handicap +1. `phase = "hand-n"`, `currplayer = 1`. Player 1 is Uwate (the giver); the challenger seats the stronger player first with the + challenge form's seating option. Player 1 types `n` in the move box (a bare integer, like Go's komi entry) with + `1 ≤ n ≤ floor(p/2)`, `p = size²`. Stored as `setup.handicap`. +2. `phase = "alt-place"` with **Player 2 (Shitate) to move**: place one Red stone, or claim. +3. Claims: Player 1 claims with `attacker` and the game starts at once (Blue = Player 2 moves). Player 2 claims with + `attacker:c1,…,cn`, **exactly n** extra Red stones bundled into the claim ply (built by clicks; `complete` becomes 1 at n), + then Blue = Player 1 moves. ### 5.3 `classic` — the 17-stone setup (19×19 only, `enabledWhen: { board: ["#board"] }`) Fixed initial board, no choosing. Blue = **Player 1** moves first, `redSeat = 2` from the start (so Player 1 moves first as @@ -207,9 +212,17 @@ Let `p = size²`. moves as Blue. Batches must contain exactly `a` / `b` stones, all legal setup placements. +For reference, Hoctaph's original (OGS forums, topic 27365 post 70) fixes the total at five stones and only splits it: +"(0) One player chooses a number n from {0,1,2,3,4,5}. (1) The other player chooses who chooses the intersections for (2). +(2) The player determined by (1) chooses up to n intersections. (3) The other player chooses colors. (4) Black places stones at +the intersections chosen for (2), and up to 5−n more stones, after which it is White's turn." The generalisation lets the +slicer choose both batch sizes for any board, with the ratio bound and the `p − 2` cap keeping the numbers reasonable, and +requires `a, b ≥ 1` so the ratio is always defined. + ### 5.6 Ply tables (strict alternation holds in every branch) ``` -alt-place : P1 place, P2 place, …, Pk attacker[:handicap] → other seat (Blue) moves +alt-place : P1 place, P2 place, …, Pk attacker → other seat (Blue) moves +handicap : P1 n, P2 place, P1 place, …, Pk attacker[:n stones when Pk = P2] → other seat (Blue) moves classic : P1 (Blue) moves first pie : P1 slice | P2 attacker → P1 Blue move | P2 defender:x → P1 Red move hoctaph : P1 slice:a,b @@ -248,7 +261,7 @@ generator only picks up game classes, so helper modules there are safe. ### 7.2 State ```ts -type Phase = "alt-place" | "pie-slice" | "pie-choose" | "hoc-slice" | "hoc-option" | "hoc-batch-a" +type Phase = "hand-n" | "alt-place" | "pie-slice" | "pie-choose" | "hoc-slice" | "hoc-option" | "hoc-batch-a" | "hoc-choose" | "hoc-batch-b" | "play" | "refute"; interface IMoveState extends IIndividualState { @@ -261,7 +274,7 @@ interface IMoveState extends IIndividualState { a?: number; b?: number; // hoctaph batchBy?: playerid; // who owes the pending batch and how big batchSize?: number; - handicapOwed?: number; // alt-place with handicap: n (only used when Player 2 takes Red) + handicap?: number; // n typed by Player 1 in the handicap opening (Player 2 owes n stones when claiming) }; claim?: { stone: string; stones: string[]; marks: string[] }; // pending life claim interim: string[]; // board signatures of intermediate positions inside this ply (PSK) @@ -275,9 +288,10 @@ make states heavy, switch to 64-bit Zobrist hashes without changing any rule.) ``` cell := [a-z]+[0-9]+ cells := cell ("," cell)* +handicap := int hand-n (Player 1 types n, 1 ≤ n ≤ floor(p/2)) placement := cell alt-place (Red), play (mover's colour) batch := cells | "pass" pie-slice (pass = zero stones); hoc-batch-a/b (exact size, no pass) -attacker := "attacker" [":" cells] take/choose Red; cells = handicap stones or Hoctaph's b-batch +attacker := "attacker" [":" cells] take/choose Red; cells = the n handicap stones (Player 2 only) or Hoctaph's b-batch defender := "defender" [":" cell] choose Blue; the cell (simple pie only) is Blue's first stone slice := "slice:" int "," int hoctaph a,b option := "iplace" [":" cells] | "youplace" hoctaph chooser @@ -290,7 +304,7 @@ Everything is lower-cased and whitespace-stripped in `move()` as in the template ### 7.4 Behaviour of the standard hooks * **`moves()`** — enumerates what is enumerable: single placements (+ `pass`, + `attacker`, + `defender:` per empty - cell in `pie-choose`, + `concede` and each legal protected point in `refute`). Claims, batches and `slice` are combinatorial + cell in `pie-choose`, + every integer `1…floor(p/2)` in `hand-n`, + `concede` and each legal protected point in `refute`). Claims, batches and `slice` are combinatorial and are *not* enumerated; `move()` skips the failsafe for those shapes (Go does the same for its komi turn). No `no-moves` flag. * **`validateMove()`** — the authority for every shape above; returns `complete: -1 / 0 / 1` per §7.5 and `canrender: true` for anything that changes the board (partial batches, partial refutations, partial claims). @@ -315,6 +329,7 @@ Everything is lower-cased and whitespace-stripped in `move()` as in the template ### 7.5 Completeness rules (`validateMove.complete`) | Shape | −1 | 0 | 1 | |---|---|---|---| +| handicap `n` | — | — | any integer in range | | batch (pie-slice) | — | ≥ 1 stone | `pass` | | batch (hoctaph, size k) | < k stones | — | exactly k | | `attacker:` with owed stones | fewer than owed | — | exactly owed | @@ -335,7 +350,8 @@ Everything is lower-cased and whitespace-stripped in `move()` as in the template * `{type: "place", where, what: "setup"}` for opening stones; `{type: "place", where}` in play. * `{type: "capture", where: "a1,b2", count}` per captured string (as `go.ts`). * `{type: "claim", how: "attacker" | "defender"}` for side choices; `{type: "claim", how: "life", where: stone, what: marks}`; - `{type: "claim", how: "concede"}`; `{type: "announce", payload: [a, b]}` for the slice; `{type: "select", what: "iplace" | "youplace"}`. + `{type: "claim", how: "concede"}`; `{type: "declare", count: n}` for the handicap; `{type: "announce", payload: [a, b]}` for the + slice; `{type: "select", what: "iplace" | "youplace"}`. * `{type: "eog", reason: "pass-alive" | "claim-upheld" | "claim-refuted" | "double-pass"}` + `winners`. All of these exist in `src/schemas/moveresults.json`; no schema change needed. @@ -353,11 +369,13 @@ variants: [ { uid: "size-13", group: "board" }, { uid: "#board" }, // 19×19 { uid: "#opening" }, // alternating placement (default) - { uid: "classic", group: "opening", enabledWhen: { board: ["#board"] } }, - { uid: "pie", group: "opening", fans: true, people: [ … ] }, - { uid: "hoctaph", group: "opening", fans: true, people: [ … ] }, - { uid: "#handicap" }, // no handicap - { uid: "handicap-1", group: "handicap", enabledWhen: { opening: ["#opening"] }, fans: true }, … "handicap-12", + { uid: "handicap", group: "opening", fans: true }, + { uid: "classic", group: "opening", enabledWhen: { board: ["#board"] } }, + { uid: "pie", group: "opening", fans: true }, + { uid: "hoctaph", group: "opening", fans: true, people: [ + { type: "designer", name: "Hoctaph", urls: ["https://forums.online-go.com/t/27365/70"] }, // verify the URL resolves + { type: "designer", name: "", apid: "" }, + ] }, ], categories: ["goal>annihilate", "mechanic>place", "mechanic>capture", "mechanic>enclose", "mechanic>asymmetry", "board>shape>rect", "board>connect>rect", "components>simple>1per"], @@ -367,20 +385,30 @@ customizations: [ { num: 2, default: 2, explanation: "Colour of the Defender's stones" }, ], // no `player` tags: the seat→slot mapping is dynamic, exactly like Go's swapped colours ``` -The default opening is also a fan design, but a `#group` sentinel cannot carry `fans`/`people`; credit it in `notes` instead -(or make it an explicit uid with `default: true` if the chip matters to you). +The default opening is also a community design, but a `#group` sentinel cannot carry `fans`/`people`; its credit lives in its +locale description (sentinels do get `name` and `description`, as Go's `#ruleset` shows). Suggested English descriptions: +* `#opening`: "Players alternate placing Attacker stones until one of them claims the Attacker side; the other player then + moves first as the Defender. A community protocol from the OGS forums, origin unknown." +* `handicap`: "Alternating placement with a handicap. Player 1 (the stronger player, seated first by the challenge) chooses the + number of extra Attacker stones; Player 2 then starts placing. If Player 2 claims the Attacker side they place that many extra + stones first. A community protocol from the OGS forums, origin unknown." +* `classic`: "The traditional 17-stone starting position; the Defender moves first. 19×19 only." +* `pie`: "Player 1 places any number of Attacker stones, then Player 2 chooses a side. The usual pie rule." +* `hoctaph`: "Player 1 chooses two batch sizes, Player 2 chooses who places the first batch, the other player chooses a side, and + the Attacker places the second batch. Hoctaph's pie (OGS forums), generalised to every board size by ." + ### 7.9 Localization (English; Esperanto through `$ap-eo` during implementation) * `names.killallgo`, `descriptions.killallgo`, `notes.killallgo` (rules summary: roles, life claims, double pass, openings, handicap seat convention, suicide/PSK statement). -* `variants.killallgo.{size-9,size-13,#board,#opening,classic,pie,hoctaph,#handicap,handicap-1…12}` — `name` (+ `description` - for the openings and for the handicap group's seat convention). Keep names neutral (the front shows the community chip). +* `variants.killallgo.{size-9,size-13,#board,#opening,handicap,classic,pie,hoctaph}` — `name` + `description` (texts in §7.8). + Keep names neutral (the front shows the community chip). * `validation.killallgo.*` — instructions per phase, `BTN_*` button labels, errors: OCCUPIED (general), SELF_CAPTURE_SETUP, - KO_PSK, PROTECTED_POINT, BATCH_SIZE, HANDICAP_COUNT, SLICE_FORMAT / SLICE_RANGE / SLICE_RATIO, CLAIM_NOT_OWN_STONE, + KO_PSK, PROTECTED_POINT, BATCH_SIZE, HANDICAP_RANGE, HANDICAP_COUNT, SLICE_FORMAT / SLICE_RANGE / SLICE_RATIO, CLAIM_NOT_OWN_STONE, CLAIM_MARK_NOT_EMPTY, CLAIM_EMPTY_MARKS (warning), REFUTE_INCOMPLETE, PASS_WARNING (Defender), INVALID_PASS, NOT_YOUR_PHASE. * `status.killallgo.{ATTACKER, DEFENDER, UNDECIDED, PHASE, HANDICAP, SLICE, CLAIM}`. * `apresults`: `PLACE.killallgo_setup`, `CLAIM.killallgo_attacker`, `CLAIM.killallgo_defender`, `CLAIM.killallgo_life`, - `CLAIM.killallgo_concede`, `ANNOUNCE.killallgo_slice` (or reuse a generic key), `SELECT.killallgo_option`, + `CLAIM.killallgo_concede`, `DECLARE.killallgo_handicap`, `ANNOUNCE.killallgo_slice` (or reuse a generic key), `SELECT.killallgo_option`, `EOG.killallgo_pass_alive`, `EOG.killallgo_claim_upheld`, `EOG.killallgo_claim_refuted`, `EOG.killallgo_double_pass`. Do not touch any locale other than `en` (and `eo` via the skill); `check-game-names-locale` will report the managed locales as missing until upstream CI seeds them — expected. @@ -393,8 +421,9 @@ missing until upstream CI seeds them — expected. * **board helpers**: capture of one/many strings, multi-stone suicide removal, single-stone suicide rejected by PSK, PSK across plies and across `interim` positions inside one ply. * **classic**: initial 17 stones at the listed cells; Player 1 (Blue) to move; `getPlayerColour(1) === 2`. -* **alt-place**: alternation; `attacker` on either seat sets `redSeat` and hands Blue the move; handicap: Player 1 takes → no - stones; Player 2 takes → exactly n stones required; sidebar strings. +* **alt-place**: alternation; `attacker` on either seat sets `redSeat` and hands Blue the move; sidebar strings. +* **handicap**: `n` range enforced (`0`, `floor(p/2) + 1` and non-integers rejected on every board size); Player 2 moves first + after the number; Player 1 claims → no stones; Player 2 claims → exactly n stones required, fewer is incomplete, more is invalid. * **pie / hoctaph**: every branch of the ply tables in §5.6, batch size enforcement, `slice` validation (range, ratio, a+b ≤ p−2), `getRounds()` has no duplicate actors, `getPlayerColour` neutral until decided. * **play**: pass-alive win with highlight; Blue win triggered by a Red move; double pass → Red wins; Blue pass warning text. @@ -419,12 +448,14 @@ Commands: `npm run generate-registry`, `npm run typecheck`, `npx mocha --require --- -## 10. Open questions for you (answers change small, well-contained parts) -1. D4 suicide: Tromp-Taylor (legal, strict Benson) as planned, or forbid it like AP's Go? -2. D5 double pass = Attacker wins? -3. D9 handicap: 1–12 as the offered range, and "Player 1 gives; use challenge seating" as the convention? -4. Hoctaph: `a, b ≥ 1` (I excluded 0 so the ratio is defined), exact batch sizes, and are "I place the first batch" / - "Opponent places the first batch" acceptable wordings for the two options? -5. Names/AP ids to credit in `people` for the fan protocols (and for "Hoctaph"). -6. Anything you want shown differently while a claim is pending (marker style, sidebar wording)? -7. `classic` seats the Defender as Player 1 (so Player 1 keeps the first move, as in every other AP game) and the Attacker as Player 2, i.e. the Attacker is *not* the seat whose default colour is red there. Fine, or would you rather have the Attacker as Player 1 with Player 2 moving first? +## 10. Decisions confirmed by the author (2026-09-21) +1. Suicide stays legal (Tromp-Taylor); strict Benson. +2. Double pass ends the game as an Attacker win. +3. Handicap: `n` in `[1, floor(p/2)]`, typed by Player 1 (Uwate, seated first via the challenge's seating option); Player 2 + (Shitate) makes the first placement or claims at once. No `handicap-N` variant list. +4. Hoctaph: `a, b ≥ 1`, `a + b ≤ p − 2`, `b/2 ≤ a ≤ 2b`. +5. Credits as in D13 / §7.8 (descriptions), `people` on `hoctaph` only. +6. Classic opening seats the Defender as Player 1; claim-pending rendering as specified in §4.5.6. + +Still to fill in during implementation: your display name / AP id in `people`, and a check that the OGS forum URL for +Hoctaph's post resolves (topic 27365, post 70). From fe81b5496761f6c83f3496989c95184a22b8e27e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:11:00 +0000 Subject: [PATCH 03/16] Add Kill-All Go New experimental game: a Go variant where the Attacker starts with stones on the board and must capture everything while the Defender wins by making one unconditionally alive string. - Tromp-Taylor captures with multi-stone suicide, positional superko over every position reached (including positions inside multi-stone plies) - Benson pass-alive detection with the stricter vital-region test that suicide-legal rules require; the Defender wins as soon as a string is pass-alive, and two consecutive passes give the Attacker the win - Seki handled by a life claim: the Defender names a string and protected points, the Attacker refutes with any number of consecutive stones - Openings: alternating placement (default), handicap (Player 1 sets n in [1, floor(p/2)]), classic 17-stone setup (19x19), simple pie, and the generalized Hoctaph's pie; every protocol keeps strict alternation - 9x9, 13x13 and 19x19 boards; roles rendered as Attacker/Defender with colours undecided until a side is chosen; in-board button bar - Unit tests for the board mechanics, Benson fixtures, every opening branch, superko, claims and serialization Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- locales/en/apgames.json | 109 +++ locales/en/apresults.json | 19 +- src/games/killallgo.ts | 1340 +++++++++++++++++++++++++++ src/games/killallgo/benson.ts | 139 +++ src/games/killallgo/board.ts | 156 ++++ test/games/killallgo-benson.test.ts | 245 +++++ test/games/killallgo.test.ts | 481 ++++++++++ 7 files changed, 2488 insertions(+), 1 deletion(-) create mode 100644 src/games/killallgo.ts create mode 100644 src/games/killallgo/benson.ts create mode 100644 src/games/killallgo/board.ts create mode 100644 test/games/killallgo-benson.test.ts create mode 100644 test/games/killallgo.test.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 33b40436..2f3a2d12 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -135,6 +135,7 @@ "irensei": "Irensei (Japanese: 囲連星) is a mixture of Go and Gomoku. Get a seven in a row inside the middle 15x15 region of the Go board to win. To reduce the first player's advantage, the first player loses if they make an overline (even if it extends beyond the 15x15 region), but the second player may win by overline.", "jacynth": "A Decktet card game where opponents vye for control of the city of Jacynth. Place cards, exert influence, and control the most area to win.", "kachit": "A chess-like game played on a 4x4 board where you try to eliminate the opposing king or promote your own. Pieces can change their orientation after moving.", + "killallgo": "A Go variant with asymmetric goals: the Attacker starts with stones on the board and must capture everything, while the Defender wins by making one string that can never be captured.", "knightline": "A four-in-a-row connection game played by splitting a stack of knights on an unbounded grid.", "konane": "A traditional Hawaiian game that was almost completely lost to time but for the memory of a single woman, Kaʻahaʻaina Naihe of Kailua-Kona. Played on a rectangular board, each player captures by jumping over their opponent's pieces. Last to move wins.", "krypte": "A 4-in-a-row game where pieces enter from active sides that rotate after each turn, and placing a piece flips all adjacent pieces.", @@ -423,6 +424,7 @@ "irensei": "Irensei", "jacynth": "Jacynth", "kachit": "Kachit Knights", + "killallgo": "Kill-All Go", "knightline": "Knight Line", "konane": "Konane", "krypte": "Krypte", @@ -615,6 +617,7 @@ "intermedium": "Intermedium (aka, Stepping Stones) is a 1974 game designed by Matt Crispis. This is a stack sowing game, where players try to surround the opposing home-base. Stacks only move diagonally, and each army starts in a different diagonal color, so they can never cross over (like two bishops at different square colors). Pieces are captured by enclosure, surrounding all orthogonal adjacencies of an opponent stack.\n\nSome words from the author: \"[Intermedium] is simple to learn, but may lead to complicated situations that require deep analysis and forward thinking. Single pieces do no move, but they are useful a stepping stones for your captures. And they may become alive by putting another piece over them. Before you master the game, avoid early attacks against the City that may prove costly. Build up your strength gradually and evenly by dominating, if possible, the center. You should also avoid building 'formidable' stacks of six or more pieces - if taken you may lose the game at one stroke. Leave some forces behind to defend your City in case of emergency.\"", "invector": "Invector, designed by Mark Steere in 2026, is an elimination game inspired by the ancient Hawiian game of Kōnane. Invector begins with a checkerboard pattern of stones, has short range captures, and is extremely simple. Invector simply combines two basic, off-the-shelf ingredients: orthogonal, adjacent capturing moves, and orthogonal, adjacent moves closer to center.", "jacynth": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com). Cards in players' hands are hidden from observers and opponents.", + "killallgo": "The Attacker, whose stones are already on the board when normal play begins, wins by preventing the Defender from ever making a living string. The Defender wins by making one string that cannot be captured. There is no scoring.\n\nStones are placed and captured as in Go with Tromp-Taylor clearing: after a placement, opponent strings without liberties are removed, then your own string if it has none, so multi-stone suicide is legal. Positional superko applies: no placement may recreate any earlier position of the board. Either player may pass; if both pass in a row, the Attacker wins.\n\nThe Defender wins as soon as one of their strings is unconditionally alive (pass-alive by Benson's algorithm: the Attacker could never capture it, even with unlimited consecutive moves). A string that lives only in seki must be claimed. Instead of moving, the Defender clicks one of their stones, marks any number of empty protected points, and submits the claim. The Attacker then plays any number of consecutive stones, none on a protected point, trying to capture the claimed string. If the string is captured, the Attacker wins. If the Attacker ends the sequence by playing a protected point, every stone stays on the board and normal play resumes with the Defender to move. If the Attacker concedes, the Defender wins. Life in ko is not recognised; the Defender must keep playing.\n\nOpenings: with alternating placement, the players take turns placing Attacker stones until one of them takes the Attacker side; the other player then moves first as the Defender. The handicap opening works the same way, except that Player 1 (the stronger player, so seat the handicap giver first when creating the challenge) first sets the number of handicap stones and Player 2 starts placing; if Player 2 takes the Attacker side, they place that many extra stones at once. The classic opening uses the traditional 17-stone position on the 19x19 board, with the Defender (Player 1) to move. In the simple pie, Player 1 places any number of Attacker stones and Player 2 chooses a side. In the generalized Hoctaph's pie, Player 1 chooses two batch sizes (each at least 1, together at most the number of points minus 2, neither more than twice the other), Player 2 decides who places the first batch, the other player then chooses a side, and the Attacker places the second batch. In every opening the Defender makes the first normal move.", "konane": "Several competing opening protocols exist, but the most common ruleset is the Naihe Ruleset, used by tournaments at the Bishop Museum in Hawaii and described in the BGG reference. This is what is implemented here.", "knightline": "For convenience, your stack is split approximately in half on your first click. Subsequent clicks on the source or target stack move one piece at a time. When done, click on an empty cell or use the Complete Move button.\n\nThe coordinate system is adapted from the coder's coordinate system for Hive, a game on an unbounded (but still reasonably sized) hex board. The y axis is labeled with Cartesian coordinates (positive and negative integers). The x axis is labeled with letters in alphabetical order, with the origin at (m, 0). If motion on the x-axis exceeds the confines of the alphabet in either direction, the letters are prefixed with another letter (z is followed by aa, ab, ac, etc., and a is preceded by zz, zy, zx, etc.). The game is not expected to reach a width that requires more double coordinates, but for the record, az is followed by ba, za is preceded by yz, and so forth. The board cannot reach a size that would result in ambiguity.", "lasca": "Maneuverability is a measure of how close your pieces are to promoting. Your maximum maneuverability is the board size times the number of stacks you control.\n\nMaterial is calculated by giving you a point for every friendly piece in a stack you control, plus an extra point if you have an officer on top of that stack.", @@ -2342,6 +2345,37 @@ "description": "The board starts with six cards in a circular pattern." } }, + "killallgo": { + "size-9": { + "name": "9x9 board" + }, + "size-13": { + "name": "13x13 board" + }, + "#board": { + "name": "19x19 board" + }, + "#opening": { + "name": "Alternating placement", + "description": "Players take turns placing Attacker stones until one of them takes the Attacker side; the other player then moves first as the Defender. A community protocol from the OGS forums, origin unknown." + }, + "handicap": { + "name": "Handicap", + "description": "Alternating placement with a handicap. Player 1 (seat the stronger player first) sets the number of extra Attacker stones, then Player 2 starts placing. If Player 2 takes the Attacker side, they place the extra stones at once. A community protocol from the OGS forums, origin unknown." + }, + "classic": { + "name": "Classic 17-stone setup", + "description": "The traditional 17-stone starting position; the Defender (Player 1) moves first. 19x19 only." + }, + "pie": { + "name": "Simple pie", + "description": "Player 1 places any number of Attacker stones, then Player 2 chooses a side. The usual pie rule." + }, + "hoctaph": { + "name": "Generalized Hoctaph's pie", + "description": "Player 1 chooses two batch sizes, Player 2 chooses who places the first batch, the other player chooses a side, and the Attacker places the second batch. Hoctaph's pie (OGS forums), generalized to every board size by Samraku." + } + }, "knightline": { "#setup": { "name": "Default setup", @@ -4916,6 +4950,27 @@ "STACKSIZES": "Stack heights", "tumbleweed": "Score (pieces + influence)", "ESTIMATEDSCORES": "Estimated scores", + "killallgo": { + "ATTACKER": "Attacker", + "DEFENDER": "Defender", + "UNDECIDED": "Not yet chosen", + "HANDICAP": "Handicap stones", + "BATCHES": "Batch sizes", + "BATCHES_VALUE": "{{a}} then {{b}}", + "CLAIM": "Life claim", + "CLAIM_VALUE": "The string at {{where}} lives unless the Attacker plays at {{marks}}", + "CLAIM_VALUE_NO_MARKS": "The string at {{where}} cannot be captured", + "PHASE_HAND_N": "{{player}} sets the number of handicap stones", + "PHASE_ALT_PLACE": "{{player}} places an Attacker stone or takes the Attacker side", + "PHASE_PIE_SLICE": "{{player}} places the opening Attacker stones", + "PHASE_PIE_CHOOSE": "{{player}} chooses a side", + "PHASE_HOC_SLICE": "{{player}} sets the two batch sizes", + "PHASE_HOC_OPTION": "{{player}} decides who places the first batch", + "PHASE_HOC_BATCH_A": "{{player}} places the first batch of Attacker stones", + "PHASE_HOC_CHOOSE": "{{player}} chooses a side", + "PHASE_HOC_BATCH_B": "{{player}} places the second batch of Attacker stones", + "PHASE_REFUTE": "{{player}} is trying to refute the life claim" + }, "TOPLACE": "Piece to place", "valley": "Moon Tokens", "waldmeister": { @@ -6389,6 +6444,60 @@ "PARTIAL_destination": "Select a destination.", "PARTIAL_orientation": "Select a neighbouring cell to orient the piece." }, + "killallgo": { + "INSTRUCTIONS_HANDICAP": "You are giving the handicap. Type the number of extra Attacker stones (1 to {{max}}) your opponent will place if they take the Attacker side.", + "HANDICAP_INVALID": "The handicap must be a whole number.", + "HANDICAP_RANGE": "The handicap must be between 1 and {{max}} stones.", + "HANDICAP_OK_one": "Handicap of one stone. Submit, or keep typing.", + "HANDICAP_OK_other": "Handicap of {{count}} stones. Submit, or keep typing.", + "INSTRUCTIONS_ALT": "Place an Attacker stone, or take the Attacker side; your opponent then moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Place an Attacker stone, or take the Attacker side and then place one more stone; your opponent then moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Place an Attacker stone, or take the Attacker side and then place {{count}} more stones; your opponent then moves first as the Defender.", + "ATTACKER_NO_STONES": "Taking the Attacker side does not involve placing stones now.", + "DEFENDER_NO_STONES": "Choosing the Defender side does not involve placing stones now.", + "STONES_LEFT_one": "Place one more Attacker stone.", + "STONES_LEFT_other": "Place {{count}} more Attacker stones.", + "BATCH_TOO_MANY_one": "This batch holds exactly one stone.", + "BATCH_TOO_MANY_other": "This batch holds exactly {{count}} stones.", + "BATCH_MORE_OR_SUBMIT": "Add more Attacker stones, or submit.", + "DUPLICATE_CELL": "The cell {{where}} is listed twice.", + "INVALID_PASS": "You cannot pass now.", + "NO_CLICKS_NOW": "Type your answer in the move box; the board is not clickable yet.", + "INSTRUCTIONS_PIE_SLICE": "Place any number of Attacker stones (or pass to place none); your opponent will then choose a side.", + "INSTRUCTIONS_PIE_CHOOSE": "Choose a side: take the Attacker stones, or place your first stone as the Defender.", + "INSTRUCTIONS_PIE_DEFENDER_STONE": "Place your first Defender stone.", + "INSTRUCTIONS_HOC_SLICE": "Type two batch sizes as a,b: each at least 1, together at most {{max}}, and neither more than twice the other.", + "SLICE_FORMAT": "Type the two batch sizes as two whole numbers separated by a comma, for example 20,15.", + "SLICE_MIN": "Each batch must hold at least one stone.", + "SLICE_SUM": "Together the two batches may hold at most {{max}} stones.", + "SLICE_RATIO": "Neither batch may be more than twice the size of the other.", + "SLICE_OK": "Batches of {{a}} and {{b}} stones. Submit, or keep typing.", + "INSTRUCTIONS_HOC_OPTION_one": "Decide who places the first batch of one Attacker stone: place it yourself (your opponent then chooses a side), or let your opponent place it (you then choose a side).", + "INSTRUCTIONS_HOC_OPTION_other": "Decide who places the first batch of {{count}} Attacker stones: place them yourself (your opponent then chooses a side), or let your opponent place them (you then choose a side).", + "INSTRUCTIONS_HOC_BATCH_one": "Place one Attacker stone.", + "INSTRUCTIONS_HOC_BATCH_other": "Place {{count}} Attacker stones.", + "INSTRUCTIONS_HOC_CHOOSE_one": "Choose a side. Taking the Attacker side means placing the second batch of one stone now; choosing the Defender side means your opponent places it and you then move first.", + "INSTRUCTIONS_HOC_CHOOSE_other": "Choose a side. Taking the Attacker side means placing the second batch of {{count}} stones now; choosing the Defender side means your opponent places them and you then move first.", + "INSTRUCTIONS_PLAY_ATTACKER": "Place a stone or pass.", + "INSTRUCTIONS_PLAY_DEFENDER": "Place a stone, pass, or click one of your own stones to claim that its string is alive.", + "KO_PSK": "That placement would recreate an earlier position of the board (positional superko).", + "PASS_WARNING": "Passing does not establish life. If the Attacker also passes, the Attacker wins.", + "CLAIM_ATTACKER": "Only the Defender can claim that a string is alive.", + "CLAIM_NOT_OWN_STONE": "There is no Defender stone at {{where}}; click one of your own stones to claim its string.", + "CLAIM_MARK_OCCUPIED": "Protected points must be empty; {{where}} is occupied.", + "INSTRUCTIONS_CLAIM": "Click the empty points your opponent must not play while refuting (click again to unmark), then submit the claim.", + "INSTRUCTIONS_CLAIM_NO_MARKS": "No protected points yet: the Attacker will be free to play anywhere. Click empty points to protect them, or submit the claim as it is.", + "INSTRUCTIONS_REFUTE": "Refute the claim: place any number of stones, none on a protected point. End by playing a protected point (play then resumes with the Defender to move), by capturing the claimed string (you win), or by conceding (the Defender wins).", + "INSTRUCTIONS_REFUTE_CONTINUE": "Keep placing stones, play a protected point to end the sequence, or concede the claim.", + "PROTECTED_POINT": "{{where}} is a protected point; it may only be the final stone of your refutation.", + "REFUTE_AFTER_END": "The refutation already ended; remove the stones after the final one.", + "BTN_ATTACKER_TAKE": "Take the Attacker side", + "BTN_ATTACKER": "Play as the Attacker", + "BTN_DEFENDER": "Play as the Defender", + "BTN_IPLACE": "I place the first batch", + "BTN_YOUPLACE": "Opponent places the first batch", + "BTN_CONCEDE": "Concede the claim" + }, "knightline": { "BAD_PASS": "You may not pass if legal moves are available.", "BAD_OPENING_MOVE": "The opening move in this variant must be a legal cell for initial stack placement.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 07150f02..72ea1fad 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -37,6 +37,7 @@ "playerSetup": "Player setup", "bearSetup": "Bear setup" }, + "killallgo_slice": "{{player}} set the batch sizes to {{a}} and {{b}} stones.", "scribe_super": "{{player}}'s super formation: {{formation}}.", "stawvs": "{{player}} was unable to move and must pass from now on. Their pieces remain on the following pyramids: {{pyramids}}." }, @@ -90,6 +91,8 @@ "elOso_one": "{{player}} annihilated one piece at {{where}}.", "elOso_other": "{{player}} annihilated {{count}} pieces at {{where}}.", "hexentafl_king": "The king was captured at {{where}}.", + "killallgo_suicide_one": "{{player}} removed their own stone by suicide.", + "killallgo_suicide_other": "{{player}} removed {{count}} of their own stones by suicide.", "lasca_officer": "{{player}} captured an officer at {{where}}.", "lasca_soldier": "{{player}} captured a soldier at {{where}}.", "lifeline": "Dead pieces were removed at {{where}}.", @@ -168,6 +171,11 @@ "fnap_row": "{{player}} claimed row {{where}}.", "frogger": "{{player}} was blocked and drew {{card}} from the draw pool.", "jacynth": "{{player}} exerted influence at {{where}}.", + "killallgo_attacker": "{{player}} chose to play as the Attacker.", + "killallgo_concede": "{{player}} conceded the claim: the string lives.", + "killallgo_defender": "{{player}} chose to play as the Defender.", + "killallgo_life": "{{player}} claimed that the string at {{where}} is alive unless the Attacker plays at {{marks}}.", + "killallgo_life_nomarks": "{{player}} claimed that the string at {{where}} cannot be captured.", "logger": "{{player}} claimed a protestor from the tree felled at {{where}}.", "magnate": "{{who}} gained {{what}}.", "magnate_initial": "Players collected their Crown resources.", @@ -218,6 +226,8 @@ "biscuit": "{{player}} has gone out!", "emu": "Year {{year}} has ended.", "frogger": "The crocodiles advanced.", + "killallgo_handicap_one": "{{player}} set the handicap to one stone.", + "killallgo_handicap_other": "{{player}} set the handicap to {{count}} stones.", "mchess": "{{player}} has \"called the clock.\" If no capture is made in the next seven turns, the game will end and be scored.", "renju": "{{player}} has declared {{count}} tentative fifths." }, @@ -293,7 +303,11 @@ "tafl_king_captured": "The game has ended because the king was captured.", "tafl_king_escaped": "The game has ended because the king escaped.", "unlur_foul": "The game has ended because one player has achieved the opponent's win condition without achieving their own.", - "druid_hex": "The game has ended because {{player}} connected the {{edges}} edges." + "druid_hex": "The game has ended because {{player}} connected the {{edges}} edges.", + "killallgo_claim_refuted": "The game has ended because the claimed string was captured.", + "killallgo_claim_upheld": "The game has ended because the Attacker conceded that the claimed string lives.", + "killallgo_double_pass": "The game has ended because both players passed without the Defender establishing life.", + "killallgo_pass_alive": "The game has ended because the Defender formed an unconditionally alive string." }, "DRUID_HEX_EDGE": { "N": "north (N)", @@ -486,6 +500,7 @@ "gorogo_henge": "{{player}} placed a henge piece at {{where}}.", "gorogo_piece": "{{player}} placed a piece at {{where}}.", "iqishiqi": "{{player}} placed a shared stone at {{where}}.", + "killallgo_setup": "{{player}} placed an Attacker stone at {{where}}.", "knightline": "{{player}} placed their initial stack at {{where}}.", "logger_logger": "{{player}} placed their logger at {{where}}.", "logger_plant": "{{player}} planted a sapling at {{where}}.", @@ -630,6 +645,8 @@ "SCORE_REPORT": "{{player}}'s score is now {{score}}.", "SELECT": { "fnap": "{{player}} selected the tile {{tile}}.", + "killallgo_iplace": "{{player}} chose to place the first batch.", + "killallgo_youplace": "{{player}} chose to let their opponent place the first batch.", "resolve": "{{player}} selects the piece in a crosscut at {{where}}.", "slyde_fixed_opponent": "{{player}} changed the state of their opponent's piece at {{where}} from mobile to fixed.", "slyde_fixed_self": "{{player}} changed the state of their own piece at {{where}} from mobile to fixed.", diff --git a/src/games/killallgo.ts b/src/games/killallgo.ts new file mode 100644 index 00000000..92868dd7 --- /dev/null +++ b/src/games/killallgo.ts @@ -0,0 +1,1340 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IStatus, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; +import type { APGamesInformation } from "../schemas/gameinfo.js"; +import type { APRenderRep, AreaButtonBar, RowCol } from "@abstractplay/renderer/build/schemas/schema"; +import type { APMoveResult } from "../schemas/moveresults.js"; +import { reviver, UserFacingError } from "../common/index.js"; +import i18next from "i18next"; +import { applyPlacement, BLUE, makeGeometry, RED, signature, stringAt, type Board, type Geometry, type Stone } from "./killallgo/board.js"; +import { passAliveStrings } from "./killallgo/benson.js"; + +/** + * Kill-All Go. + * + * Board colours: RED (1) is the Attacker, who starts with stones on the board and must kill + * everything; BLUE (2) is the Defender, who wins by making one string that can never be captured. + * Seats are not colours: `redSeat` records which seat plays Red once the opening protocol has + * decided it. User-facing strings say "Attacker" and "Defender", never a colour. + */ +export type playerid = 1 | 2; + +type Opening = "alt" | "handicap" | "classic" | "pie" | "hoctaph"; + +type Phase = + | "hand-n" // handicap opening: Player 1 types the number of handicap stones + | "alt-place" // alternating placement of Red stones until someone takes Red + | "pie-slice" // simple pie: Player 1 places any number of Red stones + | "pie-choose" // simple pie: Player 2 chooses a side + | "hoc-slice" // Hoctaph: Player 1 types the two batch sizes + | "hoc-option" // Hoctaph: Player 2 chooses who places the first batch + | "hoc-batch-a" // Hoctaph: Player 1 places the first batch (Player 2 chose "youplace") + | "hoc-choose" // Hoctaph: the other player chooses a side + | "hoc-batch-b" // Hoctaph: Red places the second batch (the chooser took Blue) + | "play" // normal alternation, Blue always moved first + | "refute"; // Red must answer a pending life claim + +interface ISetup { + a?: number; + b?: number; + batchSize?: number; + handicap?: number; +} + +interface IClaim { + stone: string; + stones: string[]; + marks: string[]; +} + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Board; + lastmove?: string; + phase: Phase; + redSeat?: playerid; + setup?: ISetup; + claim?: IClaim; + /** Board signatures of the intermediate positions inside this ply (positional superko). */ + interim: string[]; + /** Stones of the strings judged alive when the Defender won. */ + alive?: string[]; +} + +export interface IKillAllGoState extends IAPGameState { + winner: playerid[]; + stack: Array; +} + +// The traditional 17-stone starting position (Sensei's Library, "The Killing Game"), 19x19 only. +const CLASSIC_SETUP = ["j18", "c17", "q17", "d16", "j16", "p16", "b10", "d10", "j10", "p10", "r10", "d4", "j4", "p4", "c3", "q3", "j2"]; +const UNDECIDED_COLOUR = "#999999"; +const CELL_RE = /^[a-z]+\d+$/; + +export class KillAllGoGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Kill-All Go", + uid: "killallgo", + playercounts: [2], + version: "20260921", + dateAdded: "2026-09-21", + // i18next.t("apgames:descriptions.killallgo") + description: "apgames:descriptions.killallgo", + // i18next.t("apgames:notes.killallgo") + notes: "apgames:notes.killallgo", + urls: [ + "https://senseis.xmp.net/?KillAllGame", + "https://senseis.xmp.net/?ShapeGame", + ], + people: [ + { + type: "designer", + name: "Traditional", + }, + { + type: "coder", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + variants: [ + { uid: "size-9", group: "board" }, + { uid: "size-13", group: "board" }, + { uid: "#board" }, + { uid: "#opening" }, + { uid: "handicap", group: "opening", fans: true }, + { uid: "classic", group: "opening", enabledWhen: { board: ["#board"] } }, + { uid: "pie", group: "opening", fans: true }, + { + uid: "hoctaph", + group: "opening", + fans: true, + people: [ + { + type: "designer", + name: "Hoctaph", + urls: ["https://forums.online-go.com/t/kill-all-go/27365/70"], + }, + { + type: "designer", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + }, + ], + categories: ["goal>annihilate", "mechanic>place", "mechanic>capture", "mechanic>enclose", "mechanic>asymmetry", "board>shape>rect", "board>connect>rect", "components>simple>1per"], + flags: ["experimental", "custom-colours", "custom-buttons"], + customizations: [ + { + num: 1, + default: 1, + explanation: "Colour of the Attacker's stones", + }, + { + num: 2, + default: 2, + explanation: "Colour of the Defender's stones", + }, + ], + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Board; + public phase!: Phase; + public redSeat?: playerid; + public setup?: ISetup; + public claim?: IClaim; + public interim: string[] = []; + public alive?: string[]; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private boardSize = 19; + private geo!: Geometry; + + constructor(state?: IKillAllGoState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined && variants.length > 0) { + this.variants = this.applyVariantConstraints(variants); + } + const size = KillAllGoGame.sizeFromVariants(this.variants); + const opening = KillAllGoGame.openingFromVariants(this.variants); + const board: Board = new Map(); + let phase: Phase = "alt-place"; + let redSeat: playerid | undefined; + if (opening === "classic" && size === 19) { + for (const cell of CLASSIC_SETUP) { + board.set(cell, RED); + } + phase = "play"; + redSeat = 2; + } else if (opening === "handicap") { + phase = "hand-n"; + } else if (opening === "pie") { + phase = "pie-slice"; + } else if (opening === "hoctaph") { + phase = "hoc-slice"; + } + const fresh: IMoveState = { + _version: KillAllGoGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + phase, + redSeat, + interim: [], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + if (!state.startsWith("{") && !state.startsWith("[")) { + throw new Error("Compressed game state must be decompressed before constructing the engine."); + } + state = JSON.parse(state, reviver) as IKillAllGoState; + } + if (state.game !== KillAllGoGame.gameinfo.uid) { + throw new Error(`The Kill-All Go engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): KillAllGoGame { + if (idx < 0) { + idx += this.stack.length; + } + if (idx < 0 || idx >= this.stack.length) { + throw new Error("Could not load the requested state from the stack."); + } + const state = this.stack[idx]; + this.results = [...state._results]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + this.phase = state.phase; + this.redSeat = state.redSeat; + this.setup = state.setup === undefined ? undefined : { ...state.setup }; + this.claim = state.claim === undefined ? undefined : { stone: state.claim.stone, stones: [...state.claim.stones], marks: [...state.claim.marks] }; + this.interim = [...state.interim]; + this.alive = state.alive === undefined ? undefined : [...state.alive]; + this.boardSize = KillAllGoGame.sizeFromVariants(this.variants); + this.geo = makeGeometry(this.boardSize); + return this; + } + + private static sizeFromVariants(variants: string[]): number { + if (variants.includes("size-9")) { return 9; } + if (variants.includes("size-13")) { return 13; } + return 19; + } + + private static openingFromVariants(variants: string[]): Opening { + for (const uid of ["handicap", "classic", "pie", "hoctaph"] as const) { + if (variants.includes(uid)) { + return uid; + } + } + return "alt"; + } + + private get opening(): Opening { + return KillAllGoGame.openingFromVariants(this.variants); + } + + public coords2algebraic(x: number, y: number): string { + return this.geo.coords2algebraic(x, y); + } + + public algebraic2coords(cell: string): [number, number] { + return this.geo.algebraic2coords(cell); + } + + private otherSeat(seat: playerid): playerid { + return seat === 1 ? 2 : 1; + } + + private blueSeat(): playerid | undefined { + return this.redSeat === undefined ? undefined : this.otherSeat(this.redSeat); + } + + private colourOfSeat(seat: playerid): Stone | undefined { + if (this.redSeat === undefined) { + return undefined; + } + return seat === this.redSeat ? RED : BLUE; + } + + private get points(): number { + return this.boardSize * this.boardSize; + } + + private maxHandicap(): number { + return Math.floor(this.points / 2); + } + + private isValidCell(cell: string): boolean { + if (!CELL_RE.test(cell)) { + return false; + } + try { + const [x, y] = this.geo.algebraic2coords(cell); + return x >= 0 && y >= 0 && x < this.boardSize && y < this.boardSize; + } catch { + return false; + } + } + + /** Signatures of every position reached before the current ply (positional superko). */ + private positions(): Set { + const seen = new Set(); + for (const state of this.stack) { + seen.add(signature(state.board, this.geo)); + for (const sig of state.interim) { + seen.add(sig); + } + } + return seen; + } + + /** Result of placing `colour` at `cell` on a copy of `board`, or undefined when the position would repeat. */ + private simulate(board: Board, cell: string, colour: Stone, seen: Set): { board: Board; sig: string } | undefined { + const copy = new Map(board); + applyPlacement(copy, this.geo, cell, colour); + const sig = signature(copy, this.geo); + if (seen.has(sig)) { + return undefined; + } + return { board: copy, sig }; + } + + private bluePassAlive(board: Board = this.board): string[][] { + return passAliveStrings(board, this.geo, BLUE, { suicideAllowed: true }); + } + + private claimCaptured(board: Board): boolean { + return this.claim !== undefined && !board.has(this.claim.stone); + } + + private parseCells(list: string): string[] { + return list.length === 0 ? [] : list.split(","); + } + + private isEnumerable(m: string): boolean { + return /^([a-z]+\d+|\d+|pass|attacker|defender|youplace|concede|defender:[a-z]+\d+)$/.test(m); + } + + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = []; + const seen = this.positions(); + switch (this.phase) { + case "hand-n": + for (let n = 1; n <= this.maxHandicap(); n++) { + moves.push(n.toString()); + } + break; + case "alt-place": + for (const cell of this.geo.cells) { + if (!this.board.has(cell)) { + moves.push(cell); + } + } + if (this.handicapOwed() === 0) { + moves.push("attacker"); + } + break; + case "pie-slice": + moves.push("pass"); + for (const cell of this.geo.cells) { + if (!this.board.has(cell)) { + moves.push(cell); + } + } + break; + case "pie-choose": + moves.push("attacker"); + for (const cell of this.geo.cells) { + if (!this.board.has(cell) && this.simulate(this.board, cell, BLUE, seen) !== undefined) { + moves.push(`defender:${cell}`); + } + } + break; + case "hoc-option": + moves.push("youplace"); + break; + case "hoc-choose": + moves.push("defender"); + break; + case "play": { + moves.push("pass"); + const colour = this.colourOfSeat(this.currplayer)!; + for (const cell of this.geo.cells) { + if (!this.board.has(cell) && this.simulate(this.board, cell, colour, seen) !== undefined) { + moves.push(cell); + } + } + break; + } + case "refute": { + moves.push("concede"); + for (const cell of this.geo.cells) { + if (this.board.has(cell)) { continue; } + const sim = this.simulate(this.board, cell, RED, seen); + if (sim === undefined) { continue; } + if (this.claim!.marks.includes(cell) || this.claimCaptured(sim.board)) { + moves.push(cell); + } + } + break; + } + default: + // Typed numbers and multi-stone batches are not enumerable. + break; + } + return moves; + } + + /** Stones the current player must add when taking the Attacker side right now. */ + private handicapOwed(): number { + if (this.phase === "alt-place" && this.opening === "handicap" && this.currplayer === 2) { + return this.setup?.handicap ?? 0; + } + if (this.phase === "hoc-choose") { + return this.setup?.b ?? 0; + } + return 0; + } + + public getButtons(): ICustomButton[] { + if (!this.gameover && this.phase === "play") { + return [{ label: "pass", move: "pass" }]; + } + return []; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + let newmove = ""; + if (piece !== undefined && piece.startsWith("_btn_")) { + const value = piece.substring(5); + if (value === "concede") { + newmove = move.length === 0 ? "concede" : `${move},concede`; + } else { + newmove = value; + } + } else { + const cell = this.coords2algebraic(col, row); + const clicked = this.clickCell(move, cell); + if (clicked === undefined) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.killallgo.NO_CLICKS_NOW"), + }; + } + newmove = clicked; + } + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? newmove : move; + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", { move, row, col, piece, emessage: (e as Error).message }), + }; + } + } + + /** Extend `move` with a click on `cell`, or return undefined when board clicks mean nothing now. */ + private clickCell(move: string, cell: string): string | undefined { + const toggle = (list: string): string => { + const cells = this.parseCells(list); + const next = cells.includes(cell) ? cells.filter((c) => c !== cell) : [...cells, cell]; + return next.join(","); + }; + switch (this.phase) { + case "hand-n": + case "hoc-slice": + return undefined; + case "alt-place": + if (move.startsWith("attacker")) { + return `attacker:${toggle(move.substring("attacker:".length))}`; + } + return cell; + case "pie-slice": + case "hoc-batch-a": + case "hoc-batch-b": + return toggle(move === "pass" ? "" : move); + case "pie-choose": + return `defender:${cell}`; + case "hoc-option": + if (move === "youplace") { + return move; + } + return `iplace:${toggle(move.startsWith("iplace:") ? move.substring("iplace:".length) : "")}`; + case "hoc-choose": + if (move === "defender") { + return move; + } + return `attacker:${toggle(move.startsWith("attacker:") ? move.substring("attacker:".length) : "")}`; + case "play": { + if (move.startsWith("claim:")) { + const [stone, marks] = this.parseClaim(move); + if (cell === stone) { + return ""; + } + const next = marks.includes(cell) ? marks.filter((c) => c !== cell) : [...marks, cell]; + return next.length === 0 ? `claim:${stone}` : `claim:${stone}:${next.join(",")}`; + } + if (this.board.get(cell) === BLUE && this.colourOfSeat(this.currplayer) === BLUE) { + return `claim:${cell}`; + } + return cell; + } + case "refute": + return move.length === 0 ? cell : `${move},${cell}`; + } + } + + private parseClaim(m: string): [string, string[]] { + const parts = m.split(":"); + const stone = parts[1] ?? ""; + const marks = parts.length > 2 ? this.parseCells(parts[2]) : []; + return [stone, marks]; + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = { valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER") }; + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (this.gameover) { + result.message = i18next.t("apgames:MOVES_GAMEOVER"); + return result; + } + switch (this.phase) { + case "hand-n": + return this.validateHandicap(m, result); + case "alt-place": + return this.validateAltPlace(m, result); + case "pie-slice": + return this.validatePieSlice(m, result); + case "pie-choose": + return this.validatePieChoose(m, result); + case "hoc-slice": + return this.validateSlice(m, result); + case "hoc-option": + return this.validateHocOption(m, result); + case "hoc-batch-a": + case "hoc-batch-b": + return this.validateHocBatch(m, result); + case "hoc-choose": + return this.validateHocChoose(m, result); + case "play": + return this.validatePlay(m, result); + case "refute": + return this.validateRefute(m, result); + } + } + + private ok(result: IValidationResult, complete: -1 | 0 | 1, message: string, canrender = false): IValidationResult { + result.valid = true; + result.complete = complete; + result.canrender = canrender; + result.message = message; + return result; + } + + private fail(result: IValidationResult, message: string): IValidationResult { + result.valid = false; + result.message = message; + return result; + } + + /** Checks a list of setup placements: known cells, empty, no duplicates. Returns an error result or undefined. */ + private checkCells(cells: string[], result: IValidationResult): IValidationResult | undefined { + const seen = new Set(); + for (const cell of cells) { + if (!this.isValidCell(cell)) { + return this.fail(result, i18next.t("apgames:validation._general.INVALIDCELL", { cell })); + } + if (this.board.has(cell)) { + return this.fail(result, i18next.t("apgames:validation._general.OCCUPIED", { where: cell })); + } + if (seen.has(cell)) { + return this.fail(result, i18next.t("apgames:validation.killallgo.DUPLICATE_CELL", { where: cell })); + } + seen.add(cell); + } + return undefined; + } + + /** Validation for "exactly `needed` stones" batches. */ + private checkBatch(cells: string[], needed: number, result: IValidationResult): IValidationResult { + const err = this.checkCells(cells, result); + if (err !== undefined) { return err; } + if (cells.length > needed) { + return this.fail(result, i18next.t("apgames:validation.killallgo.BATCH_TOO_MANY", { count: needed })); + } + if (cells.length < needed) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.STONES_LEFT", { count: needed - cells.length }), cells.length > 0); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private validateHandicap(m: string, result: IValidationResult): IValidationResult { + const max = this.maxHandicap(); + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HANDICAP", { max })); + } + if (!/^\d+$/.test(m)) { + return this.fail(result, i18next.t("apgames:validation.killallgo.HANDICAP_INVALID")); + } + const n = parseInt(m, 10); + if (n < 1 || n > max) { + return this.fail(result, i18next.t("apgames:validation.killallgo.HANDICAP_RANGE", { max })); + } + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.HANDICAP_OK", { count: n })); + } + + private validateAltPlace(m: string, result: IValidationResult): IValidationResult { + const owed = this.handicapOwed(); + if (m.length === 0) { + if (owed > 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_ALT_HANDICAP", { count: owed })); + } + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_ALT")); + } + if (m === "attacker" || m.startsWith("attacker:")) { + const cells = this.parseCells(m.substring("attacker:".length)); + if (owed === 0) { + if (cells.length > 0) { + return this.fail(result, i18next.t("apgames:validation.killallgo.ATTACKER_NO_STONES")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + return this.checkBatch(cells, owed, result); + } + if (m === "pass") { + return this.fail(result, i18next.t("apgames:validation.killallgo.INVALID_PASS")); + } + const err = this.checkCells([m], result); + if (err !== undefined) { return err; } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private validatePieSlice(m: string, result: IValidationResult): IValidationResult { + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_PIE_SLICE")); + } + if (m === "pass") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + const err = this.checkCells(this.parseCells(m), result); + if (err !== undefined) { return err; } + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.BATCH_MORE_OR_SUBMIT"), true); + } + + private validatePieChoose(m: string, result: IValidationResult): IValidationResult { + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_PIE_CHOOSE")); + } + if (m === "attacker") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m.startsWith("attacker:")) { + return this.fail(result, i18next.t("apgames:validation.killallgo.ATTACKER_NO_STONES")); + } + if (m === "defender") { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_PIE_DEFENDER_STONE")); + } + if (m.startsWith("defender:")) { + const cell = m.substring("defender:".length); + return this.validatePlacement(cell, BLUE, result); + } + return this.fail(result, i18next.t("apgames:validation._general.INVALID_MOVE", { move: m })); + } + + private validateSlice(m: string, result: IValidationResult): IValidationResult { + const max = this.points - 2; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE", { max })); + } + if (/^\d+,?$/.test(m)) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE", { max })); + } + const match = m.match(/^(\d+),(\d+)$/); + if (match === null) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_FORMAT")); + } + const a = parseInt(match[1], 10); + const b = parseInt(match[2], 10); + if (a < 1 || b < 1) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_MIN")); + } + if (a + b > max) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_SUM", { max })); + } + if (a > 2 * b || b > 2 * a) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_RATIO")); + } + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.SLICE_OK", { a, b })); + } + + private validateHocOption(m: string, result: IValidationResult): IValidationResult { + const a = this.setup?.a ?? 0; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_OPTION", { count: a })); + } + if (m === "youplace") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m === "iplace" || m.startsWith("iplace:")) { + return this.checkBatch(this.parseCells(m.substring("iplace:".length)), a, result); + } + return this.fail(result, i18next.t("apgames:validation._general.INVALID_MOVE", { move: m })); + } + + private validateHocBatch(m: string, result: IValidationResult): IValidationResult { + const needed = this.setup?.batchSize ?? 0; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_BATCH", { count: needed })); + } + if (m === "pass") { + return this.fail(result, i18next.t("apgames:validation.killallgo.INVALID_PASS")); + } + return this.checkBatch(this.parseCells(m), needed, result); + } + + private validateHocChoose(m: string, result: IValidationResult): IValidationResult { + const b = this.setup?.b ?? 0; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_CHOOSE", { count: b })); + } + if (m === "defender") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m.startsWith("defender:")) { + return this.fail(result, i18next.t("apgames:validation.killallgo.DEFENDER_NO_STONES")); + } + if (m === "attacker" || m.startsWith("attacker:")) { + return this.checkBatch(this.parseCells(m.substring("attacker:".length)), b, result); + } + return this.fail(result, i18next.t("apgames:validation._general.INVALID_MOVE", { move: m })); + } + + /** A single normal placement by `colour` on the current board (captures, suicide, superko). */ + private validatePlacement(cell: string, colour: Stone, result: IValidationResult): IValidationResult { + if (!this.isValidCell(cell)) { + return this.fail(result, i18next.t("apgames:validation._general.INVALIDCELL", { cell })); + } + if (this.board.has(cell)) { + return this.fail(result, i18next.t("apgames:validation._general.OCCUPIED", { where: cell })); + } + if (this.simulate(this.board, cell, colour, this.positions()) === undefined) { + return this.fail(result, i18next.t("apgames:validation.killallgo.KO_PSK")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private validatePlay(m: string, result: IValidationResult): IValidationResult { + const colour = this.colourOfSeat(this.currplayer)!; + if (m.length === 0) { + const key = colour === BLUE ? "INSTRUCTIONS_PLAY_DEFENDER" : "INSTRUCTIONS_PLAY_ATTACKER"; + return this.ok(result, -1, i18next.t(`apgames:validation.killallgo.${key}`)); + } + if (m === "pass") { + if (colour === BLUE) { + return this.ok(result, 1, i18next.t("apgames:validation.killallgo.PASS_WARNING")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m === "claim" || m.startsWith("claim:")) { + if (colour !== BLUE) { + return this.fail(result, i18next.t("apgames:validation.killallgo.CLAIM_ATTACKER")); + } + const [stone, marks] = this.parseClaim(m); + if (!this.isValidCell(stone)) { + return this.fail(result, i18next.t("apgames:validation._general.INVALIDCELL", { cell: stone })); + } + if (this.board.get(stone) !== BLUE) { + return this.fail(result, i18next.t("apgames:validation.killallgo.CLAIM_NOT_OWN_STONE", { where: stone })); + } + const seen = new Set(); + for (const mark of marks) { + if (!this.isValidCell(mark)) { + return this.fail(result, i18next.t("apgames:validation._general.INVALIDCELL", { cell: mark })); + } + if (this.board.has(mark)) { + return this.fail(result, i18next.t("apgames:validation.killallgo.CLAIM_MARK_OCCUPIED", { where: mark })); + } + if (seen.has(mark)) { + return this.fail(result, i18next.t("apgames:validation.killallgo.DUPLICATE_CELL", { where: mark })); + } + seen.add(mark); + } + const key = marks.length === 0 ? "INSTRUCTIONS_CLAIM_NO_MARKS" : "INSTRUCTIONS_CLAIM"; + return this.ok(result, 0, i18next.t(`apgames:validation.killallgo.${key}`), true); + } + return this.validatePlacement(m, colour, result); + } + + private validateRefute(m: string, result: IValidationResult): IValidationResult { + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_REFUTE")); + } + const claim = this.claim!; + const tokens = m.split(","); + const seen = this.positions(); + let board = new Map(this.board); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const last = i === tokens.length - 1; + if (token === "concede") { + if (!last) { + return this.fail(result, i18next.t("apgames:validation.killallgo.REFUTE_AFTER_END")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + if (token === "pass") { + return this.fail(result, i18next.t("apgames:validation.killallgo.INVALID_PASS")); + } + if (!this.isValidCell(token)) { + return this.fail(result, i18next.t("apgames:validation._general.INVALIDCELL", { cell: token })); + } + if (board.has(token)) { + return this.fail(result, i18next.t("apgames:validation._general.OCCUPIED", { where: token })); + } + const protectedPoint = claim.marks.includes(token); + if (protectedPoint && !last) { + return this.fail(result, i18next.t("apgames:validation.killallgo.PROTECTED_POINT", { where: token })); + } + const sim = this.simulate(board, token, RED, seen); + if (sim === undefined) { + return this.fail(result, i18next.t("apgames:validation.killallgo.KO_PSK")); + } + board = sim.board; + seen.add(sim.sig); + const terminal = protectedPoint || !board.has(claim.stone) || this.bluePassAlive(board).length > 0; + if (terminal) { + if (!last) { + return this.fail(result, i18next.t("apgames:validation.killallgo.REFUTE_AFTER_END")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + } + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_REFUTE_CONTINUE"), true); + } + + public move(m: string, { partial = false, trusted = false } = {}): KillAllGoGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (!trusted) { + const result = this.validateMove(m); + if (!result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message); + } + if (!partial && result.complete === -1) { + throw new UserFacingError("VALIDATION_GENERAL", i18next.t("apgames:validation._general.INCOMPLETE_MOVE")); + } + if (!partial && this.isEnumerable(m) && !this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", { move: m })); + } + } + if (m.length === 0) { return this; } + + this.results = []; + this.interim = []; + this.alive = undefined; + const seen = this.positions(); + + switch (this.phase) { + case "hand-n": { + const n = parseInt(m, 10); + this.setup = { ...(this.setup ?? {}), handicap: n }; + this.results.push({ type: "declare", count: n }); + if (partial) { return this; } + this.phase = "alt-place"; + this.currplayer = 2; + break; + } + case "alt-place": { + if (m === "attacker" || m.startsWith("attacker:")) { + const cells = this.parseCells(m.substring("attacker:".length)); + this.results.push({ type: "claim", how: "attacker" }); + this.placeSetupStones(cells, seen); + if (partial) { return this; } + this.redSeat = this.currplayer; + this.startPlay(); + } else { + this.placeSetupStones([m], seen); + if (partial) { return this; } + this.currplayer = this.otherSeat(this.currplayer); + } + break; + } + case "pie-slice": { + if (m === "pass") { + this.results.push({ type: "pass" }); + } else { + this.placeSetupStones(this.parseCells(m), seen); + } + if (partial) { return this; } + this.phase = "pie-choose"; + this.currplayer = 2; + break; + } + case "pie-choose": { + if (m === "attacker") { + this.results.push({ type: "claim", how: "attacker" }); + if (partial) { return this; } + this.redSeat = this.currplayer; + this.startPlay(); + } else { + const cell = m.substring("defender:".length); + this.results.push({ type: "claim", how: "defender" }); + this.redSeat = this.otherSeat(this.currplayer); + this.placeStone(cell, BLUE, seen); + if (partial) { return this; } + this.phase = "play"; + this.setup = undefined; + this.checkBlueLife(); + this.currplayer = this.redSeat; + } + break; + } + case "hoc-slice": { + const [a, b] = m.split(",").map((s) => parseInt(s, 10)); + this.setup = { a, b }; + this.results.push({ type: "announce", payload: [a, b] }); + if (partial) { return this; } + this.phase = "hoc-option"; + this.currplayer = 2; + break; + } + case "hoc-option": { + if (m === "youplace") { + this.results.push({ type: "select", what: "youplace" }); + if (partial) { return this; } + this.setup = { ...this.setup, batchSize: this.setup!.a }; + this.phase = "hoc-batch-a"; + this.currplayer = 1; + } else { + this.results.push({ type: "select", what: "iplace" }); + this.placeSetupStones(this.parseCells(m.substring("iplace:".length)), seen); + if (partial) { return this; } + this.phase = "hoc-choose"; + this.currplayer = 1; + } + break; + } + case "hoc-batch-a": { + this.placeSetupStones(this.parseCells(m), seen); + if (partial) { return this; } + this.phase = "hoc-choose"; + this.currplayer = 2; + break; + } + case "hoc-choose": { + if (m === "defender") { + this.results.push({ type: "claim", how: "defender" }); + if (partial) { return this; } + this.redSeat = this.otherSeat(this.currplayer); + this.setup = { ...this.setup, batchSize: this.setup!.b }; + this.phase = "hoc-batch-b"; + this.currplayer = this.redSeat; + } else { + this.results.push({ type: "claim", how: "attacker" }); + this.placeSetupStones(this.parseCells(m.substring("attacker:".length)), seen); + if (partial) { return this; } + this.redSeat = this.currplayer; + this.startPlay(); + } + break; + } + case "hoc-batch-b": { + this.placeSetupStones(this.parseCells(m), seen); + if (partial) { return this; } + this.startPlay(); + break; + } + case "play": { + if (m === "pass") { + this.results.push({ type: "pass" }); + if (partial) { return this; } + const previous = this.stack[this.stack.length - 1].lastmove; + if (previous === "pass") { + this.endGame(this.redSeat!, "double-pass"); + } + this.currplayer = this.otherSeat(this.currplayer); + } else if (m.startsWith("claim:")) { + const [stone, marks] = this.parseClaim(m); + const info = stringAt(this.board, this.geo, stone); + this.claim = { stone, stones: info.stones, marks }; + this.results.push({ type: "claim", how: "life", where: stone, what: marks.join(",") }); + if (partial) { return this; } + this.phase = "refute"; + this.currplayer = this.otherSeat(this.currplayer); + } else { + const colour = this.colourOfSeat(this.currplayer)!; + this.placeStone(m, colour, seen); + if (partial) { return this; } + this.checkBlueLife(); + this.currplayer = this.otherSeat(this.currplayer); + } + break; + } + case "refute": { + const claim = this.claim!; + let outcome: "captured" | "protected" | "concede" | "passalive" | undefined; + for (const token of m.split(",")) { + if (token === "concede") { + this.results.push({ type: "claim", how: "concede" }); + outcome = "concede"; + break; + } + this.placeStone(token, RED, seen); + if (!this.board.has(claim.stone)) { + outcome = "captured"; + break; + } + if (this.bluePassAlive().length > 0) { + outcome = "passalive"; + break; + } + if (claim.marks.includes(token)) { + outcome = "protected"; + break; + } + } + if (partial) { return this; } + if (outcome === "concede") { + this.endGame(this.otherSeat(this.currplayer), "claim-upheld", [...claim.stones]); + } else if (outcome === "captured") { + this.endGame(this.currplayer, "claim-refuted"); + } else if (outcome === "passalive") { + this.checkBlueLife(); + } else { + this.claim = undefined; + this.phase = "play"; + } + this.currplayer = this.otherSeat(this.currplayer); + break; + } + } + + // The final position of the ply is the saved board; keep only the intermediate ones. + this.interim.pop(); + this.lastmove = m; + this.saveState(); + return this; + } + + /** Opening stones for the Attacker (no captures are possible before the Defender has moved). */ + private placeSetupStones(cells: string[], seen: Set): void { + for (const cell of cells) { + applyPlacement(this.board, this.geo, cell, RED); + this.results.push({ type: "place", where: cell, what: "setup" }); + const sig = signature(this.board, this.geo); + seen.add(sig); + this.interim.push(sig); + } + } + + /** A normal placement with Tromp-Taylor clearing, recorded in results and the superko history. */ + private placeStone(cell: string, colour: Stone, seen: Set): void { + const outcome = applyPlacement(this.board, this.geo, cell, colour); + this.results.push({ type: "place", where: cell }); + for (const group of outcome.captured) { + this.results.push({ type: "capture", where: group.join(","), count: group.length }); + } + if (outcome.suicided.length > 0) { + this.results.push({ type: "capture", where: outcome.suicided.join(","), count: outcome.suicided.length, how: "suicide" }); + } + const sig = signature(this.board, this.geo); + seen.add(sig); + this.interim.push(sig); + } + + /** The opening protocol is over: normal play starts with the Defender to move. */ + private startPlay(): void { + this.phase = "play"; + this.setup = undefined; + this.currplayer = this.blueSeat()!; + } + + /** Ends the game for the Defender if any Blue string is pass-alive. */ + private checkBlueLife(): boolean { + const alive = this.bluePassAlive(); + if (alive.length === 0) { + return false; + } + this.endGame(this.blueSeat()!, "pass-alive", alive.flat()); + return true; + } + + private endGame(winner: playerid, reason: string, alive?: string[]): void { + this.gameover = true; + this.winner = [winner]; + this.alive = alive; + this.claim = undefined; + this.results.push( + { type: "eog", reason }, + { type: "winners", players: [winner] }, + ); + } + + public state(): IKillAllGoState { + return { + game: KillAllGoGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + const state: IMoveState = { + _version: KillAllGoGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + phase: this.phase, + interim: [...this.interim], + }; + if (this.redSeat !== undefined) { state.redSeat = this.redSeat; } + if (this.setup !== undefined) { state.setup = { ...this.setup }; } + if (this.claim !== undefined) { state.claim = { stone: this.claim.stone, stones: [...this.claim.stones], marks: [...this.claim.marks] }; } + if (this.alive !== undefined) { state.alive = [...this.alive]; } + return state; + } + + public getPlayerColour(p: playerid): number | string { + if (this.redSeat === undefined) { + return UNDECIDED_COLOUR; + } + return p === this.redSeat ? 1 : 2; + } + + private buttonBar(): AreaButtonBar | undefined { + if (this.gameover) { + return undefined; + } + const button = (key: string, value: string) => ({ + label: this.neutralAreaLabel(`apgames:validation.killallgo.${key}`), + value, + }); + let buttons: ReturnType[] = []; + switch (this.phase) { + case "alt-place": + buttons = [button("BTN_ATTACKER_TAKE", "attacker")]; + break; + case "pie-choose": + case "hoc-choose": + buttons = [button("BTN_ATTACKER", "attacker"), button("BTN_DEFENDER", "defender")]; + break; + case "hoc-option": + buttons = [button("BTN_IPLACE", "iplace"), button("BTN_YOUPLACE", "youplace")]; + break; + case "refute": + buttons = [button("BTN_CONCEDE", "concede")]; + break; + default: + return undefined; + } + return { + type: "buttonBar", + position: "left", + buttons: buttons as AreaButtonBar["buttons"], + }; + } + + public render(): APRenderRep { + let pstr = ""; + for (let row = 0; row < this.boardSize; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + const contents = this.board.get(cell); + if (contents === RED) { + pstr += "A"; + } else if (contents === BLUE) { + pstr += "B"; + } else { + pstr += "-"; + } + } + } + pstr = pstr.replace(new RegExp(`-{${this.boardSize}}`, "g"), "_"); + + const rep: APRenderRep = { + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: [{ name: "piece", colour: 1 }], + B: [{ name: "piece", colour: 2 }], + }, + pieces: pstr, + }; + + const toRowCol = (cell: string): RowCol => { + const [x, y] = this.algebraic2coords(cell); + return { row: y, col: x }; + }; + const annotations: NonNullable = []; + for (const r of this.results) { + if (r.type === "place") { + annotations.push({ type: "enter", targets: [toRowCol(r.where!)] }); + } else if (r.type === "capture") { + const targets = r.where!.split(",").map(toRowCol); + annotations.push({ type: "exit", targets: targets as [RowCol, ...RowCol[]] }); + } + } + if (this.claim !== undefined) { + annotations.push({ type: "enter", targets: this.claim.stones.map(toRowCol) as [RowCol, ...RowCol[]], colour: 2, style: "solid" }); + if (this.claim.marks.length > 0) { + annotations.push({ type: "dots", targets: this.claim.marks.map(toRowCol) as [RowCol, ...RowCol[]], colour: 1 }); + } + } + if (this.gameover && this.alive !== undefined && this.alive.length > 0) { + annotations.push({ type: "enter", targets: this.alive.map(toRowCol) as [RowCol, ...RowCol[]] }); + } + if (annotations.length > 0) { + rep.annotations = annotations; + } + + const bar = this.buttonBar(); + if (bar !== undefined) { + rep.areas = [bar]; + } + return rep; + } + + public sidebarStatuses(): IStatus[] { + const statuses: IStatus[] = []; + const undecided = this.neutralAreaLabel("apgames:status.killallgo.UNDECIDED"); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.ATTACKER"), + value: [this.redSeat === undefined ? undecided : this.seatStatusValue(this.redSeat)], + }); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.DEFENDER"), + value: [this.redSeat === undefined ? undecided : this.seatStatusValue(this.otherSeat(this.redSeat))], + }); + if (this.opening === "handicap" && this.setup?.handicap !== undefined) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.HANDICAP"), + value: [this.setup.handicap.toString()], + }); + } + if (this.opening === "hoctaph" && this.setup?.a !== undefined && this.setup.b !== undefined) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.BATCHES"), + value: [this.neutralAreaLabel("apgames:status.killallgo.BATCHES_VALUE", { a: this.setup.a, b: this.setup.b })], + }); + } + if (this.phase !== "play" && !this.gameover) { + const phaseKey = this.phase.replace(/-/g, "_").toUpperCase(); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.PHASE"), + value: [this.seatAreaLabel(this.currplayer, `apgames:status.killallgo.PHASE_${phaseKey}`)], + }); + } + if (this.claim !== undefined) { + const key = this.claim.marks.length === 0 ? "CLAIM_VALUE_NO_MARKS" : "CLAIM_VALUE"; + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.CLAIM"), + value: [this.neutralAreaLabel(`apgames:status.killallgo.${key}`, { where: this.claim.stone, marks: this.claim.marks.join(", ") })], + }); + } + return statuses; + } + + public collectChatLogLine(lines: ChatLogLine[], r: APMoveResult, ctx: ChatLogCollectContext): boolean { + switch (r.type) { + case "place": + if (r.what === "setup") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PLACE.killallgo_setup", { where: r.where! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PLACE.nowhat", { where: r.where! }); + } + return true; + case "capture": + if (r.how === "suicide") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CAPTURE.killallgo_suicide", { count: r.count! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CAPTURE.noperson.group_nowhere", { count: r.count! }); + } + return true; + case "claim": + if (r.how === "attacker") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_attacker"); + } else if (r.how === "defender") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_defender"); + } else if (r.how === "life") { + if (r.what === undefined || r.what.length === 0) { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_life_nomarks", { where: r.where! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_life", { where: r.where!, marks: r.what.split(",").join(", ") }); + } + } else if (r.how === "concede") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_concede"); + } else { + return super.collectChatLogLine(lines, r, ctx); + } + return true; + case "declare": + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:DECLARE.killallgo_handicap", { count: r.count! }); + return true; + case "announce": { + const [a, b] = r.payload as [number, number]; + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:ANNOUNCE.killallgo_slice", { a, b }); + return true; + } + case "select": + this.pushSeatChatLine(lines, ctx.defaultSeat, r.what === "iplace" ? "apresults:SELECT.killallgo_iplace" : "apresults:SELECT.killallgo_youplace"); + return true; + case "eog": + if (r.reason === "pass-alive") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_pass_alive"); + } else if (r.reason === "claim-upheld") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_claim_upheld"); + } else if (r.reason === "claim-refuted") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_claim_refuted"); + } else if (r.reason === "double-pass") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_double_pass"); + } else { + this.pushNeutralChatLine(lines, "apresults:EOG.default"); + } + return true; + default: + return super.collectChatLogLine(lines, r, ctx); + } + } + + public clone(): KillAllGoGame { + return new KillAllGoGame(this.serialize()); + } +} diff --git a/src/games/killallgo/benson.ts b/src/games/killallgo/benson.ts new file mode 100644 index 00000000..5c935563 --- /dev/null +++ b/src/games/killallgo/benson.ts @@ -0,0 +1,139 @@ +/** + * Benson's algorithm for unconditional life ("pass-alive" strings). + * + * A chain of the given colour is pass-alive when it belongs to a set X of chains such that every + * chain in X has at least two vital regions enclosed by X. A region is a maximal connected set + * of points not holding the colour; it is vital to a chain when every point of the region that + * could ever become empty is adjacent to the chain. + * + * With classic (no-suicide) rules only the empty points of a region need to touch the chain. + * When multi-stone suicide is legal (Tromp-Taylor), the opponent can clear its own stones out of a + * region and refill it leaving a hole that is not a liberty of the chain, so every point of the + * region, occupied or not, must touch the chain. `suicideAllowed` selects the strict test. + */ +import type { Board, Geometry, Stone } from "./board.js"; + +export interface PassAliveOptions { + suicideAllowed?: boolean; +} + +interface Region { + points: string[]; + adjacentChains: Set; + vitalTo: Set; +} + +/** Returns every pass-alive chain of `colour`, each as its list of stones. */ +export const passAliveStrings = (board: Board, geo: Geometry, colour: Stone, opts: PassAliveOptions = {}): string[][] => { + const suicideAllowed = opts.suicideAllowed ?? true; + + // Chains of `colour`. + const chainOf = new Map(); + const chains: string[][] = []; + for (const cell of geo.cells) { + if (board.get(cell) !== colour || chainOf.has(cell)) { + continue; + } + const id = chains.length; + const stones: string[] = []; + const todo = [cell]; + chainOf.set(cell, id); + while (todo.length > 0) { + const cur = todo.pop()!; + stones.push(cur); + for (const n of geo.neighbours.get(cur)!) { + if (board.get(n) === colour && !chainOf.has(n)) { + chainOf.set(n, id); + todo.push(n); + } + } + } + chains.push(stones); + } + if (chains.length === 0) { + return []; + } + + // Regions: maximal connected sets of points not holding `colour`. + const regionOf = new Map(); + const regions: Region[] = []; + for (const cell of geo.cells) { + if (board.get(cell) === colour || regionOf.has(cell)) { + continue; + } + const id = regions.length; + const points: string[] = []; + const todo = [cell]; + regionOf.set(cell, id); + while (todo.length > 0) { + const cur = todo.pop()!; + points.push(cur); + for (const n of geo.neighbours.get(cur)!) { + if (board.get(n) !== colour && !regionOf.has(n)) { + regionOf.set(n, id); + todo.push(n); + } + } + } + regions.push({ points, adjacentChains: new Set(), vitalTo: new Set() }); + } + + // Which chains touch each region, and to which chains the region is vital. + for (const region of regions) { + const touches = new Map(); + let mustTouch = 0; + for (const p of region.points) { + const counts = suicideAllowed || !board.has(p); + if (counts) { + mustTouch++; + } + const seenHere = new Set(); + for (const n of geo.neighbours.get(p)!) { + const chain = chainOf.get(n); + if (chain === undefined || seenHere.has(chain)) { + continue; + } + seenHere.add(chain); + region.adjacentChains.add(chain); + if (counts) { + touches.set(chain, (touches.get(chain) ?? 0) + 1); + } + } + } + for (const [chain, n] of touches) { + if (n === mustTouch) { + region.vitalTo.add(chain); + } + } + } + + // Benson iteration. + const X = new Set(chains.map((_, i) => i)); + const R = new Set(regions.map((_, i) => i)); + let changed = true; + while (changed) { + changed = false; + for (const chain of [...X]) { + let vital = 0; + for (const rid of R) { + if (regions[rid].vitalTo.has(chain)) { + vital++; + } + } + if (vital < 2) { + X.delete(chain); + changed = true; + } + } + for (const rid of [...R]) { + for (const chain of regions[rid].adjacentChains) { + if (!X.has(chain)) { + R.delete(rid); + changed = true; + break; + } + } + } + } + return chains.filter((_, i) => X.has(i)); +}; diff --git a/src/games/killallgo/board.ts b/src/games/killallgo/board.ts new file mode 100644 index 00000000..114f9f76 --- /dev/null +++ b/src/games/killallgo/board.ts @@ -0,0 +1,156 @@ +/** + * Pure board helpers for Kill-All Go. + * + * Colours: 1 = Red (the Attacker, trying to kill everything), 2 = Blue (the Defender, trying to + * make one unconditionally alive string). These are board colours, not seats; which seat plays + * Red is decided by the opening protocol and lives in the game state. + */ +import { algebraic2coords, coords2algebraic } from "../../common/index.js"; + +export type Stone = 1 | 2; +export const RED: Stone = 1; +export const BLUE: Stone = 2; + +export type Board = Map; + +export interface Geometry { + size: number; + /** All cells, row by row from the top of the board. */ + cells: string[]; + neighbours: Map; + coords2algebraic: (x: number, y: number) => string; + algebraic2coords: (cell: string) => [number, number]; +} + +export interface StringInfo { + stones: string[]; + liberties: Set; +} + +export interface PlacementOutcome { + /** Each captured opponent string, in the order they were removed. */ + captured: string[][]; + /** The mover's own stones removed by suicide (Tromp-Taylor clearing), if any. */ + suicided: string[]; +} + +const geometryCache = new Map(); + +export const makeGeometry = (size: number): Geometry => { + const cached = geometryCache.get(size); + if (cached !== undefined) { + return cached; + } + const c2a = (x: number, y: number): string => coords2algebraic(x, y, size); + const a2c = (cell: string): [number, number] => algebraic2coords(cell, size); + const cells: string[] = []; + const neighbours = new Map(); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const cell = c2a(x, y); + cells.push(cell); + const adj: string[] = []; + if (x > 0) { adj.push(c2a(x - 1, y)); } + if (x < size - 1) { adj.push(c2a(x + 1, y)); } + if (y > 0) { adj.push(c2a(x, y - 1)); } + if (y < size - 1) { adj.push(c2a(x, y + 1)); } + neighbours.set(cell, adj); + } + } + const geo: Geometry = { size, cells, neighbours, coords2algebraic: c2a, algebraic2coords: a2c }; + geometryCache.set(size, geo); + return geo; +}; + +export const otherColour = (colour: Stone): Stone => (colour === RED ? BLUE : RED); + +/** The string containing `cell` (which must hold a stone) and its liberties. */ +export const stringAt = (board: Board, geo: Geometry, cell: string): StringInfo => { + const colour = board.get(cell); + if (colour === undefined) { + throw new Error(`No stone at ${cell}.`); + } + const stones: string[] = []; + const liberties = new Set(); + const seen = new Set([cell]); + const todo = [cell]; + while (todo.length > 0) { + const cur = todo.pop()!; + stones.push(cur); + for (const n of geo.neighbours.get(cur)!) { + const occupant = board.get(n); + if (occupant === undefined) { + liberties.add(n); + } else if (occupant === colour && !seen.has(n)) { + seen.add(n); + todo.push(n); + } + } + } + return { stones, liberties }; +}; + +/** Every string of the given colour, each listed once. */ +export const stringsOf = (board: Board, geo: Geometry, colour: Stone): StringInfo[] => { + const seen = new Set(); + const result: StringInfo[] = []; + for (const cell of geo.cells) { + if (board.get(cell) !== colour || seen.has(cell)) { + continue; + } + const info = stringAt(board, geo, cell); + for (const s of info.stones) { + seen.add(s); + } + result.push(info); + } + return result; +}; + +/** + * Place a stone and apply Tromp-Taylor clearing: opponent strings left without liberties are + * removed first, then the mover's own string if it has none (multi-stone suicide is legal; + * a single-stone suicide is left to the superko check, which always rejects it). + * Mutates `board`. + */ +export const applyPlacement = (board: Board, geo: Geometry, cell: string, colour: Stone): PlacementOutcome => { + if (board.has(cell)) { + throw new Error(`Cell ${cell} is occupied.`); + } + board.set(cell, colour); + const opp = otherColour(colour); + const captured: string[][] = []; + const removed = new Set(); + for (const n of geo.neighbours.get(cell)!) { + if (board.get(n) !== opp || removed.has(n)) { + continue; + } + const info = stringAt(board, geo, n); + if (info.liberties.size === 0) { + for (const s of info.stones) { + board.delete(s); + removed.add(s); + } + captured.push(info.stones); + } + } + let suicided: string[] = []; + const own = stringAt(board, geo, cell); + if (own.liberties.size === 0) { + for (const s of own.stones) { + board.delete(s); + } + suicided = own.stones; + } + return { captured, suicided }; +}; + +/** Canonical string for a board position (used for positional superko). */ +export const signature = (board: Board, geo: Geometry): string => { + let sig = ""; + for (const cell of geo.cells) { + const s = board.get(cell); + sig += s === undefined ? "-" : s === RED ? "r" : "b"; + } + return sig; +}; diff --git a/test/games/killallgo-benson.test.ts b/test/games/killallgo-benson.test.ts new file mode 100644 index 00000000..68821151 --- /dev/null +++ b/test/games/killallgo-benson.test.ts @@ -0,0 +1,245 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import "mocha"; +import { expect } from "chai"; +import { applyPlacement, BLUE, makeGeometry, RED, signature, stringAt, type Board } from "../../src/games/killallgo/board"; +import { passAliveStrings } from "../../src/games/killallgo/benson"; + +// `X` = Blue (the colour under test), `O` = Red, `.` = empty; the first row is the top of the board. +const boardFrom = (rows: string[]): Board => { + const size = rows.length; + const geo = makeGeometry(size); + const board: Board = new Map(); + rows.forEach((row, y) => { + if (row.length !== size) { + throw new Error("Fixtures must be square."); + } + row.split("").forEach((ch, x) => { + if (ch === "X") { + board.set(geo.coords2algebraic(x, y), BLUE); + } else if (ch === "O") { + board.set(geo.coords2algebraic(x, y), RED); + } + }); + }); + return board; +}; + +interface Fixture { + name: string; + rows: string[]; + classic: boolean; + strict: boolean; +} + +const fixtures: Fixture[] = [ + { + name: "two one-point eyes on the edge", + rows: [ + "X.X.X..", + "XXXXX..", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "a single eye", + rows: [ + "X.XX...", + "XXXX...", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a two-point eye plus a one-point eye", + rows: [ + "X..XX.X", + "XXXXXXX", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "two groups with one eye each", + rows: [ + "X.X.O.O", + "XXXXOOO", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a 3x3 empty eye plus a one-point eye", + rows: [ + "XXXXXXX", + "X...X.X", + "X...XXX", + "X...X..", + "XXXXX..", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a 3x3 eye with an opponent stone in the centre plus a one-point eye", + rows: [ + "XXXXXXX", + "X...X.X", + "X.O.XXX", + "X...X..", + "XXXXX..", + ".......", + ".......", + ], + classic: true, strict: false, + }, + { + name: "two shared liberties and no eyes", + rows: [ + "XX.O...", + "XXXO...", + "OOOO...", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "two chains sharing two eyes", + rows: [ + "XX.XX..", + "X.X.X..", + "XX.XX..", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "a false eye at a cutting point", + rows: [ + "X.XO...", + "XX.O...", + "OOOO...", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "an eye containing an opponent stone on a liberty point", + rows: [ + "XXXX...", + "XO.X...", + "XXXX...", + "X..X...", + "XXXX...", + ".......", + ".......", + ], + classic: true, strict: true, + }, +]; + +describe("Kill-All Go: Benson pass-alive detection", () => { + for (const f of fixtures) { + it(f.name, () => { + const board = boardFrom(f.rows); + const geo = makeGeometry(f.rows.length); + const classic = passAliveStrings(board, geo, BLUE, { suicideAllowed: false }); + const strict = passAliveStrings(board, geo, BLUE, { suicideAllowed: true }); + expect(classic.length > 0, "classic").to.equal(f.classic); + expect(strict.length > 0, "strict").to.equal(f.strict); + }); + } + + it("returns every stone of the alive chains and nothing else", () => { + // Three chains: the two eye-shaped ones and the single stone between the eyes. + const board = boardFrom(fixtures[7].rows); + const geo = makeGeometry(7); + const alive = passAliveStrings(board, geo, BLUE); + expect(alive).to.have.length(3); + const stones = alive.flat().sort(); + const expected = [...board.entries()].filter(([, c]) => c === BLUE).map(([cell]) => cell).sort(); + expect(stones).to.deep.equal(expected); + }); + + it("never reports the opponent's chains", () => { + const board = boardFrom(fixtures[0].rows); + const geo = makeGeometry(7); + expect(passAliveStrings(board, geo, RED)).to.deep.equal([]); + }); +}); + +describe("Kill-All Go: board mechanics", () => { + it("captures an opponent string that loses its last liberty", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["c3", BLUE], ["b3", RED], ["d3", RED], ["c2", RED]]); + const outcome = applyPlacement(board, geo, "c4", RED); + expect(outcome.captured).to.deep.equal([["c3"]]); + expect(outcome.suicided).to.deep.equal([]); + expect(board.has("c3")).to.be.false; + expect(board.get("c4")).to.equal(RED); + }); + + it("removes the mover's own string on a multi-stone suicide (Tromp-Taylor clearing)", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", RED]]); + const outcome = applyPlacement(board, geo, "b1", BLUE); + expect(outcome.captured).to.deep.equal([]); + expect(outcome.suicided.sort()).to.deep.equal(["a1", "b1"]); + expect(board.has("a1")).to.be.false; + expect(board.has("b1")).to.be.false; + }); + + it("captures before checking the mover's liberties", () => { + const geo = makeGeometry(5); + // Blue at a1 is in atari on b1; Red at a2 and b2 protect it from the other side. + const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", BLUE]]); + // Red plays b1: it captures a1 first and therefore keeps a liberty. + const outcome = applyPlacement(board, geo, "b1", RED); + expect(outcome.captured).to.deep.equal([["a1"]]); + expect(outcome.suicided).to.deep.equal([]); + expect(board.get("b1")).to.equal(RED); + }); + + it("finds strings and liberties", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["a1", BLUE], ["b1", BLUE], ["b2", RED]]); + const info = stringAt(board, geo, "a1"); + expect(info.stones.sort()).to.deep.equal(["a1", "b1"]); + expect([...info.liberties].sort()).to.deep.equal(["a2", "c1"]); + }); + + it("signatures differ between positions", () => { + const geo = makeGeometry(5); + const a: Board = new Map([["a1", BLUE]]); + const b: Board = new Map([["a1", RED]]); + expect(signature(a, geo)).to.not.equal(signature(b, geo)); + expect(signature(a, geo)).to.equal(signature(new Map(a), geo)); + }); +}); diff --git a/test/games/killallgo.test.ts b/test/games/killallgo.test.ts new file mode 100644 index 00000000..70862ab8 --- /dev/null +++ b/test/games/killallgo.test.ts @@ -0,0 +1,481 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import "mocha"; +import { expect } from "chai"; +import { KillAllGoGame } from "../../src/games"; +import { GameFactory } from "../../src/games"; +import { addResource } from "../../src"; +import i18next from "i18next"; + +const RED = 1; +const BLUE = 2; + +const play = (g: KillAllGoGame, moves: string[]): KillAllGoGame => { + for (const m of moves) { + g.move(m); + } + return g; +}; + +/** Player 1 takes the Attacker stones at once: Player 2 is the Defender and moves first on an empty board. */ +const attackerIsPlayerOne = (variants: string[] = ["size-9"]): KillAllGoGame => { + const g = new KillAllGoGame(undefined, variants); + g.move("attacker"); + return g; +}; + +describe("Kill-All Go", () => { + before(() => { addResource("en"); }); + after(() => { + i18next.removeResourceBundle("en", "apgames"); + i18next.removeResourceBundle("en", "apresults"); + }); + + describe("classic opening", () => { + it("sets up the 17 traditional stones with the Defender (Player 1) to move", () => { + const g = new KillAllGoGame(undefined, ["classic"]); + const expected = ["j18", "c17", "q17", "d16", "j16", "p16", "b10", "d10", "j10", "p10", "r10", "d4", "j4", "p4", "c3", "q3", "j2"]; + expect(g.board.size).to.equal(17); + for (const cell of expected) { + expect(g.board.get(cell)).to.equal(RED); + } + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.redSeat).to.equal(2); + expect(g.getPlayerColour(1)).to.equal(2); + expect(g.getPlayerColour(2)).to.equal(1); + const moves = g.moves(); + expect(moves).to.include("pass"); + expect(moves.filter((m) => m !== "pass")).to.have.length(361 - 17); + }); + + it("is only available on the 19x19 board", () => { + const g = new KillAllGoGame(undefined, ["size-9", "classic"]); + expect(g.variants).to.deep.equal(["size-9"]); + expect(g.phase).to.equal("alt-place"); + expect(g.board.size).to.equal(0); + }); + }); + + describe("alternating placement (default opening)", () => { + it("keeps the colours undecided until someone takes the Attacker side", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + expect(g.phase).to.equal("alt-place"); + expect(g.getPlayerColour(1)).to.equal("#999999"); + expect(g.getPlayerColour(2)).to.equal("#999999"); + g.move("d4"); + expect(g.board.get("d4")).to.equal(RED); + expect(g.currplayer).to.equal(2); + expect(g.stack[g.stack.length - 1]._results[0]).to.deep.include({ type: "place", where: "d4", what: "setup" }); + g.move("f6"); + expect(g.currplayer).to.equal(1); + expect(g.moves()).to.include("attacker"); + expect(g.moves()).to.not.include("d4"); + }); + + it("hands the first move to the other player as the Defender", () => { + const g = play(new KillAllGoGame(undefined, ["size-9"]), ["d4", "attacker"]); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.getPlayerColour(1)).to.equal(2); + expect(g.getPlayerColour(2)).to.equal(1); + g.move("e5"); + expect(g.board.get("e5")).to.equal(BLUE); + expect(g.currplayer).to.equal(2); + g.move("e4"); + expect(g.board.get("e4")).to.equal(RED); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2]); + }); + + it("lets Player 1 take the Attacker side at once", () => { + const g = attackerIsPlayerOne(); + expect(g.redSeat).to.equal(1); + expect(g.currplayer).to.equal(2); + expect(g.phase).to.equal("play"); + }); + + it("does not allow passing or stones with the claim", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + expect(g.validateMove("pass").valid).to.be.false; + expect(g.validateMove("attacker:a1").valid).to.be.false; + expect(() => g.move("pass")).to.throw(); + }); + + it("offers the take-the-Attacker button and translates board clicks", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + const rep = g.render(); + expect(rep.areas).to.have.length(1); + expect(rep.areas![0].type).to.equal("buttonBar"); + const click = g.handleClick("", 0, 0, "_btn_attacker"); + expect(click.valid).to.be.true; + expect(click.move).to.equal("attacker"); + const cell = g.handleClick("", 8, 0); + expect(cell.valid).to.be.true; + expect(cell.move).to.equal("a1"); + }); + }); + + describe("handicap opening", () => { + it("makes Player 1 set the handicap within [1, floor(p/2)]", () => { + const g = new KillAllGoGame(undefined, ["size-9", "handicap"]); + expect(g.phase).to.equal("hand-n"); + expect(g.validateMove("0").valid).to.be.false; + expect(g.validateMove("41").valid).to.be.false; + expect(g.validateMove("abc").valid).to.be.false; + expect(g.validateMove("40").valid).to.be.true; + expect(g.validateMove("1").valid).to.be.true; + expect(g.moves()).to.have.length(40); + expect(g.handleClick("", 0, 0).valid).to.be.false; + g.move("3"); + expect(g.setup?.handicap).to.equal(3); + expect(g.phase).to.equal("alt-place"); + expect(g.currplayer).to.equal(2); + }); + + it("makes Player 2 place the handicap stones when they take the Attacker side", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["3"]); + expect(g.moves()).to.not.include("attacker"); + expect(g.validateMove("attacker").complete).to.equal(-1); + expect(g.validateMove("attacker:a1,b1").complete).to.equal(-1); + expect(g.validateMove("attacker:a1,b1,c1,d1").valid).to.be.false; + expect(g.validateMove("attacker:a1,a1,c1").valid).to.be.false; + expect(() => g.move("attacker:a1,b1")).to.throw(); + g.move("attacker:a1,b1,c1"); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.board.size).to.equal(3); + }); + + it("lets Player 1 take the Attacker side without extra stones", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["3", "e5", "attacker"]); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(2); + expect(g.board.size).to.equal(1); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1]); + }); + }); + + describe("simple pie", () => { + it("lets Player 1 place any number of stones, then Player 2 take the Attacker side", () => { + const g = new KillAllGoGame(undefined, ["size-9", "pie"]); + expect(g.phase).to.equal("pie-slice"); + expect(g.validateMove("c3,g7").complete).to.equal(0); + g.move("c3,g7"); + expect(g.board.size).to.equal(2); + expect(g.phase).to.equal("pie-choose"); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.include("attacker"); + expect(g.moves()).to.include("defender:e5"); + g.move("attacker"); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + }); + + it("lets Player 2 choose the Defender side by placing the first stone", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), ["pass"]); + expect(g.board.size).to.equal(0); + expect(g.phase).to.equal("pie-choose"); + expect(g.validateMove("defender").complete).to.equal(-1); + const click = g.handleClick("defender", 4, 4); + expect(click.move).to.equal("defender:e5"); + g.move("defender:e5"); + expect(g.redSeat).to.equal(1); + expect(g.board.get("e5")).to.equal(BLUE); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2]); + }); + }); + + describe("generalized Hoctaph's pie", () => { + it("validates the batch sizes", () => { + const g = new KillAllGoGame(undefined, ["size-9", "hoctaph"]); + expect(g.phase).to.equal("hoc-slice"); + expect(g.validateMove("0,1").valid).to.be.false; + expect(g.validateMove("1,3").valid).to.be.false; + expect(g.validateMove("40,40").valid).to.be.false; + expect(g.validateMove("39,40").valid).to.be.true; + expect(g.validateMove("2,3").valid).to.be.true; + expect(g.validateMove("2,").valid).to.be.true; + expect(g.validateMove("2,").complete).to.equal(-1); + g.move("2,3"); + expect(g.setup).to.deep.equal({ a: 2, b: 3 }); + expect(g.phase).to.equal("hoc-option"); + expect(g.currplayer).to.equal(2); + }); + + it("option 2: the Slicer places the first batch and the Chooser picks the Defender side", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "youplace"]); + expect(g.phase).to.equal("hoc-batch-a"); + expect(g.currplayer).to.equal(1); + expect(g.validateMove("a1").complete).to.equal(-1); + expect(g.validateMove("a1,b1,c1").valid).to.be.false; + g.move("a1,b1"); + expect(g.phase).to.equal("hoc-choose"); + expect(g.currplayer).to.equal(2); + g.move("defender"); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("hoc-batch-b"); + expect(g.currplayer).to.equal(1); + g.move("c1,d1,e1"); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(2); + expect(g.board.size).to.equal(5); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2, 1]); + }); + + it("option 1: the Chooser places the first batch and the Slicer takes the Attacker side", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1"]); + expect(g.phase).to.equal("hoc-choose"); + expect(g.currplayer).to.equal(1); + expect(g.validateMove("attacker").complete).to.equal(-1); + g.move("attacker:c1,d1,e1"); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(2); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1]); + }); + + it("option 1: the Slicer picks the Defender side and the Chooser places the second batch", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1", "defender"]); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("hoc-batch-b"); + expect(g.currplayer).to.equal(2); + g.move("c1,d1,e1"); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2]); + }); + + it("builds batches by clicking", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3"]); + const first = g.handleClick("", 8, 0); + expect(first.move).to.equal("iplace:a1"); + const second = g.handleClick(first.move, 8, 1); + expect(second.move).to.equal("iplace:a1,b1"); + expect(second.complete).to.equal(1); + const undo = g.handleClick(second.move, 8, 1); + expect(undo.move).to.equal("iplace:a1"); + }); + }); + + describe("play", () => { + it("ends at once when a Defender string becomes pass-alive", () => { + const g = attackerIsPlayerOne(); + play(g, ["a2", "pass", "b2", "pass", "c2", "pass", "d2", "pass", "d1", "pass"]); + expect(g.gameover).to.be.false; + g.move("b1"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect(g.alive!.sort()).to.deep.equal(["a2", "b1", "b2", "c2", "d1", "d2"]); + const results = g.stack[g.stack.length - 1]._results; + expect(results).to.deep.include({ type: "eog", reason: "pass-alive" }); + const rep = g.render(); + const enters = rep.annotations!.filter((a) => a.type === "enter"); + expect(enters.some((a) => (a as { targets: unknown[] }).targets.length === 6)).to.be.true; + }); + + it("ends with an Attacker win after two consecutive passes", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a9", "pass"]); + expect(g.gameover).to.be.false; + expect(g.validateMove("pass").message).to.not.equal(""); + g.move("pass"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + expect(g.stack[g.stack.length - 1]._results).to.deep.include({ type: "eog", reason: "double-pass" }); + }); + + it("does not treat a pass followed by a stone as consecutive passes", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "pass", "e6", "pass"]); + expect(g.gameover).to.be.false; + expect(g.currplayer).to.equal(2); + }); + + it("forbids recreating an earlier position (positional superko)", () => { + const g = attackerIsPlayerOne(); + // Blue: c5, b4, c3 around c4; Red: d5, e4, d3 around d4; Red throws in at c4, Blue captures with d4. + play(g, ["c5", "d5", "b4", "e4", "c3", "d3", "a1", "c4", "d4"]); + expect(g.board.has("c4")).to.be.false; + expect(g.board.get("d4")).to.equal(BLUE); + expect(g.currplayer).to.equal(1); + expect(g.validateMove("c4").valid).to.be.false; + expect(g.moves()).to.not.include("c4"); + play(g, ["a9", "b9"]); + expect(g.validateMove("c4").valid).to.be.true; + g.move("c4"); + expect(g.board.has("d4")).to.be.false; + }); + + it("applies a multi-stone suicide (Tromp-Taylor clearing)", () => { + const g = attackerIsPlayerOne(); + // Red walls off a1 and b1 with a2, b2, c1. Blue plays a1 (one liberty at b1), then b1: both stones are removed. + play(g, ["e5", "a2", "e6", "b2", "e7", "c1", "a1", "f5"]); + expect(g.validateMove("b1").valid).to.be.true; + g.move("b1"); + expect(g.board.has("a1")).to.be.false; + expect(g.board.has("b1")).to.be.false; + const results = g.stack[g.stack.length - 1]._results; + expect(results).to.deep.include({ type: "capture", where: "b1,a1", count: 2, how: "suicide" }); + expect(g.currplayer).to.equal(1); + }); + + it("rejects a single-stone suicide because the position would repeat", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a2", "e6", "b1"]); + expect(g.validateMove("a1").valid).to.be.false; + expect(g.moves()).to.not.include("a1"); + expect(g.moves()).to.include("c1"); + }); + + it("reports its statuses with structured labels", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + const statuses = g.sidebarStatuses(); + expect(statuses[0].key).to.deep.include({ textKey: "apgames:status.killallgo.ATTACKER" }); + expect(statuses[0].value[0]).to.deep.include({ textKey: "apgames:status.killallgo.UNDECIDED" }); + g.move("attacker"); + const after = g.sidebarStatuses(); + expect(after[0].value[0]).to.deep.include({ textKey: "apgames:status._player", actor: { kind: "seat", seat: 1 } }); + expect(after[1].value[0]).to.deep.include({ actor: { kind: "seat", seat: 2 } }); + }); + }); + + describe("life claims", () => { + const withClaim = (marks: string): KillAllGoGame => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a9", "e6", "b9"]); + g.move(marks.length === 0 ? "claim:e5" : `claim:e5:${marks}`); + return g; + }; + + it("lets only the Defender claim one of their own strings", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a9"]); + expect(g.validateMove("claim:e5").valid).to.be.true; + expect(g.validateMove("claim:a9").valid).to.be.false; + expect(g.validateMove("claim:e5:a9").valid).to.be.false; + expect(g.validateMove("claim:e5:e4,e4").valid).to.be.false; + expect(g.validateMove("claim:e5:e4").complete).to.equal(0); + g.move("e6"); + expect(g.validateMove("claim:e5").valid).to.be.false; + }); + + it("is built by clicking a stone and then the protected points", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a9"]); + const start = g.handleClick("", 4, 4); + expect(start.move).to.equal("claim:e5"); + const mark = g.handleClick(start.move, 5, 4); + expect(mark.move).to.equal("claim:e5:e4"); + const unmark = g.handleClick(mark.move, 5, 4); + expect(unmark.move).to.equal("claim:e5"); + const cancel = g.handleClick(mark.move, 4, 4); + expect(cancel.move).to.equal(""); + }); + + it("records the claim and hands the refutation to the Attacker", () => { + const g = withClaim("e4,f5"); + expect(g.phase).to.equal("refute"); + expect(g.currplayer).to.equal(1); + expect(g.claim).to.deep.equal({ stone: "e5", stones: ["e5", "e6"], marks: ["e4", "f5"] }); + expect(g.stack[g.stack.length - 1]._results[0]).to.deep.include({ type: "claim", how: "life", where: "e5", what: "e4,f5" }); + expect(g.getButtons()).to.deep.equal([]); + const rep = g.render(); + expect(rep.areas![0].type).to.equal("buttonBar"); + expect(rep.annotations!.some((a) => a.type === "dots")).to.be.true; + const resumed = GameFactory("killallgo", g.serialize()) as KillAllGoGame; + expect(resumed.phase).to.equal("refute"); + expect(resumed.claim).to.deep.equal(g.claim); + }); + + it("is refuted when the claimed string is captured", () => { + const g = withClaim(""); + expect(g.validateMove("e4,d5,f5,d6,f6").complete).to.equal(-1); + expect(g.validateMove("e4,d5,f5,d6,f6,e7").complete).to.equal(1); + expect(g.validateMove("e4,d5,f5,d6,f6,e7,a1").valid).to.be.false; + g.move("e4,d5,f5,d6,f6,e7"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + expect(g.board.has("e5")).to.be.false; + expect(g.stack[g.stack.length - 1]._results).to.deep.include({ type: "eog", reason: "claim-refuted" }); + expect(g.stack[g.stack.length - 1].interim).to.have.length(5); + }); + + it("is upheld when the Attacker concedes", () => { + const g = withClaim("e4"); + expect(g.moves()).to.include("concede"); + expect(g.moves()).to.include("e4"); + expect(g.moves()).to.not.include("d5"); + const click = g.handleClick("d5", 0, 0, "_btn_concede"); + expect(click.move).to.equal("d5,concede"); + g.move("d5,concede"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect(g.alive!.sort()).to.deep.equal(["e5", "e6"]); + expect(g.board.get("d5")).to.equal(RED); + }); + + it("continues normal play after the Attacker ends on a protected point", () => { + const g = withClaim("e4"); + expect(g.validateMove("e4,d5").valid).to.be.false; + expect(g.validateMove("d5").complete).to.equal(-1); + g.move("d5,f5,e4"); + expect(g.gameover).to.be.false; + expect(g.phase).to.equal("play"); + expect(g.claim).to.be.undefined; + expect(g.currplayer).to.equal(2); + expect(g.board.get("d5")).to.equal(RED); + expect(g.board.get("f5")).to.equal(RED); + expect(g.board.get("e4")).to.equal(RED); + expect(g.stack[g.stack.length - 1].interim).to.have.length(2); + expect(g.validateMove("claim:e5:d6").valid).to.be.true; + }); + + it("cannot be claimed by the Attacker and cannot include passes in a refutation", () => { + const g = withClaim("e4"); + expect(g.validateMove("pass").valid).to.be.false; + expect(g.validateMove("d5,pass").valid).to.be.false; + }); + }); + + describe("records and chat", () => { + it("writes structured chat lines for the protocol actions", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1", "attacker:c1,d1,e1", "e5", "a9", "claim:e5:e4", "d5,e4"]); + const entries = g.chatLogEntries(["Alice", "Bob"]); + const keys = entries.flatMap((e) => e.lines.map((l) => l.textKey)); + expect(keys).to.include("apresults:ANNOUNCE.killallgo_slice"); + expect(keys).to.include("apresults:SELECT.killallgo_iplace"); + expect(keys).to.include("apresults:CLAIM.killallgo_attacker"); + expect(keys).to.include("apresults:PLACE.killallgo_setup"); + expect(keys).to.include("apresults:CLAIM.killallgo_life"); + const text = g.chatLog(["Alice", "Bob"]).flat().join("\n"); + expect(text).to.include("Alice chose to play as the Attacker."); + expect(text).to.include("Bob claimed that the string at e5 is alive unless the Attacker plays at e4."); + }); + + it("round-trips through serialization in every phase", () => { + const games = [ + new KillAllGoGame(undefined, ["size-9"]), + play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["2"]), + play(new KillAllGoGame(undefined, ["size-9", "pie"]), ["c3"]), + play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "youplace"]), + play(attackerIsPlayerOne(), ["e5", "a9", "claim:e5:e4"]), + ]; + for (const g of games) { + const copy = new KillAllGoGame(g.serialize()); + expect(copy.phase).to.equal(g.phase); + expect(copy.currplayer).to.equal(g.currplayer); + expect(copy.redSeat).to.equal(g.redSeat); + expect(copy.setup).to.deep.equal(g.setup); + expect(copy.claim).to.deep.equal(g.claim); + expect([...copy.board.entries()]).to.deep.equal([...g.board.entries()]); + expect(copy.moves()).to.deep.equal(g.moves()); + } + }); + }); +}); From 352f2b09963abee6f326f924d20c08c0e5bb4cf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:11:00 +0000 Subject: [PATCH 04/16] Kill-All Go: Esperanto strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Esperanto name, description, notes, variant labels, status and validation strings and result messages for Kill-All Go. The title "Ĉiomortiga Goo" is a proposal pending acceptance. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- locales/eo/apgames.json | 111 +++++++++++++++++++++++++++++++++++++- locales/eo/apresults.json | 21 +++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 98d5acaa..6f9ca49f 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -3563,6 +3563,37 @@ "name": "Duluda matĉo" } }, + "killallgo": { + "size-9": { + "name": "Tabulo 9×9" + }, + "size-13": { + "name": "Tabulo 13×13" + }, + "#board": { + "name": "Tabulo 19×19" + }, + "#opening": { + "name": "Alterna metado", + "description": "La ludantoj laŭvice metas atakantajn ŝtonojn, ĝis unu el ili prenas la flankon de la atakanto; la alia ludanto tiam movas unue kiel la defendanto. Komunuma protokolo el la forumoj de OGS, kun nekonata origino." + }, + "handicap": { + "name": "Handikapo", + "description": "Alterna metado kun handikapo. Ludanto 1 (sidigu la pli fortan ludanton unua) fiksas la nombron de pliaj atakantaj ŝtonoj, poste Ludanto 2 komencas la metadon. Se Ludanto 2 prenas la flankon de la atakanto, tiu tuj metas la pliajn ŝtonojn. Komunuma protokolo el la forumoj de OGS, kun nekonata origino." + }, + "classic": { + "name": "Klasika aranĝo de 17 ŝtonoj", + "description": "La tradicia komenca pozicio de 17 ŝtonoj; la defendanto (Ludanto 1) movas unue. Nur 19×19." + }, + "pie": { + "name": "Simpla torto", + "description": "Ludanto 1 metas iun ajn nombron da atakantaj ŝtonoj, poste Ludanto 2 elektas flankon. La kutima tortoregulo." + }, + "hoctaph": { + "name": "Ĝeneraligita torto de Hoctaph", + "description": "Ludanto 1 elektas du grandojn de aroj, Ludanto 2 elektas, kiu metas la unuan aron, la alia ludanto elektas flankon, kaj la atakanto metas la duan aron. La torto de Hoctaph (forumoj de OGS), ĝeneraligita al ĉiu tabulgrando de Samraku." + } + }, "mirador": { "#board": { "name": "Tabulo 28×28" @@ -3704,6 +3735,27 @@ "GRADE_SUPERIOR": "Supera (11)", "GRADE_EXCELLENT": "Bonega (12)" }, + "killallgo": { + "ATTACKER": "Atakanto", + "DEFENDER": "Defendanto", + "UNDECIDED": "Ankoraŭ ne elektita", + "HANDICAP": "Handikapaj ŝtonoj", + "BATCHES": "Grandoj de la aroj", + "BATCHES_VALUE": "{{a}}, poste {{b}}", + "CLAIM": "Pretendo pri vivo", + "CLAIM_VALUE": "La ĉeno ĉe {{where}} vivas, krom se la atakanto ludas ĉe {{marks}}", + "CLAIM_VALUE_NO_MARKS": "La ĉeno ĉe {{where}} ne povas esti kaptita", + "PHASE_HAND_N": "{{player}} fiksas la nombron de handikapaj ŝtonoj", + "PHASE_ALT_PLACE": "{{player}} metas atakantan ŝtonon aŭ prenas la flankon de la atakanto", + "PHASE_PIE_SLICE": "{{player}} metas la malfermajn atakantajn ŝtonojn", + "PHASE_PIE_CHOOSE": "{{player}} elektas flankon", + "PHASE_HOC_SLICE": "{{player}} fiksas la du grandojn de aroj", + "PHASE_HOC_OPTION": "{{player}} decidas, kiu metas la unuan aron", + "PHASE_HOC_BATCH_A": "{{player}} metas la unuan aron da atakantaj ŝtonoj", + "PHASE_HOC_CHOOSE": "{{player}} elektas flankon", + "PHASE_HOC_BATCH_B": "{{player}} metas la duan aron da atakantaj ŝtonoj", + "PHASE_REFUTE": "{{player}} provas refuti la pretendon pri vivo" + }, "lielow": { "MOVESUNTILSUICIDE": "Movoj ĝis memmortigo" } @@ -4497,7 +4549,8 @@ "yonmoque": "Provu formi kvaropan vicon en ludo, en kiu ne ĉiuj spacoj estas egalaj kaj oni povas konverti kontraŭulajn pecojn.", "zola": "Ludo, en kiu la distanco de peco disde la centro de la tabulo limigas ĝian movadon. Kapta movo ne rajtas pliigi tiun distancon; senkapta movo devas ĝin pliigi. Gajnas la unua, kiu kaptas ĉiujn kontraŭulajn pecojn.", "bridges": "Pontoj (angle Bridges) estas ludo por du ludantoj sur sesangula tabulo, elpensita de Marc Rebillet. La ludantoj metas bazojn kaj kunligas ilin per pontoj. Venkas la ludanto, kiu havas plej multajn pontpecojn sur la tabulo je la ludofino. Okaze de egaleco venkas tiu kun la pli longa ponto.", - "elOso": "Solludo el la mondo de Carrisor. Movu viajn stakojn trans la supran randon de la tabulo en la Ĉielon, dum la Urso ĵetas ĵetkubojn kaj antaŭeniras de supre. Por venki, alportu almenaŭ dek pecojn en la Ĉielon." + "elOso": "Solludo el la mondo de Carrisor. Movu viajn stakojn trans la supran randon de la tabulo en la Ĉielon, dum la Urso ĵetas ĵetkubojn kaj antaŭeniras de supre. Por venki, alportu almenaŭ dek pecojn en la Ĉielon.", + "killallgo": "Ĉiomortiga Goo (angle Kill-All Go): variaĵo de Goo kun nesimetriaj celoj. La atakanto komencas kun ŝtonoj sur la tabulo kaj devas kapti ĉion, dum la defendanto venkas farante unu ĉenon, kiu neniam povas esti kaptita." }, "notes": { "akimbo": "Akimbo, projektita de Luis Bolaños Mures en 2026, estas konektludo sen remizoj por du ludantoj. _Nuda diagonalo_ estas paro de samkoloraj, diagonale apudaj pecoj, sen alia samkolora peco apuda al ambaŭ. _Kructranĉo_ estas areo 2×2 kun du interplektitaj nudaj diagonaloj de kontraŭaj koloroj.\n\nDum sia vico, la ludanto metas amikan pecon sur malplenan ĉelon. Se tio kompletigas kructranĉon, la alia peco en la kructranĉo estas forigita. Neniam rajtas esti pli ol unu nuda diagonalo de ĉiu koloro sur la tabulo — eĉ ne momente antaŭ la forigo de peco. Ludanto venkas se, je la fino de sia vico, ĉeno de ortangule konektitaj samkoloraj pecoj tuŝas la du kontraŭajn randojn de la tabulo apartenantajn al tiu ludanto.\n\nOkimba estas variaĵo, en kiu maksimume unu nuda diagonalo rajtas ekzisti sur la tabulo (do dum unu ludanto havas la diagonalon, la kontraŭulo ne povas krei alian).", @@ -4599,6 +4652,7 @@ "xana": "Dum sia vico, ludanto metas aŭ movas stakon (la nombro de stakigeblaj pecoj estas limigita) kaj laŭvole metas du murojn sur malplenajn seslaterojn. Stakoj sen liberecoj estas kaptitaj. Stako havas liberecon, se almenaŭ unu el la ses apudaj seslateroj estas malplena (ne ekzistas koncepto de stakgrupo). Seslatero estas atingebla, se ĝi estas malplena kaj vojo de malplenaj seslateroj konektas ĝin al amika stako. La celo estas akiri la plej altan sumon de teritoria kaj kapta poentaroj. Xana estis projektita en 2005.", "y": "Charles Titus kaj Craige Schensted (Ea Ea) projektis la ludon en 1953. Kiel rekta posteulo de Hex, ĝi origine estis ludata sur triangula tabulo, kaj la celo estis konekti ĉiujn tri flankojn per ĉeno de amikaj ŝtonoj. Y estas konsiderata pli fundamenta ludo ol Hex pro siaj pli simplaj reguloj kaj ĉar eblas aranĝi partion de Hex sur sufiĉe granda Y-tabulo. En sia libro de 1975 **Mudcrack Y & Poly-Y**, la projektintoj prezentis la _principon de ŝlimfendo_, laŭ kiu konektludoj povas okazi sur vasta gamo de iom post iom pli kompleksaj tabuloj. Unu el tiuj projektoj (\"la fleksita Y\", kun tri kvinangulaj ĉeloj) fariĝis la komerca Y-tabulo anstataŭ la norma triangula tabulo.", "elOso": "La komenca aranĝo estas aŭtomate farata kiam vi lanĉas la ludon. La flankpanelo montras la nombrojn de pecoj por la Ĉielo, la Kaverno, la Grundo kaj la Kavo. Post ĉiu movo, uzu la regilojn de filmeroj por trairi la respondon de la Urso paŝon post paŝo. Dek aŭ pli da pecoj en la Ĉielo signifas venkon.", + "killallgo": "La atakanto, kies ŝtonoj jam estas sur la tabulo, kiam la normala ludado komenciĝas, venkas malhelpante, ke la defendanto iam faru vivantan ĉenon. La defendanto venkas farante unu ĉenon, kiu ne povas esti kaptita. Poentado ne ekzistas.\n\nŜtonoj estas metataj kaj kaptataj kiel en Goo, kun la forigo laŭ Tromp-Taylor: post meto, la ĉenoj de la kontraŭulo sen liberecoj estas forigataj, poste via propra ĉeno, se ĝi havas neniun, do plurŝtona memmortigo estas permesita. Validas pozicia superko: neniu meto rajtas rekrei iun ajn pli fruan pozicion de la tabulo. Ambaŭ ludantoj rajtas preterpasi; se ambaŭ preterpasas sinsekve, la atakanto venkas.\n\nLa defendanto venkas tuj, kiam unu el la ĉenoj de la defendanto estas senkondiĉe viva (viva laŭ la algoritmo de Benson: la atakanto neniam povus kapti ĝin, eĉ per senlime multaj sinsekvaj movoj). Ĉenon, kiu vivas nur en seki, oni devas pretendi. Anstataŭ movi, la defendanto alklakas unu el siaj ŝtonoj, markas iun ajn nombron da malplenaj protektitaj punktoj kaj sendas la pretendon. La atakanto tiam ludas iun ajn nombron da sinsekvaj ŝtonoj, neniun sur protektita punkto, provante kapti la pretenditan ĉenon. Se la ĉeno estas kaptita, la atakanto venkas. Se la atakanto finas la sekvencon ludante sur protektita punkto, ĉiu ŝtono restas sur la tabulo kaj la normala ludado daŭras, kun la vico ĉe la defendanto. Se la atakanto koncedas, la defendanto venkas. Vivo en koo ne estas agnoskata; la defendanto devas daŭrigi la ludadon.\n\nMalfermoj: ĉe la alterna metado, la ludantoj laŭvice metas atakantajn ŝtonojn, ĝis unu el ili prenas la flankon de la atakanto; la alia ludanto tiam movas unue kiel la defendanto. La handikapa malfermo funkcias same, krom ke Ludanto 1 (la pli forta ludanto; do sidigu la donanton de la handikapo unua, kiam vi kreas la defion) unue fiksas la nombron de handikapaj ŝtonoj kaj Ludanto 2 komencas la metadon; se Ludanto 2 prenas la flankon de la atakanto, tiu tuj metas tiom da pliaj ŝtonoj. La klasika malfermo uzas la tradician pozicion de 17 ŝtonoj sur la tabulo 19×19, kaj la defendanto (Ludanto 1) movas unue. Ĉe la simpla torto, Ludanto 1 metas iun ajn nombron da atakantaj ŝtonoj kaj Ludanto 2 elektas flankon. Ĉe la ĝeneraligita torto de Hoctaph, Ludanto 1 elektas du grandojn de aroj (ĉiu almenaŭ 1, kune maksimume la nombro de punktoj minus 2, neniu pli ol duoble la alia), Ludanto 2 decidas, kiu metas la unuan aron, la alia ludanto tiam elektas flankon, kaj la atakanto metas la duan aron. En ĉiu malfermo la defendanto faras la unuan normalan movon.", "lielow": "La valoro \"Movoj ĝis memmortigo\" sumas por ĉiu ludanto, kiom da movoj ĉiu el ties stakoj ankoraŭ povus fari — kreskante je unu kun ĉiu movo kaj ĉiam elektante la direkton, laŭ kiu ĝi plej longe restas sur la tabulo — antaŭ ol ĝi devus elportiĝi. La kalkulo ignoras ĉiujn aliajn stakojn sur la tabulo, ĉu amikajn, ĉu malamikajn. Ĝi do estas nur proksimuma mezuro de la senŝirmeco de la ludpecoj, ne la efektiva nombro da restantaj validaj movoj." }, "homeworlds": { @@ -7722,6 +7776,60 @@ "INVALID_DIRECTION": "Stakoj povas moviĝi diagonale aŭ flanken, sed ne rekte antaŭen aŭ malantaŭen.", "TOO_FAR": "Ĉi tiu stako povas moviĝi je maksimume {{dist}} spacoj.", "SKY_FROM_TOP": "Nur stakoj sur la supra vico povas moviĝi en la Ĉielon." + }, + "killallgo": { + "INSTRUCTIONS_HANDICAP": "Vi donas la handikapon. Tajpu la nombron de pliaj atakantaj ŝtonoj (de 1 ĝis {{max}}), kiujn via kontraŭulo metos, se tiu prenos la flankon de la atakanto.", + "HANDICAP_INVALID": "La handikapo devas esti entjero.", + "HANDICAP_RANGE": "La handikapo devas esti inter 1 kaj {{max}} ŝtonoj.", + "HANDICAP_OK_one": "Handikapo de unu ŝtono. Sendu, aŭ daŭrigu la tajpadon.", + "HANDICAP_OK_other": "Handikapo de {{count}} ŝtonoj. Sendu, aŭ daŭrigu la tajpadon.", + "INSTRUCTIONS_ALT": "Metu atakantan ŝtonon, aŭ prenu la flankon de la atakanto; via kontraŭulo tiam movos unue kiel la defendanto.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Metu atakantan ŝtonon, aŭ prenu la flankon de la atakanto kaj poste metu unu plian ŝtonon; via kontraŭulo tiam movos unue kiel la defendanto.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Metu atakantan ŝtonon, aŭ prenu la flankon de la atakanto kaj poste metu {{count}} pliajn ŝtonojn; via kontraŭulo tiam movos unue kiel la defendanto.", + "ATTACKER_NO_STONES": "Preni la flankon de la atakanto ne implicas meti ŝtonojn nun.", + "DEFENDER_NO_STONES": "Elekti la flankon de la defendanto ne implicas meti ŝtonojn nun.", + "STONES_LEFT_one": "Metu ankoraŭ unu atakantan ŝtonon.", + "STONES_LEFT_other": "Metu ankoraŭ {{count}} atakantajn ŝtonojn.", + "BATCH_TOO_MANY_one": "Ĉi tiu aro enhavas ekzakte unu ŝtonon.", + "BATCH_TOO_MANY_other": "Ĉi tiu aro enhavas ekzakte {{count}} ŝtonojn.", + "BATCH_MORE_OR_SUBMIT": "Aldonu pliajn atakantajn ŝtonojn, aŭ sendu.", + "DUPLICATE_CELL": "La ĉelo {{where}} estas listigita dufoje.", + "INVALID_PASS": "Vi ne povas preterpasi nun.", + "NO_CLICKS_NOW": "Tajpu vian respondon en la movokampon; la tabulo ankoraŭ ne estas alklakebla.", + "INSTRUCTIONS_PIE_SLICE": "Metu iun ajn nombron da atakantaj ŝtonoj (aŭ preterpasu por meti neniun); via kontraŭulo tiam elektos flankon.", + "INSTRUCTIONS_PIE_CHOOSE": "Elektu flankon: prenu la atakantajn ŝtonojn, aŭ metu vian unuan ŝtonon kiel la defendanto.", + "INSTRUCTIONS_PIE_DEFENDER_STONE": "Metu vian unuan defendantan ŝtonon.", + "INSTRUCTIONS_HOC_SLICE": "Tajpu du grandojn de aroj kiel a,b: ĉiu almenaŭ 1, kune maksimume {{max}}, kaj neniu pli ol duoble la alia.", + "SLICE_FORMAT": "Tajpu la du grandojn de aroj kiel du entjerojn apartigitajn per komo, ekzemple 20,15.", + "SLICE_MIN": "Ĉiu aro devas enhavi almenaŭ unu ŝtonon.", + "SLICE_SUM": "Kune la du aroj rajtas enhavi maksimume {{max}} ŝtonojn.", + "SLICE_RATIO": "Neniu aro rajtas esti pli ol duoble tiel granda kiel la alia.", + "SLICE_OK": "Aroj de {{a}} kaj {{b}} ŝtonoj. Sendu, aŭ daŭrigu la tajpadon.", + "INSTRUCTIONS_HOC_OPTION_one": "Decidu, kiu metas la unuan aron de unu atakanta ŝtono: metu ĝin mem (via kontraŭulo tiam elektos flankon), aŭ lasu vian kontraŭulon meti ĝin (vi tiam elektos flankon).", + "INSTRUCTIONS_HOC_OPTION_other": "Decidu, kiu metas la unuan aron de {{count}} atakantaj ŝtonoj: metu ilin mem (via kontraŭulo tiam elektos flankon), aŭ lasu vian kontraŭulon meti ilin (vi tiam elektos flankon).", + "INSTRUCTIONS_HOC_BATCH_one": "Metu unu atakantan ŝtonon.", + "INSTRUCTIONS_HOC_BATCH_other": "Metu {{count}} atakantajn ŝtonojn.", + "INSTRUCTIONS_HOC_CHOOSE_one": "Elektu flankon. Preni la flankon de la atakanto signifas meti nun la duan aron de unu ŝtono; elekti la flankon de la defendanto signifas, ke via kontraŭulo metas ĝin kaj vi tiam movas unue.", + "INSTRUCTIONS_HOC_CHOOSE_other": "Elektu flankon. Preni la flankon de la atakanto signifas meti nun la duan aron de {{count}} ŝtonoj; elekti la flankon de la defendanto signifas, ke via kontraŭulo metas ilin kaj vi tiam movas unue.", + "INSTRUCTIONS_PLAY_ATTACKER": "Metu ŝtonon aŭ preterpasu.", + "INSTRUCTIONS_PLAY_DEFENDER": "Metu ŝtonon, preterpasu, aŭ alklaku unu el viaj propraj ŝtonoj por pretendi, ke ĝia ĉeno vivas.", + "KO_PSK": "Tiu meto rekreus pli fruan pozicion de la tabulo (pozicia superko).", + "PASS_WARNING": "Preterpaso ne establas vivon. Se ankaŭ la atakanto preterpasos, la atakanto venkos.", + "CLAIM_ATTACKER": "Nur la defendanto povas pretendi, ke ĉeno vivas.", + "CLAIM_NOT_OWN_STONE": "Ĉe {{where}} ne estas defendanta ŝtono; alklaku unu el viaj propraj ŝtonoj por pretendi pri ĝia ĉeno.", + "CLAIM_MARK_OCCUPIED": "Protektitaj punktoj devas esti malplenaj; {{where}} estas okupita.", + "INSTRUCTIONS_CLAIM": "Alklaku la malplenajn punktojn, kiujn via kontraŭulo ne rajtos ludi dum la refutado (alklaku denove por malmarki), poste sendu la pretendon.", + "INSTRUCTIONS_CLAIM_NO_MARKS": "Ankoraŭ neniu protektita punkto: la atakanto rajtos ludi ie ajn. Alklaku malplenajn punktojn por protekti ilin, aŭ sendu la pretendon tia, kia ĝi estas.", + "INSTRUCTIONS_REFUTE": "Refutu la pretendon: metu iun ajn nombron da ŝtonoj, neniun sur protektita punkto. Finu ludante sur protektita punkto (la ludado tiam daŭras, kun la vico ĉe la defendanto), kaptante la pretenditan ĉenon (vi venkas), aŭ koncedante (la defendanto venkas).", + "INSTRUCTIONS_REFUTE_CONTINUE": "Daŭrigu la metadon de ŝtonoj, ludu sur protektita punkto por fini la sekvencon, aŭ koncedu la pretendon.", + "PROTECTED_POINT": "{{where}} estas protektita punkto; ĝi rajtas esti nur la lasta ŝtono de via refutado.", + "REFUTE_AFTER_END": "La refutado jam finiĝis; forigu la ŝtonojn post la lasta.", + "BTN_ATTACKER_TAKE": "Preni la flankon de la atakanto", + "BTN_ATTACKER": "Ludi kiel la atakanto", + "BTN_DEFENDER": "Ludi kiel la defendanto", + "BTN_IPLACE": "Meti la unuan aron mem", + "BTN_YOUPLACE": "Lasi la kontraŭulon meti la unuan aron", + "BTN_CONCEDE": "Koncedi la pretendon" } }, "INITIAL_UNDO": "Vi ne povas malfari la komencan pozicion de la ludo.", @@ -7864,6 +7972,7 @@ "irensei": "Irensei", "jacynth": "Jacynth", "kachit": "Kachit Knights", + "killallgo": "Ĉiomortiga Goo", "knightline": "Ĉevalira Linio", "konane": "Konane", "krypte": "Krypte", diff --git a/locales/eo/apresults.json b/locales/eo/apresults.json index c101f07f..c754d66e 100644 --- a/locales/eo/apresults.json +++ b/locales/eo/apresults.json @@ -98,6 +98,8 @@ "forms_other": "La movo de {{player}} forigis {{count}} pecojn.", "elOso_one": "{{player}} neniigis unu pecon ĉe {{where}}.", "elOso_other": "{{player}} neniigis {{count}} pecojn ĉe {{where}}.", + "killallgo_suicide_one": "{{player}} forigis sian propran ŝtonon per memmortigo.", + "killallgo_suicide_other": "{{player}} forigis {{count}} el siaj propraj ŝtonoj per memmortigo.", "spora_one": "{{player}} ĉirkaŭis kaj kaptis unu pecon: {{group}}.", "spora_other": "{{player}} ĉirkaŭis kaj kaptis {{count}} pecojn: {{group}}." }, @@ -222,7 +224,8 @@ "elOso": { "playerSetup": "Komenca aranĝo de la ludanto", "bearSetup": "Komenca aranĝo de la Urso" - } + }, + "killallgo_slice": "{{player}} fiksis la grandojn de la aroj al {{a}} kaj {{b}} ŝtonoj." }, "BEAROFF": { "canoe": "{{player}} eligis kubon de la tabulo.", @@ -250,6 +253,11 @@ "fnap_row": "{{player}} alproprigis la vicon {{where}}.", "frogger": "{{player}} estis blokita kaj tiris {{card}} el la tirprovizo.", "jacynth": "{{player}} influis ĉe {{where}}.", + "killallgo_attacker": "{{player}} elektis ludi kiel la atakanto.", + "killallgo_concede": "{{player}} koncedis la pretendon: la ĉeno vivas.", + "killallgo_defender": "{{player}} elektis ludi kiel la defendanto.", + "killallgo_life": "{{player}} pretendis, ke la ĉeno ĉe {{where}} vivas, krom se la atakanto ludos ĉe {{marks}}.", + "killallgo_life_nomarks": "{{player}} pretendis, ke la ĉeno ĉe {{where}} ne povas esti kaptita.", "logger": "{{player}} akiris protestanton de la arbo faligita ĉe {{where}}.", "magnate": "{{who}} akiris {{what}}.", "magnate_initial": "La ludantoj kolektis siajn Kronajn rimedojn.", @@ -300,6 +308,8 @@ "biscuit": "{{player}} finis sian manon!", "emu": "La jaro {{year}} finiĝis.", "frogger": "La krokodiloj antaŭeniris.", + "killallgo_handicap_one": "{{player}} fiksis la handikapon al unu ŝtono.", + "killallgo_handicap_other": "{{player}} fiksis la handikapon al {{count}} ŝtonoj.", "mchess": "{{player}} \"vokis la horloĝon\". Se neniu kapto okazos dum la sekvaj sep vicoj, la ludo finiĝos kaj estos poentita.", "renju": "{{player}} deklaris {{count}} provizorajn kvinopojn." }, @@ -375,7 +385,11 @@ "unlur_foul": "La ludo finiĝis, ĉar unu ludanto plenumis la venkokondiĉon de la kontraŭulo sen plenumi sian propran.", "druid_hex": "La ludo finiĝis, ĉar {{player}} konektis la randojn {{edges}}.", "gonnect_cascading_subboard": "{{player}} venkis sur la {{size}}x{{size}}-subtabulo.", - "gonnect_direct_connection": "{{player}} venkis per rekta konekto." + "gonnect_direct_connection": "{{player}} venkis per rekta konekto.", + "killallgo_claim_refuted": "La ludo finiĝis, ĉar la pretendita ĉeno estis kaptita.", + "killallgo_claim_upheld": "La ludo finiĝis, ĉar la atakanto koncedis, ke la pretendita ĉeno vivas.", + "killallgo_double_pass": "La ludo finiĝis, ĉar ambaŭ ludantoj preterpasis kaj la defendanto ne establis vivon.", + "killallgo_pass_alive": "La ludo finiĝis, ĉar la defendanto faris senkondiĉe vivantan ĉenon." }, "DRUID_HEX_EDGE": { "N": "nordo (N)", @@ -556,6 +570,7 @@ "streetcar_place_default": "Konstruaĵo estis metita ĉe {{where}}.", "elOso_one": "{{player}} metis unu pecon ĉe {{where}}.", "elOso_other": "{{player}} metis {{count}} pecojn ĉe {{where}}.", + "killallgo_setup": "{{player}} metis atakantan ŝtonon ĉe {{where}}.", "spora_one": "{{player}} aldonis unu pecon al la stako ĉe {{where}}.", "spora_other": "{{player}} aldonis {{size}} pecojn al la stako ĉe {{where}}." }, @@ -625,6 +640,8 @@ "SCORE_REPORT": "La poentaro de {{player}} nun estas {{score}}.", "SELECT": { "fnap": "{{player}} elektis la kahelon {{tile}}.", + "killallgo_iplace": "{{player}} elektis mem meti la unuan aron.", + "killallgo_youplace": "{{player}} elektis lasi la kontraŭulon meti la unuan aron.", "resolve": "{{player}} elektis la pecon en kructranĉo ĉe {{where}}.", "slyde_fixed_opponent": "{{player}} ŝanĝis la staton de la kontraŭula peco ĉe {{where}} de movebla al fiksa.", "slyde_fixed_self": "{{player}} ŝanĝis la staton de sia propra peco ĉe {{where}} de movebla al fiksa.", From d67cea8b21022b2333c0d2f7d658b5c0354d2c7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:11:01 +0000 Subject: [PATCH 05/16] Kill-All Go plan: record the implementation status Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- KILL-ALL-GO-PLAN.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/KILL-ALL-GO-PLAN.md b/KILL-ALL-GO-PLAN.md index fc7daeca..594dffd8 100644 --- a/KILL-ALL-GO-PLAN.md +++ b/KILL-ALL-GO-PLAN.md @@ -459,3 +459,21 @@ Commands: `npm run generate-registry`, `npm run typecheck`, `npx mocha --require Still to fill in during implementation: your display name / AP id in `people`, and a check that the OGS forum URL for Hoctaph's post resolves (topic 27365, post 70). + +--- + +## 11. Implementation status (2026-09-21) + +Implemented on this branch: `src/games/killallgo.ts`, `src/games/killallgo/board.ts`, `src/games/killallgo/benson.ts`, +`test/games/killallgo.test.ts`, `test/games/killallgo-benson.test.ts`, English and Esperanto strings in `locales/{en,eo}/apgames.json` +and `apresults.json`. Everything in §3–§7 is implemented as written, with these notes: + +* Red can never make a Blue string pass-alive (its moves only remove Blue chains and merge regions, which never adds a vital, + X-enclosed region), so the mid-refutation check in D6 is a harmless safeguard rather than a reachable rule. +* `currplayer` always advances after a ply, including game-ending plies, so the default chat collector attributes lines to the + mover exactly as `go.ts` does. +* The sidebar phase line is a seat-actor label ("{{player}} chooses a side"), so the front substitutes the display name. +* Validation of typed input (`n`, `a,b`) returns `complete: 0`, like Go's komi entry, so the player can keep typing. +* Rendered JSON for every phase was validated against the renderer schema; the button bar uses structured labels resolved by + the front (`resolveRenderLabels`), so no `front` change is needed. +* The Esperanto title `Ĉiomortiga Goo` is a proposal awaiting acceptance (see the conventions repository's game-name policy). From 88ca76df07629e0e26d2ede18a4041cff48cbf92 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:12:45 +0000 Subject: [PATCH 06/16] Kill-All Go: accept one-stone batches and keep opening sizes on the sidebar The move failsafe rejected a Hoctaph batch of a single stone because batch plies are not enumerable; batch phases now skip the failsafe and list single-stone batches. The handicap and the two batch sizes stay in the state after the opening so the sidebar keeps showing them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- src/games/killallgo.ts | 30 ++++++++++++++++++++++++++++-- test/games/killallgo.test.ts | 13 +++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/games/killallgo.ts b/src/games/killallgo.ts index 92868dd7..632a2cdf 100644 --- a/src/games/killallgo.ts +++ b/src/games/killallgo.ts @@ -329,7 +329,11 @@ export class KillAllGoGame extends GameBase { return list.length === 0 ? [] : list.split(","); } + /** Whether `moves()` lists every legal move of this shape in the current phase (used by the failsafe). */ private isEnumerable(m: string): boolean { + if (this.phase === "hoc-slice" || this.phase === "hoc-batch-a" || this.phase === "hoc-batch-b") { + return false; + } return /^([a-z]+\d+|\d+|pass|attacker|defender|youplace|concede|defender:[a-z]+\d+)$/.test(m); } @@ -372,6 +376,16 @@ export class KillAllGoGame extends GameBase { case "hoc-option": moves.push("youplace"); break; + case "hoc-batch-a": + case "hoc-batch-b": + if (this.setup?.batchSize === 1) { + for (const cell of this.geo.cells) { + if (!this.board.has(cell)) { + moves.push(cell); + } + } + } + break; case "hoc-choose": moves.push("defender"); break; @@ -910,7 +924,7 @@ export class KillAllGoGame extends GameBase { this.placeStone(cell, BLUE, seen); if (partial) { return this; } this.phase = "play"; - this.setup = undefined; + this.setup = this.keptSetup(); this.checkBlueLife(); this.currplayer = this.redSeat; } @@ -1072,10 +1086,22 @@ export class KillAllGoGame extends GameBase { /** The opening protocol is over: normal play starts with the Defender to move. */ private startPlay(): void { this.phase = "play"; - this.setup = undefined; + this.setup = this.keptSetup(); this.currplayer = this.blueSeat()!; } + /** The parts of the opening bookkeeping that stay visible in the sidebar during play. */ + private keptSetup(): ISetup | undefined { + if (this.setup === undefined) { + return undefined; + } + const kept: ISetup = {}; + if (this.setup.handicap !== undefined) { kept.handicap = this.setup.handicap; } + if (this.setup.a !== undefined) { kept.a = this.setup.a; } + if (this.setup.b !== undefined) { kept.b = this.setup.b; } + return Object.keys(kept).length === 0 ? undefined : kept; + } + /** Ends the game for the Defender if any Blue string is pass-alive. */ private checkBlueLife(): boolean { const alive = this.bluePassAlive(); diff --git a/test/games/killallgo.test.ts b/test/games/killallgo.test.ts index 70862ab8..936e43d3 100644 --- a/test/games/killallgo.test.ts +++ b/test/games/killallgo.test.ts @@ -154,6 +154,7 @@ describe("Kill-All Go", () => { expect(g.phase).to.equal("play"); expect(g.currplayer).to.equal(2); expect(g.board.size).to.equal(1); + expect(g.setup).to.deep.equal({ handicap: 3 }); expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1]); }); }); @@ -251,6 +252,18 @@ describe("Kill-All Go", () => { expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2]); }); + it("accepts one-stone batches and keeps the batch sizes on the sidebar", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["1,2", "youplace"]); + expect(g.moves()).to.include("a1"); + g.move("a1"); + expect(g.phase).to.equal("hoc-choose"); + g.move("attacker:b1,c1"); + expect(g.phase).to.equal("play"); + expect(g.setup).to.deep.equal({ a: 1, b: 2 }); + const statuses = g.sidebarStatuses(); + expect(statuses.some((st) => (st.key as { textKey: string }).textKey === "apgames:status.killallgo.BATCHES")).to.be.true; + }); + it("builds batches by clicking", () => { const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3"]); const first = g.handleClick("", 8, 0); From 57ce1ceaefec60e457968397431b96944cb9c42c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 01:44:27 +0000 Subject: [PATCH 07/16] Kill-All Go: one engine file and one test file Folds the board helpers and Benson's algorithm into killallgo.ts as named exports and merges the Benson and board-mechanics tests into killallgo.test.ts, matching the usual single-file layout of a game. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- KILL-ALL-GO-PLAN.md | 18 +- src/games/killallgo.ts | 286 +++++++++++++++++++++++++++- src/games/killallgo/benson.ts | 139 -------------- src/games/killallgo/board.ts | 156 --------------- test/games/killallgo-benson.test.ts | 245 ------------------------ test/games/killallgo.test.ts | 244 +++++++++++++++++++++++- 6 files changed, 530 insertions(+), 558 deletions(-) delete mode 100644 src/games/killallgo/benson.ts delete mode 100644 src/games/killallgo/board.ts delete mode 100644 test/games/killallgo-benson.test.ts diff --git a/KILL-ALL-GO-PLAN.md b/KILL-ALL-GO-PLAN.md index 594dffd8..efba884c 100644 --- a/KILL-ALL-GO-PLAN.md +++ b/KILL-ALL-GO-PLAN.md @@ -249,15 +249,12 @@ advisory. The manual protocol needs no algorithm at all, only the string/liberty ### 7.1 Files ``` -src/games/killallgo.ts engine (class KillAllGoGame extends GameBase) -src/games/killallgo/benson.ts pass-alive (pure) -src/games/killallgo/board.ts strings, liberties, capture/suicide application, board signature (pure helpers) -test/games/killallgo-benson.test.ts fixtures from §4.4 -test/games/killallgo.test.ts engine behaviour (§9) +src/games/killallgo.ts board helpers, Benson pass-alive (exported pure functions), then the engine class +test/games/killallgo.test.ts Benson fixtures from §4.4, board mechanics, engine behaviour (§9) locales/en/apgames.json, locales/en/apresults.json strings (§7.9); eo via the $ap-eo skill in the implementation session ``` -Subfolders under `src/games/` are established practice (`armadas/`, `cifra/`, `homeworlds/`, …) and the registry -generator only picks up game classes, so helper modules there are safe. +One engine file and one test file, matching the usual layout of a game in this repository; the pure helpers are named exports +of the engine module so the tests can exercise them directly. ### 7.2 State ```ts @@ -464,9 +461,8 @@ Hoctaph's post resolves (topic 27365, post 70). ## 11. Implementation status (2026-09-21) -Implemented on this branch: `src/games/killallgo.ts`, `src/games/killallgo/board.ts`, `src/games/killallgo/benson.ts`, -`test/games/killallgo.test.ts`, `test/games/killallgo-benson.test.ts`, English and Esperanto strings in `locales/{en,eo}/apgames.json` -and `apresults.json`. Everything in §3–§7 is implemented as written, with these notes: +Implemented on this branch: `src/games/killallgo.ts`, `test/games/killallgo.test.ts`, English and Esperanto strings in +`locales/{en,eo}/apgames.json` and `apresults.json`. Everything in §3–§7 is implemented as written, with these notes: * Red can never make a Blue string pass-alive (its moves only remove Blue chains and merge regions, which never adds a vital, X-enclosed region), so the mid-refutation check in D6 is a harmless safeguard rather than a reachable rule. @@ -476,4 +472,4 @@ and `apresults.json`. Everything in §3–§7 is implemented as written, with th * Validation of typed input (`n`, `a,b`) returns `complete: 0`, like Go's komi entry, so the player can keep typing. * Rendered JSON for every phase was validated against the renderer schema; the button bar uses structured labels resolved by the front (`resolveRenderLabels`), so no `front` change is needed. -* The Esperanto title `Ĉiomortiga Goo` is a proposal awaiting acceptance (see the conventions repository's game-name policy). +* The Esperanto title `Ĉiomortiga Goo` was accepted on 2026-09-21 and recorded in the conventions repository. diff --git a/src/games/killallgo.ts b/src/games/killallgo.ts index 632a2cdf..51e9d567 100644 --- a/src/games/killallgo.ts +++ b/src/games/killallgo.ts @@ -2,10 +2,8 @@ import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, import type { APGamesInformation } from "../schemas/gameinfo.js"; import type { APRenderRep, AreaButtonBar, RowCol } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; -import { reviver, UserFacingError } from "../common/index.js"; +import { algebraic2coords, coords2algebraic, reviver, UserFacingError } from "../common/index.js"; import i18next from "i18next"; -import { applyPlacement, BLUE, makeGeometry, RED, signature, stringAt, type Board, type Geometry, type Stone } from "./killallgo/board.js"; -import { passAliveStrings } from "./killallgo/benson.js"; /** * Kill-All Go. @@ -17,6 +15,288 @@ import { passAliveStrings } from "./killallgo/benson.js"; */ export type playerid = 1 | 2; +// --------------------------------------------------------------------------- +// Board helpers +// --------------------------------------------------------------------------- + +export type Stone = 1 | 2; +export const RED: Stone = 1; +export const BLUE: Stone = 2; + +export type Board = Map; + +export interface Geometry { + size: number; + /** All cells, row by row from the top of the board. */ + cells: string[]; + neighbours: Map; + coords2algebraic: (x: number, y: number) => string; + algebraic2coords: (cell: string) => [number, number]; +} + +export interface StringInfo { + stones: string[]; + liberties: Set; +} + +export interface PlacementOutcome { + /** Each captured opponent string, in the order they were removed. */ + captured: string[][]; + /** The mover's own stones removed by suicide (Tromp-Taylor clearing), if any. */ + suicided: string[]; +} + +const geometryCache = new Map(); + +export const makeGeometry = (size: number): Geometry => { + const cached = geometryCache.get(size); + if (cached !== undefined) { + return cached; + } + const c2a = (x: number, y: number): string => coords2algebraic(x, y, size); + const a2c = (cell: string): [number, number] => algebraic2coords(cell, size); + const cells: string[] = []; + const neighbours = new Map(); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const cell = c2a(x, y); + cells.push(cell); + const adj: string[] = []; + if (x > 0) { adj.push(c2a(x - 1, y)); } + if (x < size - 1) { adj.push(c2a(x + 1, y)); } + if (y > 0) { adj.push(c2a(x, y - 1)); } + if (y < size - 1) { adj.push(c2a(x, y + 1)); } + neighbours.set(cell, adj); + } + } + const geo: Geometry = { size, cells, neighbours, coords2algebraic: c2a, algebraic2coords: a2c }; + geometryCache.set(size, geo); + return geo; +}; + +export const otherColour = (colour: Stone): Stone => (colour === RED ? BLUE : RED); + +/** The string containing `cell` (which must hold a stone) and its liberties. */ +export const stringAt = (board: Board, geo: Geometry, cell: string): StringInfo => { + const colour = board.get(cell); + if (colour === undefined) { + throw new Error(`No stone at ${cell}.`); + } + const stones: string[] = []; + const liberties = new Set(); + const seen = new Set([cell]); + const todo = [cell]; + while (todo.length > 0) { + const cur = todo.pop()!; + stones.push(cur); + for (const n of geo.neighbours.get(cur)!) { + const occupant = board.get(n); + if (occupant === undefined) { + liberties.add(n); + } else if (occupant === colour && !seen.has(n)) { + seen.add(n); + todo.push(n); + } + } + } + return { stones, liberties }; +}; + +/** + * Place a stone and apply Tromp-Taylor clearing: opponent strings left without liberties are + * removed first, then the mover's own string if it has none (multi-stone suicide is legal; + * a single-stone suicide is left to the superko check, which always rejects it). + * Mutates `board`. + */ +export const applyPlacement = (board: Board, geo: Geometry, cell: string, colour: Stone): PlacementOutcome => { + if (board.has(cell)) { + throw new Error(`Cell ${cell} is occupied.`); + } + board.set(cell, colour); + const opp = otherColour(colour); + const captured: string[][] = []; + const removed = new Set(); + for (const n of geo.neighbours.get(cell)!) { + if (board.get(n) !== opp || removed.has(n)) { + continue; + } + const info = stringAt(board, geo, n); + if (info.liberties.size === 0) { + for (const s of info.stones) { + board.delete(s); + removed.add(s); + } + captured.push(info.stones); + } + } + let suicided: string[] = []; + const own = stringAt(board, geo, cell); + if (own.liberties.size === 0) { + for (const s of own.stones) { + board.delete(s); + } + suicided = own.stones; + } + return { captured, suicided }; +}; + +/** Canonical string for a board position (used for positional superko). */ +export const signature = (board: Board, geo: Geometry): string => { + let sig = ""; + for (const cell of geo.cells) { + const s = board.get(cell); + sig += s === undefined ? "-" : s === RED ? "r" : "b"; + } + return sig; +}; + +// --------------------------------------------------------------------------- +// Benson's algorithm for unconditional life ("pass-alive" strings) +// --------------------------------------------------------------------------- + +/** + * Benson's algorithm for unconditional life ("pass-alive" strings). + * + * A chain of the given colour is pass-alive when it belongs to a set X of chains such that every + * chain in X has at least two vital regions enclosed by X. A region is a maximal connected set + * of points not holding the colour; it is vital to a chain when every point of the region that + * could ever become empty is adjacent to the chain. + * + * With classic (no-suicide) rules only the empty points of a region need to touch the chain. + * When multi-stone suicide is legal (Tromp-Taylor), the opponent can clear its own stones out of a + * region and refill it leaving a hole that is not a liberty of the chain, so every point of the + * region, occupied or not, must touch the chain. `suicideAllowed` selects the strict test. + */ + +export interface PassAliveOptions { + suicideAllowed?: boolean; +} + +interface Region { + points: string[]; + adjacentChains: Set; + vitalTo: Set; +} + +/** Returns every pass-alive chain of `colour`, each as its list of stones. */ +export const passAliveStrings = (board: Board, geo: Geometry, colour: Stone, opts: PassAliveOptions = {}): string[][] => { + const suicideAllowed = opts.suicideAllowed ?? true; + + // Chains of `colour`. + const chainOf = new Map(); + const chains: string[][] = []; + for (const cell of geo.cells) { + if (board.get(cell) !== colour || chainOf.has(cell)) { + continue; + } + const id = chains.length; + const stones: string[] = []; + const todo = [cell]; + chainOf.set(cell, id); + while (todo.length > 0) { + const cur = todo.pop()!; + stones.push(cur); + for (const n of geo.neighbours.get(cur)!) { + if (board.get(n) === colour && !chainOf.has(n)) { + chainOf.set(n, id); + todo.push(n); + } + } + } + chains.push(stones); + } + if (chains.length === 0) { + return []; + } + + // Regions: maximal connected sets of points not holding `colour`. + const regionOf = new Map(); + const regions: Region[] = []; + for (const cell of geo.cells) { + if (board.get(cell) === colour || regionOf.has(cell)) { + continue; + } + const id = regions.length; + const points: string[] = []; + const todo = [cell]; + regionOf.set(cell, id); + while (todo.length > 0) { + const cur = todo.pop()!; + points.push(cur); + for (const n of geo.neighbours.get(cur)!) { + if (board.get(n) !== colour && !regionOf.has(n)) { + regionOf.set(n, id); + todo.push(n); + } + } + } + regions.push({ points, adjacentChains: new Set(), vitalTo: new Set() }); + } + + // Which chains touch each region, and to which chains the region is vital. + for (const region of regions) { + const touches = new Map(); + let mustTouch = 0; + for (const p of region.points) { + const counts = suicideAllowed || !board.has(p); + if (counts) { + mustTouch++; + } + const seenHere = new Set(); + for (const n of geo.neighbours.get(p)!) { + const chain = chainOf.get(n); + if (chain === undefined || seenHere.has(chain)) { + continue; + } + seenHere.add(chain); + region.adjacentChains.add(chain); + if (counts) { + touches.set(chain, (touches.get(chain) ?? 0) + 1); + } + } + } + for (const [chain, n] of touches) { + if (n === mustTouch) { + region.vitalTo.add(chain); + } + } + } + + // Benson iteration. + const X = new Set(chains.map((_, i) => i)); + const R = new Set(regions.map((_, i) => i)); + let changed = true; + while (changed) { + changed = false; + for (const chain of [...X]) { + let vital = 0; + for (const rid of R) { + if (regions[rid].vitalTo.has(chain)) { + vital++; + } + } + if (vital < 2) { + X.delete(chain); + changed = true; + } + } + for (const rid of [...R]) { + for (const chain of regions[rid].adjacentChains) { + if (!X.has(chain)) { + R.delete(rid); + changed = true; + break; + } + } + } + } + return chains.filter((_, i) => X.has(i)); +}; + +// --------------------------------------------------------------------------- +// The game +// --------------------------------------------------------------------------- + type Opening = "alt" | "handicap" | "classic" | "pie" | "hoctaph"; type Phase = diff --git a/src/games/killallgo/benson.ts b/src/games/killallgo/benson.ts deleted file mode 100644 index 5c935563..00000000 --- a/src/games/killallgo/benson.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Benson's algorithm for unconditional life ("pass-alive" strings). - * - * A chain of the given colour is pass-alive when it belongs to a set X of chains such that every - * chain in X has at least two vital regions enclosed by X. A region is a maximal connected set - * of points not holding the colour; it is vital to a chain when every point of the region that - * could ever become empty is adjacent to the chain. - * - * With classic (no-suicide) rules only the empty points of a region need to touch the chain. - * When multi-stone suicide is legal (Tromp-Taylor), the opponent can clear its own stones out of a - * region and refill it leaving a hole that is not a liberty of the chain, so every point of the - * region, occupied or not, must touch the chain. `suicideAllowed` selects the strict test. - */ -import type { Board, Geometry, Stone } from "./board.js"; - -export interface PassAliveOptions { - suicideAllowed?: boolean; -} - -interface Region { - points: string[]; - adjacentChains: Set; - vitalTo: Set; -} - -/** Returns every pass-alive chain of `colour`, each as its list of stones. */ -export const passAliveStrings = (board: Board, geo: Geometry, colour: Stone, opts: PassAliveOptions = {}): string[][] => { - const suicideAllowed = opts.suicideAllowed ?? true; - - // Chains of `colour`. - const chainOf = new Map(); - const chains: string[][] = []; - for (const cell of geo.cells) { - if (board.get(cell) !== colour || chainOf.has(cell)) { - continue; - } - const id = chains.length; - const stones: string[] = []; - const todo = [cell]; - chainOf.set(cell, id); - while (todo.length > 0) { - const cur = todo.pop()!; - stones.push(cur); - for (const n of geo.neighbours.get(cur)!) { - if (board.get(n) === colour && !chainOf.has(n)) { - chainOf.set(n, id); - todo.push(n); - } - } - } - chains.push(stones); - } - if (chains.length === 0) { - return []; - } - - // Regions: maximal connected sets of points not holding `colour`. - const regionOf = new Map(); - const regions: Region[] = []; - for (const cell of geo.cells) { - if (board.get(cell) === colour || regionOf.has(cell)) { - continue; - } - const id = regions.length; - const points: string[] = []; - const todo = [cell]; - regionOf.set(cell, id); - while (todo.length > 0) { - const cur = todo.pop()!; - points.push(cur); - for (const n of geo.neighbours.get(cur)!) { - if (board.get(n) !== colour && !regionOf.has(n)) { - regionOf.set(n, id); - todo.push(n); - } - } - } - regions.push({ points, adjacentChains: new Set(), vitalTo: new Set() }); - } - - // Which chains touch each region, and to which chains the region is vital. - for (const region of regions) { - const touches = new Map(); - let mustTouch = 0; - for (const p of region.points) { - const counts = suicideAllowed || !board.has(p); - if (counts) { - mustTouch++; - } - const seenHere = new Set(); - for (const n of geo.neighbours.get(p)!) { - const chain = chainOf.get(n); - if (chain === undefined || seenHere.has(chain)) { - continue; - } - seenHere.add(chain); - region.adjacentChains.add(chain); - if (counts) { - touches.set(chain, (touches.get(chain) ?? 0) + 1); - } - } - } - for (const [chain, n] of touches) { - if (n === mustTouch) { - region.vitalTo.add(chain); - } - } - } - - // Benson iteration. - const X = new Set(chains.map((_, i) => i)); - const R = new Set(regions.map((_, i) => i)); - let changed = true; - while (changed) { - changed = false; - for (const chain of [...X]) { - let vital = 0; - for (const rid of R) { - if (regions[rid].vitalTo.has(chain)) { - vital++; - } - } - if (vital < 2) { - X.delete(chain); - changed = true; - } - } - for (const rid of [...R]) { - for (const chain of regions[rid].adjacentChains) { - if (!X.has(chain)) { - R.delete(rid); - changed = true; - break; - } - } - } - } - return chains.filter((_, i) => X.has(i)); -}; diff --git a/src/games/killallgo/board.ts b/src/games/killallgo/board.ts deleted file mode 100644 index 114f9f76..00000000 --- a/src/games/killallgo/board.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Pure board helpers for Kill-All Go. - * - * Colours: 1 = Red (the Attacker, trying to kill everything), 2 = Blue (the Defender, trying to - * make one unconditionally alive string). These are board colours, not seats; which seat plays - * Red is decided by the opening protocol and lives in the game state. - */ -import { algebraic2coords, coords2algebraic } from "../../common/index.js"; - -export type Stone = 1 | 2; -export const RED: Stone = 1; -export const BLUE: Stone = 2; - -export type Board = Map; - -export interface Geometry { - size: number; - /** All cells, row by row from the top of the board. */ - cells: string[]; - neighbours: Map; - coords2algebraic: (x: number, y: number) => string; - algebraic2coords: (cell: string) => [number, number]; -} - -export interface StringInfo { - stones: string[]; - liberties: Set; -} - -export interface PlacementOutcome { - /** Each captured opponent string, in the order they were removed. */ - captured: string[][]; - /** The mover's own stones removed by suicide (Tromp-Taylor clearing), if any. */ - suicided: string[]; -} - -const geometryCache = new Map(); - -export const makeGeometry = (size: number): Geometry => { - const cached = geometryCache.get(size); - if (cached !== undefined) { - return cached; - } - const c2a = (x: number, y: number): string => coords2algebraic(x, y, size); - const a2c = (cell: string): [number, number] => algebraic2coords(cell, size); - const cells: string[] = []; - const neighbours = new Map(); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - const cell = c2a(x, y); - cells.push(cell); - const adj: string[] = []; - if (x > 0) { adj.push(c2a(x - 1, y)); } - if (x < size - 1) { adj.push(c2a(x + 1, y)); } - if (y > 0) { adj.push(c2a(x, y - 1)); } - if (y < size - 1) { adj.push(c2a(x, y + 1)); } - neighbours.set(cell, adj); - } - } - const geo: Geometry = { size, cells, neighbours, coords2algebraic: c2a, algebraic2coords: a2c }; - geometryCache.set(size, geo); - return geo; -}; - -export const otherColour = (colour: Stone): Stone => (colour === RED ? BLUE : RED); - -/** The string containing `cell` (which must hold a stone) and its liberties. */ -export const stringAt = (board: Board, geo: Geometry, cell: string): StringInfo => { - const colour = board.get(cell); - if (colour === undefined) { - throw new Error(`No stone at ${cell}.`); - } - const stones: string[] = []; - const liberties = new Set(); - const seen = new Set([cell]); - const todo = [cell]; - while (todo.length > 0) { - const cur = todo.pop()!; - stones.push(cur); - for (const n of geo.neighbours.get(cur)!) { - const occupant = board.get(n); - if (occupant === undefined) { - liberties.add(n); - } else if (occupant === colour && !seen.has(n)) { - seen.add(n); - todo.push(n); - } - } - } - return { stones, liberties }; -}; - -/** Every string of the given colour, each listed once. */ -export const stringsOf = (board: Board, geo: Geometry, colour: Stone): StringInfo[] => { - const seen = new Set(); - const result: StringInfo[] = []; - for (const cell of geo.cells) { - if (board.get(cell) !== colour || seen.has(cell)) { - continue; - } - const info = stringAt(board, geo, cell); - for (const s of info.stones) { - seen.add(s); - } - result.push(info); - } - return result; -}; - -/** - * Place a stone and apply Tromp-Taylor clearing: opponent strings left without liberties are - * removed first, then the mover's own string if it has none (multi-stone suicide is legal; - * a single-stone suicide is left to the superko check, which always rejects it). - * Mutates `board`. - */ -export const applyPlacement = (board: Board, geo: Geometry, cell: string, colour: Stone): PlacementOutcome => { - if (board.has(cell)) { - throw new Error(`Cell ${cell} is occupied.`); - } - board.set(cell, colour); - const opp = otherColour(colour); - const captured: string[][] = []; - const removed = new Set(); - for (const n of geo.neighbours.get(cell)!) { - if (board.get(n) !== opp || removed.has(n)) { - continue; - } - const info = stringAt(board, geo, n); - if (info.liberties.size === 0) { - for (const s of info.stones) { - board.delete(s); - removed.add(s); - } - captured.push(info.stones); - } - } - let suicided: string[] = []; - const own = stringAt(board, geo, cell); - if (own.liberties.size === 0) { - for (const s of own.stones) { - board.delete(s); - } - suicided = own.stones; - } - return { captured, suicided }; -}; - -/** Canonical string for a board position (used for positional superko). */ -export const signature = (board: Board, geo: Geometry): string => { - let sig = ""; - for (const cell of geo.cells) { - const s = board.get(cell); - sig += s === undefined ? "-" : s === RED ? "r" : "b"; - } - return sig; -}; diff --git a/test/games/killallgo-benson.test.ts b/test/games/killallgo-benson.test.ts deleted file mode 100644 index 68821151..00000000 --- a/test/games/killallgo-benson.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions */ - -import "mocha"; -import { expect } from "chai"; -import { applyPlacement, BLUE, makeGeometry, RED, signature, stringAt, type Board } from "../../src/games/killallgo/board"; -import { passAliveStrings } from "../../src/games/killallgo/benson"; - -// `X` = Blue (the colour under test), `O` = Red, `.` = empty; the first row is the top of the board. -const boardFrom = (rows: string[]): Board => { - const size = rows.length; - const geo = makeGeometry(size); - const board: Board = new Map(); - rows.forEach((row, y) => { - if (row.length !== size) { - throw new Error("Fixtures must be square."); - } - row.split("").forEach((ch, x) => { - if (ch === "X") { - board.set(geo.coords2algebraic(x, y), BLUE); - } else if (ch === "O") { - board.set(geo.coords2algebraic(x, y), RED); - } - }); - }); - return board; -}; - -interface Fixture { - name: string; - rows: string[]; - classic: boolean; - strict: boolean; -} - -const fixtures: Fixture[] = [ - { - name: "two one-point eyes on the edge", - rows: [ - "X.X.X..", - "XXXXX..", - ".......", - ".......", - ".......", - ".......", - ".......", - ], - classic: true, strict: true, - }, - { - name: "a single eye", - rows: [ - "X.XX...", - "XXXX...", - ".......", - ".......", - ".......", - ".......", - ".......", - ], - classic: false, strict: false, - }, - { - name: "a two-point eye plus a one-point eye", - rows: [ - "X..XX.X", - "XXXXXXX", - ".......", - ".......", - ".......", - ".......", - ".......", - ], - classic: true, strict: true, - }, - { - name: "two groups with one eye each", - rows: [ - "X.X.O.O", - "XXXXOOO", - ".......", - ".......", - ".......", - ".......", - ".......", - ], - classic: false, strict: false, - }, - { - name: "a 3x3 empty eye plus a one-point eye", - rows: [ - "XXXXXXX", - "X...X.X", - "X...XXX", - "X...X..", - "XXXXX..", - ".......", - ".......", - ], - classic: false, strict: false, - }, - { - name: "a 3x3 eye with an opponent stone in the centre plus a one-point eye", - rows: [ - "XXXXXXX", - "X...X.X", - "X.O.XXX", - "X...X..", - "XXXXX..", - ".......", - ".......", - ], - classic: true, strict: false, - }, - { - name: "two shared liberties and no eyes", - rows: [ - "XX.O...", - "XXXO...", - "OOOO...", - ".......", - ".......", - ".......", - ".......", - ], - classic: false, strict: false, - }, - { - name: "two chains sharing two eyes", - rows: [ - "XX.XX..", - "X.X.X..", - "XX.XX..", - ".......", - ".......", - ".......", - ".......", - ], - classic: true, strict: true, - }, - { - name: "a false eye at a cutting point", - rows: [ - "X.XO...", - "XX.O...", - "OOOO...", - ".......", - ".......", - ".......", - ".......", - ], - classic: false, strict: false, - }, - { - name: "an eye containing an opponent stone on a liberty point", - rows: [ - "XXXX...", - "XO.X...", - "XXXX...", - "X..X...", - "XXXX...", - ".......", - ".......", - ], - classic: true, strict: true, - }, -]; - -describe("Kill-All Go: Benson pass-alive detection", () => { - for (const f of fixtures) { - it(f.name, () => { - const board = boardFrom(f.rows); - const geo = makeGeometry(f.rows.length); - const classic = passAliveStrings(board, geo, BLUE, { suicideAllowed: false }); - const strict = passAliveStrings(board, geo, BLUE, { suicideAllowed: true }); - expect(classic.length > 0, "classic").to.equal(f.classic); - expect(strict.length > 0, "strict").to.equal(f.strict); - }); - } - - it("returns every stone of the alive chains and nothing else", () => { - // Three chains: the two eye-shaped ones and the single stone between the eyes. - const board = boardFrom(fixtures[7].rows); - const geo = makeGeometry(7); - const alive = passAliveStrings(board, geo, BLUE); - expect(alive).to.have.length(3); - const stones = alive.flat().sort(); - const expected = [...board.entries()].filter(([, c]) => c === BLUE).map(([cell]) => cell).sort(); - expect(stones).to.deep.equal(expected); - }); - - it("never reports the opponent's chains", () => { - const board = boardFrom(fixtures[0].rows); - const geo = makeGeometry(7); - expect(passAliveStrings(board, geo, RED)).to.deep.equal([]); - }); -}); - -describe("Kill-All Go: board mechanics", () => { - it("captures an opponent string that loses its last liberty", () => { - const geo = makeGeometry(5); - const board: Board = new Map([["c3", BLUE], ["b3", RED], ["d3", RED], ["c2", RED]]); - const outcome = applyPlacement(board, geo, "c4", RED); - expect(outcome.captured).to.deep.equal([["c3"]]); - expect(outcome.suicided).to.deep.equal([]); - expect(board.has("c3")).to.be.false; - expect(board.get("c4")).to.equal(RED); - }); - - it("removes the mover's own string on a multi-stone suicide (Tromp-Taylor clearing)", () => { - const geo = makeGeometry(5); - const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", RED]]); - const outcome = applyPlacement(board, geo, "b1", BLUE); - expect(outcome.captured).to.deep.equal([]); - expect(outcome.suicided.sort()).to.deep.equal(["a1", "b1"]); - expect(board.has("a1")).to.be.false; - expect(board.has("b1")).to.be.false; - }); - - it("captures before checking the mover's liberties", () => { - const geo = makeGeometry(5); - // Blue at a1 is in atari on b1; Red at a2 and b2 protect it from the other side. - const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", BLUE]]); - // Red plays b1: it captures a1 first and therefore keeps a liberty. - const outcome = applyPlacement(board, geo, "b1", RED); - expect(outcome.captured).to.deep.equal([["a1"]]); - expect(outcome.suicided).to.deep.equal([]); - expect(board.get("b1")).to.equal(RED); - }); - - it("finds strings and liberties", () => { - const geo = makeGeometry(5); - const board: Board = new Map([["a1", BLUE], ["b1", BLUE], ["b2", RED]]); - const info = stringAt(board, geo, "a1"); - expect(info.stones.sort()).to.deep.equal(["a1", "b1"]); - expect([...info.liberties].sort()).to.deep.equal(["a2", "c1"]); - }); - - it("signatures differ between positions", () => { - const geo = makeGeometry(5); - const a: Board = new Map([["a1", BLUE]]); - const b: Board = new Map([["a1", RED]]); - expect(signature(a, geo)).to.not.equal(signature(b, geo)); - expect(signature(a, geo)).to.equal(signature(new Map(a), geo)); - }); -}); diff --git a/test/games/killallgo.test.ts b/test/games/killallgo.test.ts index 936e43d3..d5272dcd 100644 --- a/test/games/killallgo.test.ts +++ b/test/games/killallgo.test.ts @@ -2,13 +2,10 @@ import "mocha"; import { expect } from "chai"; -import { KillAllGoGame } from "../../src/games"; import { GameFactory } from "../../src/games"; import { addResource } from "../../src"; import i18next from "i18next"; - -const RED = 1; -const BLUE = 2; +import { applyPlacement, BLUE, KillAllGoGame, makeGeometry, passAliveStrings, RED, signature, stringAt, type Board } from "../../src/games/killallgo"; const play = (g: KillAllGoGame, moves: string[]): KillAllGoGame => { for (const m of moves) { @@ -24,6 +21,245 @@ const attackerIsPlayerOne = (variants: string[] = ["size-9"]): KillAllGoGame => return g; }; +// `X` = Blue (the colour under test), `O` = Red, `.` = empty; the first row is the top of the board. +const boardFrom = (rows: string[]): Board => { + const size = rows.length; + const geo = makeGeometry(size); + const board: Board = new Map(); + rows.forEach((row, y) => { + if (row.length !== size) { + throw new Error("Fixtures must be square."); + } + row.split("").forEach((ch, x) => { + if (ch === "X") { + board.set(geo.coords2algebraic(x, y), BLUE); + } else if (ch === "O") { + board.set(geo.coords2algebraic(x, y), RED); + } + }); + }); + return board; +}; + +interface Fixture { + name: string; + rows: string[]; + classic: boolean; + strict: boolean; +} + +const fixtures: Fixture[] = [ + { + name: "two one-point eyes on the edge", + rows: [ + "X.X.X..", + "XXXXX..", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "a single eye", + rows: [ + "X.XX...", + "XXXX...", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a two-point eye plus a one-point eye", + rows: [ + "X..XX.X", + "XXXXXXX", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "two groups with one eye each", + rows: [ + "X.X.O.O", + "XXXXOOO", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a 3x3 empty eye plus a one-point eye", + rows: [ + "XXXXXXX", + "X...X.X", + "X...XXX", + "X...X..", + "XXXXX..", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a 3x3 eye with an opponent stone in the centre plus a one-point eye", + rows: [ + "XXXXXXX", + "X...X.X", + "X.O.XXX", + "X...X..", + "XXXXX..", + ".......", + ".......", + ], + classic: true, strict: false, + }, + { + name: "two shared liberties and no eyes", + rows: [ + "XX.O...", + "XXXO...", + "OOOO...", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "two chains sharing two eyes", + rows: [ + "XX.XX..", + "X.X.X..", + "XX.XX..", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "a false eye at a cutting point", + rows: [ + "X.XO...", + "XX.O...", + "OOOO...", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "an eye containing an opponent stone on a liberty point", + rows: [ + "XXXX...", + "XO.X...", + "XXXX...", + "X..X...", + "XXXX...", + ".......", + ".......", + ], + classic: true, strict: true, + }, +]; + +describe("Kill-All Go: Benson pass-alive detection", () => { + for (const f of fixtures) { + it(f.name, () => { + const board = boardFrom(f.rows); + const geo = makeGeometry(f.rows.length); + const classic = passAliveStrings(board, geo, BLUE, { suicideAllowed: false }); + const strict = passAliveStrings(board, geo, BLUE, { suicideAllowed: true }); + expect(classic.length > 0, "classic").to.equal(f.classic); + expect(strict.length > 0, "strict").to.equal(f.strict); + }); + } + + it("returns every stone of the alive chains and nothing else", () => { + // Three chains: the two eye-shaped ones and the single stone between the eyes. + const board = boardFrom(fixtures[7].rows); + const geo = makeGeometry(7); + const alive = passAliveStrings(board, geo, BLUE); + expect(alive).to.have.length(3); + const stones = alive.flat().sort(); + const expected = [...board.entries()].filter(([, c]) => c === BLUE).map(([cell]) => cell).sort(); + expect(stones).to.deep.equal(expected); + }); + + it("never reports the opponent's chains", () => { + const board = boardFrom(fixtures[0].rows); + const geo = makeGeometry(7); + expect(passAliveStrings(board, geo, RED)).to.deep.equal([]); + }); +}); + +describe("Kill-All Go: board mechanics", () => { + it("captures an opponent string that loses its last liberty", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["c3", BLUE], ["b3", RED], ["d3", RED], ["c2", RED]]); + const outcome = applyPlacement(board, geo, "c4", RED); + expect(outcome.captured).to.deep.equal([["c3"]]); + expect(outcome.suicided).to.deep.equal([]); + expect(board.has("c3")).to.be.false; + expect(board.get("c4")).to.equal(RED); + }); + + it("removes the mover's own string on a multi-stone suicide (Tromp-Taylor clearing)", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", RED]]); + const outcome = applyPlacement(board, geo, "b1", BLUE); + expect(outcome.captured).to.deep.equal([]); + expect(outcome.suicided.sort()).to.deep.equal(["a1", "b1"]); + expect(board.has("a1")).to.be.false; + expect(board.has("b1")).to.be.false; + }); + + it("captures before checking the mover's liberties", () => { + const geo = makeGeometry(5); + // Blue at a1 is in atari on b1; Red at a2 and b2 protect it from the other side. + const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", BLUE]]); + // Red plays b1: it captures a1 first and therefore keeps a liberty. + const outcome = applyPlacement(board, geo, "b1", RED); + expect(outcome.captured).to.deep.equal([["a1"]]); + expect(outcome.suicided).to.deep.equal([]); + expect(board.get("b1")).to.equal(RED); + }); + + it("finds strings and liberties", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["a1", BLUE], ["b1", BLUE], ["b2", RED]]); + const info = stringAt(board, geo, "a1"); + expect(info.stones.sort()).to.deep.equal(["a1", "b1"]); + expect([...info.liberties].sort()).to.deep.equal(["a2", "c1"]); + }); + + it("signatures differ between positions", () => { + const geo = makeGeometry(5); + const a: Board = new Map([["a1", BLUE]]); + const b: Board = new Map([["a1", RED]]); + expect(signature(a, geo)).to.not.equal(signature(b, geo)); + expect(signature(a, geo)).to.equal(signature(new Map(a), geo)); + }); +}); + describe("Kill-All Go", () => { before(() => { addResource("en"); }); after(() => { From 4ad76d43bbf11e432443e62f145c4d6ec947907a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:40:17 +0000 Subject: [PATCH 08/16] Kill-All Go: drop the concede action and move opening choices to custom buttons A refutation that neither captures the claimed string nor ends on a protected point now loses the game, which is what the protocol always meant: giving the claim up is losing, and resigning already does that. The failing sequence is submittable and every intermediate message says so, and the separate concede terminator is gone. The fixed choices of the opening protocols become custom buttons instead of an in-board button bar. Each phase needs at most one, because the alternative always involves placing stones and the clicks are unambiguous. Taking the Attacker side with handicap or batch stones owed leaves an incomplete move for the following clicks to finish, which works because the front stages a button's move rather than submitting it. The labels need front's buttons.killallgo.* keys (branch kill-all-go-buttons-2026-09-22 in samtcifihi/ap-front). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HE7GRjtjXjNPBbXXn8M7u9 --- KILL-ALL-GO-PLAN.md | 65 ++++++++++------- locales/en/apgames.json | 12 +--- locales/en/apresults.json | 3 +- locales/eo/apgames.json | 12 +--- locales/eo/apresults.json | 3 +- src/games/killallgo.ts | 136 ++++++++++++----------------------- test/games/killallgo.test.ts | 88 ++++++++++++++++++----- 7 files changed, 163 insertions(+), 156 deletions(-) diff --git a/KILL-ALL-GO-PLAN.md b/KILL-ALL-GO-PLAN.md index efba884c..ec2714d8 100644 --- a/KILL-ALL-GO-PLAN.md +++ b/KILL-ALL-GO-PLAN.md @@ -42,7 +42,7 @@ mapping returned by `getPlayerColour()`. | D8 | Ko-alive | Ignored by the engine: Blue must keep playing. No engine draw offer. | AP already has a per-move "include draw offer" checkbox, so players can agree a draw themselves. Reliable "alive in ko" detection needs search; deferred. | | D9 | Openings | One radio group `opening`: `#opening` = alternating placement (default), `handicap` = alternating placement with a handicap, `classic` (19×19 only), `pie`, `hoctaph`. | Handicap: **Player 1 is Uwate (the giver)** and chooses `n`; the challenger seats the stronger player first with the challenge form's seating option ("I play first" / "I play second"). `n` is typed on the first ply, any integer with `1 ≤ n ≤ floor(p/2)` where `p` is the number of points (180 on 19×19, 84 on 13×13, 40 on 9×9); no variant list. Player 2 (Shitate) then makes the first placement of the alternating protocol, or claims the Attacker stones straight away. **Confirmed.** | | D10 | Turn model | Plain `GameBase`, strict alternation everywhere. Every "choose a side" action that would otherwise give the same seat two plies in a row is bundled into one ply (§5.6). | No `GameBaseSequenced`, no pass padding. | -| D11 | Buttons | Role / claim buttons are rendered by the game as an `areas: [{type: "buttonBar"}]` with structured labels from `apgames.json`; clicks arrive as `piece === "_btn_"`. `custom-buttons` is used only for `pass` (front already has that label). | Avoids a `front` PR for new button labels. | +| D11 | Buttons | Every fixed choice is a **custom button** (`getButtons()`); nothing is drawn on the board. The front stages a button's move string instead of submitting it, so a button may hand back an incomplete move that board clicks then finish (taking the Attacker side while handicap or batch stones are owed). Choices that involve placing stones have no button at all: the board clicks themselves say which side you took. | Needs three new `buttons.killallgo.*` labels in `front`'s `apfront.json` (en + eo). | | D12 | Colour before roles exist | `getPlayerColour(seat)` returns a neutral grey (`"#999999"`) until Red has been claimed/chosen, then `1` / `2`. | Front renders the seat chip with `renderglyph("piece", colour)` and only uses numeric returns for palette-slot logic, so a hex string is safe. Verify in the playground during M3. | | D13 | Community credit | Non-traditional protocols carry `fans: true`; credits go into the variant *descriptions* (§7.8). Alternating placement and its handicap form: OGS-forum community protocols of unknown origin (they predate the similar openings of other AP games). Classic 17-stone setup: traditional, origin unknown. Simple pie: the standard balancing device, nobody to credit. Generalized Hoctaph's pie: Hoctaph (basic structure and insight, OGS forums) and the author of this plan (generalisation to every board size by letting the slicer pick `a` and `b` under incentives instead of splitting a fixed total). `people` on the `hoctaph` variant lists both. | **Confirmed.** Fill in your display name / AP id where the plan says ``. | | D14 | Flags | `["experimental", "custom-colours", "custom-buttons"]`. **No `pie` flag** (AP's built-in seat swap would fight the protocols). | | @@ -71,7 +71,8 @@ columns `a…s` left→right (letters are consecutive, `i` is *not* skipped), ro ### 3.4 Blue wins * Any Blue string pass-alive (§4.4) → immediate win, `eog.reason = "pass-alive"`, highlight every stone of every pass-alive Blue string. -* Red concedes a life claim → win, `eog.reason = "claim-upheld"`, highlight the claimed string. +* Red submits a refutation that neither captures the claimed string nor ends on a protected point → Blue wins, + `eog.reason = "claim-upheld"`, highlight the claimed string. (Resigning has the same effect, so no separate concede action exists.) ### 3.5 Red wins * Red captures the claimed string during a refutation ply → `eog.reason = "claim-refuted"`. @@ -132,10 +133,12 @@ a point of M, and if they do I get to answer." Move text: `claim:[:,< §3.2, but never on a point of M), then finishes the ply in exactly one of three ways: 1. **S captured** (by any stone of the sequence, protected or not): the ply ends there, game over, Red wins (`claim-refuted`). 2. **Final stone on a protected point**: the ply ends, all Red stones placed stay, phase returns to `play` with **Blue to move**. -3. **`concede`**: game over, Blue wins (`claim-upheld`), highlight S. -Move text: `[,,…,]` or `[,…,]concede`. A ply that has free placements but no terminator is -*incomplete* (`complete: -1`), so the front will not offer Submit until Red either plays a protected point, concedes, or -captures S — no accidental concessions. +3. **Anything else**: Red stopped without capturing S and without a protected point, so the attempt failed: game over, Blue + wins (`claim-upheld`), highlight S. This is the original protocol's "if they fail without playing on one of the marked + points they lose"; there is no separate concede action, because giving the claim up is exactly losing the game, which + Red can also do by resigning. +Move text: `[,,…,]`. A failing sequence is submittable (`complete: 0`) and its message says plainly that +submitting it gives the claim up and loses, so Red is warned at every intermediate step. **4.5.3 Other consequences during a refutation ply.** Captures of other Blue strings are ordinary captures. PSK applies to every stone. If a Red stone makes some Blue string pass-alive (possible when capturing a Blue stone merges regions), @@ -294,20 +297,20 @@ slice := "slice:" int "," int hoctaph a,b option := "iplace" [":" cells] | "youplace" hoctaph chooser pass := "pass" play only claim := "claim:" cell [":" cells] Blue, play phase -refute := [cells ","] (cell | "concede") Red, refute phase; last cell must be a protected point +refute := cells Red, refute phase; only the last cell may be a protected point ``` Everything is lower-cased and whitespace-stripped in `move()` as in the template. `:` separates an action from its cells, `,` separates cells — no other separators. ### 7.4 Behaviour of the standard hooks * **`moves()`** — enumerates what is enumerable: single placements (+ `pass`, + `attacker`, + `defender:` per empty - cell in `pie-choose`, + every integer `1…floor(p/2)` in `hand-n`, + `concede` and each legal protected point in `refute`). Claims, batches and `slice` are combinatorial + cell in `pie-choose`, + every integer `1…floor(p/2)` in `hand-n`, + every legal placement in `refute`, each of which is a + complete ply on its own). Claims, multi-stone refutations, batches and `slice` are combinatorial and are *not* enumerated; `move()` skips the failsafe for those shapes (Go does the same for its komi turn). No `no-moves` flag. * **`validateMove()`** — the authority for every shape above; returns `complete: -1 / 0 / 1` per §7.5 and `canrender: true` for anything that changes the board (partial batches, partial refutations, partial claims). * **`handleClick(move, row, col, piece)`** — - `piece === "_btn_attacker" | "_btn_defender" | "_btn_iplace" | "_btn_youplace" | "_btn_concede"` from the button bar; - otherwise a board click: in `play` an empty point = placement, a Blue stone (when Blue is on move) = start `claim:`, + board clicks only (custom buttons bypass `handleClick`): in `play` an empty point = placement, a Blue stone (when Blue is on move) = start `claim:`, while a claim is being built an empty point toggles a mark (re-click removes, Pippinzip style); in `refute` an empty point appends to the sequence; in batch phases an empty point toggles membership. * **`move(m, {partial})`** — applies the whole string from the ply's base state on every call (so partial re-renders are @@ -321,7 +324,7 @@ Everything is lower-cased and whitespace-stripped in `move()` as in the template `claim` lines, `eog` reasons; everything else to `super`. * **`render()`** — `vertex` board; legend `A` = `{name: "piece", colour: 1}`, `B` = colour 2 (fixed); `enter` for stones placed this ply, `exit` for captures; while a claim is pending/being built: `dots` on marks + `flood`/`outline` on the claimed - string; at EOG with a Blue win: `enter` on every stone in `alive`; `areas: [buttonBar]` with the context buttons of §7.6. + string; at EOG with a Blue win: `enter` on every stone in `alive`. No `areas`. ### 7.5 Completeness rules (`validateMove.complete`) | Shape | −1 | 0 | 1 | @@ -332,22 +335,27 @@ Everything is lower-cased and whitespace-stripped in `move()` as in the template | `attacker:` with owed stones | fewer than owed | — | exactly owed | | `defender` in pie-choose | no cell yet | — | cell given | | `claim:` | — | stone chosen, any number of marks | — (submit when ready) | -| refute | free stones only | — | ends with protected point, `concede`, or S captured | +| refute | nothing placed yet | an attempt that fails (submitting gives the claim up) | ends on a protected point, or S captured | -### 7.6 Button bar (rendered by the game, labels via `neutralAreaLabel("apgames:validation.killallgo.BTN_…")`) -| Phase | Buttons (`value`) | -|---|---| -| alt-place | Take the Attacker stones (`attacker`) | -| pie-choose / hoc-choose | Play as Attacker (`attacker`), Play as Defender (`defender`) | -| hoc-option | I place the first batch (`iplace`), Opponent places the first batch (`youplace`) | -| refute | Concede the claim (`concede`) | -| play | none (pass is a `custom-buttons` button; claims start by clicking a Defender stone) | +### 7.6 Custom buttons (`getButtons()`; labels are `buttons.