From a73d398a75c6ad23ac51f9dfa83b3869a29af30e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:25:34 +0000 Subject: [PATCH] Arimaa: Lightvector notation and click-based move entry Arimaa currently requires a player to type the raw step list of a turn by hand (`Ee2e3, Ee3e4`, pushes and pulls spelled out with `(x...)` captures). This replaces that with click/arrow-based move entry: click a piece, then the square it should end the turn on (an enemy piece too, for a push or pull), and the engine works out the steps. Every legal turn is enterable this way; typing is never required. Moves are now recorded in Lightvector notation (`Ee2e4`, `Rc2c3 Dd2x`), which names pieces and outcomes rather than steps. Existing recorded games are unaffected: the old step-list format is still read, and replays trusted regardless of what the current validator would accept from a new submission. Move entry ---------- `handleClick` builds up arrows (``), "holds" (a piece confirmed to end where it stands: ``, drawn for round trips, detours, and forcing survival off a trap), and capture marks (`x`) from clicks, and resolves them against every legal turn up to 4 steps deep. Resolution is strict by default (a token set must pick out exactly one turn) with a lenient fallback that breaks a tie by fewest own captures, then most enemy captures -- this is what lets two clicks usually be enough even though Arimaa turns can take up to 4 steps. A selection never blocks submission: move() drops a trailing selection rather than getting stuck, and `complete` is only ever 1 (auto-submit) when nothing about the turn had to be inferred. A rejected drawing now says why instead of just "no legal move matches": the opponent's piece (also what a click lands on when the previous turn hasn't been completed yet), a piece boxed in or frozen, a rabbit sent backward, chained typed steps that read as one piece visiting two destinations, or arrows that each work alone but not together. Typed input still works: Lightvector notation directly (including `Dir+` step tokens clicks never produce), or the old step-list format. Compatibility ------------- The recorded-move format changes, but old recorded games do not need to re-validate under the new rules to replay: a trusted move() call plays a turn (or a legacy step list) directly, without going back through validateMove. The version number is unchanged -- nothing about how a previously-recorded game is interpreted has changed, only what a new submission is asked to look like and how strictly it's checked. Two moves the step validator incorrectly allowed are now rejected for new submissions: a push completed by a piece no stronger than the one it's pushing, and a pull that ends the pulled piece anywhere but the square the puller just vacated. Recorded games containing either (a handful of plies across the full corpus of recorded games) still replay unchanged, since replay never re-validates. Performance ----------- The step-sequence resolver (iterative deepening with an admissible heuristic bound) got measurably slower on some inputs once "holds" were added, since a piece that ends where it started can't be pruned by the step-count bound. A transposition memo (per depth, keyed on the board plus each moved piece's visited squares, off whenever a step token is present) plus a cheaper heuristic evaluation (cached witness lists, a distance table, early-outs once a bound already exceeds the remaining budget) brought the worst observed click, from a stress test over recorded games, down from ~0.9s to ~50-190ms, with no change to what the search finds (checked against 3,000+ random positions with pruning on and off). Testing ------- test/games/arimaa.test.ts: 49 tests -- notation parsing, strict/lenient resolution, a randomized pruned-vs-unpruned equivalence check, arrow/hold/capture-mark entry (including forced arrows, detours, and trap-mark conversion), legacy-format compatibility (a submission the rules no longer allow is rejected, but the same string still replays trusted), 5 recorded games replayed move-by-move, and an independent exhaustive enumerator that checks every legal turn from several representative positions is reachable purely by clicks. Full suite (`npx mocha`): 1502 passing, 1 pre-existing failure (storisende.storage.test.ts, a fixed 2000ms timeout on a large board) in a file this change does not touch; reproduces identically on a clean develop checkout. `npm run lint` (Weblate key check, game-names locale check, eslint): clean. `tsc --noEmit` on the changed files: clean. Stress test: replayed all 449 games / 12,590 movement turns of a recorded-game corpus through both the old and new engines. Mean clicks per turn (old vs. new, Complete Move included): 7.39 -> 4.67. All but 3 turns (the illegal legacy plies above) are enterable purely by clicks under the new model. Also manually verified the click flows end-to-end by driving the actual playground/ app in headless Chromium (setup, single/multi-step turns, step-by-step re-selection, holds, an ambiguous drawing, a rotated board). locales/en/apgames.json and locales/eo/apgames.json updated for the new and changed validation/instruction strings. Other locales are untouched -- they're machine-managed and will pick these up after merge. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YPW35p91rLsBRPSz63L1k --- locales/en/apgames.json | 17 +- locales/eo/apgames.json | 17 +- src/games/arimaa.ts | 853 ++++++++++++++++-------- src/games/arimaa/notation.ts | 215 +++++++ src/games/arimaa/turns.ts | 1034 ++++++++++++++++++++++++++++++ test/fixtures/arimaa/coverage.ts | 149 +++++ test/fixtures/arimaa/records.ts | 299 +++++++++ test/games/arimaa.test.ts | 822 ++++++++++++++++++++++++ 8 files changed, 3144 insertions(+), 262 deletions(-) create mode 100644 src/games/arimaa/notation.ts create mode 100644 src/games/arimaa/turns.ts create mode 100644 test/fixtures/arimaa/coverage.ts create mode 100644 test/fixtures/arimaa/records.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 3ffc43f1..a3682147 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -578,7 +578,7 @@ "akimbo": "Akimbo, designed by Luis Bolaños Mures in 2026, is a drawless connection game for two players. A _naked diagonal_ is a pair of like-colored, diagonally adjacent pieces with no other like-colored piece adjacent to both. A _crosscut_ is a 2×2 area with two interlocking naked diagonals of opposite colors.\n\nOn their turn, the player places a friendly piece on an empty cell. If this completes a crosscut, the other piece in the crosscut is removed. There must never be more than one naked diagonal of each color on the board — not even momentarily before removing a piece. A player wins if, at the end of their turn, there is a chain of orthogonally connected pieces of their color touching the player's two opposite board edges.\n\nOkimba is a variant where only up to one naked diagonal can exist on the board (so while one player has the diagonal, the adversary cannot create another).", "alta": "There are two ways to place switches on the graphical interface: (1) Select the switch’s orientation in the panel, then click on the desired space on the board to place it, or (2) click on two vertices. The vertices are notated with an asterisk, followed by the algebraic notation of the space where the vertex is at the bottom-left corner. To toggle a switch, simply click on the space.", "anache": "This implementation follows David Ploog's rulesheet. Three notable changes are that (1) barriers are forbidden, (2) instead of a 16x16, we have a 15x15 variant, where pieces promote to knights on the centreline, and the dragon may not jump to the 7x7 space from the other corner, and (3) against the corners, pieces are captured via crushing capture instead of custodian capture, so there needs to be at least two opponent pieces in a line before a capture is made against the corners.\n\nNote: the stalemate check is expensive. If you cannot make a legal move, you should resign manually.", - "arimaa": "Made available under section 3 of the [Arimaa public license](https://arimaa.com/arimaa/license/).\n\nDuring standard setup, you only need to place your eight non-rabbits. Once they are down, Complete Move fills the rest of your setup area with rabbits. You can still place any or all of the rabbits yourself first if you want them somewhere in particular.\n\nHarlog is an accepted method for determining material advantage. Positive scores favour Gold, negative Silver. The hard range is ±112, but in practice ±20 is a very strong advantage.\n\nBecause we can't generate comprehensive lists of moves, the system cannot detect the rare cases where your only available moves are illegal due to position repetition. The system won't let you make those illegal repetitions, and you'll have to resign manually.", + "arimaa": "Made available under section 3 of the [Arimaa public license](https://arimaa.com/arimaa/license/).\n\nDuring standard setup, you only need to place your eight non-rabbits. Once they are down, Complete Move fills the rest of your setup area with rabbits. You can still place any or all of the rabbits yourself first if you want them somewhere in particular.\n\nTo enter a move, click a piece and then the square it should end your turn on. The engine finds the shortest legal way to get it there, pushes and pulls included, and draws one arrow per piece from where it started to where it ends up. You can point at enemy pieces too. Click an arrow's head to extend it, or its tail to remove it. Clicking a selected piece a second time keeps it where it stands, which is how you stop the engine moving it aside. A few rarer turns need one more click; the wiki page for this game lists them all.\n\nMoves are recorded in Lightvector notation, which names each piece and where it ends up rather than listing every step. You can type it into the move box, and the older step-by-step notation is still accepted. The wiki page explains the notation in full.\n\nHarlog is an accepted method for determining material advantage. Positive scores favour Gold, negative Silver. The hard range is ±112, but in practice ±20 is a very strong advantage.\n\nBecause we can't generate comprehensive lists of moves, the system cannot detect the rare cases where your only available moves are illegal due to position repetition. The system won't let you make those illegal repetitions, and you'll have to resign manually.", "armadas": "There are known issues with this game on iOS devices.\n\nThis game offers two scenarios:\n\n* The default is each player having two trios of pieces. In the placement phase, each player must place 1 to 3 ships until all ships are placed.\n* The \"Freeform\" variant allows you to place whatever ships you want (maximum of three at a time) until both players \"pass.\" This lets you create unbalanced or otherwise asymmetrical fleets.\n\nGames default to having one island in the centre of the field. Both a \"no islands\" and a \"two islands\" variant are available. You cannot move or fire through islands. To successfully hit a ship, at least one corner of your ship's triangle must have a clear view of at least one corner of the target ship.\n\nRemember that it is possible to get your ship in a position against an island or the edge of the board such that you can no longer move that ship! Be careful!", "ataxx": "On three-fold repetition, the game ends and the scores are calculated.", "bao": "Moves in Bao can be very complex, involving multiple laps around the board and changing directions. The annotations are, therefore, sparse. The initial cell and direction are highlighted, and captured cells are also marked. But detailed annotation of movement is not possible. If you believe you have encountered a bug, please let us know in Discord.", @@ -5162,7 +5162,7 @@ "FROZEN": "The piece at {{where}} is frozen. It cannot move on its own.", "HOME_ROW": "You may only place pieces on the two ranks closest to you.", "INITIAL_INSTRUCTIONS_place": "Select a piece from your hand to place on the board.", - "INITIAL_INSTRUCTIONS_play": "Select a piece to move.", + "INITIAL_INSTRUCTIONS_play": "Click a piece, then the square it should end the turn on. You can point at enemy pieces too; pushes and pulls are worked out for you.", "INVALID_PUSH": "Pushes must be immediately completed by moving the pushing piece into the space vacated by the pushed enemy piece.", "INVALID_PUSHPULL": "You may only move enemy pieces when completing a pull or starting a push. Neither seem to apply in this case.", "LABEL_STASH": "{{player}}'s stash", @@ -5183,7 +5183,18 @@ "WARN_BALANCE": "Balance your four strongest pieces east to west.", "WARN_FILE": "Don't put your elephant in the same file as the opposing elephant.", "WARN_HIDE": "Don't hide stronger pieces behind weaker pieces (except cats behind rabbits).", - "WARNINGS": "Your setup is complete, but there are some things for new players to consider. You can click Complete Move to ignore, or you can read the following advice:" + "WARNINGS": "Your setup is complete, but there are some things for new players to consider. You can click Complete Move to ignore, or you can read the following advice:", + "PARSE": "Could not read \"{{token}}\".", + "NO_MOVE": "No legal move matches what you have drawn.", + "NO_MOVE_CHAIN": "One arrow per piece, from where it starts to where it ends: {{first}} and {{second}} together are {{combined}}.", + "NO_MOVE_ENEMY": "The piece on {{from}} is your opponent's, and none of your pieces can push or pull it as drawn within {{num}} steps.", + "NO_MOVE_FROZEN": "The piece on {{from}} is frozen, and nothing can free it in time.", + "NO_MOVE_REACH": "The piece on {{from}} cannot get to {{to}} within {{num}} steps.", + "NO_MOVE_TOGETHER": "Each of those works on its own, but not all of them within {{num}} steps.", + "AMBIGUOUS": "That fits {{count}} different moves. Say where another piece ends up to settle it.", + "INFERRED": "Also moving: {{pieces}}.", + "INCOMPLETE": "A piece is selected. Click a square to send it there, or click it again to keep it where it is.", + "TOO_LONG": "What you have drawn needs more than {{num}} steps." }, "armadas": { "BAD_PASS": "When passing, that must be the only command you provide.", diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 98d5acaa..7601d751 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -4503,7 +4503,7 @@ "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).", "alta": "Estas du manieroj meti ŝaltilojn en la grafika interfaco: (1) elektu la orientiĝon de la ŝaltilo en la panelo, poste klaku la deziratan spacon sur la tabulo por meti ĝin; aŭ (2) klaku du verticojn. Vertico estas notata per steleto, sekvata de la algebra notacio de la spaco, kies malsupra-maldekstra angulo estas tiu vertico. Por baskuligi ŝaltilon, simple klaku la spacon.", "anache": "Ĉi tiu realigo sekvas la regularon de David Ploog. Tri rimarkindaj ŝanĝoj estas: (1) bariloj estas malpermesitaj; (2) anstataŭ tabulo 16×16 ekzistas variaĵo 15×15, en kiu pecoj promociiĝas al ĉevaloj sur la centra linio kaj la drako ne rajtas salti al la spaco 7×7 el la alia angulo; kaj (3) ĉe la anguloj pecoj estas kaptataj per dispremkapto anstataŭ gardista kapto, do devas esti almenaŭ du kontraŭulaj pecoj en linio antaŭ ol eblas kapti ĉe angulo.\n\nRimarko: la kontrolo pri pato estas komputile multekosta. Se vi ne povas fari validan movon, rezignu permane.", - "arimaa": "Disponigita laŭ sekcio 3 de la [publika permesilo de Arimaa](https://arimaa.com/arimaa/license/).\n\nHarlog estas akceptita metodo por determini materialan avantaĝon. Pozitivaj valoroj favoras Oron, negativaj Arĝenton. La teoria intervalo estas ±112, sed praktike ±20 jam estas tre forta avantaĝo.\n\nĈar ni ne povas generi kompletajn listojn de movoj, la sistemo ne povas detekti la maloftajn okazojn, kiam viaj solaj disponeblaj movoj estas malvalidaj pro ripeto de pozicio. La sistemo malpermesos tiujn ripetojn, kaj vi devos rezigni permane.", + "arimaa": "Disponigita laŭ sekcio 3 de la [publika permesilo de Arimaa](https://arimaa.com/arimaa/license/).\n\nDum la norma aranĝo vi devas meti nur viajn ok pecojn, kiuj ne estas kunikloj. Kiam ili estas metitaj, Fini la movon plenigas la reston de via aranĝa zono per kunikloj. Vi tamen povas unue mem meti iujn aŭ ĉiujn kuniklojn, se vi volas ilin en apartaj lokoj.\n\nPor enigi movon, klaku pecon kaj poste la ĉelon, sur kiu ĝi finu vian vicon. La sistemo trovas la plej mallongan validan vojon tien, inkluzive de puŝoj kaj tiroj, kaj desegnas po unu sagon por ĉiu peco, de ĝia komenca ĉelo ĝis ĝia fina ĉelo. Vi povas indiki ankaŭ malamikajn pecojn. Klaku la pinton de sago por plilongigi ĝin, aŭ ĝian komencon por forigi ĝin. Klaki elektitan pecon duan fojon lasas ĝin tie, kie ĝi staras; tiel vi malhelpas, ke la sistemo formovu ĝin el la vojo. Kelkaj pli maloftaj vicoj bezonas ankoraŭ unu klakon; la vikia paĝo pri ĉi tiu ludo listigas ĉiujn.\n\nMovoj estas registrataj en la notacio de Lightvector, kiu nomas ĉiun pecon kaj ĝian finan ĉelon anstataŭ listigi ĉiun paŝon. Vi povas tajpi ĝin en la movan enigkampon; la pli malnova paŝo-post-paŝa notacio plu estas akceptata. La vikia paĝo klarigas la notacion plene.\n\nHarlog estas akceptita metodo por determini materialan avantaĝon. Pozitivaj valoroj favoras Oron, negativaj Arĝenton. La teoria intervalo estas ±112, sed praktike ±20 jam estas tre forta avantaĝo.\n\nĈar ni ne povas generi kompletajn listojn de movoj, la sistemo ne povas detekti la maloftajn okazojn, kiam viaj solaj disponeblaj movoj estas malvalidaj pro ripeto de pozicio. La sistemo malpermesos tiujn ripetojn, kaj vi devos rezigni permane.", "armadas": "Estas konataj problemoj pri ĉi tiu ludo en iOS-aparatoj.\n\nLa ludo proponas du scenarojn:\n\n* Defaŭlte ĉiu ludanto havas du triopojn da pecoj. Dum la metfazo, ĉiu devas meti 1 ĝis 3 ŝipojn, ĝis ĉiuj estas metitaj.\n* La variaĵo \"Freeform\" permesas meti ajnajn deziratajn ŝipojn (maksimume tri samtempe), ĝis ambaŭ ludantoj preterpasas. Tiel eblas krei malekvilibrajn aŭ alimaniere malsimetriajn ŝiparojn.\n\nDefaŭlte estas unu insulo en la centro de la ludkampo. Disponeblas variaĵoj kaj \"sen insuloj\" kaj \"du insuloj\". Oni ne povas moviĝi aŭ pafi tra insuloj. Por sukcese trafi ŝipon, almenaŭ unu angulo de la triangulo de via ŝipo devas havi liberan vidlinion al almenaŭ unu angulo de la celata ŝipo.\n\nMemoru, ke eblas meti ŝipon kontraŭ insulon aŭ la randon de la tabulo tiel, ke ĝi ne plu povos moviĝi! Estu singarda!", "ataxx": "Post tria ripeto de pozicio, la ludo finiĝas kaj la poentaroj estas kalkulataj.", "bao": "Movoj en Bao povas esti tre kompleksaj, kun pluraj rondiroj ĉirkaŭ la tabulo kaj ŝanĝoj de direkto. Tial la indikoj estas malmultaj. La komenca ĉelo kaj direkto estas emfazitaj, kaj kaptitaj ĉeloj ankaŭ estas markitaj, sed detala indiko de la movado ne eblas. Se vi kredas, ke vi trovis programeraron, bonvolu sciigi nin en Discord.", @@ -4848,7 +4848,7 @@ "FROZEN": "La peco ĉe {{where}} estas frosta. Ĝi ne povas moviĝi sola.", "HOME_ROW": "Vi povas meti pecojn nur sur la du rangoj plej proksimaj al vi.", "INITIAL_INSTRUCTIONS_place": "Elektu pecon el via mano por meti sur la tabulon.", - "INITIAL_INSTRUCTIONS_play": "Elektu pecon por movi.", + "INITIAL_INSTRUCTIONS_play": "Klaku pecon, poste la ĉelon, sur kiu ĝi finu la vicon. Vi povas indiki ankaŭ malamikajn pecojn; la puŝojn kaj tirojn la sistemo kalkulas mem.", "INVALID_PUSH": "Puŝoj devas tuj esti kompletigitaj movante la puŝan pecon en la spacon liberigita per la puŝita malamika peco.", "INVALID_PUSHPULL": "Vi rajtas movi malamikajn pecojn nur kiam vi kompletigas tiron aŭ komencas puŝon. Neniu el ili ŝajnas aplikiĝi en ĉi tiu kazo.", "LABEL_STASH": "Provizo de {{player}}", @@ -4868,7 +4868,18 @@ "WARN_BALANCE": "Ekvilibru viajn kvar plej fortajn pecojn de oriento al okcidento.", "WARN_FILE": "Ne metu vian elefanton en la saman kolumnon kiel la kontraŭulan elefanton.", "WARN_HIDE": "Ne kaŝu pli fortajn pecojn malantaŭ pli malfortaj pecoj (krom katoj malantaŭ kunikloj).", - "WARNINGS": "Via aranĝo estas kompleta, sed novaj ludantoj konsideru kelkajn aferojn. Vi povas premi la butonon Fini la movon por ignori ilin, aŭ legi la jenajn konsilojn:" + "WARNINGS": "Via aranĝo estas kompleta, sed novaj ludantoj konsideru kelkajn aferojn. Vi povas premi la butonon Fini la movon por ignori ilin, aŭ legi la jenajn konsilojn:", + "PARSE": "Ne eblis legi \"{{token}}\".", + "NO_MOVE": "Neniu valida movo kongruas kun tio, kion vi desegnis.", + "NO_MOVE_CHAIN": "Unu sago por ĉiu peco, de ĝia komenca ĉelo ĝis ĝia fina ĉelo: {{first}} kaj {{second}} kune estas {{combined}}.", + "NO_MOVE_ENEMY": "La peco ĉe {{from}} apartenas al via kontraŭulo, kaj neniu el viaj pecoj povas puŝi aŭ tiri ĝin tiel, kiel vi desegnis, ene de {{num}} paŝoj.", + "NO_MOVE_FROZEN": "La peco ĉe {{from}} estas frosta, kaj nenio povas liberigi ĝin ĝustatempe.", + "NO_MOVE_REACH": "La peco ĉe {{from}} ne povas atingi {{to}} ene de {{num}} paŝoj.", + "NO_MOVE_TOGETHER": "Ĉiu el tiuj eblas aparte, sed ne ĉiuj kune ene de {{num}} paŝoj.", + "AMBIGUOUS": "Tio kongruas kun {{count}} malsamaj movoj. Indiku, kie finiĝas alia peco, por precizigi ĝin.", + "INFERRED": "Ankaŭ moviĝas: {{pieces}}.", + "INCOMPLETE": "Peco estas elektita. Klaku ĉelon por sendi ĝin tien, aŭ klaku la pecon denove por lasi ĝin tie, kie ĝi staras.", + "TOO_LONG": "Tio, kion vi desegnis, bezonas pli ol {{num}} paŝojn." }, "armadas": { "BAD_PASS": "Kiam vi preterpasas, tio devas esti la sola ordono, kiun vi donas.", diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 41d6983a..a37de9ed 100644 --- a/src/games/arimaa.ts +++ b/src/games/arimaa.ts @@ -4,6 +4,8 @@ import { APRenderRep, AreaPieces, BoardBasic, Colourfuncs, Glyph } from "@abstra import type { APMoveResult } from "../schemas/moveresults.js"; import { randomInt, RectGrid, reviver, shuffle, SquareOrthGraph, UserFacingError, cloneState } from "../common/index.js"; import i18next from "i18next"; +import { arrowToken, captureToken, isArrow, isLegacy, isMark, isHold, joinMove, normalize, parseMove, pieceChar, holdToken, tokenText, NotationError, type ParsedMove, type Token } from "./arimaa/notation.js"; +import { hasAnyMove, inferred, resolve, serializeTurn, sqName, turnFromSteps, type Turn } from "./arimaa/turns.js"; export type playerid = 1|2; export type Piece = "E" | "M" | "H" | "D" | "C" | "R"; @@ -222,15 +224,6 @@ export class ArimaaGame extends GameBase { return [pc, player, from, to]; } - private static bareMove(mv: string): string { - const idx = mv.indexOf("("); - let bare = mv; - if (idx >= 0) { - bare = mv.slice(0, idx); - } - return bare; - } - public numplayers = 2; public currplayer: playerid = 1; public board!: Map; @@ -242,6 +235,11 @@ export class ArimaaGame extends GameBase { public stack!: Array; public results: Array = []; private _selected: string|undefined; + // the player's arrows, kept for drawing when they do not yet resolve to a move + private _arrows: Array<[string, string]>|undefined; + // the squares of the player's holds and capture marks, drawn whether or not the move resolves + private _holds: string[]|undefined; + private _marks: string[]|undefined; constructor(state?: IArimaaState | string, variants?: string[]) { super(); @@ -307,6 +305,12 @@ export class ArimaaGame extends GameBase { this.lastmove = state.lastmove; this.hands = cloneState(state.hands); this.results = [...state._results]; + // what a turn did is drawn from its results; anything left over from a + // partial move belongs to the entry being typed, not to this state + this._selected = undefined; + this._arrows = undefined; + this._holds = undefined; + this._marks = undefined; return this; } @@ -365,111 +369,6 @@ export class ArimaaGame extends GameBase { return [...mvs, ...empty.map(cell => `${this.currplayer === 1 ? "R" : "r"}${cell}`)].join(","); } - // this only calculates possible next moves from the current position, - // regardless of how many moves have been made so far (no range checks) - // needs to support returning multi moves because pushes are atomic - public partialMoves(sofar: string[] = []): (string|string[])[] { - if (this.gameover) { return []; } - - // make any partial moves - const cloned = this.clone(); - cloned.move(sofar.join(","), {partial: true}); - - const moves: (string|string[])[] = []; - const g = new SquareOrthGraph(8, 8); - - // opening moves - if (cloned.hands !== undefined && cloned.hands[cloned.currplayer - 1].length > 0) { - const uniques = new Set(cloned.hands[cloned.currplayer - 1]); - let cells: string[]; - if (this.variants.includes("free")) { - cells = (g.listCells(true) as string[][]).flat().filter(cell => !cloned.board.has(cell)); - } else { - cells = []; - for (const row of (this.currplayer === 1 ? [6,7] : [0,1])) { - for (let col = 0; col < 8; col++) { - const cell = g.coords2algebraic(col, row); - if (! cloned.board.has(cell)) { - cells.push(cell); - } - } - } - } - for (const pc of uniques) { - for (const cell of cells) { - moves.push(`${cloned.currplayer === 1 ? pc : pc.toLowerCase()}${cell}`); - } - } - } - // regular moves - else { - const mine = [...cloned.board.entries()].filter(e => e[1][1] === cloned.currplayer).map(e => [e[0], e[1][0]] as [string, Piece]); - for (const [from, pc] of mine) { - // skip if frozen - if (cloned.isFrozen(from)) { - continue; - } - // can you complete a pull - if (sofar.length > 0) { - const [lastPc, lastPlayer, lastFrom] = ArimaaGame.baseMove(sofar[sofar.length - 1]); - // pulls are only possible if the last piece you moved was yours - if (lastPlayer === cloned.currplayer) { - const enemies: [Piece, string][] = []; - for (const n of g.neighbours(lastFrom!)) { - if (cloned.board.has(n)) { - const [nPc, nOwner] = cloned.board.get(n)!; - if (nOwner !== cloned.currplayer) { - enemies.push([nPc, n]); - } - } - } - // but this piece can't pull while pushing - const classifications = ArimaaGame.classify(cloned.currplayer, sofar); - const justPushed = classifications[classifications.length - 1] === "pusher"; - if (!justPushed) { - for (const [enemyPc, cell] of enemies) { - if (ArimaaGame.strength(lastPc) > ArimaaGame.strength(enemyPc)) { - moves.push(`${cloned.currplayer === 1 ? enemyPc.toLowerCase() : enemyPc}${cell}${lastFrom}`); - } - } - } - } - } - // pushes and moves to empty spaces - for (const n of g.neighbours(from)) { - // can you push - if (cloned.board.has(n)) { - const [nPc, nOwner] = cloned.board.get(n)!; - if ( (nOwner === cloned.currplayer) && (ArimaaGame.strength(nPc) < ArimaaGame.strength(pc)) ) { - for (const nn of g.neighbours(n)) { - if (! cloned.board.has(nn)) { - moves.push([`${nOwner === 1 ? nPc : nPc.toLowerCase()}${n}${nn}`, `${cloned.currplayer === 1 ? pc : pc.toLowerCase()}${from}${n}`]); - } - } - } - } - // can you move to an empty space - else { - // rabbits can't move backwards - if (pc === "R") { - const [fx, fy] = g.algebraic2coords(from); - const [tx, ty] = g.algebraic2coords(n); - const bearing = RectGrid.bearing(fx, fy, tx, ty)!; - const backward = cloned.currplayer === 1 ? "S" : "N"; - if (!bearing.startsWith(backward)) { - moves.push(`${cloned.currplayer === 1 ? pc : pc.toLowerCase()}${from}${n}`); - } - } else { - moves.push(`${cloned.currplayer === 1 ? pc : pc.toLowerCase()}${from}${n}`); - } - } - } - } - } - - return moves; - } - // A very niche helper function to fix the "can't push pull at same time" issue // Looks at each move in a chain and determines the nature of each. // Naive. Just looks at the notation and sequence, not board context. @@ -581,6 +480,37 @@ export class ArimaaGame extends GameBase { public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { try { + // placing pieces: + // - either still pieces in hand + // - or we're in standard setup and ply 1 or 2, no matter the hands + const placing = (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) || + (this.variants.length === 0 && this.stack.length <= 2); + const newmove = placing ? this.setupClick(move, row, col, piece) : this.moveClick(move, row, col); + let result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.move = move; + } else { + if (result.autocomplete !== undefined) { + const automove = result.autocomplete; + result = this.validateMove(automove) as IClickResult; + result.move = automove; + } else { + result.move = newmove; + } + } + return result; + } catch (e) { + // console.log(e); + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + // Setup clicks work on the comma-separated placement list. + private setupClick(move: string, row: number, col: number, piece?: string): string { let newmove = ""; const steps = move.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); let lastPc: Piece|undefined; @@ -610,95 +540,185 @@ export class ArimaaGame extends GameBase { const cloned = this.clone(); cloned.move(stub, {partial: true}); - // placing pieces - // - either still pieces in hand - // - or we're in standard setup and ply 1 or 2, no matter the hands - if ( - (cloned.hands !== undefined && cloned.hands[cloned.currplayer - 1].length > 0) || - (this.variants.length === 0 && this.stack.length <= 2) - ) { - // clicking off the board resets - if (row === -1 || col === -1) { - const [,pc, pstr] = piece!.split(""); - const p = parseInt(pstr, 10); - newmove = `${stub}${stub.length > 0 ? "," : ""}${p === 1 ? pc : pc.toLowerCase()}`; - } else { - const cell = ArimaaGame.coords2algebraic(col, row); - // clicking a placed cell unplaces it - if (cloned.board.has(cell)) { - // clicking an occupied cell after selecting a piece to place - if (lastmove.length === 0) { - const idx = steps.findIndex(([pc,,f,]) => pc === piece![0] && f === cell); - if (idx >= 0) { - steps.splice(idx, 1); - newmove = steps.map(([pc, p, f,]) => `${p === 1 ? pc : pc.toLowerCase()}${f}`).join(","); - } else { - newmove = stub; - } + // clicking off the board resets + if (row === -1 || col === -1) { + const [,pc, pstr] = piece!.split(""); + const p = parseInt(pstr, 10); + newmove = `${stub}${stub.length > 0 ? "," : ""}${p === 1 ? pc : pc.toLowerCase()}`; + } else { + const cell = ArimaaGame.coords2algebraic(col, row); + // clicking a placed cell unplaces it + if (cloned.board.has(cell)) { + // clicking an occupied cell after selecting a piece to place + if (lastmove.length === 0) { + const idx = steps.findIndex(([pc,,f,]) => pc === piece![0] && f === cell); + if (idx >= 0) { + steps.splice(idx, 1); + newmove = steps.map(([pc, p, f,]) => `${p === 1 ? pc : pc.toLowerCase()}${f}`).join(","); } else { newmove = stub; } } else { - // if just clicking directly on the board, choose a piece for them: - // in free setup the hand never empties, so default to a rabbit; - // otherwise take the strongest piece still in hand - if (lastmove === undefined || lastmove === "") { - let dflt: Piece; - if (this.variants.includes("free")) { - dflt = "R"; - } else { - dflt = [...cloned.hands![cloned.currplayer - 1]].sort((a,b) => ArimaaGame.strength(b) - ArimaaGame.strength(a))[0]; - } - lastmove = cloned.currplayer === 1 ? dflt : dflt.toLowerCase(); + newmove = stub; + } + } else { + // if just clicking directly on the board, choose a piece for them: + // in free setup the hand never empties, so default to a rabbit; + // otherwise take the strongest piece still in hand + if (lastmove === undefined || lastmove === "") { + let dflt: Piece; + if (this.variants.includes("free")) { + dflt = "R"; + } else { + dflt = [...cloned.hands![cloned.currplayer - 1]].sort((a,b) => ArimaaGame.strength(b) - ArimaaGame.strength(a))[0]; } - newmove = `${stub}${stub.length > 0 ? "," : ""}${lastmove}${cell}`; + lastmove = cloned.currplayer === 1 ? dflt : dflt.toLowerCase(); } + newmove = `${stub}${stub.length > 0 ? "," : ""}${lastmove}${cell}`; } } - // moving pieces - else { - const cell = ArimaaGame.coords2algebraic(col, row); - // clicking an occupied cell always resets the move - if (cloned.board.has(cell)) { - const [pc, owner] = cloned.board.get(cell)!; - newmove = `${stub}${stub.length > 0 ? "," : ""}${owner === 1 ? pc : pc.toLowerCase()}${cell}`; - } - // otherwise, if - else { - if (lastmove.length === 3) { - newmove = `${stub}${stub.length > 0 ? "," : ""}${lastmove}${cell}`; - } + + return newmove; + } + + // Movement clicks work on the drawn tokens; the rules are in the branch + // comments below. The move string holds destination tokens (arrows, and + // holds: a piece sent to its own square), capture marks (a hold on a trap, + // clicked once more), anything typed, and at most two trailing bare + // squares: the selected piece awaiting a destination and, before it, the + // selection that a second click on the current one completes an arrow from. + private moveClick(move: string, row: number, col: number): string { + let parsed: ParsedMove; + try { + parsed = parseMove(move, true); + } catch { + // unreadable typed input: start over from this click + parsed = {tokens: []}; + } + const clicked = ArimaaGame.coords2algebraic(col, row); + const drawn = parsed.tokens.filter(t => isArrow(t) || isHold(t) || isMark(t)); + const others = parsed.tokens.filter(t => !drawn.includes(t)); + let pending = parsed.pending; + let previous = parsed.previous; + const tailOf = (sq: string): number => drawn.findIndex(t => isArrow(t) && t.spec.square === sq); + const headOf = (sq: string): number => drawn.findIndex(t => isArrow(t) && (t.prop as {square: string}).square === sq); + // every arrow ending on this square, latest first: more than one piece + // can finish on a trap, the ones that died there and at most one alive + const headsOf = (sq: string): Token[] => drawn.filter(t => isArrow(t) && (t.prop as {square: string}).square === sq).reverse(); + const holdOf = (sq: string): number => drawn.findIndex(t => isHold(t) && t.spec.square === sq); + const markOf = (sq: string): number => drawn.findIndex(t => isMark(t) && t.spec.square === sq); + // occupancy is judged at the start of the turn; a square whose piece + // already has an arrow away from it counts as vacated + const unvacatedPiece = (sq: string): boolean => this.board.has(sq) && tailOf(sq) < 0; + const mkArrow = (from: string, to: string): Token => { + const [pc, owner] = this.board.get(from)!; + return arrowToken(pc, owner, from, to); + }; + // a capture asserted of an arrow's piece rides with that arrow: it says + // the piece dies where the arrow sends it, so it goes when the arrow does + const dropMark = (origin: string): void => { + const m = markOf(origin); + if (m >= 0) { + drawn.splice(m, 1); + } + }; + // send what is selected at `from` to `to`: a new arrow from a piece's + // square, or the extension of the arrow whose head was re-opened + const complete = (from: string, to: string): void => { + if (unvacatedPiece(from)) { + drawn.push(mkArrow(from, to)); + return; + } + const h = headOf(from); + if (h >= 0) { + const tail = drawn[h].spec.square!; + dropMark(tail); + if (tail === to) { + drawn.splice(headOf(from), 1); + } else { + drawn[headOf(from)] = mkArrow(tail, to); } } + }; - // console.log(`About to validate '${newmove}'`); - let result = this.validateMove(newmove) as IClickResult; - if (! result.valid) { - result.move = move; - } else { - if (result.autocomplete !== undefined) { - const automove = result.autocomplete; - result = this.validateMove(automove) as IClickResult; - result.move = automove; - } else { - result.move = newmove; + if (pending !== undefined) { + if (clicked === pending) { + if (previous !== undefined) { + // the second click on an occupied square sends the + // selection before it there; its occupant will have to move + complete(previous, clicked); + } else if (traps.includes(clicked) && headOf(clicked) >= 0) { + // the second click on an arrow's head, when that head is a + // trap, says the piece dies there rather than surviving on + // it; the arrow stays, because it says which trap. This + // comes before holding, because whatever still stands on + // the trap cannot survive alongside what is arriving. The + // claim falls on the last arrow drawn to that trap that + // does not carry one yet, and once they all do, clicking + // again takes them all back. + const heads = headsOf(clicked); + const next = heads.find(t => markOf(t.spec.square!) < 0); + if (next === undefined) { + for (const t of heads) { + dropMark(t.spec.square!); + } + } else { + const [pc, owner] = this.board.get(next.spec.square!)!; + drawn.push(captureToken(pc, owner, next.spec.square!)); + } + } else if (unvacatedPiece(clicked) && holdOf(clicked) < 0 && markOf(clicked) < 0) { + // the second click on a selected piece holds it where it stands + const [pc, owner] = this.board.get(clicked)!; + drawn.push(holdToken(pc, owner, clicked)); } + // a re-opened arrow head is simply released + pending = undefined; + previous = undefined; + } else if (unvacatedPiece(clicked)) { + // re-selection rather than a destination; the selection it + // replaces is kept in case the next click confirms this square + previous = unvacatedPiece(pending) || headOf(pending) >= 0 ? pending : undefined; + pending = clicked; + } else { + complete(pending, clicked); + pending = undefined; + previous = undefined; } - return result; - } catch (e) { - // console.log(e); - return { - move, - valid: false, - message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } else { + const t = tailOf(clicked); + const p = holdOf(clicked); + if (t >= 0) { + // grabbing an arrow's tail removes it and starts a new one + dropMark(clicked); + drawn.splice(tailOf(clicked), 1); + pending = clicked; + } else if (p >= 0 && traps.includes(clicked)) { + // on a trap a hold cannot tell survival from capture, so a + // click on it turns the hold into a capture mark + const [pc, owner] = this.board.get(clicked)!; + drawn[p] = captureToken(pc, owner, clicked); + } else if (p >= 0 || markOf(clicked) >= 0) { + // a hold or a capture mark is lifted like an arrow's tail + drawn.splice(p >= 0 ? p : markOf(clicked), 1); + pending = clicked; + } else if (this.board.has(clicked) || headOf(clicked) >= 0) { + pending = clicked; + } else { + return move; } } + return joinMove([...others, ...drawn], pending, previous); } public validateMove(m: string): IValidationResult { const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; - m = m.replace(/\s+/g, ""); - const g = new SquareOrthGraph(8, 8); + const setup = this.hands !== undefined && this.hands[this.currplayer - 1].length > 0; + // placement and legacy step lists ignore whitespace; the notation is space-delimited + if (setup || isLegacy(m)) { + m = m.replace(/\s+/g, ""); + } else { + m = normalize(m); + } if (m.length === 0) { result.valid = true; @@ -713,11 +733,20 @@ export class ArimaaGame extends GameBase { return result; } - const classifications = ArimaaGame.classify(this.currplayer, m.split(",").filter(Boolean)); - const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); - // console.log(JSON.stringify({steps})); - // placements are validated separately - if (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) { + if (setup) { + return this.validateSetup(m); + } + if (isLegacy(m)) { + return this.validateLegacyMovement(m); + } + return this.validateNotation(m); + } + + private validateSetup(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + const g = new SquareOrthGraph(8, 8); + // capture tokens written at the end of free setup carry no instruction + const steps = m.split(",").filter(Boolean).filter(mv => !mv.startsWith("x")).map(mv => ArimaaGame.baseMove(mv)); const cloned = this.clone(); const myhand = [...cloned.hands![cloned.currplayer - 1]]; for (const [pc, , cell] of steps) { @@ -850,9 +879,23 @@ export class ArimaaGame extends GameBase { result.complete = complete; result.message = message; return result; - } - // regular moves - else { + + } + + // The pre-notation movement grammar: comma-separated `` + // steps with optional capture parentheticals. Kept verbatim so every + // historical move string still validates exactly as it did. + private validateLegacyMovement(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + const g = new SquareOrthGraph(8, 8); + // This validator once had two lapses: a push completed by a piece of + // equal strength when a stronger one was also adjacent, and a pull that + // moved the pulled piece somewhere other than the square the puller + // vacated. Both are rejected here, so no new move can use them. A few + // recorded games contain such a move; they replay through `trusted`, + // which skips validation entirely, so no saved game is disturbed. + const classifications = ArimaaGame.classify(this.currplayer, m.split(",").filter(Boolean)); + const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); // can't make too many moves let maxMoves = 4; if (this.variants.includes("eee") && this.stack.length === 1) { @@ -901,7 +944,7 @@ export class ArimaaGame extends GameBase { // from must be adjacent to lastFrom // to must equal lastFrom // pc must be stronger than lastPc - if (lastPlayer === cloned.currplayer || !g.neighbours(lastFrom!).includes(from) || (to !== undefined && to !== lastFrom) || ArimaaGame.strength(pc) < ArimaaGame.strength(lastPc)) { + if (lastPlayer === cloned.currplayer || !g.neighbours(lastFrom!).includes(from) || (to !== undefined && to !== lastFrom) || ArimaaGame.strength(pc) <= ArimaaGame.strength(lastPc)) { result.valid = false; result.message = i18next.t("apgames:validation.arimaa.INVALID_PUSH", {where: from}); return result; @@ -919,7 +962,7 @@ export class ArimaaGame extends GameBase { if (i > 0 && classifications[i - 1] !== "pusher") { const [lastPc, lastPlayer, lastFrom] = steps[i - 1]; const ns = g.neighbours(from); - if (lastPlayer === cloned.currplayer && ns.includes(lastFrom!) && ArimaaGame.strength(lastPc) > ArimaaGame.strength(pc)) { + if (lastPlayer === cloned.currplayer && ns.includes(lastFrom!) && ArimaaGame.strength(lastPc) > ArimaaGame.strength(pc) && (to === undefined || to === lastFrom)) { validPull = true; // check if push is possible let canPush = false; @@ -1061,7 +1104,163 @@ export class ArimaaGame extends GameBase { result.complete = complete; result.message = message; return result; + } + + private maxSteps(): number { + if (this.variants.includes("eee") && this.stack.length === 1) { + return 2; + } + return 4; + } + + // Steps the player's arrows must take at the very least. A piece ending on + // a square needs a step for each square between the one it was seen on and + // that one; pieces ending on distinct squares are distinct, so their steps + // add up, but arrows onto one square may all describe a single piece that + // visited every one of their squares (`Ed4g4 Ee4g4` is three steps by one + // elephant), so a destination costs only its longest arrow. An enemy piece + // needs two steps per square, but its partner's steps may be an arrowed + // own piece's own steps (a pusher that follows it), so that price stands + // alone rather than being added. + private static arrowBudget(tokens: Token[], player: playerid): number { + const longest = new Map(); + let enemy = 0; + for (const t of tokens) { + if (t.prop.kind !== "dest" || t.spec.square === undefined || t.spec.owner === undefined) { + continue; + } + const [fx, fy] = ArimaaGame.algebraic2coords(t.spec.square); + const [tx, ty] = ArimaaGame.algebraic2coords(t.prop.square); + const dist = Math.abs(fx - tx) + Math.abs(fy - ty); + longest.set(t.prop.square, Math.max(longest.get(t.prop.square) ?? 0, dist)); + if (t.spec.owner !== player) { + enemy = Math.max(enemy, 2 * dist); + } + } + let sum = 0; + for (const dist of longest.values()) { + sum += dist; + } + return Math.max(sum, enemy); + } + + private describeTrajectory(tr: Turn["trajectories"][number]): string { + const pc = pieceChar(tr.type, tr.owner); + if (tr.captured) { + return `${pc}${sqName(tr.start)}x`; + } + return `${pc}${sqName(tr.start)}${sqName(tr.final)}`; + } + + // Lightvector notation: resolve the tokens leniently and report: invalid for + // unreadable or unsatisfiable input, -1 while ambiguous, selecting or + // repeating, 1 only for a full turn with nothing inferred, otherwise 0. + private validateNotation(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + let parsed: ParsedMove; + try { + parsed = parseMove(m, true); + } catch (e) { + result.message = i18next.t("apgames:validation.arimaa.PARSE", {token: e instanceof NotationError ? e.token : m}); + return result; + } + const maxMoves = this.maxSteps(); + if (ArimaaGame.arrowBudget(parsed.tokens, this.currplayer) > maxMoves) { + result.message = i18next.t("apgames:validation.arimaa.TOO_LONG", {num: maxMoves}); + return result; + } + // only a selected piece so far + if (parsed.tokens.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.arimaa.PARTIAL_MOVE"); + return result; + } + const r = resolve(this.board, this.currplayer, maxMoves, parsed.tokens, true); + if (r.status === "unsatisfiable") { + result.message = this.explainUnsatisfiable(parsed.tokens, maxMoves); + return result; + } + result.valid = true; + result.canrender = true; + if (r.status === "ambiguous") { + result.complete = -1; + result.message = i18next.t("apgames:validation.arimaa.AMBIGUOUS", {count: r.positions}); + return result; } + // the move is known; repetition is checked only now + const cloned = this.clone(); + cloned.applyTurn(r.turn); + if (cloned.numRepeats() >= 2) { + result.complete = -1; + result.message = i18next.t("apgames:validation.arimaa.REPEAT"); + return result; + } + const extra = inferred(r.turn, parsed.tokens); + const used = r.turn.steps.length; + const messages: string[] = []; + if (used === maxMoves) { + // auto-completion only when the player has seen every piece that moves + result.complete = extra.length === 0 ? 1 : 0; + messages.push(i18next.t("apgames:validation._general.VALID_MOVE")); + } else { + result.complete = 0; + messages.push(i18next.t("apgames:validation.arimaa.PARTIAL", {count: maxMoves - used})); + } + if (extra.length > 0) { + messages.push(i18next.t("apgames:validation.arimaa.INFERRED", {pieces: extra.map(tr => this.describeTrajectory(tr)).join(", ")})); + } + if (parsed.pending !== undefined) { + // a selected piece never submits on its own; the hint says what a click does + result.complete = 0; + messages.push(i18next.t("apgames:validation.arimaa.INCOMPLETE")); + } + result.message = messages.join(" "); + return result; + } + + // Why nothing matches what was drawn. Two arrows chained through one + // square are the old step list typed with spaces; otherwise the first + // token that cannot be met on its own is named with the reason a player + // can act on, and when each token can be met alone it is the combination + // that does not fit. Only ever runs on a rejection. + private explainUnsatisfiable(tokens: Token[], maxMoves: number): string { + const dest = (t: Token): string|undefined => t.prop.kind === "dest" ? t.prop.square : undefined; + for (const a of tokens) { + const to = dest(a); + if (to === undefined || a.spec.square === undefined || a.spec.piece === undefined) { + continue; + } + const b = tokens.find(t => t !== a && t.spec.square === to && dest(t) !== undefined && (t.spec.piece === undefined || (t.spec.piece === a.spec.piece && t.spec.owner === a.spec.owner))); + if (b !== undefined) { + const pc = pieceChar(a.spec.piece, a.spec.owner!); + return i18next.t("apgames:validation.arimaa.NO_MOVE_CHAIN", {first: tokenText(a), second: tokenText(b), combined: `${pc}${a.spec.square}${dest(b)}`}); + } + } + const culprit = tokens.length === 1 ? tokens[0] : tokens.find(t => resolve(this.board, this.currplayer, maxMoves, [t], true).status === "unsatisfiable"); + if (culprit === undefined) { + return i18next.t("apgames:validation.arimaa.NO_MOVE_TOGETHER", {num: maxMoves}); + } + const from = culprit.spec.square; + const occupant = from === undefined ? undefined : this.board.get(from); + if (from === undefined || occupant === undefined || (culprit.spec.piece !== undefined && (occupant[0] !== culprit.spec.piece || occupant[1] !== culprit.spec.owner))) { + return i18next.t("apgames:validation.arimaa.NO_MOVE"); + } + if (occupant[1] !== this.currplayer) { + return i18next.t("apgames:validation.arimaa.NO_MOVE_ENEMY", {from, num: maxMoves}); + } + const to = dest(culprit); + if (to !== undefined && to !== from && occupant[0] === "R" && (this.currplayer === 1 ? to[1] < from[1] : to[1] > from[1])) { + return i18next.t("apgames:validation.arimaa.BACKWARDS"); + } + if (this.isFrozen(from)) { + return i18next.t("apgames:validation.arimaa.NO_MOVE_FROZEN", {from}); + } + if (to !== undefined && to !== from) { + return i18next.t("apgames:validation.arimaa.NO_MOVE_REACH", {from, to, num: maxMoves}); + } + return i18next.t("apgames:validation.arimaa.NO_MOVE"); } public move(m: string, {trusted = false, partial = false} = {}): ArimaaGame { @@ -1069,7 +1268,12 @@ export class ArimaaGame extends GameBase { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); } - m = m.replace(/\s+/g, ""); + const setup = this.hands !== undefined && this.hands[this.currplayer - 1].length > 0; + if (setup || isLegacy(m)) { + m = m.replace(/\s+/g, ""); + } else { + m = normalize(m); + } if (! trusted) { const result = this.validateMove(m); if (! result.valid) { @@ -1084,52 +1288,24 @@ export class ArimaaGame extends GameBase { const initial = this.clone(); // used to triple check that the board state changes const lastmove: string[] = []; + this.results = []; + this._selected = undefined; + this._arrows = undefined; + this._holds = undefined; + this._marks = undefined; if (m.length > 0) { - // because we don't have a move list to fall back on, - // we do some basic validation as we go and throw on errors - // but we don't go so far as to validate pushes and pulls here - this.results = []; - const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); - for (let i = 0; i < steps.length; i++) { - const [pc, owner, from, to] = steps[i]; - // placement - if (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) { - if (from !== undefined) { - this.board.set(from, [pc, this.currplayer]); - this.results.push({type: "place", what: pc, where: from}); - // update hand - if (!this.variants.includes("free")) { - this.hands![this.currplayer - 1].splice(this.hands![this.currplayer - 1].indexOf(pc), 1); - } - lastmove.push(`${this.currplayer === 1 ? pc : pc.toLowerCase()}${from}`); - } else if (i !== steps.length - 1) { - throw new Error("Invalid placement detected in the middle of the move."); - } + if (setup) { + lastmove.push(...this.applySetup(m)); + } else if (isLegacy(m)) { + const {steps, legacy} = this.applyLegacy(m); + if (!partial) { + // a move only the legacy validator allows keeps the legacy notation + lastmove.push(serializeTurn(initial.board, this.currplayer, this.maxSteps(), turnFromSteps(initial.board, this.currplayer, steps)) ?? legacy.join(", ")); } - // movement - else { - if (from !== undefined && to !== undefined) { - this._selected = undefined; - const moved = this.board.get(from)!; - this.board.set(to, moved); - this.board.delete(from); - this.results.push({type: "move", from, to}); - // check traps - let parenthetical = ""; - for (const trap of traps) { - if (this.board.has(trap) && this.isAlone(trap)) { - const [trapPc, trapOwner] = this.board.get(trap)!; - this.board.delete(trap); - this.results.push({type: "destroy", what: trapOwner === 1 ? trapPc : trapPc.toLowerCase(), where: trap}); - parenthetical = `(x${trapOwner === 1 ? trapPc : trapPc.toLowerCase()}${trap})`; - } - } - lastmove.push(`${owner === 1 ? pc : pc.toLowerCase()}${from}${to}${parenthetical}`); - } else if (from !== undefined) { - this._selected = from; - } else if (i !== steps.length - 1) { - throw new Error("Invalid move detected in the middle of the move."); - } + } else { + const turn = this.applyNotation(m, partial); + if (turn !== undefined && !partial) { + lastmove.push(serializeTurn(initial.board, this.currplayer, this.maxSteps(), turn) ?? m); } } } @@ -1192,6 +1368,103 @@ export class ArimaaGame extends GameBase { } + + // One step of the turn: move the piece, then resolve the traps. + private applyStep(from: string, to: string): void { + const moved = this.board.get(from)!; + this.board.set(to, moved); + this.board.delete(from); + this.results.push({type: "move", from, to}); + for (const trap of traps) { + if (this.board.has(trap) && this.isAlone(trap)) { + const [trapPc, trapOwner] = this.board.get(trap)!; + this.board.delete(trap); + this.results.push({type: "destroy", what: trapOwner === 1 ? trapPc : trapPc.toLowerCase(), where: trap}); + } + } + } + + // Play out a resolved turn step by step. + private applyTurn(turn: Turn): void { + for (const st of turn.steps) { + this.applyStep(sqName(st.from), sqName(st.to)); + } + } + + // Placement: returns the notation for each piece placed. + private applySetup(m: string): string[] { + const parts: string[] = []; + const steps = m.split(",").filter(Boolean).filter(mv => !mv.startsWith("x")).map(mv => ArimaaGame.baseMove(mv)); + for (let i = 0; i < steps.length; i++) { + const [pc, , from] = steps[i]; + if (from !== undefined) { + this.board.set(from, [pc, this.currplayer]); + this.results.push({type: "place", what: pc, where: from}); + // update hand + if (!this.variants.includes("free")) { + this.hands![this.currplayer - 1].splice(this.hands![this.currplayer - 1].indexOf(pc), 1); + } + parts.push(`${this.currplayer === 1 ? pc : pc.toLowerCase()}${from}`); + } else if (i !== steps.length - 1) { + throw new Error("Invalid placement detected in the middle of the move."); + } + } + return parts; + } + + // Legacy step list: returns the steps taken, for serialization, and each + // step written as the pre-notation engine did (with its captures). + private applyLegacy(m: string): {steps: Array<{from: string; to: string}>; legacy: string[]} { + const taken: Array<{from: string; to: string}> = []; + const legacy: string[] = []; + const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); + for (let i = 0; i < steps.length; i++) { + const [pc, owner, from, to] = steps[i]; + if (from !== undefined && to !== undefined) { + const before = this.results.length; + this.applyStep(from, to); + taken.push({from, to}); + const captures = this.results.slice(before).filter(r => r.type === "destroy").map(r => `(x${r.what}${r.where})`).join(""); + legacy.push(`${owner === 1 ? pc : pc.toLowerCase()}${from}${to}${captures}`); + } else if (from !== undefined) { + this._selected = from; + } else if (i !== steps.length - 1) { + throw new Error("Invalid move detected in the middle of the move."); + } + } + return {steps: taken, legacy}; + } + + // Lightvector notation: resolve and play the turn. Returns nothing when a + // partial move does not (yet) denote one; the arrows are kept for drawing. + private applyNotation(m: string, partial: boolean): Turn|undefined { + // a selection never blocks a submission: it is simply dropped + const parsed = parseMove(m, true); + this._selected = parsed.pending; + if (parsed.tokens.length === 0) { + return undefined; + } + const r = resolve(this.board, this.currplayer, this.maxSteps(), parsed.tokens, true); + if (r.status !== "resolved") { + if (!partial) { + throw new UserFacingError("VALIDATION_GENERAL", i18next.t(r.status === "ambiguous" ? "apgames:validation.arimaa.AMBIGUOUS" : "apgames:validation.arimaa.NO_MOVE", {count: r.status === "ambiguous" ? r.positions : 0})); + } + // the move does not yet denote a turn, so draw what was entered + this._arrows = parsed.tokens.filter(isArrow).map(t => [t.spec.square!, (t.prop as {square: string}).square]); + this._holds = parsed.tokens.filter(isHold).map(t => t.spec.square!); + this._marks = parsed.tokens.filter(isMark).map(t => t.spec.square!); + return undefined; + } + this.applyTurn(r.turn); + if (partial) { + // the turn's own results draw its arrows and captures, but a held + // piece leaves no trace in them, and the player needs to see that + // the hold is there (a click on it lifts it) + this._holds = parsed.tokens.filter(isHold).map(t => t.spec.square!); + } + return r.turn; + } + protected checkEOG(): ArimaaGame { const prevPlayer: playerid = this.currplayer === 1 ? 2 : 1; const prevGoal = prevPlayer === 1 ? 8 : 1; @@ -1234,7 +1507,7 @@ export class ArimaaGame extends GameBase { } // Check if currplayer has no possible move (all pieces are frozen or have no place to move). If so prevPlayer wins. if (!this.gameover) { - if (this.partialMoves().length === 0) { + if (!hasAnyMove(this.board, this.currplayer)) { this.gameover = true; this.winner = [prevPlayer]; } @@ -1429,20 +1702,53 @@ export class ArimaaGame extends GameBase { areas, }; - // Add annotations + // Add annotations: one arrow per piece from where it started the turn + // to where it ended up, the enter glyph for a piece back where it began, + // and the exit glyph on a trap that claimed a piece rep.annotations = []; - if (this.results.length > 0) { - for (const move of this.results) { - if (move.type === "move") { - const [fromX, fromY] = ArimaaGame.algebraic2coords(move.from); - const [toX, toY] = ArimaaGame.algebraic2coords(move.to); - rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); - } else if (move.type === "place") { - const [x, y] = ArimaaGame.algebraic2coords(move.where!); - rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); - } else if (move.type === "destroy") { - const [x, y] = ArimaaGame.algebraic2coords(move.where!); - rep.annotations.push({type: "exit", targets: [{row: y, col: x}]}); + const point = (cell: string): {row: number; col: number} => { + const [x, y] = ArimaaGame.algebraic2coords(cell); + return {row: y, col: x}; + }; + const entered = new Set(); + const exited = new Set(); + for (const tr of ArimaaGame.trajectoriesFromResults(this.results)) { + if (tr.start !== tr.final) { + rep.annotations.push({type: "move", targets: [point(tr.start), point(tr.final)]}); + } + if (tr.captured) { + rep.annotations.push({type: "exit", targets: [point(tr.final)]}); + exited.add(tr.final); + } else if (tr.start === tr.final) { + rep.annotations.push({type: "enter", targets: [point(tr.start)]}); + entered.add(tr.start); + } + } + for (const r of this.results) { + if (r.type === "place") { + rep.annotations.push({type: "enter", targets: [point(r.where!)]}); + } + } + // arrows that do not yet denote a move are still shown + if (this._arrows !== undefined) { + for (const [from, to] of this._arrows) { + rep.annotations.push({type: "move", targets: [point(from), point(to)]}); + } + } + // holds are drawn as the piece entering its own square, capture marks as it leaving + if (this._holds !== undefined) { + for (const sq of this._holds) { + if (!entered.has(sq) && !exited.has(sq)) { + rep.annotations.push({type: "enter", targets: [point(sq)]}); + entered.add(sq); + } + } + } + if (this._marks !== undefined) { + for (const sq of this._marks) { + if (!exited.has(sq)) { + rep.annotations.push({type: "exit", targets: [point(sq)]}); + exited.add(sq); } } } @@ -1559,10 +1865,45 @@ export class ArimaaGame extends GameBase { return num; } + // Many spellings denote one move, and case carries colour, so compare the + // positions the two strings reach from the state before move1. Both are + // moves that were played, so they are replayed trusted: a recorded move + // the rules no longer allow still has to compare equal to itself. public sameMove(move1: string, move2: string): boolean { - const left = move1.replace(/\s+/g, "").split(",").map(m => ArimaaGame.bareMove(m)).join(","); - const right = move2.replace(/\s+/g, "").split(",").map(m => ArimaaGame.bareMove(m)).join(","); - return left === right; + const norm = (m: string): string => m.replace(/\s+/g, ""); + if (norm(move1) === norm(move2)) { + return true; + } + const cloned = this.clone(); + cloned.stack.pop(); + cloned.load(-1); + cloned.gameover = false; + cloned.winner = []; + try { + cloned.move(move2, {trusted: true}); + } catch { + return false; + } + return cloned.signature() === this.signature() && JSON.stringify(cloned.hands) === JSON.stringify(this.hands); + } + + // Follow each piece through the per-step results of a turn. + public static trajectoriesFromResults(results: APMoveResult[]): Array<{start: string; final: string; captured: boolean}> { + const where = new Map(); + const out = new Map(); + for (const r of results) { + if (r.type === "move") { + const key = where.get(r.from) ?? r.from; + where.delete(r.from); + where.set(r.to, key); + out.set(key, {start: key, final: r.to, captured: false}); + } else if (r.type === "destroy" && r.where !== undefined) { + const key = where.get(r.where) ?? r.where; + where.delete(r.where); + out.set(key, {start: key, final: r.where, captured: true}); + } + } + return [...out.values()]; } public getStartingPosition(): string { diff --git a/src/games/arimaa/notation.ts b/src/games/arimaa/notation.ts new file mode 100644 index 00000000..345be71b --- /dev/null +++ b/src/games/arimaa/notation.ts @@ -0,0 +1,215 @@ +/** + * Lightvector notation for Arimaa: tokenizer, parser, and legacy dispatch. + * + * A move is a space-separated list of tokens ``: + * specifier := piece | square | piece square e.g. `E`, `d4`, `Ed4` + * property := square | [nsew]+ | x e.g. `e5`, `nn`, `x` + * Upper-case pieces are Gold, lower-case Silver. Squares are algebraic from + * Gold's side and directions are absolute (`n` is toward rank 8 for both). + * A token asserts that some piece satisfying its specifier (at any point in + * the turn) ends on the square, took those steps in that order, or was + * captured. The move meant is found in the first (steps, displaced pieces) + * bucket holding a satisfying turn, provided every satisfying turn there + * reaches the same position. + */ + +export type Piece = "E" | "M" | "H" | "D" | "C" | "R"; +export type playerid = 1 | 2; +export type Dir = "n" | "s" | "e" | "w"; + +const pieceRe = /^[EMHDCRemhdcr]$/; +const squareRe = /^[a-h][1-8]$/; + +export interface Specifier { + piece?: Piece; + owner?: playerid; + square?: string; +} + +export type Property = + | { kind: "dest"; square: string } + | { kind: "steps"; dirs: Dir[] } + | { kind: "capture" }; + +export interface Token { + spec: Specifier; + prop: Property; + text: string; +} + +export interface ParsedMove { + tokens: Token[]; + /** A trailing bare square: the selected piece awaiting a destination. Input only. */ + pending?: string; + /** The selection before `pending`, kept while a second click on `pending` could complete an arrow from it. Input only. */ + previous?: string; +} + +export class NotationError extends Error { + public readonly token: string; + constructor(token: string) { + super(`Cannot parse token '${token}'`); + this.name = "NotationError"; + this.token = token; + } +} + +export function isSquare(s: string): boolean { + return squareRe.test(s); +} + +/** Trim and collapse runs of whitespace to single spaces. */ +export function normalize(m: string): string { + return m.trim().replace(/\s+/g, " "); +} + +export function tokenize(m: string): string[] { + const n = normalize(m); + return n.length === 0 ? [] : n.split(" "); +} + +/** + * The pre-Lightvector notation: comma-separated `` steps with + * optional `(x)` parentheticals, plus prefix-`x` capture tokens + * (`xRc3`) written at the end of free setup. None of those characters can + * appear in Lightvector notation, so their presence is the whole test. + */ +export function isLegacy(m: string): boolean { + if (m.includes(",") || m.includes("(")) { + return true; + } + return tokenize(m).some(t => t.startsWith("x")); +} + +function pieceFrom(ch: string): Specifier { + return { piece: ch.toUpperCase() as Piece, owner: ch === ch.toUpperCase() ? 1 : 2 }; +} + +export function parseSpecifier(s: string): Specifier | undefined { + if (s.length === 1 && pieceRe.test(s)) { + return pieceFrom(s); + } + if (s.length === 2 && squareRe.test(s)) { + return { square: s }; + } + if (s.length === 3 && pieceRe.test(s[0]) && squareRe.test(s.slice(1))) { + return { ...pieceFrom(s[0]), square: s.slice(1) }; + } + return undefined; +} + +/** + * Properties are self-delimiting, so one pass from the right splits a token: + * a trailing `x` is a capture; a trailing digit ends a square; otherwise the + * maximal trailing run of `[nsew]` is the step list, backing off one character + * if that would leave no specifier (`ee` is the Silver elephant stepping east). + */ +export function parseToken(text: string): Token { + if (text.length < 2) { + throw new NotationError(text); + } + const last = text[text.length - 1]; + let specText: string; + let prop: Property; + if (last === "x") { + prop = { kind: "capture" }; + specText = text.slice(0, -1); + } else if (/[1-8]/.test(last)) { + const square = text.slice(-2); + if (!squareRe.test(square)) { + throw new NotationError(text); + } + prop = { kind: "dest", square }; + specText = text.slice(0, -2); + } else if (/[nsew]/.test(last)) { + let i = text.length; + while (i > 0 && /[nsew]/.test(text[i - 1])) { + i--; + } + if (i === 0) { + i = 1; + } + prop = { kind: "steps", dirs: text.slice(i).split("") as Dir[] }; + specText = text.slice(0, i); + } else { + throw new NotationError(text); + } + const spec = parseSpecifier(specText); + if (spec === undefined) { + throw new NotationError(text); + } + return { spec, prop, text }; +} + +/** + * Parse a whole move. With `allowPending`, up to two final bare squares are + * returned separately, as the pending selection and the selection before it, + * instead of being rejected. + */ +export function parseMove(m: string, allowPending = false): ParsedMove { + const parts = tokenize(m); + let end = parts.length; + let pending: string | undefined; + let previous: string | undefined; + if (allowPending && end > 0 && squareRe.test(parts[end - 1])) { + pending = parts[end - 1]; + end--; + if (end > 0 && squareRe.test(parts[end - 1])) { + previous = parts[end - 1]; + end--; + } + } + const tokens = parts.slice(0, end).map(parseToken); + return { tokens, pending, previous }; +} + +export function pieceChar(piece: Piece, owner: playerid): string { + return owner === 1 ? piece : piece.toLowerCase(); +} + +export function tokenText(t: Token): string { + return t.text; +} + +/** Rebuild a move string from tokens, keeping the selections last (the pending square at the very end). */ +export function joinMove(tokens: Token[], pending?: string, previous?: string): string { + const parts = tokens.map(tokenText); + if (pending !== undefined) { + if (previous !== undefined) { + parts.push(previous); + } + parts.push(pending); + } + return parts.join(" "); +} + +/** Build a destination token for `piece` on `from` ending on `to`. */ +export function arrowToken(piece: Piece, owner: playerid, from: string, to: string): Token { + const text = `${pieceChar(piece, owner)}${from}${to}`; + return { spec: { piece, owner, square: from }, prop: { kind: "dest", square: to }, text }; +} + +/** A hold: `piece` on `square` ends the turn where it stands (a zero-length arrow). */ +export function holdToken(piece: Piece, owner: playerid, square: string): Token { + return arrowToken(piece, owner, square, square); +} + +/** A destination token drawn from a piece's square to another square. */ +export function isArrow(t: Token): boolean { + return t.prop.kind === "dest" && t.spec.square !== undefined && t.spec.square !== t.prop.square; +} + +/** A destination token that keeps a piece on its own square. */ +export function isHold(t: Token): boolean { + return t.prop.kind === "dest" && t.spec.square !== undefined && t.spec.square === t.prop.square; +} + +/** A capture token for the piece on `square`: it is captured during the turn. */ +export function captureToken(piece: Piece, owner: playerid, square: string): Token { + return { spec: { piece, owner, square }, prop: { kind: "capture" }, text: `${pieceChar(piece, owner)}${square}x` }; +} + +/** A capture token naming a square: a mark on the piece standing there. */ +export function isMark(t: Token): boolean { + return t.prop.kind === "capture" && t.spec.square !== undefined; +} diff --git a/src/games/arimaa/turns.ts b/src/games/arimaa/turns.ts new file mode 100644 index 00000000..8cc878af --- /dev/null +++ b/src/games/arimaa/turns.ts @@ -0,0 +1,1034 @@ +/** + * Turn enumeration, Lightvector-notation resolution, and canonical + * serialization for Arimaa. + * + * Squares are indexed `rank * 8 + file` with rank 0 being rank "1", so `n` is + * +8, `s` is -8, `e` is +1 and `w` is -1. + */ +import { arrowToken, captureToken, pieceChar, tokenText, type Dir, type Piece, type playerid, type Token } from "./notation.js"; + +export type CellContents = [Piece, playerid]; + +export function sqIndex(cell: string): number { + return (cell.charCodeAt(1) - 49) * 8 + (cell.charCodeAt(0) - 97); +} + +export function sqName(i: number): string { + return String.fromCharCode(97 + (i % 8)) + String(Math.floor(i / 8) + 1); +} + +const DIRS: ReadonlyArray<[Dir, number]> = [["n", 8], ["s", -8], ["e", 1], ["w", -1]]; + +function stepTo(i: number, d: number): number { + const f = i % 8; + const r = (i - f) / 8; + switch (d) { + case 8: return r < 7 ? i + 8 : -1; + case -8: return r > 0 ? i - 8 : -1; + case 1: return f < 7 ? i + 1 : -1; + default: return f > 0 ? i - 1 : -1; + } +} + +const NEIGHBOURS: number[][] = []; +for (let i = 0; i < 64; i++) { + const ns: number[] = []; + for (const [, d] of DIRS) { + const j = stepTo(i, d); + if (j >= 0) { + ns.push(j); + } + } + NEIGHBOURS.push(ns); +} + +export const TRAPS: number[] = ["c3", "f3", "c6", "f6"].map(sqIndex); + +export function manhattan(a: number, b: number): number { + return Math.abs((a % 8) - (b % 8)) + Math.abs(Math.floor(a / 8) - Math.floor(b / 8)); +} + +/** `DIST[a * 64 + b]` is the manhattan distance between squares `a` and `b`. */ +const DIST = new Int8Array(64 * 64); +for (let a = 0; a < 64; a++) { + for (let b = 0; b < 64; b++) { + DIST[a * 64 + b] = manhattan(a, b); + } +} + +function dirBetween(from: number, to: number): Dir { + const d = to - from; + return d === 8 ? "n" : d === -8 ? "s" : d === 1 ? "e" : "w"; +} + +const STRENGTH: Record = { R: 1, C: 2, D: 3, H: 4, M: 5, E: 6 }; + +interface PieceState { + id: number; + type: Piece; + owner: playerid; + strength: number; + start: number; + /** current square, or -1 once captured */ + cur: number; + /** the trap it was captured on, or -1 */ + capturedAt: number; + /** trajectory: start square, then every square stepped to (including a trap it died on) */ + visited: number[]; +} + +export interface TurnStep { + pid: number; + piece: Piece; + owner: playerid; + from: number; + to: number; + dir: Dir; +} + +export interface CaptureRec { + pid: number; + piece: Piece; + owner: playerid; + square: number; + /** index of the step after which the capture happened */ + afterStep: number; +} + +export interface Trajectory { + type: Piece; + owner: playerid; + start: number; + /** final square: where it ends the turn, or the trap it was captured on */ + final: number; + captured: boolean; + visited: number[]; + /** index of the piece's first step, or of the step that captured it in place */ + firstStep: number; +} + +export interface Turn { + steps: TurnStep[]; + captures: CaptureRec[]; + /** every piece that moved or was captured */ + trajectories: Trajectory[]; + /** pieces of either colour whose final square is not their start square */ + displaced: number; + signature: string; + ownCaptured: number; + enemyCaptured: number; +} + +interface Undo { + st: TurnStep; + caps: Array<{ pid: number; square: number; code: number }>; +} + +class Search { + public readonly cells = new Int8Array(64); + public readonly ids = new Int16Array(64).fill(-1); + public readonly pieces: PieceState[] = []; + public readonly steps: TurnStep[] = []; + public readonly captures: CaptureRec[] = []; + public readonly startSig: string; + + constructor(board: Map, public readonly player: playerid) { + const cells = [...board.entries()].map(([cell, [type, owner]]) => ({ i: sqIndex(cell), type, owner })).sort((a, b) => a.i - b.i); + for (const { i, type, owner } of cells) { + const id = this.pieces.length; + this.pieces.push({ id, type, owner, strength: STRENGTH[type], start: i, cur: i, capturedAt: -1, visited: [i] }); + this.cells[i] = owner === 1 ? STRENGTH[type] : -STRENGTH[type]; + this.ids[i] = id; + } + this.startSig = this.signature(); + } + + public signature(): string { + let s = ""; + for (let i = 0; i < 64; i++) { + const c = this.cells[i]; + if (c === 0) { + s += "-"; + } else { + s += pieceChar(this.pieces[this.ids[i]].type, c > 0 ? 1 : 2); + } + } + return s; + } + + private hasFriend(i: number, gold: boolean): boolean { + for (const n of NEIGHBOURS[i]) { + const c = this.cells[n]; + if (c !== 0 && (c > 0) === gold) { + return true; + } + } + return false; + } + + public frozen(i: number): boolean { + const c = this.cells[i]; + const gold = c > 0; + let stronger = false; + for (const n of NEIGHBOURS[i]) { + const cn = this.cells[n]; + if (cn === 0) { + continue; + } + if ((cn > 0) === gold) { + return false; + } + if (Math.abs(cn) > Math.abs(c)) { + stronger = true; + } + } + return stronger; + } + + /** + * Every legal atom from the current position: a single step by one of the + * mover's pieces, a push (enemy steps away, pusher follows into its square) + * or a pull (puller steps away, enemy follows into its square). Legality + * matches the step-by-step validator exactly. + */ + public atoms(remaining: number): TurnStep[][] { + const out: TurnStep[][] = []; + const gold = this.player === 1; + for (const p of this.pieces) { + if (p.owner !== this.player || p.cur < 0) { + continue; + } + const i = p.cur; + if (this.frozen(i)) { + continue; + } + const backward = p.type === "R" ? (gold ? -8 : 8) : 0; + for (const [dir, d] of DIRS) { + if (d === backward) { + continue; + } + const j = stepTo(i, d); + if (j < 0) { + continue; + } + const c = this.cells[j]; + const own: TurnStep = { pid: p.id, piece: p.type, owner: p.owner, from: i, to: j, dir }; + if (c === 0) { + out.push([own]); + if (remaining >= 2) { + for (const n of NEIGHBOURS[i]) { + const q = this.ids[n]; + if (q < 0) { + continue; + } + const qp = this.pieces[q]; + if (qp.owner === this.player || qp.strength >= p.strength) { + continue; + } + out.push([own, { pid: q, piece: qp.type, owner: qp.owner, from: n, to: i, dir: dirBetween(n, i) }]); + } + } + } else if (remaining >= 2 && (c > 0) !== gold && Math.abs(c) < p.strength) { + const q = this.ids[j]; + const qp = this.pieces[q]; + for (const [dir2, d2] of DIRS) { + const t = stepTo(j, d2); + if (t < 0 || this.cells[t] !== 0) { + continue; + } + out.push([{ pid: q, piece: qp.type, owner: qp.owner, from: j, to: t, dir: dir2 }, own]); + } + } + } + } + return out; + } + + public apply(st: TurnStep): Undo { + const p = this.pieces[st.pid]; + this.cells[st.to] = this.cells[st.from]; + this.cells[st.from] = 0; + this.ids[st.to] = st.pid; + this.ids[st.from] = -1; + p.cur = st.to; + p.visited.push(st.to); + this.steps.push(st); + const undo: Undo = { st, caps: [] }; + for (const t of TRAPS) { + const c = this.cells[t]; + if (c === 0 || this.hasFriend(t, c > 0)) { + continue; + } + const q = this.ids[t]; + const qp = this.pieces[q]; + this.cells[t] = 0; + this.ids[t] = -1; + qp.cur = -1; + qp.capturedAt = t; + this.captures.push({ pid: q, piece: qp.type, owner: qp.owner, square: t, afterStep: this.steps.length - 1 }); + undo.caps.push({ pid: q, square: t, code: c }); + } + return undo; + } + + public undo(u: Undo): void { + for (let k = u.caps.length - 1; k >= 0; k--) { + const { pid, square, code } = u.caps[k]; + this.cells[square] = code; + this.ids[square] = pid; + this.pieces[pid].cur = square; + this.pieces[pid].capturedAt = -1; + this.captures.pop(); + } + const p = this.pieces[u.st.pid]; + this.cells[u.st.from] = this.cells[u.st.to]; + this.cells[u.st.to] = 0; + this.ids[u.st.from] = u.st.pid; + this.ids[u.st.to] = -1; + p.cur = u.st.from; + p.visited.pop(); + this.steps.pop(); + } + + // Net displacement: pieces whose final square, the trap for a captured + // one, is not their start square. A round trip does not count, nor does + // a capture in place, nor a piece that steps out and dies back home. + public displaced(): number { + let n = 0; + for (const p of this.pieces) { + if (p.visited.length > 1 && (p.cur < 0 ? p.capturedAt : p.cur) !== p.start) { + n++; + } + } + return n; + } + + public snapshot(displaced: number, signature: string): Turn { + const trajectories: Trajectory[] = []; + let ownCaptured = 0; + let enemyCaptured = 0; + for (const p of this.pieces) { + const captured = p.cur < 0; + if (p.visited.length === 1 && !captured) { + continue; + } + let firstStep = this.steps.findIndex(s => s.pid === p.id); + if (firstStep < 0) { + firstStep = this.captures.find(c => c.pid === p.id)!.afterStep; + } + trajectories.push({ type: p.type, owner: p.owner, start: p.start, final: captured ? p.capturedAt : p.cur, captured, visited: [...p.visited], firstStep }); + if (captured) { + if (p.owner === this.player) { + ownCaptured++; + } else { + enemyCaptured++; + } + } + } + trajectories.sort((a, b) => a.firstStep - b.firstStep); + return { steps: this.steps.map(s => ({ ...s })), captures: this.captures.map(c => ({ ...c })), trajectories, displaced, signature, ownCaptured, enemyCaptured }; + } +} + +// --------------------------------------------------------------------------- +// Resolution + +interface PTok { + type?: Piece; + owner?: playerid; + /** specifier square index, or -1 */ + sq: number; + kind: "dest" | "steps" | "capture"; + dest: number; + dirs: Dir[]; +} + +function prepare(tokens: Token[]): PTok[] { + return tokens.map(t => ({ + type: t.spec.piece, + owner: t.spec.owner, + sq: t.spec.square === undefined ? -1 : sqIndex(t.spec.square), + kind: t.prop.kind, + dest: t.prop.kind === "dest" ? sqIndex(t.prop.square) : -1, + dirs: t.prop.kind === "steps" ? t.prop.dirs : [], + })); +} + +export type Resolution = + | { status: "unsatisfiable" } + | { status: "ambiguous"; bucket: [number, number]; positions: number; candidates: Turn[] } + | { status: "resolved"; bucket: [number, number]; turn: Turn; positions: number; lenient: boolean }; + +/** + * Find the move a token list denotes from `board` with `player` to move. + * Strict resolution is the notation's meaning; lenient resolution breaks a + * tie in the first bucket by fewest own captures, then most enemy captures. + * `prune` disables the search heuristic; it exists so tests can check that + * pruning never changes an answer. + */ +export function resolve(board: Map, player: playerid, maxSteps: number, tokens: Token[], lenient: boolean, prune = true): Resolution { + const s = new Search(board, player); + const toks = prepare(tokens); + const destGroups = new Map(); + const captureToks: PTok[] = []; + const stepToks: PTok[] = []; + for (const t of toks) { + if (t.kind === "dest") { + const g = destGroups.get(t.dest); + if (g === undefined) { + destGroups.set(t.dest, [t]); + } else { + g.push(t); + } + } else if (t.kind === "capture") { + captureToks.push(t); + } else { + stepToks.push(t); + } + } + + const typeMatch = (p: PieceState, t: PTok): boolean => t.type === undefined || (p.type === t.type && p.owner === t.owner); + const visitedOk = (p: PieceState, t: PTok): boolean => t.sq < 0 || p.visited.includes(t.sq); + const finalOf = (p: PieceState): number => p.cur < 0 ? p.capturedAt : p.cur; + + // Longest prefix of a step token some piece has taken so far, ignoring the + // ordering between tokens (a lower bound on what remains). + const bestPrefix = (t: PTok): number => { + let best = 0; + for (const p of s.pieces) { + if (!typeMatch(p, t)) { + continue; + } + let j = 0; + for (const st of s.steps) { + if (st.pid !== p.id) { + continue; + } + if (j === 0 && t.sq >= 0 && st.from !== t.sq) { + continue; + } + if (st.dir === t.dirs[j]) { + j++; + if (j === t.dirs.length) { + break; + } + } + } + if (j > best) { + best = j; + } + } + return best; + }; + + // Pieces a token could be witnessed by, and for a destination group the + // pieces every token of it accepts; fixed for the whole search. + const cands = new Map(); + for (const t of toks) { + cands.set(t, s.pieces.filter(p => typeMatch(p, t))); + } + const groupCands = new Map(); + for (const [dest, group] of destGroups) { + groupCands.set(dest, s.pieces.filter(p => group.every(t => typeMatch(p, t)))); + } + + // Steps before some own piece stronger than `e` is adjacent to it (the + // precondition for pushing or pulling it); Infinity if there is none. + // Cached for the duration of one heuristic evaluation. + const approachStamp = new Int32Array(s.pieces.length); + const approachValue = new Float64Array(s.pieces.length); + let stamp = 0; + const approach = (e: PieceState): number => { + if (approachStamp[e.id] === stamp) { + return approachValue[e.id]; + } + let best = Infinity; + for (const q of s.pieces) { + if (q.owner !== s.player || q.cur < 0 || q.strength <= e.strength) { + continue; + } + const d = Math.max(0, DIST[q.cur * 64 + e.cur] - 1); + if (d < best) { + best = d; + } + } + approachStamp[e.id] = stamp; + approachValue[e.id] = best; + return best; + }; + + const rank = (i: number): number => i >> 3; + // The mover's rabbits never step backward, and nothing else moves them + // during the turn, so a square behind one is out of its reach for good. + const behind = (from: number, to: number): boolean => s.player === 1 ? rank(to) < rank(from) : rank(to) > rank(from); + const ownRabbit = (p: PieceState): boolean => p.type === "R" && p.owner === s.player; + const backwardDir: Dir = s.player === 1 ? "s" : "n"; + // Squares `p` must still cover to satisfy a token: to the specifier's + // square first if it has not been there, then to the target. + const squaresTo = (p: PieceState, t: PTok, target: number): number => { + if (t.sq >= 0 && !p.visited.includes(t.sq)) { + if (ownRabbit(p) && (behind(p.cur, t.sq) || behind(t.sq, target))) { + return Infinity; + } + return DIST[p.cur * 64 + t.sq] + DIST[t.sq * 64 + target]; + } + if (ownRabbit(p) && behind(p.cur, target)) { + return Infinity; + } + return DIST[p.cur * 64 + target]; + }; + + // The full price of an own piece covering `d` squares: a frozen piece + // cannot step until another piece has, and that step is none of its own. + const ownPrice = (p: PieceState, d: number): number => { + if (d === 0 || d === Infinity) { + return d; + } + return s.frozen(p.cur) ? d + 1 : d; + }; + + // The full price of moving an enemy piece `d` squares: two steps a square + // plus getting a stronger piece next to it. Sound on its own, but not when + // added to other witnesses' costs, since the partner's steps may be theirs. + const enemyPrice = (p: PieceState, d: number): number => { + if (d === 0) { + return 0; + } + const a = approach(p); + return a === Infinity ? Infinity : 2 * d + a; + }; + // What a full price is at least, before the partner and release steps are + // priced: enough to skip a witness that cannot beat the best so far. + const atLeast = (p: PieceState, d: number): number => p.owner === s.player ? d : 2 * d; + + // An admissible lower bound on the steps still needed, or any value above + // `limit` as soon as the bound is known to exceed it. Two bounds are + // combined by max: (1) one step per square for every witness, summed over + // distinct destinations, which need distinct witnesses; (2) per token, the + // full price of its cheapest witness, including an enemy's partner and + // approach steps, a frozen piece's release, and clearing the destination. + const heuristic = (limit: number): number => { + stamp++; + let sum = 0; + let max = 0; + for (const [dest, group] of destGroups) { + let groupCost = 0; + let groupFull = 0; + if (TRAPS.includes(dest)) { + // several pieces can end on a trap (captured on it, then another + // arrives), so each token finds its own witness + for (const t of group) { + let best = Infinity; + let bestFull = Infinity; + for (const p of cands.get(t)!) { + let c: number; + if (p.cur < 0) { + c = p.capturedAt === dest && visitedOk(p, t) ? 0 : Infinity; + if (c < best) { + best = c; + } + if (c < bestFull) { + bestFull = c; + } + continue; + } + c = squaresTo(p, t, dest); + if (c < best) { + best = c; + } + if (atLeast(p, c) < bestFull) { + const full = p.owner === s.player ? ownPrice(p, c) : enemyPrice(p, c); + if (full < bestFull) { + bestFull = full; + } + } + } + if (best === Infinity || bestFull === Infinity) { + return Infinity; + } + if (best > groupCost) { + groupCost = best; + } + if (bestFull > groupFull) { + groupFull = bestFull; + } + } + } else { + // only one piece can stand on the square at the end, so a single + // witness has to satisfy every token of the group + groupCost = Infinity; + groupFull = Infinity; + const occupant = s.ids[dest]; + let occCost = Infinity; + let occFull = Infinity; + let others = Infinity; + for (const p of groupCands.get(dest)!) { + if (p.cur < 0) { + continue; + } + let c = 0; + for (const t of group) { + const d = squaresTo(p, t, dest); + if (d > c) { + c = d; + } + } + if (c < groupCost) { + groupCost = c; + } + if (p.id === occupant) { + occCost = c; + occFull = p.owner === s.player ? ownPrice(p, c) : enemyPrice(p, c); + if (occFull < groupFull) { + groupFull = occFull; + } + continue; + } + if (c < others) { + others = c; + } + if (atLeast(p, c) < groupFull) { + const full = p.owner === s.player ? ownPrice(p, c) : enemyPrice(p, c); + if (full < groupFull) { + groupFull = full; + } + } + } + if (groupCost === Infinity || groupFull === Infinity) { + return Infinity; + } + // whatever stands on the destination and does not already satisfy + // the group either becomes its witness by leaving and returning or + // has to make way: an enemy by being pushed or pulled, an own + // piece by a step of its own that is none of the witness's + if (occupant >= 0 && occCost !== 0) { + const x = s.pieces[occupant]; + const clear = x.owner !== s.player ? enemyPrice(x, 1) : Math.min(occFull, others + 1); + if (clear === Infinity) { + return Infinity; + } + if (clear > groupFull) { + groupFull = clear; + } + } + } + sum += groupCost; + if (groupFull > max) { + max = groupFull; + } + if (sum > limit || max > limit) { + return Math.max(sum, max); + } + } + for (const t of captureToks) { + let bestFull = Infinity; + for (const p of cands.get(t)!) { + if (p.cur < 0) { + if (visitedOk(p, t)) { + bestFull = 0; + break; + } + continue; + } + // it dies on some trap: after getting there, and once every + // neighbour of the trap of its colour has gone. Own + // neighbours step away; enemy ones are pushed or pulled, + // two dedicated steps each, after a first approach. + for (const trap of TRAPS) { + const d = squaresTo(p, t, trap); + if (d === Infinity || atLeast(p, d) >= bestFull) { + continue; + } + let friends = 0; + let nearest = Infinity; + for (const n of NEIGHBOURS[trap]) { + const q = s.ids[n]; + if (q < 0 || q === p.id || s.pieces[q].owner !== p.owner) { + continue; + } + friends++; + if (p.owner !== s.player) { + const a = approach(s.pieces[q]); + if (a < nearest) { + nearest = a; + } + } + } + let cost: number; + if (p.owner === s.player) { + cost = Math.max(ownPrice(p, d), d + friends); + } else if (friends === 0) { + cost = enemyPrice(p, d); + } else if (nearest === Infinity) { + continue; + } else { + cost = Math.max(enemyPrice(p, d), 2 * (d + friends), 2 * friends + nearest); + } + if (cost < bestFull) { + bestFull = cost; + } + } + } + if (bestFull === Infinity) { + return Infinity; + } + if (bestFull > max) { + max = bestFull; + } + if (max > limit) { + return max; + } + } + for (const t of stepToks) { + if (t.type === "R" && t.owner === s.player && t.dirs.includes(backwardDir)) { + return Infinity; + } + const left = t.dirs.length - bestPrefix(t); + if (left > max) { + max = left; + } + } + return Math.max(sum, max); + }; + + const satisfies = (): boolean => { + for (const group of destGroups.values()) { + for (const t of group) { + if (!s.pieces.some(p => typeMatch(p, t) && visitedOk(p, t) && finalOf(p) === t.dest)) { + return false; + } + } + } + for (const t of captureToks) { + if (!s.pieces.some(p => typeMatch(p, t) && visitedOk(p, t) && p.cur < 0)) { + return false; + } + } + // step tokens must be witnessed in the order written; the earliest + // completion of each leaves the most room for the next + let after = -1; + for (const t of stepToks) { + let bestEnd = Infinity; + for (const p of s.pieces) { + if (!typeMatch(p, t)) { + continue; + } + let j = 0; + let end = -1; + for (let i = after + 1; i < s.steps.length; i++) { + const st = s.steps[i]; + if (st.pid !== p.id) { + continue; + } + if (j === 0 && t.sq >= 0 && st.from !== t.sq) { + continue; + } + if (st.dir === t.dirs[j]) { + j++; + end = i; + if (j === t.dirs.length) { + break; + } + } + } + if (j === t.dirs.length && end < bestEnd) { + bestEnd = end; + } + } + if (bestEnd === Infinity) { + return false; + } + after = bestEnd; + } + return true; + }; + + // Everything the search asks of a state, except the order of the steps + // that led to it, is in the board and each moved piece's visited squares. + // Step tokens are the exception, so the memo is off while any is present. + const stateKey = (): string => { + let key = s.signature(); + for (const p of s.pieces) { + if (p.visited.length > 1 || p.cur < 0) { + key += `|${p.id}:${p.cur < 0 ? p.capturedAt : ""}:${[...p.visited].sort((a, b) => a - b).join(",")}`; + } + } + return key; + }; + const memo = stepToks.length === 0; + + for (let k = 1; k <= maxSteps; k++) { + const found = new Map(); + // States already searched with at least as many steps left. Every + // shallower iteration came up empty, so reaching a state again with + // fewer steps left can only lead to ends found there or ends that + // would have satisfied a shallower iteration. + const seen = new Map(); + const dfs = (remaining: number): void => { + if (remaining === 0) { + const sig = s.signature(); + if (sig === s.startSig || !satisfies()) { + return; + } + const displaced = s.displaced(); + const existing = found.get(sig); + if (existing === undefined || displaced < existing.displaced) { + found.set(sig, s.snapshot(displaced, sig)); + } + return; + } + if (prune && heuristic(remaining) > remaining) { + return; + } + if (prune && memo && remaining < k) { + const key = stateKey(); + const best = seen.get(key); + if (best !== undefined && best >= remaining) { + return; + } + seen.set(key, remaining); + } + for (const atom of s.atoms(remaining)) { + if (atom.length > remaining) { + continue; + } + const undos: Undo[] = []; + for (const st of atom) { + undos.push(s.apply(st)); + } + dfs(remaining - atom.length); + while (undos.length > 0) { + s.undo(undos.pop()!); + } + } + }; + dfs(k); + if (found.size === 0) { + continue; + } + let minDisplaced = Infinity; + for (const t of found.values()) { + if (t.displaced < minDisplaced) { + minDisplaced = t.displaced; + } + } + const candidates = [...found.values()].filter(t => t.displaced === minDisplaced); + const bucket: [number, number] = [k, minDisplaced]; + if (candidates.length === 1) { + return { status: "resolved", bucket, turn: candidates[0], positions: 1, lenient: false }; + } + if (lenient) { + const score = (t: Turn): number => t.ownCaptured * 100 - t.enemyCaptured; + let best = candidates[0]; + let tie = false; + for (const t of candidates.slice(1)) { + if (score(t) < score(best)) { + best = t; + tie = false; + } else if (score(t) === score(best)) { + tie = true; + } + } + if (!tie) { + return { status: "resolved", bucket, turn: best, positions: candidates.length, lenient: true }; + } + } + return { status: "ambiguous", bucket, positions: candidates.length, candidates }; + } + return { status: "unsatisfiable" }; +} + +/** Whether the player to move has any legal step, push or pull. */ +export function hasAnyMove(board: Map, player: playerid): boolean { + return new Search(board, player).atoms(2).length > 0; +} + +/** Replay an explicit step list (no legality checks) and describe the turn. */ +export function turnFromSteps(board: Map, player: playerid, steps: Array<{ from: string; to: string }>): Turn { + const s = new Search(board, player); + for (const { from, to } of steps) { + const f = sqIndex(from); + const t = sqIndex(to); + const pid = s.ids[f]; + if (pid < 0) { + throw new Error(`No piece on ${from} to move.`); + } + const p = s.pieces[pid]; + s.apply({ pid, piece: p.type, owner: p.owner, from: f, to: t, dir: dirBetween(f, t) }); + } + return s.snapshot(s.displaced(), s.signature()); +} + +/** The displaced pieces of `turn` that no token's specifier could refer to. */ +export function inferred(turn: Turn, tokens: Token[]): Trajectory[] { + const named = (tr: Trajectory): boolean => tokens.some(t => + (t.spec.piece === undefined || (t.spec.piece === tr.type && t.spec.owner === tr.owner)) && + (t.spec.square === undefined || tr.visited.includes(sqIndex(t.spec.square)))); + return turn.trajectories.filter(tr => tr.final !== tr.start && !named(tr)); +} + +// --------------------------------------------------------------------------- +// Serialization + +function stepToken(st: TurnStep): Token { + const from = sqName(st.from); + return { spec: { piece: st.piece, owner: st.owner, square: from }, prop: { kind: "steps", dirs: [st.dir] }, text: `${pieceChar(st.piece, st.owner)}${from}${st.dir}` }; +} + +function bareToken(t: Token): Token { + const piece = pieceChar(t.spec.piece!, t.spec.owner!); + let text: string; + if (t.prop.kind === "dest") { + text = `${piece}${t.prop.square}`; + } else if (t.prop.kind === "capture") { + text = `${piece}x`; + } else { + text = `${piece}${t.prop.dirs.join("")}`; + } + return { spec: { piece: t.spec.piece, owner: t.spec.owner }, prop: t.prop, text }; +} + +/** Fewest steps for the mover's piece on `from` to reach `to`, charging 2 to enter a square occupied at the start of the turn. */ +function ownReach(cells: Int8Array, from: number, to: number): number { + const dist = new Array(64).fill(Infinity); + dist[from] = 0; + const done = new Array(64).fill(false); + for (;;) { + let u = -1; + for (let i = 0; i < 64; i++) { + if (!done[i] && dist[i] < Infinity && (u < 0 || dist[i] < dist[u])) { + u = i; + } + } + if (u < 0 || u === to) { + break; + } + done[u] = true; + for (const n of NEIGHBOURS[u]) { + const w = cells[n] === 0 ? 1 : 2; + if (dist[u] + w < dist[n]) { + dist[n] = dist[u] + w; + } + } + } + return dist[to]; +} + +/** + * Write `turn` in Lightvector notation: one destination token per displaced + * survivor, one capture token per captured piece (its origin if it moved), + * nothing for pieces that return home; discriminated (a returning piece's + * intermediate square, then holds on pieces the strict reading would move, + * then step tokens) until it resolves strictly to the turn's position; then + * specifiers simplified to the bare + * piece wherever the resolver confirms the square is redundant. Returns + * nothing for a turn no legal step sequence reaches, which the resolver can + * never denote (the legacy validator accepts a few such moves). + */ +export function serializeTurn(board: Map, player: playerid, maxSteps: number, turn: Turn): string | undefined { + let tokens: Token[] = []; + for (const tr of turn.trajectories) { + if (tr.captured) { + tokens.push(captureToken(tr.type, tr.owner, sqName(tr.visited.length > 1 ? tr.start : tr.final))); + } else if (tr.final !== tr.start) { + tokens.push(arrowToken(tr.type, tr.owner, sqName(tr.start), sqName(tr.final))); + } + } + const ok = (toks: Token[]): boolean => { + const r = resolve(board, player, maxSteps, toks, false); + return r.status === "resolved" && r.turn.signature === turn.signature; + }; + const explicit = tokens.length; + let resolved = ok(tokens); + if (!resolved) { + for (const tr of turn.trajectories) { + if (!tr.captured && tr.final === tr.start && tr.visited.length > 1) { + tokens.push(arrowToken(tr.type, tr.owner, sqName(tr.visited[1]), sqName(tr.start))); + if (ok(tokens)) { + resolved = true; + break; + } + } + } + } + if (!resolved) { + // hold the pieces the strict reading moves or captures that this turn leaves alone + const r = resolve(board, player, maxSteps, tokens, false); + if (r.status !== "unsatisfiable") { + const touched = new Set(turn.trajectories.filter(tr => tr.captured || tr.final !== tr.start).map(tr => tr.start)); + const held = new Set(); + for (const other of r.status === "resolved" ? [r.turn] : r.candidates) { + for (const tr of other.trajectories) { + if ((tr.captured || tr.final !== tr.start) && !touched.has(tr.start) && !held.has(tr.start)) { + held.add(tr.start); + tokens.push(arrowToken(tr.type, tr.owner, sqName(tr.start), sqName(tr.start))); + if (ok(tokens)) { + resolved = true; + break; + } + } + } + if (resolved) { + break; + } + } + } + } + if (!resolved) { + for (const st of turn.steps) { + tokens.push(stepToken(st)); + if (ok(tokens)) { + resolved = true; + break; + } + } + } + if (!resolved) { + return undefined; + } + // a discriminator added before the decisive one may have done nothing + for (let i = tokens.length - 1; i >= explicit; i--) { + const trial = tokens.filter((_, j) => j !== i); + if (ok(trial)) { + tokens = trial; + } + } + + // simplification candidates: the piece type is unique on the board, or + // only one piece of that type could reach the token's target this turn + const s = new Search(board, player); + const budget = maxSteps; + const candidate = (t: Token): boolean => { + const same = s.pieces.filter(p => p.type === t.spec.piece && p.owner === t.spec.owner); + if (same.length === 1) { + return true; + } + let targets: number[]; + if (t.prop.kind === "dest") { + targets = [sqIndex(t.prop.square)]; + } else if (t.prop.kind === "capture") { + targets = TRAPS; + } else { + return false; + } + const reachable = same.filter(p => targets.some(target => { + if (p.owner === player) { + return ownReach(s.cells, p.start, target) <= budget; + } + return manhattan(p.start, target) <= Math.floor(budget / 2); + })); + return reachable.length === 1; + }; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + if (t.spec.piece === undefined || t.spec.square === undefined || !candidate(t)) { + continue; + } + const trial = tokens.slice(); + trial[i] = bareToken(t); + if (ok(trial)) { + tokens = trial; + } + } + return tokens.map(tokenText).join(" "); +} diff --git a/test/fixtures/arimaa/coverage.ts b/test/fixtures/arimaa/coverage.ts new file mode 100644 index 00000000..bfe273f6 --- /dev/null +++ b/test/fixtures/arimaa/coverage.ts @@ -0,0 +1,149 @@ +// An independent enumerator of every legal Arimaa turn from a position, used to +// check that each reachable position can be entered with clicks alone. It +// deliberately re-implements the rules rather than calling the engine's own +// search, so a mistake in one is not mirrored in the other. +export type Owner = 1 | 2; +export type Board = Map; + +const FILES = "abcdefgh"; +const TRAPS = ["c3", "f3", "c6", "f6"]; +const STRENGTH: Record = { R: 1, C: 2, D: 3, H: 4, M: 5, E: 6 }; +const at = (x: number, y: number): string => `${FILES[x]}${y + 1}`; +const xy = (c: string): [number, number] => [FILES.indexOf(c[0]), parseInt(c[1], 10) - 1]; + +export const neighbours = (c: string): string[] => { + const [x, y] = xy(c); + const out: string[] = []; + for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0]]) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < 8 && ny >= 0 && ny < 8) { + out.push(at(nx, ny)); + } + } + return out; +}; + +export const boardOf = (spec: string): Board => { + const b: Board = new Map(); + for (const t of spec.trim().split(/\s+/)) { + b.set(t.slice(1), [t[0].toUpperCase(), t[0] === t[0].toUpperCase() ? 1 : 2]); + } + return b; +}; + +/** A piece alone on a trap would already be gone, so such a position is not legal. */ +export const unsupportedOnTrap = (b: Board): string | undefined => + TRAPS.find(t => b.has(t) && !neighbours(t).some(n => b.get(n)?.[1] === b.get(t)![1])); + +const frozen = (b: Board, c: string): boolean => { + const [pc, owner] = b.get(c)!; + let stronger = false; + for (const n of neighbours(c)) { + const o = b.get(n); + if (o === undefined) { + continue; + } + if (o[1] === owner) { + return false; + } + if (STRENGTH[o[0]] > STRENGTH[pc]) { + stronger = true; + } + } + return stronger; +}; + +const step = (b: Board, from: string, to: string): Board => { + const nb: Board = new Map(b); + nb.set(to, nb.get(from)!); + nb.delete(from); + for (const t of TRAPS) { + if (nb.has(t) && !neighbours(t).some(n => nb.get(n)?.[1] === nb.get(t)![1])) { + nb.delete(t); + } + } + return nb; +}; + +/** Rank 8 first, to match ArimaaGame.signature(). */ +export const signature = (b: Board): string => { + let s = ""; + for (let y = 7; y >= 0; y--) { + for (let x = 0; x < 8; x++) { + const o = b.get(at(x, y)); + s += o === undefined ? "-" : (o[1] === 1 ? o[0] : o[0].toLowerCase()); + } + } + return s; +}; + +// one own step, a push (enemy first, pusher follows) or a pull (puller first, enemy follows) +const atoms = (b: Board, player: Owner, remaining: number): Array> => { + const out: Array> = []; + for (const [from, [pc, owner]] of b.entries()) { + if (owner !== player || frozen(b, from)) { + continue; + } + const [, fy] = xy(from); + for (const to of neighbours(from)) { + const [, ty] = xy(to); + if (pc === "R" && (player === 1 ? ty < fy : ty > fy)) { + continue; + } + const occ = b.get(to); + if (occ === undefined) { + out.push([[from, to]]); + if (remaining >= 2) { + for (const n of neighbours(from)) { + const e = b.get(n); + if (e !== undefined && e[1] !== player && STRENGTH[e[0]] < STRENGTH[pc]) { + out.push([[from, to], [n, from]]); + } + } + } + } else if (remaining >= 2 && occ[1] !== player && STRENGTH[occ[0]] < STRENGTH[pc]) { + for (const t of neighbours(to)) { + if (!b.has(t)) { + out.push([[to, t], [from, to]]); + } + } + } + } + } + return out; +}; + +/** Every position reachable in at most `maxSteps`, with the step lists that reach it. */ +export const allTurns = (b: Board, player: Owner, maxSteps: number): Map>> => { + const start = signature(b); + const found = new Map>>(); + const dfs = (cur: Board, steps: Array<[string, string]>, left: number): void => { + if (steps.length > 0) { + const s = signature(cur); + if (s !== start) { + const seen = found.get(s); + if (seen === undefined) { + found.set(s, [steps.slice()]); + } else if (seen.length < 4 && steps.length <= seen[0].length) { + seen.push(steps.slice()); + } + } + } + if (left === 0) { + return; + } + for (const atom of atoms(cur, player, left)) { + if (atom.length > left) { + continue; + } + let nb = cur; + for (const [f, t] of atom) { + nb = step(nb, f, t); + } + dfs(nb, [...steps, ...atom], left - atom.length); + } + }; + dfs(b, [], maxSteps); + return found; +}; diff --git a/test/fixtures/arimaa/records.ts b/test/fixtures/arimaa/records.ts new file mode 100644 index 00000000..d6c34075 --- /dev/null +++ b/test/fixtures/arimaa/records.ts @@ -0,0 +1,299 @@ +// Recorded Abstract Play games (moves only), replayed by test/games/arimaa.test.ts. +// Every stored move is in the pre-notation step format; the replay checks that +// each turn's new notation resolves to the position played. `legacyTurns` are +// plies the rules no longer allow: they replay trusted and keep the old format. +export interface ArimaaRecord { + name: string; + variants: string[]; + startingPosition?: string; + gameover: boolean; + legacyTurns?: number[]; + moves: string[]; +} + +export const arimaaRecords: ArimaaRecord[] = [ + { + name: "standard game 7 (3 captures, ended by resign)", + variants: [], + gameover: false, + moves: [ + "Ee2, Mb2, Ha2, Hh2, Dd2, De1, Cb1, Cg2, Rh1, Rg1, Rf1, Rf2, Rc2, Rc1, Rd1, Ra1", + "hb7, hg7, ra7, ra8, rb8, rc8, rf8, rg8, rh8, rh7, df7, dc7, ce8, cd8, ed7, me7", + "Ee2e3, Ee3e4, Ee4e5, Hh2h3", + "ed7d6, ed6d5, me7e6, df7e7", + "me6f6(xmf6), Ee5e6, Hh3g3, Ha2a3", + "dc7d7, dd7d6, hb7b6, hg7g6", + "Hg3g4, Hg4g5, Hg5h5, Hh5h6", + "hb6b5, ra7a6, ra8a7, rb8b7", + "dd6c6(xdc6), Ee6d6, Ed6e6, Cg2g3", + "ed5e5, hg6g5, ra6a5, ra7a6", + "Ee6d6, de7e6, de6f6(xdf6), Ed6e6", + ], + }, + { + name: "standard game 81 (16 captures, played out)", + variants: [], + gameover: true, + moves: [ + "Ee2, Md2, Hb2, Hg2, Ra2, Ra1, Rb1, Rc1, Rf1, Rg1, Rh1, Rh2, Cc2, Df2, Ce1, Dd1", + "ee7, md7, hb7, ra8, rb8, rc8, rf8, rg8, rh8, hf7, rh7, rd8, ce8, dg7, dc7, ca7", + "Ee2e3, Ee3e4, Ee4e5, Ee5e6", + "dg7g6, hf7f6, rf8f7, hb7b6", + "Hg2g3, Hg3g4, Hg4g5, Md2d3", + "dg6h6, hf6g6, rf7g7, ee7f7", + "Md3d4, Md4d5, Md5e5, Me5f5", + "ef7f6, dc7c6, rc8c7, rb8b7", + "Hb2b3, Hb3c3, Dd1d2, Hc3c4", + "ef6f7, Mf5f6, md7d6, rd8d7", + "Mf6f5, hg6f6, Hg5g6, Hc4d4", + "md6d5, md5c5, Hd4d5, dc6d6", + "dd6c6, Ee6d6, Hd5e5, He5e6", + "mc5d5, dc6c5, dc5c4, rc7c6", + "md5c5, Ed6d5, mc5b5, Ed5c5", + "hb6a6(xrc6), ha6a5, mb5b6, rb7c7", + "dc4c3(xdc3), Ec5c4, Ec4b4, Eb4b5", + "ca7a6, mb6c6, mc6d6, ra8a7", + "Eb5c5, Ec5d5, md6c6, Ed5d6", + "ha5a4, ha4b4, ca6b6, ra7a6", + "Rb1b2, Rb2b3, Ra2a3, Ra1a2", + "hb4c4, hc4d4, mc6c5, mc5d5", + "md5e5, Ed6d5, Rb3b4, Rb4c4", + "cb6b5, cb5b4, Rc4c3, cb4c4", + "me5e4, Ed5e5, me4f4, Ee5e4", + "dh6h5, dh5h4, dh4h3, dh3g3", + "Rc3b3, Rb3b4, Dd2d3, Ra3a4", + "ce8d8, cd8c8, cc8b8, cb8b7", + "hd4d5, Ee4d4, Ed4e4, Dd3d4", + "hd5d6, Dd4d5, cb7b6, cb6b5", + "Ra4a5, Ra2a3, Ra3a4, Ce1e2", + "cc4d4, Dd5e5, hd6d5, rg8f8", + "Ce2e3, Df2e2, De2d2, Dd2d3", + "cd4c4, hd5d4, rh7h6, rh8h7", + "hd4d5, Ee4d4, cc4c3(xcc3), Ed4c4", + "mf4g4, mg4g5, cb5b6, rh6h5", + "Ec4c5, Ec5b5, cb6c6, Eb5b6", + "Hg6h6, mg5g6, hd5d6, De5d5", + "ra6a7, Eb6a6, He6e7, Rb4b5", + "hf6e6, ef7f6, rc7b7, rd7c7", + "Rb5b6, ra7a8, Ea6a7, Ra5b5", + "Rb6a6, cc6b6, ef6f7, Mf5f6(xMf6)", + "rb7b8, Ea7b7, Ra4a5, Ra6a7", + "rf8e8, ef7f8, re8d8, rd8c8", + "He7d7, rc7c6, Hd7c7, Dd3d4", + "ef8e8, ee8d8, hd6d7, dg3g4", + "cb6a6(xrc6), Eb7b6, Eb6c6, Ec6d6", + "mg6f6, Hh6g6, mf6f7, Hg6f6(xHf6)", + "Ed6c6, hd7d6, Ec6b6, hd6c6(xhc6)", + "ed8d7, he6e7, he7e8, he8d8", + "Eb6c6, ca6b6, Ec6d6, cb6c6(xcc6)", + "rh5g5, rg5f5, mf7e7, dg4g3", + "Ce3e4, Ce4f4, rf5f6(xrf6), Cf4f5", + "dg3g4, dg4g5, Cf5f6(xCf6), dg5f5", + "Ed6e6, Ee6d6, me7e6, Rh2h3", + "ed7e7, Hc7d7, ee7f7, Hd7e7", + "me6f6, Ed6e6, Rb5b6, Rb6b7", + "rg7g6, He7d7, ef7e7, rh7h6", + "Ee6e5, df5g5, Ee5f5, Dd5c5", + "mf6e6, me6d6, md6d5, rh6h5", + "Ra5a6, Ra6b6, Rb6c6, Rc6d6", + "Rd6c6, md5d6, ee7e6, Hd7e7", + "Rc6c7, Rc7d7, Dd4d5, Cc2d2", + "Rd7c7, hd8d7, Dd5d4, md6d5", + "Ef5e5, dg5f5, df5f6, Ee5f5", + "Rc7c6, hd7c7, md5d6, Dc5d5(xRc6)", + "Ef5g5, Eg5f5, rh5g5, Rh3h4", + "rg6h6, df6g6, Dd5c5, md6d5", + "Cd2d3, Dd4c4, Dc5b5, Db5b6", + "md5d6, md6c6, mc6d6, Db6c6(xDc6)", + "rg5g4, Ef5g5, Cd3e3, Dc4d4", + "hc7d7, Rb7c7, Rc7c6(xRc6), hd7c7", + "rg4f4, Eg5g4, rf4f3(xrf3), Eg4f4", + "ee6f6, He7e6, ef6f5, He6f6(xHf6)", + "Ce3d3, Rf1e1, Re1e2, Re2e3", + "hc7b7, hb7c7, Ra7b7, ra8a7", + "Ef4e4, Ee4e5, Ee5d5, Ed5c5", + "ef5f4, ef4e4, Re3f3(xRf3), ee4e3", + "Ec5b5, Eb5b6, Dd4c4, Dc4b4", + "rh6h5, rh5g5, rg5g4, rg4g3", + "Rg1g2, Db4c4, Dc4d4, Dd4e4", + "rg3f3, rf3f2, rf2f1", + ], + }, + { + name: "endless endgame game 9 (0 captures, played out)", + variants: ["eee"], + startingPosition: "Eg1,Hc1,Dh1,Da1,Rh2,Rb2,Re2,Rd2,Rf1,Rc2,Rd1,Re1,eb8,hf8,da8,dh8,ra7,rg7,rd7,re7,rc8,rf7,re8,rd8", + gameover: true, + moves: [ + "Eg1g2, Eg2g3", + "rc8c7, eb8b7, eb7b6, eb6c6", + "Da1a2, Da2a3, Rb2a2, Hc1b1", + "ec6d6, ed6d5, re7e6, rf7e7", + "Eg3g4, Eg4f4, Dh1g1, Dg1g2", + "da8b8, db8b7, hf8f7, ed5e5", + "Ef4f5, Rf1f2, Dg2g3, Hb1b2", + "re6d6, rd6d5, rg7g6, dh8h7", + "Hb2b3, Ef5g5, Eg5f5, rg6g5", + "dh7g7, dg7g6, hf7f6, re7f7", + "rg5g4, Ef5g5, Rh2h3, Re1f1", + "ee5f5, ef5f4, re8e7, re7e6", + "Rh3h4, Rf1g1, Rg1g2, Rg2h2", + "rf7g7, rd7e7, re7f7, dg6h6", + "Hb3b4, Hb4b5, Hb5b6, Da3b3", + "hf6f5, hf5e5, rd8d7, rd7d6", + "Eg5f5, db7b8, Hb6b7, Ra2a3", + "he5e4, re6e5, he4e3, he3f3", + "Ra3a4, Ra4a5, Ra5a6, Db3b4", + "hf3e3, Dg3f3, rg4g3, ef4g4", + "Hb7b6, ra7b7, Ra6a7, Ra7a8", + ], + }, + { + name: "free placement game 428 (8 captures, ended by resign)", + variants: ["free"], + gameover: false, + moves: [ + "Ee2, Hb2, Hh2, Cg2, Ca2, Dd2, De1, Ra1, Rc2, Rb1, Rc1, Rd1, Rf1, Rf2, Rg1, Rh1", + "rh7, rh8, rg8, rf8, rc8, rb8, ra8, ra7, cf7, cc7, de8, dd8, ed7, mb7, hg7, he7", + "Ee2e3, Ee3e4, Hh2h3, Hh3g3", + "ed7d6, ed6d5, dd8d7, mb7b6", + "Ee4d4, De1e2, Hb2b3, Ca2b2", + "ed5c5, ec5c4, mb6b5, mb5b4", + "Ed4d5, Ed5c5, Ec5b5, Ra1a2", + "Hb3a3, mb4b3, dd7d6, mb3c3", + "Eb5b4, Eb4b3, mc3d3, Eb3c3", + "ec4d4, md3e3, me3e4, me4e5", + "Ec3c4, Ec4c5, Ec5d5, Rb1a1", + "ed4e4, hg7g6, hg6g5, hg5f5", + "me5e6, Ed5e5, Dd2d3, Rd1d2", + "me6f6, mf6g6, mg6g5, mg5g4", + "hf5f4, Ee5f5, hf4f3(xhf3), Ef5f4", + "ee4e3, rh7h6, rh6h5, rh5h4", + "Rh1h2, Rh2h3, Rf1e1, Rg1h1", + "mg4g5, Hg3g4, mg5f5, Hg4g5", + "mf5e5, Ef4f5, Cg2g3, Rf2g2", + "Dd3d4, ee3d3, he7e6, me5d5", + "De2f2, Ha3b3, Ef5e5, Hg5g6", + "md5c5, Dd4d5, mc5b5, Dd5c5", + "he6e7, Ee5e6, Re1e2, Rh1h2", + "ed3d4, ed4e4, ee4e5, ee5f5", + "Hg6g7, Hg7h7, dd6d7, Ee6d6", + "mb5b6, Dc5b5, ef5f6, ef6g6", + "Hb3b4, Db5a5, Da5a4, Cb2b3", + "eg6g7, eg7g6, Hh7g7, de8d8", + "Cg3g4, Cg4g3, rh4g4, Ed6e6", + "mb6b5, eg6g5, rg4h4, eg5g6", + "Hb4c4, Hc4c3, Ra2a3, Ra1a2", + "mb5b4, Da4a5, mb4a4", + "Ee6d6, he7e6, he6e5, Ed6e6", + "eg6g5, Hg7g6, eg5f5, Hg6g5", + "Cg3g4, Hg5g6, Rg2g3, Hc3d3", + "Da5a6, ma4a5, Da6b6, ma5a6", + "Ee6d6, Ed6c6, Ec6d6, cc7c6(xcc6)", + "ef5f6, ef6f5, Hg6f6(xHf6), dd7e7", + "Ed6d5, Ed5d6, he5d5, Re2e3", + "ef5e5, hd5d4, hd4e4, he4f4", + "Rc2c3, Rc3c4, Rc4c5, Rc5c6", + "Cg4g5, hf4g4, Cg5g6, hg4g5", + "Ed6e6, Ee6f6, Ef6f5, cf7f6(xcf6)", + "rf8f7, de7e6, Db6b7(xRc6), ma6b6", + "Ef5f6, hg5f5, hf5f4, Ef6f5", + "ee5e4, hf4g4, Db7c7, mb6b7", + "Cg6g7, Ef5e5, Ee5d5, Ed5d6", + "ee4e5, de6f6, df6g6, ee5d5", + "Ed6d7, Ed7d6, dd8d7, Rc1c2", + "dg6g5, Cg7g6, rf7e7, ed5c5", + "Ed6e6, Ee6e5, Ee5f5, Rh2g2", + "ec5d5, rg8f8, rf8f7, re7e6", + "Ef5e5, dg5f5, df5f4, Ee5f5", + "df4e4, Dc7c6(xDc6), mb7c7, ed5d4", + "Re3f3, Rf3f4, Hd3e3, Rd2d3", + "Rd3d2, ed4d3, He3e2, ed3e3", + "Ef5e5, de4d4, Ee5e4, Rf4f5", + "ee3d3, dd4c4, dc4b4, ed3e3", + "Rf5g5, Rg5h5, Cg6g7, Ee4e5", + "dd7e7, rf7f6, rf6g6, de7f7", + "re6e7, Ee5e6, re7e8, Ee6e7", + "hg4g5, hg5f5, Rh5g5, mc7b7", + "Ee7e6, df7e7, de7d7, Ee6e7", + "mb7b6, rc8d8, Cb3b2, db4b3", + "rg6f6, Cg7g6, Rg5h5, Rh5h6", + "rf6e6, hf5g5, Cg6f6(xCf6), hg5g6", + "Ee7f7, Ef7g7, hg6g5, Eg7g6", + "dd7e7, de7f7, df7g7, re8f8", + "Rh6h7, dg7f7, Eg6g7, Rd2d3", + "rf8g8, rd8e8, Cb2b1, db3b2", + "He2d2, Rc2c3, Hd2c2, Rc3b3", + "mb6b5, mb5b4, mb4c4, hg5g4", + "Eg7g6, Eg6g5, Eg5f5, Ef5e5", + "ra7a6, ra6b6, Rd3c3, ee3d3", + "Ee5e4, Ee4f4, Ef4f5, hg4f4", + "rh4g4, hf4e4, he4e3, he3e2", + "Ef5f4, Ef4f3, Ef3e3, Rh3h4", + "ed3d2, Df2f1, he2f2, rg4f4", + "Ee3f3, Ef3e3, rf4f3, Rh4g4", + "re6e5, re5f5, rf5f4, df7g7", + "Ee3e4, Ee4e3, rf4e4, Rg4f4", + "ed2e2, Hc2d2, Rb3b4(xRc3), db2b3", + "Rf4f5, Rf5e5, Re5e6, Re6e7", + "mc4d4, md4d5, md5d6, md6d7", + "Ee3d3, Ed3d4, Ed4d5, Ed5d6", + "dg7f7, Hd2c2, ee2d2, re4e3", + ], + }, + { + name: "endless endgame game 216 (4 captures, played out, legacy ply 29)", + variants: ["eee"], + startingPosition: "Ed1,Da1,Cg2,Ch1,Rc2,ed8,da8,cg7,ch8,rc7", + gameover: true, + legacyTurns: [29], + moves: [ + "Ed1d2, Ed2d3", + "ed8d7, ed7d6, ed6d5, ed5d4", + "Da1a2, Da2b2, Db2b3, Rc2b2", + "ed4c4, ec4b4, Db3a3, eb4b3", + "Cg2f2, Cf2e2, Ce2d2, Cd2c2", + "cg7g6, rc7d7, rd7e7, re7f7", + "Cc2c1, Cc1b1, Rb2c2, Rc2d2", + "eb3b2, eb2c2, Rd2d1, ec2d2", + "Da3b3, Cb1c1, Rd1e1, Cc1c2", + "rf7f6, rf6f5, rf5g5, rg5g4", + "Ch1g1, Cg1g2, Cg2g3, Ed3e3", + "cg6g5, rg4h4, cg5g4, ed2e2", + "Ee3e4, Ee4f4, Cg3h3, Cc2c1", + "ee2e3, Re1e2, cg4g3, da8b8", + "Cc1d1, Cd1e1, Re2d2, Rd2c2", + "db8c8, dc8d8, ch8g8, cg8f8", + "Rc2b2, Ce1d1, Cd1c1, Cc1c2", + "dd8d7, cf8e8, ce8d8, cd8c8", + "Ef4g4, rh4h5, Eg4h4, Rb2a2", + "dd7c7, dc7b7, db7b6, cc8b8", + "rh5g5, Eh4h5, rg5g6, Eh5g5", + "ee3e4, ee4e5, ee5f5, cg3g2", + "rg6g7, Eg5g6, Ch3h4, Ch4h5", + "db6b5, db5b4, db4a4, da4a3", + "Ch5h6, Ch6h7, rg7f7, Ch7g7", + "Ra2b2, da3a2, Rb2b1, da2b2", + "rf7e7, Cg7f7, Eg6f6, Ef6e6", + "cb8c8, cc8d8, cg2g3, cg3g4", + "Ee6d6, re7d7, Db3c3, Dc3d3", + "rd7e7, cd8d7, cg4g5, cg5g6", + "cd7c7, Ed6d7, cc7c6(xcc6), Ed7c7", + "ef5f6, ef6e6, Cf7f6(xCf6), re7f7", + "Ec7d7, Ed7e7, rf7f8, Ee7f7", + "cg6h6, ch6h7, ch7h8, ch8g8", + "rf8e8, Ef7f8, re8d8, Ef8e8", + "ee6e7, ee7d7, cg8g7, cg7f7", + "Ee8e7, cf7f6(xcf6), Ee7f7, Ef7e7", + "rd8c8, rc8b8, rb8b7, rb7a7", + "Ee7e6, Ee6d6, Ed6d5, Ed5d4", + "ed7d6, ra7b7, rb7c7, rc7d7", + "Ed4c4, Ec4c3, Cc2c1, Ec3c2", + "rd7e7, re7f7, rf7g7, rg7g6", + "db2b3, Ec2b2, db3c3(xdc3), Eb2b3", + "ed6d5, ed5d4, rg6g5, rg5g4", + "Cc1d1, Cd1e1, Ce1f1, Cf1g1", + "rg4g3, rg3h3, rh3h2, rh2h1", + ], + }, +]; diff --git a/test/games/arimaa.test.ts b/test/games/arimaa.test.ts index b3febba7..d8e79eee 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -224,3 +224,825 @@ describe("Arimaa", () => { }); }); + +// --------------------------------------------------------------------------- +// Lightvector notation and arrow entry + +import { isLegacy, parseMove, parseToken, NotationError } from "../../src/games/arimaa/notation"; +import { resolve, serializeTurn, sqName, turnFromSteps, type CellContents } from "../../src/games/arimaa/turns"; +import { arimaaRecords } from "../fixtures/arimaa/records"; +import { allTurns, boardOf as coverageBoard, unsupportedOnTrap, type Owner } from "../fixtures/arimaa/coverage"; + +const rc = (cell: string): [number, number] => { + const [x, y] = ArimaaGame.algebraic2coords(cell); + return [y, x]; +}; +const click = (g: ArimaaGame, move: string, cell: string) => g.handleClick(move, ...rc(cell)); +const annotations = (g: ArimaaGame): string[] => { + const rep = g.render(); + const name = (t: {row: number; col: number}): string => ArimaaGame.coords2algebraic(t.col, t.row); + return ((rep.annotations ?? []) as Array<{type: string; targets: Array<{row: number; col: number}>}>).map(a => `${a.type}:${a.targets.map(name).join(">")}`); +}; +// free placement lets a test build any position: gold places, silver places, gold to move +const position = (gold: string, silver: string): ArimaaGame => { + const g = new ArimaaGame(undefined, ["free"]); + g.move(gold); + g.move(silver); + return g; +}; +const boardOf = (spec: string): Map => { + const b = new Map(); + for (const t of spec.trim().split(/\s+/)) { + b.set(t.slice(1), [t[0].toUpperCase() as CellContents[0], t[0] === t[0].toUpperCase() ? 1 : 2]); + } + return b; +}; + +describe("Arimaa notation parser", () => { + it("splits tokens from the right", () => { + const cases: Array<[string, string, string]> = [ + ["Ed4", "E", "dest:d4"], + ["d4e5", "d4", "dest:e5"], + ["Ed4e5", "Ed4", "dest:e5"], + ["ee", "e", "steps:e"], + ["eee", "e", "steps:ee"], + ["en", "e", "steps:n"], + ["d4ee", "d4", "steps:ee"], + ["de4e", "de4", "steps:e"], + ["Ed4news", "Ed4", "steps:news"], + ["h5x", "h5", "capture"], + ["hh5", "h", "dest:h5"], + ["hx", "h", "capture"], + ["Rc3x", "Rc3", "capture"], + ]; + for (const [text, spec, prop] of cases) { + const t = parseToken(text); + const specText = `${t.spec.piece === undefined ? "" : (t.spec.owner === 1 ? t.spec.piece : t.spec.piece.toLowerCase())}${t.spec.square ?? ""}`; + const propText = t.prop.kind === "dest" ? `dest:${t.prop.square}` : t.prop.kind === "steps" ? `steps:${t.prop.dirs.join("")}` : "capture"; + expect(specText, text).to.equal(spec); + expect(propText, text).to.equal(prop); + } + }); + it("rejects malformed tokens", () => { + for (const bad of ["x", "e4", "R", "n", "garbage!", "4e", "Ex4", "z3", "i9x", "Ed4x4"]) { + expect(() => parseToken(bad), bad).to.throw(NotationError); + } + }); + it("recognises legacy strings", () => { + expect(isLegacy("Db4b5, Ra5a6")).to.be.true; + expect(isLegacy("Dc4c3(xDc3)")).to.be.true; + expect(isLegacy("Ec3,Rd4,xRc3")).to.be.true; + expect(isLegacy("Db4b5")).to.be.false; + expect(isLegacy("Ed4 Me")).to.be.false; + expect(isLegacy("dx")).to.be.false; + }); + it("keeps a trailing bare square as the pending selection only when allowed", () => { + const p = parseMove("Ed4e4 e4", true); + expect(p.tokens.map(t => t.text)).to.deep.equal(["Ed4e4"]); + expect(p.pending).to.equal("e4"); + expect(() => parseMove("Ed4e4 e4")).to.throw(NotationError); + expect(() => parseMove("e4 Ed4e4", true)).to.throw(NotationError); + // two trailing squares: the selection before the current one is kept too + const q = parseMove("Ed4e4 d4 e4", true); + expect(q.tokens.map(t => t.text)).to.deep.equal(["Ed4e4"]); + expect(q.previous).to.equal("d4"); + expect(q.pending).to.equal("e4"); + expect(() => parseMove("d4 e4 Ed4e4", true)).to.throw(NotationError); + }); +}); + +describe("Arimaa resolution", () => { + const flip = boardOf("Hg3 dh3 Cf2 Ra2 ra7 Ee1 ee8"); + it("resolves the flip from any of its spellings and writes it as dx", () => { + for (const m of ["dx", "df3", "dh3f3", "dh3x"]) { + const r = resolve(flip, 1, 4, parseMove(m).tokens, false); + expect(r.status, m).to.equal("resolved"); + if (r.status === "resolved") { + expect(r.bucket).to.deep.equal([4, 1]); + expect(r.turn.captures.map(c => sqName(c.square))).to.deep.equal(["f3"]); + expect(serializeTurn(flip, 1, 4, r.turn)).to.equal("dx"); + } + } + }); + it("reports ambiguity and unsatisfiability", () => { + expect(resolve(flip, 1, 4, parseMove("dg3").tokens, false).status).to.equal("ambiguous"); + expect(resolve(flip, 1, 4, parseMove("Hg3g4 dh3f3").tokens, false).status).to.equal("unsatisfiable"); + expect(resolve(flip, 1, 4, parseMove("Ea8").tokens, false).status).to.equal("unsatisfiable"); + }); + it("needs the pusher named when two pieces could push", () => { + const b = boardOf("Ed4 Hf4 re4 Ra1 ra8 ee8"); + expect(resolve(b, 1, 4, parseMove("re4e5").tokens, false).status).to.equal("ambiguous"); + const r = resolve(b, 1, 4, parseMove("re4e5 Ed4e4").tokens, false); + expect(r.status).to.equal("resolved"); + if (r.status === "resolved") { + expect(r.bucket).to.deep.equal([2, 2]); + expect(serializeTurn(b, 1, 4, r.turn)).to.equal("re5 Ee4"); + } + }); + it("treats a round trip as equivalent to the walk that reaches the same position", () => { + const b = boardOf("Ed2 re2 Rb7 eg7 rf8 Ra1"); + const turn = turnFromSteps(b, 1, [{from: "e2", to: "e1"}, {from: "d2", to: "e2"}, {from: "e2", to: "e3"}, {from: "e1", to: "e2"}]); + expect(turn.displaced).to.equal(1); + expect(serializeTurn(b, 1, 4, turn)).to.equal("Ee3"); + const r = resolve(b, 1, 4, parseMove("Ee3").tokens, false); + expect(r.status).to.equal("resolved"); + if (r.status === "resolved") { + expect(r.bucket).to.deep.equal([2, 1]); + expect(r.turn.signature).to.equal(turn.signature); + } + }); + it("does not count a piece that steps out and dies back on its start square", () => { + // net displacement: the cat's final square is the trap it started on + const b = boardOf("Cc3 Rc2 Ra1 ee8 ra8"); + const turn = turnFromSteps(b, 1, [{from: "c3", to: "b3"}, {from: "c2", to: "d2"}, {from: "b3", to: "c3"}]); + const cat = turn.trajectories.find(tr => tr.type === "C")!; + expect(cat.captured).to.be.true; + expect(cat.final).to.equal(cat.start); + expect(turn.displaced).to.equal(1); + }); + it("orders step tokens as written", () => { + // Lightvector's example, shifted off the traps: the same four tokens + // in another order name a different sequence, here one nobody can play + const b = boardOf("Rd3 Rc4 Ee1 Ra1 ra8 ee8"); + const a = resolve(b, 1, 4, parseMove("d3n d4n c4e d4e").tokens, false); + expect(a.status).to.equal("resolved"); + if (a.status === "resolved") { + expect(a.turn.steps.map(s => `${sqName(s.from)}${s.dir}`)).to.deep.equal(["d3n", "d4n", "c4e", "d4e"]); + } + expect(resolve(b, 1, 4, parseMove("d3n d4e d4n c4e").tokens, false).status).to.equal("unsatisfiable"); + // without the ordering the tokens would also fit the other interleaving + const c = resolve(b, 1, 4, parseMove("c4e d4n d3n d4e").tokens, false); + expect(c.status).to.equal("resolved"); + if (c.status === "resolved") { + expect(c.turn.steps.map(s => `${sqName(s.from)}${s.dir}`)).to.deep.equal(["c4e", "d4n", "d3n", "d4e"]); + } + }); + it("lets a pulled piece end where the puller started", () => { + // the pruning heuristic once demanded the pulled rabbit be moved off g5 again + const b = boardOf("mg5 Rh5 hh7 ee8 Ee1 ra8 Ra1"); + const r = resolve(b, 2, 4, parseMove("mg5g3 Rh5g5 hh7h6").tokens, false); + expect(r.status).to.equal("resolved"); + if (r.status === "resolved") { + expect(r.bucket).to.deep.equal([4, 3]); + expect(serializeTurn(b, 2, 4, r.turn)).to.equal("mg3 Rg5 hh6"); + } + const c = boardOf("rb6 mb5 Ra5 ee8 Ee1 ra8 Rh1"); + const r2 = resolve(c, 2, 4, parseMove("rb6x mb5x Ra5b5").tokens, false); + expect(r2.status).to.equal("resolved"); + }); + it("pruning never changes an answer", function () { + this.timeout(120000); + // deterministic pseudo-random positions and token sets: sparse boards + // searched four steps deep, then denser ones three steps deep + // mulberry32: a plain LCG overflows double precision here and collapses + let seed = 12345; + const rnd = (n: number): number => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) % n; + }; + const types = ["E", "M", "H", "D", "C", "R"]; + const key = (r: ReturnType): string => r.status === "resolved" ? `${r.status}:${r.bucket}:${r.turn.signature}` : r.status === "ambiguous" ? `${r.status}:${r.bucket}:${r.positions}` : r.status; + const square = (): string => `${"abcdefgh"[rnd(8)]}${1 + rnd(8)}`; + const check = (pieces: number, maxSteps: number): void => { + const b = new Map(); + for (let i = 0; i < pieces; i++) { + const cell = square(); + if (!b.has(cell)) { + b.set(cell, [types[rnd(6)] as CellContents[0], (1 + rnd(2)) as 1 | 2]); + } + } + // a legal position has no unsupported piece on a trap + for (const trap of ["c3", "f3", "c6", "f6"]) { + if (b.has(trap)) { + const [x, y] = ArimaaGame.algebraic2coords(trap); + const owner = b.get(trap)![1]; + const supported = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]].some(([nx, ny]) => b.get(ArimaaGame.coords2algebraic(nx, ny))?.[1] === owner); + if (!supported) { + b.delete(trap); + } + } + } + const player = (1 + rnd(2)) as 1 | 2; + const cells = [...b.keys()]; + const letterOf = (cell: string): string => { + const [pc, owner] = b.get(cell)!; + return owner === 1 ? pc : pc.toLowerCase(); + }; + const tokens: string[] = []; + for (let k = 0; k < 1 + rnd(2) && cells.length > 0; k++) { + const from = cells[rnd(cells.length)]; + // half the destinations are squares other pieces occupy, where the + // heuristic's occupancy reasoning is exercised + const to = rnd(2) === 0 ? cells[rnd(cells.length)] : square(); + const letter = letterOf(from); + switch (rnd(6)) { + case 0: + tokens.push(`${letter}${from}x`); + break; + case 1: + tokens.push(`${letter}${to}`); + break; + case 2: + tokens.push(`${letter}${from}${"nsew"[rnd(4)]}`); + break; + case 3: + // two tokens for one square + tokens.push(`${letter}${from}${to}`, `${letterOf(cells[rnd(cells.length)])}${to}`); + break; + default: + tokens.push(`${letter}${from}${to}`); + } + } + const parsed = parseMove(tokens.join(" ")).tokens; + const pruned = resolve(b, player, maxSteps, parsed, false, true); + const full = resolve(b, player, maxSteps, parsed, false, false); + expect(key(pruned), `${[...b.entries()].map(([c, [p, o]]) => (o === 1 ? p : p.toLowerCase()) + c).join(" ")} / ${tokens.join(" ")} / player ${player} / ${maxSteps} steps`).to.equal(key(full)); + }; + for (let trial = 0; trial < 150; trial++) { + check(4 + rnd(4), 4); + } + for (let trial = 0; trial < 60; trial++) { + check(9 + rnd(5), 3); + } + }); +}); + +describe("Arimaa arrow entry", () => { + it("enters a flip in two clicks and records it as dx", () => { + const g = position("Hg3,Cf2,Ra2,Ee1", "dh3,ra7,ee8"); + let r = click(g, "", "h3"); + expect(r.move).to.equal("h3"); + expect(r.complete).to.equal(-1); + r = click(g, r.move, "f3"); + expect(r.move).to.equal("dh3f3"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + const preview = g.clone(); + preview.move(r.move, {partial: true}); + expect(annotations(preview)).to.have.members(["enter:g3", "move:h3>f3", "exit:f3"]); + g.move(r.move); + expect(g.lastmove).to.equal("dx"); + expect(g.board.has("h3")).to.be.false; + expect(g.board.get("g3")).to.deep.equal(["H", 1]); + expect(annotations(g)).to.have.members(["enter:g3", "move:h3>f3", "exit:f3"]); + expect(g.results.filter(x => x.type === "move").length).to.equal(4); + expect(g.results.filter(x => x.type === "destroy").length).to.equal(1); + }); + it("holds an ambiguous push by arrowing the pusher onto the vacated square", () => { + const g = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + let r = click(g, "", "e4"); + r = click(g, r.move, "e5"); + expect(r.move).to.equal("re4e5"); + expect(r.complete).to.equal(-1); + r = click(g, r.move, "d4"); + expect(r.move).to.equal("re4e5 d4"); + r = click(g, r.move, "e4"); + expect(r.move).to.equal("re4e5 Ed4e4"); + expect(r.complete).to.equal(0); + g.move(r.move); + expect(g.lastmove).to.equal("re5 Ee4"); + }); + it("enters a pull in four clicks", () => { + const g = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + let r = click(g, "", "d4"); + r = click(g, r.move, "c4"); + r = click(g, r.move, "e4"); + expect(r.move).to.equal("Ed4c4 e4"); + // the arrows already form a move; the selection only adds a hint + expect(r.complete).to.equal(0); + expect(r.message).to.contain(i18next.t("apgames:validation.arimaa.INCOMPLETE")); + r = click(g, r.move, "d4"); + expect(r.move).to.equal("Ed4c4 re4d4"); + g.move(r.move); + expect(g.lastmove).to.equal("Ec4 rd4"); + expect(annotations(g)).to.have.members(["move:d4>c4", "move:e4>d4"]); + }); + it("re-selects, holds, extends from an arrow head and deletes from its tail", () => { + const g = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + let r = click(g, "", "d4"); + r = click(g, r.move, "f4"); + // another piece re-selects, remembering the first for a second click + expect(r.move).to.equal("d4 f4"); + r = click(g, r.move, "f4"); + // which sends the first onto this square; the horse has to make way, + // and where it goes is still open + expect(r.move).to.equal("Ed4f4"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(-1); + r = click(g, "d4", "d6"); + expect(r.move).to.equal("Ed4d6"); + r = click(g, r.move, "d6"); + expect(r.move).to.equal("Ed4d6 d6"); + r = click(g, r.move, "e6"); + expect(r.move).to.equal("Ed4e6"); + r = click(g, r.move, "d4"); + expect(r.move).to.equal("d4"); + // a second click on a selected piece holds it; a click on the hold lifts it + r = click(g, r.move, "d4"); + expect(r.move).to.equal("Ed4d4"); + expect(r.valid).to.be.true; + r = click(g, r.move, "d4"); + expect(r.move).to.equal("d4"); + // an empty square with nothing selected is a no-op + r = click(g, "", "b5"); + expect(r.move).to.equal(""); + }); + it("rejects a click that no legal move can satisfy and keeps the previous move", () => { + const g = position("Ed4,Ra1", "ee5,ra8"); + let r = click(g, "d4", "h8"); + expect(r.move).to.equal("d4"); + expect(r.valid).to.be.false; + // an occupied, unvacated square is a re-selection, never a destination + r = click(g, "d4", "e5"); + expect(r.move).to.equal("d4 e5"); + expect(r.valid).to.be.true; + }); + it("rejects arrows no turn can satisfy on a full board, and says why", () => { + const g = new ArimaaGame(); + g.move("Ee2,Md2,Hb2,Hg2,Dd1,De1,Cc2,Cf2,Ra2,Ra1,Rb1,Rc1,Rf1,Rg1,Rh1,Rh2"); + g.move("ee7,md7,hb7,hg7,dd8,de8,cc7,cf7,ra7,ra8,rb8,rc8,rf8,rg8,rh8,rh7"); + const rejected = (m: string): string => { + const r = g.validateMove(m); + expect(r.valid, m).to.be.false; + return r.message!; + }; + // a rabbit backward + expect(rejected("Ra2a1")).to.equal(i18next.t("apgames:validation.arimaa.BACKWARDS")); + // two pieces on one square: each arrow works alone + expect(rejected("Hb2b3 Ra2b3")).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE_TOGETHER", {num: 4})); + // a boxed-in piece, and one on the back rank with the front rank in the way + expect(rejected("Rh1h4")).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE_REACH", {from: "h1", to: "h4", num: 4})); + expect(rejected("De1d3")).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE_REACH", {from: "e1", to: "d3", num: 4})); + // an enemy piece nothing can push or pull, which is also what a click + // on the wrong side's piece produces + expect(rejected("ra7a6")).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE_ENEMY", {from: "a7", num: 4})); + // the old step list typed with spaces reads as two elephants + expect(rejected("Ee2e3 Ee3e4")).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE_CHAIN", {first: "Ee2e3", second: "Ee3e4", combined: "Ee2e4"})); + // typed tokens with nothing to point at keep the plain message + for (const m of ["ex", "mx", "Ra2s"]) { + expect(rejected(m), m).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE")); + } + // a frozen piece nothing can free in time + const f = position("Ma4,Rh1", "ea5,rh8"); + const fr = f.validateMove("Ma4b4"); + expect(fr.valid).to.be.false; + expect(fr.message).to.equal(i18next.t("apgames:validation.arimaa.NO_MOVE_FROZEN", {from: "a4"})); + }); + it("charges a destination for its longest arrow only, since one piece can answer for all of them", () => { + // three steps by one elephant, however many of its squares are named + const g = position("Ed4,Ra1", "ee8,ra8"); + for (const m of ["Ed4g4 Ee4g4", "Ed4g4 Ed4g4", "Ed4g4 Ef4g4"]) { + const r = g.validateMove(m); + expect(r.valid, m).to.be.true; + expect(r.complete, m).to.equal(0); + } + // one rabbit can visit c3 on its way, once the other steps aside + const h = position("Rc2,Rc3,Ra1", "ee8,ra8"); + expect(h.validateMove("Rc3c5 Rc2c5").valid).to.be.true; + // distinct destinations are distinct pieces, whose steps still add up + const r = g.validateMove("Ed4g4 Ed4a4"); + expect(r.valid).to.be.false; + expect(r.message).to.equal(i18next.t("apgames:validation.arimaa.TOO_LONG", {num: 4})); + }); + it("keeps a hold visible once the move resolves", () => { + const g = position("Ed4,Cc4,Ra1", "ee8,ra8"); + let r = click(g, "", "d4"); + r = click(g, r.move, "e4"); + r = click(g, r.move, "c4"); + r = click(g, r.move, "c4"); + expect(r.move).to.equal("Ed4e4 Cc4c4"); + expect(r.valid).to.be.true; + g.move(r.move, {partial: true}); + expect(annotations(g)).to.include("move:d4>e4"); + expect(annotations(g)).to.include("enter:c4"); + }); + it("holds a piece that steps out and back with a second click on it", () => { + // the elephant pushes the dog into the trap and returns; without the hold + // the arrows read as the three-step turn that leaves it on d6 + const g = position("Ee6,Cg2,Ra1", "dd6,ra8,ee8"); + let r = click(g, "", "d6"); + r = click(g, r.move, "c6"); + r = click(g, r.move, "g2"); + r = click(g, r.move, "g3"); + expect(r.move).to.equal("dd6c6 Cg2g3"); + expect(r.valid).to.be.true; + expect(r.message).to.contain("Ee6d6"); + r = click(g, r.move, "e6"); + r = click(g, r.move, "e6"); + expect(r.move).to.equal("dd6c6 Cg2g3 Ee6e6"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + const preview = g.clone(); + preview.move(r.move, {partial: true}); + expect(annotations(preview)).to.include.members(["enter:e6", "move:d6>c6", "exit:c6", "move:g2>g3"]); + g.move(r.move); + expect(g.lastmove).to.equal("Cg3 dx Ee6"); + expect(g.board.get("e6")).to.deep.equal(["E", 1]); + expect(g.board.has("c6")).to.be.false; + }); + it("completes an arrow onto an occupied square with a second click on it", () => { + // a rotation: the elephant pulls one rabbit and pushes the other, + // ending where the second one stood + const g = position("Eh5,Ra1", "rh6,rg6,ra8,ee8"); + let r = click(g, "", "h5"); + r = click(g, r.move, "g6"); + expect(r.move).to.equal("h5 g6"); + r = click(g, r.move, "g6"); + expect(r.move).to.equal("Eh5g6"); + expect(r.valid).to.be.true; + r = click(g, r.move, "g6"); + expect(r.move).to.equal("Eh5g6 g6"); + r = click(g, r.move, "h6"); + r = click(g, r.move, "h6"); + expect(r.move).to.equal("Eh5g6 rg6h6"); + r = click(g, r.move, "h6"); + r = click(g, r.move, "h5"); + expect(r.move).to.equal("Eh5g6 rg6h6 rh6h5"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + g.move(r.move); + expect(g.board.get("g6")).to.deep.equal(["E", 1]); + expect(g.board.get("h6")).to.deep.equal(["R", 2]); + expect(g.board.get("h5")).to.deep.equal(["R", 2]); + }); + it("holds a blocker so that a piece walks around it", () => { + const g = position("Re2,De3,Ra1", "ra8,ee8"); + let r = click(g, "", "e2"); + r = click(g, r.move, "e4"); + expect(r.move).to.equal("Re2e4"); + // the shorter reading moves the dog aside (to d3 rather than the trap) + expect(r.complete).to.equal(0); + expect(r.message).to.contain("De3d3"); + r = click(g, r.move, "e3"); + r = click(g, r.move, "e3"); + expect(r.move).to.equal("Re2e4 De3e3"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + g.move(r.move); + expect(g.board.get("e4")).to.deep.equal(["R", 1]); + expect(g.board.get("e3")).to.deep.equal(["D", 1]); + expect(g.lastmove).to.equal("Re4 De3"); + }); + it("marks a piece on a trap captured with a third click on it", () => { + // from a recorded game, mirrored: the horse leaves its rabbit on the + // trap unsupported; read without the mark, the rabbit's own move comes + // first and keeps it alive + const g = position("Ra1,Rg1,Rh1,Rh2,Ce1,Ca2,Hb3,Dh3,Rg2,Rb2,Ef2,Rd2,Hf3,Dc5,Rc3,Mb4", "ra7,ra8,rb8,rc8,rf8,rg8,rh8,rh7,cc7,df7,ce8,dd7,mf4,hg3,he3,ec4"); + let r = click(g, "", "b3"); + r = click(g, r.move, "a4"); + r = click(g, r.move, "b4"); + r = click(g, r.move, "b3"); + r = click(g, r.move, "b2"); + r = click(g, r.move, "c2"); + expect(r.move).to.equal("Hb3a4 Mb4b3 Rb2c2"); + expect(r.valid).to.be.true; + const reading = g.clone(); + reading.move(r.move, {partial: true}); + expect(reading.board.has("c3")).to.be.true; + r = click(g, r.move, "c3"); + r = click(g, r.move, "c3"); + // the second click holds it, which changes nothing here... + expect(r.move).to.equal("Hb3a4 Mb4b3 Rb2c2 Rc3c3"); + expect(r.valid).to.be.true; + // ...and on a trap a third click turns the hold into a capture mark + r = click(g, r.move, "c3"); + expect(r.move).to.equal("Hb3a4 Mb4b3 Rb2c2 Rc3x"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + const preview = g.clone(); + preview.move(r.move, {partial: true}); + expect(preview.board.has("c3")).to.be.false; + expect(annotations(preview)).to.include("exit:c3"); + // a click on the mark lifts it + r = click(g, r.move, "c3"); + expect(r.move).to.equal("Hb3a4 Mb4b3 Rb2c2 c3"); + g.move("Hb3a4 Mb4b3 Rb2c2 Rc3x"); + expect(g.board.has("c3")).to.be.false; + }); + it("marks an enemy piece captured by pieces that step out and back", () => { + // from a recorded game, mirrored: the elephant steps aside, pulls the + // camel off the rabbit's trap and pushes it back; nothing is displaced + const g = position("Rh3,Re3,Rf3,Rh2,Re2,Rd2,Rg3,Rg2,Re1,Rg1,Ra3,Rd3,Ra2,Hb5,Cb2,Eb4", "rg8,cb8,hf7,rh5,ef2,rd7,cb6,ra4,rf5,rd4,rc3,mb3"); + let r = click(g, "", "c3"); + r = click(g, r.move, "c3"); + expect(r.move).to.equal("rc3c3"); + r = click(g, r.move, "c3"); + expect(r.move).to.equal("rc3x"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(0); + r = click(g, r.move, "b4"); + r = click(g, r.move, "b4"); + expect(r.move).to.equal("rc3x Eb4b4"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + g.move(r.move); + expect(g.board.has("c3")).to.be.false; + expect(g.board.get("b4")).to.deep.equal(["E", 1]); + expect(g.board.get("b3")).to.deep.equal(["M", 2]); + expect(g.lastmove).to.equal("rc3x Eb4"); + }); + it("draws a past turn from its results, with nothing left over from an entry", () => { + // a half-entered move leaves arrows, holds and a selection behind; going + // back to a committed state must not draw any of them over it + const committed = (): string[] => { + const g = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + return annotations(g); + }; + for (const partial of ["re4e5", "Ed4d4", "d4"]) { + const g = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + g.move(partial, {partial: true}); + g.load(); + expect(annotations(g), partial).to.deep.equal(committed()); + const markers = (g.render().board as {markers?: Array<{type: string}>}).markers ?? []; + expect(markers.filter(m => m.type === "flood"), partial).to.be.empty; + } + }); + it("submits with a piece still selected", () => { + const g = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + const r = g.validateMove("Ed4c4 f4"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(0); + expect(r.message).to.contain(i18next.t("apgames:validation.arimaa.INCOMPLETE")); + g.move("Ed4c4 f4"); + expect(g.lastmove).to.equal("Ec4"); + expect(g.board.get("f4")).to.deep.equal(["H", 1]); + }); + it("accepts a double push with the pusher arrowed", () => { + const g = position("Ed4,Ra1,Cc1", "re4,ra8,ee8"); + const r = g.validateMove("re4e6 Ed4e5"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(1); + g.move("re4e6 Ed4e5"); + expect(g.board.get("e6")).to.deep.equal(["R", 2]); + expect(g.board.get("e5")).to.deep.equal(["E", 1]); + expect(g.lastmove).to.equal("re6 Ee5"); + }); + it("names the pieces it inferred", () => { + const g = position("Ed4,Ra1,Cc1", "re4,ra8,ee8"); + const r = g.validateMove("re4e5"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(0); + expect(r.message).to.contain("Ed4e4"); + }); + it("applies the two-step ceiling on the first ply of an endless endgame", () => { + const g = new ArimaaGame(undefined, ["eee"]); + const e = [...g.board.entries()].find(([, [pc, owner]]) => pc === "E" && owner === 1)![0]; + const [x, y] = ArimaaGame.algebraic2coords(e); + const far = ArimaaGame.coords2algebraic(x, y < 4 ? y + 3 : y - 3); + const r = g.validateMove(`E${e}${far}`); + expect(r.valid).to.be.false; + expect(r.message).to.contain("2"); + }); +}); + +describe("Arimaa notation compatibility", () => { + it("reads the old step notation and writes the new one", () => { + const g = position("Hg3,Cf2,Ra2,Ee1", "dh3,ra7,ee8"); + g.move("Hg3g4,dh3g3,dg3f3(xdf3),Hg4g3"); + expect(g.lastmove).to.equal("dx"); + expect(g.board.has("f3")).to.be.false; + }); + it("compares moves by the position they reach", () => { + const g = position("Hg3,Cf2,Ra2,Ee1", "dh3,ra7,ee8"); + g.move("dh3f3"); + expect(g.sameMove("dx", "Hg3g2, dh3g3, dg3f3, Hg2g3")).to.be.true; + expect(g.sameMove("dx", "df3")).to.be.true; + expect(g.sameMove("dx", "Hg3g4")).to.be.false; + }); + it("stored notation replays to the same position", () => { + const g = position("Ed4,Hf4,Ra1,Cc1,Db4", "re4,ra8,ee8,md7"); + for (const m of ["Db4b5 Ed4c4", "md7d6 ra8b8", "Hf4e4 re4e5 Ec4c5", "ee8e7", "Hf4 Db5b4"]) { + const before = g.clone(); + g.move(m); + const replay = before.clone(); + replay.move(g.lastmove!); + expect(replay.signature(), `${m} -> ${g.lastmove}`).to.equal(g.signature()); + const strict = resolve(before.board, before.currplayer, 4, parseMove(g.lastmove!).tokens, false); + expect(strict.status, g.lastmove).to.equal("resolved"); + } + }); + it("rejects a submitted move the rules do not allow, but still reads a recorded one", () => { + // from a recorded game (mirrored so that Gold moves): the rabbit on a2 + // completes a push of its equal, which the validator once accepted + // because the elephant on a4 satisfied the push precondition + const g = position("De1,Dg2,Cd2,Ch1,Rh2,Ra2,Ra1,Hf2,Mc2,Eb4", "hb8,ca8,rh7,rh8,cf7,ra3,db6,ec4,dd6,mg5"); + const push = "Mc2b2, Eb4a4, ra3b3, Ra2a3"; + const rejected = g.validateMove(push); + expect(rejected.valid).to.be.false; + expect(rejected.message).to.equal(i18next.t("apgames:validation.arimaa.INVALID_PUSH", {where: "a2"})); + expect(() => g.move(push)).to.throw(); + // typing it in the new notation does not get round the rules either + expect(g.validateMove("Mb2 Ea4 rb3 Ra3").valid).to.be.false; + // but a record replays: no validation, and the notation is kept as it was + g.move(push, {trusted: true}); + expect(g.lastmove).to.equal(push); + expect(g.board.get("b3")).to.deep.equal(["R", 2]); + expect(g.sameMove(g.lastmove!, "Mc2b2,Eb4a4,ra3b3,Ra2a3")).to.be.true; + // from another: a "pull" that moves the pulled rabbit beside the + // vacated square, which the validator never checked + const h = position("Db3,Cc2,Rb1,Cf7,Ee6", "ef5,db2,re7,cd8,cg4"); + const pull = "Ee6d6, re7d7, Db3c3, Dc3d3"; + const rejectedPull = h.validateMove(pull); + expect(rejectedPull.valid).to.be.false; + expect(rejectedPull.message).to.equal(i18next.t("apgames:validation.arimaa.INVALID_PUSH", {where: "b3"})); + expect(h.validateMove("Ed6 rd7 Dd3").valid).to.be.false; + h.move(pull, {trusted: true}); + expect(h.lastmove).to.equal(pull); + // a capture along the way is written as the old engine wrote it: the + // rabbit is "pulled" sideways onto f6 and dies there, and the other + // two steps leave no room for the legal way to reach that position + const k = position("Ee5,Ra1", "re6,ra8,eh8"); + k.move("Ee5e4, re6f6, Ra1a2, Ra2a3", {trusted: true}); + expect(k.lastmove).to.equal("Ee5e4, re6f6(xrf6), Ra1a2, Ra2a3"); + expect(k.board.has("f6")).to.be.false; + }); + it("still allows a push and a pull the rules do allow", () => { + // a stronger pusher, and a pull into the square the puller vacated + const g = position("Ed4,Ra1,Cc1", "re4,ra8,ee8"); + expect(g.validateMove("re4e5, Ed4e4").valid).to.be.true; + const h = position("Ed4,Hf4,Ra1,Cc1", "re4,ra8,ee8"); + expect(h.validateMove("Ed4c4, re4d4").valid).to.be.true; + }); + it("refuses a third repetition only after resolving the move", () => { + const g = position("Ed4,Ra2", "ee8,ra7"); + const cycle = ["Ed4d5", "ee8e7", "Ed5d4", "ee7e8"]; + for (let i = 0; i < 7; i++) { + g.move(cycle[i % 4]); + } + const r = g.validateMove("ee7e8"); + expect(r.valid).to.be.true; + expect(r.complete).to.equal(-1); + expect(r.message).to.equal(i18next.t("apgames:validation.arimaa.REPEAT")); + expect(() => g.move("ee7e8")).to.throw(); + }); +}); + +describe("Arimaa recorded games", () => { + // stored legacy moves from real games replay, and each turn's new notation + // resolves strictly to the position played and compares equal to the record + const positionOf = (spec: string): Map => { + const b = new Map(); + for (const t of spec.split(",").filter(Boolean)) { + b.set(t.slice(1), [t[0].toUpperCase() as CellContents[0], t[0] === t[0].toUpperCase() ? 1 : 2]); + } + return b; + }; + for (const rec of arimaaRecords) { + it(`replays ${rec.name}`, function () { + this.timeout(20000); + const g = new ArimaaGame(undefined, rec.variants); + if (rec.startingPosition !== undefined) { + g.stack[0].board = positionOf(rec.startingPosition); + g.load(); + } + rec.moves.forEach((recorded, i) => { + const before = g.clone(); + const setup = g.hands !== undefined && g.hands[g.currplayer - 1].length > 0; + const illegal = rec.legacyTurns?.includes(i + 1) ?? false; + // a record replays trusted, but the rules still judge each move + expect(g.validateMove(recorded).valid, recorded).to.equal(!illegal); + g.move(recorded, {trusted: true}); + if (setup) { + return; + } + const maxSteps = rec.variants.includes("eee") && before.stack.length === 1 ? 2 : 4; + const stored = g.lastmove!; + if (illegal) { + // no legal turn reaches it, so it keeps the old notation + expect(isLegacy(stored), stored).to.be.true; + expect(stored.replace(/\s+/g, "")).to.equal(recorded.replace(/\s+/g, "")); + } else { + expect(isLegacy(stored), stored).to.be.false; + const strict = resolve(before.board, before.currplayer, maxSteps, parseMove(stored).tokens, false); + expect(strict.status, `${recorded} -> ${stored}`).to.equal("resolved"); + } + const replay = before.clone(); + replay.move(stored, {trusted: true}); + expect(replay.signature(), `${recorded} -> ${stored}`).to.equal(g.signature()); + expect(g.sameMove(stored, recorded), `${recorded} -> ${stored}`).to.be.true; + }); + expect(g.gameover).to.equal(rec.gameover); + }); + } +}); + + +describe("Arimaa click coverage", () => { + // a position to click on, with no hands and the given player to move + const ready = (spec: string, player: Owner, eee: boolean): ArimaaGame => { + const board = boardOf(spec.replace(/,/g, " ")); + const g = new ArimaaGame(undefined, eee ? ["eee"] : ["free"]); + g.stack[0].board = new Map(board); + if (!eee) { + g.stack.push({...g.stack[0], _results: [], _timestamp: new Date(), currplayer: player, board: new Map(board), hands: undefined, lastmove: "x"}); + } + g.load(); + g.hands = undefined; + return g; + }; + const perms = (xs: T[]): T[][] => xs.length <= 1 ? [xs] : xs.flatMap((x, i) => perms([...xs.slice(0, i), ...xs.slice(i + 1)]).map(r => [x, ...r])); + // every arrow first: naming every displaced piece is what usually works + const subsets = (xs: T[]): T[][] => xs.reduce((acc, x) => [...acc, ...acc.map(a => [...a, x])], [[]]).sort((a, b) => b.length - a.length); + + // every legal turn from a position must be reachable with clicks alone + for (const [name, spec, player, eee] of [ + ["a frozen-piece tangle", "Ed5,Mf5,Rd3,Ra1,me5,rd4,rf4,ee8,ra8", 2, false], + ["a rabbit near goal", "Eb2,Hc4,Rh1,Ra1,rb1,rc2,ed8,ra8", 2, false], + ["pieces dying on a trap", "Rb4,Ec2,rc4,ee8,Ra1", 1, false], + ] as Array<[string, string, Owner, boolean]>) { + it(`enters every legal turn in ${name}`, function () { + this.timeout(120000); + const board = coverageBoard(spec.replace(/,/g, " ")); + expect(unsupportedOnTrap(board), "the position itself must be legal").to.be.undefined; + const maxSteps = eee ? 2 : 4; + const reachable = allTurns(board, player, maxSteps); + expect(reachable.size).to.be.greaterThan(20); + const missed: string[] = []; + for (const [target, stepLists] of reachable.entries()) { + const g = ready(spec, player, eee); + let entered = false; + for (const steps of stepLists) { + if (entered) { + break; + } + const turn = turnFromSteps(board as Map, player, steps.map(([from, to]) => ({from, to}))); + const arrows = turn.trajectories.filter(t => t.final !== t.start) + .map(t => ({letter: t.owner === 1 ? t.type : t.type.toLowerCase(), from: sqName(t.start), to: sqName(t.final), dies: t.captured})); + const extras = turn.trajectories.filter(t => (t.captured && t.visited.length === 1) || (!t.captured && t.final === t.start && t.visited.length > 1)) + .map(t => ({sq: sqName(t.start), mark: t.captured})); + const draw = (order: typeof arrows, holds: typeof extras): string | undefined => { + let m = ""; + for (const a of order) { + let r = click(g, m, a.from); + if (!r.valid || !r.move!.endsWith(a.from)) { return undefined; } + m = r.move!; + r = click(g, m, a.to); + if (!r.valid) { return undefined; } + m = r.move!; + if (!m.endsWith(`${a.letter}${a.from}${a.to}`)) { + r = click(g, m, a.to); + if (!r.valid) { return undefined; } + m = r.move!; + } + if (!m.endsWith(`${a.letter}${a.from}${a.to}`)) { return undefined; } + if (a.dies) { + r = click(g, m, a.to); + if (!r.valid) { return undefined; } + m = r.move!; + r = click(g, m, a.to); + if (!r.valid || !r.move!.endsWith(`${a.letter}${a.from}x`)) { return undefined; } + m = r.move!; + } + } + for (const {sq, mark} of holds) { + let r = click(g, m, sq); + if (!r.valid) { return undefined; } + m = r.move!; + r = click(g, m, sq); + if (!r.valid || !r.move!.endsWith(`${sq}${sq}`)) { return undefined; } + m = r.move!; + if (mark) { + r = click(g, m, sq); + if (!r.valid || !r.move!.endsWith(`${sq}x`)) { return undefined; } + m = r.move!; + } + } + return m; + }; + for (const subset of subsets(arrows)) { + if (entered) { + break; + } + for (const order of (subset.length <= 4 ? perms(subset) : [subset])) { + for (const holds of [[], extras]) { + const m = draw(order, holds as typeof extras); + if (m === undefined || m.length === 0) { + continue; + } + const v = g.validateMove(m); + if (!v.valid || v.complete === -1) { + continue; + } + const preview = g.clone(); + preview.move(m, {partial: true}); + if (preview.signature() === target) { + entered = true; + break; + } + } + if (entered) { + break; + } + } + } + } + if (!entered) { + missed.push(stepLists[0].map(([f, t]) => f + t).join(" ")); + } + } + expect(missed, `${missed.length} of ${reachable.size} turns could not be entered`).to.be.empty; + }); + } +});