diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9b7719..f9144264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Since the `1.0.0-beta` release, the version in `package.json` has stayed at `1.0 ### Added -- **New games (dev / experimental only):** Agents of MARS, Bagh Chal, Bashni, Clearpath, Croda, Dama, Ice Palace, Kill-All Go, Neutron, Selfo, Thricewise, Yodd. +- **New games (dev / experimental only):** Agents of MARS, Bagh Chal, Bashni, Clearpath, Croda, Dama, Ice Palace, Kill-All Go, Neutron, Selfo, Thricewise, Thue-Morse Go, Yodd. ## [1.0.0-ci] - 2026-10-17 diff --git a/locale-src/en/apresults/apresults-path-map.json b/locale-src/en/apresults/apresults-path-map.json index b02353b6..ea80206f 100644 --- a/locale-src/en/apresults/apresults-path-map.json +++ b/locale-src/en/apresults/apresults-path-map.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-15", + "generatedAt": "2026-10-17", "schemaVersion": 1, "entries": [ { @@ -6722,6 +6722,216 @@ "newFullKey": "apresults:thricewise.SELECT.default", "english": "{{player}} selected {{card}}." }, + { + "oldPath": "CAPTURE.thuemorsego_suicide_one", + "oldFullKey": "apresults:CAPTURE.thuemorsego_suicide_one", + "uid": "thuemorsego", + "resultType": "CAPTURE", + "newLeaf": "suicide_one", + "newPath": "thuemorsego.CAPTURE.suicide_one", + "newFullKey": "apresults:thuemorsego.CAPTURE.suicide_one", + "english": "{{player}} removed their own stone by suicide." + }, + { + "oldPath": "CAPTURE.thuemorsego_suicide_other", + "oldFullKey": "apresults:CAPTURE.thuemorsego_suicide_other", + "uid": "thuemorsego", + "resultType": "CAPTURE", + "newLeaf": "suicide_other", + "newPath": "thuemorsego.CAPTURE.suicide_other", + "newFullKey": "apresults:thuemorsego.CAPTURE.suicide_other", + "english": "{{player}} removed {{count}} of their own stones by suicide." + }, + { + "oldPath": "CLAIM.thuemorsego_attacker", + "oldFullKey": "apresults:CLAIM.thuemorsego_attacker", + "uid": "thuemorsego", + "resultType": "CLAIM", + "newLeaf": "attacker", + "newPath": "thuemorsego.CLAIM.attacker", + "newFullKey": "apresults:thuemorsego.CLAIM.attacker", + "english": "{{player}} chose to play as the Attacker." + }, + { + "oldPath": "DECLARE.thuemorsego_komi_one", + "oldFullKey": "apresults:DECLARE.thuemorsego_komi_one", + "uid": "thuemorsego", + "resultType": "DECLARE", + "newLeaf": "komi_one", + "newPath": "thuemorsego.DECLARE.komi_one", + "newFullKey": "apresults:thuemorsego.DECLARE.komi_one", + "english": "{{player}} gave their opponent a reverse komi of {{count}} point." + }, + { + "oldPath": "DECLARE.thuemorsego_komi_other", + "oldFullKey": "apresults:DECLARE.thuemorsego_komi_other", + "uid": "thuemorsego", + "resultType": "DECLARE", + "newLeaf": "komi_other", + "newPath": "thuemorsego.DECLARE.komi_other", + "newFullKey": "apresults:thuemorsego.DECLARE.komi_other", + "english": "{{player}} gave their opponent a reverse komi of {{count}} points." + }, + { + "oldPath": "DECLARE.thuemorsego_stones_one", + "oldFullKey": "apresults:DECLARE.thuemorsego_stones_one", + "uid": "thuemorsego", + "resultType": "DECLARE", + "newLeaf": "stones_one", + "newPath": "thuemorsego.DECLARE.stones_one", + "newFullKey": "apresults:thuemorsego.DECLARE.stones_one", + "english": "{{player}} set the handicap to one stone." + }, + { + "oldPath": "DECLARE.thuemorsego_stones_other", + "oldFullKey": "apresults:DECLARE.thuemorsego_stones_other", + "uid": "thuemorsego", + "resultType": "DECLARE", + "newLeaf": "stones_other", + "newPath": "thuemorsego.DECLARE.stones_other", + "newFullKey": "apresults:thuemorsego.DECLARE.stones_other", + "english": "{{player}} set the handicap to {{count}} stones." + }, + { + "oldPath": "EOG.thuemorsego_no_moves", + "oldFullKey": "apresults:EOG.thuemorsego_no_moves", + "uid": "thuemorsego", + "resultType": "EOG", + "newLeaf": "no_moves", + "newPath": "thuemorsego.EOG.no_moves", + "newFullKey": "apresults:thuemorsego.EOG.no_moves", + "english": "The game has ended because the player to move has no legal move." + }, + { + "oldPath": "EOG.thuemorsego_no_room", + "oldFullKey": "apresults:EOG.thuemorsego_no_room", + "uid": "thuemorsego", + "resultType": "EOG", + "newLeaf": "no_room", + "newPath": "thuemorsego.EOG.no_room", + "newFullKey": "apresults:thuemorsego.EOG.no_room", + "english": "The game has ended because the Attacker's unconditionally alive stones leave the Defender no room to form an unconditionally alive string." + }, + { + "oldPath": "EOG.thuemorsego_pass_alive", + "oldFullKey": "apresults:EOG.thuemorsego_pass_alive", + "uid": "thuemorsego", + "resultType": "EOG", + "newLeaf": "pass_alive", + "newPath": "thuemorsego.EOG.pass_alive", + "newFullKey": "apresults:thuemorsego.EOG.pass_alive", + "english": "The game has ended because the Defender formed an unconditionally alive string." + }, + { + "oldPath": "EOG.thuemorsego_repetition", + "oldFullKey": "apresults:EOG.thuemorsego_repetition", + "uid": "thuemorsego", + "resultType": "EOG", + "newLeaf": "repetition", + "newPath": "thuemorsego.EOG.repetition", + "newFullKey": "apresults:thuemorsego.EOG.repetition", + "english": "The game has ended in a draw because the same position occurred five times." + }, + { + "oldPath": "PASS.thuemorsego_handicap", + "oldFullKey": "apresults:PASS.thuemorsego_handicap", + "uid": "thuemorsego", + "resultType": "PASS", + "newLeaf": "handicap", + "newPath": "thuemorsego.PASS.handicap", + "newFullKey": "apresults:thuemorsego.PASS.handicap", + "english": "{{player}} gave a handicap stone by passing." + }, + { + "oldPath": "PLACE.thuemorsego_setup", + "oldFullKey": "apresults:PLACE.thuemorsego_setup", + "uid": "thuemorsego", + "resultType": "PLACE", + "newLeaf": "setup", + "newPath": "thuemorsego.PLACE.setup", + "newFullKey": "apresults:thuemorsego.PLACE.setup", + "english": "{{player}} placed an Attacker stone at {{where}}." + }, + { + "oldPath": "REMOVE.thuemorsego_dead_one", + "oldFullKey": "apresults:REMOVE.thuemorsego_dead_one", + "uid": "thuemorsego", + "resultType": "REMOVE", + "newLeaf": "dead_one", + "newPath": "thuemorsego.REMOVE.dead_one", + "newFullKey": "apresults:thuemorsego.REMOVE.dead_one", + "english": "The stone marked dead was removed from the board." + }, + { + "oldPath": "REMOVE.thuemorsego_dead_other", + "oldFullKey": "apresults:REMOVE.thuemorsego_dead_other", + "uid": "thuemorsego", + "resultType": "REMOVE", + "newLeaf": "dead_other", + "newPath": "thuemorsego.REMOVE.dead_other", + "newFullKey": "apresults:thuemorsego.REMOVE.dead_other", + "english": "The {{count}} stones marked dead were removed from the board." + }, + { + "oldPath": "SELECT.thuemorsego_changed_one", + "oldFullKey": "apresults:SELECT.thuemorsego_changed_one", + "uid": "thuemorsego", + "resultType": "SELECT", + "newLeaf": "changed_one", + "newPath": "thuemorsego.SELECT.changed_one", + "newFullKey": "apresults:thuemorsego.SELECT.changed_one", + "english": "{{player}} changed the marking: one stone is marked dead." + }, + { + "oldPath": "SELECT.thuemorsego_changed_other", + "oldFullKey": "apresults:SELECT.thuemorsego_changed_other", + "uid": "thuemorsego", + "resultType": "SELECT", + "newLeaf": "changed_other", + "newPath": "thuemorsego.SELECT.changed_other", + "newFullKey": "apresults:thuemorsego.SELECT.changed_other", + "english": "{{player}} changed the marking: {{count}} stones are marked dead." + }, + { + "oldPath": "SELECT.thuemorsego_cleared", + "oldFullKey": "apresults:SELECT.thuemorsego_cleared", + "uid": "thuemorsego", + "resultType": "SELECT", + "newLeaf": "cleared", + "newPath": "thuemorsego.SELECT.cleared", + "newFullKey": "apresults:thuemorsego.SELECT.cleared", + "english": "{{player}} cleared the marking: no stones are marked dead." + }, + { + "oldPath": "SELECT.thuemorsego_locked", + "oldFullKey": "apresults:SELECT.thuemorsego_locked", + "uid": "thuemorsego", + "resultType": "SELECT", + "newLeaf": "locked", + "newPath": "thuemorsego.SELECT.locked", + "newFullKey": "apresults:thuemorsego.SELECT.locked", + "english": "Dead-stone marking has ended: no stones are marked dead, and passes carry no marking for the rest of the game." + }, + { + "oldPath": "SELECT.thuemorsego_marked_one", + "oldFullKey": "apresults:SELECT.thuemorsego_marked_one", + "uid": "thuemorsego", + "resultType": "SELECT", + "newLeaf": "marked_one", + "newPath": "thuemorsego.SELECT.marked_one", + "newFullKey": "apresults:thuemorsego.SELECT.marked_one", + "english": "{{player}} marked one stone as dead." + }, + { + "oldPath": "SELECT.thuemorsego_marked_other", + "oldFullKey": "apresults:SELECT.thuemorsego_marked_other", + "uid": "thuemorsego", + "resultType": "SELECT", + "newLeaf": "marked_other", + "newPath": "thuemorsego.SELECT.marked_other", + "newFullKey": "apresults:thuemorsego.SELECT.marked_other", + "english": "{{player}} marked {{count}} stones as dead." + }, { "oldPath": "CLAIM.toguz", "oldFullKey": "apresults:CLAIM.toguz", diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 3678273f..297e0696 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -76,6 +76,9 @@ "loseI": "Give up infantry", "loseA": "Give up artillery", "loseC": "Give up cavalry" + }, + "thuemorsego": { + "attacker": "Play as the Attacker" } }, "categories": { @@ -711,6 +714,26 @@ "STAGE_SECOND": "Second chance: the defender may still retreat", "TURN": "Turn" }, + "thuemorsego": { + "NEXT_PLACEMENT": "Next placements (hex)", + "BUTTON": "Button", + "BUTTON_AVAILABLE": "Available", + "PASSES_OWED": "Handicap stones still to give", + "DEFENDER": "Defender", + "ATTACKER": "Attacker", + "UNDECIDED": "Not yet chosen", + "HANDICAP": "Handicap stones", + "PHASE_HAND_N": "{{player}} sets the number of handicap stones", + "PHASE_ALT_PLACE": "{{player}} places an Attacker stone or takes the Attacker side", + "PHASE_DECLARE": "{{player}} sets the number of handicap stones", + "PHASE_KOMI": "{{player}} chooses the reverse komi", + "PHASE_KOMI_DECLARE": "{{player}} chooses the reverse komi and the handicap", + "KOMI": "Reverse komi", + "MARKING": "Dead-stone marking", + "MARKING_COUNT_one": "One stone marked dead", + "MARKING_COUNT_other": "{{count}} stones marked dead", + "MARKING_LOCKED": "Ended: all dead stones should be manually captured before passing" + }, "tintas": { "MAJORITIES": "Majorities won", "MONOCHROME": "Monochrome potential" @@ -3705,6 +3728,101 @@ "VALID_PARTIAL_CELL": "Choose a cell for this card.", "VALID_PARTIAL_MORE": "Place your remaining card(s)." }, + "thuemorsego": { + "INSTRUCTIONS_PLAY_one": "Place a stone, or pass; this move is a single placement.", + "INSTRUCTIONS_PLAY_other": "This move is {{count}} placements: place up to {{count}} stones, or submit early to pass the rest.", + "INSTRUCTIONS_PLAY_BUTTON_one": "Place a stone, pass, or take the button (worth {{points}}); this move is a single placement.", + "INSTRUCTIONS_PLAY_BUTTON_other": "This move is {{count}} placements: place up to {{count}} stones, take the button (worth {{points}}) in place of one, or submit early to pass the rest.", + "INSTRUCTIONS_PLAY_FORCED": "The first placement of this move is given away as a handicap stone. Place a stone for the other, or pass.", + "INSTRUCTIONS_PLAY_FORCED_BUTTON": "The first placement of this move is given away as a handicap stone. Place a stone for the other, take the button (worth {{points}}), or pass.", + "INSTRUCTIONS_PLAY_DEFENDER_one": "Place a stone. You win by forming one unconditionally alive string.", + "INSTRUCTIONS_PLAY_DEFENDER_other": "Place {{count}} stones. You win by forming one unconditionally alive string.", + "INSTRUCTIONS_PLAY_ATTACKER_one": "Place a stone. You win by preventing the Defender from forming an unconditionally alive string.", + "INSTRUCTIONS_PLAY_ATTACKER_other": "Place {{count}} stones. You win by preventing the Defender from forming an unconditionally alive string.", + "INSTRUCTIONS_DECLARE": "You are giving the handicap. Click a numbered stone to set how many handicap stones you give, from 1 to {{max}}: you pass that many placements while your opponent places freely, and this declaration is the first of them.", + "INSTRUCTIONS_BOTH": "Begin this move with the reverse komi and the handicap, in either order. Click a numbered stone in the upper row, or enter any whole number from 1 to {{max}} in the move box, to set how many points you give your opponent; click one in the lower row to set how many handicap stones you give, from 1 to {{stones}}. The komi costs no placement; the handicap stones are the placements you pass, this declaration being the first of them.", + "HANDICAP_THEN_KOMI_one": "Handicap of {{count}} stone. Now choose the reverse komi: click a numbered stone in the upper row, or enter a whole number in the move box.", + "HANDICAP_THEN_KOMI_other": "Handicap of {{count}} stones. Now choose the reverse komi: click a numbered stone in the upper row, or enter a whole number in the move box.", + "KOMI_MISSING": "Choose the reverse komi before placing a stone: click a numbered stone in the upper row, or enter a whole number first.", + "DECLARE_MISSING": "Choose the handicap before placing a stone: click one of its numbered stones first.", + "CHOOSE_FIRST": "Choose the reverse komi and the handicap first: click a numbered stone from each row. {{where}} is not one of them.", + "BAD_DECLARATION": "The reverse komi and the handicap come before the placements, once each: a number of points as 14p and a number of stones as 3s, in either order, or plain numbers in that order.", + "DECLARE_RANGE": "The handicap must be between 1 and {{max}} stones.", + "DECLARE_NO_PLACEMENT": "With a handicap of {{count}} stones, every placement of this move is given away.", + "DECLARE_OK_one": "Handicap of one stone. Submit, or click a different numbered stone.", + "DECLARE_OK_other": "Handicap of {{count}} stones. Submit, or click a different numbered stone.", + "DECLARE_ONE": "Handicap of one stone, given by this declaration. Place a stone as the second placement of this move, or submit to pass it.", + "DECLARE_ONE_BUTTON": "Handicap of one stone, given by this declaration. Place a stone or take the button as the second placement of this move, or submit to pass it.", + "DECLARE_FIRST": "Choose the handicap first: click one of its numbered stones. {{where}} is not one of them.", + "NO_PLACEMENT_LEFT": "This move has no placement left to make: submit, or click a numbered stone to change your choice.", + "TOO_MANY_one": "This move has only one placement left to fill.", + "TOO_MANY_other": "This move has only {{count}} placements left to fill.", + "FORCED_MOVE": "Every placement of this move is given away as handicap stones, which happens automatically.", + "NO_PASS": "Passing is not allowed in Kill-All games.", + "NO_BUTTON": "There is no button in this game.", + "BUTTON_TAKEN": "The button has already been taken.", + "KO_PSK": "That placement would recreate an earlier position of the board (positional superko).", + "KO_PSK_DDPSK": "That placement would recreate an earlier position of the board for the fourth time (double delayed superko).", + "SUICIDE_REPEAT": "That stone has no liberties and captures nothing, so it is removed as suicide and the board is left as it was, which positional superko forbids.", + "SUICIDE_REPEAT_DDPSK": "That stone has no liberties and captures nothing, so it is removed as suicide and the board is left as it was, a position that has already arisen three times, which double delayed superko forbids.", + "SUICIDE_REPEAT_MOVE": "The stones placed this move have no liberties and capture nothing, so the clearing after its last placement removes them and leaves the board as it was before the move, which positional superko forbids.", + "SUICIDE_REPEAT_MOVE_DDPSK": "The stones placed this move have no liberties and capture nothing, so the clearing after its last placement removes them and leaves the board as it was before the move, a position that has already arisen three times, which double delayed superko forbids.", + "OVER_MID_MOVE": "The game ended at the previous placement.", + "PLACE_MORE_one": "Place one more stone.", + "PLACE_MORE_other": "Place {{count}} more stones.", + "DEAD_END": "No legal placement can follow that one; choose a different placement.", + "MUST_CONTINUE": "Ending the move here would recreate an earlier position of the board; place another stone, or choose a different placement.", + "MUST_CONTINUE_SUICIDE": "Ending the move here would leave that stone with no liberties and nothing captured, so it would be removed as suicide and the board left as it was; place another stone, or choose a different placement.", + "INSTRUCTIONS_HANDICAP": "You are giving the handicap. Click a numbered stone to set how many extra Attacker stones, from 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 click a different numbered stone.", + "HANDICAP_OK_other": "Handicap of {{count}} stones. Submit, or click a different numbered stone.", + "NOT_A_NUMBERED_STONE": "{{where}} is not one of the numbered stones.", + "INSTRUCTIONS_ALT": "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, your opponent moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, place one extra stone; your opponent then moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, place {{count}} extra stones; your opponent then moves first as the Defender.", + "SETUP_FULL": "The board already holds the maximum number of setup stones this phase allows ({{max}}); take the Attacker side instead of placing another.", + "ATTACKER_NO_STONES": "Taking the Attacker 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": "You must place exactly one extra Attacker stone.", + "BATCH_TOO_MANY_other": "You must place exactly {{count}} extra Attacker stones.", + "DUPLICATE_CELL": "The cell {{where}} is listed twice.", + "INVALID_PASS": "You cannot pass now.", + "INSTRUCTIONS_KOMI": "Begin this move with the reverse komi: click one of the numbered stones, or enter any whole number from 1 to {{max}} in the move box, to set how many points you give your opponent. It costs no placement; your stones follow it.", + "KOMI_INVALID": "This move must begin with the reverse komi: a whole number from 1 to {{max}}, the numbered stones offering the multiples of 14.", + "KOMI_THEN_PLACE_one": "Reverse komi of {{count}} point to your opponent. Now place your stones, or submit to pass this move.", + "KOMI_THEN_PLACE_other": "Reverse komi of {{count}} points to your opponent. Now place your stones, or submit to pass this move.", + "KOMI_THEN_DECLARE_one": "Reverse komi of {{count}} point to your opponent. Now click a numbered stone in the lower row to set the handicap.", + "KOMI_THEN_DECLARE_other": "Reverse komi of {{count}} points to your opponent. Now click a numbered stone in the lower row to set the handicap.", + "KOMI_FIRST": "Choose the reverse komi first: click one of its numbered stones, or enter a whole number in the move box. {{where}} is not one of the numbered stones.", + "OCCUPIED_MARK": "{{where}} is occupied. To mark its string as dead, press Pass first, then click the string; in a two-placement move a stone can still be placed for one of the placements.", + "MARKS_NEED_PASS": "Strings can be marked dead only in a move that passes at least one placement: pass first, then click or name the strings.", + "MARK_EMPTY": "There is no stone at {{where}} to mark dead.", + "MARKS_LOCKED": "Strings can no longer be marked dead: dead-stone marking ended when a move passing nothing, or a pass marking nothing dead, followed a marking. Capture any dead stones on the board, then end the game with two whole-move passes in turn; every stone still on the board then counts as alive.", + "PASS_OPEN_zero": "Submit to pass this move, click strings to mark them dead first, or place a stone instead. If your opponent then passes keeping the marking, the game ends with the marked strings removed.", + "PASS_OPEN_one": "Submit to pass this move with one stone marked dead, or click strings to change the marking. If your opponent then passes keeping it, the game ends with the marked strings removed.", + "PASS_OPEN_other": "Submit to pass this move with {{count}} stones marked dead, or click strings to change the marking. If your opponent then passes keeping it, the game ends with the marked strings removed.", + "PASS_AGREE_zero": "Your opponent passed with no strings marked dead. Submit to pass and end the game, or click strings to mark them dead, after which your opponent may pass to accept the marking or play on.", + "PASS_AGREE_one": "Your opponent passed with one stone marked dead. Submit to pass and end the game with it removed, or click strings to change the marking, after which your opponent may pass to accept it or play on.", + "PASS_AGREE_other": "Your opponent passed with {{count}} stones marked dead. Submit to pass and end the game with them removed, or click strings to change the marking, after which your opponent may pass to accept it or play on.", + "PASS_DISPUTE_one": "This changes your opponent's marking to one stone marked dead, so the game goes on: your opponent may pass to accept it, change it again, or play on. Submit, or click strings to change the marking.", + "PASS_DISPUTE_other": "This changes your opponent's marking to {{count}} stones marked dead, so the game goes on: your opponent may pass to accept it, change it again, or play on. Submit, or click strings to change the marking.", + "PASS_PLACE_zero": "Submit to pass the rest of this move, or place another stone. Passing part of a move does not end the game, but your opponent may then pass their whole move to end it.", + "PASS_PLACE_one": "Submit to pass the rest of this move with one stone marked dead, or place another stone. Passing part of a move does not end the game, but your opponent may then pass their whole move keeping the marking to end it with the marked string removed.", + "PASS_PLACE_other": "Submit to pass the rest of this move with {{count}} stones marked dead, or place another stone. Passing part of a move does not end the game, but your opponent may then pass their whole move keeping the marking to end it with the marked strings removed.", + "PASS_LOCKED_PLACE": "Dead-stone marking has ended for this game, so passes carry no marking. Submit to pass the rest of this move, or place another stone; your opponent may then pass their whole move to end the game with every stone alive.", + "PASS_CLEARS": "Passing with no strings marked dead after a marking ends dead-stone marking for the rest of the game: the game goes on, dead stones must be captured, and the game ends when both players pass their whole moves in turn, with every stone on the board alive. Submit, or click strings to mark them dead instead.", + "PASS_CLEARS_PLACE": "Passing the rest of this move with no strings marked dead after a marking ends dead-stone marking for the rest of the game, as does placing another stone: the game goes on, dead stones must be captured, and the game ends when both players pass their whole moves in turn, with every stone on the board alive. Submit, or place another stone.", + "MARK_CLICK": "Click strings to mark them dead, or to change the marking.", + "MARK_HINT": "To mark strings dead as well, press Pass first, then place the stone again and click them.", + "PLACING_ENDS_MARKING": "This move passes nothing, so it ends dead-stone marking for the rest of the game: dead stones must then be captured, and the game ends when both players pass their whole moves in turn, with every stone on the board alive.", + "MARKING_PENDING_one": "One stone is marked dead. Pass at least one placement to answer the marking; a move that passes nothing ends dead-stone marking for the rest of the game.", + "MARKING_PENDING_other": "{{count}} stones are marked dead. Pass at least one placement to answer the marking; a move that passes nothing ends dead-stone marking for the rest of the game.", + "PASS_LOCKED": "Dead-stone marking has ended for this game, so passes carry no marking. Submit to pass this move, or place a stone for one of its placements; if your opponent then passes their whole move, the game ends with every stone alive.", + "PASS_LOCKED_ENDS": "Dead-stone marking has ended for this game, so passes carry no marking. Your opponent's last move passed: submit to pass this move and end the game with every stone alive, or place a stone to play on." + }, "tintas": { "CAPTURED_LABEL": "{{player}}'s captured pieces", "INITIAL_INSTRUCTIONS_movepawn": "Select the cell where you want the pawn to move.", @@ -4293,6 +4411,7 @@ "terrace": "A royal capture/escape game played on a three-dimensional board with pieces of various sizes.", "tessella": "Pieces capture in pairs using a \"shooting\" mechanic. Be the first to capture four of your opponent's pieces.", "thricewise": "A fast-paced Decktet tableau game: each turn everyone secretly chooses a card, then non-deferred plays go onto the grid in rank order. Score whenever your play completes a line of three (flush, set, or straight). Highest score when the grid fills or hands are empty wins.", + "thuemorsego": "Go in which the order of placements follows the Thue-Morse sequence (0110 1001 1001 0110 …), so every move is one or two stones and no komi is needed. Tromp-Taylor rules with suicide allowed, an optional button, and a Kill-All variant.", "tintas": "Players race to either collect all seven of one colour or to collect four of four different colours.", "toguz": "A traditional mancala-style sowing game from central Asia where you attempt to capture the majority of the pieces.", "trax": "A game of loops and lines. Place tiles adjacent to those already in play such that the colours of the tracks match. The objective is to get a loop or line of your colour while attempting to stop your opponent in their colour. What gives Trax its strategic depth is the forced play rule which allows (or even requires) multiple tiles to be played in a turn.", @@ -4591,6 +4710,7 @@ "terrace": "Terrace", "tessella": "Tessella", "thricewise": "Thricewise", + "thuemorsego": "Thue-Morse Go", "tintas": "Tintas", "toguz": "Toguz Kumalak", "trax": "Trax", @@ -4717,6 +4837,7 @@ "tbt": "When it's your turn, you will see the die you have to work with, but once your move is complete, the die will reroll. Exploration is not helpful because the die roll is not finalized until after the move is submitted. As you scroll back through the game history, the die you see is for the *next* turn. The die used to make the move you're seeing is displayed below the board.", "terrace": "The Assassination variant is described in [this BGG thread](https://boardgamegeek.com/thread/551125/variant-for-more-aggressive-less-drawish-play), summarized below:\n\n- Up straight, you must be larger or a smallest-size piece can assassinate a largest-size piece.\n\n- Same level (orthogonally adjacent only), you must be at least the same size.\n\n- Down diagonal, you may be one rank smaller.", "thricewise": "With 2 or 4 players the opening grid is 2×2; with 3 or 5 players it is 2×3. Short labels appear beneath cards in your hand. \"Sel\" indicates the card chosen in the last selection phase (including an uncommitted partial select) and \"Def\" indicates cards deferred from an earlier tied selection. More information on the Decktet is on the [Decktet wiki](https://decktet.wikidot.com/).", + "thuemorsego": "Placements follow the Thue-Morse sequence: placement n (counting from 0) belongs to Player 1 when n has an even number of 1 bits in binary and to Player 2 otherwise, giving the order 0110 1001 1001 0110 … A move is a maximal run of placements by one player, so every move is one or two placements. The tracker beside the board shows the next 16 placements in a single column, those of the next move as full-size stones; the tint behind the column and the border around it are the colour of the next placement. A display option shows instead the index of the next placement in hexadecimal, one column per digit: the rightmost column gives the colours of the placements to come before the next carry, each other column the colour of the 0 row of the column to its right, and the marked cell of each column carries its digit. It has three columns; past placement fff it shows only grey stones, with no markers or border, rather than digits it cannot show. The Balanced Marseillais protocol repeats the first four terms (ABBA) instead, and shows no tracker.\n\nStones are placed and captured under Tromp-Taylor rules: after a placement, opponent strings without liberties are removed, then your own, so multi-stone suicide is legal. Any placement may instead be a pass or, while nobody holds it, take the button, worth one point to its holder. By default the button evens the count: there is one on boards with an odd number of points (11x11, 19x19, 23x23) and none on boards with an even number (16x16); the 0.5 point button variant makes it worth half a point on any board, so scores cannot tie. A move lists its placements separated by commas; a move submitted with fewer placements than it allows passes the rest. A player's score is their area (stones plus the empty points reaching only their colour) plus the button; there is no komi. Captured stones do not score. Dots show the empty points that would count as territory as the board stands, so a lone first stone claims the whole board; a display option hides them.\n\nEnding the game: the game ends when a player passes their whole move right after the opponent passed at least one placement, keeping the marking of dead strings the opponent's move carried. A move that passes any of its placements, whether or not it also places a stone, may mark strings of either colour dead: click them, or name them after the passes (`pass,pass,-a1` marks the string at a1; `e5,pass,-a1` places e5, passes the other placement and marks it). The marked stones are shown faded, and the dots show the territory as it would be scored with them removed, the points under them included; a mark on a stone the move's own placement captures drops out. The opponent's next move starts from that marking, and clicking a string changes it. If the opponent passes their whole move keeping the marking, the game ends: the marked strings are removed and the board is scored. A move that places a stone, or that changes the marking, never ends the game, and the players may keep passing and changing the marking in turn for as long as every move passes at least one placement and keeps some string marked. Dead-stone marking ends for the rest of the game as soon as a marking is answered by a move that passes nothing, or by a pass that marks nothing dead. From then on passes carry no marking, dead stones must be captured for real, and the game ends when a whole-move pass follows a move of the opponent that passed at least one placement, with every stone on the board alive. A stone placed after passing the move is entered as the placement followed by the pass, such as `e5,pass`.\n\nBy default there is no ko rule, and the fifth occurrence of a position ends the game in a draw, even partway through a move. Under the positional superko variant no placement may recreate an earlier position of the board, including positions reached partway through a move; under double delayed superko a position may arise three times, and only a placement that would make a fourth occurrence is forbidden. Passes, taking the button and handicap passes change nothing and create no position. A single stone with no liberties that captures nothing is removed as suicide and so recreates the position it was played into, which is why superko does not allow it, and double delayed superko only until the position has arisen three times.\n\nWeak eyes: normally every placement clears the board, so two eyes live. With checked weak eyes only the last placement of a move clears it, so a two-placement move can fill both eyes of a group, three eyes are needed to live, and a placement that would be suicide can be rescued by the second placement of the move. Only the position the move leaves then counts for repetition, not the one partway through it.\n\nHandicap stones: Player 2's first move begins by giving Player 1 a number of handicap stones, up to a fortieth of the board's points rounded away from nine (3 on 11x11, 6 on 16x16, 10 on 19x19, 14 on 23x23): Player 2 passes that many placements, the first with the declaration itself and the rest automatically at the start of their following moves. These passes do not count towards ending the game and may run out partway through a two-placement move. In the move notation the handicap is a number followed by s, such as 3s, and the reverse komi a number followed by p, such as 14p; they come before the placements in either order.\n\nReverse komi: Player 2's first move begins by giving Player 1 a reverse komi, any whole number of points up to 14 times the handicap limit (the numbered stones offer the multiples of 14), added to Player 1's score at the end of the game. Unlike the handicap it costs no placement; it comes before any handicap declaration, and the game is unrated.\n\nKill-All: players take turns placing Attacker stones until one of them claims the Attacker side (pieboxing); the other plays the Defender and makes placement 0. The Defender wins by forming an unconditionally alive string (pass-alive by Benson's algorithm, with three vital regions instead of two under checked weak eyes), the Attacker by making that impossible. The Attacker's stones take the first colour and the Defender's the second, as in Kill-All Go; a palette set for this game can change them through slots 3 and 4, for instance the other way round. Passing is not allowed, and a player with no legal move loses. With a handicap, Player 1 first sets a number of extra Attacker stones, up to twice the handicap limit, that Player 2 places immediately on claiming the Attacker side. There is no button in Kill-All games. In the position that ends the game, the pass-alive stones of both sides are shown faded; a display option shows them faded in every position.\n\nColours: a stone's border is black or white, whichever contrasts better with its fill. A display option takes the borders from palette slots 5 to 8 instead, for stones in slots 1 to 4; it needs a palette of at least eight colours, since a palette that lacks a slot the game names cannot be drawn.\n\nOrigins: the Thue-Morse order was proposed for Go independently by atimholt and TheElvenAngelCatboy on r/baduk and by BHydden on the Online Go Forum, each pointing to the same video about the sequence; the forum community then built tools for playing it on OGS, ran tournaments, and worked out the rules implemented here. The Balanced Marseillais order comes from chess: Marseillais Chess (AABB) is attributed either to Albert Fortis with J. Rossow or to Jehan de Queylar, and Robert Bruce first proposed the ABBA correction that balances it.", "toguz": "Depicting state changes in sowing games is challenging. The initial chosen pit is marked, as is any capture. Small numbers appear to show the change in the number of stones in each pit. If you believe you have encountered a bug, please let us know in Discord.", "tricouleur": "Tricouleur is a 2005 game designed by Bill Taylor. Each player has a army of three types of pieces that are able to capture the next type, in a cyclic way: think Rock-Paper-Scissors (Circle-Square-Ex). The move sequence is 12* where a player must use different pieces (it is also possible to pass the entire turn). A piece may move to an empty neighboring cell (in which case it leaves an unmoved duplicate behind) or to an empty cell among the twelve 2nd-neighbours, regardless of what is in between. Any weaker opposing pieces that the moved piece becomes adjacent to are recolored as the moving piece. Any same-strength opposing pieces that the moved piece becomes adjacent to, are removed from the board.\n\nThe game ends when either (a) both players pass consecutively, (b) the board is full, (c) one army is destroyed, (d) a position is repeated three times. Whoever has the most pieces of their own colours in total, wins.", "tumbleweed": "A space is claimed by a player if they have a piece on it, or if they have the majority of the lines of sight to it. The score is the amount of territory own by each player. The game ends when both players pass in succession. If there is no change in score for 20 plies, the game also ends.\n\nThe experimental **Fog of war** variant hides off-sight cells; last-seen cells stay on the board dimmed until line of sight returns. The score sidebar and influence/threatened overlays are disabled in that variant.", @@ -5230,6 +5351,28 @@ "name": "Flat view" } }, + "thuemorsego": { + "tms-tracker": { + "name": "TMS tracker", + "description": "Show the tracker as the three hexadecimal digits of the next placement's index, one column of 16 per digit, instead of a single column of the next 16 placements." + }, + "digits-down": { + "name": "Tracker runs downward", + "description": "Run the tracker from the top down: the next placement at the top of the column, or digit 0 at the top of each column of the digit tracker." + }, + "hide-territory": { + "name": "Hide territory dots", + "description": "Do not dot the points that count as territory as the board stands: the empty points reaching stones of only one colour, with the strings marked dead removed. Strings marked dead are still shown faded. Kill-All games show no territory anyway." + }, + "palette-borders": { + "name": "Stone borders from the palette", + "description": "Draw the border of each stone in palette slot 5 to 8, for stones in slots 1 to 4, instead of black or white, whichever contrasts better with the fill. The palette must have at least eight colours." + }, + "pass-alive-throughout": { + "name": "Mark pass-alive stones throughout the game", + "description": "In Kill-All games, show the unconditionally alive (pass-alive) stones of both sides faded in every position, not only in the position that ended the game. Does nothing outside Kill-All games." + } + }, "toguz": { "pips": { "description": "Display the stones as pips instead of as numerals.", @@ -8700,6 +8843,68 @@ "description": "This variant expands the capturing options, leading to a more aggressive game." } }, + "thuemorsego": { + "size-11": { + "name": "11x11 board" + }, + "#board": { + "name": "16x16 board" + }, + "size-19": { + "name": "19x19 board" + }, + "size-23": { + "name": "23x23 board" + }, + "#connect": { + "name": "Square", + "description": "Points connect orthogonally, as in Go." + }, + "snub": { + "name": "Snub square", + "description": "Points connect as in the snub square tiling, so each point has five neighbours and the board contains triangles." + }, + "#protocol": { + "name": "Thue-Morse", + "description": "Placements follow the full Thue-Morse sequence: 0110 1001 1001 0110 …" + }, + "marseillais": { + "name": "Balanced Marseillais", + "description": "Placements repeat the first four terms of the Thue-Morse sequence, ABBA, also known as 12* or T(2)." + }, + "handicap": { + "name": "Handicap stones", + "description": "Player 2 begins their first move by giving Player 1 a number of handicap stones, up to a fortieth of the board's points: Player 2 passes that many placements, the declaration being the first, while Player 1 places freely. In Kill-All games, Player 1 instead sets a number of extra Attacker stones, up to twice that, which Player 2 places immediately on claiming the Attacker side." + }, + "reverse-komi": { + "name": "Reverse komi", + "description": "Player 2 begins their first move by giving Player 1 a reverse komi: a whole number of points up to 14 times the handicap limit, the numbered stones offering the multiples of 14, added to Player 1's score at the end of the game. Unlike the handicap it costs no placement; it comes before any handicap declaration. Cannot be combined with Kill-All, and makes the game unrated." + }, + "#repetition": { + "name": "5-fold repetition draw", + "description": "No ko rule: the fifth occurrence of a position ends the game in a draw. Intended for players who don't mind draws and want to let short cycles play out." + }, + "superko": { + "name": "Positional superko (PSK)", + "description": "No placement may recreate an earlier position of the board. Intended for players who want to keep the ko prohibition from Go." + }, + "double-delayed-psk": { + "name": "Double delayed PSK", + "description": "Like positional superko, except that a position may arise three times: only a placement that would make a fourth occurrence is forbidden. Intended for players who dislike draws and want to let short cycles play out." + }, + "weak-eyes": { + "name": "Weak eyes", + "description": "When checked, only the last placement of a move clears the board or checks repetition, so a two-placement move can fill both eyes of a group and three eyes are needed to live. Unchecked, every placement clears the board and two eyes live." + }, + "half-button": { + "name": "0.5 point button", + "description": "Any placement may instead take the button, worth half a point to whoever takes it first, on any board, which prevents tied scores. Unchecked, the button evens the count instead: a 1-point button on boards with an odd number of points and none on boards with an even number, so the points at stake always add up to an even number. Kill-All games have no button." + }, + "kill-all": { + "name": "Kill-All", + "description": "Players take turns placing Attacker stones until one of them claims the Attacker side; the other plays the Defender, places first, and wins by forming an unconditionally alive string. Cannot be combined with the 0.5 point button, and has no button." + } + }, "tintas": { "#board": { "description": "A round-ish hex field of 49 spaces", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 0706fb3e..cb502edf 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -1571,6 +1571,45 @@ "default": "{{player}} selected {{card}}." } }, + "thuemorsego": { + "CAPTURE": { + "suicide_one": "{{player}} removed their own stone by suicide.", + "suicide_other": "{{player}} removed {{count}} of their own stones by suicide." + }, + "CLAIM": { + "attacker": "{{player}} chose to play as the Attacker." + }, + "DECLARE": { + "stones_one": "{{player}} set the handicap to one stone.", + "stones_other": "{{player}} set the handicap to {{count}} stones.", + "komi_one": "{{player}} gave their opponent a reverse komi of {{count}} point.", + "komi_other": "{{player}} gave their opponent a reverse komi of {{count}} points." + }, + "EOG": { + "repetition": "The game has ended in a draw because the same position occurred five times.", + "pass_alive": "The game has ended because the Defender formed an unconditionally alive string.", + "no_room": "The game has ended because the Attacker's unconditionally alive stones leave the Defender no room to form an unconditionally alive string.", + "no_moves": "The game has ended because the player to move has no legal move." + }, + "PASS": { + "handicap": "{{player}} gave a handicap stone by passing." + }, + "PLACE": { + "setup": "{{player}} placed an Attacker stone at {{where}}." + }, + "REMOVE": { + "dead_one": "The stone marked dead was removed from the board.", + "dead_other": "The {{count}} stones marked dead were removed from the board." + }, + "SELECT": { + "marked_one": "{{player}} marked one stone as dead.", + "marked_other": "{{player}} marked {{count}} stones as dead.", + "changed_one": "{{player}} changed the marking: one stone is marked dead.", + "changed_other": "{{player}} changed the marking: {{count}} stones are marked dead.", + "cleared": "{{player}} cleared the marking: no stones are marked dead.", + "locked": "Dead-stone marking has ended: no stones are marked dead, and passes carry no marking for the rest of the game." + } + }, "toguz": { "CLAIM": { "default": "Pit {{pit}} became a tuz." diff --git a/src/games/killallgo.ts b/src/games/killallgo.ts index 6c64042d..dedc86aa 100644 --- a/src/games/killallgo.ts +++ b/src/games/killallgo.ts @@ -313,6 +313,11 @@ export const signature = (board: Board, geo: Geometry): string => { export interface PassAliveOptions { /** Tromp-Taylor rules; selects the strict vital-region test. Defaults to true. */ suicideAllowed?: boolean; + /** + * Vital regions every chain in the set needs. Defaults to 2, Benson's condition; a game in + * which the opponent can fill more than one liberty before any clearing needs more. + */ + minVital?: number; } interface Region { @@ -325,9 +330,9 @@ interface Region { * Every pass-alive chain of `colour`, each as its list of stones. * * A chain 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. + * least two vital regions enclosed by X (`minVital` raises that number). 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. * * Under classic (no-suicide) rules only the empty points of a region need to touch the chain. * When multi-stone suicide is legal, the opponent can clear its own stones out of a region and @@ -336,6 +341,7 @@ interface Region { */ export const passAliveStrings = (board: Board, geo: Geometry, colour: Stone, opts: PassAliveOptions = {}): string[][] => { const suicideAllowed = opts.suicideAllowed ?? true; + const minVital = opts.minVital ?? 2; // Chains of `colour`. const chainOf = new Map(); @@ -430,7 +436,7 @@ export const passAliveStrings = (board: Board, geo: Geometry, colour: Stone, opt vital++; } } - if (vital < 2) { + if (vital < minVital) { X.delete(chain); changed = true; } @@ -1921,7 +1927,7 @@ export class KillAllGoGame extends GameBase { return true; case "capture": if (r.how === "suicide") { - this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:killallgo.CAPTURE", { count: r.count! }); + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:killallgo.CAPTURE.suicide", { count: r.count! }); } else { this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:_general.CAPTURE.noperson.group_nowhere", { count: r.count! }); } @@ -1936,7 +1942,7 @@ export class KillAllGoGame extends GameBase { } return true; case "declare": - this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:killallgo.DECLARE", { count: r.count! }); + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:killallgo.DECLARE.handicap", { count: r.count! }); return true; case "announce": { const [a, b] = r.payload as [number, number]; diff --git a/src/games/thuemorsego.ts b/src/games/thuemorsego.ts new file mode 100644 index 00000000..38218fad --- /dev/null +++ b/src/games/thuemorsego.ts @@ -0,0 +1,3296 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; +import type { APGamesInformation } from "../schemas/gameinfo.js"; +import type { APRenderRep, AreaTrack, BoardBasic, ColourResolvable, Colourfuncs, Glyph, MarkerDots, MarkerLine, RowCol } from "@abstractplay/renderer/build/schemas/schema"; +import type { APMoveResult } from "../schemas/moveresults.js"; +import { reviver, shuffle, SnubSquareGraph, UserFacingError } from "../common/index.js"; +import type { FlagContext, GameFlag } from "../common/flags.js"; +import { makeGeometry, otherColour, passAliveStrings, signature, stringAt, type Board, type Geometry, type Stone } from "./killallgo.js"; +import i18next from "i18next"; +import { gameApgamesUidKey } from "../common/game-apgames-t.js"; + +/** Variant and display labels are resolved in {@link GameBase}; anchors for strict apgames key coverage. */ +void gameApgamesUidKey("thuemorsego", "variants", "size-11", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "#board", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "size-19", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "size-23", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "#connect", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "#connect", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "snub", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "snub", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "#protocol", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "#protocol", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "marseillais", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "marseillais", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "handicap", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "handicap", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "reverse-komi", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "reverse-komi", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "#repetition", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "#repetition", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "superko", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "superko", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "double-delayed-psk", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "double-delayed-psk", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "weak-eyes", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "weak-eyes", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "half-button", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "half-button", "description"); +void gameApgamesUidKey("thuemorsego", "variants", "kill-all", "name"); +void gameApgamesUidKey("thuemorsego", "variants", "kill-all", "description"); +void gameApgamesUidKey("thuemorsego", "displays", "tms-tracker", "name"); +void gameApgamesUidKey("thuemorsego", "displays", "tms-tracker", "description"); +void gameApgamesUidKey("thuemorsego", "displays", "digits-down", "name"); +void gameApgamesUidKey("thuemorsego", "displays", "digits-down", "description"); +void gameApgamesUidKey("thuemorsego", "displays", "hide-territory", "name"); +void gameApgamesUidKey("thuemorsego", "displays", "hide-territory", "description"); +void gameApgamesUidKey("thuemorsego", "displays", "palette-borders", "name"); +void gameApgamesUidKey("thuemorsego", "displays", "palette-borders", "description"); +void gameApgamesUidKey("thuemorsego", "displays", "pass-alive-throughout", "name"); +void gameApgamesUidKey("thuemorsego", "displays", "pass-alive-throughout", "description"); + +export const gameApgamesEn = { + validation: { + INSTRUCTIONS_PLAY_one: "Place a stone, or pass; this move is a single placement.", + INSTRUCTIONS_PLAY_other: "This move is {{count}} placements: place up to {{count}} stones, or submit early to pass the rest.", + INSTRUCTIONS_PLAY_BUTTON_one: "Place a stone, pass, or take the button (worth {{points}}); this move is a single placement.", + INSTRUCTIONS_PLAY_BUTTON_other: "This move is {{count}} placements: place up to {{count}} stones, take the button (worth {{points}}) in place of one, or submit early to pass the rest.", + INSTRUCTIONS_PLAY_FORCED: "The first placement of this move is given away as a handicap stone. Place a stone for the other, or pass.", + INSTRUCTIONS_PLAY_FORCED_BUTTON: "The first placement of this move is given away as a handicap stone. Place a stone for the other, take the button (worth {{points}}), or pass.", + INSTRUCTIONS_PLAY_DEFENDER_one: "Place a stone. You win by forming one unconditionally alive string.", + INSTRUCTIONS_PLAY_DEFENDER_other: "Place {{count}} stones. You win by forming one unconditionally alive string.", + INSTRUCTIONS_PLAY_ATTACKER_one: "Place a stone. You win by preventing the Defender from forming an unconditionally alive string.", + INSTRUCTIONS_PLAY_ATTACKER_other: "Place {{count}} stones. You win by preventing the Defender from forming an unconditionally alive string.", + INSTRUCTIONS_DECLARE: "You are giving the handicap. Click a numbered stone to set how many handicap stones you give, from 1 to {{max}}: you pass that many placements while your opponent places freely, and this declaration is the first of them.", + INSTRUCTIONS_BOTH: "Begin this move with the reverse komi and the handicap, in either order. Click a numbered stone in the upper row, or enter any whole number from 1 to {{max}} in the move box, to set how many points you give your opponent; click one in the lower row to set how many handicap stones you give, from 1 to {{stones}}. The komi costs no placement; the handicap stones are the placements you pass, this declaration being the first of them.", + HANDICAP_THEN_KOMI_one: "Handicap of {{count}} stone. Now choose the reverse komi: click a numbered stone in the upper row, or enter a whole number in the move box.", + HANDICAP_THEN_KOMI_other: "Handicap of {{count}} stones. Now choose the reverse komi: click a numbered stone in the upper row, or enter a whole number in the move box.", + KOMI_MISSING: "Choose the reverse komi before placing a stone: click a numbered stone in the upper row, or enter a whole number first.", + DECLARE_MISSING: "Choose the handicap before placing a stone: click one of its numbered stones first.", + CHOOSE_FIRST: "Choose the reverse komi and the handicap first: click a numbered stone from each row. {{where}} is not one of them.", + BAD_DECLARATION: "The reverse komi and the handicap come before the placements, once each: a number of points as 14p and a number of stones as 3s, in either order, or plain numbers in that order.", + DECLARE_RANGE: "The handicap must be between 1 and {{max}} stones.", + DECLARE_NO_PLACEMENT: "With a handicap of {{count}} stones, every placement of this move is given away.", + DECLARE_OK_one: "Handicap of one stone. Submit, or click a different numbered stone.", + DECLARE_OK_other: "Handicap of {{count}} stones. Submit, or click a different numbered stone.", + DECLARE_ONE: "Handicap of one stone, given by this declaration. Place a stone as the second placement of this move, or submit to pass it.", + DECLARE_ONE_BUTTON: "Handicap of one stone, given by this declaration. Place a stone or take the button as the second placement of this move, or submit to pass it.", + DECLARE_FIRST: "Choose the handicap first: click one of its numbered stones. {{where}} is not one of them.", + NO_PLACEMENT_LEFT: "This move has no placement left to make: submit, or click a numbered stone to change your choice.", + TOO_MANY_one: "This move has only one placement left to fill.", + TOO_MANY_other: "This move has only {{count}} placements left to fill.", + FORCED_MOVE: "Every placement of this move is given away as handicap stones, which happens automatically.", + NO_PASS: "Passing is not allowed in Kill-All games.", + NO_BUTTON: "There is no button in this game.", + BUTTON_TAKEN: "The button has already been taken.", + KO_PSK: "That placement would recreate an earlier position of the board (positional superko).", + KO_PSK_DDPSK: "That placement would recreate an earlier position of the board for the fourth time (double delayed superko).", + SUICIDE_REPEAT: "That stone has no liberties and captures nothing, so it is removed as suicide and the board is left as it was, which positional superko forbids.", + SUICIDE_REPEAT_DDPSK: "That stone has no liberties and captures nothing, so it is removed as suicide and the board is left as it was, a position that has already arisen three times, which double delayed superko forbids.", + SUICIDE_REPEAT_MOVE: "The stones placed this move have no liberties and capture nothing, so the clearing after its last placement removes them and leaves the board as it was before the move, which positional superko forbids.", + SUICIDE_REPEAT_MOVE_DDPSK: "The stones placed this move have no liberties and capture nothing, so the clearing after its last placement removes them and leaves the board as it was before the move, a position that has already arisen three times, which double delayed superko forbids.", + OVER_MID_MOVE: "The game ended at the previous placement.", + PLACE_MORE_one: "Place one more stone.", + PLACE_MORE_other: "Place {{count}} more stones.", + DEAD_END: "No legal placement can follow that one; choose a different placement.", + MUST_CONTINUE: "Ending the move here would recreate an earlier position of the board; place another stone, or choose a different placement.", + MUST_CONTINUE_SUICIDE: "Ending the move here would leave that stone with no liberties and nothing captured, so it would be removed as suicide and the board left as it was; place another stone, or choose a different placement.", + INSTRUCTIONS_HANDICAP: "You are giving the handicap. Click a numbered stone to set how many extra Attacker stones, from 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 click a different numbered stone.", + HANDICAP_OK_other: "Handicap of {{count}} stones. Submit, or click a different numbered stone.", + NOT_A_NUMBERED_STONE: "{{where}} is not one of the numbered stones.", + INSTRUCTIONS_ALT: "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, your opponent moves first as the Defender.", + INSTRUCTIONS_ALT_HANDICAP_one: "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, place one extra stone; your opponent then moves first as the Defender.", + INSTRUCTIONS_ALT_HANDICAP_other: "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, place {{count}} extra stones; your opponent then moves first as the Defender.", + SETUP_FULL: "The board already holds the maximum number of setup stones this phase allows ({{max}}); take the Attacker side instead of placing another.", + ATTACKER_NO_STONES: "Taking the Attacker 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: "You must place exactly one extra Attacker stone.", + BATCH_TOO_MANY_other: "You must place exactly {{count}} extra Attacker stones.", + DUPLICATE_CELL: "The cell {{where}} is listed twice.", + INVALID_PASS: "You cannot pass now.", + INSTRUCTIONS_KOMI: "Begin this move with the reverse komi: click one of the numbered stones, or enter any whole number from 1 to {{max}} in the move box, to set how many points you give your opponent. It costs no placement; your stones follow it.", + KOMI_INVALID: "This move must begin with the reverse komi: a whole number from 1 to {{max}}, the numbered stones offering the multiples of 14.", + KOMI_THEN_PLACE_one: "Reverse komi of {{count}} point to your opponent. Now place your stones, or submit to pass this move.", + KOMI_THEN_PLACE_other: "Reverse komi of {{count}} points to your opponent. Now place your stones, or submit to pass this move.", + KOMI_THEN_DECLARE_one: "Reverse komi of {{count}} point to your opponent. Now click a numbered stone in the lower row to set the handicap.", + KOMI_THEN_DECLARE_other: "Reverse komi of {{count}} points to your opponent. Now click a numbered stone in the lower row to set the handicap.", + KOMI_FIRST: "Choose the reverse komi first: click one of its numbered stones, or enter a whole number in the move box. {{where}} is not one of the numbered stones.", + OCCUPIED_MARK: "{{where}} is occupied. To mark its string as dead, press Pass first, then click the string; in a two-placement move a stone can still be placed for one of the placements.", + MARKS_NEED_PASS: "Strings can be marked dead only in a move that passes at least one placement: pass first, then click or name the strings.", + MARK_EMPTY: "There is no stone at {{where}} to mark dead.", + MARKS_LOCKED: "Strings can no longer be marked dead: dead-stone marking ended when a move passing nothing, or a pass marking nothing dead, followed a marking. Capture any dead stones on the board, then end the game with two whole-move passes in turn; every stone still on the board then counts as alive.", + PASS_OPEN_zero: "Submit to pass this move, click strings to mark them dead first, or place a stone instead. If your opponent then passes keeping the marking, the game ends with the marked strings removed.", + PASS_OPEN_one: "Submit to pass this move with one stone marked dead, or click strings to change the marking. If your opponent then passes keeping it, the game ends with the marked strings removed.", + PASS_OPEN_other: "Submit to pass this move with {{count}} stones marked dead, or click strings to change the marking. If your opponent then passes keeping it, the game ends with the marked strings removed.", + PASS_AGREE_zero: "Your opponent passed with no strings marked dead. Submit to pass and end the game, or click strings to mark them dead, after which your opponent may pass to accept the marking or play on.", + PASS_AGREE_one: "Your opponent passed with one stone marked dead. Submit to pass and end the game with it removed, or click strings to change the marking, after which your opponent may pass to accept it or play on.", + PASS_AGREE_other: "Your opponent passed with {{count}} stones marked dead. Submit to pass and end the game with them removed, or click strings to change the marking, after which your opponent may pass to accept it or play on.", + PASS_DISPUTE_one: "This changes your opponent's marking to one stone marked dead, so the game goes on: your opponent may pass to accept it, change it again, or play on. Submit, or click strings to change the marking.", + PASS_DISPUTE_other: "This changes your opponent's marking to {{count}} stones marked dead, so the game goes on: your opponent may pass to accept it, change it again, or play on. Submit, or click strings to change the marking.", + PASS_PLACE_zero: "Submit to pass the rest of this move, or place another stone. Passing part of a move does not end the game, but your opponent may then pass their whole move to end it.", + PASS_PLACE_one: "Submit to pass the rest of this move with one stone marked dead, or place another stone. Passing part of a move does not end the game, but your opponent may then pass their whole move keeping the marking to end it with the marked string removed.", + PASS_PLACE_other: "Submit to pass the rest of this move with {{count}} stones marked dead, or place another stone. Passing part of a move does not end the game, but your opponent may then pass their whole move keeping the marking to end it with the marked strings removed.", + PASS_LOCKED_PLACE: "Dead-stone marking has ended for this game, so passes carry no marking. Submit to pass the rest of this move, or place another stone; your opponent may then pass their whole move to end the game with every stone alive.", + PASS_CLEARS: "Passing with no strings marked dead after a marking ends dead-stone marking for the rest of the game: the game goes on, dead stones must be captured, and the game ends when both players pass their whole moves in turn, with every stone on the board alive. Submit, or click strings to mark them dead instead.", + PASS_CLEARS_PLACE: "Passing the rest of this move with no strings marked dead after a marking ends dead-stone marking for the rest of the game, as does placing another stone: the game goes on, dead stones must be captured, and the game ends when both players pass their whole moves in turn, with every stone on the board alive. Submit, or place another stone.", + MARK_CLICK: "Click strings to mark them dead, or to change the marking.", + MARK_HINT: "To mark strings dead as well, press Pass first, then place the stone again and click them.", + PLACING_ENDS_MARKING: "This move passes nothing, so it ends dead-stone marking for the rest of the game: dead stones must then be captured, and the game ends when both players pass their whole moves in turn, with every stone on the board alive.", + MARKING_PENDING_one: "One stone is marked dead. Pass at least one placement to answer the marking; a move that passes nothing ends dead-stone marking for the rest of the game.", + MARKING_PENDING_other: "{{count}} stones are marked dead. Pass at least one placement to answer the marking; a move that passes nothing ends dead-stone marking for the rest of the game.", + PASS_LOCKED: "Dead-stone marking has ended for this game, so passes carry no marking. Submit to pass this move, or place a stone for one of its placements; if your opponent then passes their whole move, the game ends with every stone alive.", + PASS_LOCKED_ENDS: "Dead-stone marking has ended for this game, so passes carry no marking. Your opponent's last move passed: submit to pass this move and end the game with every stone alive, or place a stone to play on.", + }, + variants: { + "size-11": { + name: "11x11 board", + }, + "#board": { + name: "16x16 board", + }, + "size-19": { + name: "19x19 board", + }, + "size-23": { + name: "23x23 board", + }, + "#connect": { + name: "Square", + description: "Points connect orthogonally, as in Go.", + }, + snub: { + name: "Snub square", + description: "Points connect as in the snub square tiling, so each point has five neighbours and the board contains triangles.", + }, + "#protocol": { + name: "Thue-Morse", + description: "Placements follow the full Thue-Morse sequence: 0110 1001 1001 0110 …", + }, + marseillais: { + name: "Balanced Marseillais", + description: "Placements repeat the first four terms of the Thue-Morse sequence, ABBA, also known as 12* or T(2).", + }, + handicap: { + name: "Handicap stones", + description: "Player 2 begins their first move by giving Player 1 a number of handicap stones, up to a fortieth of the board's points: Player 2 passes that many placements, the declaration being the first, while Player 1 places freely. In Kill-All games, Player 1 instead sets a number of extra Attacker stones, up to twice that, which Player 2 places immediately on claiming the Attacker side.", + }, + "reverse-komi": { + name: "Reverse komi", + description: "Player 2 begins their first move by giving Player 1 a reverse komi: a whole number of points up to 14 times the handicap limit, the numbered stones offering the multiples of 14, added to Player 1's score at the end of the game. Unlike the handicap it costs no placement; it comes before any handicap declaration. Cannot be combined with Kill-All, and makes the game unrated.", + }, + "#repetition": { + name: "5-fold repetition draw", + description: "No ko rule: the fifth occurrence of a position ends the game in a draw. Intended for players who don't mind draws and want to let short cycles play out.", + }, + superko: { + name: "Positional superko (PSK)", + description: "No placement may recreate an earlier position of the board. Intended for players who want to keep the ko prohibition from Go.", + }, + "double-delayed-psk": { + name: "Double delayed PSK", + description: "Like positional superko, except that a position may arise three times: only a placement that would make a fourth occurrence is forbidden. Intended for players who dislike draws and want to let short cycles play out.", + }, + "weak-eyes": { + name: "Weak eyes", + description: "When checked, only the last placement of a move clears the board or checks repetition, so a two-placement move can fill both eyes of a group and three eyes are needed to live. Unchecked, every placement clears the board and two eyes live.", + }, + "half-button": { + name: "0.5 point button", + description: "Any placement may instead take the button, worth half a point to whoever takes it first, on any board, which prevents tied scores. Unchecked, the button evens the count instead: a 1-point button on boards with an odd number of points and none on boards with an even number, so the points at stake always add up to an even number. Kill-All games have no button.", + }, + "kill-all": { + name: "Kill-All", + description: "Players take turns placing Attacker stones until one of them claims the Attacker side; the other plays the Defender, places first, and wins by forming an unconditionally alive string. Cannot be combined with the 0.5 point button, and has no button.", + }, + }, + displays: { + "tms-tracker": { + name: "TMS tracker", + description: "Show the tracker as the three hexadecimal digits of the next placement's index, one column of 16 per digit, instead of a single column of the next 16 placements.", + }, + "digits-down": { + name: "Tracker runs downward", + description: "Run the tracker from the top down: the next placement at the top of the column, or digit 0 at the top of each column of the digit tracker.", + }, + "hide-territory": { + name: "Hide territory dots", + description: "Do not dot the points that count as territory as the board stands: the empty points reaching stones of only one colour, with the strings marked dead removed. Strings marked dead are still shown faded. Kill-All games show no territory anyway.", + }, + "palette-borders": { + name: "Stone borders from the palette", + description: "Draw the border of each stone in palette slot 5 to 8, for stones in slots 1 to 4, instead of black or white, whichever contrasts better with the fill. The palette must have at least eight colours.", + }, + "pass-alive-throughout": { + name: "Mark pass-alive stones throughout the game", + description: "In Kill-All games, show the unconditionally alive (pass-alive) stones of both sides faded in every position, not only in the position that ended the game. Does nothing outside Kill-All games.", + }, + }, + buttons: { + attacker: "Play as the Attacker", + }, + status: { + NEXT_PLACEMENT: "Next placements (hex)", + BUTTON: "Button", + BUTTON_AVAILABLE: "Available", + PASSES_OWED: "Handicap stones still to give", + DEFENDER: "Defender", + ATTACKER: "Attacker", + UNDECIDED: "Not yet chosen", + HANDICAP: "Handicap stones", + PHASE_HAND_N: "{{player}} sets the number of handicap stones", + PHASE_ALT_PLACE: "{{player}} places an Attacker stone or takes the Attacker side", + PHASE_DECLARE: "{{player}} sets the number of handicap stones", + PHASE_KOMI: "{{player}} chooses the reverse komi", + PHASE_KOMI_DECLARE: "{{player}} chooses the reverse komi and the handicap", + KOMI: "Reverse komi", + MARKING: "Dead-stone marking", + MARKING_COUNT_one: "One stone marked dead", + MARKING_COUNT_other: "{{count}} stones marked dead", + MARKING_LOCKED: "Ended: all dead stones should be manually captured before passing", + }, +}; + +export const gameApresultsEn = { + CAPTURE: { + suicide_one: "{{player}} removed their own stone by suicide.", + suicide_other: "{{player}} removed {{count}} of their own stones by suicide.", + }, + CLAIM: { + attacker: "{{player}} chose to play as the Attacker.", + }, + DECLARE: { + stones_one: "{{player}} set the handicap to one stone.", + stones_other: "{{player}} set the handicap to {{count}} stones.", + komi_one: "{{player}} gave their opponent a reverse komi of {{count}} point.", + komi_other: "{{player}} gave their opponent a reverse komi of {{count}} points.", + }, + EOG: { + repetition: "The game has ended in a draw because the same position occurred five times.", + pass_alive: "The game has ended because the Defender formed an unconditionally alive string.", + no_room: "The game has ended because the Attacker's unconditionally alive stones leave the Defender no room to form an unconditionally alive string.", + no_moves: "The game has ended because the player to move has no legal move.", + }, + PASS: { + handicap: "{{player}} gave a handicap stone by passing.", + }, + PLACE: { + setup: "{{player}} placed an Attacker stone at {{where}}.", + }, + REMOVE: { + dead_one: "The stone marked dead was removed from the board.", + dead_other: "The {{count}} stones marked dead were removed from the board.", + }, + SELECT: { + marked_one: "{{player}} marked one stone as dead.", + marked_other: "{{player}} marked {{count}} stones as dead.", + changed_one: "{{player}} changed the marking: one stone is marked dead.", + changed_other: "{{player}} changed the marking: {{count}} stones are marked dead.", + cleared: "{{player}} cleared the marking: no stones are marked dead.", + locked: "Dead-stone marking has ended: no stones are marked dead, and passes carry no marking for the rest of the game.", + }, +}; + +/** + * Thue-Morse Go. + * + * Go under Tromp-Taylor rules in which the order of placements follows the Thue-Morse sequence + * (0110 1001 1001 0110 …): placement n belongs to colour 1 when n has an even number of 1 bits + * and to colour 2 otherwise. A move is a maximal run of placements by one colour, so every move + * is one or two placements and the seats alternate moves exactly as in ordinary Go. + * + * Colours are seats in the standard game. In the Kill-All variant colour 1 is the Defender + * (Red, who places first) and colour 2 the Attacker (Blue); `defenderSeat` records which seat + * plays Red once pieboxing has decided it. + */ +type playerid = 1 | 2; + +type Phase = + | "hand-n" // Kill-All handicap: Player 1 types the number of extra Attacker stones + | "alt-place" // Kill-All pieboxing: alternating Attacker stones until someone takes them + | "play"; // Thue-Morse placements + +interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Board; + lastmove?: string; + phase: Phase; + /** Index of the next placement; its Thue-Morse digit names the colour to place. */ + placed: number; + /** Seats of the trailing run of moves that each passed at least one placement voluntarily. */ + passes: playerid[]; + /** Positions this move created other than the saved board (positional superko, repetition). */ + interim: string[]; + /** Whether the saved board is a position this move created (false after a move without a stone). */ + novel: boolean; + /** Stones of the strings the last pass marked dead, in board order. */ + marks: string[]; + /** Whether dead-stone marking has ended for the rest of the game, so that passes carry none. */ + locked: boolean; + /** The seat holding the button. */ + button?: playerid; + /** The declared handicap: passes in the standard game, extra Attacker stones in Kill-All. */ + handicap?: number; + /** Standard handicap: passes Player 2 still has to serve. */ + passesOwed?: number; + /** Reverse komi: the points Player 2 chose, with their first move, to have added to their score. */ + komi?: number; + defenderSeat?: playerid; + /** The stones that decided a Kill-All game. */ + alive?: string[]; +} + +export interface IThueMorseGoState extends IAPGameState { + winner: playerid[]; + stack: Array; +} + +interface ILegend { + [key: string]: Glyph | [Glyph, ...Glyph[]]; +} + +/** An empty region of the board and the colour it scores for, when it reaches only one. */ +interface IRegion { + cells: string[]; + owner?: Stone; +} + +/** One numbered stone of an on-board picker. */ +interface IPickerButton { + cell: string; + value: number; +} + +/** The messages saying why a click means nothing now, by the outcome that names them. */ +const CLICK_ERROR_KEYS = { + NOT_A_NUMBERED_STONE: "apgames:validation.thuemorsego.NOT_A_NUMBERED_STONE", + KOMI_FIRST: "apgames:validation.thuemorsego.KOMI_FIRST", + DECLARE_FIRST: "apgames:validation.thuemorsego.DECLARE_FIRST", + CHOOSE_FIRST: "apgames:validation.thuemorsego.CHOOSE_FIRST", + NO_PLACEMENT_LEFT: "apgames:validation.thuemorsego.NO_PLACEMENT_LEFT", +} as const; + +/** What a click does: the move it leaves, or why it does nothing. */ +type IClickOutcome = { move: string } | { error: keyof typeof CLICK_ERROR_KEYS }; + +/** The messages of the errors a placement token can meet, by the code `applyToken` reports. */ +const TOKEN_ERROR_KEYS = { + OVER_MID_MOVE: "apgames:validation.thuemorsego.OVER_MID_MOVE", + TOO_MANY: "apgames:validation.thuemorsego.TOO_MANY", + NO_PASS: "apgames:validation.thuemorsego.NO_PASS", + NO_BUTTON: "apgames:validation.thuemorsego.NO_BUTTON", + BUTTON_TAKEN: "apgames:validation.thuemorsego.BUTTON_TAKEN", + KO_PSK: "apgames:validation.thuemorsego.KO_PSK", + KO_PSK_DDPSK: "apgames:validation.thuemorsego.KO_PSK_DDPSK", + SUICIDE_REPEAT: "apgames:validation.thuemorsego.SUICIDE_REPEAT", + SUICIDE_REPEAT_DDPSK: "apgames:validation.thuemorsego.SUICIDE_REPEAT_DDPSK", + SUICIDE_REPEAT_MOVE: "apgames:validation.thuemorsego.SUICIDE_REPEAT_MOVE", + SUICIDE_REPEAT_MOVE_DDPSK: "apgames:validation.thuemorsego.SUICIDE_REPEAT_MOVE_DDPSK", +} as const; + +/** The codes of the errors a placement token can meet; the last three take the token itself. */ +type TokenError = keyof typeof TOKEN_ERROR_KEYS | "INVALIDCELL" | "OCCUPIED" | "INCOMPLETE"; + +/** The parts of Player 2's first move: the declarations it names, and what follows them. */ +interface IFirstMove { + komi?: string; + handicap?: string; + placements: string[]; + /** Set when the declarations are malformed. */ + error?: "BAD_DECLARATION"; +} + +/** + * A move in progress: the board and bookkeeping as the placements of one move are applied, on a + * scratch copy while validating and enumerating, and committed to the game when a move is made. + */ +interface ISim { + board: Board; + /** Occurrence counts of every position reached before this move (shared, never mutated). */ + base: Map; + results: APMoveResult[]; + /** The position at the start of the move. */ + start: string; + /** Positions created so far in this move, in order; the last is the board as it stands. */ + created: string[]; + /** Opponent stones captured so far in this move. */ + captured: number; + /** Own stones removed by suicide so far in this move. */ + suicided: number; + /** Placements in this move. */ + length: number; + /** Placements consumed so far, the handicap passes served at its start included. */ + placed: number; + buttonTaken: boolean; + placedStone: boolean; + /** Checked weak eyes: whether the move's deferred clearing has happened. */ + cleared: boolean; + /** Whether the game ended during this move (repetition draw). */ + over: boolean; +} + +const UNDECIDED_COLOUR = "#999999"; +/** The palette slots of the Kill-All roles, used when a palette for this game sets them. */ +const ATTACKER_SLOT = 3; +const DEFENDER_SLOT = 4; +/** The colours the Kill-All roles take otherwise: the Attacker the first, as in Kill-All Go. */ +const ATTACKER_DEFAULT = 1; +const DEFENDER_DEFAULT = 2; +const CELL_RE = /^[a-z]+\d+$/; +const TRACK_ROWS = 16; +const TRACK_DIGITS = 3; +/** sqrt(1/3): the small tracker glyphs have about a third of the area of the digit markers. */ +const SMALL_SCALE = 0.57735; +const TRACK_TINT = 0.2; +/** The palette slot of a stone's border lies this far above the slot of its fill: 5 to 8 for 1 to 4. */ +const BORDER_SLOT_OFFSET = 4; +const REPETITIONS_FOR_DRAW = 5; +/** The reverse komi comes in multiples of this many points. */ +const KOMI_STEP = 14; +/** + * The legend keys of the tracker's glyphs: the small stones s1/s2 (s0 when grey), the rolling + * tracker's plain markers m1/m2, and the TMS tracker's digit markers m1d0 to m2df. + */ +const TRACK_KEY_RE = /^(s[012]|m[12](d[0-9a-f])?)$/; +/** The last placement the digit tracker's three columns can show; past it the tracker goes grey. */ +const MAX_TRACK_INDEX = 0xfff; +/** + * A declaration token of Player 2's first move: a whole number, tagged `p` for points of reverse + * komi or `s` for handicap stones, or untagged to fill the komi and then the handicap in turn. + */ +const DECLARATION_RE = /^(\d+)([ps]?)$/; +/** Opacity of a stone marked dead. */ +const DEAD_OPACITY = 0.4; +/** The opacity of pass-alive stones in Kill-All games, as in Kill-All Go. */ +const ALIVE_OPACITY = 0.75; +/** Diameter of a territory dot as a fraction of a cell, the size of the renderer's own dots. */ +const DOT_SIZE = 0.2; +/** Diameter of a star point as a fraction of a cell, the size the renderer draws its own. */ +const STAR_SIZE = 0.15; +/** Scale of the digit written on a tracker marker. */ +const DIGIT_SCALE = 0.75; +/** Stroke width of the border around the tracker's columns, in the track board's own units. */ +const TRACK_BORDER_WIDTH = 8; + +const popcount = (n: number): number => { + let count = 0; + let rest = n; + while (rest > 0) { + count += rest & 1; + rest = Math.floor(rest / 2); + } + return count; +}; + +const snubGeometryCache = new Map(); + +/** The snub square board as a `Geometry`, so the shared Go helpers can walk it. */ +const makeSnubGeometry = (size: number): Geometry => { + const cached = snubGeometryCache.get(size); + if (cached !== undefined) { + return cached; + } + const graph = new SnubSquareGraph(size, size); + const cells: string[] = []; + const neighbours = new Map(); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const cell = graph.coords2algebraic(x, y); + cells.push(cell); + neighbours.set(cell, graph.neighbours(cell)); + } + } + const geo: Geometry = { + size, + cells, + neighbours, + coords2algebraic: (x: number, y: number) => graph.coords2algebraic(x, y), + algebraic2coords: (cell: string) => graph.algebraic2coords(cell), + }; + snubGeometryCache.set(size, geo); + return geo; +}; + +/** Remove every string of `colour` that has no liberties; returns the strings removed. */ +const clearColour = (board: Board, geo: Geometry, colour: Stone): string[][] => { + const removed: string[][] = []; + const seen = new Set(); + for (const cell of geo.cells) { + if (board.get(cell) !== colour || seen.has(cell)) { + continue; + } + const info = stringAt(board, geo, cell); + for (const stone of info.stones) { + seen.add(stone); + } + if (info.liberties.size === 0) { + for (const stone of info.stones) { + board.delete(stone); + } + removed.push(info.stones); + } + } + return removed; +}; + +export class ThueMorseGoGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Thue-Morse Go", + uid: "thuemorsego", + playercounts: [2], + version: "20261004", + dateAdded: "2026-10-04", + description: "Go in which the order of placements follows the Thue-Morse sequence (0110 1001 1001 0110 …), so every move is one or two stones and no komi is needed. Tromp-Taylor rules with suicide allowed, an optional button, and a Kill-All variant.", + notes: "Placements follow the Thue-Morse sequence: placement n (counting from 0) belongs to Player 1 when n has an even number of 1 bits in binary and to Player 2 otherwise, giving the order 0110 1001 1001 0110 … A move is a maximal run of placements by one player, so every move is one or two placements. The tracker beside the board shows the next 16 placements in a single column, those of the next move as full-size stones; the tint behind the column and the border around it are the colour of the next placement. A display option shows instead the index of the next placement in hexadecimal, one column per digit: the rightmost column gives the colours of the placements to come before the next carry, each other column the colour of the 0 row of the column to its right, and the marked cell of each column carries its digit. It has three columns; past placement fff it shows only grey stones, with no markers or border, rather than digits it cannot show. The Balanced Marseillais protocol repeats the first four terms (ABBA) instead, and shows no tracker.\n\nStones are placed and captured under Tromp-Taylor rules: after a placement, opponent strings without liberties are removed, then your own, so multi-stone suicide is legal. Any placement may instead be a pass or, while nobody holds it, take the button, worth one point to its holder. By default the button evens the count: there is one on boards with an odd number of points (11x11, 19x19, 23x23) and none on boards with an even number (16x16); the 0.5 point button variant makes it worth half a point on any board, so scores cannot tie. A move lists its placements separated by commas; a move submitted with fewer placements than it allows passes the rest. A player's score is their area (stones plus the empty points reaching only their colour) plus the button; there is no komi. Captured stones do not score. Dots show the empty points that would count as territory as the board stands, so a lone first stone claims the whole board; a display option hides them.\n\nEnding the game: the game ends when a player passes their whole move right after the opponent passed at least one placement, keeping the marking of dead strings the opponent's move carried. A move that passes any of its placements, whether or not it also places a stone, may mark strings of either colour dead: click them, or name them after the passes (`pass,pass,-a1` marks the string at a1; `e5,pass,-a1` places e5, passes the other placement and marks it). The marked stones are shown faded, and the dots show the territory as it would be scored with them removed, the points under them included; a mark on a stone the move's own placement captures drops out. The opponent's next move starts from that marking, and clicking a string changes it. If the opponent passes their whole move keeping the marking, the game ends: the marked strings are removed and the board is scored. A move that places a stone, or that changes the marking, never ends the game, and the players may keep passing and changing the marking in turn for as long as every move passes at least one placement and keeps some string marked. Dead-stone marking ends for the rest of the game as soon as a marking is answered by a move that passes nothing, or by a pass that marks nothing dead. From then on passes carry no marking, dead stones must be captured for real, and the game ends when a whole-move pass follows a move of the opponent that passed at least one placement, with every stone on the board alive. A stone placed after passing the move is entered as the placement followed by the pass, such as `e5,pass`.\n\nBy default there is no ko rule, and the fifth occurrence of a position ends the game in a draw, even partway through a move. Under the positional superko variant no placement may recreate an earlier position of the board, including positions reached partway through a move; under double delayed superko a position may arise three times, and only a placement that would make a fourth occurrence is forbidden. Passes, taking the button and handicap passes change nothing and create no position. A single stone with no liberties that captures nothing is removed as suicide and so recreates the position it was played into, which is why superko does not allow it, and double delayed superko only until the position has arisen three times.\n\nWeak eyes: normally every placement clears the board, so two eyes live. With checked weak eyes only the last placement of a move clears it, so a two-placement move can fill both eyes of a group, three eyes are needed to live, and a placement that would be suicide can be rescued by the second placement of the move. Only the position the move leaves then counts for repetition, not the one partway through it.\n\nHandicap stones: Player 2's first move begins by giving Player 1 a number of handicap stones, up to a fortieth of the board's points rounded away from nine (3 on 11x11, 6 on 16x16, 10 on 19x19, 14 on 23x23): Player 2 passes that many placements, the first with the declaration itself and the rest automatically at the start of their following moves. These passes do not count towards ending the game and may run out partway through a two-placement move. In the move notation the handicap is a number followed by s, such as 3s, and the reverse komi a number followed by p, such as 14p; they come before the placements in either order.\n\nReverse komi: Player 2's first move begins by giving Player 1 a reverse komi, any whole number of points up to 14 times the handicap limit (the numbered stones offer the multiples of 14), added to Player 1's score at the end of the game. Unlike the handicap it costs no placement; it comes before any handicap declaration, and the game is unrated.\n\nKill-All: players take turns placing Attacker stones until one of them claims the Attacker side (pieboxing); the other plays the Defender and makes placement 0. The Defender wins by forming an unconditionally alive string (pass-alive by Benson's algorithm, with three vital regions instead of two under checked weak eyes), the Attacker by making that impossible. The Attacker's stones take the first colour and the Defender's the second, as in Kill-All Go; a palette set for this game can change them through slots 3 and 4, for instance the other way round. Passing is not allowed, and a player with no legal move loses. With a handicap, Player 1 first sets a number of extra Attacker stones, up to twice the handicap limit, that Player 2 places immediately on claiming the Attacker side. There is no button in Kill-All games. In the position that ends the game, the pass-alive stones of both sides are shown faded; a display option shows them faded in every position.\n\nColours: a stone's border is black or white, whichever contrasts better with its fill. A display option takes the borders from palette slots 5 to 8 instead, for stones in slots 1 to 4; it needs a palette of at least eight colours, since a palette that lacks a slot the game names cannot be drawn.\n\nOrigins: the Thue-Morse order was proposed for Go independently by atimholt and TheElvenAngelCatboy on r/baduk and by BHydden on the Online Go Forum, each pointing to the same video about the sequence; the forum community then built tools for playing it on OGS, ran tournaments, and worked out the rules implemented here. The Balanced Marseillais order comes from chess: Marseillais Chess (AABB) is attributed either to Albert Fortis with J. Rossow or to Jehan de Queylar, and Robert Bruce first proposed the ABBA correction that balances it.", + urls: [ + "https://forums.online-go.com/t/thue-morse-fair-sharing-sequence-a-possible-alternative-to-komi/22547", + "https://www.reddit.com/r/baduk/comments/65nx60/using_the_thuemorse_sequence_to_determine_whose/", + "https://www.reddit.com/r/baduk/comments/92clfm/how_would_go_using_the_thuemorse_fairestsharing/", + "https://www.youtube.com/watch?v=prh72BLNjIk", + "https://www.chessvariants.com/multimove.dir/marseill.html", + "https://en.wikipedia.org/wiki/Thue%E2%80%93Morse_sequence", + "https://tromp.github.io/go.html", + ], + // The Thue-Morse order was proposed for Go independently at least three times; the Online + // Go Forum community then built tools, ran tournaments and settled the rules used here. + people: [ + { + type: "designer", + name: "atimholt", + urls: ["https://www.reddit.com/r/baduk/comments/65nx60/using_the_thuemorse_sequence_to_determine_whose/"], + }, + { + type: "designer", + name: "TheElvenAngelCatboy", + urls: ["https://www.reddit.com/r/baduk/comments/92clfm/how_would_go_using_the_thuemorse_fairestsharing/"], + }, + { + type: "designer", + name: "BHydden", + urls: ["https://forums.online-go.com/t/thue-morse-fair-sharing-sequence-a-possible-alternative-to-komi/22547"], + }, + { + type: "other", + name: "Online Go Forum community", + urls: ["https://forums.online-go.com/t/thue-morse-fair-sharing-sequence-a-possible-alternative-to-komi/22547"], + }, + { + type: "coder", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + variants: [ + { uid: "size-11", group: "board" }, + { uid: "#board" }, + { uid: "size-19", group: "board" }, + { uid: "size-23", group: "board" }, + { uid: "#connect" }, + { uid: "snub", group: "connect" }, + { uid: "#protocol" }, + { + uid: "marseillais", + group: "protocol", + people: [ + { + type: "designer", + name: "Robert Bruce", + urls: ["https://www.chessvariants.com/multimove.dir/marseill.html"], + }, + ], + }, + { uid: "handicap", unrated: true }, + { uid: "reverse-komi", unrated: true, conflictsWith: ["kill-all"] }, + { uid: "#repetition" }, + { uid: "superko", group: "repetition" }, + { uid: "double-delayed-psk", group: "repetition" }, + { uid: "weak-eyes" }, + { uid: "half-button" }, + { uid: "kill-all", conflictsWith: ["half-button"] }, + ], + categories: ["goal>area", "goal>cripple", "mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>rect", "board>connect>rect", "board>connect>snub", "components>simple>1per"], + flags: ["experimental", "scores", "custom-buttons", "no-moves", "custom-randomization"], + displays: [{ uid: "tms-tracker" }, { uid: "digits-down" }, { uid: "hide-territory" }, { uid: "palette-borders" }, { uid: "pass-alive-throughout" }], + // Kill-All games draw their roles from slots 3 and 4, so that the Attacker takes the first + // colour as in Kill-All Go by default, and a player who prefers the Defender in the first + // colour can set those two slots the other way round. + customizations: [ + { + num: 1, + default: 1, + explanation: "Colour of Player 1's stones, the Thue-Morse 0 placements, outside Kill-All games", + player: 1, + }, + { + num: 2, + default: 2, + explanation: "Colour of Player 2's stones, the Thue-Morse 1 placements, outside Kill-All games", + player: 2, + }, + { + num: 3, + default: 1, + explanation: "Colour of the Attacker's stones in Kill-All games. Unless a palette for this game sets it, the Attacker takes the first colour, as in Kill-All Go; set slots 3 and 4 the other way round to give the Defender, who makes placement 0, the first colour.", + }, + { + num: 4, + default: 2, + explanation: "Colour of the Defender's stones in Kill-All games. Unless a palette for this game sets it, the Defender takes the second colour.", + }, + { + num: 5, + default: "#000000", + explanation: "Border of the stones in slot 1 under the display option that takes the borders from the palette; otherwise borders are black or white, whichever contrasts better with the fill", + }, + { + num: 6, + default: "#000000", + explanation: "Border of the stones in slot 2 under the same display option", + }, + { + num: 7, + default: "#000000", + explanation: "Border of the stones in slot 3 under the same display option", + }, + { + num: 8, + default: "#000000", + explanation: "Border of the stones in slot 4 under the same display option", + }, + ], + }; + + /** Kill-All games have no scores, and their colours follow roles rather than seats. */ + public static resolveFlags(context: FlagContext = {}): readonly GameFlag[] { + const flags: GameFlag[] = [...(this.gameinfo.flags ?? [])]; + if (context.variants?.includes("kill-all")) { + flags.push("custom-colours"); + return flags.filter((flag) => flag !== "scores"); + } + return flags; + } + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Board; + public phase!: Phase; + public placed = 0; + public passes: playerid[] = []; + public interim: string[] = []; + public novel = false; + public marks: string[] = []; + public locked = false; + public button?: playerid; + public handicap?: number; + public passesOwed = 0; + public komi?: number; + public defenderSeat?: playerid; + public alive?: string[]; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private boardSize = 16; + private geo!: Geometry; + + constructor(state?: IThueMorseGoState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined && variants.length > 0) { + this.variants = this.applyVariantConstraints(variants); + } + let phase: Phase = "play"; + if (this.variants.includes("kill-all")) { + phase = this.variants.includes("handicap") ? "hand-n" : "alt-place"; + } + const fresh: IMoveState = { + _version: ThueMorseGoGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + phase, + placed: 0, + passes: [], + interim: [], + novel: false, + marks: [], + locked: false, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + // A state pasted with indentation around it is still JSON, not a compressed state. + const text = state.trim(); + if (!text.startsWith("{") && !text.startsWith("[")) { + throw new Error("Compressed game state must be decompressed before constructing the engine."); + } + state = JSON.parse(text, reviver) as IThueMorseGoState; + } + if (state.game !== ThueMorseGoGame.gameinfo.uid) { + throw new Error(`The Thue-Morse 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): ThueMorseGoGame { + 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.placed = state.placed; + this.passes = [...state.passes]; + this.interim = [...state.interim]; + this.novel = state.novel; + this.marks = [...state.marks]; + this.locked = state.locked; + this.button = state.button; + this.handicap = state.handicap; + this.passesOwed = state.passesOwed ?? 0; + this.komi = state.komi; + this.defenderSeat = state.defenderSeat; + this.alive = state.alive === undefined ? undefined : [...state.alive]; + this.boardSize = ThueMorseGoGame.sizeFromVariants(this.variants); + this.geo = this.variants.includes("snub") ? makeSnubGeometry(this.boardSize) : makeGeometry(this.boardSize); + return this; + } + + private static sizeFromVariants(variants: string[]): number { + if (variants.includes("size-11")) { return 11; } + if (variants.includes("size-19")) { return 19; } + if (variants.includes("size-23")) { return 23; } + return 16; + } + + // ----------------------------------------------------------------------- + // Variants + // ----------------------------------------------------------------------- + + private get killAll(): boolean { + return this.variants.includes("kill-all"); + } + + private get handicapVariant(): boolean { + return this.variants.includes("handicap"); + } + + private get reverseKomi(): boolean { + return this.variants.includes("reverse-komi"); + } + + /** A fortieth of the board's points, rounded away from nine: the handicap stones offered. */ + private get handicapLimit(): number { + const base = this.points / 40; + return base <= 9 ? Math.floor(base) : Math.ceil(base); + } + + /** The largest handicap: the limit, or twice that for Kill-All's cheaper Attacker stones. */ + private get maxHandicap(): number { + return this.killAll ? 2 * this.handicapLimit : this.handicapLimit; + } + + /** Reverse komi: points given to Player 1, up to 14 times the handicap limit; the stones offer the multiples of 14. */ + private get maxKomi(): number { + return KOMI_STEP * this.handicapLimit; + } + + private komiValues(): number[] { + const values: number[] = []; + for (let value = KOMI_STEP; value <= this.maxKomi; value += KOMI_STEP) { + values.push(value); + } + return values; + } + + /** Any whole number of points up to the largest stone may be typed. */ + private isKomi(n: number): boolean { + return n >= 1 && n <= this.maxKomi; + } + + /** Checked weak eyes: only the last placement of a move clears the board. */ + private get checkedEyes(): boolean { + return this.variants.includes("weak-eyes"); + } + + /** + * How many times a position may arise before a placement recreating it is forbidden: once + * under positional superko, three times under double delayed superko, and without limit by + * default, where the fifth occurrence draws the game instead. + */ + private get repetitionLimit(): number | undefined { + if (this.variants.includes("superko")) { + return 1; + } + if (this.variants.includes("double-delayed-psk")) { + return 3; + } + return undefined; + } + + /** + * The button's worth: half a point under the 0.5 point button variant; otherwise a point on + * boards with an odd number of points and nothing on boards with an even number, so that the + * points at stake always add up to an even number. Kill-All games have no button. + */ + private get buttonValue(): number { + if (this.killAll) { + return 0; + } + if (this.variants.includes("half-button")) { + return 0.5; + } + return this.geo.cells.length % 2 === 1 ? 1 : 0; + } + + /** The period of a repeating protocol (Balanced Marseillais: ABBA), or 0 for the full sequence. */ + private get period(): number { + return this.variants.includes("marseillais") ? 4 : 0; + } + + /** + * Vital regions a string needs to be pass-alive. With checked weak eyes the opponent can fill + * two liberties before any clearing, so two vital regions are not enough: Benson's argument + * then needs three, which the two-placement move cannot all empty at once. + */ + private get minVital(): number { + return this.checkedEyes ? 3 : 2; + } + + // ----------------------------------------------------------------------- + // The sequence + // ----------------------------------------------------------------------- + + /** The Thue-Morse digit of placement `n`: the parity of its 1 bits (within one period of a repeating protocol). */ + public digitAt(n: number): 0 | 1 { + const idx = this.period === 0 ? n : n % this.period; + return (popcount(idx) % 2) as 0 | 1; + } + + public colourAt(n: number): Stone { + return this.digitAt(n) === 0 ? 1 : 2; + } + + /** The number of placements in the move that starts at placement `n`: the run of its colour. */ + public moveLength(n: number): number { + let length = 1; + while (this.colourAt(n + length) === this.colourAt(n)) { + length++; + } + return length; + } + + // ----------------------------------------------------------------------- + // Seats and colours + // ----------------------------------------------------------------------- + + private otherSeat(seat: playerid): playerid { + return seat === 1 ? 2 : 1; + } + + private get attackerSeat(): playerid | undefined { + return this.defenderSeat === undefined ? undefined : this.otherSeat(this.defenderSeat); + } + + private colourOfSeat(seat: playerid): Stone | undefined { + if (!this.killAll) { + return seat; + } + if (this.defenderSeat === undefined) { + return undefined; + } + return seat === this.defenderSeat ? 1 : 2; + } + + /** + * The palette slot a stone colour is customized in: the seats' own slots in the standard game, + * and in Kill-All games the roles' slots. + */ + private slotOfColour(colour: Stone): number { + if (!this.killAll) { + return colour; + } + return colour === 1 ? DEFENDER_SLOT : ATTACKER_SLOT; + } + + /** + * The fill of a stone colour. In the standard game it is the seat's own slot. In Kill-All games + * it is the role's slot when a palette for this game sets it, and otherwise the first colour for + * the Attacker and the second for the Defender. The renderer's custom function makes that + * choice: without it, players with no palette for this game would get the renderer's own third + * and fourth colours. + */ + private fillOfColour(colour: Stone): number | Colourfuncs { + const slot = this.slotOfColour(colour); + if (!this.killAll) { + return slot; + } + return { func: "custom", default: slot === ATTACKER_SLOT ? ATTACKER_DEFAULT : DEFENDER_DEFAULT, palette: slot }; + } + + /** + * The paint of a stone of `colour`: its border is black or white, whichever contrasts better + * with the fill, or under the display option the palette slot four above the colour's slot, 5 + * to 8 for slots 1 to 4. The option is opt-in because the renderer rejects a palette that lacks + * a slot the game names, and the engine cannot see how many colours a palette has. + */ + private stonePaint(colour: Stone, fromPalette: boolean): { fill: number | Colourfuncs; border: ColourResolvable } { + const fill = this.fillOfColour(colour); + const border: ColourResolvable = fromPalette + ? this.slotOfColour(colour) + BORDER_SLOT_OFFSET + : { func: "bestContrast", bg: fill, fg: ["#000", "#fff"] }; + return { fill, border }; + } + + public getPlayerColour(p: playerid): number | string | Colourfuncs { + if (!this.killAll) { + return p; + } + if (this.defenderSeat === undefined) { + return UNDECIDED_COLOUR; + } + return this.fillOfColour(this.colourOfSeat(p)!); + } + + 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 get points(): number { + return this.boardSize * this.boardSize; + } + + /** + * The most Attacker stones one Kill-All setup phase may contribute: just under half the board, + * so the two phases together can never cover every point, which is what keeps whole-board + * suicide (and so a repeated position) out of the setup without simulating it. + */ + private maxSetupStones(): number { + return Math.floor((this.points - 1) / 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; + } + } + + // ----------------------------------------------------------------------- + // Turn structure + // ----------------------------------------------------------------------- + + /** + * Split Player 2's first move into its declarations and the placements after them. Tagged + * tokens (`14p`, `3s`) may come in either order; untagged numbers fill the reverse komi and + * then the handicap, whichever of them the variants ask for, in that order. + */ + private parseFirstMove(tokens: string[]): IFirstMove { + const out: IFirstMove = { placements: [] }; + let i = 0; + for (; i < tokens.length; i++) { + const m = DECLARATION_RE.exec(tokens[i]); + if (m === null) { + break; + } + let slot: "komi" | "handicap"; + if (m[2] === "p") { + slot = "komi"; + } else if (m[2] === "s") { + slot = "handicap"; + } else if (this.reverseKomi && out.komi === undefined) { + slot = "komi"; + } else if (this.handicapVariant && out.handicap === undefined) { + slot = "handicap"; + } else { + out.error = "BAD_DECLARATION"; + return out; + } + const wanted = slot === "komi" ? this.reverseKomi : this.handicapVariant; + if (!wanted || out[slot] !== undefined) { + out.error = "BAD_DECLARATION"; + return out; + } + out[slot] = m[1]; + } + out.placements = tokens.slice(i); + return out; + } + + /** The canonical tokens of the declarations: points as `14p`, stones as `3s`. */ + private declarationTokens(first: IFirstMove): string[] { + const tokens: string[] = []; + if (first.komi !== undefined) { + tokens.push(`${first.komi}p`); + } + if (first.handicap !== undefined) { + tokens.push(`${first.handicap}s`); + } + return tokens; + } + + /** Whether Player 2's first move still has a reverse komi or handicap to declare. */ + private declarationsPending(): boolean { + return this.komiPending() || (this.declaring() && this.handicap === undefined); + } + + /** + * Whether this is Player 2's first move of the standard game, which follows placement 0. The + * stack says so even while a partial move has already counted its declaration. + */ + private firstMoveOfPlayer2(): boolean { + return !this.killAll && this.phase === "play" && this.stack.length === 2 && this.currplayer === 2; + } + + /** Standard handicap: whether this is Player 2's first move, in which the handicap is set. */ + private declaring(): boolean { + return this.handicapVariant && this.firstMoveOfPlayer2(); + } + + /** Reverse komi: whether this move must begin with the komi, which costs no placement. */ + private komiPending(): boolean { + return this.reverseKomi && this.komi === undefined && this.firstMoveOfPlayer2(); + } + + /** Standard handicap: the passes served at the start of the current move. */ + private forcedPasses(): number { + if (this.killAll || this.phase !== "play" || this.currplayer !== 2 || this.passesOwed <= 0) { + return 0; + } + return Math.min(this.passesOwed, this.moveLength(this.placed)); + } + + /** Kill-All handicap: stones the current player must add when taking the Attacker side now. */ + private handicapOwed(): number { + if (this.phase === "alt-place" && this.handicapVariant && this.currplayer === 2) { + return this.handicap ?? 0; + } + return 0; + } + + // ----------------------------------------------------------------------- + // Dead strings + // ----------------------------------------------------------------------- + + /** The stones of the string at `cell` on `board`, which must hold a stone there. */ + private stringOn(board: Board, cell: string): string[] { + return stringAt(board, this.geo, cell).stones; + } + + /** + * The marking that names the strings at `cells` on `board`: every stone of each, in board + * order. Cells without a stone name nothing, so a mark on a stone the move's own placements + * captured simply drops out. + */ + private canonicalMarksOn(board: Board, cells: Iterable): string[] { + const marked = new Set(); + for (const cell of cells) { + if (board.has(cell) && !marked.has(cell)) { + for (const stone of this.stringOn(board, cell)) { + marked.add(stone); + } + } + } + return this.geo.cells.filter((cell) => marked.has(cell)); + } + + private canonicalMarks(cells: Iterable): string[] { + return this.canonicalMarksOn(this.board, cells); + } + + /** The tokens that spell a marking on `board`: one per marked string, naming its first stone in board order. */ + private markTokensOn(board: Board, marks: string[]): string[] { + const named = new Set(); + const tokens: string[] = []; + for (const cell of marks) { + if (!named.has(cell)) { + tokens.push(`-${cell}`); + for (const stone of this.stringOn(board, cell)) { + named.add(stone); + } + } + } + return tokens; + } + + private markTokens(marks: string[]): string[] { + return this.markTokensOn(this.board, marks); + } + + private sameMarks(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((cell, i) => cell === b[i]); + } + + /** A move's placement tokens and the cells its mark tokens (`-cell`) name. */ + private splitMarks(tokens: string[]): { placements: string[]; marked: string[] } { + const placements: string[] = []; + const marked: string[] = []; + for (const token of tokens) { + if (token.startsWith("-")) { + marked.push(token.substring(1)); + } else { + placements.push(token); + } + } + return { placements, marked }; + } + + /** Whether `placements` are the voluntary passes of a whole-move pass, the only move that can end the game. */ + private passesWholeMove(placements: string[]): boolean { + return !this.killAll && placements.length > 0 && placements.every((token) => token === "pass"); + } + + /** + * Whether `placements` pass at least one placement. Such a move counts as a pass, may carry a + * marking, and stays open for strings to be clicked; a whole-move pass by the opponent can then + * end the game. + */ + private passesAny(placements: string[]): boolean { + return !this.killAll && placements.includes("pass"); + } + + /** + * The marking the current player's move answers: the pending one when the opponent's last + * move passed at least one placement, so that a whole-move pass keeping it ends the game. + */ + private inheritedMarks(): string[] | undefined { + const n = this.passes.length; + return n > 0 && this.passes[n - 1] !== this.currplayer ? this.marks : undefined; + } + + /** The board with the marked strings removed. */ + private effectiveBoard(): Board { + if (this.marks.length === 0) { + return this.board; + } + const board = new Map(this.board); + for (const cell of this.marks) { + board.delete(cell); + } + return board; + } + + private clearMarks(): void { + this.marks = []; + } + + /** + * End dead-stone marking for the rest of the game: a placement or a pass that marks nothing + * dead has followed a marking, so the players must capture dead stones for real, and the game + * ends when both pass their whole moves in turn, every stone on the board counting as alive. + */ + private endMarking(): void { + this.results.push({ type: "select", what: "dead", how: "locked" }); + this.locked = true; + this.marks = []; + } + + /** Whether a marking is pending that the current move answers, so that a placement would end marking. */ + private markingPending(): boolean { + return this.marks.length > 0 && !this.locked; + } + + /** Append to a valid verdict the warning that a placement now ends dead-stone marking. */ + private warnMarkingEnds(result: IValidationResult): IValidationResult { + if (result.valid && this.markingPending()) { + result.message = `${result.message} ${i18next.t("apgames:validation.thuemorsego.PLACING_ENDS_MARKING")}`; + } + return result; + } + + // ----------------------------------------------------------------------- + // Positions and placements + // ----------------------------------------------------------------------- + + /** + * How often each position has arisen so far: the initial board and every position a stone + * placement created, including those partway through a move. Passes, taking the button and + * the handicap's declaration and passes change nothing and create no position. + */ + private positionCounts(): Map { + const counts = new Map(); + const add = (sig: string): void => { + counts.set(sig, (counts.get(sig) ?? 0) + 1); + }; + add(signature(this.stack[0].board, this.geo)); + for (let i = 1; i < this.stack.length; i++) { + const state = this.stack[i]; + for (const sig of state.interim) { + add(sig); + } + if (state.novel) { + add(signature(state.board, this.geo)); + } + } + return counts; + } + + private newSim(length: number, placed: number, base?: Map): ISim { + return { + board: new Map(this.board), + base: base ?? this.positionCounts(), + results: [], + start: signature(this.board, this.geo), + created: [], + captured: 0, + suicided: 0, + length, + placed, + buttonTaken: false, + placedStone: false, + cleared: false, + over: false, + }; + } + + private copySim(sim: ISim): ISim { + return { ...sim, board: new Map(sim.board), created: [...sim.created], results: [...sim.results] }; + } + + private occurrences(sim: ISim, sig: string): number { + let count = sim.base.get(sig) ?? 0; + for (const created of sim.created) { + if (created === sig) { + count++; + } + } + return count; + } + + /** + * Record the position the board holds after a placement (or after the clearing a placement + * owed). Returns the key of the validation message when the repetition rule forbids it: the + * suicide message when the placement left the board as it was because its own stone was + * removed, the move's suicide message when the clearing at the end of a move with checked + * weak eyes removed every stone it placed, the plain one otherwise, each in its superko or + * double delayed superko wording. By default the fifth occurrence of a position ends the game + * instead. `step` is what the clearing removed this time. + */ + private recordPosition(sim: ISim, step: { captured: number; suicided: number }): TokenError | undefined { + const sig = signature(sim.board, this.geo); + const seen = this.occurrences(sim, sig); + const limit = this.repetitionLimit; + if (limit !== undefined && seen >= limit) { + const previous = sim.created.length > 0 ? sim.created[sim.created.length - 1] : sim.start; + const unchanged = step.captured === 0 && step.suicided > 0 && (sig === previous || sig === sim.start); + const stem = !unchanged ? "KO_PSK" : sig === sim.start && step.suicided > 1 ? "SUICIDE_REPEAT_MOVE" : "SUICIDE_REPEAT"; + return limit === 1 ? stem : `${stem}_DDPSK`; + } + sim.created.push(sig); + if (limit === undefined && seen + 1 >= REPETITIONS_FOR_DRAW) { + sim.over = true; + } + return undefined; + } + + /** Tromp-Taylor clearing after `colour` placed: opponent strings without liberties, then its own. */ + private clear(sim: ISim, colour: Stone): { captured: number; suicided: number } { + const step = { captured: 0, suicided: 0 }; + for (const group of clearColour(sim.board, this.geo, otherColour(colour))) { + sim.results.push({ type: "capture", where: group.join(","), count: group.length }); + step.captured += group.length; + } + for (const group of clearColour(sim.board, this.geo, colour)) { + sim.results.push({ type: "capture", where: group.join(","), count: group.length, how: "suicide" }); + step.suicided += group.length; + } + sim.captured += step.captured; + sim.suicided += step.suicided; + return step; + } + + /** + * Checked weak eyes: the clearing deferred to the move's last placement, done once. When it + * removes anything, the board it leaves is a position to record. + */ + private clearAtEnd(sim: ISim, colour: Stone): TokenError | undefined { + if (!this.checkedEyes || sim.cleared || !sim.placedStone) { + return undefined; + } + sim.cleared = true; + return this.recordPosition(sim, this.clear(sim, colour)); + } + + /** Serve handicap passes at the start of a move. */ + private serveForced(sim: ISim, count: number): void { + for (let i = 0; i < count; i++) { + sim.results.push({ type: "pass", why: "handicap" }); + sim.placed++; + } + } + + /** + * Apply one placement token (a cell, `pass`, or `button`) for `colour` to `sim`. Returns the + * key of the validation message when the token is illegal. Every stone placement creates a + * position, judged at once; with checked weak eyes the position after a placement that is not + * the move's last is the board before any clearing. + */ + private applyToken(sim: ISim, token: string, colour: Stone): TokenError | undefined { + if (sim.over) { + return "OVER_MID_MOVE"; + } + if (sim.placed >= sim.length) { + return "TOO_MANY"; + } + const final = sim.placed === sim.length - 1; + if (token === "pass" || token === "button") { + if (token === "pass") { + if (this.killAll) { + return "NO_PASS"; + } + sim.results.push({ type: "pass" }); + } else { + if (this.buttonValue === 0) { + return "NO_BUTTON"; + } + if (this.button !== undefined || sim.buttonTaken) { + return "BUTTON_TAKEN"; + } + sim.buttonTaken = true; + sim.results.push({ type: "button" }); + } + sim.placed++; + return final ? this.clearAtEnd(sim, colour) : undefined; + } + if (!this.isValidCell(token)) { + return "INVALIDCELL"; + } + if (sim.board.has(token)) { + return "OCCUPIED"; + } + sim.board.set(token, colour); + sim.results.push({ type: "place", where: token }); + sim.placedStone = true; + sim.placed++; + if (!this.checkedEyes) { + return this.recordPosition(sim, this.clear(sim, colour)); + } + if (!final) { + // With checked weak eyes the board is neither cleared nor its position recorded until + // the move's last placement: only the position the move leaves counts for repetition. + return undefined; + } + sim.cleared = true; + return this.recordPosition(sim, this.clear(sim, colour)); + } + + /** Pass the placements a move string left unspecified (standard game only). */ + private finishMove(sim: ISim, colour: Stone): TokenError | undefined { + while (!sim.over && sim.placed < sim.length) { + if (this.killAll) { + return "INCOMPLETE"; + } + const err = this.applyToken(sim, "pass", colour); + if (err !== undefined) { + return err; + } + } + return undefined; + } + + private tokenMessage(key: TokenError, token: string): string { + switch (key) { + case "INVALIDCELL": + return i18next.t("apgames:validation._general.INVALIDCELL", { cell: token }); + case "OCCUPIED": + if (this.killAll || this.locked) { + return i18next.t("apgames:validation._general.OCCUPIED", { where: token }); + } + return i18next.t("apgames:validation.thuemorsego.OCCUPIED_MARK", { where: token }); + case "INCOMPLETE": + return i18next.t("apgames:validation._general.INCOMPLETE_MOVE"); + default: + return i18next.t(TOKEN_ERROR_KEYS[key]); + } + } + + /** The tokens that may come next in a move on `sim`. */ + private candidates(sim: ISim): string[] { + const list: string[] = []; + if (!this.killAll) { + list.push("pass"); + if (this.buttonValue > 0 && this.button === undefined && !sim.buttonTaken) { + list.push("button"); + } + } + for (const cell of this.geo.cells) { + if (!sim.board.has(cell)) { + list.push(cell); + } + } + return list; + } + + /** Whether some legal sequence of placements completes the move on `sim`. */ + private hasContinuation(sim: ISim, colour: Stone): boolean { + if (sim.over || sim.placed >= sim.length) { + return true; + } + for (const token of this.candidates(sim)) { + const trial = this.copySim(sim); + if (this.applyToken(trial, token, colour) !== undefined) { + continue; + } + if (this.hasContinuation(trial, colour)) { + return true; + } + } + return false; + } + + /** Every legal sequence of placements that completes the move on `sim`. */ + private completions(sim: ISim, colour: Stone): string[][] { + if (sim.over || sim.placed >= sim.length) { + return [[]]; + } + const out: string[][] = []; + for (const token of this.candidates(sim)) { + const trial = this.copySim(sim); + if (this.applyToken(trial, token, colour) !== undefined) { + continue; + } + for (const rest of this.completions(trial, colour)) { + out.push([token, ...rest]); + } + } + return out; + } + + /** A random legal sequence of placements that completes the move on `sim`. */ + private randomCompletion(sim: ISim, colour: Stone): string[] | undefined { + if (sim.over || sim.placed >= sim.length) { + return []; + } + for (const token of shuffle(this.candidates(sim)) as string[]) { + const trial = this.copySim(sim); + if (this.applyToken(trial, token, colour) !== undefined) { + continue; + } + const rest = this.randomCompletion(trial, colour); + if (rest !== undefined) { + return [token, ...rest]; + } + } + return undefined; + } + + private hasLegalMove(seat: playerid): boolean { + const colour = this.colourOfSeat(seat)!; + const length = this.moveLength(this.placed); + const forced = seat === this.currplayer ? this.forcedPasses() : 0; + const sim = this.newSim(length, 0); + this.serveForced(sim, forced); + return this.hasContinuation(sim, colour); + } + + // ----------------------------------------------------------------------- + // Move lists + // ----------------------------------------------------------------------- + + /** + * Legal moves. Two-placement moves are enumerated exhaustively, which is slow on large boards; + * the game is flagged `no-moves` so that only tests and tooling call this. + */ + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = []; + switch (this.phase) { + case "hand-n": + for (let n = 1; n <= this.maxHandicap; n++) { + moves.push(n.toString()); + } + break; + case "alt-place": + if (this.board.size < this.maxSetupStones()) { + for (const cell of this.geo.cells) { + if (!this.board.has(cell)) { + moves.push(cell); + } + } + } + if (this.handicapOwed() === 0) { + moves.push("attacker"); + } + break; + case "play": + // The komi may be any of the numbered values; the list shows the moves with the first. + moves.push(...this.playMoves().map((move) => this.komiPending() ? `${KOMI_STEP}p,${move}` : move)); + break; + } + return moves; + } + + private playMoves(): string[] { + const colour = this.colourOfSeat(this.currplayer)!; + const length = this.moveLength(this.placed); + const base = this.positionCounts(); + const moves: string[] = []; + if (this.declaring() && this.handicap === undefined) { + for (let n = 1; n <= this.maxHandicap; n++) { + const forced = Math.min(n, length); + if (forced === length) { + moves.push(`${n}s`); + continue; + } + const sim = this.newSim(length, forced, base); + for (const rest of this.completions(sim, colour)) { + moves.push([`${n}s`, ...rest].join(",")); + } + } + return moves; + } + const forced = this.forcedPasses(); + if (forced === length) { + return [Array(length).fill("pass").join(",")]; + } + const sim = this.newSim(length, forced, base); + return this.completions(sim, colour).map((tokens) => this.withMarks(tokens).join(",")); + } + + /** A whole-move pass keeps the pending marking, which is the pass that can end the game. */ + private withMarks(tokens: string[]): string[] { + return this.passesWholeMove(tokens) ? [...tokens, ...this.markTokens(this.marks)] : tokens; + } + + public randomMove(): string { + if (this.phase !== "play") { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + const values = this.komiValues(); + const komi = this.komiPending() ? [`${values[Math.floor(Math.random() * values.length)]}p`] : []; + const colour = this.colourOfSeat(this.currplayer)!; + const length = this.moveLength(this.placed); + let forced: number; + let prefix: string[] = []; + if (this.declaring() && this.handicap === undefined) { + const n = 1 + Math.floor(Math.random() * this.maxHandicap); + forced = Math.min(n, length); + prefix = [`${n}s`]; + } else { + forced = this.forcedPasses(); + } + const sim = this.newSim(length, forced); + const rest = this.randomCompletion(sim, colour) ?? []; + const tokens = [...komi, ...(prefix.length > 0 ? [...prefix, ...rest] : this.withMarks(rest))]; + return tokens.length > 0 ? tokens.join(",") : Array(length).fill("pass").join(","); + } + + // ----------------------------------------------------------------------- + // Pickers and buttons + // ----------------------------------------------------------------------- + + /** + * Numbered stones in a row across the board: `values` left to right, centred on the centre + * column, `offset` rows below the centre point. A cell that already holds a stone shows that + * stone instead, but still answers to a click with its value, since clicking the opponent's + * stone means nothing else. + */ + private pickerRow(values: number[], offset: number): IPickerButton[] { + const centre = Math.floor(this.boardSize / 2); + const left = centre - Math.floor(values.length / 2); + return values.map((value, i) => ({ cell: this.coords2algebraic(left + i, centre + offset), value })); + } + + private range(max: number): number[] { + const values: number[] = []; + for (let value = 1; value <= max; value++) { + values.push(value); + } + return values; + } + + /** Kill-All handicap: the odd values in a row just above the centre, the even ones just below. */ + private killAllPicker(): IPickerButton[] { + const values = this.range(this.maxHandicap); + return [ + ...this.pickerRow(values.filter((value) => value % 2 === 1), -1), + ...this.pickerRow(values.filter((value) => value % 2 === 0), 1), + ]; + } + + /** Standard handicap: every value up to the limit in one row. */ + private handicapPicker(offset: number): IPickerButton[] { + return this.pickerRow(this.range(this.maxHandicap), offset); + } + + /** Reverse komi: every multiple of 14 up to the limit in one row. */ + private komiPicker(offset: number): IPickerButton[] { + return this.pickerRow(this.komiValues(), offset); + } + + /** Rows of the first-move pickers: the komi above the handicap when both are offered. */ + private pickerOffsets(): { komi: number; handicap: number } { + const both = this.reverseKomi && this.handicapVariant; + return { komi: both ? -1 : 0, handicap: both ? 1 : 0 }; + } + + /** + * The picker the current state shows, if any, and the cells of the values chosen so far. On + * Player 2's first move the komi and handicap stones stay until every choice is made, and + * then as long as no placement remains, so that a choice can be changed by clicking. + */ + private picker(): { buttons: IPickerButton[]; chosen: string[] } | undefined { + if (this.gameover) { + return undefined; + } + if (this.phase === "hand-n") { + const buttons = this.killAllPicker(); + return { buttons, chosen: buttons.filter((btn) => btn.value === this.handicap).map((btn) => btn.cell) }; + } + if (!this.firstMoveOfPlayer2() || (!this.reverseKomi && !this.handicapVariant)) { + return undefined; + } + const chosenAll = (!this.reverseKomi || this.komi !== undefined) && (!this.handicapVariant || this.handicap !== undefined); + if (chosenAll && (!this.handicapVariant || this.handicap === 1)) { + // A placement remains, so the board takes over. + return undefined; + } + const offsets = this.pickerOffsets(); + const buttons: IPickerButton[] = []; + const chosen: string[] = []; + if (this.reverseKomi) { + for (const btn of this.komiPicker(offsets.komi)) { + buttons.push(btn); + if (btn.value === this.komi) { + chosen.push(btn.cell); + } + } + } + if (this.handicapVariant) { + for (const btn of this.handicapPicker(offsets.handicap)) { + buttons.push(btn); + if (btn.value === this.handicap) { + chosen.push(btn.cell); + } + } + } + return { buttons, chosen }; + } + + /** + * The move that passes every placement of the current move the player controls, keeping the + * pending marking so that clicks on strings change it rather than start it over. + */ + private wholeMovePass(): string { + const free = Math.max(1, this.moveLength(this.placed) - this.forcedPasses()); + return [...Array(free).fill("pass"), ...this.markTokens(this.marks)].join(","); + } + + /** + * The move a `_btn_` click stands for: `pass` passes the whole move, keeping the reverse komi + * the move in progress begins with, and other values are moves. + */ + private buttonMove(value: string, move: string): string { + if (value !== "pass" || this.phase !== "play") { + return value; + } + const pass = this.wholeMovePass(); + if (this.declarationsPending()) { + return [...this.declarationTokens(this.parseFirstMove(this.parseTokens(move))), pass].join(","); + } + return pass; + } + + public getButtons(): ICustomButton[] { + if (this.gameover) { + return []; + } + if (this.phase === "alt-place") { + return [{ label: "apgames:buttons.thuemorsego.attacker", move: "attacker" }]; + } + if (this.phase !== "play" || this.killAll) { + return []; + } + // The handicap declaration and the reverse komi are typed or clicked, not pressed. + if ((this.declaring() && this.handicap === undefined) || this.komiPending()) { + return []; + } + if (this.forcedPasses() === this.moveLength(this.placed)) { + return []; + } + // The Pass button passes the whole move; a single click on the board can still fill a placement. + const buttons: ICustomButton[] = [{ label: "apgames:buttons.pass", move: this.wholeMovePass() }]; + if (this.buttonValue > 0 && this.button === undefined) { + buttons.push({ label: "apgames:buttons.takebutton", move: "button" }); + } + return buttons; + } + + // ----------------------------------------------------------------------- + // Clicks + // ----------------------------------------------------------------------- + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + // Renderer builds before August 2026 pass clicks on the tracker's glyphs to the game + // with the tracker's own rows and columns. They change nothing. + if (piece !== undefined && TRACK_KEY_RE.test(piece)) { + return { ...this.validateMove(move), move }; + } + if (row < 0 || col < 0) { + // Button clicks replace whatever move is in progress. + if (piece === undefined || !piece.startsWith("_btn_")) { + return { move, valid: false, message: i18next.t("apgames:validation._general.UNKNOWN_CLICK") }; + } + const newmove = this.buttonMove(piece.substring("_btn_".length), move); + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? newmove : move; + return result; + } + const cell = this.coords2algebraic(col, row); + const outcome = this.clickCell(move.toLowerCase().replace(/\s+/g, ""), cell); + if ("error" in outcome) { + return { move, valid: false, message: i18next.t(CLICK_ERROR_KEYS[outcome.error], { where: cell }) }; + } + const newmove = outcome.move; + 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 }), + }; + } + } + + private parseTokens(move: string): string[] { + return move.length === 0 ? [] : move.split(","); + } + + /** Extend `move` with a click on `cell`, or say why the click means nothing now. */ + private clickCell(move: string, cell: string): IClickOutcome { + const valueAt = (buttons: IPickerButton[]): string | undefined => buttons.find((btn) => btn.cell === cell)?.value.toString(); + const toggle = (list: string): string => { + const cells = this.parseTokens(list); + const next = cells.includes(cell) ? cells.filter((c) => c !== cell) : [...cells, cell]; + return next.join(","); + }; + switch (this.phase) { + case "hand-n": { + const value = valueAt(this.killAllPicker()); + return value === undefined ? { error: "NOT_A_NUMBERED_STONE" } : { move: value }; + } + case "alt-place": + if (move.startsWith("attacker")) { + return { move: `attacker:${toggle(move.substring("attacker:".length))}` }; + } + return { move: cell }; + case "play": { + const tokens = this.parseTokens(move); + if (this.declarationsPending()) { + return this.clickFirstMove(tokens, cell); + } + return { move: this.clickPlacements(tokens, cell, this.moveLength(this.placed) - this.forcedPasses()) }; + } + } + } + + /** + * Player 2's first move with a reverse komi or handicap to choose: a numbered stone sets or + * changes its value, in either order; a changed handicap drops the placements, as it decides + * how many there are. Once every choice is made and a placement remains, the stones give way + * to the board. + */ + private clickFirstMove(tokens: string[], cell: string): IClickOutcome { + const valueAt = (buttons: IPickerButton[]): string | undefined => buttons.find((btn) => btn.cell === cell)?.value.toString(); + const wantKomi = this.reverseKomi; + const wantHandicap = this.handicapVariant; + const length = this.moveLength(this.placed); + const first = this.parseFirstMove(tokens); + if (first.error !== undefined) { + first.komi = undefined; + first.handicap = undefined; + first.placements = []; + } + const chosenAll = (!wantKomi || first.komi !== undefined) && (!wantHandicap || first.handicap !== undefined); + const stonesShown = !(chosenAll && (!wantHandicap || first.handicap === "1")); + const offsets = this.pickerOffsets(); + const komiHit = stonesShown && wantKomi ? valueAt(this.komiPicker(offsets.komi)) : undefined; + const handicapHit = stonesShown && wantHandicap ? valueAt(this.handicapPicker(offsets.handicap)) : undefined; + if (komiHit !== undefined) { + first.komi = komiHit; + } else if (handicapHit !== undefined) { + first.handicap = handicapHit; + first.placements = []; + } else { + const komiMissing = wantKomi && first.komi === undefined; + const handicapMissing = wantHandicap && first.handicap === undefined; + if (komiMissing && handicapMissing) { + return { error: "CHOOSE_FIRST" }; + } + if (komiMissing) { + return { error: "KOMI_FIRST" }; + } + if (handicapMissing) { + return { error: "DECLARE_FIRST" }; + } + const forced = first.handicap === undefined ? 0 : Math.min(parseInt(first.handicap, 10), length); + if (length - forced === 0) { + return { error: "NO_PLACEMENT_LEFT" }; + } + first.placements = this.parseTokens(this.clickPlacements(first.placements, cell, length - forced)); + } + return { move: [...this.declarationTokens(first), ...first.placements].join(",") }; + } + + /** + * Extend the placement tokens of a move in progress with a click on `cell`, `free` placements + * being the player's. A stone clicked marks its string dead only once the move passes (the + * Pass button, a typed pass or a mark), never by itself: a stone placed first then a stone + * clicked is a misclick, refused as occupied, so the player must pass first and then place. + */ + private clickPlacements(tokens: string[], cell: string, free: number): string { + const { placements, marked } = this.splitMarks(tokens); + const passing = placements.includes("pass") || marked.length > 0; + let placed = placements.filter((token) => token !== "pass"); + const board = this.boardAfter(placed); + if (placed.includes(cell) && board.has(cell)) { + // A placement clicked again is taken back, while its stone is still on the board; a + // point emptied again by suicide or capture can take a second stone. + placed = placed.filter((token) => token !== cell); + } else if (board.has(cell) && !this.killAll && passing && placed.length < free) { + // A stone clicked while the move passes: its string is marked dead, or unmarked + // again, as the board stands after the move's placements. + const marks = new Set(this.canonicalMarksOn(board, marked)); + const stones = this.stringOn(board, cell); + if (stones.every((stone) => marks.has(stone))) { + for (const stone of stones) { marks.delete(stone); } + } else { + for (const stone of stones) { marks.add(stone); } + } + const passes = Array(free - placed.length).fill("pass"); + return [...placed, ...passes, ...this.markTokensOn(board, this.geo.cells.filter((c) => marks.has(c)))].join(","); + } else if (placed.length < free) { + placed = [...placed, cell]; + } else { + // A click on a stone with no placement left to pass is left to fail as occupied. + placed = [...placed.slice(0, -1), cell]; + } + // The placements come first and the passes that fill the rest of the move after them, so + // a stone placed after passing the move gives "e5,pass" rather than "pass,e5". Marks on + // strings that survive the placements stay; a move that passes nothing carries none. + const after = this.boardAfter(placed); + const marks = placed.length < free ? this.canonicalMarksOn(after, marked) : []; + const passes = passing || marks.length > 0 ? Array(free - placed.length).fill("pass") : []; + return [...placed, ...passes, ...this.markTokensOn(after, marks)].join(","); + } + + /** The board after the current player's placements `stones` (cells or the button), as far as they are legal. */ + private boardAfter(stones: string[]): Board { + const colour = this.colourOfSeat(this.currplayer)!; + const sim = this.newSim(this.moveLength(this.placed), 0); + this.serveForced(sim, this.forcedPasses()); + for (const token of stones) { + if (this.applyToken(sim, token, colour) !== undefined) { + break; + } + } + return sim.board; + } + + // ----------------------------------------------------------------------- + // Validation + // ----------------------------------------------------------------------- + + 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) { + return this.fail(result, i18next.t("apgames:validation._general.MOVES_GAMEOVER")); + } + switch (this.phase) { + case "hand-n": + return this.validateHandicapStones(m, result); + case "alt-place": + return this.validateAltPlace(m, result); + case "play": + return this.validatePlay(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; + } + + private validateHandicapStones(m: string, result: IValidationResult): IValidationResult { + const max = this.maxHandicap; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.thuemorsego.INSTRUCTIONS_HANDICAP", { max })); + } + if (!/^\d+$/.test(m)) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.HANDICAP_INVALID")); + } + const n = parseInt(m, 10); + if (n < 1 || n > max) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.HANDICAP_RANGE", { max })); + } + return this.ok(result, 0, i18next.t("apgames:validation.thuemorsego.HANDICAP_OK", { count: n }), true); + } + + /** Setup placements: known cells, empty, no duplicates (see `maxSetupStones` for why no superko check is needed). */ + private checkSetupCells(cells: string[], result: IValidationResult): IValidationResult | undefined { + const named = 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 (named.has(cell)) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.DUPLICATE_CELL", { where: cell })); + } + named.add(cell); + } + return undefined; + } + + 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.thuemorsego.INSTRUCTIONS_ALT_HANDICAP", { count: owed })); + } + return this.ok(result, -1, i18next.t("apgames:validation.thuemorsego.INSTRUCTIONS_ALT")); + } + if (m === "attacker" || m.startsWith("attacker:")) { + const cells = this.parseTokens(m.substring("attacker:".length)); + if (owed === 0) { + if (cells.length > 0) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.ATTACKER_NO_STONES")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + const err = this.checkSetupCells(cells, result); + if (err !== undefined) { return err; } + if (cells.length > owed) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.BATCH_TOO_MANY", { count: owed })); + } + if (cells.length < owed) { + return this.ok(result, -1, i18next.t("apgames:validation.thuemorsego.STONES_LEFT", { count: owed - cells.length }), cells.length > 0); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + if (m === "pass") { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.INVALID_PASS")); + } + if (this.board.size >= this.maxSetupStones()) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.SETUP_FULL", { max: this.maxSetupStones() })); + } + const err = this.checkSetupCells([m], result); + if (err !== undefined) { return err; } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private instructions(length: number, free: number): string { + if (this.killAll) { + const key = this.currplayer === this.defenderSeat + ? "apgames:validation.thuemorsego.INSTRUCTIONS_PLAY_DEFENDER" + : "apgames:validation.thuemorsego.INSTRUCTIONS_PLAY_ATTACKER"; + return i18next.t(key, { count: length }); + } + const button = this.buttonValue > 0 && this.button === undefined; + if (free < length) { + const key = button + ? "apgames:validation.thuemorsego.INSTRUCTIONS_PLAY_FORCED_BUTTON" + : "apgames:validation.thuemorsego.INSTRUCTIONS_PLAY_FORCED"; + return i18next.t(key, { points: this.buttonValue }); + } + const key = button ? "apgames:validation.thuemorsego.INSTRUCTIONS_PLAY_BUTTON" : "apgames:validation.thuemorsego.INSTRUCTIONS_PLAY"; + return i18next.t(key, { count: free, points: this.buttonValue }); + } + + private validatePlay(m: string, result: IValidationResult): IValidationResult { + if (!this.declarationsPending()) { + return this.validatePlacements(m, result); + } + return this.validateFirstMove(m, result); + } + + /** + * Player 2's first move: the reverse komi and the handicap it declares, in either order, then + * its placements. Neither declaration costs a placement; the handicap stones are the + * placements passed, this declaration being the first. + */ + private validateFirstMove(m: string, result: IValidationResult): IValidationResult { + const colour = this.colourOfSeat(this.currplayer)!; + const length = this.moveLength(this.placed); + const wantKomi = this.komiPending(); + const wantHandicap = this.declaring() && this.handicap === undefined; + const max = this.maxKomi; + const stones = this.maxHandicap; + if (m.length === 0) { + const key = wantKomi && wantHandicap + ? "apgames:validation.thuemorsego.INSTRUCTIONS_BOTH" + : wantKomi ? "apgames:validation.thuemorsego.INSTRUCTIONS_KOMI" : "apgames:validation.thuemorsego.INSTRUCTIONS_DECLARE"; + return this.ok(result, -1, i18next.t(key, { max: wantKomi ? max : stones, stones })); + } + const first = this.parseFirstMove(m.split(",")); + if (first.error !== undefined) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.BAD_DECLARATION")); + } + let komi: number | undefined; + if (first.komi !== undefined) { + komi = parseInt(first.komi, 10); + if (!this.isKomi(komi)) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.KOMI_INVALID", { max })); + } + } + let handicap: number | undefined; + if (first.handicap !== undefined) { + handicap = parseInt(first.handicap, 10); + if (handicap < 1 || handicap > stones) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.DECLARE_RANGE", { max: stones })); + } + } + if (wantKomi && komi === undefined) { + if (first.placements.length > 0) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.KOMI_MISSING")); + } + return this.ok(result, -1, i18next.t("apgames:validation.thuemorsego.HANDICAP_THEN_KOMI", { count: handicap }), true); + } + if (wantHandicap && handicap === undefined) { + if (first.placements.length > 0) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.DECLARE_MISSING")); + } + return this.ok(result, -1, i18next.t("apgames:validation.thuemorsego.KOMI_THEN_DECLARE", { count: komi }), true); + } + if (handicap !== undefined) { + return this.validateDeclaration(handicap, first.placements, result, colour, length); + } + if (first.placements.length === 0) { + return this.ok(result, 0, i18next.t("apgames:validation.thuemorsego.KOMI_THEN_PLACE", { count: komi }), true); + } + return this.validatePlacements(first.placements.join(","), result); + } + + private validatePlacements(m: string, result: IValidationResult): IValidationResult { + const colour = this.colourOfSeat(this.currplayer)!; + const length = this.moveLength(this.placed); + const forced = this.forcedPasses(); + const free = length - forced; + if (m.length === 0) { + const note = this.markingPending() ? ` ${i18next.t("apgames:validation.thuemorsego.MARKING_PENDING", { count: this.marks.length })}` : ""; + return this.ok(result, -1, `${this.instructions(length, free)}${note}`); + } + const { placements: tokens, marked } = this.splitMarks(m.split(",")); + if (free === 0) { + // The whole move is served automatically; accept the passes that spell it out. + if (marked.length === 0 && tokens.length <= length && tokens.every((token) => token === "pass")) { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + return this.fail(result, i18next.t("apgames:validation.thuemorsego.FORCED_MOVE")); + } + if (tokens.length > free) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.TOO_MANY", { count: free })); + } + const sim = this.newSim(length, 0); + this.serveForced(sim, forced); + const err = this.applyTokens(sim, tokens, colour, result); + if (err !== undefined) { return err; } + // A move that passes at least one placement, as submitted now, stays open for strings to be + // marked dead or a stone to be placed for a pass; it counts as a pass for ending the game. + const passes = this.passesAny(tokens) || (!this.killAll && tokens.length < free); + if (marked.length > 0) { + const bad = this.checkMarks(passes, marked, sim.board); + if (bad !== undefined) { return this.fail(result, bad); } + } + if (passes && !sim.over) { + // With checked weak eyes the clearing that ends the move can leave a forbidden position. + const finish = this.finishMove(this.copySim(sim), colour); + if (finish !== undefined) { + const key = finish.startsWith("SUICIDE_REPEAT") + ? "apgames:validation.thuemorsego.MUST_CONTINUE_SUICIDE" + : "apgames:validation.thuemorsego.MUST_CONTINUE"; + return this.ok(result, -1, i18next.t(key), true); + } + // Every placement passed, spelled out or (marks alone) implied, is a whole-move pass. + const pure = tokens.every((token) => token === "pass"); + const explicit = this.passesAny(tokens) || marked.length > 0; + return this.passVerdict(sim.board, marked, pure, explicit, free, result); + } + return this.warnMarkingEnds(this.completeness(sim, colour, result)); + } + + /** + * The validation message when the mark tokens of a move are not allowed, if they are not. + * `passes` is whether the move, as submitted now, passes at least one placement, and `board` + * the board after its placements: a mark on a stone they captured is allowed and simply drops + * out, while a mark on a point that never held a stone is a mistake. + */ + private checkMarks(passes: boolean, marked: string[], board: Board): string | undefined { + if (!passes) { + return i18next.t("apgames:validation.thuemorsego.MARKS_NEED_PASS"); + } + for (const cell of marked) { + if (!this.isValidCell(cell)) { + return i18next.t("apgames:validation._general.INVALIDCELL", { cell }); + } + if (!board.has(cell) && !this.board.has(cell)) { + return i18next.t("apgames:validation.thuemorsego.MARK_EMPTY", { where: cell }); + } + } + return undefined; + } + + /** + * The verdict on a move that passes at least one placement: what submitting it does to the + * marking and to the game. The move is left open (`complete` 0) while strings can still be + * clicked, or a stone placed for one of its passes; once marking has ended, a pass of a + * single placement is finished. `board` is the board after the move's placements, `pure` + * whether the move passes every placement, which alone can end the game, and `explicit` + * whether its pass is spelled out (or a mark carried), which clicks on strings need. + */ + private passVerdict(board: Board, marked: string[], pure: boolean, explicit: boolean, free: number, result: IValidationResult): IValidationResult { + const marks = this.canonicalMarksOn(board, marked); + const count = marks.length; + const inherited = this.inheritedMarks(); + if (this.locked) { + if (count > 0) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.MARKS_LOCKED")); + } + const key = !pure + ? "apgames:validation.thuemorsego.PASS_LOCKED_PLACE" + : inherited === undefined ? "apgames:validation.thuemorsego.PASS_LOCKED" : "apgames:validation.thuemorsego.PASS_LOCKED_ENDS"; + return this.ok(result, free > 1 ? 0 : 1, i18next.t(key), true); + } + let key: string; + if (this.marks.length > 0 && count === 0) { + // A pass that marks nothing dead after a marking ends marking for the rest of the game. + key = pure ? "apgames:validation.thuemorsego.PASS_CLEARS" : "apgames:validation.thuemorsego.PASS_CLEARS_PLACE"; + } else if (!pure) { + key = "apgames:validation.thuemorsego.PASS_PLACE"; + } else if (inherited === undefined) { + key = "apgames:validation.thuemorsego.PASS_OPEN"; + } else if (this.sameMarks(marks, this.canonicalMarksOn(board, inherited))) { + key = "apgames:validation.thuemorsego.PASS_AGREE"; + } else { + key = "apgames:validation.thuemorsego.PASS_DISPUTE"; + } + let message = i18next.t(key, { count }); + if (!pure) { + // A stone placed first leaves no way to click a string: the move must pass first. + const hint = explicit ? "apgames:validation.thuemorsego.MARK_CLICK" : "apgames:validation.thuemorsego.MARK_HINT"; + message = `${message} ${i18next.t(hint)}`; + } + return this.ok(result, 0, message, true); + } + + /** The rest of a first move that declares a handicap of `n`: the placements `rest` after it. */ + private validateDeclaration(n: number, rest: string[], result: IValidationResult, colour: Stone, length: number): IValidationResult { + // The declaration itself is the first pass served. + const forced = Math.min(n, length); + const free = length - forced; + if (free === 0) { + if (rest.length > 0) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.DECLARE_NO_PLACEMENT", { count: n })); + } + return this.ok(result, 0, i18next.t("apgames:validation.thuemorsego.DECLARE_OK", { count: n }), true); + } + if (rest.length > free) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.TOO_MANY", { count: free })); + } + const sim = this.newSim(length, forced); + const err = this.applyTokens(sim, rest, colour, result); + if (err !== undefined) { return err; } + if (rest.length === 0) { + const button = this.buttonValue > 0 && this.button === undefined; + const key = button ? "apgames:validation.thuemorsego.DECLARE_ONE_BUTTON" : "apgames:validation.thuemorsego.DECLARE_ONE"; + return this.ok(result, 0, i18next.t(key), true); + } + return this.completeness(sim, colour, result); + } + + private applyTokens(sim: ISim, tokens: string[], colour: Stone, result: IValidationResult): IValidationResult | undefined { + for (const token of tokens) { + const err = this.applyToken(sim, token, colour); + if (err !== undefined) { + return this.fail(result, this.tokenMessage(err, token)); + } + } + return undefined; + } + + /** + * The verdict on a move that places every placement it controls, or whose placements have + * ended the game: it is complete. In Kill-All, where nothing is passed, a move with + * placements left must go on, and must be able to. + */ + private completeness(sim: ISim, colour: Stone, result: IValidationResult): IValidationResult { + if (sim.over || sim.placed === sim.length) { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + if (!this.hasContinuation(sim, colour)) { + return this.fail(result, i18next.t("apgames:validation.thuemorsego.DEAD_END")); + } + return this.ok(result, -1, i18next.t("apgames:validation.thuemorsego.PLACE_MORE", { count: sim.length - sim.placed }), true); + } + + // ----------------------------------------------------------------------- + // Moves + // ----------------------------------------------------------------------- + + public move(m: string, { partial = false, trusted = false } = {}): ThueMorseGoGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:validation._general.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 (m.length === 0) { return this; } + + this.results = []; + this.interim = []; + this.novel = false; + this.alive = undefined; + + let committed: boolean; + switch (this.phase) { + case "hand-n": + committed = this.moveHandicapStones(m, partial); + break; + case "alt-place": + committed = this.moveAltPlace(m, partial); + break; + case "play": + committed = this.movePlay(m, partial); + break; + } + if (!committed) { + return this; + } + // Every move that leaves the board in play, the claim included, may have settled a + // Kill-All game or left the next player without a legal move. + if (this.killAll && this.phase === "play") { + this.checkLifeAndDeath(); + this.checkStalemate(); + } + this.saveState(); + this.serveForcedMoves(); + return this; + } + + private moveHandicapStones(m: string, partial: boolean): boolean { + const n = parseInt(m, 10); + this.handicap = n; + this.results.push({ type: "declare", count: n }); + if (partial) { return false; } + this.phase = "alt-place"; + this.currplayer = 2; + this.lastmove = m; + return true; + } + + /** Opening stones for the Attacker (no captures are possible before the Defender has moved). */ + private placeSetupStones(cells: string[]): void { + const created: string[] = []; + for (const cell of cells) { + this.board.set(cell, 2); + this.results.push({ type: "place", where: cell, what: "setup" }); + created.push(signature(this.board, this.geo)); + } + this.interim = created.slice(0, -1); + this.novel = created.length > 0; + } + + private moveAltPlace(m: string, partial: boolean): boolean { + if (m === "attacker" || m.startsWith("attacker:")) { + this.results.push({ type: "claim", how: "attacker" }); + this.placeSetupStones(this.parseTokens(m.substring("attacker:".length))); + if (partial) { return false; } + this.defenderSeat = this.otherSeat(this.currplayer); + this.phase = "play"; + this.placed = 0; + this.currplayer = this.defenderSeat; + } else { + this.placeSetupStones([m]); + if (partial) { return false; } + this.currplayer = this.otherSeat(this.currplayer); + } + this.lastmove = m; + return true; + } + + private movePlay(m: string, partial: boolean): boolean { + const seat = this.currplayer; + const colour = this.colourOfSeat(seat)!; + const length = this.moveLength(this.placed); + const prefix: string[] = []; + let komiResult: APMoveResult | undefined; + let declared: number | undefined; + let raw = m.length === 0 ? [] : m.split(","); + if (this.declarationsPending()) { + // The reverse komi and the handicap lead Player 2's first move; the komi costs no placement. + const first = this.parseFirstMove(raw); + if (first.komi !== undefined) { + const n = parseInt(first.komi, 10); + this.komi = n; + komiResult = { type: "komi", value: n }; + prefix.push(`${n}p`); + } + if (first.handicap !== undefined) { + declared = parseInt(first.handicap, 10); + } + raw = first.placements; + } + const split = this.splitMarks(raw); + const marked = split.marked; + let tokens = split.placements; + let forced: number; + if (declared !== undefined) { + forced = Math.min(declared, length); + } else { + forced = this.forcedPasses(); + if (forced === length) { + // The passes that spell out a move served entirely by the handicap. + tokens = []; + } + } + const sim = this.newSim(length, 0); + if (declared !== undefined) { + sim.results.push({ type: "declare", count: declared }); + sim.placed++; + this.serveForced(sim, forced - 1); + } else { + this.serveForced(sim, forced); + } + for (const token of tokens) { + const err = this.applyToken(sim, token, colour); + if (err !== undefined) { + throw new UserFacingError("VALIDATION_GENERAL", this.tokenMessage(err, token)); + } + } + if (partial) { + this.commitSim(sim, seat, declared, forced); + if (komiResult !== undefined) { + this.results.unshift(komiResult); + } + // Show the marking the move would leave: its own when it passes any placement (judged + // on the board after its placements), none when it places every one. + const rest = Array(Math.max(0, length - forced - tokens.length)).fill("pass"); + if (declared === undefined && this.passesAny([...tokens, ...rest])) { + this.marks = this.locked ? [] : this.canonicalMarks(marked); + } else if (tokens.some((token) => token !== "pass")) { + this.marks = []; + } + return false; + } + const err = this.finishMove(sim, colour); + if (err !== undefined) { + throw new UserFacingError("VALIDATION_GENERAL", this.tokenMessage(err, "pass")); + } + const spelled = [...tokens, ...Array(sim.placed - forced - tokens.length).fill("pass")]; + this.commitSim(sim, seat, declared, forced); + if (komiResult !== undefined) { + this.results.unshift(komiResult); + } + this.interim = sim.created.slice(0, -1); + this.novel = sim.created.length > 0; + let agreed = false; + if (declared !== undefined && forced === length) { + // The declaration alone: every placement of the move given away as a handicap stone, + // which answers a marking like a move that passes nothing. + this.lastmove = `${declared}s`; + this.passes = []; + if (this.markingPending()) { + this.endMarking(); + } + this.clearMarks(); + } else if (forced === length) { + // Automatic passes neither count towards ending the game nor interrupt a run of + // passes, and leave the pending marking as it is. + this.lastmove = Array(length).fill("pass").join(","); + } else if (this.passesAny(spelled)) { + agreed = this.recordPass(seat, spelled, marked); + } else { + // A move that places every placement: it answers no marking, and ends marking for the + // rest of the game when one is pending. + this.lastmove = spelled.join(","); + this.passes = []; + if (this.markingPending()) { + this.endMarking(); + } + this.clearMarks(); + } + if (declared !== undefined && forced < length) { + this.lastmove = [`${declared}s`, this.lastmove].join(","); + } + if (prefix.length > 0) { + this.lastmove = [...prefix, this.lastmove].join(","); + } + this.currplayer = this.otherSeat(seat); + + if (sim.over) { + this.endGame([1, 2], "repetition"); + } else if (agreed) { + this.endByAgreement(); + } + return true; + } + + /** + * Record a move that passes at least one placement, and the marking it carries, judged on the + * board after its placements: a mark on a stone they captured drops out. When the opponent's + * last move was such a pass, this one answers its marking: a whole-move pass keeping it ends + * the game, which is what the result reports; a move that places a stone or changes the + * marking never does, and the players may change it back and forth for as long as their + * passes follow one another. A pass that marks nothing dead after a marking ends marking for + * the rest of the game; it still counts as a pass, so the opponent's next whole-move pass ends + * the game with every stone alive. + */ + private recordPass(seat: playerid, spelled: string[], marked: string[]): boolean { + const pure = this.passesWholeMove(spelled); + const marks = this.canonicalMarks(marked); + const pending = this.inheritedMarks(); + const inherited = pending === undefined ? undefined : this.canonicalMarks(pending); + const changed = inherited !== undefined && !this.sameMarks(marks, inherited); + if (changed || (inherited === undefined && marks.length > 0)) { + // Marks after a pass that carried none are a marking, not a change of one. + const how = inherited === undefined || inherited.length === 0 ? "marked" : marks.length > 0 ? "changed" : "cleared"; + this.results.push({ type: "select", what: "dead", how, ...(marks.length > 0 ? { where: marks.join(",") } : {}) }); + } + this.lastmove = [...spelled, ...this.markTokens(marks)].join(","); + if (this.markingPending() && marks.length === 0) { + this.endMarking(); + } + this.marks = marks; + this.passes = [...this.passes, seat]; + return pure && inherited !== undefined && !changed; + } + + /** Copy the outcome of a move in progress into the game. */ + private commitSim(sim: ISim, seat: playerid, declared: number | undefined, forced: number): void { + this.board = sim.board; + this.results = sim.results; + this.placed += sim.placed; + if (sim.buttonTaken) { + this.button = seat; + } + if (declared !== undefined) { + this.handicap = declared; + this.passesOwed = declared - forced; + } else { + this.passesOwed -= forced; + } + } + + /** Standard handicap: serve every following move of Player 2 that the handicap passes cover. */ + private serveForcedMoves(): void { + while (!this.gameover && this.phase === "play" && !this.killAll && this.currplayer === 2 && this.passesOwed > 0) { + const length = this.moveLength(this.placed); + if (this.passesOwed < length) { + return; + } + this.results = []; + for (let i = 0; i < length; i++) { + this.results.push({ type: "pass", why: "handicap" }); + } + this.interim = []; + this.novel = false; + this.alive = undefined; + this.passesOwed -= length; + this.placed += length; + this.lastmove = Array(length).fill("pass").join(","); + this.currplayer = 1; + this.saveState(); + } + } + + // ----------------------------------------------------------------------- + // Ends of games + // ----------------------------------------------------------------------- + + private endGame(winners: playerid[], reason: string, alive?: string[]): void { + this.gameover = true; + this.winner = [...winners]; + this.alive = alive; + this.results.push( + { type: "eog", reason }, + { type: "winners", players: [...winners] }, + ); + } + + /** + * The standard game ends when a player passes their whole move keeping the marking the + * opponent's whole-move pass left: the marked strings are removed and the board is scored. + */ + private endByAgreement(): void { + if (this.marks.length > 0) { + for (const cell of this.marks) { + this.board.delete(cell); + } + this.results.push({ type: "remove", where: this.marks.join(","), num: this.marks.length, how: "dead" }); + this.marks = []; + } + const scores = [this.getPlayerScore(1), this.getPlayerScore(2)]; + let winners: playerid[] = [1, 2]; + if (scores[0] !== scores[1]) { + winners = scores[0] > scores[1] ? [1] : [2]; + } + this.endGame(winners, "consecutive-passes"); + } + + /** + * Kill-All: the Defender wins once a Red string is pass-alive, and the Attacker wins once that + * can never happen, because the Attacker's own pass-alive stones leave no room for the eyes + * such a string needs. The two cannot coincide, as a pass-alive Red string has that room. + */ + private checkLifeAndDeath(): void { + if (this.gameover) { + return; + } + const minVital = this.minVital; + const alive = passAliveStrings(this.board, this.geo, 1, { suicideAllowed: true, minVital }); + if (alive.length > 0) { + this.endGame([this.defenderSeat!], "pass-alive", alive.flat()); + return; + } + const permanent = passAliveStrings(this.board, this.geo, 2, { suicideAllowed: true, minVital }).flat(); + if (!this.roomForEyes(new Set(permanent), minVital)) { + this.endGame([this.attackerSeat!], "no-room", permanent); + } + } + + /** Kill-All: passing is not allowed, so a player with no legal move loses. */ + private checkStalemate(): void { + if (this.gameover) { + return; + } + if (!this.hasLegalMove(this.currplayer)) { + this.endGame([this.otherSeat(this.currplayer)], "no-moves"); + } + } + + /** + * Whether the board still has room for the `eyes` vital regions a pass-alive Defender string + * needs, given the Attacker's pass-alive stones `permanent`. Those can never be captured, and + * a point next to one of them can never lie in a vital region of a pass-alive Defender string, + * so the regions lie among the points touching no permanent stone, in one component of the + * board minus those stones, and are pairwise non-adjacent. The test is sound but not complete: + * it never gives up on a Defender who could still live, but does not recognise every hopeless + * position. + */ + private roomForEyes(permanent: Set, eyes: number): boolean { + if (permanent.size === 0) { + return true; + } + const seen = new Set(); + for (const start of this.geo.cells) { + if (permanent.has(start) || seen.has(start)) { + continue; + } + const eyePoints: string[] = []; + const todo = [start]; + seen.add(start); + while (todo.length > 0) { + const cur = todo.pop()!; + const adj = this.geo.neighbours.get(cur)!; + if (!adj.some((n) => permanent.has(n))) { + eyePoints.push(cur); + } + for (const n of adj) { + if (!permanent.has(n) && !seen.has(n)) { + seen.add(n); + todo.push(n); + } + } + } + if (this.hasIndependentPoints(eyePoints, eyes)) { + return true; + } + } + return false; + } + + /** Whether `points` include `count` that are pairwise non-adjacent. */ + private hasIndependentPoints(points: string[], count: number): boolean { + // The board graph is planar, so (four colours) any n points include n/4 non-adjacent ones. + if (points.length >= 4 * count) { + return true; + } + const adjacent = (a: string, b: string): boolean => this.geo.neighbours.get(a)!.includes(b); + const search = (from: number, chosen: string[]): boolean => { + if (chosen.length === count) { + return true; + } + for (let i = from; i < points.length; i++) { + if (chosen.some((c) => adjacent(c, points[i]))) { + continue; + } + if (search(i + 1, [...chosen, points[i]])) { + return true; + } + } + return false; + }; + return search(0, []); + } + + // ----------------------------------------------------------------------- + // Scores + // ----------------------------------------------------------------------- + + /** The empty regions of `board`, each with the colour it reaches when that is a single one. */ + private regions(board: Board): IRegion[] { + const regions: IRegion[] = []; + const seen = new Set(); + for (const cell of this.geo.cells) { + if (board.has(cell) || seen.has(cell)) { + continue; + } + const cells: string[] = []; + const reached = new Set(); + const todo = [cell]; + seen.add(cell); + while (todo.length > 0) { + const cur = todo.pop()!; + cells.push(cur); + for (const n of this.geo.neighbours.get(cur)!) { + const occupant = board.get(n); + if (occupant === undefined) { + if (!seen.has(n)) { + seen.add(n); + todo.push(n); + } + } else { + reached.add(occupant); + } + } + } + const region: IRegion = { cells }; + if (reached.size === 1) { + region.owner = [...reached][0]; + } + regions.push(region); + } + return regions; + } + + /** Tromp-Taylor area on `board`: stones of `colour` plus the empty points reaching only them. */ + private area(colour: Stone, board: Board): number { + let score = 0; + for (const stone of board.values()) { + if (stone === colour) { + score++; + } + } + for (const region of this.regions(board)) { + if (region.owner === colour) { + score += region.cells.length; + } + } + return score; + } + + /** Tromp-Taylor area, with the strings marked dead removed, plus the button, plus Player 1's reverse komi. */ + public getPlayerScore(player: playerid): number { + const colour = this.colourOfSeat(player); + if (colour === undefined) { + return 0; + } + const komi = player === 1 ? this.komi ?? 0 : 0; + return this.area(colour, this.effectiveBoard()) + (this.button === player ? this.buttonValue : 0) + komi; + } + + public sidebarScores(): IScores[] { + if (this.killAll) { + return []; + } + return [{ + name: this.neutralAreaLabel("apgames:status.SCORES"), + scores: [this.getPlayerScore(1), this.getPlayerScore(2)], + }]; + } + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + public state(): IThueMorseGoState { + return { + game: ThueMorseGoGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + const state: IMoveState = { + _version: ThueMorseGoGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + phase: this.phase, + placed: this.placed, + passes: [...this.passes], + interim: [...this.interim], + novel: this.novel, + marks: [...this.marks], + locked: this.locked, + }; + if (this.button !== undefined) { state.button = this.button; } + if (this.handicap !== undefined) { state.handicap = this.handicap; } + if (this.passesOwed > 0) { state.passesOwed = this.passesOwed; } + if (this.komi !== undefined) { state.komi = this.komi; } + if (this.defenderSeat !== undefined) { state.defenderSeat = this.defenderSeat; } + if (this.alive !== undefined) { state.alive = [...this.alive]; } + return state; + } + + // ----------------------------------------------------------------------- + // Rendering + // ----------------------------------------------------------------------- + + /** + * The numbered stones of the current picker, added to `legend` and returned as a cell map, + * along with the cell of the value chosen so far. The stones are Attacker stones in Kill-All + * games and neutral when they stand for passes. + */ + private pickerOverlay(legend: ILegend, paletteBorders: boolean): { overlay: Map; chosen: string[] } { + const overlay = new Map(); + const picker = this.picker(); + if (picker === undefined) { + return { overlay, chosen: [] }; + } + const paint = this.killAll ? this.stonePaint(2, paletteBorders) : { fill: UNDECIDED_COLOUR }; + for (const btn of picker.buttons) { + if (this.board.has(btn.cell)) { + // The stone shows; the point still answers to a click with this value. + continue; + } + const key = `n${btn.value}`; + legend[key] = [{ name: "piece", paint }, { text: btn.value.toString(), scale: 0.75, rotate: null }]; + overlay.set(btn.cell, key); + } + return { overlay, chosen: [...picker.chosen] }; + } + + /** + * The star points of the board: those the renderer draws on a square board of this size, except + * that the 11x11 board takes the 4-4 points and no centre. + */ + private starPoints(): RowCol[] { + const size = this.boardSize; + const d = size === 11 || size > 12 ? 4 : 3; + const lo = d - 1; + const hi = size - d; + const points: RowCol[] = [{ row: lo, col: lo }, { row: lo, col: hi }, { row: hi, col: hi }, { row: hi, col: lo }]; + if (size % 2 === 1 && size !== 11) { + const mid = Math.floor(size / 2); + points.push({ row: mid, col: mid }); + if (size >= 15) { + points.push({ row: lo, col: mid }, { row: mid, col: hi }, { row: hi, col: mid }, { row: mid, col: lo }); + } + } + return points; + } + + /** + * Kill-All: the pass-alive stones of both sides, drawn faded in the position that ended the + * game, or in every position under the display option, as in Kill-All Go. The final position + * is known by its own `eog` result, since `gameover` holds for every position of a finished + * game once loaded, and the stones fade at any end, a stalemate or a repetition included. + */ + private passAliveOverlay(legend: ILegend, throughout: boolean, paletteBorders: boolean): Map { + const alive = new Map(); + if (!this.killAll || !(throughout || this.results.some((r) => r.type === "eog"))) { + return alive; + } + for (const colour of [1, 2] as const) { + const cells = passAliveStrings(this.board, this.geo, colour, { suicideAllowed: true, minVital: this.minVital }).flat(); + if (cells.length > 0) { + const key = `L${colour}`; + legend[key] = [{ name: "piece", paint: this.stonePaint(colour, paletteBorders), opacity: ALIVE_OPACITY }]; + for (const cell of cells) { + alive.set(cell, key); + } + } + } + return alive; + } + + /** + * The territory the board scores as it stands, with the strings marked dead removed: a dot on + * every empty point that reaches only one colour, and the marked stones faded, each with a dot + * of the colour the point under it counts for, so a lone first stone claims the whole board. + * Shown in the standard game unless the display hides it; the marked stones are faded regardless. + * Returns the legend keys of the marked stones by cell and the dots for the empty points. + */ + private territoryOverlay(legend: ILegend, hide: boolean, paletteBorders: boolean): { dead: Map; dots: MarkerDots[] } { + const dead = new Map(); + const dots: MarkerDots[] = []; + if (this.killAll) { + return { dead, dots }; + } + const owner = new Map(); + if (!hide) { + for (const region of this.regions(this.effectiveBoard())) { + if (region.owner !== undefined) { + for (const cell of region.cells) { + owner.set(cell, region.owner); + } + } + } + } + // Colours are seats in the standard game, so they name their palette slots directly. + for (const cell of this.marks) { + const stone = this.board.get(cell); + if (stone === undefined) { + continue; + } + const under = owner.get(cell); + const key = under === undefined ? `D${stone}` : `D${stone}T${under}`; + if (!(key in legend)) { + const faded: Glyph = { name: "piece", paint: this.stonePaint(stone, paletteBorders), opacity: DEAD_OPACITY }; + legend[key] = under === undefined ? [faded] : [faded, { name: "piece-borderless", paint: { fill: under }, scale: DOT_SIZE }]; + } + dead.set(cell, key); + } + const points: [RowCol[], RowCol[]] = [[], []]; + for (const [cell, colour] of owner) { + if (!this.board.has(cell)) { + const [x, y] = this.algebraic2coords(cell); + points[colour - 1].push({ row: y, col: x }); + } + } + for (const colour of [1, 2] as const) { + const pts = points[colour - 1]; + if (pts.length > 0) { + dots.push({ type: "dots", colour, size: DOT_SIZE, points: pts as [RowCol, ...RowCol[]] }); + } + } + return { dead, dots }; + } + + /** + * The Thue-Morse tracker, a column of 16 cells per hexadecimal digit of the index of the next + * placement, least significant on the right, with the rows running 0 to f up (or down) each + * column and a marker on each digit's current value. A column shows, for each value of its + * digit, the colour of the placement with that value there, the current values above it and + * zeros below: the rightmost column is the colours of the placements still to come before the + * next carry, and every other column is the colour of the 0 row of the column to its right. + * The rolling display shows one column of the next 16 placements instead, marking those of the + * next move. The tint behind the columns is the colour of the next placement. Blank columns + * separate the columns and keep the last one clear of the board's row labels. A repeating + * protocol needs no tracker. The digit tracker has three columns; once the index needs more, + * it shows only grey stones, with no markers, tint or border, rather than digits it cannot show. + */ + private trackArea(legend: ILegend, downward: boolean, rolling: boolean, paletteBorders: boolean): AreaTrack | undefined { + if (this.period > 0) { + return undefined; + } + const next = this.placed; + const overflow = !rolling && next > MAX_TRACK_INDEX; + const nextFill = this.fillOfColour(this.colourAt(next)); + const tint: Glyph = { name: "piece-square", paint: { fill: overflow ? UNDECIDED_COLOUR : nextFill }, opacity: TRACK_TINT }; + // Keys that differ only in case would be confused by a page in quirks mode, where id lookups + // ignore case, so the small glyphs are s1/s2, the plain markers m1/m2, and the digit + // markers m1d0 to m2df, by their digit. + const small = (colour: Stone): string => { + const key = `s${colour}`; + if (!(key in legend)) { + legend[key] = [tint, { name: "piece", paint: this.stonePaint(colour, paletteBorders), scale: SMALL_SCALE }]; + } + return key; + }; + // A full-size stone; the TMS tracker writes the digit it stands for on it. + const marker = (colour: Stone, digit?: number): string => { + const key = digit === undefined ? `m${colour}` : `m${colour}d${digit.toString(16)}`; + if (!(key in legend)) { + const stone: Glyph = { name: "piece", paint: this.stonePaint(colour, paletteBorders) }; + legend[key] = digit === undefined + ? [tint, stone] + : [tint, stone, { text: digit.toString(16), scale: DIGIT_SCALE, rotate: null }]; + } + return key; + }; + + // Each column top to bottom, as legend keys. + const columns: string[][] = []; + if (rolling) { + const length = this.moveLength(next); + const column: string[] = []; + for (let row = 0; row < TRACK_ROWS; row++) { + const i = downward ? row : TRACK_ROWS - 1 - row; + const colour = this.colourAt(next + i); + column.push(i < length ? marker(colour) : small(colour)); + } + columns.push(column); + } else if (overflow) { + const key = "s0"; + legend[key] = [tint, { name: "piece", paint: { fill: UNDECIDED_COLOUR }, scale: SMALL_SCALE }]; + for (let d = 0; d < TRACK_DIGITS; d++) { + columns.push(Array(TRACK_ROWS).fill(key)); + } + } else { + const digits = TRACK_DIGITS; + for (let d = 0; d < digits; d++) { + const unit = Math.pow(16, digits - 1 - d); + const above = Math.floor(next / (unit * 16)); + const current = Math.floor(next / unit) % 16; + const column: string[] = []; + for (let row = 0; row < TRACK_ROWS; row++) { + const digit = downward ? row : TRACK_ROWS - 1 - row; + const colour = this.colourAt((above * 16 + digit) * unit); + column.push(digit === current ? marker(colour, digit) : small(colour)); + } + columns.push(column); + } + } + + // A blank frame surrounds the columns, with a blank column between digits. The frame makes + // room for the border and keeps the tracker clear of the board's row labels. + const width = 2 * columns.length + 1; + const height = TRACK_ROWS + 2; + const blocked: RowCol[] = []; + const rows: string[] = []; + for (let row = 0; row < height; row++) { + const cells: string[] = []; + for (let col = 0; col < width; col++) { + if (row >= 1 && row <= TRACK_ROWS && col % 2 === 1) { + cells.push(columns[(col - 1) / 2][row - 1]); + } else { + cells.push("-"); + blocked.push({ row, col }); + } + } + // Multi-character keys need the comma-delimited form. + rows.push(cells.join(",")); + } + // A thick border around the columns in the colour of the next placement, unless the tracker + // has gone grey; the line points of a squares board are cell corners. + const corners: RowCol[] = [ + { row: 1, col: 1 }, + { row: 1, col: width - 1 }, + { row: height - 1, col: width - 1 }, + { row: height - 1, col: 1 }, + ]; + const border: MarkerLine[] = corners.map((from, i) => ({ + type: "line", + points: [from, corners[(i + 1) % corners.length]], + colour: nextFill, + width: TRACK_BORDER_WIDTH, + })); + return { + type: "track", + position: "left", + board: { + style: "squares", + width, + height, + blocked: blocked as [RowCol, ...RowCol[]], + ...(overflow ? {} : { markers: border }), + }, + pieces: rows.join("\n"), + }; + } + + /** + * The results the board annotates: the loaded state's own, unless that state is a move served + * automatically by the handicap, which changed nothing on the board; the last placements are + * then those of the move before it. + */ + private annotationResults(): APMoveResult[] { + if (this.results.length === 0 || !this.results.every((r) => r.type === "pass" && r.why === "handicap")) { + return this.results; + } + const idx = this.stack.findIndex((state) => state.placed === this.placed && state.currplayer === this.currplayer); + return idx > 0 ? this.stack[idx - 1]._results : this.results; + } + + public render(opts?: IRenderOpts): APRenderRep { + const downward = this.hasDisplay(opts, "digits-down"); + const rolling = !this.hasDisplay(opts, "tms-tracker"); + const hideDots = this.hasDisplay(opts, "hide-territory"); + const paletteBorders = this.hasDisplay(opts, "palette-borders"); + const legend: ILegend = { + A: [{ name: "piece", paint: this.stonePaint(1, paletteBorders) }], + B: [{ name: "piece", paint: this.stonePaint(2, paletteBorders) }], + }; + const { overlay, chosen } = this.pickerOverlay(legend, paletteBorders); + const { dead, dots } = this.territoryOverlay(legend, hideDots, paletteBorders); + const alive = this.passAliveOverlay(legend, this.hasDisplay(opts, "pass-alive-throughout"), paletteBorders); + + const rows: string[] = []; + for (let row = 0; row < this.boardSize; row++) { + const cells: string[] = []; + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + const contents = this.board.get(cell); + cells.push(overlay.get(cell) ?? dead.get(cell) ?? alive.get(cell) ?? (contents === 1 ? "A" : contents === 2 ? "B" : "-")); + } + if (cells.every((c) => c === "-")) { + rows.push("_"); + } else { + // Multi-character keys need the comma-delimited form. + rows.push(overlay.size > 0 || dead.size > 0 || alive.size > 0 ? cells.join(",") : cells.join("")); + } + } + + const rep: APRenderRep = { + board: { + style: this.variants.includes("snub") ? "snubsquare" : "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend, + pieces: rows.join("\n"), + }; + // The renderer's own star points serve every square board but the 11x11, which takes the + // 4-4 points without a centre; snub square boards get those of the square board of their size. + const snub = this.variants.includes("snub"); + const markers: MarkerDots[] = []; + if (snub || this.boardSize === 11) { + markers.push({ type: "dots", size: STAR_SIZE, points: this.starPoints() as [RowCol, ...RowCol[]] }); + } + markers.push(...dots); + if (markers.length > 0) { + (rep.board as BoardBasic).markers = markers; + } + if (!snub && this.boardSize === 11) { + rep.options = ["hide-star-points"]; + } + const track = this.trackArea(legend, downward, rolling, paletteBorders); + if (track !== undefined) { + rep.areas = [track]; + } + + const toRowCol = (cell: string): RowCol => { + const [x, y] = this.algebraic2coords(cell); + return { row: y, col: x }; + }; + const annotations: NonNullable = []; + for (const r of this.annotationResults()) { + if (r.type === "place") { + annotations.push({ type: "enter", targets: [toRowCol(r.where!)] }); + } else if (r.type === "capture" || r.type === "remove") { + const targets = r.where!.split(",").map(toRowCol); + annotations.push({ type: "exit", targets: targets as [RowCol, ...RowCol[]] }); + } + } + for (const cell of chosen) { + annotations.push({ type: "enter", targets: [toRowCol(cell)] }); + } + if (annotations.length > 0) { + rep.annotations = annotations; + } + return rep; + } + + public sidebarStatuses(): IStatus[] { + const statuses: IStatus[] = []; + // Every placement of the next move, so that a two-placement move shows both indices. + const placements = Array.from({ length: this.moveLength(this.placed) }, (_, i) => (this.placed + i).toString(16)); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.NEXT_PLACEMENT"), + value: [placements.join(", ")], + }); + if (this.killAll) { + const undecided = this.neutralAreaLabel("apgames:status.thuemorsego.UNDECIDED"); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.DEFENDER"), + value: [this.defenderSeat === undefined ? undecided : this.seatStatusValue(this.defenderSeat)], + }); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.ATTACKER"), + value: [this.attackerSeat === undefined ? undecided : this.seatStatusValue(this.attackerSeat)], + }); + if (this.handicap !== undefined) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.HANDICAP"), + value: [this.handicap.toString()], + }); + } + if (this.phase !== "play" && !this.gameover) { + const phaseKey = this.phase === "hand-n" ? "apgames:status.thuemorsego.PHASE_HAND_N" : "apgames:status.thuemorsego.PHASE_ALT_PLACE"; + statuses.push({ + key: this.neutralAreaLabel("apgames:status.PHASE"), + value: [this.seatAreaLabel(this.currplayer, phaseKey)], + }); + } + return statuses; + } + if (this.buttonValue > 0) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.BUTTON"), + value: [this.button === undefined ? this.neutralAreaLabel("apgames:status.thuemorsego.BUTTON_AVAILABLE") : this.seatStatusValue(this.button)], + }); + } + if (this.reverseKomi) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.KOMI"), + value: [this.komi === undefined ? this.neutralAreaLabel("apgames:status.thuemorsego.UNDECIDED") : this.komi.toString()], + }); + } + if (this.komiPending()) { + const key = this.declaring() && this.handicap === undefined + ? "apgames:status.thuemorsego.PHASE_KOMI_DECLARE" + : "apgames:status.thuemorsego.PHASE_KOMI"; + statuses.push({ + key: this.neutralAreaLabel("apgames:status.PHASE"), + value: [this.seatAreaLabel(this.currplayer, key)], + }); + } + if (!this.gameover && (this.marks.length > 0 || this.locked)) { + const value = []; + if (this.marks.length > 0) { + value.push(this.neutralAreaLabel("apgames:status.thuemorsego.MARKING_COUNT", { count: this.marks.length })); + } + if (this.locked) { + value.push(this.neutralAreaLabel("apgames:status.thuemorsego.MARKING_LOCKED")); + } + statuses.push({ key: this.neutralAreaLabel("apgames:status.thuemorsego.MARKING"), value }); + } + if (this.handicapVariant && !this.gameover && !this.komiPending()) { + if (this.declaring() && this.handicap === undefined) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.PHASE"), + value: [this.seatAreaLabel(this.currplayer, "apgames:status.thuemorsego.PHASE_DECLARE")], + }); + } else if (this.passesOwed > 0) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.thuemorsego.PASSES_OWED"), + value: [this.passesOwed.toString()], + }); + } + } + 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:thuemorsego.PLACE.setup", { where: r.where! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:_general.PLACE.nowhat", { where: r.where! }); + } + return true; + case "capture": + if (r.how === "suicide") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:thuemorsego.CAPTURE.suicide", { count: r.count! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:_general.CAPTURE.noperson.group_nowhere", { count: r.count! }); + } + return true; + case "pass": + if (r.why === "handicap") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:thuemorsego.PASS.handicap"); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:_general.PASS.simple"); + } + return true; + case "declare": + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:thuemorsego.DECLARE.stones", { count: r.count! }); + return true; + case "claim": + if (r.how === "attacker") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:thuemorsego.CLAIM.attacker"); + return true; + } + return super.collectChatLogLine(lines, r, ctx); + case "select": + if (r.what === "dead") { + if (r.how === "locked") { + this.pushNeutralChatLine(lines, "apresults:thuemorsego.SELECT.locked"); + return true; + } + const count = r.where === undefined ? 0 : r.where.split(",").length; + const key = r.how === "marked" + ? "apresults:thuemorsego.SELECT.marked" + : r.how === "changed" ? "apresults:thuemorsego.SELECT.changed" : "apresults:thuemorsego.SELECT.cleared"; + this.pushSeatChatLine(lines, ctx.defaultSeat, key, { count }); + return true; + } + return super.collectChatLogLine(lines, r, ctx); + case "remove": + if (r.how === "dead") { + this.pushNeutralChatLine(lines, "apresults:thuemorsego.REMOVE.dead", { count: r.num! }); + return true; + } + return super.collectChatLogLine(lines, r, ctx); + case "komi": + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:thuemorsego.DECLARE.komi", { count: r.value }); + return true; + case "eog": + switch (r.reason) { + case "consecutive-passes": + this.pushNeutralChatLine(lines, "apresults:_general.EOG.consecutive_passes"); + break; + case "repetition": + this.pushNeutralChatLine(lines, "apresults:thuemorsego.EOG.repetition"); + break; + case "pass-alive": + this.pushNeutralChatLine(lines, "apresults:thuemorsego.EOG.pass_alive"); + break; + case "no-room": + this.pushNeutralChatLine(lines, "apresults:thuemorsego.EOG.no_room"); + break; + case "no-moves": + this.pushNeutralChatLine(lines, "apresults:thuemorsego.EOG.no_moves"); + break; + default: + this.pushNeutralChatLine(lines, "apresults:_general.EOG.default"); + } + return true; + default: + return super.collectChatLogLine(lines, r, ctx); + } + } + + public clone(): ThueMorseGoGame { + return new ThueMorseGoGame(this.serialize()); + } +} diff --git a/test/games/killallgo.test.ts b/test/games/killallgo.test.ts index abf9c856..d653185d 100644 --- a/test/games/killallgo.test.ts +++ b/test/games/killallgo.test.ts @@ -536,6 +536,7 @@ describe("Kill-All Go", () => { expect(g.setup?.handicap).to.equal(3); expect(g.phase).to.equal("alt-place"); expect(g.currplayer).to.equal(2); + expect(g.chatLog(["Alice", "Bob"]).flat().join("\n")).to.include("Alice set the handicap to 3 stones."); }); it("makes Player 2 place the handicap stones when they take the Attacker side", () => { @@ -1126,6 +1127,16 @@ describe("Kill-All Go", () => { expect(text).to.include("Alice placed an Attacker stone at c1."); }); + it("reports a multi-stone suicide with the mover and the count", () => { + // The Defender's a1 leaves a1 and b1 without liberties; both are removed. + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), ["a2,b2,c1", "attacker", "b1", "i9", "a1"]); + expect(g.board.has("a1")).to.be.false; + expect(g.board.has("b1")).to.be.false; + const keys = g.chatLogEntries(["Alice", "Bob"]).flatMap((e) => e.lines.map((l) => l.textKey)); + expect(keys).to.include("apresults:killallgo.CAPTURE.suicide"); + expect(g.chatLog(["Alice", "Bob"]).flat().join("\n")).to.include("Alice removed 2 of their own stones by suicide."); + }); + it("still displays the result of an older game ended by consecutive passes", () => { const g = play(attackerIsPlayerOne(), ["e5", "a9"]); const state = g.state(); diff --git a/test/games/thuemorsego.test.ts b/test/games/thuemorsego.test.ts new file mode 100644 index 00000000..5f193b7d --- /dev/null +++ b/test/games/thuemorsego.test.ts @@ -0,0 +1,1592 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import "mocha"; +import { expect } from "chai"; +import type { AreaTrack, BoardBasic, Glyph, MarkerDots, MarkerLine } from "@abstractplay/renderer/build/schemas/schema"; +import { addResource } from "../../src"; +import { SnubSquareGraph } from "../../src/common"; +import { ThueMorseGoGame } from "../../src/games/thuemorsego"; +import type { IClickResult } from "../../src/games/_base"; +import { assertChatLogParity } from "../fixtures/chat/helpers"; + +before(() => { addResource("en"); }); + +const play = (g: ThueMorseGoGame, moves: string[]): ThueMorseGoGame => { + for (const m of moves) { + g.move(m); + } + return g; +}; + +/** A fresh game on the 11x11 board. */ +const small = (variants: string[] = []): ThueMorseGoGame => new ThueMorseGoGame(undefined, ["size-11", ...variants]); + +/** A fresh game on the default 16x16 board, whose handicap limit is 6. */ +const sixteen = (variants: string[] = []): ThueMorseGoGame => new ThueMorseGoGame(undefined, variants); +/** The fill of a Kill-All role: its slot (3 Attacker, 4 Defender) when customized for this game, else the first or second colour. */ +const role = (slot: 3 | 4) => ({ func: "custom", default: slot === 3 ? 1 : 2, palette: slot }); +/** The paint of a Kill-All role's stones: like `stonePaint`, with the role's fill. */ +const rolePaint = (slot: 3 | 4, fromPalette = false) => ({ + fill: role(slot), + border: fromPalette ? slot + 4 : { func: "bestContrast", bg: role(slot), fg: ["#000", "#fff"] }, +}); +/** The paint of a stone in palette slot `slot`: its border is black or white by contrast, or slot `slot + 4` under the display option. */ +const stonePaint = (slot: number, fromPalette = false) => ({ + fill: slot, + border: fromPalette ? slot + 4 : { func: "bestContrast", bg: slot, fg: ["#000", "#fff"] }, +}); + +/** Put stones on the board, and into the initial and current positions, without playing them. */ +const setStones = (g: ThueMorseGoGame, colour1: string[], colour2: string[]): void => { + const board = new Map(); + for (const cell of colour1) { board.set(cell, 1); } + for (const cell of colour2) { board.set(cell, 2); } + g.stack[0].board = new Map(board); + g.stack[g.stack.length - 1].board = new Map(board); + g.board = new Map(board); +}; + +/** The cells whose stones the rendered board draws translucent, sorted: pass-alive stones in Kill-All games. */ +const faded = (g: ThueMorseGoGame, opts?: { altDisplays?: string[] }): string[] => { + const rep = g.render(opts); + const keys = new Set(Object.entries(rep.legend ?? {}) + .filter(([, entry]) => [entry].flat().some((glyph) => ((glyph as { opacity?: number }).opacity ?? 1) < 1)) + .map(([key]) => key)); + const cells: string[] = []; + (rep.pieces as string).split("\n").forEach((row, y) => { + if (row !== "_") { + (row.includes(",") ? row.split(",") : row.split("")).forEach((key, x) => { + if (keys.has(key)) { + cells.push(g.coords2algebraic(x, y)); + } + }); + } + }); + return cells.sort(); +}; +const THROUGHOUT = { altDisplays: ["pass-alive-throughout"] }; + +/** The click on `cell` with `move` in progress. */ +const click = (g: ThueMorseGoGame, move: string, cell: string): IClickResult => { + const [x, y] = g.algebraic2coords(cell); + return g.handleClick(move, y, x); +}; + +/** The layers of a tracker legend entry. */ +const layers = (g: ThueMorseGoGame, key: string, opts?: { altDisplays?: string[] }): Glyph[] => { + const legend = g.render(opts).legend as Record; + return legend[key]; +}; + +/** A corner string with the two single-point eyes a1 and c1, and the wall that leaves it no other liberty. */ +const twoEyedCorner = (): { own: string[]; wall: string[] } => ({ + own: ["a2", "b1", "b2", "c2", "d1", "d2"], + wall: ["a3", "b3", "c3", "d3", "e1", "e2", "e3"], +}); + +// The tracker of the mockup: placement 0x123 is next, digits running upward. The mockup's keys +// e/o (small) and E/O (markers) are s1/s2 and m1d*/m2d* here, the markers carrying their digit; +// a blank column separates the digit columns and a blank frame surrounds them. +const TRACK_ROWS_0X123 = [ + "e-o-e", "o-e-o", "o-e-o", "e-o-e", "o-e-o", "e-o-e", "e-o-e", "o-e-o", + "o-e-o", "e-o-e", "e-o-e", "o-e-o", "e-o-E", "o-E-o", "O-e-o", "e-o-e", +]; +const BLANK_TRACK_ROW = Array(7).fill("-").join(","); +const TRACK_0X123 = [ + BLANK_TRACK_ROW, + ...TRACK_ROWS_0X123.map((row, r) => { + const digit = (15 - r).toString(16); + const keys = row.split("").map((c) => ({ e: "s1", o: "s2", E: `m1d${digit}`, O: `m2d${digit}` }[c] ?? c)); + return ["-", ...keys, "-"].join(","); + }), + BLANK_TRACK_ROW, +].join("\n"); + +describe("Thue-Morse Go: the sequence", () => { + it("names the colour of each placement by the parity of its 1 bits", () => { + const g = small(); + const digits = []; + for (let n = 0; n < 16; n++) { + digits.push(g.digitAt(n)); + } + expect(digits).to.deep.equal([0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0]); + }); + + it("makes every move one or two placements", () => { + const g = small(); + const starts = [0, 1, 3, 4, 5, 7, 9, 11, 12, 13, 15, 16, 17]; + expect(starts.map((n) => g.moveLength(n))).to.deep.equal([1, 2, 1, 1, 2, 2, 2, 1, 1, 2, 1, 1, 2]); + for (let n = 0; n < 4096; n++) { + expect(g.moveLength(n)).to.be.at.most(2); + } + }); + + it("repeats ABBA under Balanced Marseillais", () => { + const g = small(["marseillais"]); + const digits = []; + for (let n = 0; n < 8; n++) { + digits.push(g.digitAt(n)); + } + expect(digits).to.deep.equal([0, 1, 1, 0, 0, 1, 1, 0]); + expect([0, 1, 3, 5, 7].map((n) => g.moveLength(n))).to.deep.equal([1, 2, 2, 2, 2]); + }); + + it("alternates the seats move by move", () => { + const g = play(small(), ["f6", "e5,g7", "f5", "d4", "c3,c4", "h8,h9"]); + expect(g.stack.map((s) => s.currplayer)).to.deep.equal([1, 2, 1, 2, 1, 2, 1]); + expect(g.placed).to.equal(9); + expect(g.stack.map((s) => s.placed)).to.deep.equal([0, 1, 3, 4, 5, 7, 9]); + }); +}); + +describe("Thue-Morse Go: moves", () => { + it("takes one placement, then two", () => { + const g = small(); + expect(g.validateMove("f6").complete).to.equal(1); + g.move("f6"); + expect(g.currplayer).to.equal(2); + expect(g.placed).to.equal(1); + expect(g.validateMove("e5").complete).to.equal(0); + expect(g.validateMove("e5,g7").complete).to.equal(1); + expect(g.validateMove("e5,g7,h8").valid).to.be.false; + expect(g.validateMove("e5,e5").valid).to.be.false; + expect(g.validateMove("e5,f6").valid).to.be.false; + g.move("e5,g7"); + expect(g.board.get("e5")).to.equal(2); + expect(g.board.get("g7")).to.equal(2); + expect(g.placed).to.equal(3); + expect(g.currplayer).to.equal(1); + expect(g.lastmove).to.equal("e5,g7"); + }); + + it("passes the placements a short move leaves out", () => { + const g = play(small(), ["f6", "e5"]); + expect(g.lastmove).to.equal("e5,pass"); + expect(g.results.map((r) => r.type)).to.deep.equal(["place", "pass"]); + expect(g.placed).to.equal(3); + expect(g.currplayer).to.equal(1); + const h = play(small(), ["f6", "pass"]); + expect(h.lastmove).to.equal("pass,pass"); + expect(h.gameover).to.be.false; + }); + + it("builds a move from clicks", () => { + const g = play(small(), ["f6"]); + const [ex, ey] = g.algebraic2coords("e5"); + const [gx, gy] = g.algebraic2coords("g7"); + let click = g.handleClick("", ey, ex); + expect(click.move).to.equal("e5"); + expect(click.complete).to.equal(0); + click = g.handleClick("e5", gy, gx); + expect(click.move).to.equal("e5,g7"); + expect(click.complete).to.equal(1); + // Clicking the last placement again takes it back; an occupied cell is refused, even + // after one placement of two, since marking a string dead takes an explicit pass first. + expect(g.handleClick("e5,g7", gy, gx).move).to.equal("e5"); + const [fx, fy] = g.algebraic2coords("f6"); + const refused = g.handleClick("e5", fy, fx); + expect(refused.valid).to.be.false; + expect(refused.move).to.equal("e5"); + expect(refused.message).to.contain("press Pass first"); + expect(g.handleClick("", fy, fx).valid).to.be.false; + }); + + it("takes a placement back on a second click only while its stone is still on the board", () => { + // Player 2's two placements: a1 has no liberties and captures nothing, so it is removed at once. + const g = play(small(), ["k11"]); + setStones(g, ["a2", "b1", "k11"], []); + expect(click(g, "", "a1").move).to.equal("a1"); + expect(click(g, "a1", "a1").move).to.equal("a1,a1"); + expect(click(g, "a1,a1", "a1").move).to.equal("a1,a1"); + // A stone that stays is taken back, as is one placed on a point its own capture emptied. + expect(click(g, "k10", "k10").move).to.equal(""); + const h = play(small(), ["k11"]); + setStones(h, ["a1", "k11"], ["a2"]); + expect(click(h, "b1", "a1").move).to.equal("b1,a1"); + expect(click(h, "b1,a1", "a1").move).to.equal("b1"); + // With checked weak eyes the stone stays until the move ends, so the second click takes it back. + const w = play(small(["weak-eyes"]), ["k11"]); + setStones(w, ["a2", "b1", "k11"], []); + expect(click(w, "a1", "a1").move).to.equal(""); + }); + + it("ignores clicks on the tracker's glyphs", () => { + const g = play(small(), ["f6"]); + for (const [piece, move] of [["s1", ""], ["s2", "e5"], ["m2", "e5"], ["m1", ""], ["m2d1", "e5"], ["m1da", "pass,pass"]]) { + const result = g.handleClick(move, 3, 1, piece); + expect(result.valid).to.be.true; + expect(result.move).to.equal(move); + expect(result.complete).to.equal(g.validateMove(move).complete); + } + // A click on the board itself still places a stone there. + const [x, y] = g.algebraic2coords("b8"); + expect(g.handleClick("", y, x, "").move).to.equal("b8"); + }); + + it("treats button clicks as whole-move passes or the button's move", () => { + const g = play(small(), ["f6"]); + // Passing replaces a placement already made in the move. + const pass = g.handleClick("e5", -1, -1, "_btn_pass"); + expect(pass.move).to.equal("pass,pass"); + // A whole-move pass stays open so that strings can be marked dead. + expect(pass.complete).to.equal(0); + expect(small().handleClick("", -1, -1, "_btn_pass").move).to.equal("pass"); + const button = g.handleClick("e5", -1, -1, "_btn_button"); + expect(button.move).to.equal("button"); + expect(button.complete).to.equal(0); + const unknown = g.handleClick("e5", -1, -1, "stash"); + expect(unknown.valid).to.be.false; + expect(unknown.move).to.equal("e5"); + // Kill-All games cannot pass, so the click is refused and the move kept. + const k = play(small(["kill-all"]), ["f6", "attacker"]); + const refused = k.handleClick("e5", -1, -1, "_btn_pass"); + expect(refused.valid).to.be.false; + expect(refused.move).to.equal("e5"); + }); + + it("offers a button that passes the whole move, and one that takes the button while it lasts", () => { + const g = small(); + expect(g.getButtons().map((b) => b.move)).to.deep.equal(["pass", "button"]); + g.move("button"); + expect(g.getButtons().map((b) => b.move)).to.deep.equal(["pass,pass"]); + expect(g.validateMove("pass,pass").complete).to.equal(0); + // The 16x16 board has an even number of points, so no button. + expect(sixteen().getButtons().map((b) => b.move)).to.deep.equal(["pass"]); + // With one placement given away by the handicap, only the other is passed. + const h = play(new ThueMorseGoGame(undefined, ["size-19", "handicap"]), ["h8", "4", "g7", "f6,f7"]); + expect(h.getButtons().map((b) => b.move)).to.deep.equal(["pass", "button"]); + }); + + it("evens the count with the button: one point on boards with an odd number of points, none otherwise", () => { + const offered = (variants: string[]): boolean => new ThueMorseGoGame(undefined, variants).getButtons().some((b) => b.move === "button"); + expect(offered(["size-11"])).to.be.true; + expect(offered([])).to.be.false; + expect(offered(["size-19"])).to.be.true; + expect(offered(["size-23"])).to.be.true; + expect(offered(["snub"])).to.be.false; + expect(offered(["size-11", "snub"])).to.be.true; + expect(offered(["size-11", "half-button"])).to.be.true; + expect(offered(["half-button"])).to.be.true; + expect(sixteen(["half-button"]).move("button").getPlayerScore(1)).to.equal(0.5); + expect(sixteen().validateMove("button").message).to.contain("no button"); + // The half-point button is a lone checkbox: no group, and no separate 1-point variant. + const uids = small().allvariants()!.map((v) => v.uid); + expect(uids).to.not.include("#button"); + expect(uids).to.not.include("button"); + const half = small().allvariants()!.find((v) => v.uid === "half-button")!; + expect(half.group).to.be.undefined; + expect(half.name).to.equal("0.5 point button"); + expect(half.description).to.contain("prevents tied scores"); + // A Kill-All game never has one, whatever the board. + expect(play(small(["kill-all"]), ["f6", "attacker"]).getButtons().some((b) => b.move === "button")).to.be.false; + }); + + it("takes the button and then places a stone by clicking", () => { + const g = play(small(), ["f6"]); + expect(g.validateMove("button").complete).to.equal(0); + const [x, y] = g.algebraic2coords("e5"); + const click = g.handleClick("button", y, x); + expect(click.move).to.equal("button,e5"); + expect(click.complete).to.equal(1); + g.move(click.move); + expect(g.button).to.equal(2); + expect(g.board.get("e5")).to.equal(2); + expect(g.placed).to.equal(3); + // Taking the button is not a pass: the following whole-move passes end the game on the board as it is. + play(g, ["pass", "pass"]); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect([g.getPlayerScore(1), g.getPlayerScore(2)]).to.deep.equal([1, 2]); + }); + + it("captures stones", () => { + const g = small(); + setStones(g, ["a2"], ["a1"]); + g.move("b1"); + expect(g.board.has("a1")).to.be.false; + expect(g.results.filter((r) => r.type === "capture").map((r) => r.count)).to.deep.equal([1]); + // Two stones, the point they enclose, and the rest of the empty board; the capture itself does not score. + expect(g.getPlayerScore(1)).to.equal(121); + expect(g.getPlayerScore(2)).to.equal(0); + expect(g.sidebarScores().map((table) => table.scores)).to.deep.equal([[121, 0]]); + }); + + it("allows multi-stone suicide but not the single stone that repeats the position", () => { + const g = small(["superko"]); + setStones(g, ["a1"], ["a2", "b2", "c1", "k11"]); + 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 suicide = g.results.find((r) => r.type === "capture")!; + expect(suicide.how).to.equal("suicide"); + expect(suicide.count).to.equal(2); + + const h = small(["superko"]); + setStones(h, [], ["a2", "b1", "k11"]); + const result = h.validateMove("a1"); + expect(result.valid).to.be.false; + expect(result.message).to.contain("suicide"); + }); + + it("judges a suicidal first placement at once, or at the end of the move under checked weak eyes", () => { + // Player 2's two placements: a1 has no liberties and captures nothing. + const g = play(small(["superko"]), ["k11"]); + setStones(g, ["a2", "b1", "k11"], []); + const first = g.validateMove("a1"); + expect(first.valid).to.be.false; + expect(first.message).to.contain("suicide"); + expect(g.validateMove("a1,k10").valid).to.be.false; + // With checked weak eyes nothing is cleared until the move ends, so the second stone decides. + const h = play(small(["superko", "weak-eyes"]), ["k11"]); + setStones(h, ["a2", "b1", "k11"], []); + const deferred = h.validateMove("a1"); + expect(deferred.valid).to.be.true; + expect(deferred.complete).to.equal(-1); + expect(deferred.message).to.contain("suicide"); + expect(h.validateMove("a1,pass").valid).to.be.false; + expect(h.validateMove("a1,k10").complete).to.equal(1); + h.move("a1,k10"); + expect(h.board.has("a1")).to.be.false; + expect(h.board.get("k10")).to.equal(2); + // Two stones removed together at the end of the move are reported as the move's suicide. + const both = play(small(["superko", "weak-eyes"]), ["k11"]); + setStones(both, ["a2", "b2", "c1", "k11"], []); + const together = both.validateMove("a1,b1"); + expect(together.valid).to.be.false; + expect(together.message).to.contain("stones placed this move"); + expect(together.message).to.contain("positional superko"); + const apart = play(small(["superko", "weak-eyes"]), ["k11"]); + setStones(apart, ["a2", "b1", "a10", "b11", "k11"], []); + expect(apart.validateMove("a1,a11").message).to.contain("stones placed this move"); + // Under double delayed superko the move is allowed until the position has arisen three times. + const delayed = small(["double-delayed-psk", "weak-eyes"]); + setStones(delayed, [], ["a2", "b2", "c1", "j11", "k10"]); + play(delayed, ["k11", "pass,pass", "k11", "pass"]); + expect(delayed.gameover).to.be.false; + const fourth = delayed.validateMove("a1,b1"); + expect(fourth.valid).to.be.false; + expect(fourth.message).to.contain("stones placed this move"); + expect(fourth.message).to.contain("three times"); + }); + + it("judges repetition only by the position a move leaves under checked weak eyes", () => { + // A ko: retaking it at once recreates the position the capture came from. + const g = small(["superko", "weak-eyes"]); + setStones(g, ["a2", "b1", "b3"], ["b2", "c1", "c3", "d2"]); + g.move("c2"); + expect(g.board.has("b2")).to.be.false; + // The retake alone would end the move in that position; followed by another stone it is a new one. + const alone = g.validateMove("b2"); + expect(alone.complete).to.equal(-1); + expect(alone.message).to.contain("earlier position"); + expect(g.validateMove("b2,pass").valid).to.be.false; + expect(g.validateMove("b2,k11").complete).to.equal(1); + g.move("b2,k11"); + expect(g.board.has("c2")).to.be.false; + expect(g.board.get("k11")).to.equal(2); + // Only the position the move left was recorded, so the next move sees one new position. + expect(g.interim).to.deep.equal([]); + expect(g.novel).to.be.true; + // The fifth occurrence by default is likewise counted when the move ends. + const d = small(["weak-eyes"]); + setStones(d, ["k10", "j11"], ["a2", "b1"]); + play(d, ["a1", "k11,pass", "a1", "pass"]); + expect(d.gameover).to.be.false; + expect(d.validateMove("a1,k9").complete).to.equal(1); + d.move("a1,pass"); + expect(d.gameover).to.be.true; + expect(d.results.find((r) => r.type === "eog")!.reason).to.equal("repetition"); + }); + + it("can empty a cell and refill it within one move", () => { + // Player 2's two placements: the first captures a1, the second plays there. + const g = small(); + setStones(g, ["a1"], ["a2"]); + g.move("k11"); + expect(g.validateMove("b1,a1").complete).to.equal(1); + g.move("b1,a1"); + expect(g.board.get("a1")).to.equal(2); + expect(g.board.has("b1")).to.be.true; + }); + + it("lets a two-placement move fill both eyes under checked weak eyes", () => { + const { own, wall } = twoEyedCorner(); + const plain = small(["superko"]); + setStones(plain, wall, own); + plain.placed = 5; // Player 1's two-placement move + // Each eye filled is suicide at once and recreates the position. + expect(plain.validateMove("a1").valid).to.be.false; + expect(plain.validateMove("a1,c1").valid).to.be.false; + + const checked = small(["superko", "weak-eyes"]); + setStones(checked, wall, own); + checked.placed = 5; + const first = checked.validateMove("a1"); + expect(first.valid).to.be.true; + expect(first.complete).to.equal(-1); + expect(checked.validateMove("a1,c1").complete).to.equal(1); + checked.move("a1,c1"); + expect(checked.results.filter((r) => r.type === "capture").map((r) => r.count)).to.deep.equal([6]); + for (const cell of own) { + expect(checked.board.has(cell)).to.be.false; + } + expect(checked.board.get("a1")).to.equal(1); + expect(checked.board.get("c1")).to.equal(1); + }); + + it("hands out the button once", () => { + const g = small(); + g.move("button"); + expect(g.button).to.equal(1); + expect(g.placed).to.equal(1); + expect(g.getPlayerScore(1)).to.equal(1); + expect(g.validateMove("button").valid).to.be.false; + expect(g.validateMove("e5,button").valid).to.be.false; + expect(g.validateMove("e5,pass").complete).to.equal(0); + expect(small(["half-button"]).move("button").getPlayerScore(1)).to.equal(0.5); + expect(sixteen().validateMove("button").valid).to.be.false; + }); + + it("ends when both players pass their whole moves in turn", () => { + const g = play(small(), ["pass", "pass,pass"]); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1, 2]); + expect(g.results.find((r) => r.type === "eog")!.reason).to.equal("consecutive-passes"); + // A move that passes one of its placements counts as a pass, so the opponent's whole-move + // pass then ends the game; the pass that ends it must itself pass the whole move. + const partial = play(small(), ["f6", "pass,e5"]); + expect(partial.gameover).to.be.false; + partial.move("pass"); + expect(partial.gameover).to.be.true; + const half = play(small(), ["f6", "e5,g7", "d4", "c3", "pass,pass", "b2,pass"]); + expect(half.gameover).to.be.false; + half.move("pass,pass"); + expect(half.gameover).to.be.true; + // A move that passes nothing breaks the run of passes. + const reset = play(small(), ["f6", "pass,pass", "e5", "pass"]); + expect(reset.gameover).to.be.false; + reset.move("pass,pass"); + expect(reset.gameover).to.be.true; + + const h = play(small(), ["button", "pass,pass"]); + expect(h.gameover).to.be.false; + h.move("pass"); + expect(h.gameover).to.be.true; + expect(h.winner).to.deep.equal([1]); + }); + + it("lists complete legal moves and plays random ones", () => { + const g = small(); + expect(g.moves()).to.have.lengthOf(123); + expect(sixteen().moves()).to.have.lengthOf(257); + g.move("f6"); + const moves = g.moves(); + expect(moves).to.include("e5,g7"); + expect(moves).to.include("pass,pass"); + expect(moves).to.include("button,e5"); + expect(moves).to.not.include("f6,e5"); + for (let i = 0; i < 6; i++) { + g.move(g.randomMove()); + } + expect(g.gameover).to.be.false; + expect(g.stack).to.have.lengthOf(8); + }); + + it("applies partial moves without saving them", () => { + const g = play(small(), ["f6"]); + g.move("e5", { partial: true }); + expect(g.board.get("e5")).to.equal(2); + expect(g.placed).to.equal(2); + expect(g.stack).to.have.lengthOf(2); + expect(g.render().areas).to.have.lengthOf(1); + }); + + it("survives a round trip through serialization", () => { + const g = play(small(), ["f6", "e5,g7", "button"]); + const h = new ThueMorseGoGame(g.serialize()); + expect(h.placed).to.equal(4); + expect(h.button).to.equal(1); + expect(h.validateMove("d4").complete).to.equal(1); + }); +}); + +describe("Thue-Morse Go: handicap", () => { + it("offers the button after a one-stone handicap only when there is one to take", () => { + // 16x16 has no button. + const even = play(sixteen(["handicap"]), ["h8"]); + const plain = even.validateMove("1s"); + expect(plain.complete).to.equal(0); + expect(plain.message).to.contain("Handicap of one stone"); + expect(plain.message).to.not.contain("button"); + // 11x11 has one while nobody holds it. + expect(play(small(["handicap"]), ["f6"]).validateMove("1s").message).to.contain("take the button"); + expect(play(small(["handicap"]), ["button"]).validateMove("1s").message).to.not.contain("button"); + // The same holds when the declaration follows a reverse komi. + expect(play(sixteen(["handicap", "reverse-komi"]), ["h8"]).validateMove("14p,1s").message).to.not.contain("button"); + }); + + it("serves the declared passes, the declaration first", () => { + const g = small(["handicap"]); + g.move("f6"); + expect(g.validateMove("e5").valid).to.be.false; + expect(g.getButtons()).to.deep.equal([]); + expect(g.validateMove("3").complete).to.equal(0); + expect(g.validateMove("3,e5").valid).to.be.false; + g.move("3"); + expect(g.handicap).to.equal(3); + expect(g.passesOwed).to.equal(1); + expect(g.placed).to.equal(3); + expect(g.currplayer).to.equal(1); + expect(g.lastmove).to.equal("3s"); + expect(g.results.map((r) => r.type)).to.deep.equal(["declare", "pass"]); + // Player 2's next move is a single placement, so it is served automatically. + g.move("e5"); + expect(g.stack).to.have.lengthOf(5); + expect(g.currplayer).to.equal(1); + expect(g.placed).to.equal(5); + expect(g.passesOwed).to.equal(0); + const served = g.stack[4]; + expect(served.lastmove).to.equal("pass"); + expect(served.currplayer).to.equal(1); + expect(served._results).to.deep.equal([{ type: "pass", why: "handicap" }]); + }); + + it("lets a handicap of one place the second stone of the move", () => { + const g = play(small(["handicap"]), ["f6"]); + expect(g.validateMove("1").complete).to.equal(0); + expect(g.validateMove("1,e5").complete).to.equal(1); + g.move("1,e5"); + expect(g.board.get("e5")).to.equal(2); + expect(g.passesOwed).to.equal(0); + expect(g.placed).to.equal(3); + expect(play(small(["handicap"]), ["f6", "1"]).lastmove).to.equal("1s,pass"); + expect(play(small(["handicap"]), ["f6", "1s,e5"]).board.get("e5")).to.equal(2); + }); + + it("can run out partway through a two-placement move", () => { + const g = play(sixteen(["handicap"]), ["f6", "4", "e5"]); + expect(g.passesOwed).to.equal(1); + expect(g.currplayer).to.equal(1); + g.move("d4,d5"); + expect(g.placed).to.equal(7); + expect(g.currplayer).to.equal(2); + expect(g.validateMove("c3,d3").valid).to.be.false; + expect(g.validateMove("c3").complete).to.equal(1); + g.move("c3"); + expect(g.lastmove).to.equal("c3"); + expect(g.placed).to.equal(9); + expect(g.passesOwed).to.equal(0); + expect(g.results.map((r) => r.type)).to.deep.equal(["pass", "place"]); + }); + + it("keeps marking the last placement across an automatically served move", () => { + const g = play(small(["handicap"]), ["f6", "3", "e5"]); + expect(g.results).to.deep.equal([{ type: "pass", why: "handicap" }]); + const [x, y] = g.algebraic2coords("e5"); + expect(g.render().annotations).to.deep.include({ type: "enter", targets: [{ row: y, col: x }] }); + }); + + it("does not count served passes towards the end of the game", () => { + const g = play(small(["handicap"]), ["f6", "3", "pass", "pass,pass"]); + expect(g.gameover).to.be.false; + expect(g.currplayer).to.equal(2); + g.move("pass"); + expect(g.gameover).to.be.true; + }); + + it("offers a fortieth of the board's points across the centre row, covered cells still answering to clicks", () => { + const g = play(small(["handicap"]), ["f6"]); + const pieces = g.render().pieces as string; + expect(pieces).to.contain("n1,").and.to.contain("n3,"); + expect(pieces).to.not.contain("n4"); + // Player 1's stone at f6 covers the stone for 2, which shows as the stone but still means 2. + expect(pieces).to.not.contain("n2,"); + expect(click(g, "", "e6").move).to.equal("1s"); + expect(click(g, "", "f6").move).to.equal("2s"); + expect(click(g, "", "g6").move).to.equal("3s"); + expect(click(g, "3", "e6").move).to.equal("1s"); + expect(click(g, "1", "e5").move).to.equal("1s,e5"); + expect(g.validateMove("4").valid).to.be.false; + // Off the numbered stones the click explains itself; with no placement left it does too. + const early = click(g, "", "e9"); + expect(early.valid).to.be.false; + expect(early.message).to.contain("Choose the handicap first"); + expect(click(g, "3", "e9").message).to.contain("no placement left"); + // Larger boards offer more, rounded away from nine: 6, 10 and 14. + expect(sixteen(["handicap"]).move("h8").validateMove("6").valid).to.be.true; + expect(sixteen(["handicap"]).move("h8").validateMove("7").valid).to.be.false; + expect(new ThueMorseGoGame(undefined, ["size-19", "handicap"]).move("k10").validateMove("10").valid).to.be.true; + expect(new ThueMorseGoGame(undefined, ["size-23", "handicap"]).move("l12").validateMove("14").valid).to.be.true; + expect(new ThueMorseGoGame(undefined, ["size-23", "handicap"]).move("l12").validateMove("15").valid).to.be.false; + // Kill-All doubles the limit, odd values in a row above the centre and even ones below. + const k = small(["kill-all", "handicap"]); + const kp = k.render().pieces as string; + expect(kp).to.contain("n5,").and.to.contain("n6,").and.to.not.contain("n7"); + const [ox, oy] = k.algebraic2coords("e7"); + expect(k.handleClick("", oy, ox).move).to.equal("1"); + const [ex, ey] = k.algebraic2coords("e5"); + expect(k.handleClick("", ey, ex).move).to.equal("2"); + expect(k.validateMove("6").valid).to.be.true; + expect(k.validateMove("7").valid).to.be.false; + }); +}); + +describe("Thue-Morse Go: ko", () => { + it("forbids retaking a ko at once but allows it after a threat", () => { + const g = small(["superko"]); + setStones(g, ["a2", "b1", "b3"], ["b2", "c1", "c3", "d2"]); + g.move("c2"); + expect(g.board.has("b2")).to.be.false; + // Every placement is judged as it is made, so the retake has to follow the threat. + const retake = g.validateMove("b2"); + expect(retake.valid).to.be.false; + expect(retake.message).to.contain("earlier position"); + expect(retake.message).to.not.contain("suicide"); + expect(g.validateMove("b2,k11").valid).to.be.false; + expect(g.validateMove("k11,b2").complete).to.equal(1); + g.move("k11,b2"); + expect(g.board.has("c2")).to.be.false; + }); + + it("forbids only the fourth occurrence of a position under double delayed superko", () => { + const g = small(["double-delayed-psk"]); + expect(g.allvariants()!.find((v) => v.uid === "double-delayed-psk")!.group).to.equal("repetition"); + setStones(g, ["a2", "b1", "b3"], ["b2", "c1", "c3", "d2"]); + // The ko can be retaken at once, twice over; the retake that would recreate the position a fourth time cannot. + play(g, ["c2", "b2", "c2", "b2", "c2,pass"]); + expect(g.board.has("c2")).to.be.true; + expect(g.board.has("b2")).to.be.false; + const fourth = g.validateMove("b2"); + expect(fourth.valid).to.be.false; + expect(fourth.message).to.contain("fourth time"); + expect(fourth.message).to.not.contain("suicide"); + // A threat first makes a new position, as under superko. + expect(g.validateMove("k11,b2").complete).to.equal(1); + // The same holds for a single stone removed as suicide, which never draws the game under this rule. + const h = small(["double-delayed-psk"]); + setStones(h, [], ["a2", "b1", "k11"]); + play(h, ["a1", "pass,pass", "a1", "pass"]); + expect(h.gameover).to.be.false; + const again = h.validateMove("a1"); + expect(again.valid).to.be.false; + expect(again.message).to.contain("three times"); + expect(again.message).to.contain("suicide"); + }); + + it("draws the game on the fifth repetition by default", () => { + const g = small(); + // The repetition draw is the group's sentinel, so a game with no variant named has it; superko is the variant. + expect(g.allvariants()!.find((v) => v.uid === "superko")!.group).to.equal("repetition"); + expect(g.allvariants()!.find((v) => v.uid === "#repetition")!.default).to.be.undefined; + setStones(g, [], ["a2", "b1", "k11"]); + expect(g.validateMove("a1").valid).to.be.true; + // Every placement counts, so the second stone of one move can be the fifth occurrence. + play(g, ["a1", "pass,pass", "a1", "pass"]); + expect(g.gameover).to.be.false; + g.move("a1,a1"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1, 2]); + expect(g.results.find((r) => r.type === "eog")!.reason).to.equal("repetition"); + }); +}); + +describe("Thue-Morse Go: Kill-All", () => { + it("requires no button and swaps the flags", () => { + const g = new ThueMorseGoGame(undefined, ["size-11", "kill-all"]); + expect(g.variants).to.deep.equal(["size-11", "kill-all"]); + play(g, ["f6", "attacker"]); + expect(g.validateMove("button").valid).to.be.false; + // Kill-All and the half-point button exclude each other; the later choice wins. + const h = new ThueMorseGoGame(undefined, ["size-11", "kill-all", "half-button"]); + expect(h.variants).to.deep.equal(["size-11", "half-button"]); + const k = new ThueMorseGoGame(undefined, ["size-11", "half-button", "kill-all"]); + expect(k.variants).to.deep.equal(["size-11", "kill-all"]); + expect(play(k, ["f6", "attacker"]).getButtons().some((b) => b.move === "button")).to.be.false; + expect(g.getFlags()).to.include("custom-colours"); + expect(g.getFlags()).to.not.include("scores"); + expect(small().getFlags()).to.include("scores"); + }); + + it("decides the sides by pieboxing", () => { + const g = small(["kill-all"]); + expect(g.phase).to.equal("alt-place"); + g.move("f6"); + expect(g.board.get("f6")).to.equal(2); + expect(g.currplayer).to.equal(2); + g.move("attacker"); + expect(g.defenderSeat).to.equal(1); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.placed).to.equal(0); + // The Defender draws from slot 4 and the Attacker from slot 3, falling back to the second and first colours. + expect(g.getPlayerColour(1)).to.deep.equal(role(4)); + expect(g.getPlayerColour(2)).to.deep.equal(role(3)); + g.move("e5"); + expect(g.board.get("e5")).to.equal(1); + expect(g.validateMove("pass").valid).to.be.false; + expect(g.validateMove("button").valid).to.be.false; + expect(g.validateMove("e4").complete).to.equal(-1); + g.move("e4,e6"); + expect(g.board.get("e4")).to.equal(2); + expect(g.sidebarScores()).to.deep.equal([]); + }); + + it("ends when the Defender is unconditionally alive", () => { + const { own, wall } = twoEyedCorner(); + const g = play(small(["kill-all"]), ["f6", "attacker"]); + setStones(g, own, [...wall, "f6"]); + // Before the end, pass-alive stones are faded only under the display option. + expect(faded(g)).to.deep.equal([]); + expect(faded(g, THROUGHOUT)).to.deep.equal([...own].sort()); + g.move("k11"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + expect(g.results.find((r) => r.type === "eog")!.reason).to.equal("pass-alive"); + expect(g.alive!.sort()).to.deep.equal([...own].sort()); + // The position that ended the game fades them, instead of boxing them; the last stone is still boxed. + expect(faded(g)).to.deep.equal([...own].sort()); + const rep = g.render(); + const enters = rep.annotations!.filter((a) => a.type === "enter") as Array<{ targets: RowCol[] }>; + expect(enters.map((a) => a.targets.length)).to.deep.equal([1]); + expect(layers(g, "L1")[0]).to.deep.include({ opacity: 0.75 }); + expect((layers(g, "L1")[0] as { paint: { fill: unknown } }).paint.fill).to.deep.equal(role(4)); + // Earlier positions of the finished game are marked only on request. + g.load(g.stack.length - 2); + expect(faded(g)).to.deep.equal([]); + expect(faded(g, THROUGHOUT)).to.deep.equal([...own].sort()); + // Outside Kill-All the display does nothing, and dead stones keep their own fade. + const plain = small(); + setStones(plain, ["f6"], []); + plain.move("pass,-f6"); + expect(faded(plain, THROUGHOUT)).to.deep.equal(["f6"]); + expect(plain.render(THROUGHOUT).legend).to.not.have.property("L1"); + }); + + it("needs three eyes under checked weak eyes", () => { + const { own, wall } = twoEyedCorner(); + const g = play(small(["kill-all", "weak-eyes"]), ["f6", "attacker"]); + setStones(g, own, [...wall, "f6"]); + g.move("k11"); + expect(g.gameover).to.be.false; + // Two eyes are not pass-alive here, so nothing fades even on request. + expect(faded(g, THROUGHOUT)).to.deep.equal([]); + + const h = play(small(["kill-all", "weak-eyes"]), ["f6", "attacker"]); + const three = [...own, "e2", "f1", "f2"]; + setStones(h, three, ["a3", "b3", "c3", "d3", "e3", "f3", "g1", "g2", "g3", "f6"]); + expect(faded(h, THROUGHOUT)).to.deep.equal([...three].sort()); + h.move("k11"); + expect(h.gameover).to.be.true; + expect(h.winner).to.deep.equal([1]); + expect(faded(h)).to.deep.equal([...three].sort()); + }); + + it("fades the Attacker's pass-alive stones too, and at any end of the game", () => { + // The Attacker's own two-eyed corner is pass-alive as well. + const { own, wall } = twoEyedCorner(); + const g = play(small(["kill-all"]), ["f6", "attacker"]); + setStones(g, [], [...own, "f6"]); + expect(faded(g, THROUGHOUT)).to.deep.equal([...own].sort()); + expect(g.render(THROUGHOUT).legend).to.have.property("L2"); + expect(g.render(THROUGHOUT).legend).to.not.have.property("L1"); + void wall; + // A state pasted with indentation around it still loads. + const again = new ThueMorseGoGame(` \n ${g.serialize()}\n`); + expect(again.render(THROUGHOUT).pieces).to.deep.equal(g.render(THROUGHOUT).pieces); + }); + + it("places the handicap stones when Player 2 takes the Attacker side", () => { + const g = small(["kill-all", "handicap"]); + expect(g.phase).to.equal("hand-n"); + expect(g.validateMove("3").complete).to.equal(0); + g.move("3"); + expect(g.phase).to.equal("alt-place"); + expect(g.currplayer).to.equal(2); + expect(g.validateMove("attacker").complete).to.equal(-1); + expect(() => g.move("attacker")).to.throw(); + g.move("attacker:a1,b1,c1"); + expect(g.defenderSeat).to.equal(1); + expect(g.board.get("c1")).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + }); + + it("draws the Attacker in the first colour and the Defender in the second unless slots 3 and 4 are set, chips included", () => { + const g = play(small(["kill-all"]), ["f6"]); + // Undecided roles: neutral chips, Attacker stones already in the Attacker's fill. + expect(g.getPlayerColour(1)).to.equal("#999999"); + expect((layers(g, "B")[0] as { paint: { fill: unknown } }).paint.fill).to.deep.equal(role(3)); + g.move("attacker"); + // Player 2 is the Attacker and Player 1 the Defender, on the board, tracker and chips alike. + expect(g.getPlayerColour(2)).to.deep.equal(role(3)); + expect(g.getPlayerColour(1)).to.deep.equal(role(4)); + expect((layers(g, "A")[0] as { paint: { fill: unknown } }).paint.fill).to.deep.equal(role(4)); + expect((layers(g, "B")[0] as { paint: { fill: unknown } }).paint.fill).to.deep.equal(role(3)); + expect((layers(g, "s1")[1] as { paint: { fill: unknown } }).paint.fill).to.deep.equal(role(4)); + // The tracker's tint and border follow the next placement's role too. + const track = g.render().areas![0] as AreaTrack; + expect((track.board.markers as MarkerLine[])[0].colour).to.deep.equal(role(4)); + const slots = ThueMorseGoGame.gameinfo.customizations!.map((c) => ("num" in c ? [c.num, c.default] : [])); + expect(slots).to.deep.equal([[1, 1], [2, 2], [3, 1], [4, 2], [5, "#000000"], [6, "#000000"], [7, "#000000"], [8, "#000000"]]); + // Every stone's border is black or white by contrast, or the slot four above its fill under the display option. + expect((layers(g, "A")[0] as { paint: unknown }).paint).to.deep.equal(rolePaint(4)); + expect((layers(g, "B")[0] as { paint: unknown }).paint).to.deep.equal(rolePaint(3)); + const fromPalette = g.render({ altDisplays: ["palette-borders"] }).legend as Record; + expect((fromPalette.A[0] as { paint: unknown }).paint).to.deep.equal(rolePaint(4, true)); + expect((fromPalette.B[0] as { paint: unknown }).paint).to.deep.equal(rolePaint(3, true)); + // Standard games keep the seats' own slots, and there is no swap display any more. + const h = play(small(), ["f6"]); + expect(h.getPlayerColour(1)).to.equal(1); + expect((layers(h, "B")[0] as { paint: { fill: number } }).paint.fill).to.equal(2); + expect(ThueMorseGoGame.gameinfo.displays!.map((d) => d.uid)).to.deep.equal(["tms-tracker", "digits-down", "hide-territory", "palette-borders", "pass-alive-throughout"]); + }); +}); + +describe("Thue-Morse Go: tracker and sidebar", () => { + it("draws the tracker of the mockup for placement 0x123 under the digit display", () => { + const g = small(); + g.placed = 0x123; + const digits = { altDisplays: ["tms-tracker"] }; + const track = g.render(digits).areas![0] as AreaTrack; + expect(track.type).to.equal("track"); + expect(track.position).to.equal("left"); + expect(track.board.width).to.equal(7); + expect(track.board.height).to.equal(18); + expect(track.board.blocked).to.have.lengthOf(7 * 18 - 48); + expect(track.pieces).to.equal(TRACK_0X123); + const [tint, marker, digit] = layers(g, "m1d2", digits); + expect(tint).to.deep.equal({ name: "piece-square", paint: { fill: 1 }, opacity: 0.2 }); + expect(marker).to.deep.equal({ name: "piece", paint: stonePaint(1) }); + expect(digit).to.deep.equal({ text: "2", scale: 0.75, rotate: null }); + expect(layers(g, "m2d1", digits)[2]).to.deep.equal({ text: "1", scale: 0.75, rotate: null }); + const [, smallGlyph] = layers(g, "s2", digits); + expect(smallGlyph).to.deep.equal({ name: "piece", paint: stonePaint(2), scale: 0.57735 }); + expect(layers(g, "s2", digits)).to.have.lengthOf(2); + // A thick border in the colour of the next placement runs around the columns, inside the frame. + const border = track.board.markers as MarkerLine[]; + expect(border).to.have.lengthOf(4); + expect(border[0]).to.deep.equal({ type: "line", points: [{ row: 1, col: 1 }, { row: 1, col: 6 }], colour: 1, width: 8 }); + expect(border[2]).to.deep.equal({ type: "line", points: [{ row: 17, col: 6 }, { row: 17, col: 1 }], colour: 1, width: 8 }); + g.placed = 0x124; + const next = (g.render(digits).areas![0] as AreaTrack).board.markers as MarkerLine[]; + expect(next.every((line) => line.colour === 2)).to.be.true; + }); + + it("tints the tracker with the colour of the next placement", () => { + const g = play(small(), ["f6"]); + expect(layers(g, "s1")[0]).to.deep.equal({ name: "piece-square", paint: { fill: 2 }, opacity: 0.2 }); + }); + + it("can run the digits downward, and goes grey past placement fff", () => { + const g = small(); + g.placed = 0x123; + const track = g.render({ altDisplays: ["tms-tracker", "digits-down"] }).areas![0] as AreaTrack; + expect(track.pieces).to.equal(TRACK_0X123.split("\n").reverse().join("\n")); + // The last index the three columns can show still has markers and a border. + g.placed = 0xfff; + const last = g.render({ altDisplays: ["tms-tracker"] }).areas![0] as AreaTrack; + expect(last.board.width).to.equal(7); + expect(last.board.markers).to.have.lengthOf(4); + expect(last.pieces).to.contain("m1df"); + // Past it the tracker keeps its shape but shows only grey stones: no markers, tint or border. + g.placed = 0x1000; + const grey = g.render({ altDisplays: ["tms-tracker"] }).areas![0] as AreaTrack; + expect(grey.board.width).to.equal(7); + expect(grey.board.markers).to.be.undefined; + expect(grey.pieces!.split("\n").slice(1, -1)).to.deep.equal(Array(16).fill("-,s0,-,s0,-,s0,-")); + expect(layers(g, "s0", { altDisplays: ["tms-tracker"] })).to.deep.equal([ + { name: "piece-square", paint: { fill: "#999999" }, opacity: 0.2 }, + { name: "piece", paint: { fill: "#999999" }, scale: 0.57735 }, + ]); + // The rolling tracker has no such limit. + expect((g.render().areas![0] as AreaTrack).board.markers).to.have.lengthOf(4); + expect((g.render().areas![0] as AreaTrack).pieces).to.contain("-,m2,-"); + }); + + it("rolls a single column of the next 16 placements by default", () => { + const g = small(); + const expected = (from: number, length: number, downward: boolean): string => { + const rows: string[] = ["-,-,-"]; + for (let row = 0; row < 16; row++) { + const i = downward ? row : 15 - row; + const colour = g.colourAt(from + i); + rows.push(`-,${i < length ? `m${colour}` : `s${colour}`},-`); + } + rows.push("-,-,-"); + return rows.join("\n"); + }; + let track = g.render().areas![0] as AreaTrack; + expect(track.board.width).to.equal(3); + expect(track.board.height).to.equal(18); + expect(track.board.blocked).to.have.lengthOf(3 * 18 - 16); + expect(track.pieces).to.equal(expected(0, 1, false)); + g.move("f6"); + // Player 2's move is two placements, both full-size stones with no digit on them. + track = g.render().areas![0] as AreaTrack; + expect(track.pieces).to.equal(expected(1, 2, false)); + expect(track.pieces.split("\n")[16]).to.equal("-,m2,-"); + expect(track.pieces.split("\n")[15]).to.equal("-,m2,-"); + const legend = g.render().legend as Record; + expect(legend.m2).to.deep.equal([ + { name: "piece-square", paint: { fill: 2 }, opacity: 0.2 }, + { name: "piece", paint: stonePaint(2) }, + ]); + expect(Object.keys(legend).filter((key) => /^m[12]d/.test(key))).to.deep.equal([]); + track = g.render({ altDisplays: ["digits-down"] }).areas![0] as AreaTrack; + expect(track.pieces).to.equal(expected(1, 2, true)); + }); + + it("shows no tracker under Balanced Marseillais", () => { + expect(small(["marseillais"]).render().areas).to.be.undefined; + expect(small().render().areas).to.have.lengthOf(1); + }); + + it("reports the placements of the next move in hexadecimal without a prefix", () => { + // The 16x16 board has no button, so the status panel has just this row. + const g = sixteen(); + expect(g.sidebarStatuses()[0].value).to.deep.equal(["0"]); + expect(JSON.stringify(g.sidebarStatuses()[0].key)).to.contain("NEXT_PLACEMENT"); + // A two-placement move lists both of its placements. + play(g, ["f6"]); + expect(g.sidebarStatuses()[0].value).to.deep.equal(["1, 2"]); + play(g, ["e5,g7"]); + expect(g.sidebarStatuses()[0].value).to.deep.equal(["3"]); + g.placed = 0x1a; + expect(g.sidebarStatuses()[0].value).to.deep.equal(["1a"]); + g.placed = 0x1d; + expect(g.sidebarStatuses()[0].value).to.deep.equal(["1d, 1e"]); + // The coming placements are no longer listed; the tracker shows them. + expect(g.sidebarStatuses()).to.have.lengthOf(1); + }); + + it("uses the snub square board and its connections", () => { + const g = small(["snub"]); + expect(g.render().board).to.deep.include({ style: "snubsquare" }); + const neighbours = new SnubSquareGraph(11, 11).neighbours("f6"); + expect(neighbours).to.have.lengthOf(5); + setStones(g, neighbours.slice(0, -1), ["f6"]); + g.move(neighbours[neighbours.length - 1]); + expect(g.board.has("f6")).to.be.false; + expect(g.results.some((r) => r.type === "capture" && r.count === 1)).to.be.true; + }); +}); + +describe("Thue-Morse Go: chat log", () => { + it("matches the legacy chat log", () => { + const g = small(["handicap"]); + setStones(g, ["a2"], ["a1"]); + play(g, ["b1", "2", "button", "pass", "pass,pass"]); + expect(g.gameover).to.be.true; + assertChatLogParity(g, ["Alice", "Bob"]); + const log = g.chatLog(["Alice", "Bob"]).flat().join("\n"); + expect(log).to.contain("Alice placed a piece at b1."); + expect(log).to.contain("Bob set the handicap to 2 stones."); + expect(log).to.contain("Bob gave a handicap stone by passing."); + expect(log).to.contain("Alice took the button."); + }); +}); + +describe("Thue-Morse Go: territory and dead strings", () => { + /** A wall giving Player 1 the corner a1-b1, a far stone of theirs, and two Player 2 stones giving them k11. */ + const scene = (variants: string[] = []): ThueMorseGoGame => { + const g = small(variants); + setStones(g, ["a2", "b2", "c2", "c1", "f6"], ["j11", "k10"]); + return g; + }; + const rc = (g: ThueMorseGoGame, cell: string): { row: number; col: number } => { + const [col, row] = g.algebraic2coords(cell); + return { row, col }; + }; + /** The territory dots: the coloured dot markers, as opposed to the star points. */ + const dots = (g: ThueMorseGoGame, opts?: { altDisplays?: string[] }): MarkerDots[] => + (((g.render(opts).board as BoardBasic).markers ?? []) as MarkerDots[]).filter((m) => m.colour !== undefined); + it("dots the empty points that count as territory", () => { + const g = scene(); + const markers = dots(g); + expect(markers).to.have.lengthOf(2); + expect(markers[0]).to.deep.equal({ type: "dots", colour: 1, size: 0.2, points: [rc(g, "a1"), rc(g, "b1")] }); + expect(markers[1]).to.deep.equal({ type: "dots", colour: 2, size: 0.2, points: [rc(g, "k11")] }); + expect(g.getPlayerScore(1)).to.equal(7); + expect(g.getPlayerScore(2)).to.equal(3); + // The display option hides them, and Kill-All games never show them. + expect(dots(g, { altDisplays: ["hide-territory"] })).to.have.lengthOf(0); + const k = play(small(["kill-all"]), ["f6", "attacker"]); + setStones(k, ["a2", "b2", "c2", "c1"], ["j11", "k10"]); + expect(dots(k)).to.have.lengthOf(0); + // A lone first stone reaches every empty point, so the whole board is dotted, as in Go. + const first = dots(play(small(), ["f6"])); + expect(first).to.have.lengthOf(1); + expect(first[0].colour).to.equal(1); + expect(first[0].points).to.have.lengthOf(120); + expect(dots(small())).to.have.lengthOf(0); + }); + + it("keeps a whole-move pass open for marking strings dead", () => { + const single = scene().validateMove("pass"); + expect(single.complete).to.equal(0); + expect(single.canrender).to.be.true; + expect(single.message).to.contain("mark them dead"); + const g = play(scene(), ["f7"]); + expect(g.validateMove("pass,pass").complete).to.equal(0); + // A single pass typed in a two-placement move passes the whole move. + expect(g.validateMove("pass").complete).to.equal(0); + expect(g.validateMove("pass").message).to.contain("mark them dead"); + // A move that places one stone and passes the other placement stays open too, whether or + // not the pass is spelled out; strings can be clicked only once the pass is spelled out. + const half = g.validateMove("e5,pass"); + expect(half.complete).to.equal(0); + expect(half.message).to.contain("pass the rest of this move"); + expect(half.message).to.contain("does not end the game"); + expect(half.message).to.contain("Click strings to mark them dead"); + const implied = g.validateMove("e5"); + expect(implied.complete).to.equal(0); + expect(implied.message).to.contain("pass the rest of this move"); + expect(implied.message).to.contain("press Pass first"); + expect(implied.message).to.not.contain("Click strings"); + expect(g.validateMove("e5,g7").complete).to.equal(1); + }); + + it("marks and unmarks strings by clicking them after passing", () => { + const g = scene(); + expect(click(g, "pass", "b2").move).to.equal("pass,-a2"); + expect(click(g, "pass,-a2", "c1").move).to.equal("pass"); + // Marks are listed in board order, one token per string. + expect(click(g, "pass,-a2", "k10").move).to.equal("pass,-k10,-a2"); + expect(g.validateMove("pass,-c1,-b2").valid).to.be.true; + // An empty point placed afterwards drops the marking; a stone clicked without passing is just occupied. + expect(click(g, "pass,-a2", "e5").move).to.equal("e5"); + const occupied = click(g, "", "a2"); + expect(occupied.valid).to.be.false; + expect(occupied.message).to.contain("press Pass"); + expect(click(g, "e5", "a2").valid).to.be.false; + // A two-placement move: the Pass button then a click, or a typed single pass then a click. + const h = play(scene(), ["f7"]); + expect(click(h, h.handleClick("", -1, -1, "_btn_pass").move, "j11").move).to.equal("pass,pass,-j11"); + expect(click(h, "pass", "j11").move).to.equal("pass,pass,-j11"); + // A stone placed after passing the move comes first, the pass after it, and the marking stays. + expect(click(h, "pass,pass", "e5").move).to.equal("e5,pass"); + expect(click(h, "pass,pass,-j11", "e5").move).to.equal("e5,pass,-j11"); + expect(click(h, "e5,pass", "e5").move).to.equal("pass,pass"); + expect(click(h, "e5,pass,-j11", "e5").move).to.equal("pass,pass,-j11"); + expect(click(h, "e5,pass", "g7").move).to.equal("e5,g7"); + expect(click(h, "e5,pass", "g7").complete).to.equal(1); + // Once the move passes, a click on a string marks it dead, or unmarks it again; without a + // pass spelled out a stone clicked is just occupied, so a misclick never passes a placement. + expect(click(h, "e5,pass", "j11").move).to.equal("e5,pass,-j11"); + const misclick = click(h, "e5", "j11"); + expect(misclick.valid).to.be.false; + expect(misclick.move).to.equal("e5"); + expect(click(h, "e5,pass,-j11", "j11").move).to.equal("e5,pass"); + // Marks alone pass the move too, so strings can be clicked after them. + expect(click(g, "-a2", "k10").move).to.equal("pass,-k10,-a2"); + expect(click(h, "e5,pass,-j11", "k10").move).to.equal("e5,pass,-j11,-k10"); + expect(click(h, "e5,pass,-j11", "g7").move).to.equal("e5,g7"); + const occupiedAfter = click(h, "e5,g7", "j11"); + expect(occupiedAfter.valid).to.be.false; + expect(occupiedAfter.message).to.contain("press Pass"); + // Without a pass in the move, clicks behave as before. + expect(click(h, "", "e5").move).to.equal("e5"); + expect(click(h, "e5", "g7").move).to.equal("e5,g7"); + expect(click(h, "e5,g7", "e5").move).to.equal("g7"); + expect(click(h, "e5,g7", "d4").move).to.equal("e5,d4"); + }); + + it("drops a mark on a stone the move's own placement captures", () => { + // Player 1's a1 has its last liberty at a2; Player 2 captures it while passing the other placement. + const g = small(); + setStones(g, ["a1", "f6"], ["b1", "k10"]); + g.move("k11"); + expect(click(g, "pass,pass,-a1,-k10", "a2").move).to.equal("a2,pass,-k10"); + const kept = g.validateMove("a2,pass,-a1,-k10"); + expect(kept.valid).to.be.true; + expect(kept.message).to.contain("one stone marked dead"); + expect(g.validateMove("a2,pass,-a3").message).to.contain("no stone"); + g.move("a2,pass,-a1,-k10"); + expect(g.board.has("a1")).to.be.false; + expect(g.lastmove).to.equal("a2,pass,-k10"); + expect(g.marks).to.deep.equal(["k10"]); + expect(g.results).to.deep.include({ type: "select", what: "dead", how: "marked", where: "k10" }); + // The opponent's whole-move pass keeping the marking ends the game. + expect(g.getButtons()[0].move).to.equal("pass,-k10"); + g.move("pass,-k10"); + expect(g.gameover).to.be.true; + expect(g.board.has("k10")).to.be.false; + // A capture that leaves nothing marked after a marking is a pass that marks nothing dead. + const h = small(); + setStones(h, ["a1", "f6"], ["b1", "k10"]); + h.move("pass,-a1"); + expect(click(h, "pass,pass,-a1", "a2").move).to.equal("a2,pass"); + expect(h.validateMove("a2,pass,-a1").message).to.contain("ends dead-stone marking"); + h.move("a2,pass,-a1"); + expect(h.locked).to.be.true; + expect(h.marks).to.deep.equal([]); + }); + + it("refuses marks in a move that passes nothing", () => { + const g = scene(); + expect(g.validateMove("f7,-a2").valid).to.be.false; + // Marks alone pass the move, the pass being implied. + expect(g.validateMove("-a2").complete).to.equal(0); + expect(play(scene(), ["-a2"]).lastmove).to.equal("pass,-a2"); + expect(g.validateMove("pass,-a1").message).to.contain("no stone"); + expect(g.validateMove("pass,-zz9").valid).to.be.false; + const k = play(small(["kill-all"]), ["f6", "attacker"]); + expect(k.validateMove("pass,-f6").valid).to.be.false; + // In a two-placement move, one placement suffices, its pass spelled out or not. + const h = play(scene(), ["f7"]); + expect(h.validateMove("e5,pass,-a2").valid).to.be.true; + expect(h.validateMove("e5,-a2").valid).to.be.true; + expect(h.validateMove("e5,-a2").complete).to.equal(0); + expect(h.validateMove("e5,g7,-a2").valid).to.be.false; + expect(h.validateMove("pass,e5,-a2").valid).to.be.true; + expect(play(scene(), ["f7", "e5,-a2"]).lastmove).to.equal("e5,pass,-a2"); + }); + + it("shows marked strings faded, with the territory they would leave", () => { + const g = scene(); + g.move("pass,-k10,-j11", { partial: true }); + expect(g.marks).to.deep.equal(["j11", "k10"]); + const rep = g.render(); + const legend = rep.legend as Record; + expect(legend.D2T1).to.deep.equal([ + { name: "piece", paint: stonePaint(2), opacity: 0.4 }, + { name: "piece-borderless", paint: { fill: 1 }, scale: 0.2 }, + ]); + expect((rep.pieces as string).split("\n")[0]).to.contain("D2T1"); + // With Player 2 gone, every empty point is Player 1's, the corner under the dead stones included. + const markers = dots(g); + expect(markers).to.have.lengthOf(1); + expect(markers[0].colour).to.equal(1); + expect(markers[0].points).to.have.lengthOf(121 - 7); + expect(g.getPlayerScore(1)).to.equal(121); + expect(g.getPlayerScore(2)).to.equal(0); + // A dead wall opens the corner to both colours, so the stones are faded without a dot. + const h = scene(); + h.move("pass,-a2", { partial: true }); + expect((h.render().legend as Record).D1).to.deep.equal([{ name: "piece", paint: stonePaint(1), opacity: 0.4 }]); + expect(dots(h).map((m) => m.colour)).to.deep.equal([2]); + expect(h.getPlayerScore(1)).to.equal(1); + // Hiding the territory keeps the marked stones faded, without dots. + expect(dots(g, { altDisplays: ["hide-territory"] })).to.have.lengthOf(0); + expect((g.render({ altDisplays: ["hide-territory"] }).legend as Record).D2).to.have.lengthOf(1); + // A typo in a mark names nothing when clicking, and is reported when validating. + expect(click(scene(), "pass,-zz9", "j11").move).to.equal("pass,-j11"); + }); + + it("takes the stone borders from palette slots 5 to 8 only under the display option", () => { + expect(ThueMorseGoGame.gameinfo.displays!.map((d) => d.uid)).to.include("palette-borders"); + const g = play(scene(), ["pass,-k10"]); + const paintOf = (legend: Record, key: string, layer = 0): unknown => (legend[key][layer] as { paint: unknown }).paint; + // By default no slot above 2 is named, so a two-colour palette suffices. + const plain = g.render().legend as Record; + expect(paintOf(plain, "A")).to.deep.equal(stonePaint(1)); + expect(paintOf(plain, "B")).to.deep.equal(stonePaint(2)); + expect(JSON.stringify(plain)).to.not.match(/"border":[3-9]/); + // Under the option every stone, faded or in the tracker, names the slot four above its fill. + const custom = g.render({ altDisplays: ["palette-borders"] }).legend as Record; + expect(paintOf(custom, "A")).to.deep.equal(stonePaint(1, true)); + expect(paintOf(custom, "B")).to.deep.equal(stonePaint(2, true)); + const faded = Object.keys(custom).filter((key) => key.startsWith("D2")); + expect(faded).to.have.lengthOf(1); + expect(paintOf(custom, faded[0])).to.deep.equal({ fill: 2, border: 6 }); + expect(paintOf(custom, "s1", 1)).to.deep.equal(stonePaint(1, true)); + expect(paintOf(custom, "s2", 1)).to.deep.equal(stonePaint(2, true)); + expect(JSON.stringify(custom)).to.not.contain("bestContrast"); + }); + + it("ends the game when the opponent passes keeping the marking, removing the marked strings", () => { + const g = play(scene(), ["pass,-j11,-k10"]); + expect(g.gameover).to.be.false; + expect(g.lastmove).to.equal("pass,-j11,-k10"); + expect(g.marks).to.deep.equal(["j11", "k10"]); + expect(g.sidebarStatuses().some((s) => JSON.stringify(s.key).includes("MARKING"))).to.be.true; + // The opponent's pass starts from that marking. + expect(g.getButtons()[0].move).to.equal("pass,pass,-j11,-k10"); + expect(g.moves()).to.include("pass,pass,-j11,-k10"); + expect(g.moves()).to.not.include("pass,pass"); + const accept = g.validateMove("pass,pass,-j11,-k10"); + expect(accept.complete).to.equal(0); + expect(accept.message).to.contain("end the game"); + g.move("pass,pass,-j11,-k10"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + expect(g.board.has("k10")).to.be.false; + expect(g.board.has("j11")).to.be.false; + expect(g.results).to.deep.include({ type: "remove", where: "j11,k10", num: 2, how: "dead" }); + expect(g.getPlayerScore(1)).to.equal(121); + expect(dots(g)[0].points).to.have.lengthOf(121 - 5); + // Reloading keeps the marking. + const again = new ThueMorseGoGame(play(scene(), ["pass,-j11,-k10"]).serialize()); + expect(again.marks).to.deep.equal(["j11", "k10"]); + expect(again.render()).to.deep.equal(play(scene(), ["pass,-j11,-k10"]).render()); + }); + + it("lets players change the marking back and forth until a placement or a plain pass ends marking", () => { + // Player 1 marks Player 2's stones; the players then change the marking in turn as often as they like. + const g = play(scene(), ["pass,-j11,-k10"]); + const change = g.validateMove("pass,pass,-k10"); + expect(change.complete).to.equal(0); + expect(change.message).to.contain("game goes on"); + expect(change.message).to.contain("change it again"); + play(g, ["pass,pass,-k10", "pass,-j11,-k10", "pass,-k10", "pass,pass,-j11,-k10"]); + expect(g.locked).to.be.false; + expect(g.gameover).to.be.false; + expect(g.marks).to.deep.equal(["j11", "k10"]); + // Keeping the marking ends the game with the marked strings removed. + const kept = g.clone().move("pass,pass,-j11,-k10"); + expect(kept.gameover).to.be.true; + expect(kept.board.has("j11")).to.be.false; + expect(kept.board.has("k10")).to.be.false; + // A plain pass instead ends marking: the game goes on, and the opponent's next pass ends it with every stone alive. + expect(g.validateMove("").message).to.contain("2 stones are marked dead"); + const clears = g.validateMove("pass,pass"); + expect(clears.complete).to.equal(0); + expect(clears.message).to.contain("ends dead-stone marking"); + const p = g.clone().move("pass,pass"); + expect(p.gameover).to.be.false; + expect(p.locked).to.be.true; + expect(p.marks).to.deep.equal([]); + expect(p.results).to.deep.include({ type: "select", what: "dead", how: "cleared" }); + expect(p.results).to.deep.include({ type: "select", what: "dead", how: "locked" }); + expect(p.sidebarStatuses().some((s) => JSON.stringify(s.value).includes("MARKING_LOCKED"))).to.be.true; + expect(p.validateMove("pass,-k10").valid).to.be.false; + const lockedClick = click(p, "pass", "j11"); + expect(lockedClick.valid).to.be.false; + expect(lockedClick.message).to.contain("Capture any dead stones"); + expect(p.getButtons()[0].move).to.equal("pass,pass"); + // A whole-move pass of two placements stays open, so that a stone can be placed for one of them. + const ends = p.validateMove("pass,pass"); + expect(ends.complete).to.equal(0); + expect(ends.message).to.contain("end the game"); + expect(p.clone().move("e5").gameover).to.be.false; + p.move("pass,pass"); + expect(p.gameover).to.be.true; + expect(p.board.has("j11")).to.be.true; + expect(p.results.some((r) => r.type === "remove")).to.be.false; + // A move that passes nothing ends marking, as does one that passes but marks nothing dead. + const placing = g.validateMove("e5,g7"); + expect(placing.valid).to.be.true; + expect(placing.message).to.contain("ends dead-stone marking"); + const placed = g.clone().move("e5,g7"); + expect(placed.locked).to.be.true; + expect(placed.passes).to.deep.equal([]); + expect(placed.results).to.deep.include({ type: "select", what: "dead", how: "locked" }); + expect(g.validateMove("e5").message).to.contain("ends dead-stone marking"); + expect(g.validateMove("e5,pass").message).to.contain("ends dead-stone marking"); + const q = g.clone().move("e5,pass"); + expect(q.locked).to.be.true; + expect(q.marks).to.deep.equal([]); + expect(q.passes[q.passes.length - 1]).to.equal(2); + expect(q.results).to.deep.include({ type: "select", what: "dead", how: "cleared" }); + expect(q.results).to.deep.include({ type: "select", what: "dead", how: "locked" }); + // It still counts as a pass: the opponent's whole-move pass ends the game with every stone alive. + expect(q.validateMove("pass,pass").complete).to.equal(0); + expect(q.validateMove("pass,pass").message).to.contain("end the game with every stone alive"); + expect(q.validateMove("e5,pass").valid).to.be.false; + expect(q.validateMove("d4,pass").message).to.contain("pass the rest of this move"); + expect(q.clone().move("d4,pass").gameover).to.be.false; + q.move("pass,pass"); + expect(q.gameover).to.be.true; + expect(q.board.has("e5")).to.be.true; + expect(q.board.has("j11")).to.be.true; + // A move that places a stone and passes keeping the marking goes on marking. + const keeps = g.clone().move("e5,pass,-j11,-k10"); + expect(keeps.locked).to.be.false; + expect(keeps.marks).to.deep.equal(["j11", "k10"]); + expect(keeps.lastmove).to.equal("e5,pass,-j11,-k10"); + expect(keeps.results.some((r) => r.type === "select")).to.be.false; + expect(keeps.gameover).to.be.false; + expect(keeps.validateMove("pass,pass,-j11,-k10").message).to.contain("end the game"); + expect(keeps.clone().move("pass,pass,-j11,-k10").gameover).to.be.true; + expect(keeps.clone().move("d4,pass,-j11,-k10").gameover).to.be.false; + // Taking the button for a placement is the same. + expect(g.clone().move("button,pass").locked).to.be.true; + expect(g.clone().move("button,pass,-j11,-k10").locked).to.be.false; + // A handicap declaration that gives away every placement of the move answers the marking too. + const declared = small(["handicap"]); + setStones(declared, ["f6"], []); + play(declared, ["pass,-f6", "2"]); + expect(declared.locked).to.be.true; + expect(declared.marks).to.deep.equal([]); + expect(declared.results).to.deep.include({ type: "select", what: "dead", how: "locked" }); + // Once ended, even after play resumes, passes carry no marking. + const h = play(scene(), ["pass,-j11,-k10", "pass,e5", "f7"]); + expect(h.locked).to.be.true; + expect(h.validateMove("pass,-j11").valid).to.be.false; + expect(click(h, "pass", "j11").valid).to.be.false; + }); + + it("keeps marking available while no marking has been answered by a placement or a plain pass", () => { + // A plain pass before any marking does not end marking: the opponent may still mark. + const g = play(scene(), ["pass", "pass,pass,-k10"]); + expect(g.locked).to.be.false; + expect(g.marks).to.deep.equal(["k10"]); + expect(g.results).to.deep.include({ type: "select", what: "dead", how: "marked", where: "k10" }); + expect(g.validateMove("pass,-k10").message).to.contain("end the game"); + // Neither does a placement before any marking. + const h = play(scene(), ["pass", "e5,pass", "pass,-k10"]); + expect(h.locked).to.be.false; + expect(h.marks).to.deep.equal(["k10"]); + expect(h.validateMove("").message).to.contain("One stone is marked dead"); + // Two plain passes in a row end the game as they always did. + expect(play(scene(), ["pass", "pass,pass"]).gameover).to.be.true; + }); + + it("ends the reported game only when a pass keeps the marking", () => { + // Each player changed the other's marking in turn; the fourth pass is answered on its merits. + const g = play(sixteen(), ["j13", "h8,g6", "e11", "d12", "pass,pass,-j13,-e11", "pass,pass,-j13", "pass,pass,-j13,-e11"]); + expect(g.locked).to.be.false; + expect(g.marks).to.deep.equal(["j13", "e11"]); + expect(g.getButtons()[0].move).to.equal("pass,-j13,-e11"); + // Keeping it ends the game with both stones removed. + const kept = g.clone().move("pass,-j13,-e11"); + expect(kept.gameover).to.be.true; + expect([...kept.board.keys()].sort()).to.deep.equal(["d12", "g6", "h8"]); + // Changing it goes on; clearing it ends marking, and the next pass ends the game with every stone alive. + expect(g.clone().move("pass,-j13").gameover).to.be.false; + const cleared = g.clone().move("pass"); + expect(cleared.gameover).to.be.false; + expect(cleared.locked).to.be.true; + cleared.move("pass"); + expect(cleared.gameover).to.be.true; + expect([...cleared.board.keys()].sort()).to.deep.equal(["d12", "e11", "g6", "h8", "j13"]); + }); + + it("carries the marking across served handicap passes, which never answer it", () => { + const g = play(small(["handicap"]), ["f6", "3", "pass,-f6"]); + expect(g.currplayer).to.equal(1); + expect(g.marks).to.deep.equal(["f6"]); + expect(g.getButtons()[0].move).to.equal("pass,pass,-f6"); + g.move("pass,pass,-f6"); + expect(g.gameover).to.be.false; + expect(g.locked).to.be.false; + g.move("pass,-f6"); + expect(g.gameover).to.be.true; + expect(g.board.size).to.equal(0); + expect(g.winner).to.deep.equal([1, 2]); + }); + + it("reports markings in the chat log", () => { + const g = scene(); + play(g, ["pass,-j11,-k10", "pass,pass,-k10", "pass,-j11,-k10", "pass,-k10", "pass,pass", "pass,pass"]); + expect(g.gameover).to.be.true; + assertChatLogParity(g, ["Alice", "Bob"]); + const log = g.chatLog(["Alice", "Bob"]).flat().join("\n"); + expect(log).to.contain("Alice marked 2 stones as dead."); + expect(log).to.contain("Bob changed the marking: one stone is marked dead."); + expect(log).to.contain("Alice changed the marking: 2 stones are marked dead."); + expect(log).to.contain("Alice cleared the marking: no stones are marked dead."); + expect(log).to.contain("Dead-stone marking has ended"); + expect(log).to.not.contain("removed from the board"); + expect(log).to.contain("both players passed consecutively"); + // A game that ends by accepting a marking removes the marked stones. + const h = play(scene(), ["pass,-j11,-k10", "pass,pass", "pass"]); + expect(h.gameover).to.be.true; + expect(h.chatLog(["Alice", "Bob"]).flat().join("\n")).to.not.contain("removed from the board"); + const k = play(scene(), ["pass,-k10", "pass,pass,-k10"]); + expect(k.chatLog(["Alice", "Bob"]).flat().join("\n")).to.contain("The stone marked dead was removed from the board."); + }); +}); + +describe("Thue-Morse Go: boards and star points", () => { + const stars = (g: ThueMorseGoGame): MarkerDots | undefined => + (((g.render().board as BoardBasic).markers ?? []) as MarkerDots[]).find((m) => m.type === "dots" && m.colour === undefined); + const corners = (lo: number, hi: number) => [{ row: lo, col: lo }, { row: lo, col: hi }, { row: hi, col: hi }, { row: hi, col: lo }]; + + it("offers a 19x19 board", () => { + const g = new ThueMorseGoGame(undefined, ["size-19"]); + expect(g.render().board).to.deep.include({ width: 19, height: 19 }); + expect(g.validateMove("s19").valid).to.be.true; + expect(g.validateMove("t19").valid).to.be.false; + expect(g.allvariants()!.find((v) => v.uid === "size-19")!.group).to.equal("board"); + }); + + it("puts the 11x11 star points on the 4-4 points without a centre", () => { + const g = small(); + expect(g.render().options).to.deep.equal(["hide-star-points"]); + expect(stars(g)).to.deep.equal({ type: "dots", size: 0.15, points: corners(3, 7) }); + // Other square boards keep the renderer's own star points. + for (const size of ["size-19", "size-23", undefined]) { + const h = new ThueMorseGoGame(undefined, size === undefined ? [] : [size]); + expect(h.render().options).to.be.undefined; + expect(stars(h)).to.be.undefined; + } + }); + + it("draws star points on snub square boards where the square boards have them", () => { + expect(stars(small(["snub"]))!.points).to.deep.equal(corners(3, 7)); + expect(stars(new ThueMorseGoGame(undefined, ["snub"]))!.points).to.deep.equal(corners(3, 12)); + const nineteen = stars(new ThueMorseGoGame(undefined, ["snub", "size-19"]))!.points; + expect(nineteen).to.have.lengthOf(9); + expect(nineteen).to.deep.include({ row: 9, col: 9 }); + expect(nineteen).to.deep.include({ row: 3, col: 9 }); + expect(stars(new ThueMorseGoGame(undefined, ["snub", "size-23"]))!.points).to.have.lengthOf(9); + expect(small(["snub"]).render().options).to.be.undefined; + }); +}); + +describe("Thue-Morse Go: reverse komi", () => { + const click = (g: ThueMorseGoGame, move: string, cell: string): IClickResult => { + const [x, y] = g.algebraic2coords(cell); + return g.handleClick(move, y, x); + }; + + it("begins Player 2's first move with a multiple of 14 given to Player 1, costing no placement", () => { + const g = small(["reverse-komi"]); + expect(g.allvariants()!.find((v) => v.uid === "reverse-komi")).to.deep.include({ unrated: true }); + expect(g.allvariants()!.some((v) => v.fans === true)).to.be.false; + expect(g.currplayer).to.equal(1); + expect(g.turnModel()).to.equal("sequential"); + g.move("f6"); + expect(g.getButtons()).to.deep.equal([]); + const empty = g.validateMove(""); + expect(empty.complete).to.equal(-1); + expect(empty.message).to.contain("reverse komi"); + expect(g.validateMove("e5").valid).to.be.false; + expect(g.validateMove("0").valid).to.be.false; + expect(g.validateMove("-14").valid).to.be.false; + expect(g.validateMove("43").valid).to.be.false; + // Any whole number up to the largest stone may be typed. + expect(g.validateMove("1").complete).to.equal(0); + expect(g.validateMove("7,e5").complete).to.equal(0); + // Placing one stone of the move passes the other, so the opponent's pass then ends the game. + expect(play(small(["reverse-komi"]), ["f6", "7,e5", "pass"]).getPlayerScore(1)).to.equal(1 + 7); + const alone = g.validateMove("14"); + expect(alone.complete).to.equal(0); + expect(alone.message).to.contain("14 points"); + expect(g.validateMove("42,e5").complete).to.equal(0); + expect(g.validateMove("28,e5,g7").complete).to.equal(1); + expect(g.validateMove("14,e5,g7,d4").valid).to.be.false; + expect(g.moves()).to.include("14p,e5,g7"); + expect(g.randomMove()).to.match(/^(14|28|42)p(,|$)/); + // The numbered stones offer 14, 28 and 42 across the centre row; f6 is covered but still means 28. + let pieces = g.render().pieces as string; + expect(pieces).to.contain("n14,").and.to.contain("n42"); + expect(pieces).to.not.contain("n28,").and.to.not.contain("n56"); + expect(click(g, "", "e6").move).to.equal("14p"); + expect(click(g, "", "f6").move).to.equal("28p"); + expect(click(g, "", "g6").move).to.equal("42p"); + const early = click(g, "", "e9"); + expect(early.valid).to.be.false; + expect(early.message).to.contain("reverse komi"); + // Once the komi is set the stones give way to the board, and clicks place, even on their cells. + pieces = g.clone().move("14", { partial: true }).render().pieces as string; + expect(pieces).to.not.contain("n14"); + expect(click(g, "14", "e5").move).to.equal("14p,e5"); + expect(click(g, "14p", "e6").move).to.equal("14p,e6"); + expect(click(g, "14p,e5", "e5").move).to.equal("14p"); + expect(g.handleClick("14,e5", -1, -1, "_btn_pass").move).to.equal("14p,pass,pass"); + // Larger boards offer up to 14 times the handicap limit. + expect(play(sixteen(["reverse-komi"]), ["h8"]).validateMove("84").valid).to.be.true; + expect(play(sixteen(["reverse-komi"]), ["h8"]).validateMove("85").valid).to.be.false; + expect(play(new ThueMorseGoGame(undefined, ["size-23", "reverse-komi"]), ["l12"]).render().pieces as string).to.contain("n196"); + // Submitting the komi alone passes the move. + const passed = g.clone().move("14"); + expect(passed.lastmove).to.equal("14p,pass,pass"); + expect(passed.passes).to.deep.equal([2]); + expect(passed.komi).to.equal(14); + g.move("28p,e5,g7"); + expect(g.komi).to.equal(28); + expect(g.placed).to.equal(3); + expect(g.currplayer).to.equal(1); + expect(g.lastmove).to.equal("28p,e5,g7"); + expect(g.results.slice(0, 2)).to.deep.equal([{ type: "komi", value: 28 }, { type: "place", where: "e5" }]); + expect(g.getPlayerScore(1)).to.equal(1 + 28); + expect(g.getPlayerScore(2)).to.equal(2); + expect(g.getButtons().map((b) => b.move)).to.deep.equal(["pass", "button"]); + // The move table stays sequential, and reloading keeps the komi. + expect(g.getRounds()).to.deep.equal([[{ move: "f6", result: [{ type: "place", where: "f6" }] }, { move: "28p,e5,g7", result: g.results }]]); + const again = new ThueMorseGoGame(g.serialize()); + expect(again.komi).to.equal(28); + expect(again.sidebarStatuses().some((s) => JSON.stringify(s).includes("\"28\""))).to.be.true; + expect(again.validateMove("f7").valid).to.be.true; + }); + + it("counts the komi for Player 1 at the end of the game and in the chat log", () => { + const g = play(small(["reverse-komi"]), ["f6", "14", "pass"]); + expect(g.gameover).to.be.true; + expect(g.getPlayerScore(1)).to.equal(121 + 14); + expect(g.getPlayerScore(2)).to.equal(0); + expect(g.winner).to.deep.equal([1]); + assertChatLogParity(g, ["Alice", "Bob"]); + const log = g.chatLog(["Alice", "Bob"]).flat().join("\n"); + expect(log).to.contain("Bob gave their opponent a reverse komi of 14 points."); + expect(log).to.contain("Bob passed."); + // One stone each is level, so the komi decides. + expect(play(small(["reverse-komi"]), ["f6", "14,e5", "pass"]).winner).to.deep.equal([1]); + expect(play(small(), ["f6", "e5", "pass"]).winner).to.deep.equal([1, 2]); + }); + + it("takes the komi and the handicap in either order, both open to clicks until a placement remains", () => { + const g = play(small(["reverse-komi", "handicap"]), ["f6"]); + expect(g.validateMove("").message).to.contain("in either order"); + const komiOnly = g.validateMove("14"); + expect(komiOnly.complete).to.equal(-1); + expect(komiOnly.message).to.contain("lower row"); + const handicapOnly = g.validateMove("3s"); + expect(handicapOnly.complete).to.equal(-1); + expect(handicapOnly.message).to.contain("upper row"); + // Tagged tokens come in either order; bare numbers fill the komi and then the handicap. + expect(g.validateMove("3s,14p").complete).to.equal(0); + expect(g.validateMove("3s,14").complete).to.equal(0); + expect(g.validateMove("14p,3s").complete).to.equal(0); + expect(g.validateMove("14,3").complete).to.equal(0); + expect(g.validateMove("14p,14p").valid).to.be.false; + expect(g.validateMove("3s,e5").valid).to.be.false; + expect(g.validateMove("14p,e5").valid).to.be.false; + expect(g.validateMove("e5").valid).to.be.false; + expect(play(small(["reverse-komi", "handicap"]), ["f6", "3s,14"]).lastmove).to.equal("14p,3s"); + // Komi stones in the row above the centre, handicap stones in the row below. + const pieces = g.render().pieces as string; + expect(pieces).to.contain("n14,").and.to.contain("n42").and.to.contain("n1,").and.to.contain("n3,"); + expect(click(g, "", "e7").move).to.equal("14p"); + expect(click(g, "", "e5").move).to.equal("1s"); + expect(click(g, "1s", "e7").move).to.equal("14p,1s"); + expect(click(g, "14", "e5").move).to.equal("14p,1s"); + expect(click(g, "14", "g5").move).to.equal("14p,3s"); + expect(click(g, "", "c3").message).to.contain("from each row"); + expect(click(g, "3s", "c3").message).to.contain("Choose the reverse komi first"); + expect(click(g, "14p", "c3").message).to.contain("Choose the handicap first"); + // Either choice can be changed while the stones show; a new handicap drops any placement. + expect(click(g, "14,3", "f7").move).to.equal("28p,3s"); + expect(click(g, "14,3", "f5").move).to.equal("14p,2s"); + // With a handicap of one the stones are gone, so f5 is a point: it replaces the placement. + expect(click(g, "14,1,c3", "f5").move).to.equal("14p,1s,f5"); + expect(click(g, "14,3", "c3").message).to.contain("no placement left"); + // A handicap of one leaves a placement, so the board takes over, the stones' cells included. + expect(click(g, "14,1", "c3").move).to.equal("14p,1s,c3"); + expect(click(g, "14,1", "e7").move).to.equal("14p,1s,e7"); + let partial = g.clone().move("14,3", { partial: true }).render(); + expect(partial.pieces as string).to.contain("n14,").and.to.contain("n1,"); + const [kx, ky] = g.algebraic2coords("e7"); + const [hx, hy] = g.algebraic2coords("g5"); + expect(partial.annotations).to.deep.include({ type: "enter", targets: [{ row: ky, col: kx }] }); + expect(partial.annotations).to.deep.include({ type: "enter", targets: [{ row: hy, col: hx }] }); + partial = g.clone().move("14,1", { partial: true }).render(); + expect(partial.pieces as string).to.not.contain("n14"); + expect(g.validateMove("14,3").complete).to.equal(0); + expect(g.validateMove("14,4").valid).to.be.false; + g.move("14,3"); + expect(g.komi).to.equal(14); + expect(g.handicap).to.equal(3); + expect(g.passesOwed).to.equal(1); + expect(g.placed).to.equal(3); + expect(g.currplayer).to.equal(1); + // The declaration serves placement 1 and a forced pass placement 2, as in a plain handicap move. + expect(g.lastmove).to.equal("14p,3s"); + g.move("e5"); + // Player 2's single placement 4 is served by the handicap. + expect(g.placed).to.equal(5); + expect(g.currplayer).to.equal(1); + expect(g.passesOwed).to.equal(0); + expect(g.getPlayerScore(1)).to.equal(121 + 14); + expect(g.getPlayerScore(2)).to.equal(0); + const one = play(small(["reverse-komi", "handicap"]), ["f6", "28,1,e5"]); + expect(one.komi).to.equal(28); + expect(one.board.get("e5")).to.equal(2); + expect(new ThueMorseGoGame(undefined, ["kill-all", "reverse-komi"]).variants).to.deep.equal(["reverse-komi"]); + expect(new ThueMorseGoGame(undefined, ["reverse-komi", "kill-all"]).variants).to.deep.equal(["kill-all"]); + expect(g.randomMove()).to.match(/^[a-k]\d+/); + }); +});