diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 725745ba..7268b7a0 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -139,7 +139,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.", + "killallgo": "A Go variant with asymmetric goals: the Attacker starts with stones on the board and must prevent the Defender from establishing unconditional life. The Defender wins by forming just one unconditionally alive string.", "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.", @@ -641,7 +641,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.", + "killallgo": "The Defender must form an unconditionally alive string (pass-alive according to Benson's algorithm): one that can survive any sequence of opposing moves without responding. Life dependent on seki or ko does not count. The Attacker wins when their unconditionally alive stones leave no room for such a string. Passing is not allowed after the opening.\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. Multi-stone suicide is therefore legal. Positional superko applies: no placement may recreate an earlier board position.", "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.", @@ -2454,7 +2454,7 @@ }, "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." + "description": "Pieboxing with a handicap. Player 1, the stronger player, sets the number of extra Attacker stones, then Player 2 starts placing. If Player 2 chooses to play as the Attacker, they place the extra stones immediately. Devised by MXHero." }, "classic": { "name": "Classic 17-stone setup", @@ -2466,7 +2466,7 @@ }, "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." + "description": "Player 1 chooses two batch sizes, and Player 2 decides who places the first batch. The player who did not place that batch then chooses a side, and the Attacker places the second batch. Hoctaph's pie (OGS forums), generalized to every board size by Samraku." } }, "knightline": { @@ -6743,31 +6743,31 @@ "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.", + "INSTRUCTIONS_HANDICAP": "You are giving the handicap. Click a numbered stone, or enter a number from 1 to {{max}} manually, to set how many extra Attacker stones 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.", + "HANDICAP_OK_one": "Handicap of one stone. Submit, or choose a different handicap.", + "HANDICAP_OK_other": "Handicap of {{count}} stones. Submit, or choose a different handicap.", + "NOT_A_NUMBERED_STONE": "{{where}} is not one of the numbered stones.", + "INSTRUCTIONS_ALT": "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, your opponent moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, place one extra stone; your opponent then moves first as the Defender.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Place an Attacker stone to continue the opening, or choose to play as the Attacker. If you choose the Attacker, place {{count}} extra stones; your opponent then moves first as the Defender.", "SETUP_FULL": "The board already holds the maximum number of setup stones this phase allows ({{max}}); take the Attacker side instead of placing another.", "ATTACKER_NO_STONES": "Taking the Attacker side does not involve placing stones now.", "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_TOO_MANY_one": "This batch must contain exactly one stone.", + "BATCH_TOO_MANY_other": "This batch must contain 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.", + "INSTRUCTIONS_HOC_SLICE": "Choose the sizes of two batches of Attacker stones: the first batch will be placed before sides are chosen, and the Attacker will then place the second. Click a numbered stone in the upper block for the first batch and one in the lower block for the second, or enter both sizes manually in that order, separated by a comma.", + "SLICE_FORMAT": "Manually entered batch sizes must be 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.", @@ -6775,18 +6775,16 @@ "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.", + "SLICE_OK": "Batches of {{a}} and {{b}} stones. Submit, or choose different sizes.", "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." + "INSTRUCTIONS_PLAY_ATTACKER": "Place a stone. You win by preventing the Defender from forming an unconditionally alive string.", + "INSTRUCTIONS_PLAY_DEFENDER": "Place a stone. You win by forming one unconditionally alive string.", + "KO_PSK": "That placement would recreate an earlier position of the board (positional superko)." }, "knightline": { "BAD_PASS": "You may not pass if legal moves are available.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index e58c705d..72ba1361 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -305,8 +305,8 @@ "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.", - "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_double_pass": "The game has ended because both players passed consecutively without the Defender establishing unconditional life.", + "killallgo_no_room": "The game has ended because the Attacker's unconditionally alive stones leave the Defender no room to form an unconditionally alive string.", "killallgo_pass_alive": "The game has ended because the Defender formed an unconditionally alive string." }, "DRUID_HEX_EDGE": { diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 2d1f68fa..a9251533 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -3595,7 +3595,7 @@ }, "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." + "description": "Pieboxing kun handikapo. Ludanto 1, la pli forta ludanto, fiksas la nombron de pliaj atakantaj ŝtonoj, poste Ludanto 2 komencas la metadon. Se Ludanto 2 elektas ludi kiel la atakanto, tiu tuj metas la pliajn ŝtonojn. Elpensita de MXHero." }, "classic": { "name": "Klasika aranĝo de 17 ŝtonoj", @@ -3607,7 +3607,7 @@ }, "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." + "description": "Ludanto 1 elektas la grandojn de du aroj, kaj Ludanto 2 decidas, kiu metas la unuan aron. La ludanto, kiu ne metis tiun aron, tiam elektas flankon, kaj la atakanto metas la duan aron. La torto de Hoctaph (forumoj de OGS), ĝeneraligita al ĉiu tabulgrando de Samraku." } }, "mirador": { @@ -3760,7 +3760,7 @@ "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_SLICE": "{{player}} metas la komencajn 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", @@ -4562,7 +4562,7 @@ "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.", - "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." + "killallgo": "Ĉiomortiga Goo (angle Kill-All Go): variaĵo de Goo kun nesimetriaj celoj. La atakanto komencas kun ŝtonoj sur la tabulo kaj devas malhelpi la defendanton establi senkondiĉan vivon. La defendanto venkas formante eĉ nur unu senkondiĉe vivan ĉenon." }, "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).", @@ -4665,7 +4665,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.", + "killallgo": "La defendanto devas formi senkondiĉe vivan ĉenon laŭ la algoritmo de Benson: ĉenon, kiu povas elteni ajnan sinsekvon de kontraŭulaj movoj sen respondo. Vivo dependa de seki aŭ koo ne sufiĉas. La atakanto venkas, kiam ties senkondiĉe vivaj ŝtonoj lasas neniun spacon por tia ĉeno. Preterpaso ne estas permesita post la malfermo.\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. Plurŝtona memmortigo do estas permesita. Validas pozicia superko: neniu meto rajtas rekrei pli fruan tabulpozicion.", "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": { @@ -7802,31 +7802,31 @@ "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.", + "INSTRUCTIONS_HANDICAP": "Vi donas la handikapon. Alklaku numeritan ŝtonon, aŭ permane enigu nombron de 1 ĝis {{max}}, por fiksi, kiom da pliaj atakantaj ŝtonoj 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.", + "HANDICAP_OK_one": "Handikapo de unu ŝtono. Sendu, aŭ elektu alian handikapon.", + "HANDICAP_OK_other": "Handikapo de {{count}} ŝtonoj. Sendu, aŭ elektu alian handikapon.", + "NOT_A_NUMBERED_STONE": "{{where}} ne estas unu el la numeritaj ŝtonoj.", + "INSTRUCTIONS_ALT": "Metu atakantan ŝtonon por daŭrigi la malfermon, aŭ elektu ludi kiel la atakanto. Se vi elektas tiun flankon, via kontraŭulo movos unue kiel la defendanto.", + "INSTRUCTIONS_ALT_HANDICAP_one": "Metu atakantan ŝtonon por daŭrigi la malfermon, aŭ elektu ludi kiel la atakanto. Se vi elektas tiun flankon, metu unu plian ŝtonon; via kontraŭulo tiam movos unue kiel la defendanto.", + "INSTRUCTIONS_ALT_HANDICAP_other": "Metu atakantan ŝtonon por daŭrigi la malfermon, aŭ elektu ludi kiel la atakanto. Se vi elektas tiun flankon, 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_TOO_MANY_one": "Ĉi tiu aro devas enhavi ekzakte unu ŝtonon.", + "BATCH_TOO_MANY_other": "Ĉi tiu aro devas enhavi 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.", + "INSTRUCTIONS_HOC_SLICE": "Elektu la grandojn de du aroj da atakantaj ŝtonoj: la unua aro estos metita antaŭ la elekto de flankoj, kaj poste la atakanto metos la duan. Alklaku numeritan ŝtonon en la supra bloko por la unua aro kaj unu en la suba bloko por la dua, aŭ permane enigu ambaŭ grandojn en tiu ordo, apartigitajn per komo.", + "SLICE_FORMAT": "Permane enigitaj grandoj de aroj devas esti du entjeroj apartigitaj 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.", @@ -7834,18 +7834,16 @@ "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.", + "SLICE_OK": "Aroj de {{a}} kaj {{b}} ŝtonoj. Sendu, aŭ elektu aliajn grandojn.", "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." + "INSTRUCTIONS_PLAY_ATTACKER": "Metu ŝtonon. Vi venkas malhelpante la defendanton formi senkondiĉe vivan ĉenon.", + "INSTRUCTIONS_PLAY_DEFENDER": "Metu ŝtonon. Vi venkas formante unu senkondiĉe vivan ĉenon.", + "KO_PSK": "Tiu meto rekreus pli fruan pozicion de la tabulo (pozicia superko)." } }, "INITIAL_UNDO": "Vi ne povas malfari la komencan pozicion de la ludo.", diff --git a/locales/eo/apresults.json b/locales/eo/apresults.json index 54b81fbb..2dcc27f7 100644 --- a/locales/eo/apresults.json +++ b/locales/eo/apresults.json @@ -383,8 +383,8 @@ "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.", - "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_double_pass": "La ludo finiĝis, ĉar ambaŭ ludantoj sinsekve preterpasis kaj la defendanto ne establis senkondiĉan vivon.", + "killallgo_no_room": "La ludo finiĝis, ĉar la senkondiĉe vivaj ŝtonoj de la atakanto lasas al la defendanto neniun spacon por formi senkondiĉe vivan ĉenon.", "killallgo_pass_alive": "La ludo finiĝis, ĉar la defendanto faris senkondiĉe vivantan ĉenon." }, "DRUID_HEX_EDGE": { diff --git a/src/games/killallgo.ts b/src/games/killallgo.ts index 292edc69..479e1508 100644 --- a/src/games/killallgo.ts +++ b/src/games/killallgo.ts @@ -369,16 +369,19 @@ interface ILegend { [key: string]: Glyph | [Glyph, ...Glyph[]]; } -/** One numbered stone of the on-board batch-size picker. */ -interface ISliceButton { +/** One numbered stone of an on-board picker. */ +interface IPickerButton { cell: string; value: number; } -interface ISliceLayout { - a: ISliceButton[]; - b: ISliceButton[]; - labels: [string, string]; +/** A picker as the current phase shows it. */ +interface IPicker { + buttons: IPickerButton[]; + /** The value currently chosen in this picker, if any. */ + chosen?: number; + /** Whether a value fits with what is chosen elsewhere. */ + allowed: (value: number) => boolean; } export interface IMoveState extends IIndividualState { @@ -435,7 +438,7 @@ export class KillAllGoGame extends GameBase { ], variants: [ { uid: "size-9", group: "board" }, - { uid: "size-13", group: "board" }, + { uid: "size-13", group: "board", default: true }, { uid: "#board" }, { uid: "#opening", @@ -687,32 +690,79 @@ export class KillAllGoGame extends GameBase { } /** - * 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. + * The picker offered during the Hoctaph slice: the values 1 to ceil(2p/25) for each batch, the + * first batch above the centre point and the second mirrored below it, in a centred block nine + * wide (or as wide as the values, when there are fewer). 1 to 9 run left to right along the + * second row out from the centre point, leaving three empty rows between the blocks; from there + * the values snake away from the centre, two stones out along the end column and then back + * across the row, so 10 and 11 sit beyond 9, 12 to 19 run right to left from 11, 20 and 21 sit + * beyond 19, and so on. Larger sizes are still entered manually. */ - 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 }); + private sliceLayout(): { a: IPickerButton[]; b: IPickerButton[] } { + const max = Math.ceil((2 * this.points) / 25); + const width = Math.min(9, max); + const centre = Math.floor(this.boardSize / 2); + const left = centre - Math.floor(width / 2); + /** Where a value goes, as [rows out from the centre, column across the block]. */ + const place = (value: number): [number, number] => { + if (value <= width) { + return [2, value - 1]; } - return out; + // Each lap after the first row is a stone on the end column plus a row running back. + const lap = Math.floor((value - width - 1) / (width + 1)); + const step = (value - width - 1) % (width + 1); + const fromLeft = lap % 2 === 1; + if (step === 0) { + return [3 + 2 * lap, fromLeft ? 0 : width - 1]; + } + return [4 + 2 * lap, fromLeft ? step - 1 : width - step]; }; - return { - a: block(aCols), - b: block(bCols), - labels: [this.coords2algebraic(aCols[1], 0), this.coords2algebraic(bCols[1], 0)], + const block = (direction: -1 | 1): IPickerButton[] => { + const buttons: IPickerButton[] = []; + for (let value = 1; value <= max; value++) { + const [out, col] = place(value); + buttons.push({ cell: this.coords2algebraic(left + col, centre + direction * out), value }); + } + return buttons; }; + return { a: block(-1), b: block(1) }; + } + + /** + * The picker offered while the handicap is set: the handicaps 1 to 18 in two centred rows of + * nine, the odd values on the row just above the centre point and the even values on the row + * just below it, so each column holds a consecutive pair. Larger handicaps are still entered + * manually. + */ + private handicapLayout(): IPickerButton[] { + const centre = Math.floor(this.boardSize / 2); + const left = centre - 4; + const buttons: IPickerButton[] = []; + for (let value = 1; value <= Math.min(18, this.maxSetupStones()); value++) { + const row = value % 2 === 1 ? centre - 1 : centre + 1; + buttons.push({ cell: this.coords2algebraic(left + Math.floor((value - 1) / 2), row), value }); + } + return buttons; + } + + /** The on-board pickers of the current phase: the handicap, or the two Hoctaph batch sizes. */ + private pickers(): IPicker[] { + if (this.gameover) { + return []; + } + if (this.phase === "hand-n") { + return [{ buttons: this.handicapLayout(), chosen: this.setup?.handicap, allowed: () => true }]; + } + if (this.phase === "hoc-slice") { + const layout = this.sliceLayout(); + const a = this.setup?.a; + const b = this.setup?.b; + return [ + { buttons: layout.a, chosen: a, allowed: (v) => b === undefined || this.sliceAllowed(v, b) }, + { buttons: layout.b, chosen: b, allowed: (v) => a === undefined || this.sliceAllowed(a, v) }, + ]; + } + return []; } /** The two batch sizes named by a complete or partial slice move. */ @@ -794,7 +844,6 @@ export class KillAllGoGame extends GameBase { 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) { @@ -839,7 +888,6 @@ export class KillAllGoGame extends GameBase { case "hoc-choose": return [{ label: "apgames:buttons.killallgo.defender", move: "defender" }]; case "pie-slice": - case "play": return [{ label: "apgames:buttons.pass", move: "pass" }]; default: return []; @@ -851,11 +899,11 @@ export class KillAllGoGame extends GameBase { 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"; + // Only the two pickers ignore clicks, away from their numbered stones. return { move, valid: false, - message: i18next.t(`apgames:validation.killallgo.${key}`), + message: i18next.t("apgames:validation.killallgo.NOT_A_NUMBERED_STONE", { where: cell }), }; } const result = this.validateMove(newmove) as IClickResult; @@ -879,10 +927,9 @@ export class KillAllGoGame extends GameBase { }; switch (this.phase) { case "hand-n": - return undefined; + return this.handicapLayout().find((btn) => btn.cell === cell)?.value.toString(); 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); @@ -1017,7 +1064,7 @@ export class KillAllGoGame extends GameBase { 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 })); + return this.ok(result, 0, i18next.t("apgames:validation.killallgo.HANDICAP_OK", { count: n }), true); } private validateAltPlace(m: string, result: IValidationResult): IValidationResult { @@ -1089,14 +1136,14 @@ export class KillAllGoGame extends GameBase { 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); + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE"), 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); + return this.ok(result, -1, i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE"), true); } if (b === undefined) { return this.ok(result, -1, i18next.t("apgames:validation.killallgo.SLICE_A_ONLY", { count: a }), true); @@ -1179,10 +1226,7 @@ export class KillAllGoGame extends GameBase { 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.fail(result, i18next.t("apgames:validation.killallgo.INVALID_PASS")); } return this.validatePlacement(m, colour, result); } @@ -1322,20 +1366,10 @@ export class KillAllGoGame extends GameBase { 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); - } + const colour = this.colourOfSeat(this.currplayer)!; + this.placeStone(m, colour, seen); + if (partial) { return this; } + this.currplayer = this.otherSeat(this.currplayer); break; } } @@ -1397,9 +1431,9 @@ export class KillAllGoGame extends GameBase { /** * 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. + * happen: the Attacker's pass-alive stones cannot be captured by the Defender, 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") { @@ -1462,19 +1496,14 @@ export class KillAllGoGame extends GameBase { } /** - * 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. + * The numbered stones of the current pickers, added to `legend` and returned as a cell map, + * along with the cells of the values chosen so far. In the Hoctaph slice, 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 { + private pickerOverlay(legend: ILegend): { overlay: Map; chosen: string[] } { const overlay = new Map(); - if (this.gameover || this.phase !== "hoc-slice") { - return overlay; - } - const layout = this.sliceLayout(); - if (layout === undefined) { - return overlay; - } + const chosen: string[] = []; const stone = (text: string, dimmed: boolean): [Glyph, ...Glyph[]] => { const piece: Glyph = { name: "piece", colour: 1 }; const label: Glyph = { text, scale: 0.75, rotate: null }; @@ -1484,22 +1513,18 @@ export class KillAllGoGame extends GameBase { } 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)); + for (const picker of this.pickers()) { + for (const btn of picker.buttons) { + const allowed = picker.allowed(btn.value); const key = `${allowed ? "n" : "d"}${btn.value}`; legend[key] = stone(btn.value.toString(), !allowed); overlay.set(btn.cell, key); + if (btn.value === picker.chosen) { + chosen.push(btn.cell); + } } } - legend.la = stone("a", false); - legend.lb = stone("b", false); - overlay.set(layout.labels[0], "la"); - overlay.set(layout.labels[1], "lb"); - return overlay; + return { overlay, chosen }; } public render(): APRenderRep { @@ -1507,7 +1532,7 @@ export class KillAllGoGame extends GameBase { A: [{ name: "piece", colour: 1 }], B: [{ name: "piece", colour: 2 }], }; - const overlay = this.sliceOverlay(legend); + const { overlay, chosen } = this.pickerOverlay(legend); let pstr = ""; if (overlay.size > 0) { @@ -1569,6 +1594,9 @@ export class KillAllGoGame extends GameBase { if (this.gameover && this.alive !== undefined && this.alive.length > 0) { annotations.push({ type: "enter", targets: this.alive.map(toRowCol) as [RowCol, ...RowCol[]] }); } + for (const cell of chosen) { + annotations.push({ type: "enter", targets: [toRowCol(cell)] }); + } if (annotations.length > 0) { rep.annotations = annotations; } @@ -1651,6 +1679,7 @@ export class KillAllGoGame extends GameBase { } else if (r.reason === "no-room") { this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_no_room"); } else if (r.reason === "double-pass") { + // Preserve the move log for games completed before normal-play passes were removed. this.pushNeutralChatLine(lines, "apresults:EOG.killallgo_double_pass"); } else { this.pushNeutralChatLine(lines, "apresults:EOG.default"); diff --git a/test/games/killallgo.test.ts b/test/games/killallgo.test.ts index 9c607c91..772068c6 100644 --- a/test/games/killallgo.test.ts +++ b/test/games/killallgo.test.ts @@ -20,6 +20,20 @@ const attackerIsPlayerOne = (variants: string[] = ["size-9"]): KillAllGoGame => return g; }; +/** 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]; +}; + +/** The cells the rendered board marks with an `enter` annotation. */ +const entered = (g: KillAllGoGame): string[] => + (g.render().annotations ?? []) + .filter((a) => a.type === "enter") + .flatMap((a) => (a as { targets: Array<{ row: number; col: number }> }).targets) + .map(({ row, col }) => g.coords2algebraic(col, row)); + // `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; @@ -318,6 +332,13 @@ describe("Kill-All Go", () => { expect(handicap?.people?.some((p) => p.name === "MXHero")).to.be.true; }); + it("preselects the 13x13 board", () => { + const boards = (KillAllGoGame.gameinfo.variants ?? []).filter((v) => v.group === "board"); + expect(boards.filter((v) => v.default === true).map((v) => v.uid)).to.deep.equal(["size-13"]); + // Games created without a board variant are still 19x19. + expect(new KillAllGoGame(undefined, []).render().board).to.deep.include({ width: 19, height: 19 }); + }); + it("is experimental and cannot be rated with a handicap", () => { const flags = KillAllGoGame.gameinfo.flags ?? []; expect(flags).to.include("experimental"); @@ -399,8 +420,8 @@ describe("Kill-All Go", () => { 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); + expect(moves).to.not.include("pass"); + expect(moves).to.have.length(361 - 17); }); it("is only available on the 19x19 board", () => { @@ -509,6 +530,63 @@ describe("Kill-All Go", () => { expect(g.setup).to.deep.equal({ handicap: 3 }); expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2, 1]); }); + + describe("the on-board handicap picker", () => { + it("lays 1 to 18 out in two centred rows, odd values above the centre point and even ones below", () => { + const g = new KillAllGoGame(undefined, ["size-9", "handicap"]); + // Nine columns span the whole 9x9 board; each column holds a consecutive pair. + "abcdefghi".split("").forEach((col, i) => { + expect(keyAt(g, `${col}6`), `${col}6`).to.equal(`n${2 * i + 1}`); + expect(keyAt(g, `${col}4`), `${col}4`).to.equal(`n${2 * i + 2}`); + }); + // The centre row between them stays empty, and there is no label stone. + for (const cell of ["a5", "e5", "i5", "e7", "e3"]) { + expect(keyAt(g, cell), cell).to.equal("-"); + } + expect(Object.keys(g.render().legend!).filter((k) => k.startsWith("l"))).to.deep.equal([]); + expect(entered(g)).to.deep.equal([]); + }); + + it("centres the same 1 to 18 on every board and still accepts larger typed handicaps", () => { + // [variants, cells of 1 and 2, cells of 17 and 18, floor(p/2)] + const cases: Array<[string[], string[], string[], number]> = [ + [["size-9"], ["a6", "a4"], ["i6", "i4"], 40], + [["size-13"], ["c8", "c6"], ["k8", "k6"], 84], + [[], ["f11", "f9"], ["n11", "n9"], 180], + ]; + for (const [variants, first, last, max] of cases) { + const g = new KillAllGoGame(undefined, [...variants, "handicap"]); + expect([...first, ...last].map((cell) => keyAt(g, cell)), `${max}`).to.deep.equal(["n1", "n2", "n17", "n18"]); + const numbered = (g.render().pieces as string).split(/[,\n]/).filter((k) => /^n\d+$/.test(k)); + expect(numbered, `${max}`).to.have.length(18); + expect(g.validateMove(`${max}`).valid, `accepts ${max}`).to.be.true; + } + }); + + it("sets the handicap by clicking and marks the choice", () => { + const g = new KillAllGoGame(undefined, ["size-9", "handicap"]); + const click = g.handleClick("", 3, 3); // d6 = 7 + expect(click.valid).to.be.true; + expect(click.move).to.equal("7"); + expect(click.complete).to.equal(0); + expect(click.canrender).to.be.true; + // Another click replaces the choice. + expect(g.handleClick("7", 5, 2).move).to.equal("6"); // c4 = 6 + g.move("7", { partial: true }); + expect(entered(g)).to.deep.equal(["d6"]); + }); + + it("ignores clicks away from the numbered stones and goes away once the handicap is set", () => { + const g = new KillAllGoGame(undefined, ["size-9", "handicap"]); + const stray = g.handleClick("", 4, 4); // e5, the centre point between the two rows + expect(stray.valid).to.be.false; + expect(stray.move).to.equal(""); + expect(stray.message).to.equal(i18next.t("apgames:validation.killallgo.NOT_A_NUMBERED_STONE", { where: "e5" })); + g.move("7"); + expect(g.phase).to.equal("alt-place"); + expect(Object.keys(g.render().legend!)).to.deep.equal(["A", "B"]); + }); + }); }); describe("simple pie", () => { @@ -529,7 +607,11 @@ describe("Kill-All Go", () => { }); it("lets Player 2 choose the Defender side by placing the first stone", () => { - const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), ["pass"]); + const g = new KillAllGoGame(undefined, ["size-9", "pie"]); + expect(g.validateMove("pass").valid).to.be.true; + expect(g.moves()).to.include("pass"); + expect(g.getButtons()).to.deep.equal([{ label: "apgames:buttons.pass", move: "pass" }]); + g.move("pass"); expect(g.board.size).to.equal(0); expect(g.phase).to.equal("pie-choose"); expect(g.validateMove("defender").complete).to.equal(-1); @@ -540,6 +622,9 @@ describe("Kill-All Go", () => { expect(g.board.get("e5")).to.equal(BLUE); expect(g.phase).to.equal("play"); expect(g.currplayer).to.equal(1); + expect(g.validateMove("pass").valid).to.be.false; + expect(g.moves()).to.not.include("pass"); + expect(g.getButtons()).to.deep.equal([]); expect(g.getPlies().map((p) => p.actor)).to.deep.equal([1, 2]); }); }); @@ -555,6 +640,13 @@ describe("Kill-All Go", () => { 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); + // Each limit is explained only by its own error; the prompt itself leaves them out. + expect(g.validateMove("pass").message).to.equal(i18next.t("apgames:validation.killallgo.SLICE_FORMAT")); + expect(g.validateMove("0,1").message).to.equal(i18next.t("apgames:validation.killallgo.SLICE_MIN")); + expect(g.validateMove("40,40").message).to.equal(i18next.t("apgames:validation.killallgo.SLICE_SUM", { max: 79 })); + expect(g.validateMove("1,3").message).to.equal(i18next.t("apgames:validation.killallgo.SLICE_RATIO")); + expect(g.validateMove("").message).to.equal(i18next.t("apgames:validation.killallgo.INSTRUCTIONS_HOC_SLICE")); + expect(g.validateMove("").message).to.not.match(/\d/); g.move("2,3"); expect(g.setup).to.deep.equal({ a: 2, b: 3 }); expect(g.phase).to.equal("hoc-option"); @@ -617,54 +709,70 @@ describe("Kill-All Go", () => { }); 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", () => { + it("lays each batch out in a centred row of seven, the first above the centre point and the second below it", () => { 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"]) { + // 9x9 offers 1 to ceil(2 * 81 / 25) = 7 for each batch, leaving the edge columns empty. + "bcdefgh".split("").forEach((col, i) => { + expect(keyAt(g, `${col}7`), `${col}7`).to.equal(`n${i + 1}`); + expect(keyAt(g, `${col}3`), `${col}3`).to.equal(`n${i + 1}`); + }); + // Nothing else is drawn: no label stones, three empty rows between the blocks, and empty edges. + for (const cell of ["e9", "e8", "a7", "i7", "e6", "e5", "e4", "a3", "i3", "e2", "e1"]) { 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 }]); + expect(Object.keys(legend).filter((k) => !/^(A|B|[nd]\d+)$/.test(k))).to.deep.equal([]); + expect(legend.n7).to.deep.equal([{ name: "piece", colour: 1 }, { text: "7", scale: 0.75, rotate: null }]); + }); + + it("marks each chosen size", () => { + expect(entered(slicing(["size-9", "hoctaph"]))).to.deep.equal([]); + expect(entered(slicing(["size-9", "hoctaph"], "2"))).to.deep.equal(["c7"]); + expect(entered(slicing(["size-9", "hoctaph"], "2,3"))).to.deep.equal(["c7", "d3"]); + // A size entered beyond the picker has no stone to mark. + expect(entered(slicing(["size-9", "hoctaph"], "10,"))).to.deep.equal([]); }); - 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; + it("shows ceil(2p/25) values per batch but still accepts larger typed sizes", () => { + for (const [variants, max] of [[["size-9"], 7], [["size-13"], 14], [[], 29]] as Array<[string[], number]>) { + const keys = (slicing([...variants, "hoctaph"]).render().pieces as string).split(/[,\n]/); + expect(keys.filter((k) => k === `n${max}`), `max ${max}`).to.have.length(2); + expect(keys.includes(`n${max + 1}`), `beyond ${max}`).to.be.false; } + expect(slicing(["hoctaph"]).validateMove("40,35").valid).to.be.true; + }); + + it("snakes the values after a first row of nine back and forth away from the centre", () => { + // 13x13: 1 to 9 run c9 to k9; 10 and 11 go out along the right-hand column, then 12 to 14 run back leftward. + const mid = slicing(["size-13", "hoctaph"]); + const lap = ["n1", "n9", "n10", "n11", "n12", "n14", "-"]; + expect(["c9", "k9", "k10", "k11", "j11", "h11", "g11"].map((cell) => keyAt(mid, cell))).to.deep.equal(lap); + expect(["c5", "k5", "k4", "k3", "j3", "h3", "g3"].map((cell) => keyAt(mid, cell))).to.deep.equal(lap); + // Three empty rows between the blocks, around the centre point, and empty edge rows. + expect(["c8", "g8", "k8", "c7", "g7", "k7", "c6", "g6", "k6", "k12", "k13", "k2", "k1"].every((cell) => keyAt(mid, cell) === "-")).to.be.true; + // 19x19: each snake turns on the right for 10 and 11, then on the left for 20 and 21, ending at 29. const big = slicing(["hoctaph"]); - expect(big.validateMove("40,35").valid).to.be.true; + const laps = ["n1", "n9", "n10", "n11", "n19", "n20", "n21", "n29", "-", "-"]; + expect(["f12", "n12", "n13", "n14", "f14", "f15", "f16", "n16", "e16", "o16"].map((cell) => keyAt(big, cell))).to.deep.equal(laps); + expect(["f8", "n8", "n7", "n6", "f6", "f5", "f4", "n4", "e4", "o4"].map((cell) => keyAt(big, cell))).to.deep.equal(laps); + // Three empty rows between the blocks, around the centre point, and nothing beyond them. + expect(["f11", "j11", "n11", "f10", "j10", "n10", "f9", "j9", "n9"].every((cell) => keyAt(big, cell) === "-")).to.be.true; + expect(["j17", "j18", "j19", "j3", "j2", "j1"].every((cell) => keyAt(big, cell) === "-")).to.be.true; }); - it("picks the first batch on the left and the second on the right", () => { + it("picks the first batch above the centre point and the second below it", () => { const g = slicing(["size-9", "hoctaph"]); - const first = g.handleClick("", 1, 1); // b8 = first batch, 1 + const first = g.handleClick("", 2, 1); // b7 = 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 + const second = g.handleClick(first.move, 6, 2); // c3 = second batch, 2 expect(second.move).to.equal("1,2"); expect(second.complete).to.equal(0); g.move("1,2"); @@ -676,10 +784,10 @@ describe("Kill-All Go", () => { 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"); + expect(keyAt(g, "b3")).to.equal("n1"); + expect(keyAt(g, "c3")).to.equal("n2"); + expect(keyAt(g, "d3")).to.equal("d3"); + expect(keyAt(g, "h7")).to.equal("n7"); const legend = g.render().legend!; expect(legend.d3).to.deep.equal([ { name: "piece", colour: 1, opacity: 0.5 }, @@ -689,22 +797,28 @@ describe("Kill-All Go", () => { 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 + const click = g.handleClick("1", 6, 3); // d3 = 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"); + expect(keyAt(after, "b7")).to.equal("d1"); + expect(keyAt(after, "c7")).to.equal("n2"); + expect(after.handleClick(",3", 2, 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); + const stray = g.handleClick("", 4, 4); // e5, the centre point between the two blocks expect(stray.valid).to.be.false; expect(stray.move).to.equal(""); + // Like other games' invalid clicks, the warning just names the point, without repeating the prompt. + expect(stray.message).to.equal(i18next.t("apgames:validation.killallgo.NOT_A_NUMBERED_STONE", { where: "e5" })); + const beyond = g.handleClick("2", 1, 4); // e8, an empty point beyond the first batch + expect(beyond.valid).to.be.false; + expect(beyond.move).to.equal("2"); + expect(beyond.message).to.equal(i18next.t("apgames:validation.killallgo.NOT_A_NUMBERED_STONE", { where: "e8" })); 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; @@ -747,7 +861,7 @@ describe("Kill-All Go", () => { ["2,3", "youplace", "a1,b1"], [{ label: "apgames:buttons.killallgo.defender", move: "defender" }], ], - [["classic"], [], [{ label: "apgames:buttons.pass", move: "pass" }]], + [["classic"], [], []], ]; for (const [variants, moves, buttons] of cases) { const g = play(new KillAllGoGame(undefined, variants), moves); @@ -840,7 +954,7 @@ describe("Kill-All Go", () => { 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"]); + play(g, ["a2", "i9", "b2", "h9", "c2", "g9", "d2", "f9", "d1", "e9"]); expect(g.gameover).to.be.false; g.move("b1"); expect(g.gameover).to.be.true; @@ -860,25 +974,23 @@ describe("Kill-All Go", () => { 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" }); + expect(g.moves().every((m) => /^[a-z]+\d+$/.test(m))).to.be.true; }); - it("does not treat a pass followed by a stone as consecutive passes", () => { + it("rejects normal-play passes for both sides without changing the game", () => { const g = attackerIsPlayerOne(); - play(g, ["e5", "pass", "e6", "pass"]); - expect(g.gameover).to.be.false; - expect(g.currplayer).to.equal(2); + for (const placement of ["e5", "a9"]) { + const before = g.serialize(); + expect(g.validateMove("pass").valid).to.be.false; + expect(g.validateMove("pass").message).to.equal(i18next.t("apgames:validation.killallgo.INVALID_PASS")); + expect(g.moves()).to.not.include("pass"); + expect(g.getButtons()).to.deep.equal([]); + expect(() => g.move("pass")).to.throw(); + expect(() => g.move("pass", { partial: true })).to.throw(); + expect(g.serialize()).to.equal(before); + g.move(placement); + expect(g.gameover).to.be.false; + } }); it("forbids recreating an earlier position (positional superko)", () => { @@ -917,10 +1029,8 @@ describe("Kill-All Go", () => { 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: "apgames:buttons.pass", move: "pass" }]); - play(g, ["e5", "a9", "pass", "pass"]); + it("offers no buttons once the game ends", () => { + const g = play(new KillAllGoGame(undefined, ["size-9", "pie"]), [rowsOf([8, 6, 4, 2]), "attacker"]); expect(g.gameover).to.be.true; expect(g.getButtons()).to.deep.equal([]); }); @@ -939,19 +1049,39 @@ describe("Kill-All Go", () => { 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 g = play(new KillAllGoGame(undefined, ["size-9", "hoctaph"]), ["2,3", "iplace:a1,b1", "attacker:c1,d1,e1", "e5", "a9"]); 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("still displays the result of an older game ended by consecutive passes", () => { + const g = play(attackerIsPlayerOne(), ["e5", "a9"]); + const state = g.state(); + state.gameover = true; + state.winner = [1]; + const last = g.moveState(); + state.stack.push({ ...last, _version: "20260921", lastmove: "pass", currplayer: 1, _results: [{ type: "pass" }] }); + state.stack.push({ + ...last, + _version: "20260921", + lastmove: "pass", + _results: [{ type: "pass" }, { type: "eog", reason: "double-pass" }, { type: "winners", players: [1] }], + }); + const restored = new KillAllGoGame(state); + expect(restored.gameover).to.be.true; + expect(restored.winner).to.deep.equal([1]); + const keys = restored.chatLogEntries(["Alice", "Bob"]).flatMap((e) => e.lines.map((l) => l.textKey)); + expect(keys).to.include("apresults:EOG.killallgo_double_pass"); + expect(restored.chatLog(["Alice", "Bob"]).flat().join("\n")).to.include("both players passed consecutively"); + }); + it("round-trips through serialization in every phase", () => { const games = [ new KillAllGoGame(undefined, ["size-9"]),