diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 33b40436..e485a890 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -135,6 +135,7 @@ "irensei": "Irensei (Japanese: 囲連星) is a mixture of Go and Gomoku. Get a seven in a row inside the middle 15x15 region of the Go board to win. To reduce the first player's advantage, the first player loses if they make an overline (even if it extends beyond the 15x15 region), but the second player may win by overline.", "jacynth": "A Decktet card game where opponents vye for control of the city of Jacynth. Place cards, exert influence, and control the most area to win.", "kachit": "A chess-like game played on a 4x4 board where you try to eliminate the opposing king or promote your own. Pieces can change their orientation after moving.", + "killallgo": "A Go variant with asymmetric goals: the Attacker starts with stones on the board and must capture everything, while the Defender wins by making one string that can never be captured.", "knightline": "A four-in-a-row connection game played by splitting a stack of knights on an unbounded grid.", "konane": "A traditional Hawaiian game that was almost completely lost to time but for the memory of a single woman, Kaʻahaʻaina Naihe of Kailua-Kona. Played on a rectangular board, each player captures by jumping over their opponent's pieces. Last to move wins.", "krypte": "A 4-in-a-row game where pieces enter from active sides that rotate after each turn, and placing a piece flips all adjacent pieces.", @@ -423,6 +424,7 @@ "irensei": "Irensei", "jacynth": "Jacynth", "kachit": "Kachit Knights", + "killallgo": "Kill-All Go", "knightline": "Knight Line", "konane": "Konane", "krypte": "Krypte", @@ -615,6 +617,7 @@ "intermedium": "Intermedium (aka, Stepping Stones) is a 1974 game designed by Matt Crispis. This is a stack sowing game, where players try to surround the opposing home-base. Stacks only move diagonally, and each army starts in a different diagonal color, so they can never cross over (like two bishops at different square colors). Pieces are captured by enclosure, surrounding all orthogonal adjacencies of an opponent stack.\n\nSome words from the author: \"[Intermedium] is simple to learn, but may lead to complicated situations that require deep analysis and forward thinking. Single pieces do no move, but they are useful a stepping stones for your captures. And they may become alive by putting another piece over them. Before you master the game, avoid early attacks against the City that may prove costly. Build up your strength gradually and evenly by dominating, if possible, the center. You should also avoid building 'formidable' stacks of six or more pieces - if taken you may lose the game at one stroke. Leave some forces behind to defend your City in case of emergency.\"", "invector": "Invector, designed by Mark Steere in 2026, is an elimination game inspired by the ancient Hawiian game of Kōnane. Invector begins with a checkerboard pattern of stones, has short range captures, and is extremely simple. Invector simply combines two basic, off-the-shelf ingredients: orthogonal, adjacent capturing moves, and orthogonal, adjacent moves closer to center.", "jacynth": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com). Cards in players' hands are hidden from observers and opponents.", + "killallgo": "The Attacker, whose stones are already on the board when normal play begins, wins by preventing the Defender from ever making a living string. The Defender wins by making one string that cannot be captured. There is no scoring.\n\nStones are placed and captured as in Go with Tromp-Taylor clearing: after a placement, opponent strings without liberties are removed, then your own string if it has none, so multi-stone suicide is legal. Positional superko applies: no placement may recreate any earlier position of the board. Either player may pass; if both pass in a row, the Attacker wins.\n\nThe Defender wins as soon as one of their strings is unconditionally alive: pass-alive by Benson's algorithm, meaning the Attacker could never capture it even with unlimited consecutive moves. That is the only way to win as the Defender. A string that is merely alive in seki or in ko does not count, so the Defender must keep playing and turn it into a string that cannot be captured at all.\n\nThe Attacker wins as soon as that has become impossible. Stones of the Attacker that are unconditionally alive can never be removed, and no point next to one of them can ever lie in an eye of a living Defender string. When no area left over by those stones holds two points that are neither next to each other nor next to those stones, there is no room anywhere for the two eyes a living string needs, and the game ends. The check does not recognise every lost position; when it does not trigger, play simply continues.\n\nOpenings: with Pieboxing, the players take turns placing Attacker stones until one of them takes the Attacker side; the other player then moves first as the Defender. The handicap opening works the same way, except that Player 1 (the stronger player, so seat the handicap giver first when creating the challenge) first sets the number of handicap stones and Player 2 starts placing; if Player 2 takes the Attacker side, they place that many extra stones at once. The classic opening uses the traditional 17-stone position on the 19x19 board, with the Defender (Player 1) to move. In the simple pie, Player 1 places any number of Attacker stones and Player 2 chooses a side. In the generalized Hoctaph's pie, Player 1 chooses two batch sizes (each at least 1, together at most the number of points minus 2, neither more than twice the other), Player 2 decides who places the first batch, the other player then chooses a side, and the Attacker places the second batch. In every opening the Defender makes the first normal move.", "konane": "Several competing opening protocols exist, but the most common ruleset is the Naihe Ruleset, used by tournaments at the Bishop Museum in Hawaii and described in the BGG reference. This is what is implemented here.", "knightline": "For convenience, your stack is split approximately in half on your first click. Subsequent clicks on the source or target stack move one piece at a time. When done, click on an empty cell or use the Complete Move button.\n\nThe coordinate system is adapted from the coder's coordinate system for Hive, a game on an unbounded (but still reasonably sized) hex board. The y axis is labeled with Cartesian coordinates (positive and negative integers). The x axis is labeled with letters in alphabetical order, with the origin at (m, 0). If motion on the x-axis exceeds the confines of the alphabet in either direction, the letters are prefixed with another letter (z is followed by aa, ab, ac, etc., and a is preceded by zz, zy, zx, etc.). The game is not expected to reach a width that requires more double coordinates, but for the record, az is followed by ba, za is preceded by yz, and so forth. The board cannot reach a size that would result in ambiguity.", "lasca": "Maneuverability is a measure of how close your pieces are to promoting. Your maximum maneuverability is the board size times the number of stacks you control.\n\nMaterial is calculated by giving you a point for every friendly piece in a stack you control, plus an extra point if you have an officer on top of that stack.", @@ -2342,6 +2345,37 @@ "description": "The board starts with six cards in a circular pattern." } }, + "killallgo": { + "size-9": { + "name": "9x9 board" + }, + "size-13": { + "name": "13x13 board" + }, + "#board": { + "name": "19x19 board" + }, + "#opening": { + "name": "Pieboxing", + "description": "Players take turns placing Attacker stones until one of them takes the Attacker side; the other player then moves first as the Defender. Pieboxing, devised by MXHero." + }, + "handicap": { + "name": "Handicap", + "description": "Pieboxing with a handicap. Player 1 (seat the stronger player first) sets the number of extra Attacker stones, then Player 2 starts placing. If Player 2 takes the Attacker side, they place the extra stones at once. Devised by MXHero." + }, + "classic": { + "name": "Classic 17-stone setup", + "description": "The traditional 17-stone starting position; the Defender (Player 1) moves first. 19x19 only." + }, + "pie": { + "name": "Simple pie", + "description": "Player 1 places any number of Attacker stones, then Player 2 chooses a side. The usual pie rule." + }, + "hoctaph": { + "name": "Generalized Hoctaph's pie", + "description": "Player 1 chooses two batch sizes, Player 2 chooses who places the first batch, the other player chooses a side, and the Attacker places the second batch. Hoctaph's pie (OGS forums), generalized to every board size by Samraku." + } + }, "knightline": { "#setup": { "name": "Default setup", @@ -4916,6 +4950,23 @@ "STACKSIZES": "Stack heights", "tumbleweed": "Score (pieces + influence)", "ESTIMATEDSCORES": "Estimated scores", + "killallgo": { + "ATTACKER": "Attacker", + "DEFENDER": "Defender", + "UNDECIDED": "Not yet chosen", + "HANDICAP": "Handicap stones", + "BATCHES": "Batch sizes", + "BATCHES_VALUE": "{{a}} then {{b}}", + "PHASE_HAND_N": "{{player}} sets the number of handicap stones", + "PHASE_ALT_PLACE": "{{player}} places an Attacker stone or takes the Attacker side", + "PHASE_PIE_SLICE": "{{player}} places the opening Attacker stones", + "PHASE_PIE_CHOOSE": "{{player}} chooses a side", + "PHASE_HOC_SLICE": "{{player}} sets the two batch sizes", + "PHASE_HOC_OPTION": "{{player}} decides who places the first batch", + "PHASE_HOC_BATCH_A": "{{player}} places the first batch of Attacker stones", + "PHASE_HOC_CHOOSE": "{{player}} chooses a side", + "PHASE_HOC_BATCH_B": "{{player}} places the second batch of Attacker stones" + }, "TOPLACE": "Piece to place", "valley": "Moon Tokens", "waldmeister": { @@ -6389,6 +6440,52 @@ "PARTIAL_destination": "Select a destination.", "PARTIAL_orientation": "Select a neighbouring cell to orient the piece." }, + "killallgo": { + "INSTRUCTIONS_HANDICAP": "You are giving the handicap. Type the number of extra Attacker stones (1 to {{max}}) your opponent will place if they take the Attacker side.", + "HANDICAP_INVALID": "The handicap must be a whole number.", + "HANDICAP_RANGE": "The handicap must be between 1 and {{max}} stones.", + "HANDICAP_OK_one": "Handicap of one stone. Submit, or keep typing.", + "HANDICAP_OK_other": "Handicap of {{count}} stones. Submit, or keep typing.", + "INSTRUCTIONS_ALT": "Place an Attacker stone, or take the Attacker side; your opponent then moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Place an Attacker stone, or take the Attacker side and then place one more stone; your opponent then moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Place an Attacker stone, or take the Attacker side and then place {{count}} more stones; your opponent then moves first as the Defender.", + "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.", + "DEFENDER_NO_STONES": "Choosing the Defender side does not involve placing stones now.", + "STONES_LEFT_one": "Place one more Attacker stone.", + "STONES_LEFT_other": "Place {{count}} more Attacker stones.", + "BATCH_TOO_MANY_one": "This batch holds exactly one stone.", + "BATCH_TOO_MANY_other": "This batch holds exactly {{count}} stones.", + "BATCH_MORE_OR_SUBMIT": "Add more Attacker stones, or submit.", + "DUPLICATE_CELL": "The cell {{where}} is listed twice.", + "INVALID_PASS": "You cannot pass now.", + "NO_CLICKS_NOW": "Type your answer in the move box; the board is not clickable yet.", + "INSTRUCTIONS_PIE_SLICE": "Place any number of Attacker stones up to {{max}} (or pass to place none); your opponent will then choose a side.", + "PIE_SLICE_MAX": "You may place at most {{max}} Attacker stones this way.", + "INSTRUCTIONS_PIE_CHOOSE": "Choose a side: take the Attacker stones, or place your first stone as the Defender.", + "INSTRUCTIONS_PIE_DEFENDER_STONE": "Place your first Defender stone.", + "INSTRUCTIONS_HOC_SLICE": "Click a numbered stone on the left to size the first batch and one on the right to size the second, or type both sizes as a,b: each at least 1, together at most {{max}}, and neither more than twice the other.", + "SLICE_FORMAT": "Type the two batch sizes as two whole numbers separated by a comma, for example 20,15.", + "SLICE_MIN": "Each batch must hold at least one stone.", + "SLICE_SUM": "Together the two batches may hold at most {{max}} stones.", + "SLICE_RATIO": "Neither batch may be more than twice the size of the other.", + "SLICE_A_ONLY_one": "First batch of one stone. Now choose the size of the second batch.", + "SLICE_A_ONLY_other": "First batch of {{count}} stones. Now choose the size of the second batch.", + "SLICE_B_ONLY_one": "Second batch of one stone. Now choose the size of the first batch.", + "SLICE_B_ONLY_other": "Second batch of {{count}} stones. Now choose the size of the first batch.", + "SLICE_NOT_A_BUTTON": "Click one of the numbered stones: the block on the left sizes the first batch, the block on the right the second. Larger sizes can still be typed.", + "SLICE_OK": "Batches of {{a}} and {{b}} stones. Submit, or keep typing.", + "INSTRUCTIONS_HOC_OPTION_one": "Decide who places the first batch of one Attacker stone: place it yourself (your opponent then chooses a side), or let your opponent place it (you then choose a side).", + "INSTRUCTIONS_HOC_OPTION_other": "Decide who places the first batch of {{count}} Attacker stones: place them yourself (your opponent then chooses a side), or let your opponent place them (you then choose a side).", + "INSTRUCTIONS_HOC_BATCH_one": "Place one Attacker stone.", + "INSTRUCTIONS_HOC_BATCH_other": "Place {{count}} Attacker stones.", + "INSTRUCTIONS_HOC_CHOOSE_one": "Choose a side. Taking the Attacker side means placing the second batch of one stone now; choosing the Defender side means your opponent places it and you then move first.", + "INSTRUCTIONS_HOC_CHOOSE_other": "Choose a side. Taking the Attacker side means placing the second batch of {{count}} stones now; choosing the Defender side means your opponent places them and you then move first.", + "INSTRUCTIONS_PLAY_ATTACKER": "Place a stone or pass. You win by leaving the Defender no room to make a string that can never be captured.", + "INSTRUCTIONS_PLAY_DEFENDER": "Place a stone or pass. You win by making a string that can never be captured.", + "KO_PSK": "That placement would recreate an earlier position of the board (positional superko).", + "PASS_WARNING": "Passing does not establish life. If the Attacker also passes, the Attacker wins." + }, "knightline": { "BAD_PASS": "You may not pass if legal moves are available.", "BAD_OPENING_MOVE": "The opening move in this variant must be a legal cell for initial stack placement.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 07150f02..7df8ace1 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -37,6 +37,7 @@ "playerSetup": "Player setup", "bearSetup": "Bear setup" }, + "killallgo_slice": "{{player}} set the batch sizes to {{a}} and {{b}} stones.", "scribe_super": "{{player}}'s super formation: {{formation}}.", "stawvs": "{{player}} was unable to move and must pass from now on. Their pieces remain on the following pyramids: {{pyramids}}." }, @@ -90,6 +91,8 @@ "elOso_one": "{{player}} annihilated one piece at {{where}}.", "elOso_other": "{{player}} annihilated {{count}} pieces at {{where}}.", "hexentafl_king": "The king was captured at {{where}}.", + "killallgo_suicide_one": "{{player}} removed their own stone by suicide.", + "killallgo_suicide_other": "{{player}} removed {{count}} of their own stones by suicide.", "lasca_officer": "{{player}} captured an officer at {{where}}.", "lasca_soldier": "{{player}} captured a soldier at {{where}}.", "lifeline": "Dead pieces were removed at {{where}}.", @@ -168,6 +171,8 @@ "fnap_row": "{{player}} claimed row {{where}}.", "frogger": "{{player}} was blocked and drew {{card}} from the draw pool.", "jacynth": "{{player}} exerted influence at {{where}}.", + "killallgo_attacker": "{{player}} chose to play as the Attacker.", + "killallgo_defender": "{{player}} chose to play as the Defender.", "logger": "{{player}} claimed a protestor from the tree felled at {{where}}.", "magnate": "{{who}} gained {{what}}.", "magnate_initial": "Players collected their Crown resources.", @@ -218,6 +223,8 @@ "biscuit": "{{player}} has gone out!", "emu": "Year {{year}} has ended.", "frogger": "The crocodiles advanced.", + "killallgo_handicap_one": "{{player}} set the handicap to one stone.", + "killallgo_handicap_other": "{{player}} set the handicap to {{count}} stones.", "mchess": "{{player}} has \"called the clock.\" If no capture is made in the next seven turns, the game will end and be scored.", "renju": "{{player}} has declared {{count}} tentative fifths." }, @@ -293,7 +300,10 @@ "tafl_king_captured": "The game has ended because the king was captured.", "tafl_king_escaped": "The game has ended because the king escaped.", "unlur_foul": "The game has ended because one player has achieved the opponent's win condition without achieving their own.", - "druid_hex": "The game has ended because {{player}} connected the {{edges}} edges." + "druid_hex": "The game has ended because {{player}} connected the {{edges}} edges.", + "killallgo_double_pass": "The game has ended because both players passed without the Defender establishing life.", + "killallgo_no_room": "The game has ended because the Attacker's unconditionally alive stones leave the Defender no room to make a living string.", + "killallgo_pass_alive": "The game has ended because the Defender formed an unconditionally alive string." }, "DRUID_HEX_EDGE": { "N": "north (N)", @@ -486,6 +496,7 @@ "gorogo_henge": "{{player}} placed a henge piece at {{where}}.", "gorogo_piece": "{{player}} placed a piece at {{where}}.", "iqishiqi": "{{player}} placed a shared stone at {{where}}.", + "killallgo_setup": "{{player}} placed an Attacker stone at {{where}}.", "knightline": "{{player}} placed their initial stack at {{where}}.", "logger_logger": "{{player}} placed their logger at {{where}}.", "logger_plant": "{{player}} planted a sapling at {{where}}.", @@ -630,6 +641,8 @@ "SCORE_REPORT": "{{player}}'s score is now {{score}}.", "SELECT": { "fnap": "{{player}} selected the tile {{tile}}.", + "killallgo_iplace": "{{player}} chose to place the first batch.", + "killallgo_youplace": "{{player}} chose to let their opponent place the first batch.", "resolve": "{{player}} selects the piece in a crosscut at {{where}}.", "slyde_fixed_opponent": "{{player}} changed the state of their opponent's piece at {{where}} from mobile to fixed.", "slyde_fixed_self": "{{player}} changed the state of their own piece at {{where}} from mobile to fixed.", diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 98d5acaa..cacf1e14 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -3563,6 +3563,37 @@ "name": "Duluda matĉo" } }, + "killallgo": { + "size-9": { + "name": "Tabulo 9×9" + }, + "size-13": { + "name": "Tabulo 13×13" + }, + "#board": { + "name": "Tabulo 19×19" + }, + "#opening": { + "name": "Pieboxing", + "description": "La ludantoj laŭvice metas atakantajn ŝtonojn, ĝis unu el ili prenas la flankon de la atakanto; la alia ludanto tiam movas unue kiel la defendanto. Pieboxing, elpensita de MXHero." + }, + "handicap": { + "name": "Handikapo", + "description": "Pieboxing kun handikapo. Ludanto 1 (sidigu la pli fortan ludanton unua) fiksas la nombron de pliaj atakantaj ŝtonoj, poste Ludanto 2 komencas la metadon. Se Ludanto 2 prenas la flankon de la atakanto, tiu tuj metas la pliajn ŝtonojn. Elpensita de MXHero." + }, + "classic": { + "name": "Klasika aranĝo de 17 ŝtonoj", + "description": "La tradicia komenca pozicio de 17 ŝtonoj; la defendanto (Ludanto 1) movas unue. Nur 19×19." + }, + "pie": { + "name": "Simpla torto", + "description": "Ludanto 1 metas iun ajn nombron da atakantaj ŝtonoj, poste Ludanto 2 elektas flankon. La kutima tortoregulo." + }, + "hoctaph": { + "name": "Ĝeneraligita torto de Hoctaph", + "description": "Ludanto 1 elektas du grandojn de aroj, Ludanto 2 elektas, kiu metas la unuan aron, la alia ludanto elektas flankon, kaj la atakanto metas la duan aron. La torto de Hoctaph (forumoj de OGS), ĝeneraligita al ĉiu tabulgrando de Samraku." + } + }, "mirador": { "#board": { "name": "Tabulo 28×28" @@ -3704,6 +3735,23 @@ "GRADE_SUPERIOR": "Supera (11)", "GRADE_EXCELLENT": "Bonega (12)" }, + "killallgo": { + "ATTACKER": "Atakanto", + "DEFENDER": "Defendanto", + "UNDECIDED": "Ankoraŭ ne elektita", + "HANDICAP": "Handikapaj ŝtonoj", + "BATCHES": "Grandoj de la aroj", + "BATCHES_VALUE": "{{a}}, poste {{b}}", + "PHASE_HAND_N": "{{player}} fiksas la nombron de handikapaj ŝtonoj", + "PHASE_ALT_PLACE": "{{player}} metas atakantan ŝtonon aŭ prenas la flankon de la atakanto", + "PHASE_PIE_SLICE": "{{player}} metas la malfermajn atakantajn ŝtonojn", + "PHASE_PIE_CHOOSE": "{{player}} elektas flankon", + "PHASE_HOC_SLICE": "{{player}} fiksas la du grandojn de aroj", + "PHASE_HOC_OPTION": "{{player}} decidas, kiu metas la unuan aron", + "PHASE_HOC_BATCH_A": "{{player}} metas la unuan aron da atakantaj ŝtonoj", + "PHASE_HOC_CHOOSE": "{{player}} elektas flankon", + "PHASE_HOC_BATCH_B": "{{player}} metas la duan aron da atakantaj ŝtonoj" + }, "lielow": { "MOVESUNTILSUICIDE": "Movoj ĝis memmortigo" } @@ -4497,7 +4545,8 @@ "yonmoque": "Provu formi kvaropan vicon en ludo, en kiu ne ĉiuj spacoj estas egalaj kaj oni povas konverti kontraŭulajn pecojn.", "zola": "Ludo, en kiu la distanco de peco disde la centro de la tabulo limigas ĝian movadon. Kapta movo ne rajtas pliigi tiun distancon; senkapta movo devas ĝin pliigi. Gajnas la unua, kiu kaptas ĉiujn kontraŭulajn pecojn.", "bridges": "Pontoj (angle Bridges) estas ludo por du ludantoj sur sesangula tabulo, elpensita de Marc Rebillet. La ludantoj metas bazojn kaj kunligas ilin per pontoj. Venkas la ludanto, kiu havas plej multajn pontpecojn sur la tabulo je la ludofino. Okaze de egaleco venkas tiu kun la pli longa ponto.", - "elOso": "Solludo el la mondo de Carrisor. Movu viajn stakojn trans la supran randon de la tabulo en la Ĉielon, dum la Urso ĵetas ĵetkubojn kaj antaŭeniras de supre. Por venki, alportu almenaŭ dek pecojn en la Ĉielon." + "elOso": "Solludo el la mondo de Carrisor. Movu viajn stakojn trans la supran randon de la tabulo en la Ĉielon, dum la Urso ĵetas ĵetkubojn kaj antaŭeniras de supre. Por venki, alportu almenaŭ dek pecojn en la Ĉielon.", + "killallgo": "Ĉiomortiga Goo (angle Kill-All Go): variaĵo de Goo kun nesimetriaj celoj. La atakanto komencas kun ŝtonoj sur la tabulo kaj devas kapti ĉion, dum la defendanto venkas farante unu ĉenon, kiu neniam povas esti kaptita." }, "notes": { "akimbo": "Akimbo, projektita de Luis Bolaños Mures en 2026, estas konektludo sen remizoj por du ludantoj. _Nuda diagonalo_ estas paro de samkoloraj, diagonale apudaj pecoj, sen alia samkolora peco apuda al ambaŭ. _Kructranĉo_ estas areo 2×2 kun du interplektitaj nudaj diagonaloj de kontraŭaj koloroj.\n\nDum sia vico, la ludanto metas amikan pecon sur malplenan ĉelon. Se tio kompletigas kructranĉon, la alia peco en la kructranĉo estas forigita. Neniam rajtas esti pli ol unu nuda diagonalo de ĉiu koloro sur la tabulo — eĉ ne momente antaŭ la forigo de peco. Ludanto venkas se, je la fino de sia vico, ĉeno de ortangule konektitaj samkoloraj pecoj tuŝas la du kontraŭajn randojn de la tabulo apartenantajn al tiu ludanto.\n\nOkimba estas variaĵo, en kiu maksimume unu nuda diagonalo rajtas ekzisti sur la tabulo (do dum unu ludanto havas la diagonalon, la kontraŭulo ne povas krei alian).", @@ -4599,6 +4648,7 @@ "xana": "Dum sia vico, ludanto metas aŭ movas stakon (la nombro de stakigeblaj pecoj estas limigita) kaj laŭvole metas du murojn sur malplenajn seslaterojn. Stakoj sen liberecoj estas kaptitaj. Stako havas liberecon, se almenaŭ unu el la ses apudaj seslateroj estas malplena (ne ekzistas koncepto de stakgrupo). Seslatero estas atingebla, se ĝi estas malplena kaj vojo de malplenaj seslateroj konektas ĝin al amika stako. La celo estas akiri la plej altan sumon de teritoria kaj kapta poentaroj. Xana estis projektita en 2005.", "y": "Charles Titus kaj Craige Schensted (Ea Ea) projektis la ludon en 1953. Kiel rekta posteulo de Hex, ĝi origine estis ludata sur triangula tabulo, kaj la celo estis konekti ĉiujn tri flankojn per ĉeno de amikaj ŝtonoj. Y estas konsiderata pli fundamenta ludo ol Hex pro siaj pli simplaj reguloj kaj ĉar eblas aranĝi partion de Hex sur sufiĉe granda Y-tabulo. En sia libro de 1975 **Mudcrack Y & Poly-Y**, la projektintoj prezentis la _principon de ŝlimfendo_, laŭ kiu konektludoj povas okazi sur vasta gamo de iom post iom pli kompleksaj tabuloj. Unu el tiuj projektoj (\"la fleksita Y\", kun tri kvinangulaj ĉeloj) fariĝis la komerca Y-tabulo anstataŭ la norma triangula tabulo.", "elOso": "La komenca aranĝo estas aŭtomate farata kiam vi lanĉas la ludon. La flankpanelo montras la nombrojn de pecoj por la Ĉielo, la Kaverno, la Grundo kaj la Kavo. Post ĉiu movo, uzu la regilojn de filmeroj por trairi la respondon de la Urso paŝon post paŝo. Dek aŭ pli da pecoj en la Ĉielo signifas venkon.", + "killallgo": "La atakanto, kies ŝtonoj jam estas sur la tabulo, kiam la normala ludado komenciĝas, venkas malhelpante, ke la defendanto iam faru vivantan ĉenon. La defendanto venkas farante unu ĉenon, kiu ne povas esti kaptita. Poentado ne ekzistas.\n\nŜtonoj estas metataj kaj kaptataj kiel en Goo, kun la forigo laŭ Tromp-Taylor: post meto, la ĉenoj de la kontraŭulo sen liberecoj estas forigataj, poste via propra ĉeno, se ĝi havas neniun, do plurŝtona memmortigo estas permesita. Validas pozicia superko: neniu meto rajtas rekrei iun ajn pli fruan pozicion de la tabulo. Ambaŭ ludantoj rajtas preterpasi; se ambaŭ preterpasas sinsekve, la atakanto venkas.\n\nLa defendanto venkas tuj, kiam unu el la ĉenoj de la defendanto estas senkondiĉe viva: viva laŭ la algoritmo de Benson, tio estas, ke la atakanto neniam povus kapti ĝin, eĉ per senlime multaj sinsekvaj movoj. Tio estas la sola maniero venki kiel la defendanto. Ĉeno, kiu nur vivas en seki aŭ en koo, ne validas, do la defendanto devas daŭrigi la ludadon kaj fari el ĝi ĉenon, kiu tute ne povas esti kaptita.\n\nLa atakanto venkas tuj, kiam tio fariĝis neebla. Senkondiĉe vivaj ŝtonoj de la atakanto neniam povas esti forigitaj, kaj neniu punkto apud unu el ili iam povas kuŝi en okulo de vivanta ĉeno de la defendanto. Kiam neniu areo restanta ekster tiuj ŝtonoj enhavas du punktojn, kiuj estas nek apud unu la alia nek apud tiuj ŝtonoj, nenie plu estas spaco por la du okuloj, kiujn vivanta ĉeno bezonas, kaj la ludo finiĝas. La kontrolo ne rekonas ĉiun perditan pozicion; kiam ĝi ne ekagas, la ludado simple daŭras.\n\nMalfermoj: ĉe Pieboxing, la ludantoj laŭvice metas atakantajn ŝtonojn, ĝis unu el ili prenas la flankon de la atakanto; la alia ludanto tiam movas unue kiel la defendanto. La handikapa malfermo funkcias same, krom ke Ludanto 1 (la pli forta ludanto; do sidigu la donanton de la handikapo unua, kiam vi kreas la defion) unue fiksas la nombron de handikapaj ŝtonoj kaj Ludanto 2 komencas la metadon; se Ludanto 2 prenas la flankon de la atakanto, tiu tuj metas tiom da pliaj ŝtonoj. La klasika malfermo uzas la tradician pozicion de 17 ŝtonoj sur la tabulo 19×19, kaj la defendanto (Ludanto 1) movas unue. Ĉe la simpla torto, Ludanto 1 metas iun ajn nombron da atakantaj ŝtonoj kaj Ludanto 2 elektas flankon. Ĉe la ĝeneraligita torto de Hoctaph, Ludanto 1 elektas du grandojn de aroj (ĉiu almenaŭ 1, kune maksimume la nombro de punktoj minus 2, neniu pli ol duoble la alia), Ludanto 2 decidas, kiu metas la unuan aron, la alia ludanto tiam elektas flankon, kaj la atakanto metas la duan aron. En ĉiu malfermo la defendanto faras la unuan normalan movon.", "lielow": "La valoro \"Movoj ĝis memmortigo\" sumas por ĉiu ludanto, kiom da movoj ĉiu el ties stakoj ankoraŭ povus fari — kreskante je unu kun ĉiu movo kaj ĉiam elektante la direkton, laŭ kiu ĝi plej longe restas sur la tabulo — antaŭ ol ĝi devus elportiĝi. La kalkulo ignoras ĉiujn aliajn stakojn sur la tabulo, ĉu amikajn, ĉu malamikajn. Ĝi do estas nur proksimuma mezuro de la senŝirmeco de la ludpecoj, ne la efektiva nombro da restantaj validaj movoj." }, "homeworlds": { @@ -7722,6 +7772,52 @@ "INVALID_DIRECTION": "Stakoj povas moviĝi diagonale aŭ flanken, sed ne rekte antaŭen aŭ malantaŭen.", "TOO_FAR": "Ĉi tiu stako povas moviĝi je maksimume {{dist}} spacoj.", "SKY_FROM_TOP": "Nur stakoj sur la supra vico povas moviĝi en la Ĉielon." + }, + "killallgo": { + "INSTRUCTIONS_HANDICAP": "Vi donas la handikapon. Tajpu la nombron de pliaj atakantaj ŝtonoj (de 1 ĝis {{max}}), kiujn via kontraŭulo metos, se tiu prenos la flankon de la atakanto.", + "HANDICAP_INVALID": "La handikapo devas esti entjero.", + "HANDICAP_RANGE": "La handikapo devas esti inter 1 kaj {{max}} ŝtonoj.", + "HANDICAP_OK_one": "Handikapo de unu ŝtono. Sendu, aŭ daŭrigu la tajpadon.", + "HANDICAP_OK_other": "Handikapo de {{count}} ŝtonoj. Sendu, aŭ daŭrigu la tajpadon.", + "INSTRUCTIONS_ALT": "Metu atakantan ŝtonon, aŭ prenu la flankon de la atakanto; via kontraŭulo tiam movos unue kiel la defendanto.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Metu atakantan ŝtonon, aŭ prenu la flankon de la atakanto kaj poste metu unu plian ŝtonon; via kontraŭulo tiam movos unue kiel la defendanto.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Metu atakantan ŝtonon, aŭ prenu la flankon de la atakanto kaj poste metu {{count}} pliajn ŝtonojn; via kontraŭulo tiam movos unue kiel la defendanto.", + "SETUP_FULL": "La tabulo jam havas la maksimuman nombron da preparaj ŝtonoj, kiun ĉi tiu fazo permesas ({{max}}); prenu la flankon de la atakanto anstataŭ meti plian.", + "ATTACKER_NO_STONES": "Preni la flankon de la atakanto ne implicas meti ŝtonojn nun.", + "DEFENDER_NO_STONES": "Elekti la flankon de la defendanto ne implicas meti ŝtonojn nun.", + "STONES_LEFT_one": "Metu ankoraŭ unu atakantan ŝtonon.", + "STONES_LEFT_other": "Metu ankoraŭ {{count}} atakantajn ŝtonojn.", + "BATCH_TOO_MANY_one": "Ĉi tiu aro enhavas ekzakte unu ŝtonon.", + "BATCH_TOO_MANY_other": "Ĉi tiu aro enhavas ekzakte {{count}} ŝtonojn.", + "BATCH_MORE_OR_SUBMIT": "Aldonu pliajn atakantajn ŝtonojn, aŭ sendu.", + "DUPLICATE_CELL": "La ĉelo {{where}} estas listigita dufoje.", + "INVALID_PASS": "Vi ne povas preterpasi nun.", + "NO_CLICKS_NOW": "Tajpu vian respondon en la movokampon; la tabulo ankoraŭ ne estas alklakebla.", + "INSTRUCTIONS_PIE_SLICE": "Metu iun ajn nombron da atakantaj ŝtonoj ĝis {{max}} (aŭ preterpasu por meti neniun); via kontraŭulo tiam elektos flankon.", + "PIE_SLICE_MAX": "Vi rajtas meti maksimume {{max}} atakantajn ŝtonojn tiel.", + "INSTRUCTIONS_PIE_CHOOSE": "Elektu flankon: prenu la atakantajn ŝtonojn, aŭ metu vian unuan ŝtonon kiel la defendanto.", + "INSTRUCTIONS_PIE_DEFENDER_STONE": "Metu vian unuan defendantan ŝtonon.", + "INSTRUCTIONS_HOC_SLICE": "Alklaku numeritan ŝtonon maldekstre por difini la grandon de la unua aro kaj unu dekstre por la dua, aŭ tajpu ambaŭ grandojn kiel a,b: ĉiu almenaŭ 1, kune maksimume {{max}}, kaj neniu pli ol duoble la alia.", + "SLICE_FORMAT": "Tajpu la du grandojn de aroj kiel du entjerojn apartigitajn per komo, ekzemple 20,15.", + "SLICE_MIN": "Ĉiu aro devas enhavi almenaŭ unu ŝtonon.", + "SLICE_SUM": "Kune la du aroj rajtas enhavi maksimume {{max}} ŝtonojn.", + "SLICE_RATIO": "Neniu aro rajtas esti pli ol duoble tiel granda kiel la alia.", + "SLICE_A_ONLY_one": "Unua aro de unu ŝtono. Nun elektu la grandon de la dua aro.", + "SLICE_A_ONLY_other": "Unua aro de {{count}} ŝtonoj. Nun elektu la grandon de la dua aro.", + "SLICE_B_ONLY_one": "Dua aro de unu ŝtono. Nun elektu la grandon de la unua aro.", + "SLICE_B_ONLY_other": "Dua aro de {{count}} ŝtonoj. Nun elektu la grandon de la unua aro.", + "SLICE_NOT_A_BUTTON": "Alklaku unu el la numeritaj ŝtonoj: la bloko maldekstre difinas la grandon de la unua aro, la bloko dekstre tiun de la dua. Pli grandajn valorojn oni ankoraŭ povas tajpi.", + "SLICE_OK": "Aroj de {{a}} kaj {{b}} ŝtonoj. Sendu, aŭ daŭrigu la tajpadon.", + "INSTRUCTIONS_HOC_OPTION_one": "Decidu, kiu metas la unuan aron de unu atakanta ŝtono: metu ĝin mem (via kontraŭulo tiam elektos flankon), aŭ lasu vian kontraŭulon meti ĝin (vi tiam elektos flankon).", + "INSTRUCTIONS_HOC_OPTION_other": "Decidu, kiu metas la unuan aron de {{count}} atakantaj ŝtonoj: metu ilin mem (via kontraŭulo tiam elektos flankon), aŭ lasu vian kontraŭulon meti ilin (vi tiam elektos flankon).", + "INSTRUCTIONS_HOC_BATCH_one": "Metu unu atakantan ŝtonon.", + "INSTRUCTIONS_HOC_BATCH_other": "Metu {{count}} atakantajn ŝtonojn.", + "INSTRUCTIONS_HOC_CHOOSE_one": "Elektu flankon. Preni la flankon de la atakanto signifas meti nun la duan aron de unu ŝtono; elekti la flankon de la defendanto signifas, ke via kontraŭulo metas ĝin kaj vi tiam movas unue.", + "INSTRUCTIONS_HOC_CHOOSE_other": "Elektu flankon. Preni la flankon de la atakanto signifas meti nun la duan aron de {{count}} ŝtonoj; elekti la flankon de la defendanto signifas, ke via kontraŭulo metas ilin kaj vi tiam movas unue.", + "INSTRUCTIONS_PLAY_ATTACKER": "Metu ŝtonon aŭ preterpasu. Vi venkas, lasante al la defendanto neniun spacon por fari ĉenon, kiu neniam povas esti kaptita.", + "INSTRUCTIONS_PLAY_DEFENDER": "Metu ŝtonon aŭ preterpasu. Vi venkas farante ĉenon, kiu neniam povas esti kaptita.", + "KO_PSK": "Tiu meto rekreus pli fruan pozicion de la tabulo (pozicia superko).", + "PASS_WARNING": "Preterpaso ne establas vivon. Se ankaŭ la atakanto preterpasos, la atakanto venkos." } }, "INITIAL_UNDO": "Vi ne povas malfari la komencan pozicion de la ludo.", @@ -7864,6 +7960,7 @@ "irensei": "Irensei", "jacynth": "Jacynth", "kachit": "Kachit Knights", + "killallgo": "Ĉiomortiga Goo", "knightline": "Ĉevalira Linio", "konane": "Konane", "krypte": "Krypte", diff --git a/locales/eo/apresults.json b/locales/eo/apresults.json index c101f07f..54b81fbb 100644 --- a/locales/eo/apresults.json +++ b/locales/eo/apresults.json @@ -98,6 +98,8 @@ "forms_other": "La movo de {{player}} forigis {{count}} pecojn.", "elOso_one": "{{player}} neniigis unu pecon ĉe {{where}}.", "elOso_other": "{{player}} neniigis {{count}} pecojn ĉe {{where}}.", + "killallgo_suicide_one": "{{player}} forigis sian propran ŝtonon per memmortigo.", + "killallgo_suicide_other": "{{player}} forigis {{count}} el siaj propraj ŝtonoj per memmortigo.", "spora_one": "{{player}} ĉirkaŭis kaj kaptis unu pecon: {{group}}.", "spora_other": "{{player}} ĉirkaŭis kaj kaptis {{count}} pecojn: {{group}}." }, @@ -222,7 +224,8 @@ "elOso": { "playerSetup": "Komenca aranĝo de la ludanto", "bearSetup": "Komenca aranĝo de la Urso" - } + }, + "killallgo_slice": "{{player}} fiksis la grandojn de la aroj al {{a}} kaj {{b}} ŝtonoj." }, "BEAROFF": { "canoe": "{{player}} eligis kubon de la tabulo.", @@ -250,6 +253,8 @@ "fnap_row": "{{player}} alproprigis la vicon {{where}}.", "frogger": "{{player}} estis blokita kaj tiris {{card}} el la tirprovizo.", "jacynth": "{{player}} influis ĉe {{where}}.", + "killallgo_attacker": "{{player}} elektis ludi kiel la atakanto.", + "killallgo_defender": "{{player}} elektis ludi kiel la defendanto.", "logger": "{{player}} akiris protestanton de la arbo faligita ĉe {{where}}.", "magnate": "{{who}} akiris {{what}}.", "magnate_initial": "La ludantoj kolektis siajn Kronajn rimedojn.", @@ -300,6 +305,8 @@ "biscuit": "{{player}} finis sian manon!", "emu": "La jaro {{year}} finiĝis.", "frogger": "La krokodiloj antaŭeniris.", + "killallgo_handicap_one": "{{player}} fiksis la handikapon al unu ŝtono.", + "killallgo_handicap_other": "{{player}} fiksis la handikapon al {{count}} ŝtonoj.", "mchess": "{{player}} \"vokis la horloĝon\". Se neniu kapto okazos dum la sekvaj sep vicoj, la ludo finiĝos kaj estos poentita.", "renju": "{{player}} deklaris {{count}} provizorajn kvinopojn." }, @@ -375,7 +382,10 @@ "unlur_foul": "La ludo finiĝis, ĉar unu ludanto plenumis la venkokondiĉon de la kontraŭulo sen plenumi sian propran.", "druid_hex": "La ludo finiĝis, ĉar {{player}} konektis la randojn {{edges}}.", "gonnect_cascading_subboard": "{{player}} venkis sur la {{size}}x{{size}}-subtabulo.", - "gonnect_direct_connection": "{{player}} venkis per rekta konekto." + "gonnect_direct_connection": "{{player}} venkis per rekta konekto.", + "killallgo_double_pass": "La ludo finiĝis, ĉar ambaŭ ludantoj preterpasis kaj la defendanto ne establis vivon.", + "killallgo_no_room": "La ludo finiĝis, ĉar la senkondiĉe vivaj ŝtonoj de la atakanto lasas al la defendanto neniun spacon por fari vivantan ĉenon.", + "killallgo_pass_alive": "La ludo finiĝis, ĉar la defendanto faris senkondiĉe vivantan ĉenon." }, "DRUID_HEX_EDGE": { "N": "nordo (N)", @@ -556,6 +566,7 @@ "streetcar_place_default": "Konstruaĵo estis metita ĉe {{where}}.", "elOso_one": "{{player}} metis unu pecon ĉe {{where}}.", "elOso_other": "{{player}} metis {{count}} pecojn ĉe {{where}}.", + "killallgo_setup": "{{player}} metis atakantan ŝtonon ĉe {{where}}.", "spora_one": "{{player}} aldonis unu pecon al la stako ĉe {{where}}.", "spora_other": "{{player}} aldonis {{size}} pecojn al la stako ĉe {{where}}." }, @@ -625,6 +636,8 @@ "SCORE_REPORT": "La poentaro de {{player}} nun estas {{score}}.", "SELECT": { "fnap": "{{player}} elektis la kahelon {{tile}}.", + "killallgo_iplace": "{{player}} elektis mem meti la unuan aron.", + "killallgo_youplace": "{{player}} elektis lasi la kontraŭulon meti la unuan aron.", "resolve": "{{player}} elektis la pecon en kructranĉo ĉe {{where}}.", "slyde_fixed_opponent": "{{player}} ŝanĝis la staton de la kontraŭula peco ĉe {{where}} de movebla al fiksa.", "slyde_fixed_self": "{{player}} ŝanĝis la staton de sia propra peco ĉe {{where}} de movebla al fiksa.", diff --git a/src/games/killallgo.ts b/src/games/killallgo.ts new file mode 100644 index 00000000..21a09bf5 --- /dev/null +++ b/src/games/killallgo.ts @@ -0,0 +1,1667 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IStatus, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; +import type { APGamesInformation } from "../schemas/gameinfo.js"; +import type { APRenderRep, Glyph, RowCol } from "@abstractplay/renderer/build/schemas/schema"; +import type { APMoveResult } from "../schemas/moveresults.js"; +import { algebraic2coords, coords2algebraic, reviver, UserFacingError } from "../common/index.js"; +import i18next from "i18next"; + +/** + * Kill-All Go. + * + * Board colours: RED (1) is the Attacker, who starts with stones on the board and must kill + * everything; BLUE (2) is the Defender, who wins by making one string that can never be captured. + * Seats are not colours: `redSeat` records which seat plays Red once the opening protocol has + * decided it. User-facing strings say "Attacker" and "Defender", never a colour. + */ +export type playerid = 1 | 2; + +// --------------------------------------------------------------------------- +// Board helpers +// --------------------------------------------------------------------------- + +export type Stone = 1 | 2; +export const RED: Stone = 1; +export const BLUE: Stone = 2; + +export type Board = Map; + +export interface Geometry { + size: number; + /** All cells, row by row from the top of the board. */ + cells: string[]; + neighbours: Map; + coords2algebraic: (x: number, y: number) => string; + algebraic2coords: (cell: string) => [number, number]; +} + +export interface StringInfo { + stones: string[]; + liberties: Set; +} + +export interface PlacementOutcome { + /** Each captured opponent string, in the order they were removed. */ + captured: string[][]; + /** The mover's own stones removed by suicide (Tromp-Taylor clearing), if any. */ + suicided: string[]; +} + +const geometryCache = new Map(); + +export const makeGeometry = (size: number): Geometry => { + const cached = geometryCache.get(size); + if (cached !== undefined) { + return cached; + } + const c2a = (x: number, y: number): string => coords2algebraic(x, y, size); + const a2c = (cell: string): [number, number] => algebraic2coords(cell, size); + const cells: string[] = []; + const neighbours = new Map(); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const cell = c2a(x, y); + cells.push(cell); + const adj: string[] = []; + if (x > 0) { adj.push(c2a(x - 1, y)); } + if (x < size - 1) { adj.push(c2a(x + 1, y)); } + if (y > 0) { adj.push(c2a(x, y - 1)); } + if (y < size - 1) { adj.push(c2a(x, y + 1)); } + neighbours.set(cell, adj); + } + } + const geo: Geometry = { size, cells, neighbours, coords2algebraic: c2a, algebraic2coords: a2c }; + geometryCache.set(size, geo); + return geo; +}; + +export const otherColour = (colour: Stone): Stone => (colour === RED ? BLUE : RED); + +/** The string containing `cell` (which must hold a stone) and its liberties. */ +export const stringAt = (board: Board, geo: Geometry, cell: string): StringInfo => { + const colour = board.get(cell); + if (colour === undefined) { + throw new Error(`No stone at ${cell}.`); + } + const stones: string[] = []; + const liberties = new Set(); + const seen = new Set([cell]); + const todo = [cell]; + while (todo.length > 0) { + const cur = todo.pop()!; + stones.push(cur); + for (const n of geo.neighbours.get(cur)!) { + const occupant = board.get(n); + if (occupant === undefined) { + liberties.add(n); + } else if (occupant === colour && !seen.has(n)) { + seen.add(n); + todo.push(n); + } + } + } + return { stones, liberties }; +}; + +/** + * Place a stone and apply Tromp-Taylor clearing: opponent strings left without liberties are + * removed first, then the mover's own string if it has none (multi-stone suicide is legal; + * a single-stone suicide is left to the superko check, which always rejects it). + * Mutates `board`. + */ +export const applyPlacement = (board: Board, geo: Geometry, cell: string, colour: Stone): PlacementOutcome => { + if (board.has(cell)) { + throw new Error(`Cell ${cell} is occupied.`); + } + board.set(cell, colour); + const opp = otherColour(colour); + const captured: string[][] = []; + const removed = new Set(); + for (const n of geo.neighbours.get(cell)!) { + if (board.get(n) !== opp || removed.has(n)) { + continue; + } + const info = stringAt(board, geo, n); + if (info.liberties.size === 0) { + for (const s of info.stones) { + board.delete(s); + removed.add(s); + } + captured.push(info.stones); + } + } + let suicided: string[] = []; + const own = stringAt(board, geo, cell); + if (own.liberties.size === 0) { + for (const s of own.stones) { + board.delete(s); + } + suicided = own.stones; + } + return { captured, suicided }; +}; + +/** Canonical string for a board position (used for positional superko). */ +export const signature = (board: Board, geo: Geometry): string => { + let sig = ""; + for (const cell of geo.cells) { + const s = board.get(cell); + sig += s === undefined ? "-" : s === RED ? "r" : "b"; + } + return sig; +}; + +// --------------------------------------------------------------------------- +// Benson's algorithm for unconditional life ("pass-alive" strings) +// --------------------------------------------------------------------------- + +export interface PassAliveOptions { + /** Tromp-Taylor rules; selects the strict vital-region test. Defaults to true. */ + suicideAllowed?: boolean; +} + +interface Region { + points: string[]; + adjacentChains: Set; + vitalTo: Set; +} + +/** + * 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. + * + * 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 + * refill it leaving a hole that is not a liberty of the chain, so every point of the region, + * occupied or not, must touch it. + */ +export const passAliveStrings = (board: Board, geo: Geometry, colour: Stone, opts: PassAliveOptions = {}): string[][] => { + const suicideAllowed = opts.suicideAllowed ?? true; + + // Chains of `colour`. + const chainOf = new Map(); + const chains: string[][] = []; + for (const cell of geo.cells) { + if (board.get(cell) !== colour || chainOf.has(cell)) { + continue; + } + const id = chains.length; + const stones: string[] = []; + const todo = [cell]; + chainOf.set(cell, id); + while (todo.length > 0) { + const cur = todo.pop()!; + stones.push(cur); + for (const n of geo.neighbours.get(cur)!) { + if (board.get(n) === colour && !chainOf.has(n)) { + chainOf.set(n, id); + todo.push(n); + } + } + } + chains.push(stones); + } + if (chains.length === 0) { + return []; + } + + // Regions: maximal connected sets of points not holding `colour`. + const regionOf = new Map(); + const regions: Region[] = []; + for (const cell of geo.cells) { + if (board.get(cell) === colour || regionOf.has(cell)) { + continue; + } + const id = regions.length; + const points: string[] = []; + const todo = [cell]; + regionOf.set(cell, id); + while (todo.length > 0) { + const cur = todo.pop()!; + points.push(cur); + for (const n of geo.neighbours.get(cur)!) { + if (board.get(n) !== colour && !regionOf.has(n)) { + regionOf.set(n, id); + todo.push(n); + } + } + } + regions.push({ points, adjacentChains: new Set(), vitalTo: new Set() }); + } + + // Which chains touch each region, and to which chains the region is vital. + for (const region of regions) { + const touches = new Map(); + let mustTouch = 0; + for (const p of region.points) { + const counts = suicideAllowed || !board.has(p); + if (counts) { + mustTouch++; + } + const seenHere = new Set(); + for (const n of geo.neighbours.get(p)!) { + const chain = chainOf.get(n); + if (chain === undefined || seenHere.has(chain)) { + continue; + } + seenHere.add(chain); + region.adjacentChains.add(chain); + if (counts) { + touches.set(chain, (touches.get(chain) ?? 0) + 1); + } + } + } + for (const [chain, n] of touches) { + if (n === mustTouch) { + region.vitalTo.add(chain); + } + } + } + + // Benson iteration. + const X = new Set(chains.map((_, i) => i)); + const R = new Set(regions.map((_, i) => i)); + let changed = true; + while (changed) { + changed = false; + for (const chain of [...X]) { + let vital = 0; + for (const rid of R) { + if (regions[rid].vitalTo.has(chain)) { + vital++; + } + } + if (vital < 2) { + X.delete(chain); + changed = true; + } + } + for (const rid of [...R]) { + for (const chain of regions[rid].adjacentChains) { + if (!X.has(chain)) { + R.delete(rid); + changed = true; + break; + } + } + } + } + return chains.filter((_, i) => X.has(i)); +}; + +/** + * Whether any part of the board still has room for the two eyes a pass-alive Defender string + * needs, given the Attacker's pass-alive stones `permanent`. + * + * Those stones 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 (such a region would have to contain the stone, and a + * pass-alive Attacker chain cannot sit inside one). So the two vital regions of any future living + * Defender string lie among the points touching no permanent stone, in one component of the board + * minus those stones, and are not adjacent to each other. When no component offers two such + * points, the Defender can never live. The test is sound but not complete: it never gives up on + * a Defender who could still live, but it does not recognise every hopeless position. + */ +export const roomForTwoEyes = (geo: Geometry, permanent: Set): boolean => { + if (permanent.size === 0) { + return true; + } + const seen = new Set(); + for (const start of 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 = 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); + } + } + } + // The grid has no triangles, so any three points include two that are not adjacent. + if (eyePoints.length >= 3) { + return true; + } + if (eyePoints.length === 2 && !geo.neighbours.get(eyePoints[0])!.includes(eyePoints[1])) { + return true; + } + } + return false; +}; + +// --------------------------------------------------------------------------- +// The game +// --------------------------------------------------------------------------- + +type Opening = "alt" | "handicap" | "classic" | "pie" | "hoctaph"; + +type Phase = + | "hand-n" // handicap opening: Player 1 types the number of handicap stones + | "alt-place" // Pieboxing: alternating placement of Red stones until someone takes Red + | "pie-slice" // simple pie: Player 1 places any number of Red stones + | "pie-choose" // simple pie: Player 2 chooses a side + | "hoc-slice" // Hoctaph: Player 1 chooses the two batch sizes + | "hoc-option" // Hoctaph: Player 2 chooses who places the first batch + | "hoc-batch-a" // Hoctaph: Player 1 places the first batch (Player 2 chose "youplace") + | "hoc-choose" // Hoctaph: the other player chooses a side + | "hoc-batch-b" // Hoctaph: Red places the second batch (the chooser took Blue) + | "play"; // normal alternation, Blue always moved first + +interface ISetup { + a?: number; + b?: number; + batchSize?: number; + handicap?: number; +} + +interface ILegend { + [key: string]: Glyph | [Glyph, ...Glyph[]]; +} + +/** One numbered stone of the on-board batch-size picker. */ +interface ISliceButton { + cell: string; + value: number; +} + +interface ISliceLayout { + a: ISliceButton[]; + b: ISliceButton[]; + labels: [string, string]; +} + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Board; + lastmove?: string; + phase: Phase; + redSeat?: playerid; + setup?: ISetup; + /** Board signatures of the intermediate positions inside this ply (positional superko). */ + interim: string[]; + /** The stones that decided the game: the Defender's living strings, or the Attacker's that leave them no room. */ + alive?: string[]; +} + +export interface IKillAllGoState extends IAPGameState { + winner: playerid[]; + stack: Array; +} + +// The traditional 17-stone starting position (Sensei's Library, "The Killing Game"), 19x19 only. +const CLASSIC_SETUP = ["j18", "c17", "q17", "d16", "j16", "p16", "b10", "d10", "j10", "p10", "r10", "d4", "j4", "p4", "c3", "q3", "j2"]; +const UNDECIDED_COLOUR = "#999999"; +const CELL_RE = /^[a-z]+\d+$/; + +export class KillAllGoGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Kill-All Go", + uid: "killallgo", + playercounts: [2], + version: "20260921", + dateAdded: "2026-09-21", + // i18next.t("apgames:descriptions.killallgo") + description: "apgames:descriptions.killallgo", + // i18next.t("apgames:notes.killallgo") + notes: "apgames:notes.killallgo", + urls: [ + "https://senseis.xmp.net/?KillAllGame", + "https://senseis.xmp.net/?Pieboxing", + "https://senseis.xmp.net/?ShapeGame", + ], + people: [ + { + type: "designer", + name: "Alexandre Dinerchtein", + urls: ["https://senseis.xmp.net/?KillAllGame"], + }, + { + type: "coder", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + variants: [ + { uid: "size-9", group: "board" }, + { uid: "size-13", group: "board" }, + { uid: "#board" }, + { + uid: "#opening", + people: [ + { + type: "designer", + name: "MXHero", + urls: ["https://senseis.xmp.net/?Pieboxing"], + }, + ], + }, + { + uid: "handicap", + group: "opening", + fans: true, + unrated: true, + people: [ + { + type: "designer", + name: "MXHero", + urls: ["https://senseis.xmp.net/?Pieboxing"], + }, + ], + }, + { uid: "classic", group: "opening", enabledWhen: { board: ["#board"] } }, + { uid: "pie", group: "opening", fans: true }, + { + uid: "hoctaph", + group: "opening", + fans: true, + people: [ + { + type: "designer", + name: "Hoctaph", + urls: ["https://forums.online-go.com/t/kill-all-go/27365/70"], + }, + { + type: "designer", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + }, + ], + categories: ["goal>annihilate", "mechanic>place", "mechanic>capture", "mechanic>enclose", "mechanic>asymmetry", "board>shape>rect", "board>connect>rect", "components>simple>1per"], + flags: ["experimental", "custom-colours", "custom-buttons"], + customizations: [ + { + num: 1, + default: 1, + explanation: "Colour of the Attacker's stones", + }, + { + num: 2, + default: 2, + explanation: "Colour of the Defender's stones", + }, + ], + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Board; + public phase!: Phase; + public redSeat?: playerid; + public setup?: ISetup; + public interim: string[] = []; + public alive?: string[]; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private boardSize = 19; + private geo!: Geometry; + + constructor(state?: IKillAllGoState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined && variants.length > 0) { + this.variants = this.applyVariantConstraints(variants); + } + const size = KillAllGoGame.sizeFromVariants(this.variants); + const opening = KillAllGoGame.openingFromVariants(this.variants); + const board: Board = new Map(); + let phase: Phase = "alt-place"; + let redSeat: playerid | undefined; + if (opening === "classic" && size === 19) { + for (const cell of CLASSIC_SETUP) { + board.set(cell, RED); + } + phase = "play"; + redSeat = 2; + } else if (opening === "handicap") { + phase = "hand-n"; + } else if (opening === "pie") { + phase = "pie-slice"; + } else if (opening === "hoctaph") { + phase = "hoc-slice"; + } + const fresh: IMoveState = { + _version: KillAllGoGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + phase, + redSeat, + interim: [], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + if (!state.startsWith("{") && !state.startsWith("[")) { + throw new Error("Compressed game state must be decompressed before constructing the engine."); + } + state = JSON.parse(state, reviver) as IKillAllGoState; + } + if (state.game !== KillAllGoGame.gameinfo.uid) { + throw new Error(`The Kill-All Go engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): KillAllGoGame { + if (idx < 0) { + idx += this.stack.length; + } + if (idx < 0 || idx >= this.stack.length) { + throw new Error("Could not load the requested state from the stack."); + } + const state = this.stack[idx]; + this.results = [...state._results]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + this.phase = state.phase; + this.redSeat = state.redSeat; + this.setup = state.setup === undefined ? undefined : { ...state.setup }; + this.interim = [...state.interim]; + this.alive = state.alive === undefined ? undefined : [...state.alive]; + this.boardSize = KillAllGoGame.sizeFromVariants(this.variants); + this.geo = makeGeometry(this.boardSize); + return this; + } + + private static sizeFromVariants(variants: string[]): number { + if (variants.includes("size-9")) { return 9; } + if (variants.includes("size-13")) { return 13; } + return 19; + } + + private static openingFromVariants(variants: string[]): Opening { + for (const uid of ["handicap", "classic", "pie", "hoctaph"] as const) { + if (variants.includes(uid)) { + return uid; + } + } + return "alt"; + } + + private get opening(): Opening { + return KillAllGoGame.openingFromVariants(this.variants); + } + + public coords2algebraic(x: number, y: number): string { + return this.geo.coords2algebraic(x, y); + } + + public algebraic2coords(cell: string): [number, number] { + return this.geo.algebraic2coords(cell); + } + + private otherSeat(seat: playerid): playerid { + return seat === 1 ? 2 : 1; + } + + private blueSeat(): playerid | undefined { + return this.redSeat === undefined ? undefined : this.otherSeat(this.redSeat); + } + + private colourOfSeat(seat: playerid): Stone | undefined { + if (this.redSeat === undefined) { + return undefined; + } + return seat === this.redSeat ? RED : BLUE; + } + + private get points(): number { + return this.boardSize * this.boardSize; + } + + /** + * The largest number of Attacker stones any one setup phase may contribute: half the board, + * rounded down. Every supported board has an odd number of points, so two phases each staying + * at or under this bound can never together reach the whole board, and a Red-only string can + * only lose its last liberty by covering literally every point. This is what keeps setup + * placements safe from positional superko without simulating them: the whole-board suicide + * that would otherwise repeat an earlier position (the empty board, in the simplest case) + * becomes impossible. Also the range offered for the handicap declared in `hand-n`. + */ + private maxSetupStones(): number { + return Math.floor(this.points / 2); + } + + private isValidCell(cell: string): boolean { + if (!CELL_RE.test(cell)) { + return false; + } + try { + const [x, y] = this.geo.algebraic2coords(cell); + return x >= 0 && y >= 0 && x < this.boardSize && y < this.boardSize; + } catch { + return false; + } + } + + /** Signatures of every position reached before the current ply (positional superko). */ + private positions(): Set { + const seen = new Set(); + for (const state of this.stack) { + seen.add(signature(state.board, this.geo)); + for (const sig of state.interim) { + seen.add(sig); + } + } + return seen; + } + + /** Result of placing `colour` at `cell` on a copy of `board`, or undefined when the position would repeat. */ + private simulate(board: Board, cell: string, colour: Stone, seen: Set): { board: Board; sig: string } | undefined { + const copy = new Map(board); + applyPlacement(copy, this.geo, cell, colour); + const sig = signature(copy, this.geo); + if (seen.has(sig)) { + return undefined; + } + return { board: copy, sig }; + } + + private bluePassAlive(): string[][] { + return passAliveStrings(this.board, this.geo, BLUE, { suicideAllowed: true }); + } + + /** + * The on-board picker offered during the Hoctaph slice: the values 1 to floor(p/12) laid out + * in two three-wide blocks, the first batch on the left and the second on the right, each one + * intersection in from its corner with a label stone above it. Larger sizes are still typed. + */ + private sliceLayout(): ISliceLayout | undefined { + const width = 3; + const max = Math.floor(this.points / 12); + const rows = Math.ceil(max / width); + if (max < 1 || rows + 1 > this.boardSize || this.boardSize < 2 * width + 3) { + return undefined; + } + const aCols = [1, 2, 3]; + const bCols = [this.boardSize - 4, this.boardSize - 3, this.boardSize - 2]; + const block = (cols: number[]): ISliceButton[] => { + const out: ISliceButton[] = []; + for (let value = 1; value <= max; value++) { + const idx = value - 1; + out.push({ cell: this.coords2algebraic(cols[idx % width], 1 + Math.floor(idx / width)), value }); + } + return out; + }; + return { + a: block(aCols), + b: block(bCols), + labels: [this.coords2algebraic(aCols[1], 0), this.coords2algebraic(bCols[1], 0)], + }; + } + + /** The two batch sizes named by a complete or partial slice move. */ + private parseSlice(m: string): [number | undefined, number | undefined] { + const parts = m.split(","); + const num = (raw: string | undefined): number | undefined => (raw !== undefined && /^\d+$/.test(raw) ? parseInt(raw, 10) : undefined); + return [num(parts[0]), num(parts[1])]; + } + + /** Whether two batch sizes may be chosen together. */ + private sliceAllowed(a: number, b: number): boolean { + return a >= 1 && b >= 1 && a + b <= this.points - 2 && a <= 2 * b && b <= 2 * a; + } + + private parseCells(list: string): string[] { + return list.length === 0 ? [] : list.split(","); + } + + /** Whether `moves()` lists every legal move of this shape in the current phase (used by the failsafe). */ + private isEnumerable(m: string): boolean { + if (this.phase === "hoc-slice" || this.phase === "hoc-batch-a" || this.phase === "hoc-batch-b") { + return false; + } + return /^([a-z]+\d+|\d+|pass|attacker|defender|youplace|defender:[a-z]+\d+)$/.test(m); + } + + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = []; + const seen = this.positions(); + switch (this.phase) { + case "hand-n": + for (let n = 1; n <= this.maxSetupStones(); 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 "pie-slice": + moves.push("pass"); + for (const cell of this.geo.cells) { + if (!this.board.has(cell)) { + moves.push(cell); + } + } + break; + case "pie-choose": + moves.push("attacker"); + for (const cell of this.geo.cells) { + if (!this.board.has(cell) && this.simulate(this.board, cell, BLUE, seen) !== undefined) { + moves.push(`defender:${cell}`); + } + } + break; + case "hoc-option": + moves.push("youplace"); + break; + case "hoc-batch-a": + case "hoc-batch-b": + if (this.setup?.batchSize === 1) { + for (const cell of this.geo.cells) { + if (!this.board.has(cell)) { + moves.push(cell); + } + } + } + break; + case "hoc-choose": + moves.push("defender"); + break; + case "play": { + moves.push("pass"); + const colour = this.colourOfSeat(this.currplayer)!; + for (const cell of this.geo.cells) { + if (!this.board.has(cell) && this.simulate(this.board, cell, colour, seen) !== undefined) { + moves.push(cell); + } + } + break; + } + default: + // Typed numbers and multi-stone batches are not enumerable. + break; + } + return moves; + } + + /** Stones the current player must add when taking the Attacker side right now. */ + private handicapOwed(): number { + if (this.phase === "alt-place" && this.opening === "handicap" && this.currplayer === 2) { + return this.setup?.handicap ?? 0; + } + if (this.phase === "hoc-choose") { + return this.setup?.b ?? 0; + } + return 0; + } + + /** + * Fixed choices are offered as buttons; everything else is built by clicking the board. + * Taking the Attacker side while handicap or batch stones are owed leaves an incomplete + * move in the box, which the following board clicks finish. + */ + public getButtons(): ICustomButton[] { + if (this.gameover) { + return []; + } + switch (this.phase) { + case "alt-place": + case "pie-choose": + return [{ label: "killallgo.attacker", move: "attacker" }]; + case "hoc-option": + return [{ label: "killallgo.youplace", move: "youplace" }]; + case "hoc-choose": + return [{ label: "killallgo.defender", move: "defender" }]; + case "pie-slice": + case "play": + return [{ label: "pass", move: "pass" }]; + default: + return []; + } + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.coords2algebraic(col, row); + const newmove = this.clickCell(move, cell); + if (newmove === undefined) { + const key = this.phase === "hoc-slice" ? "SLICE_NOT_A_BUTTON" : "NO_CLICKS_NOW"; + return { + move, + valid: false, + message: i18next.t(`apgames:validation.killallgo.${key}`), + }; + } + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? newmove : move; + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", { move, row, col, piece, emessage: (e as Error).message }), + }; + } + } + + /** Extend `move` with a click on `cell`, or return undefined when board clicks mean nothing now. */ + private clickCell(move: string, cell: string): string | undefined { + const toggle = (list: string): string => { + const cells = this.parseCells(list); + const next = cells.includes(cell) ? cells.filter((c) => c !== cell) : [...cells, cell]; + return next.join(","); + }; + switch (this.phase) { + case "hand-n": + return undefined; + case "hoc-slice": { + const layout = this.sliceLayout(); + if (layout === undefined) { return undefined; } + const hit = layout.a.find((btn) => btn.cell === cell) ?? layout.b.find((btn) => btn.cell === cell); + if (hit === undefined) { return undefined; } + const first = layout.a.some((btn) => btn.cell === cell); + const [curA, curB] = this.parseSlice(move); + // Picking a value the other batch size forbids drops that other choice. + if (first) { + const keep = curB !== undefined && this.sliceAllowed(hit.value, curB) ? curB : undefined; + return keep === undefined ? `${hit.value}` : `${hit.value},${keep}`; + } + const keep = curA !== undefined && this.sliceAllowed(curA, hit.value) ? curA : undefined; + return keep === undefined ? `,${hit.value}` : `${keep},${hit.value}`; + } + case "alt-place": + if (move.startsWith("attacker")) { + return `attacker:${toggle(move.substring("attacker:".length))}`; + } + return cell; + case "pie-slice": + case "hoc-batch-a": + case "hoc-batch-b": + return toggle(move === "pass" ? "" : move); + case "pie-choose": + return `defender:${cell}`; + case "hoc-option": + if (move === "youplace") { + return move; + } + return `iplace:${toggle(move.startsWith("iplace:") ? move.substring("iplace:".length) : "")}`; + case "hoc-choose": + if (move === "defender") { + return move; + } + return `attacker:${toggle(move.startsWith("attacker:") ? move.substring("attacker:".length) : "")}`; + case "play": + return cell; + } + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = { valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER") }; + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (this.gameover) { + result.message = i18next.t("apgames:MOVES_GAMEOVER"); + return result; + } + switch (this.phase) { + case "hand-n": + return this.validateHandicap(m, result); + case "alt-place": + return this.validateAltPlace(m, result); + case "pie-slice": + return this.validatePieSlice(m, result); + case "pie-choose": + return this.validatePieChoose(m, result); + case "hoc-slice": + return this.validateSlice(m, result); + case "hoc-option": + return this.validateHocOption(m, result); + case "hoc-batch-a": + case "hoc-batch-b": + return this.validateHocBatch(m, result); + case "hoc-choose": + return this.validateHocChoose(m, result); + case "play": + return this.validatePlay(m, result); + } + } + + private ok(result: IValidationResult, complete: -1 | 0 | 1, message: string, canrender = false): IValidationResult { + result.valid = true; + result.complete = complete; + result.canrender = canrender; + result.message = message; + return result; + } + + private fail(result: IValidationResult, message: string): IValidationResult { + result.valid = false; + result.message = message; + return result; + } + + /** + * Checks a sequence of setup placements (always Red, one seat placing several stones in one + * ply): known cells, empty, no duplicates. No superko check is needed for the sequence itself + * (unlike an ordinary multi-stone ply): every setup phase separately caps its own contribution + * at `maxSetupStones()`, and every supported board has an odd number of points, so two + * independently capped contributions can never together cover the whole board. A Red-only + * string can only lose its last liberty by covering literally every point, so a whole-board + * self-capture during setup (which would otherwise repeat an earlier position, the empty + * board in the simplest case) is impossible as long as every caller enforces its own cap. + */ + private checkCells(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.killallgo.DUPLICATE_CELL", { where: cell })); + } + named.add(cell); + } + return undefined; + } + + private checkBatch(cells: string[], needed: number, result: IValidationResult): IValidationResult { + const err = this.checkCells(cells, result); + if (err !== undefined) { return err; } + if (cells.length > needed) { + return this.fail(result, i18next.t("apgames:validation.killallgo.BATCH_TOO_MANY", { count: needed })); + } + if (cells.length < needed) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.STONES_LEFT", { count: needed - cells.length }), cells.length > 0); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private validateHandicap(m: string, result: IValidationResult): IValidationResult { + const max = this.maxSetupStones(); + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HANDICAP", { max })); + } + if (!/^\d+$/.test(m)) { + return this.fail(result, i18next.t("apgames:validation.killallgo.HANDICAP_INVALID")); + } + const n = parseInt(m, 10); + if (n < 1 || n > max) { + return this.fail(result, i18next.t("apgames:validation.killallgo.HANDICAP_RANGE", { max })); + } + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.HANDICAP_OK", { count: n })); + } + + private validateAltPlace(m: string, result: IValidationResult): IValidationResult { + const owed = this.handicapOwed(); + if (m.length === 0) { + if (owed > 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_ALT_HANDICAP", { count: owed })); + } + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_ALT")); + } + if (m === "attacker" || m.startsWith("attacker:")) { + const cells = this.parseCells(m.substring("attacker:".length)); + if (owed === 0) { + if (cells.length > 0) { + return this.fail(result, i18next.t("apgames:validation.killallgo.ATTACKER_NO_STONES")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + return this.checkBatch(cells, owed, result); + } + if (m === "pass") { + return this.fail(result, i18next.t("apgames:validation.killallgo.INVALID_PASS")); + } + if (this.board.size >= this.maxSetupStones()) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SETUP_FULL", { max: this.maxSetupStones() })); + } + const err = this.checkCells([m], result); + if (err !== undefined) { return err; } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private validatePieSlice(m: string, result: IValidationResult): IValidationResult { + const max = this.maxSetupStones(); + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_PIE_SLICE", { max })); + } + if (m === "pass") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + const cells = this.parseCells(m); + if (cells.length > max) { + return this.fail(result, i18next.t("apgames:validation.killallgo.PIE_SLICE_MAX", { max })); + } + const err = this.checkCells(cells, result); + if (err !== undefined) { return err; } + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.BATCH_MORE_OR_SUBMIT"), true); + } + + private validatePieChoose(m: string, result: IValidationResult): IValidationResult { + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_PIE_CHOOSE")); + } + if (m === "attacker") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m.startsWith("attacker:")) { + return this.fail(result, i18next.t("apgames:validation.killallgo.ATTACKER_NO_STONES")); + } + if (m === "defender") { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_PIE_DEFENDER_STONE")); + } + if (m.startsWith("defender:")) { + const cell = m.substring("defender:".length); + return this.validatePlacement(cell, BLUE, result); + } + return this.fail(result, i18next.t("apgames:validation._general.INVALID_MOVE", { move: m })); + } + + private validateSlice(m: string, result: IValidationResult): IValidationResult { + const max = this.points - 2; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE", { max }), true); + } + if (!/^\d*,?\d*$/.test(m)) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_FORMAT")); + } + const [a, b] = this.parseSlice(m); + if (a === undefined && b === undefined) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE", { max }), true); + } + if (b === undefined) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.SLICE_A_ONLY", { count: a }), true); + } + if (a === undefined) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.SLICE_B_ONLY", { count: b }), true); + } + if (a < 1 || b < 1) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_MIN")); + } + if (a + b > max) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_SUM", { max })); + } + if (a > 2 * b || b > 2 * a) { + return this.fail(result, i18next.t("apgames:validation.killallgo.SLICE_RATIO")); + } + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.SLICE_OK", { a, b }), true); + } + + private validateHocOption(m: string, result: IValidationResult): IValidationResult { + const a = this.setup?.a ?? 0; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_OPTION", { count: a })); + } + if (m === "youplace") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m === "iplace" || m.startsWith("iplace:")) { + return this.checkBatch(this.parseCells(m.substring("iplace:".length)), a, result); + } + return this.fail(result, i18next.t("apgames:validation._general.INVALID_MOVE", { move: m })); + } + + private validateHocBatch(m: string, result: IValidationResult): IValidationResult { + const needed = this.setup?.batchSize ?? 0; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_BATCH", { count: needed })); + } + if (m === "pass") { + return this.fail(result, i18next.t("apgames:validation.killallgo.INVALID_PASS")); + } + return this.checkBatch(this.parseCells(m), needed, result); + } + + private validateHocChoose(m: string, result: IValidationResult): IValidationResult { + const b = this.setup?.b ?? 0; + if (m.length === 0) { + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_CHOOSE", { count: b })); + } + if (m === "defender") { + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + if (m.startsWith("defender:")) { + return this.fail(result, i18next.t("apgames:validation.killallgo.DEFENDER_NO_STONES")); + } + if (m === "attacker" || m.startsWith("attacker:")) { + return this.checkBatch(this.parseCells(m.substring("attacker:".length)), b, result); + } + return this.fail(result, i18next.t("apgames:validation._general.INVALID_MOVE", { move: m })); + } + + /** A single normal placement by `colour` on the current board (captures, suicide, superko). */ + private validatePlacement(cell: string, colour: Stone, result: IValidationResult): IValidationResult { + if (!this.isValidCell(cell)) { + return this.fail(result, i18next.t("apgames:validation._general.INVALIDCELL", { cell })); + } + if (this.board.has(cell)) { + return this.fail(result, i18next.t("apgames:validation._general.OCCUPIED", { where: cell })); + } + if (this.simulate(this.board, cell, colour, this.positions()) === undefined) { + return this.fail(result, i18next.t("apgames:validation.killallgo.KO_PSK")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE"), true); + } + + private validatePlay(m: string, result: IValidationResult): IValidationResult { + const colour = this.colourOfSeat(this.currplayer)!; + if (m.length === 0) { + const key = colour === BLUE ? "INSTRUCTIONS_PLAY_DEFENDER" : "INSTRUCTIONS_PLAY_ATTACKER"; + return this.ok(result, -1, i18next.t(`apgames:validation.killallgo.${key}`)); + } + if (m === "pass") { + if (colour === BLUE) { + return this.ok(result, 1, i18next.t("apgames:validation.killallgo.PASS_WARNING")); + } + return this.ok(result, 1, i18next.t("apgames:validation._general.VALID_MOVE")); + } + return this.validatePlacement(m, colour, result); + } + + public move(m: string, { partial = false, trusted = false } = {}): KillAllGoGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (!trusted) { + const result = this.validateMove(m); + if (!result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message); + } + if (!partial && result.complete === -1) { + throw new UserFacingError("VALIDATION_GENERAL", i18next.t("apgames:validation._general.INCOMPLETE_MOVE")); + } + if (!partial && this.isEnumerable(m) && !this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", { move: m })); + } + } + if (m.length === 0) { return this; } + + this.results = []; + this.interim = []; + this.alive = undefined; + const seen = this.positions(); + + switch (this.phase) { + case "hand-n": { + const n = parseInt(m, 10); + this.setup = { ...(this.setup ?? {}), handicap: n }; + this.results.push({ type: "declare", count: n }); + if (partial) { return this; } + this.phase = "alt-place"; + this.currplayer = 2; + break; + } + case "alt-place": { + if (m === "attacker" || m.startsWith("attacker:")) { + const cells = this.parseCells(m.substring("attacker:".length)); + this.results.push({ type: "claim", how: "attacker" }); + this.placeSetupStones(cells, seen); + if (partial) { return this; } + this.redSeat = this.currplayer; + this.startPlay(); + } else { + this.placeSetupStones([m], seen); + if (partial) { return this; } + this.currplayer = this.otherSeat(this.currplayer); + } + break; + } + case "pie-slice": { + if (m === "pass") { + this.results.push({ type: "pass" }); + } else { + this.placeSetupStones(this.parseCells(m), seen); + } + if (partial) { return this; } + this.phase = "pie-choose"; + this.currplayer = 2; + break; + } + case "pie-choose": { + if (m === "attacker") { + this.results.push({ type: "claim", how: "attacker" }); + if (partial) { return this; } + this.redSeat = this.currplayer; + this.startPlay(); + } else { + const cell = m.substring("defender:".length); + this.results.push({ type: "claim", how: "defender" }); + this.redSeat = this.otherSeat(this.currplayer); + this.placeStone(cell, BLUE, seen); + if (partial) { return this; } + this.phase = "play"; + this.setup = this.keptSetup(); + this.currplayer = this.redSeat; + } + break; + } + case "hoc-slice": { + const [a, b] = this.parseSlice(m); + this.setup = { a, b }; + if (partial) { return this; } + this.results.push({ type: "announce", payload: [a!, b!] }); + this.phase = "hoc-option"; + this.currplayer = 2; + break; + } + case "hoc-option": { + if (m === "youplace") { + this.results.push({ type: "select", what: "youplace" }); + if (partial) { return this; } + this.setup = { ...this.setup, batchSize: this.setup!.a }; + this.phase = "hoc-batch-a"; + this.currplayer = 1; + } else { + this.results.push({ type: "select", what: "iplace" }); + this.placeSetupStones(this.parseCells(m.substring("iplace:".length)), seen); + if (partial) { return this; } + this.phase = "hoc-choose"; + this.currplayer = 1; + } + break; + } + case "hoc-batch-a": { + this.placeSetupStones(this.parseCells(m), seen); + if (partial) { return this; } + this.phase = "hoc-choose"; + this.currplayer = 2; + break; + } + case "hoc-choose": { + if (m === "defender") { + this.results.push({ type: "claim", how: "defender" }); + if (partial) { return this; } + this.redSeat = this.otherSeat(this.currplayer); + this.setup = { ...this.setup, batchSize: this.setup!.b }; + this.phase = "hoc-batch-b"; + this.currplayer = this.redSeat; + } else { + this.results.push({ type: "claim", how: "attacker" }); + this.placeSetupStones(this.parseCells(m.substring("attacker:".length)), seen); + if (partial) { return this; } + this.redSeat = this.currplayer; + this.startPlay(); + } + break; + } + case "hoc-batch-b": { + this.placeSetupStones(this.parseCells(m), seen); + if (partial) { return this; } + this.startPlay(); + break; + } + case "play": { + if (m === "pass") { + this.results.push({ type: "pass" }); + if (partial) { return this; } + const previous = this.stack[this.stack.length - 1].lastmove; + if (previous === "pass") { + this.endGame(this.redSeat!, "double-pass"); + } + this.currplayer = this.otherSeat(this.currplayer); + } else { + const colour = this.colourOfSeat(this.currplayer)!; + this.placeStone(m, colour, seen); + if (partial) { return this; } + this.currplayer = this.otherSeat(this.currplayer); + } + break; + } + } + + // Every ply that leaves the board in play may have settled the game. + this.checkLifeAndDeath(); + + // The final position of the ply is the saved board; keep only the intermediate ones. + this.interim.pop(); + this.lastmove = m; + this.saveState(); + return this; + } + + /** Opening stones for the Attacker (no captures are possible before the Defender has moved). */ + private placeSetupStones(cells: string[], seen: Set): void { + for (const cell of cells) { + applyPlacement(this.board, this.geo, cell, RED); + this.results.push({ type: "place", where: cell, what: "setup" }); + const sig = signature(this.board, this.geo); + seen.add(sig); + this.interim.push(sig); + } + } + + /** A normal placement with Tromp-Taylor clearing, recorded in results and the superko history. */ + private placeStone(cell: string, colour: Stone, seen: Set): void { + const outcome = applyPlacement(this.board, this.geo, cell, colour); + this.results.push({ type: "place", where: cell }); + for (const group of outcome.captured) { + this.results.push({ type: "capture", where: group.join(","), count: group.length }); + } + if (outcome.suicided.length > 0) { + this.results.push({ type: "capture", where: outcome.suicided.join(","), count: outcome.suicided.length, how: "suicide" }); + } + const sig = signature(this.board, this.geo); + seen.add(sig); + this.interim.push(sig); + } + + /** The opening protocol is over: normal play starts with the Defender to move. */ + private startPlay(): void { + this.phase = "play"; + this.setup = this.keptSetup(); + this.currplayer = this.blueSeat()!; + } + + /** The parts of the opening bookkeeping that stay visible in the sidebar during play. */ + private keptSetup(): ISetup | undefined { + if (this.setup === undefined) { + return undefined; + } + const kept: ISetup = {}; + if (this.setup.handicap !== undefined) { kept.handicap = this.setup.handicap; } + if (this.setup.a !== undefined) { kept.a = this.setup.a; } + if (this.setup.b !== undefined) { kept.b = this.setup.b; } + return Object.keys(kept).length === 0 ? undefined : kept; + } + + /** + * The Defender wins once a Blue string is pass-alive. The Attacker wins once that can never + * happen: the Attacker's pass-alive stones are permanent (the Defender cannot remove them, + * and the Attacker can keep them by passing), and they leave no room for two eyes. The two + * outcomes cannot coincide, because a pass-alive Blue string always has that room. + */ + private checkLifeAndDeath(): void { + if (this.gameover || this.phase !== "play") { + return; + } + const alive = this.bluePassAlive(); + if (alive.length > 0) { + this.endGame(this.blueSeat()!, "pass-alive", alive.flat()); + return; + } + const permanent = passAliveStrings(this.board, this.geo, RED, { suicideAllowed: true }).flat(); + if (!roomForTwoEyes(this.geo, new Set(permanent))) { + this.endGame(this.redSeat!, "no-room", permanent); + } + } + + private endGame(winner: playerid, reason: string, alive?: string[]): void { + this.gameover = true; + this.winner = [winner]; + this.alive = alive; + this.results.push( + { type: "eog", reason }, + { type: "winners", players: [winner] }, + ); + } + + public state(): IKillAllGoState { + return { + game: KillAllGoGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + const state: IMoveState = { + _version: KillAllGoGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + phase: this.phase, + interim: [...this.interim], + }; + if (this.redSeat !== undefined) { state.redSeat = this.redSeat; } + if (this.setup !== undefined) { state.setup = { ...this.setup }; } + if (this.alive !== undefined) { state.alive = [...this.alive]; } + return state; + } + + public getPlayerColour(p: playerid): number | string { + if (this.redSeat === undefined) { + return UNDECIDED_COLOUR; + } + return p === this.redSeat ? 1 : 2; + } + + /** + * Numbered picker stones for the Hoctaph slice, added to `legend` and returned as a cell map. + * A value the batch size already chosen on the other side would forbid is drawn at half + * opacity; it stays clickable, and picking it drops that other choice. + */ + private sliceOverlay(legend: ILegend): Map { + const overlay = new Map(); + if (this.gameover || this.phase !== "hoc-slice") { + return overlay; + } + const layout = this.sliceLayout(); + if (layout === undefined) { + return overlay; + } + const stone = (text: string, dimmed: boolean): [Glyph, ...Glyph[]] => { + const piece: Glyph = { name: "piece", colour: 1 }; + const label: Glyph = { text, scale: 0.75, rotate: null }; + if (dimmed) { + piece.opacity = 0.5; + label.opacity = 0.5; + } + return [piece, label]; + }; + const chosen = { a: this.setup?.a, b: this.setup?.b }; + for (const [side, buttons] of [["a", layout.a], ["b", layout.b]] as Array<["a" | "b", ISliceButton[]]>) { + const other = side === "a" ? chosen.b : chosen.a; + for (const btn of buttons) { + const allowed = other === undefined + || (side === "a" ? this.sliceAllowed(btn.value, other) : this.sliceAllowed(other, btn.value)); + const key = `${allowed ? "n" : "d"}${btn.value}`; + legend[key] = stone(btn.value.toString(), !allowed); + overlay.set(btn.cell, key); + } + } + legend.la = stone("a", false); + legend.lb = stone("b", false); + overlay.set(layout.labels[0], "la"); + overlay.set(layout.labels[1], "lb"); + return overlay; + } + + public render(): APRenderRep { + const legend: ILegend = { + A: [{ name: "piece", colour: 1 }], + B: [{ name: "piece", colour: 2 }], + }; + const overlay = this.sliceOverlay(legend); + + let pstr = ""; + if (overlay.size > 0) { + // Multi-character keys need the comma-delimited form. + 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) ?? (contents === RED ? "A" : contents === BLUE ? "B" : "-")); + } + rows.push(cells.every((c) => c === "-") ? "_" : cells.join(",")); + } + pstr = rows.join("\n"); + } else { + for (let row = 0; row < this.boardSize; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + const contents = this.board.get(cell); + if (contents === RED) { + pstr += "A"; + } else if (contents === BLUE) { + pstr += "B"; + } else { + pstr += "-"; + } + } + } + pstr = pstr.replace(new RegExp(`-{${this.boardSize}}`, "g"), "_"); + } + + const rep: APRenderRep = { + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend, + pieces: pstr, + }; + + const toRowCol = (cell: string): RowCol => { + const [x, y] = this.algebraic2coords(cell); + return { row: y, col: x }; + }; + const annotations: NonNullable = []; + for (const r of this.results) { + if (r.type === "place") { + annotations.push({ type: "enter", targets: [toRowCol(r.where!)] }); + } else if (r.type === "capture") { + const targets = r.where!.split(",").map(toRowCol); + annotations.push({ type: "exit", targets: targets as [RowCol, ...RowCol[]] }); + } + } + if (this.gameover && this.alive !== undefined && this.alive.length > 0) { + annotations.push({ type: "enter", targets: this.alive.map(toRowCol) as [RowCol, ...RowCol[]] }); + } + if (annotations.length > 0) { + rep.annotations = annotations; + } + + return rep; + } + + public sidebarStatuses(): IStatus[] { + const statuses: IStatus[] = []; + const undecided = this.neutralAreaLabel("apgames:status.killallgo.UNDECIDED"); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.ATTACKER"), + value: [this.redSeat === undefined ? undecided : this.seatStatusValue(this.redSeat)], + }); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.DEFENDER"), + value: [this.redSeat === undefined ? undecided : this.seatStatusValue(this.otherSeat(this.redSeat))], + }); + if (this.opening === "handicap" && this.setup?.handicap !== undefined) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.HANDICAP"), + value: [this.setup.handicap.toString()], + }); + } + if (this.opening === "hoctaph" && this.setup?.a !== undefined && this.setup.b !== undefined) { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.killallgo.BATCHES"), + value: [this.neutralAreaLabel("apgames:status.killallgo.BATCHES_VALUE", { a: this.setup.a, b: this.setup.b })], + }); + } + if (this.phase !== "play" && !this.gameover) { + const phaseKey = this.phase.replace(/-/g, "_").toUpperCase(); + statuses.push({ + key: this.neutralAreaLabel("apgames:status.PHASE"), + value: [this.seatAreaLabel(this.currplayer, `apgames:status.killallgo.PHASE_${phaseKey}`)], + }); + } + return statuses; + } + + public collectChatLogLine(lines: ChatLogLine[], r: APMoveResult, ctx: ChatLogCollectContext): boolean { + switch (r.type) { + case "place": + if (r.what === "setup") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PLACE.killallgo_setup", { where: r.where! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PLACE.nowhat", { where: r.where! }); + } + return true; + case "capture": + if (r.how === "suicide") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CAPTURE.killallgo_suicide", { count: r.count! }); + } else { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CAPTURE.noperson.group_nowhere", { count: r.count! }); + } + return true; + case "claim": + if (r.how === "attacker") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_attacker"); + } else if (r.how === "defender") { + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:CLAIM.killallgo_defender"); + } else { + return super.collectChatLogLine(lines, r, ctx); + } + return true; + case "declare": + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:DECLARE.killallgo_handicap", { count: r.count! }); + return true; + case "announce": { + const [a, b] = r.payload as [number, number]; + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:ANNOUNCE.killallgo_slice", { a, b }); + return true; + } + case "select": + this.pushSeatChatLine(lines, ctx.defaultSeat, r.what === "iplace" ? "apresults:SELECT.killallgo_iplace" : "apresults:SELECT.killallgo_youplace"); + return true; + case "eog": + if (r.reason === "pass-alive") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_pass_alive"); + } else if (r.reason === "no-room") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_no_room"); + } else if (r.reason === "double-pass") { + this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_double_pass"); + } else { + this.pushNeutralChatLine(lines, "apresults:EOG.default"); + } + return true; + default: + return super.collectChatLogLine(lines, r, ctx); + } + } + + public clone(): KillAllGoGame { + return new KillAllGoGame(this.serialize()); + } +} diff --git a/test/games/killallgo.test.ts b/test/games/killallgo.test.ts new file mode 100644 index 00000000..61592e91 --- /dev/null +++ b/test/games/killallgo.test.ts @@ -0,0 +1,960 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +import "mocha"; +import { expect } from "chai"; +import { addResource } from "../../src"; +import i18next from "i18next"; +import { applyPlacement, BLUE, KillAllGoGame, makeGeometry, passAliveStrings, RED, roomForTwoEyes, signature, stringAt, type Board } from "../../src/games/killallgo"; + +const play = (g: KillAllGoGame, moves: string[]): KillAllGoGame => { + for (const m of moves) { + g.move(m); + } + return g; +}; + +/** Player 1 takes the Attacker stones at once: Player 2 is the Defender and moves first on an empty board. */ +const attackerIsPlayerOne = (variants: string[] = ["size-9"]): KillAllGoGame => { + const g = new KillAllGoGame(undefined, variants); + g.move("attacker"); + return g; +}; + +// `X` = Blue (the colour under test), `O` = Red, `.` = empty; the first row is the top of the board. +const boardFrom = (rows: string[]): Board => { + const size = rows.length; + const geo = makeGeometry(size); + const board: Board = new Map(); + rows.forEach((row, y) => { + if (row.length !== size) { + throw new Error("Fixtures must be square."); + } + row.split("").forEach((ch, x) => { + if (ch === "X") { + board.set(geo.coords2algebraic(x, y), BLUE); + } else if (ch === "O") { + board.set(geo.coords2algebraic(x, y), RED); + } + }); + }); + return board; +}; + +interface Fixture { + name: string; + rows: string[]; + classic: boolean; + strict: boolean; +} + +const fixtures: Fixture[] = [ + { + name: "two one-point eyes on the edge", + rows: [ + "X.X.X..", + "XXXXX..", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "a single eye", + rows: [ + "X.XX...", + "XXXX...", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a two-point eye plus a one-point eye", + rows: [ + "X..XX.X", + "XXXXXXX", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "two groups with one eye each", + rows: [ + "X.X.O.O", + "XXXXOOO", + ".......", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a 3x3 empty eye plus a one-point eye", + rows: [ + "XXXXXXX", + "X...X.X", + "X...XXX", + "X...X..", + "XXXXX..", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "a 3x3 eye with an opponent stone in the centre plus a one-point eye", + rows: [ + "XXXXXXX", + "X...X.X", + "X.O.XXX", + "X...X..", + "XXXXX..", + ".......", + ".......", + ], + classic: true, strict: false, + }, + { + name: "two shared liberties and no eyes", + rows: [ + "XX.O...", + "XXXO...", + "OOOO...", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "two chains sharing two eyes", + rows: [ + "XX.XX..", + "X.X.X..", + "XX.XX..", + ".......", + ".......", + ".......", + ".......", + ], + classic: true, strict: true, + }, + { + name: "a false eye at a cutting point", + rows: [ + "X.XO...", + "XX.O...", + "OOOO...", + ".......", + ".......", + ".......", + ".......", + ], + classic: false, strict: false, + }, + { + name: "an eye containing an opponent stone on a liberty point", + rows: [ + "XXXX...", + "XO.X...", + "XXXX...", + "X..X...", + "XXXX...", + ".......", + ".......", + ], + classic: true, strict: true, + }, +]; + +describe("Kill-All Go: Benson pass-alive detection", () => { + for (const f of fixtures) { + it(f.name, () => { + const board = boardFrom(f.rows); + const geo = makeGeometry(f.rows.length); + const classic = passAliveStrings(board, geo, BLUE, { suicideAllowed: false }); + const strict = passAliveStrings(board, geo, BLUE, { suicideAllowed: true }); + expect(classic.length > 0, "classic").to.equal(f.classic); + expect(strict.length > 0, "strict").to.equal(f.strict); + }); + } + + it("returns every stone of the alive chains and nothing else", () => { + // Three chains: the two eye-shaped ones and the single stone between the eyes. + const board = boardFrom(fixtures[7].rows); + const geo = makeGeometry(7); + const alive = passAliveStrings(board, geo, BLUE); + expect(alive).to.have.length(3); + const stones = alive.flat().sort(); + const expected = [...board.entries()].filter(([, c]) => c === BLUE).map(([cell]) => cell).sort(); + expect(stones).to.deep.equal(expected); + }); + + it("never reports the opponent's chains", () => { + const board = boardFrom(fixtures[0].rows); + const geo = makeGeometry(7); + expect(passAliveStrings(board, geo, RED)).to.deep.equal([]); + }); +}); + +describe("Kill-All Go: room for two eyes", () => { + const geo = makeGeometry(5); + // Everything outside `pocket` is a permanent Attacker stone. + const walledOff = (pocket: string[]): Set => new Set(geo.cells.filter((c) => !pocket.includes(c))); + + it("always finds room while the Attacker has no permanent stones", () => { + expect(roomForTwoEyes(geo, new Set())).to.be.true; + }); + + it("finds no room when every free point touches a permanent stone", () => { + expect(roomForTwoEyes(geo, walledOff(["a5", "b5", "c5", "d5", "e5"]))).to.be.false; + }); + + it("finds no room when the only two eye points are next to each other", () => { + // In a 3x2 corner pocket only a5 and b5 touch no permanent stone, and they are adjacent. + expect(roomForTwoEyes(geo, walledOff(["a5", "b5", "c5", "a4", "b4", "c4"]))).to.be.false; + }); + + it("finds room when two eye points are apart, even if they are the only two", () => { + // a5 and e5 are the only eye points, joined through the rest of the top row. + expect(roomForTwoEyes(geo, walledOff(["a5", "b5", "c5", "d5", "e5", "a4", "e4"]))).to.be.true; + }); + + it("needs both eye points in one area", () => { + // Two 2x2 corner pockets each offer a single eye point, but they are not connected. + expect(roomForTwoEyes(geo, walledOff(["a5", "b5", "a4", "b4", "d1", "e1", "d2", "e2"]))).to.be.false; + }); + + it("never gives up on a Defender who is already pass-alive", () => { + for (const f of fixtures.filter((x) => x.strict)) { + const board = boardFrom(f.rows); + const size = f.rows.length; + const g = makeGeometry(size); + expect(passAliveStrings(board, g, BLUE).length, f.name).to.be.greaterThan(0); + const permanent = new Set(passAliveStrings(board, g, RED).flat()); + expect(roomForTwoEyes(g, permanent), f.name).to.be.true; + } + }); +}); + +describe("Kill-All Go: board mechanics", () => { + it("captures an opponent string that loses its last liberty", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["c3", BLUE], ["b3", RED], ["d3", RED], ["c2", RED]]); + const outcome = applyPlacement(board, geo, "c4", RED); + expect(outcome.captured).to.deep.equal([["c3"]]); + expect(outcome.suicided).to.deep.equal([]); + expect(board.has("c3")).to.be.false; + expect(board.get("c4")).to.equal(RED); + }); + + it("removes the mover's own string on a multi-stone suicide (Tromp-Taylor clearing)", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", RED]]); + const outcome = applyPlacement(board, geo, "b1", BLUE); + expect(outcome.captured).to.deep.equal([]); + expect(outcome.suicided.sort()).to.deep.equal(["a1", "b1"]); + expect(board.has("a1")).to.be.false; + expect(board.has("b1")).to.be.false; + }); + + it("captures before checking the mover's liberties", () => { + const geo = makeGeometry(5); + // Blue at a1 is in atari on b1; Red at a2 and b2 protect it from the other side. + const board: Board = new Map([["a1", BLUE], ["a2", RED], ["b2", RED], ["c1", BLUE]]); + // Red plays b1: it captures a1 first and therefore keeps a liberty. + const outcome = applyPlacement(board, geo, "b1", RED); + expect(outcome.captured).to.deep.equal([["a1"]]); + expect(outcome.suicided).to.deep.equal([]); + expect(board.get("b1")).to.equal(RED); + }); + + it("finds strings and liberties", () => { + const geo = makeGeometry(5); + const board: Board = new Map([["a1", BLUE], ["b1", BLUE], ["b2", RED]]); + const info = stringAt(board, geo, "a1"); + expect(info.stones.sort()).to.deep.equal(["a1", "b1"]); + expect([...info.liberties].sort()).to.deep.equal(["a2", "c1"]); + }); + + it("signatures differ between positions", () => { + const geo = makeGeometry(5); + const a: Board = new Map([["a1", BLUE]]); + const b: Board = new Map([["a1", RED]]); + expect(signature(a, geo)).to.not.equal(signature(b, geo)); + expect(signature(a, geo)).to.equal(signature(new Map(a), geo)); + }); +}); + +describe("Kill-All Go", () => { + before(() => { addResource("en"); }); + after(() => { + i18next.removeResourceBundle("en", "apgames"); + i18next.removeResourceBundle("en", "apresults"); + }); + + describe("metadata", () => { + it("links to Pieboxing and Kill-All Game on Sensei's Library, and credits MXHero for both openings that use Pieboxing", () => { + const info = KillAllGoGame.gameinfo; + expect(info.urls ?? []).to.include("https://senseis.xmp.net/?Pieboxing"); + expect(info.urls ?? []).to.include("https://senseis.xmp.net/?KillAllGame"); + const variants = info.variants ?? []; + const opening = variants.find((v) => v.uid === "#opening"); + const handicap = variants.find((v) => v.uid === "handicap"); + expect(opening?.people?.some((p) => p.name === "MXHero")).to.be.true; + expect(handicap?.people?.some((p) => p.name === "MXHero")).to.be.true; + }); + + it("is experimental and cannot be rated with a handicap", () => { + const flags = KillAllGoGame.gameinfo.flags ?? []; + expect(flags).to.include("experimental"); + const variants = KillAllGoGame.gameinfo.variants ?? []; + const handicap = variants.find((v) => v.uid === "handicap"); + expect(handicap).to.not.be.undefined; + expect(handicap!.unrated).to.be.true; + for (const v of variants.filter((x) => x.uid !== "handicap")) { + expect(v.unrated, v.uid).to.not.equal(true); + } + }); + }); + + describe("the setup stone cap", () => { + // 9x9 has 81 points; floor(81/2) = 40, matching the handicap range tested above. + const allCells = (): string[] => { + const cells: string[] = []; + for (const col of "abcdefghi") { for (let row = 1; row <= 9; row++) { cells.push(`${col}${row}`); } } + return cells; + }; + + it("stops a simple-pie slice from naming more than floor(p/2) stones", () => { + const g = new KillAllGoGame(undefined, ["size-9", "pie"]); + const atCap = allCells().slice(0, 40).join(","); + const overCap = allCells().slice(0, 41).join(","); + expect(g.validateMove(atCap).valid).to.be.true; + expect(g.validateMove(atCap).complete).to.equal(0); + expect(g.validateMove(overCap).valid).to.be.false; + g.move(atCap); + expect(g.board.size).to.equal(40); + }); + + it("rejects a simple-pie slice that names every point on the board", () => { + // Before the cap existed, this suicided the whole board back to the empty starting + // position, an illegal repeat that PSK is supposed to forbid. + const g = new KillAllGoGame(undefined, ["size-9", "pie"]); + const everyCell = allCells().join(","); + expect(g.validateMove(everyCell).valid).to.be.false; + expect(() => g.move(everyCell)).to.throw(); + }); + + it("stops alt-place's ordinary single-stone placements once the running total hits floor(p/2)", () => { + const g = play(new KillAllGoGame(undefined, ["size-9"]), allCells().slice(0, 40)); + expect(g.board.size).to.equal(40); + expect(g.moves().some((m) => /^[a-z]+\d+$/.test(m))).to.be.false; + expect(g.moves()).to.deep.equal(["attacker"]); + expect(g.validateMove(allCells()[40]).valid).to.be.false; + expect(() => g.move(allCells()[40])).to.throw(); + // The cap only blocks placing another stone; claiming the Attacker side still works. + g.move("attacker"); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("play"); + }); + + it("lets the handicap opening's claiming batch reach its own independent cap even after ordinary turns", () => { + // 20 ordinary turns (well under the 40-stone cap) followed by Player 2 claiming with + // the full 40-stone handicap: 20 + 40 = 60 stones, comfortably under all 81 points. + const g = play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["40", ...allCells().slice(0, 20)]); + expect(g.board.size).to.equal(20); + const batch = allCells().slice(20, 60).join(","); + expect(g.validateMove(`attacker:${batch}`).valid).to.be.true; + g.move(`attacker:${batch}`); + expect(g.board.size).to.equal(60); + expect(g.redSeat).to.equal(2); + }); + }); + + describe("classic opening", () => { + it("sets up the 17 traditional stones with the Defender (Player 1) to move", () => { + const g = new KillAllGoGame(undefined, ["classic"]); + const expected = ["j18", "c17", "q17", "d16", "j16", "p16", "b10", "d10", "j10", "p10", "r10", "d4", "j4", "p4", "c3", "q3", "j2"]; + expect(g.board.size).to.equal(17); + for (const cell of expected) { + expect(g.board.get(cell)).to.equal(RED); + } + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.redSeat).to.equal(2); + expect(g.getPlayerColour(1)).to.equal(2); + expect(g.getPlayerColour(2)).to.equal(1); + const moves = g.moves(); + expect(moves).to.include("pass"); + expect(moves.filter((m) => m !== "pass")).to.have.length(361 - 17); + }); + + it("is only available on the 19x19 board", () => { + const g = new KillAllGoGame(undefined, ["size-9", "classic"]); + expect(g.variants).to.deep.equal(["size-9"]); + expect(g.phase).to.equal("alt-place"); + expect(g.board.size).to.equal(0); + }); + }); + + describe("Pieboxing (default opening)", () => { + it("keeps the colours undecided until someone takes the Attacker side", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + expect(g.phase).to.equal("alt-place"); + expect(g.getPlayerColour(1)).to.equal("#999999"); + expect(g.getPlayerColour(2)).to.equal("#999999"); + g.move("d4"); + expect(g.board.get("d4")).to.equal(RED); + expect(g.currplayer).to.equal(2); + expect(g.stack[g.stack.length - 1]._results[0]).to.deep.include({ type: "place", where: "d4", what: "setup" }); + g.move("f6"); + expect(g.currplayer).to.equal(1); + expect(g.moves()).to.include("attacker"); + expect(g.moves()).to.not.include("d4"); + }); + + it("hands the first move to the other player as the Defender", () => { + const g = play(new KillAllGoGame(undefined, ["size-9"]), ["d4", "attacker"]); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.getPlayerColour(1)).to.equal(2); + expect(g.getPlayerColour(2)).to.equal(1); + g.move("e5"); + expect(g.board.get("e5")).to.equal(BLUE); + expect(g.currplayer).to.equal(2); + g.move("e4"); + expect(g.board.get("e4")).to.equal(RED); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2]); + }); + + it("lets Player 1 take the Attacker side at once", () => { + const g = attackerIsPlayerOne(); + expect(g.redSeat).to.equal(1); + expect(g.currplayer).to.equal(2); + expect(g.phase).to.equal("play"); + }); + + it("does not allow passing or stones alongside taking the Attacker side", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + expect(g.validateMove("pass").valid).to.be.false; + expect(g.validateMove("attacker:a1").valid).to.be.false; + expect(() => g.move("pass")).to.throw(); + }); + + it("offers the Attacker side as a button and places stones by clicking", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + expect(g.render().areas).to.be.undefined; + expect(g.getButtons()).to.deep.equal([{ label: "killallgo.attacker", move: "attacker" }]); + const cell = g.handleClick("", 8, 0); + expect(cell.valid).to.be.true; + expect(cell.move).to.equal("a1"); + }); + }); + + describe("handicap opening", () => { + it("makes Player 1 set the handicap within [1, floor(p/2)]", () => { + const g = new KillAllGoGame(undefined, ["size-9", "handicap"]); + expect(g.phase).to.equal("hand-n"); + expect(g.validateMove("0").valid).to.be.false; + expect(g.validateMove("41").valid).to.be.false; + expect(g.validateMove("abc").valid).to.be.false; + expect(g.validateMove("40").valid).to.be.true; + expect(g.validateMove("1").valid).to.be.true; + expect(g.moves()).to.have.length(40); + expect(g.handleClick("", 0, 0).valid).to.be.false; + g.move("3"); + expect(g.setup?.handicap).to.equal(3); + expect(g.phase).to.equal("alt-place"); + expect(g.currplayer).to.equal(2); + }); + + it("makes Player 2 place the handicap stones when they take the Attacker side", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["3"]); + expect(g.moves()).to.not.include("attacker"); + expect(g.validateMove("attacker").complete).to.equal(-1); + expect(g.validateMove("attacker:a1,b1").complete).to.equal(-1); + expect(g.validateMove("attacker:a1,b1,c1,d1").valid).to.be.false; + expect(g.validateMove("attacker:a1,a1,c1").valid).to.be.false; + expect(() => g.move("attacker:a1,b1")).to.throw(); + g.move("attacker:a1,b1,c1"); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.board.size).to.equal(3); + }); + + it("lets Player 1 take the Attacker side without extra stones", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["3", "e5", "attacker"]); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(2); + expect(g.board.size).to.equal(1); + expect(g.setup).to.deep.equal({ handicap: 3 }); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1]); + }); + }); + + describe("simple pie", () => { + it("lets Player 1 place any number of stones, then Player 2 take the Attacker side", () => { + const g = new KillAllGoGame(undefined, ["size-9", "pie"]); + expect(g.phase).to.equal("pie-slice"); + expect(g.validateMove("c3,g7").complete).to.equal(0); + g.move("c3,g7"); + expect(g.board.size).to.equal(2); + expect(g.phase).to.equal("pie-choose"); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.include("attacker"); + expect(g.moves()).to.include("defender:e5"); + g.move("attacker"); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + }); + + it("lets Player 2 choose the Defender side by placing the first stone", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), ["pass"]); + expect(g.board.size).to.equal(0); + expect(g.phase).to.equal("pie-choose"); + expect(g.validateMove("defender").complete).to.equal(-1); + const click = g.handleClick("defender", 4, 4); + expect(click.move).to.equal("defender:e5"); + g.move("defender:e5"); + expect(g.redSeat).to.equal(1); + expect(g.board.get("e5")).to.equal(BLUE); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2]); + }); + }); + + describe("generalized Hoctaph's pie", () => { + it("validates the batch sizes", () => { + const g = new KillAllGoGame(undefined, ["size-9", "hoctaph"]); + expect(g.phase).to.equal("hoc-slice"); + expect(g.validateMove("0,1").valid).to.be.false; + expect(g.validateMove("1,3").valid).to.be.false; + expect(g.validateMove("40,40").valid).to.be.false; + expect(g.validateMove("39,40").valid).to.be.true; + expect(g.validateMove("2,3").valid).to.be.true; + expect(g.validateMove("2,").valid).to.be.true; + expect(g.validateMove("2,").complete).to.equal(-1); + g.move("2,3"); + expect(g.setup).to.deep.equal({ a: 2, b: 3 }); + expect(g.phase).to.equal("hoc-option"); + expect(g.currplayer).to.equal(2); + }); + + it("option 2: the Slicer places the first batch and the Chooser picks the Defender side", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "youplace"]); + expect(g.phase).to.equal("hoc-batch-a"); + expect(g.currplayer).to.equal(1); + expect(g.validateMove("a1").complete).to.equal(-1); + expect(g.validateMove("a1,b1,c1").valid).to.be.false; + g.move("a1,b1"); + expect(g.phase).to.equal("hoc-choose"); + expect(g.currplayer).to.equal(2); + g.move("defender"); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("hoc-batch-b"); + expect(g.currplayer).to.equal(1); + g.move("c1,d1,e1"); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(2); + expect(g.board.size).to.equal(5); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2, 1]); + }); + + it("option 1: the Chooser places the first batch and the Slicer takes the Attacker side", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1"]); + expect(g.phase).to.equal("hoc-choose"); + expect(g.currplayer).to.equal(1); + expect(g.validateMove("attacker").complete).to.equal(-1); + g.move("attacker:c1,d1,e1"); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(2); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1]); + }); + + it("option 1: the Slicer picks the Defender side and the Chooser places the second batch", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1", "defender"]); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("hoc-batch-b"); + expect(g.currplayer).to.equal(2); + g.move("c1,d1,e1"); + expect(g.phase).to.equal("play"); + expect(g.currplayer).to.equal(1); + expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1, 2]); + }); + + it("accepts one-stone batches and keeps the batch sizes on the sidebar", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["1,2", "youplace"]); + expect(g.moves()).to.include("a1"); + g.move("a1"); + expect(g.phase).to.equal("hoc-choose"); + g.move("attacker:b1,c1"); + expect(g.phase).to.equal("play"); + expect(g.setup).to.deep.equal({ a: 1, b: 2 }); + const statuses = g.sidebarStatuses(); + expect(statuses.some((st) => (st.key as { textKey: string }).textKey === "apgames:status.killallgo.BATCHES")).to.be.true; + }); + + describe("the on-board batch-size picker", () => { + // Which legend key the rendered board puts on a cell. + const keyAt = (g: KillAllGoGame, cell: string): string => { + const rows = (g.render().pieces as string).split("\n"); + const [x, y] = g.algebraic2coords(cell); + return rows[y] === "_" ? "-" : rows[y].split(",")[x]; + }; + const slicing = (variants: string[], partial?: string): KillAllGoGame => { + const g = new KillAllGoGame(undefined, variants); + if (partial !== undefined) { g.move(partial, { partial: true }); } + return g; + }; + + it("lays the values out in two three-wide blocks with labels above them", () => { + const g = slicing(["size-9", "hoctaph"]); + // 9x9 offers 1 to floor(81/12) = 6, reading order, one line in from each corner. + const first = ["b8", "c8", "d8", "b7", "c7", "d7"]; + const second = ["f8", "g8", "h8", "f7", "g7", "h7"]; + first.forEach((cell, i) => expect(keyAt(g, cell), cell).to.equal(`n${i + 1}`)); + second.forEach((cell, i) => expect(keyAt(g, cell), cell).to.equal(`n${i + 1}`)); + expect(keyAt(g, "c9")).to.equal("la"); + expect(keyAt(g, "g9")).to.equal("lb"); + // A gap of empty points around each block, and nothing below them. + for (const cell of ["a8", "e8", "i8", "b9", "b6", "e5"]) { + expect(keyAt(g, cell), cell).to.equal("-"); + } + const legend = g.render().legend!; + expect(legend.n6).to.deep.equal([{ name: "piece", colour: 1 }, { text: "6", scale: 0.75, rotate: null }]); + expect(legend.la).to.deep.equal([{ name: "piece", colour: 1 }, { text: "a", scale: 0.75, rotate: null }]); + }); + + it("stops at floor(p/12) on every board size but still accepts larger typed sizes", () => { + for (const [variants, max] of [[["size-9"], 6], [["size-13"], 14], [[], 30]] as Array<[string[], number]>) { + const g = slicing([...variants, "hoctaph"]); + const rendered = (g.render().pieces as string); + expect(rendered.includes(`n${max}`), `max ${max}`).to.be.true; + expect(rendered.includes(`n${max + 1}`), `beyond ${max}`).to.be.false; + } + const big = slicing(["hoctaph"]); + expect(big.validateMove("40,35").valid).to.be.true; + }); + + it("picks the first batch on the left and the second on the right", () => { + const g = slicing(["size-9", "hoctaph"]); + const first = g.handleClick("", 1, 1); // b8 = first batch, 1 + expect(first.valid).to.be.true; + expect(first.move).to.equal("1"); + expect(first.complete).to.equal(-1); + const second = g.handleClick(first.move, 1, 6); // g8 = second batch, 2 + expect(second.move).to.equal("1,2"); + expect(second.complete).to.equal(0); + g.move("1,2"); + expect(g.setup).to.deep.equal({ a: 1, b: 2 }); + expect(g.phase).to.equal("hoc-option"); + }); + + it("shades the values the other batch size forbids", () => { + const g = slicing(["size-9", "hoctaph"], "1"); + expect(g.setup).to.deep.equal({ a: 1, b: undefined }); + // With a first batch of 1, a second batch may only be 1 or 2. + expect(keyAt(g, "f8")).to.equal("n1"); + expect(keyAt(g, "g8")).to.equal("n2"); + expect(keyAt(g, "h8")).to.equal("d3"); + expect(keyAt(g, "d7")).to.equal("n6"); + const legend = g.render().legend!; + expect(legend.d3).to.deep.equal([ + { name: "piece", colour: 1, opacity: 0.5 }, + { text: "3", scale: 0.75, rotate: null, opacity: 0.5 }, + ]); + }); + + it("lets a shaded value be picked, dropping the choice that forbade it", () => { + const g = slicing(["size-9", "hoctaph"], "1"); + const click = g.handleClick("1", 1, 7); // h8 = second batch, 3, shaded while the first is 1 + expect(click.valid).to.be.true; + expect(click.move).to.equal(",3"); + const after = slicing(["size-9", "hoctaph"], ",3"); + expect(after.setup).to.deep.equal({ a: undefined, b: 3 }); + // A second batch of 3 now forbids a first batch of 1. + expect(keyAt(after, "b8")).to.equal("d1"); + expect(keyAt(after, "c8")).to.equal("n2"); + expect(after.handleClick(",3", 1, 2).move).to.equal("2,3"); + }); + + it("ignores clicks away from the blocks and shows the picker only while slicing", () => { + const g = slicing(["size-9", "hoctaph"]); + const stray = g.handleClick("", 4, 4); + expect(stray.valid).to.be.false; + expect(stray.move).to.equal(""); + const later = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3"]); + expect(later.phase).to.equal("hoc-option"); + expect((later.render().pieces as string).includes("n1")).to.be.false; + expect(Object.keys(later.render().legend!)).to.deep.equal(["A", "B"]); + }); + }); + + it("builds batches by clicking", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3"]); + const first = g.handleClick("", 8, 0); + expect(first.move).to.equal("iplace:a1"); + const second = g.handleClick(first.move, 8, 1); + expect(second.move).to.equal("iplace:a1,b1"); + expect(second.complete).to.equal(1); + const undo = g.handleClick(second.move, 8, 1); + expect(undo.move).to.equal("iplace:a1"); + }); + }); + + describe("opening buttons", () => { + it("offers one fixed choice per opening phase and none once play starts", () => { + const cases: Array<[string[], string[], Array<{ label: string; move: string }>]> = [ + [["size-9"], [], [{ label: "killallgo.attacker", move: "attacker" }]], + [["size-9", "handicap"], [], []], + [["size-9", "handicap"], ["2"], [{ label: "killallgo.attacker", move: "attacker" }]], + [["size-9", "pie"], [], [{ label: "pass", move: "pass" }]], + [["size-9", "pie"], ["c3"], [{ label: "killallgo.attacker", move: "attacker" }]], + [["size-9", "hoctaph"], ["2,3"], [{ label: "killallgo.youplace", move: "youplace" }]], + [["size-9", "hoctaph"], ["2,3", "youplace"], []], + [["size-9", "hoctaph"], ["2,3", "youplace", "a1,b1"], [{ label: "killallgo.defender", move: "defender" }]], + [["classic"], [], [{ label: "pass", move: "pass" }]], + ]; + for (const [variants, moves, buttons] of cases) { + const g = play(new KillAllGoGame(undefined, variants), moves); + expect(g.getButtons(), `${variants.join("+")} after ${moves.join(" ")}`).to.deep.equal(buttons); + expect(g.render().areas, `${variants.join("+")} areas`).to.be.undefined; + } + }); + + it("finishes an incomplete Attacker button press with board clicks", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["3"]); + const staged = g.validateMove("attacker"); + expect(staged.valid).to.be.true; + expect(staged.complete).to.equal(-1); + let move = "attacker"; + for (const [row, col] of [[8, 0], [8, 1], [8, 2]] as Array<[number, number]>) { + const click = g.handleClick(move, row, col); + expect(click.valid).to.be.true; + move = click.move; + } + expect(move).to.equal("attacker:a1,b1,c1"); + expect(g.validateMove(move).complete).to.equal(1); + g.move(move); + expect(g.redSeat).to.equal(2); + expect(g.phase).to.equal("play"); + }); + + it("takes the Attacker side in Hoctaph by clicking the second batch", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1"]); + let move = ""; + for (const [row, col] of [[8, 2], [8, 3], [8, 4]] as Array<[number, number]>) { + move = g.handleClick(move, row, col).move; + } + expect(move).to.equal("attacker:c1,d1,e1"); + g.move(move); + expect(g.redSeat).to.equal(1); + expect(g.phase).to.equal("play"); + }); + }); + + describe("play", () => { + // Attacker stones on whole rows of the 9x9 board, optionally leaving some points out. + const rowsOf = (rows: number[], except: string[] = []): string => + rows.flatMap((r) => "abcdefghi".split("").map((c) => `${c}${r}`)).filter((c) => !except.includes(c)).join(","); + + it("ends for the Attacker once their living stones leave the Defender no room", () => { + // Full rows 8, 6, 4 and 2 live unconditionally, and every other point touches them. + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), [rowsOf([8, 6, 4, 2]), "attacker"]); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect(g.stack[g.stack.length - 1]._results).to.deep.include({ type: "eog", reason: "no-room" }); + expect(g.alive!.length).to.equal(36); + const enters = g.render().annotations!.filter((a) => a.type === "enter"); + expect(enters.some((a) => (a as { targets: unknown[] }).targets.length === 36)).to.be.true; + const keys = g.chatLogEntries(["Alice", "Bob"]).flatMap((e) => e.lines.map((l) => l.textKey)); + expect(keys).to.include("apresults:EOG.killallgo_no_room"); + }); + + it("notices the Attacker's win on the move that completes it", () => { + // With e2 missing, row 2 is two chains and nothing lives yet. + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), [rowsOf([8, 6, 4, 2], ["e2"]), "attacker"]); + expect(g.gameover).to.be.false; + expect(g.currplayer).to.equal(1); + g.move("a1"); + expect(g.gameover).to.be.false; + g.move("e2"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect(g.stack[g.stack.length - 1]._results).to.deep.include({ type: "eog", reason: "no-room" }); + }); + + // A corner string with two one-point eyes, a9 and c9: unconditionally alive. + const livingCorner = "b9,d9,a8,b8,c8,d8"; + + it("keeps playing while the Attacker's living stones still leave room", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), [livingCorner, "attacker"]); + expect(passAliveStrings(g.board, makeGeometry(9), RED).flat().sort()).to.deep.equal(livingCorner.split(",").sort()); + expect(g.gameover).to.be.false; + expect(g.phase).to.equal("play"); + }); + + it("treats only unconditionally alive Attacker stones as obstacles", () => { + // A solid 5x5 Attacker block clear of the corner has no eyes, so it could still be + // captured, and the free points it covers still count as room for the Defender. + const block = "cdefg".split("").flatMap((c) => [1, 2, 3, 4, 5].map((r) => `${c}${r}`)).join(","); + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), [`${livingCorner},${block}`, "attacker"]); + const permanent = passAliveStrings(g.board, makeGeometry(9), RED).flat(); + expect(permanent.sort()).to.deep.equal(livingCorner.split(",").sort()); + expect(g.gameover).to.be.false; + }); + + it("ends at once when a Defender string becomes pass-alive", () => { + const g = attackerIsPlayerOne(); + play(g, ["a2", "pass", "b2", "pass", "c2", "pass", "d2", "pass", "d1", "pass"]); + expect(g.gameover).to.be.false; + g.move("b1"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect(g.alive!.sort()).to.deep.equal(["a2", "b1", "b2", "c2", "d1", "d2"]); + const results = g.stack[g.stack.length - 1]._results; + expect(results).to.deep.include({ type: "eog", reason: "pass-alive" }); + const rep = g.render(); + const enters = rep.annotations!.filter((a) => a.type === "enter"); + expect(enters.some((a) => (a as { targets: unknown[] }).targets.length === 6)).to.be.true; + }); + + it("gives the Defender no way to win but a pass-alive string", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a9", "e6", "b9"]); + // Seki and ko are not recognised, so there is nothing to claim and no claim notation. + expect(g.validateMove("claim:e5").valid).to.be.false; + expect(g.validateMove("claim").valid).to.be.false; + expect(g.handleClick("", 4, 4).move).to.equal(""); + expect(g.moves().filter((m) => m !== "pass").every((m) => /^[a-z]+\d+$/.test(m))).to.be.true; + }); + + it("ends with an Attacker win after two consecutive passes", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a9", "pass"]); + expect(g.gameover).to.be.false; + expect(g.validateMove("pass").message).to.not.equal(""); + g.move("pass"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + expect(g.stack[g.stack.length - 1]._results).to.deep.include({ type: "eog", reason: "double-pass" }); + }); + + it("does not treat a pass followed by a stone as consecutive passes", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "pass", "e6", "pass"]); + expect(g.gameover).to.be.false; + expect(g.currplayer).to.equal(2); + }); + + it("forbids recreating an earlier position (positional superko)", () => { + const g = attackerIsPlayerOne(); + // Blue: c5, b4, c3 around c4; Red: d5, e4, d3 around d4; Red throws in at c4, Blue captures with d4. + play(g, ["c5", "d5", "b4", "e4", "c3", "d3", "a1", "c4", "d4"]); + expect(g.board.has("c4")).to.be.false; + expect(g.board.get("d4")).to.equal(BLUE); + expect(g.currplayer).to.equal(1); + expect(g.validateMove("c4").valid).to.be.false; + expect(g.moves()).to.not.include("c4"); + play(g, ["a9", "b9"]); + expect(g.validateMove("c4").valid).to.be.true; + g.move("c4"); + expect(g.board.has("d4")).to.be.false; + }); + + it("applies a multi-stone suicide (Tromp-Taylor clearing)", () => { + const g = attackerIsPlayerOne(); + // Red walls off a1 and b1 with a2, b2, c1. Blue plays a1 (one liberty at b1), then b1: both stones are removed. + play(g, ["e5", "a2", "e6", "b2", "e7", "c1", "a1", "f5"]); + expect(g.validateMove("b1").valid).to.be.true; + g.move("b1"); + expect(g.board.has("a1")).to.be.false; + expect(g.board.has("b1")).to.be.false; + const results = g.stack[g.stack.length - 1]._results; + expect(results).to.deep.include({ type: "capture", where: "b1,a1", count: 2, how: "suicide" }); + expect(g.currplayer).to.equal(1); + }); + + it("rejects a single-stone suicide because the position would repeat", () => { + const g = attackerIsPlayerOne(); + play(g, ["e5", "a2", "e6", "b1"]); + expect(g.validateMove("a1").valid).to.be.false; + expect(g.moves()).to.not.include("a1"); + expect(g.moves()).to.include("c1"); + }); + + it("offers the pass button only while the board is in play", () => { + const g = attackerIsPlayerOne(); + expect(g.getButtons()).to.deep.equal([{ label: "pass", move: "pass" }]); + play(g, ["e5", "a9", "pass", "pass"]); + expect(g.gameover).to.be.true; + expect(g.getButtons()).to.deep.equal([]); + }); + + it("reports its statuses with structured labels", () => { + const g = new KillAllGoGame(undefined, ["size-9"]); + const statuses = g.sidebarStatuses(); + expect(statuses[0].key).to.deep.include({ textKey: "apgames:status.killallgo.ATTACKER" }); + expect(statuses[0].value[0]).to.deep.include({ textKey: "apgames:status.killallgo.UNDECIDED" }); + g.move("attacker"); + const after = g.sidebarStatuses(); + expect(after[0].value[0]).to.deep.include({ textKey: "apgames:status._player", actor: { kind: "seat", seat: 1 } }); + expect(after[1].value[0]).to.deep.include({ actor: { kind: "seat", seat: 2 } }); + }); + }); + + describe("records and chat", () => { + it("writes structured chat lines for the protocol actions", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1", "attacker:c1,d1,e1", "e5", "a9", "pass", "pass"]); + const entries = g.chatLogEntries(["Alice", "Bob"]); + const keys = entries.flatMap((e) => e.lines.map((l) => l.textKey)); + expect(keys).to.include("apresults:ANNOUNCE.killallgo_slice"); + expect(keys).to.include("apresults:SELECT.killallgo_iplace"); + expect(keys).to.include("apresults:CLAIM.killallgo_attacker"); + expect(keys).to.include("apresults:PLACE.killallgo_setup"); + expect(keys).to.include("apresults:EOG.killallgo_double_pass"); + const text = g.chatLog(["Alice", "Bob"]).flat().join("\n"); + expect(text).to.include("Alice chose to play as the Attacker."); + expect(text).to.include("Alice placed an Attacker stone at c1."); + }); + + it("round-trips through serialization in every phase", () => { + const games = [ + new KillAllGoGame(undefined, ["size-9"]), + play(new KillAllGoGame(undefined, ["size-9", "handicap"]), ["2"]), + play(new KillAllGoGame(undefined, ["size-9", "pie"]), ["c3"]), + play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "youplace"]), + play(attackerIsPlayerOne(), ["e5", "a9"]), + ]; + for (const g of games) { + const copy = new KillAllGoGame(g.serialize()); + expect(copy.phase).to.equal(g.phase); + expect(copy.currplayer).to.equal(g.currplayer); + expect(copy.redSeat).to.equal(g.redSeat); + expect(copy.setup).to.deep.equal(g.setup); + expect([...copy.board.entries()]).to.deep.equal([...g.board.entries()]); + expect(copy.moves()).to.deep.equal(g.moves()); + } + }); + }); +});