From d81aadf48dd63a94b42f278cff1849e76096b20e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:35:39 +0000 Subject: [PATCH] Add SQUARES - The Civil War Battle Game Adds Deer Valley Game Company's SQUARES - The Civil War Battle Game, designed by G. Myers, under the uid squares. The game is flagged experimental. - Board and units: the renderer's dvgc board, with squares named by the rulebook's codes, and NATO unit glyphs painted in each player's colour. Gray is player 1 at the south and Blue player 2 at the north, both customisable, and each player sees their own side at the bottom. - Rules: single and double turns, cavalry two-step and paired moves, forest and diagonal moves that take a whole double turn, the two-consecutive-turn limit, freezes after a retreat, supported, unsupported and flank attacks, artillery pins, the five attacker options with the second-chance retreat, cavalry and forest retreats with displacement chains, attacks on a reserve, and wins by entering the enemy reserve or by ten points. The implementation notes list how ambiguous rules are read, and a threefold-repetition draw. - Moves: an attack and the attacker's own follow-up decisions form one ply, joined by slashes (IG1C+AG1CL>AB1C/option2/advance). The defender's answers are plies of their own, and any decision with a single legal answer is made automatically. - Interface: click entry for every action and decision, custom buttons for combat decisions, previews that fade units about to be lost, arrows and outlines for moves, attacks and retreats, edge markers and framed arrivals for the reserves, and losses and points in the sidebar. - Locales: English name, description, notes, validation and status strings, chat results and button labels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H6BBoyAGMsYXd8N71s6T5k --- locales/en/apgames.json | 111 ++ locales/en/apresults.json | 53 +- src/games/squares.ts | 2668 ++++++++++++++++++++++++++++++++++++ test/games/squares.test.ts | 1094 +++++++++++++++ 4 files changed, 3925 insertions(+), 1 deletion(-) create mode 100644 src/games/squares.ts create mode 100644 test/games/squares.test.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 463eef07..4e1939cd 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -235,6 +235,7 @@ "spora": "A territorial game with limited pieces to build stacks that capture by enclosure and/or by sowing.", "spree": "An impartial n-in-a-row game where players take turns handing a ball to their opponent to place. The goal is to try to make a full line of one of two colours (red and blue by default). There is a third wild neutral colour (green by default) that can play the role of either colour.", "squaredance": "A game where you move groups of pieces by rotation, trying to force your opponent into a position where they cannot move.", + "squares": "An abstract representation of an American Civil War battle. Each side fields nine infantry, three artillery and four cavalry units. Each turn you move a unit, attack an adjacent enemy with or without support, or pass to earn a double turn. Attacked units may stand and fight or retreat, and artillery, cavalry and forests all follow their own rules. Win by marching a unit into the enemy reserve or by eliminating ten points' worth of enemy units.", "squirm": "Players fight to create the longest snake-like group.", "stairs": "Stack higher than your opponent in this game of one-space, one-level jumps where you also have to move your lowest pieces first. To take the lead and win, you must surpass your opponent's tallest stack height or, failing that, their number of stacks at the tallest height.", "stapeldammen": "A Draughts variant where captured pieces remain on the board and can be freed later. Bring as many pieces as possible to your last row. Derived from Lasca, the percursor of Emergo.", @@ -533,6 +534,7 @@ "spora": "Spora", "spree": "Spree", "squaredance": "Squaredance", + "squares": "SQUARES - The Civil War Battle Game", "squirm": "Squirm", "stairs": "Stairs", "stapeldammen": "Stapeldammen II", @@ -679,6 +681,7 @@ "spook": "When using the randomised board setup, the only fairness heuristics that we currently have are that (1) the number of balls in solid 5-ball pyramids of the same colour are equal for both players and (2), the second-highest layer must contain balls of both colours. Feel free to contact us on Discord if you think of other ways to make the game fairer.", "spora": "Spora takes the concepts like group, liberty and territory, known from territorial games like Go, and adds support for stacks. Stacks can sow their pieces to adjacent intersections, capturing enemy pieces or supporting near friendly structures. The total number of pieces is limited, so players need to manage their finite budget to reach endgame controlling as much territory as possible. The limited budget implies that players must measure well when and how to deploy stacks with multiple pieces (like having an arsenal of daggers, but always being hesitant when to use them)\n\nSpora is the Greek σπορά, meaning 'sowing'.", "spree": "We use 1, 2, 3 to denote the three colours. The neutral colour for the wild ball is colour 3. 1 and 2 are red and blue respectively by default, and 3 is green by default.", + "squares": "Gray (player 1) sits at the south and Blue (player 2) at the north, as in the rulebook's diagram, and squares carry the rulebook's identification codes. Decisions that fall to the defending player (standing or retreating, or which unit a reserve gives up) are made as moves of their own, while the attacker's decisions (the option after a defender stands, and whether to advance) are joined by slashes to the attack, or to each other, in a single move; any decision with only one legal answer is made automatically. The attacker's options are entered by clicking the units involved: the defender to eliminate it, and then the defender again to withdraw both attacking units afterwards, the support to give it up, or the attacker to exchange it; or the attacker or the support straight away to withdraw that unit. Once the target square is empty, clicking it advances; a move completed without that stays put.\n\nReadings of the rules made by this implementation: a unit may move (or attack) on at most two consecutive turns, where the two halves of a double turn are separate turns and a pass turn breaks the run; a unit that retreats may not act on its owner's next turn, or on the next half of a double turn; passing counts as a legal action, so a player loses for lack of one only when a double turn cannot be completed; closeness to a reserve is measured from the centre of each square, so the outer forests sit between the second and first lines and the two inner forests are equidistant from both reserves; cavalry may step diagonally, may pass through its own reserve, and may retreat into a forest or its reserve; a supported attack that empties the enemy reserve lets a non-artillery attacker advance into it and win; the second-chance retreat applies only to option 1; units of one type in a reserve are interchangeable, so the least-restricted one leaves and the most-restricted one is lost; and, since the published rules say nothing about repetition, chess's rule applies: the same position with the same player to act occurring three times is a draw.\n\nSQUARES - The Civil War Battle Game is copyright, 1995 G. Myers, and is published by Deer Valley Game Company, with whose permission it appears here.", "squirm": "Squirm is one of the first snake/serpent/root games, where groups are defined with at most two friendly connections per stone. But instead of being a territorial or stalemate-oriented game, Squirm is a scoring game, where players need to build the longest possible snake-like group (following the original ruleset), or adding has much points as possible (following either available variant).", "stapeldammen": "Stapeldammen II is a 1960s game designed by Tim ter Kuile. This is a game from the Bashni family of Checkers games. [Bashni](https://boardgamegeek.com/boardgame/36550/bashni), from 1875, is the first game that uses true stacks (Checkers' Kings are not stacks). This concept became well-known with the much more famous 1911's [Lasca](https://play.abstractplay.com/games/lasca). Stapeldammen is one of the games in this lineage and is, arguably, one of its best designs. It is also one of the games that influenced Christian Freeling and Ed van Zon when designing [Emergo](https://play.abstractplay.com/games/emergo).", "stawvs": "For clarity, the game pieces are represented as circles, though in real life, caps (small pyramids of distinct player colors) are usually used. The UI will sort and score your trees (trios consisting of one of each pyramid size) for you as in Mega-Volcano, but for reference the point values are: 7 points for each monochrome tree, 5 points for multicolor trees, and 1 point each for loose pyramids. Except in the variant where final pyramids aren't collected, the pyramids under player pieces are added to the players' collections and scored, but greyed-out pyramids remain on the board under the pieces to clarify the final position.", @@ -5197,6 +5200,20 @@ "ROUND": "Round", "RESERVE": "Reserve", "SCORES": "Scores", + "squares": { + "COMBAT": "Combat", + "DOUBLE_one": "Double turn, {{count}} action left", + "DOUBLE_other": "Double turn, {{count}} actions left", + "LOSSES": "{{player}}'s losses", + "POINTS": "Points to 10 (units eliminated)", + "SINGLE": "Single turn", + "STAGE_ADVANCE": "The attacker may advance", + "STAGE_DEFEND": "The defender chooses to stand or retreat", + "STAGE_LOSE": "The reserve's owner chooses which unit is lost", + "STAGE_RESOLVE": "The defender stands; the attacker chooses an option", + "STAGE_SECOND": "Second chance: the defender may still retreat", + "TURN": "Turn" + }, "STACKSIZES": "Stack heights", "tintas": { "MAJORITIES": "Majorities won", @@ -7890,6 +7907,85 @@ "PARTIAL_DESTINATION": "Select the point where the end stone will end up. You may not cover friendly pieces, and any covered enemy pieces will be captured.", "PARTIAL_ENDSTONE": "Select the end stone. This is the stone that will move the furthest." }, + "squares": { + "ARTILLERY_TARGET": "Artillery may only attack other artillery or a reserve.", + "ATTACK_FROM_RESERVE": "Units may not attack from their reserve.", + "ATTACK_LIMIT": "The unit at {{where}} has attacked on its last two turns and may not attack again this turn.", + "CAVALRY_NEEDS_SUPPORT": "Cavalry may not attack infantry without support. Click one of your units adjacent to the target to support the attack.", + "CAVALRY_NO_SUPPORT": "Cavalry may not attack infantry without support, and none of your units can support this attack.", + "CHAIN_CONTINUE": "The displaced unit must retreat too. Click where it goes.", + "CHOOSE_LOSS": "Click a unit in your reserve, or press a button, to choose the type of unit you give up.", + "CHOOSE_RETREAT": "Now click the square, or the reserve, that the retreating unit goes to.", + "DECIDE_ELIMINATED": "The defender will be eliminated. Now click it again to withdraw both your units afterwards (option 1), click your support to give it up instead (option 2), or click your attacker to give it up (option 3).", + "DECISION_EARLY": "'{{decision}}' comes too early: finish the part of the move before it first.", + "DECISION_EXTRA": "'{{decision}}' is not needed: nothing is left for you to decide in this move.", + "DIAGONAL_DOUBLE": "Infantry can only move diagonally with a double move: pass, then spend the whole double turn on it.", + "EXHAUSTED": "The unit at {{where}} has moved on its last two turns and cannot attack from where it stands.", + "FOREST_DOUBLE": "Infantry and artillery can only enter a forest with a double move: pass, then spend the whole double turn on it.", + "FOREST_TWO_STEPS": "A two-square cavalry move may not enter or pass through a forest.", + "FROZEN": "The unit at {{where}} retreated and may not move, attack or support this turn.", + "ILLEGAL_MOVE": "'{{move}}' is not a legal move.", + "ILLEGAL_RESPONSE": "'{{move}}' is not a legal choice here.", + "INITIAL_ACTION_double": "Double turn, {{count}} action(s) left: move or attack, but do not pass. A whole double turn lets infantry or artillery enter a forest, or infantry move diagonally.", + "INITIAL_ACTION_single": "Move a unit, declare an attack, or pass. Click one of your units (in your reserve or on the board), then its destination or an adjacent enemy.", + "INITIAL_ADVANCE": "Click the vacated square (or press Advance) to move in, or click your attacking unit (or press Stay) to stay put.", + "INITIAL_DEFEND": "Your unit is under attack. Click it (or press Stand) to stand, or click the square or reserve it retreats to.", + "INITIAL_LOSE": "Your reserve is under attack. Click a unit in your reserve, or press a button, to give up one unit of that type.", + "INITIAL_RESOLVE": "The defender stands. Click it to eliminate it (options 1 to 3), click your attacker to withdraw it (option 4), or click your support to withdraw it (option 5); the buttons choose an option directly.", + "INITIAL_SECOND": "The supporting artillery has withdrawn, so your unit gets a second chance. Click it (or press Stand) to stand, or click where it retreats to.", + "MOVE_LIMIT": "The unit at {{where}} has moved on its last two turns and may not move again this turn.", + "NOT_ADJACENT": "{{to}} does not touch {{from}}.", + "NOT_CONNECTED": "An attack must come from a square sharing an edge with its target; {{from}} does not connect to {{to}}.", + "NO_PASS_DOUBLE": "You may not pass during a double turn.", + "NO_SUCH_UNIT": "There is no {{what}} of yours at {{where}}.", + "NO_TARGET": "There is no enemy unit at {{where}}.", + "OPTION_1": "the defender is eliminated, then the attacker and its support both withdraw", + "OPTION_2": "the defender and the supporting unit are eliminated, and the attacker may advance", + "OPTION_3": "the attacker is exchanged for the defender", + "OPTION_4": "the attacker withdraws to its reserve", + "OPTION_5": "the supporting unit withdraws", + "OPTION_UNAVAILABLE": "That option is not available here: it would need a retreat that cannot be made.", + "OWN_RESERVE": "You cannot attack your own reserve.", + "PARTIAL_CAVALRY": "The cavalry may stop here, continue one more square, or be joined by a second cavalry unit moving one square.", + "PARTIAL_SUPPORT": "Unsupported attack. Click another of your units adjacent to the target to add support, or submit as is.", + "PARTIAL_UNIT": "Now choose a destination, or an adjacent enemy unit to attack.", + "PATH_BLOCKED": "The square {{where}} is occupied, so the cavalry cannot pass through it.", + "PINNED": "The unit at {{where}} is adjacent to enemy artillery and may only attack or support an attack on that artillery, or do neither if it is adjacent to more than one.", + "PLY_ADVANCED": "The attacker advances; click its original square to stay put instead.", + "PLY_AGAIN": "Clicking any other square starts the decision again.", + "PLY_CHOOSE_ADVANCE": "The target square is now empty: click it to advance, or complete the move to stay put.", + "PLY_CHOOSE_OPTION": "The defender stands. Click it to eliminate it (options 1 to 3), click your attacker to withdraw it (option 4), or click your support to withdraw it (option 5); the buttons choose an option directly.", + "PLY_COMPLETE": "Complete the move as it stands.", + "PLY_OPTION": "Option {{n}}: {{what}}.", + "PLY_STAYED": "The attacker stays put; click the target square to advance instead.", + "RESERVE_EMPTY": "The enemy reserve is empty; move into it to win instead.", + "RESERVE_OCCUPIED": "You may only enter the enemy reserve once it is empty.", + "RETREAT_ARTILLERY": "Artillery cannot retreat when attacked, except from one forest into another.", + "RETREAT_CHAIN_BLOCKED": "The unit on {{to}} would have no legal retreat of its own, so the cavalry cannot displace it.", + "RETREAT_ENEMY_OCCUPIED": "{{to}} is held by an enemy unit.", + "RETREAT_ENEMY_RESERVE": "A unit cannot retreat into the enemy reserve.", + "RETREAT_FOREST_ADJACENT": "A forest retreat must go into a forest adjacent to the one the unit is in; {{to}} is not.", + "RETREAT_FOREST_DISPLACED": "Only the unit under attack may use a forest retreat; displaced and supporting units go back to the reserve.", + "RETREAT_FOREST_FROM_CLEAR": "Only a unit attacked while in a forest may retreat into a forest.", + "RETREAT_FOREST_GUNS": "A unit may not retreat into a forest that touches enemy artillery, and {{to}} does.", + "RETREAT_NOT_ADJACENT": "Cavalry retreats one square, to a square touching {{from}}; {{to}} is not touching it.", + "RETREAT_NOT_CLOSER": "{{to}} is not closer to your reserve than {{from}}, so the unit cannot retreat there.", + "RETREAT_NO_PATH": "There is no clear path back to the reserve: every route is blocked by enemy units, so the unit must stand.", + "RETREAT_TO_RESERVE_ONLY": "Infantry and artillery retreat to their reserve, not to another square. Only a unit attacked in a forest may instead fall back into an adjacent forest.", + "RETREAT_VACANT_FIRST": "Cavalry must retreat into a vacant square when one is available ({{vacant}}), so it cannot displace the unit on {{to}}.", + "STAND_CAVALRY": "Cavalry must retreat when it can. It may only stand against lone cavalry, in a forest, or when no retreat is possible.", + "STAND_FLANK": "This is a flank attack, so the unit must retreat while it can.", + "SUPPORT_FROM_RESERVE": "Units may not support an attack from their reserve.", + "SUPPORT_NOT_ADJACENT": "The supporting unit at {{where}} is not adjacent to {{target}}.", + "SUPPORT_SELF": "A unit cannot support its own attack.", + "THROUGH_ENEMY_RESERVE": "A move may not pass through the enemy reserve.", + "TOO_FAR": "No unit may move more than two squares.", + "TWO_CAVALRY_ONE_STEP": "When two cavalry units move together, each moves exactly one square and neither may enter a forest.", + "TWO_CAVALRY_ONLY": "Only two cavalry units may move in one action.", + "TWO_CAVALRY_SAME": "Choose two different cavalry units.", + "TWO_STEPS_CAVALRY": "Only cavalry may move two squares.", + "WRONG_TYPE": "The unit at {{where}} is {{what}}." + }, "squirm": { "INITIAL_INSTRUCTIONS": "Drop a friendly stone on an empty cell. Only snake-like groups are valid. A snake is a group where each stone has, at most, two friendly neighbors, and there are no three friendly stones in line.", "INVALID_3ROW": "The stone cannot make a three in-a-row.", @@ -8548,6 +8644,21 @@ "noObjSwap": "No objective swap", "objSwap": "Swap objectives", "swap": "Swap on board" + }, + "squares": { + "stand": "Stand", + "retreatReserve": "Retreat to reserve", + "retreat": "Retreat to…", + "option1": "Eliminate defender, retreat both", + "option2": "Eliminate defender and support", + "option3": "Exchange attacker for defender", + "option4": "Retreat attacker", + "option5": "Retreat support", + "advance": "Advance", + "stay": "Stay", + "loseI": "Give up infantry", + "loseA": "Give up artillery", + "loseC": "Give up cavalry" } }, "categories": { diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 2a75b6bc..a44577a5 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -41,6 +41,7 @@ }, "killallgo_slice": "{{player}} set the batch sizes to {{a}} and {{b}} stones.", "scribe_super": "{{player}}'s super formation: {{formation}}.", + "squares_flank": "It is a flank attack: the defender must retreat if it can.", "stawvs": "{{player}} was unable to move and must pass from now on. Their pieces remain on the following pyramids: {{pyramids}}." }, "BEAROFF": { @@ -137,6 +138,14 @@ "spook_lateral_self_other": "{{player}} has {{count}} of their own balls captured by Spooky on the same level at {{where}}.", "spora_one": "{{player}} surrounded and captured one piece: {{group}}.", "spora_other": "{{player}} surrounded and captured {{count}} pieces: {{group}}.", + "squares": "{{player}}'s unit at {{where}} was eliminated.", + "squares_artillery": "{{player}}'s artillery at {{where}} was eliminated.", + "squares_cavalry": "{{player}}'s cavalry at {{where}} was eliminated.", + "squares_infantry": "{{player}}'s infantry at {{where}} was eliminated.", + "squares_reserve": "{{player}} lost a unit from their reserve.", + "squares_reserve_artillery": "{{player}} lost artillery from their reserve.", + "squares_reserve_cavalry": "{{player}} lost cavalry from their reserve.", + "squares_reserve_infantry": "{{player}} lost infantry from their reserve.", "stawvs": "{{player}} captured the {{what}} at {{where}}.", "swarm": "{{player}} captured stack {{where}} using {{count}} singletons.", "tafl": "{{player}} captured a piece at {{where}}.", @@ -295,6 +304,10 @@ "repetition_other": "The game has ended because the same position has occurred {{count}} times by the same player.", "repetition_positional_one": "The game has ended because the same position has occurred.", "repetition_positionas_other": "The game has ended because the same position has occurred {{count}} times.", + "squares_points": "The game has ended because ten points' worth of units have been eliminated.", + "squares_repetition": "The game has ended because the same position has occurred three times.", + "squares_reserve": "The game has ended because a unit entered the enemy reserve.", + "squares_stuck": "The game has ended because a player could not complete a double turn.", "stalemate": "The game has ended because one player has run out of moves.", "stalemate_all": "The game has ended because all players have run out of moves.", "stawvs": "The game has ended because the last player was unable to move.", @@ -323,7 +336,11 @@ "FIRE": { "murus": "{{player}} fired a catapult from {{from}} to {{to}}.", "pigs_F": "{{player}} fired their laser at {{direction}}.", - "pigs_H": "{{player}} swung their mighty snout!" + "pigs_H": "{{player}} swung their mighty snout!", + "squares": "{{player}}'s unit at {{from}} attacked {{to}}.", + "squares_artillery": "{{player}}'s artillery at {{from}} attacked {{to}}.", + "squares_cavalry": "{{player}}'s cavalry at {{from}} attacked {{to}}.", + "squares_infantry": "{{player}}'s infantry at {{from}} attacked {{to}}." }, "FLIP": "{{player}} flips {{where}} and reveals {{revealed}}.", "FURL": { @@ -411,6 +428,26 @@ "slyde": "{{player}} slid a piece from {{from}} to {{to}}.", "spook": "{{player}} moved Spooky from {{from}} to {{to}}.", "spora": "{{player}} sowed from {{from}} to {{to}}.", + "squares_advance": "{{player}}'s unit advanced from {{from}} into {{to}}.", + "squares_advance_artillery": "{{player}}'s artillery advanced from {{from}} into {{to}}.", + "squares_advance_cavalry": "{{player}}'s cavalry advanced from {{from}} into {{to}}.", + "squares_advance_infantry": "{{player}}'s infantry advanced from {{from}} into {{to}}.", + "squares_displaced": "{{player}}'s unit at {{from}} was displaced by the retreating cavalry and fell back to {{to}}.", + "squares_displaced_artillery": "{{player}}'s artillery at {{from}} was displaced by the retreating cavalry and fell back to {{to}}.", + "squares_displaced_cavalry": "{{player}}'s cavalry at {{from}} was displaced by the retreating cavalry and fell back to {{to}}.", + "squares_displaced_infantry": "{{player}}'s infantry at {{from}} was displaced by the retreating cavalry and fell back to {{to}}.", + "squares_double": "{{player}} spent a double turn moving a unit from {{from}} to {{to}}.", + "squares_double_artillery": "{{player}} spent a double turn moving artillery from {{from}} to {{to}}.", + "squares_double_cavalry": "{{player}} spent a double turn moving cavalry from {{from}} to {{to}}.", + "squares_double_infantry": "{{player}} spent a double turn moving infantry from {{from}} to {{to}}.", + "squares_move": "{{player}} moved a unit from {{from}} to {{to}}.", + "squares_move_artillery": "{{player}} moved artillery from {{from}} to {{to}}.", + "squares_move_cavalry": "{{player}} moved cavalry from {{from}} to {{to}}.", + "squares_move_infantry": "{{player}} moved infantry from {{from}} to {{to}}.", + "squares_retreat": "{{player}}'s unit retreated from {{from}} to {{to}}.", + "squares_retreat_artillery": "{{player}}'s artillery retreated from {{from}} to {{to}}.", + "squares_retreat_cavalry": "{{player}}'s cavalry retreated from {{from}} to {{to}}.", + "squares_retreat_infantry": "{{player}}'s infantry retreated from {{from}} to {{to}}.", "stawvs": "{{player}} moved a piece from {{from}} to {{to}} and captured {{what}} at {{how}}.", "susan": "{{player}} slid a piece from {{from}} to {{to}}.", "tafl": "{{player}} moved a piece from {{from}} to {{to}}.", @@ -452,6 +489,7 @@ "pigs": "{{player}} idles.", "pippinzip": "{{player}} decides to pass and becomes Zip", "simple": "{{player}} passed.", + "squares": "{{player}} passed and will take a double turn next.", "tablero": "{{player}} bumped!", "tiebreaker": "{{player}} passed, winning the tiebreaker.", "agofmars_noObjSwap": "{{player}} did not swap objectives." @@ -668,6 +706,19 @@ "slyde_mobile_opponent": "{{player}} changed the state of their opponent's piece at {{where}} from fixed to mobile.", "slyde_mobile_self": "{{player}} changed the state of their own piece at {{where}} from fixed to mobile.", "spree": "{{player}} handed a ball of colour {{who}} to the opponent.", + "squares_option1": "{{player}} chose to eliminate the defender and retreat both attacking units.", + "squares_option2": "{{player}} chose to trade the supporting unit for the defender.", + "squares_option3": "{{player}} chose to trade the attacking unit for the defender.", + "squares_option4": "{{player}} chose to retreat the attacking unit.", + "squares_option5": "{{player}} chose to retreat the supporting unit.", + "squares_stand": "{{player}}'s unit at {{where}} stands.", + "squares_stand_chosen": "{{player}}'s unit at {{where}} stands its ground.", + "squares_stand_forced": "{{player}}'s unit at {{where}} cannot retreat and must stand.", + "squares_stay": "{{player}} declined to advance.", + "squares_support": "The attack is supported by the unit at {{where}}.", + "squares_support_artillery": "The attack is supported by artillery at {{where}}.", + "squares_support_cavalry": "The attack is supported by cavalry at {{where}}.", + "squares_support_infantry": "The attack is supported by infantry at {{where}}.", "thricewise": "{{player}} selected {{card}}." }, "SET": { diff --git a/src/games/squares.ts b/src/games/squares.ts new file mode 100644 index 00000000..f7ba5f51 --- /dev/null +++ b/src/games/squares.ts @@ -0,0 +1,2668 @@ +import { GameBaseSequenced } from "./_turn-sequenced.js"; +import { IAPGameState, IClickResult, ICustomButton, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult, StatusValue, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; +import type { APGamesInformation } from "../schemas/gameinfo.js"; +import type { APRenderRep, AreaReserves, Colourfuncs } from "@abstractplay/renderer/build/schemas/schema"; +import type { APMoveResult } from "../schemas/moveresults.js"; +import { reviver, UserFacingError } from "../common/index.js"; +import i18next from "i18next"; + +/* + * SQUARES - The Civil War Battle Game. + * + * The file runs: the board (cells, adjacency, distances); the state types; then the game class with + * the rules (movement, attacks, retreats, combat, turns), the ply model (how a move string is read, + * listed, validated, previewed and played), the interface (clicks, buttons, rendering, statuses), + * and finally chat and serialisation. + * + * A ply is an action or a combat decision, followed by whatever further decisions the same player + * must make before the opponent gets a say, joined by slashes: `IG1C+AG1CL>AB1C/option2/advance`. + * Decisions that fall to the opponent are plies of their own. `walkPly` is the one routine that + * reads such a string; validation, previews, clicks and play all go through it. + */ + +/* ------------------------------------------------------------------ board */ + +export type playerid = 1 | 2; +export type UnitType = "I" | "A" | "C"; + +export const UNIT_TYPES: readonly UnitType[] = ["I", "A", "C"]; +/** English words for the unit types, used as i18next `context` values in the move log. */ +export const UNIT_NAMES: Readonly> = { I: "infantry", A: "artillery", C: "cavalry" }; +const TYPE_BY_NAME: Readonly> = { infantry: "I", artillery: "A", cavalry: "C" }; +const UNIT_COUNTS: Readonly> = { I: 9, A: 3, C: 4 }; +/** A unit may move (or attack) on at most this many consecutive turns. */ +export const MAX_STREAK = 2; +/** Points needed for a win by elimination. */ +export const WIN_POINTS = 10; + +interface IRect { x1: number; y1: number; x2: number; y2: number; } + +/** + * The renderer's `dvgc` board is three rings of ten cells. The cells carry the DVGC + * rulebook's identification codes: B = Blue (player 2, at the north), G = Gray (player 1, + * at the south), the digit is the line number counting from the front, L/C/R are left, + * centre and right from the owner's point of view, and the six forests are named by + * compass direction. The rectangles are the renderer's own cell polygons, so adjacency + * is computed from the drawn board rather than declared by hand. + */ +export const CELL_ROWS: readonly (readonly string[])[] = [ + ["B3R", "B3C", "B3L", "NEF", "SEF", "G3R", "G3C", "G3L", "SWF", "NWF"], + ["B2R", "B2CR", "B2CL", "B2L", "EF", "G2R", "G2CR", "G2CL", "G2L", "WF"], + ["B1R", "B1CR", "B1C", "B1CL", "B1L", "G1R", "G1CR", "G1C", "G1CL", "G1L"], +]; +const RECT_ROWS: readonly (readonly IRect[])[] = [ + [ + { x1: 0, y1: 0, x2: 150, y2: 50 }, { x1: 150, y1: 0, x2: 300, y2: 50 }, { x1: 300, y1: 0, x2: 450, y2: 50 }, + { x1: 400, y1: 50, x2: 450, y2: 150 }, { x1: 400, y1: 150, x2: 450, y2: 250 }, + { x1: 300, y1: 250, x2: 450, y2: 300 }, { x1: 150, y1: 250, x2: 300, y2: 300 }, { x1: 0, y1: 250, x2: 150, y2: 300 }, + { x1: 0, y1: 150, x2: 50, y2: 250 }, { x1: 0, y1: 50, x2: 50, y2: 150 }, + ], + [ + { x1: 50, y1: 50, x2: 125, y2: 100 }, { x1: 125, y1: 50, x2: 225, y2: 100 }, { x1: 225, y1: 50, x2: 325, y2: 100 }, { x1: 325, y1: 50, x2: 400, y2: 100 }, + { x1: 350, y1: 100, x2: 400, y2: 200 }, + { x1: 325, y1: 200, x2: 400, y2: 250 }, { x1: 225, y1: 200, x2: 325, y2: 250 }, { x1: 125, y1: 200, x2: 225, y2: 250 }, { x1: 50, y1: 200, x2: 125, y2: 250 }, + { x1: 50, y1: 100, x2: 100, y2: 200 }, + ], + [ + { x1: 100, y1: 100, x2: 150, y2: 150 }, { x1: 150, y1: 100, x2: 200, y2: 150 }, { x1: 200, y1: 100, x2: 250, y2: 150 }, { x1: 250, y1: 100, x2: 300, y2: 150 }, { x1: 300, y1: 100, x2: 350, y2: 150 }, + { x1: 300, y1: 150, x2: 350, y2: 200 }, { x1: 250, y1: 150, x2: 300, y2: 200 }, { x1: 200, y1: 150, x2: 250, y2: 200 }, { x1: 150, y1: 150, x2: 200, y2: 200 }, { x1: 100, y1: 150, x2: 150, y2: 200 }, + ], +]; +export const FORESTS: ReadonlySet = new Set(["NWF", "NEF", "WF", "EF", "SWF", "SEF"]); +/** Blue's reserve lies beyond the north edge of the board, Gray's beyond the south edge. */ +const NORTH_RESERVE = "BR"; +const SOUTH_RESERVE = "GR"; +/** Gray (the south) moves first, so that the board's natural orientation is the first player's. */ +export const RESERVES: Readonly> = { 1: SOUTH_RESERVE, 2: NORTH_RESERVE }; +const BOARD_HEIGHT = 300; +/** A piece that has just arrived in a reserve is drawn a little smaller inside a dashed frame. */ +const FRAME_SCALE = 1.1; +const FRAMED_PIECE_SCALE = 0.9; + +const rects = new Map(); +const cellCoords = new Map(); +CELL_ROWS.forEach((row, r) => { + row.forEach((name, c) => { + rects.set(name, RECT_ROWS[r][c]); + cellCoords.set(name, [r, c]); + }); +}); +export const BOARD_CELLS: readonly string[] = [...rects.keys()]; +export const ALL_LOCS: readonly string[] = [...BOARD_CELLS, RESERVES[1], RESERVES[2]]; + +const connectedMap = new Map(); +const diagonalMap = new Map(); +for (const loc of ALL_LOCS) { + connectedMap.set(loc, []); + diagonalMap.set(loc, []); +} +const link = (map: Map, a: string, b: string): void => { + map.get(a)!.push(b); + map.get(b)!.push(a); +}; +for (let i = 0; i < BOARD_CELLS.length; i++) { + for (let j = i + 1; j < BOARD_CELLS.length; j++) { + const a = rects.get(BOARD_CELLS[i])!; + const b = rects.get(BOARD_CELLS[j])!; + const xTouch = a.x2 === b.x1 || b.x2 === a.x1; + const yTouch = a.y2 === b.y1 || b.y2 === a.y1; + const xOverlap = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1); + const yOverlap = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1); + if ((xTouch && yOverlap > 0) || (yTouch && xOverlap > 0)) { + link(connectedMap, BOARD_CELLS[i], BOARD_CELLS[j]); + } else if (xTouch && yTouch && xOverlap === 0 && yOverlap === 0) { + link(diagonalMap, BOARD_CELLS[i], BOARD_CELLS[j]); + } + } +} +for (const cell of BOARD_CELLS) { + const r = rects.get(cell)!; + if (r.y1 === 0) { + link(connectedMap, cell, NORTH_RESERVE); + } + if (r.y2 === BOARD_HEIGHT) { + link(connectedMap, cell, SOUTH_RESERVE); + } +} + +/** Locations sharing an edge with `loc` ("adjacent connected" in the rules). Includes the reserves. */ +export const connectedTo = (loc: string): readonly string[] => connectedMap.get(loc) ?? []; +/** Locations touching `loc` at a single point. These only exist between the two centre rows. */ +export const diagonalTo = (loc: string): readonly string[] => diagonalMap.get(loc) ?? []; +/** Everything touching `loc`, by edge or by point. */ +export const adjacentTo = (loc: string): string[] => [...connectedTo(loc), ...diagonalTo(loc)]; +export const isReserve = (loc: string): boolean => loc === RESERVES[1] || loc === RESERVES[2]; +export const isCell = (loc: string): boolean => rects.has(loc); +export const isLocation = (loc: string): boolean => isCell(loc) || isReserve(loc); + +const centreY = (loc: string): number => { + if (loc === NORTH_RESERVE) { + return -25; + } + if (loc === SOUTH_RESERVE) { + return BOARD_HEIGHT + 25; + } + const r = rects.get(loc); + if (r === undefined) { + throw new Error(`Unknown location "${loc}".`); + } + return (r.y1 + r.y2) / 2; +}; +/** Distance of a location from `player`'s reserve, measured from the centre of the square. */ +export const distance = (loc: string, player: playerid): number => player === 1 ? BOARD_HEIGHT - centreY(loc) : centreY(loc); +/** True when `a` is strictly closer to `player`'s reserve than `b`. */ +export const isCloser = (a: string, b: string, player: playerid): boolean => distance(a, player) < distance(b, player); +export const otherPlayer = (player: playerid): playerid => player === 1 ? 2 : 1; + +/* ------------------------------------------------------------------ state */ + +export interface IUnit { + id: string; + owner: playerid; + type: UnitType; + /** A cell, the owner's reserve, or "X" once eliminated. */ + loc: string; + /** Consecutive completed turns (of the owner) ending with the latest, in which the unit moved. */ + moveStreak: number; + attackStreak: number; + movedNow: boolean; + attackedNow: boolean; + /** The owner's turn index during which the unit may not move, attack or support. */ + frozenTurn?: number; +} + +export type CombatStage = "defend" | "resolve" | "second" | "advance" | "lose"; + +export interface ICombat { + attacker: string; + from: string; + target: string; + supporter?: string; + supportFrom?: string; + defender?: string; + flank: boolean; + stage: CombatStage; +} + +export interface IStep { + unit: string; + type: UnitType; + from: string; + to: string; +} + +/** Transient state used only to draw a partially entered move. */ +interface IPreview { + outline: string[]; + attack?: { from: string; target: string; support?: string }; + /** The ply as entered so far, once its head has been played out: buttons offered mid-entry build on it. */ + move?: string; + /** Units the ply would eliminate, as they were before it, so that they can still be drawn (faded). */ + ghosts?: IUnit[]; +} + +/** One stop on the way through a ply: a decision its author has to make, and what was entered for it. */ +interface IDecisionPoint { + stage: CombatStage; + legal: string[]; + /** The segment entered for this decision, if any. It may be unfinished (a retreat still needing its square) or no legal choice at all. */ + chosen?: string; +} + +/** What `walkPly` found: the decision points visited, how many segments it played, and the unit whose move cost a whole double turn. */ +interface IWalk { + points: IDecisionPoint[]; + played: number; + doubleUnit?: IUnit; +} + +interface ICombatSquares { + from: string; + target: string; + support?: string; +} + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + lastmove?: string; + units: Map; + turnOwner: playerid; + actionsLeft: number; + isDouble: boolean; + pendingDouble: boolean[]; + turnNo: number[]; + combat?: ICombat; +} + +export interface ISquaresState extends IAPGameState { + winner: playerid[]; + stack: Array; +} + +export const fmtSteps = (steps: IStep[]): string => steps.map(s => `${s.type}${s.from}-${s.to}`).join(","); + +const cloneUnits = (units: Map): Map => new Map([...units].map(([k, u]) => [k, { ...u }])); + +const STEP_RE = /^([IAC])([A-Z0-9]+)-([A-Z0-9]+)$/; +const ATTACK_RE = /^([IAC])([A-Z0-9]+)(?:\+([IAC])([A-Z0-9]+))?>(?:([IAC])([A-Z0-9]+)|(BR|GR))$/; +const SEGMENT_RE = /^([IAC])([A-Z0-9]+)((?:-[A-Z0-9]+)*)$/; + +/* ------------------------------------------------------------------ game */ + +export class SquaresGame extends GameBaseSequenced { + public static readonly gameinfo: APGamesInformation = { + name: "SQUARES - The Civil War Battle Game", + uid: "squares", + playercounts: [2], + version: "20260924", + dateAdded: "2026-09-24", + // i18next.t("apgames:descriptions.squares") + description: "apgames:descriptions.squares", + // i18next.t("apgames:notes.squares") + notes: "apgames:notes.squares", + urls: [ + "https://www.dvgc.com/rules2.html", + "https://www.dvgc.com/comments.html", + "https://boardgamegeek.com/boardgame/3654/squares-the-civil-war-battle-game", + "https://www.dvgc.com/order.html", + ], + bggid: "3654", + people: [ + { + type: "designer", + name: "G. Myers", + }, + { + type: "publisher", + name: "Deer Valley Game Company", + urls: ["https://www.dvgc.com/"], + }, + { + type: "coder", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + categories: [ + "goal>breakthrough", + "goal>annihilate", + "mechanic>move", + "mechanic>capture", + "mechanic>differentiate", + "mechanic>displace", + "board>shape>rect", + "board>connect>rect", + "components>simple>3c", + ], + flags: ["experimental", "custom-colours", "scores", "perspective", "custom-rotation", "custom-buttons"], + customizations: [ + { + num: 1, + default: "#8c8c8c", + explanation: "Colour of the first player (Gray)", + player: 1, + }, + { + num: 2, + default: "#1f78b4", + explanation: "Colour of the second player (Blue)", + player: 2, + }, + ], + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public units!: Map; + public turnOwner: playerid = 1; + public actionsLeft = 1; + public isDouble = false; + public pendingDouble: boolean[] = [false, false]; + public turnNo: number[] = [0, 0]; + public combat?: ICombat; + private preview?: IPreview; + public gameover = false; + public winner: playerid[] = []; + public stack!: Array; + public results: Array = []; + public variants: string[] = []; + public lastmove?: string; + + constructor(state?: ISquaresState | string) { + super(); + if (state !== undefined) { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ISquaresState; + } + if (state.game !== SquaresGame.gameinfo.uid) { + throw new Error(`The Squares game code cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = [...state.variants]; + this.stack = [...state.stack]; + } else { + const units = new Map(); + for (const p of [1, 2] as playerid[]) { + for (const t of UNIT_TYPES) { + for (let i = 1; i <= UNIT_COUNTS[t]; i++) { + const id = `${p}${t}${i}`; + units.set(id, { id, owner: p, type: t, loc: RESERVES[p], moveStreak: 0, attackStreak: 0, movedNow: false, attackedNow: false }); + } + } + } + const fresh: IMoveState = { + _version: SquaresGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + units, + turnOwner: 1, + actionsLeft: 1, + isDouble: false, + pendingDouble: [false, false], + turnNo: [0, 0], + }; + this.stack = [fresh]; + } + this.load(); + } + + public load(idx = -1): SquaresGame { + if (idx < 0) { + idx += this.stack.length; + } + if (idx < 0 || idx >= this.stack.length) { + throw new Error("Could not load the requested state from the stack."); + } + const state = this.stack[idx]; + this.currplayer = state.currplayer; + this.lastmove = state.lastmove; + this.units = cloneUnits(state.units); + this.turnOwner = state.turnOwner; + this.actionsLeft = state.actionsLeft; + this.isDouble = state.isDouble; + this.pendingDouble = [...state.pendingDouble]; + this.turnNo = [...state.turnNo]; + this.combat = state.combat === undefined ? undefined : { ...state.combat }; + this.preview = undefined; + this.results = [...state._results]; + return this; + } + + /* ---------------------------------------------------------- unit helpers */ + + public unit(id: string): IUnit { + const u = this.units.get(id); + if (u === undefined) { + throw new Error(`Unknown unit "${id}".`); + } + return u; + } + + /** The unit standing on a board cell, if any. Reserves hold many units; see `reserveUnits`. */ + public unitAt(loc: string): IUnit | undefined { + if (!isCell(loc)) { + return undefined; + } + for (const u of this.units.values()) { + if (u.loc === loc) { + return u; + } + } + return undefined; + } + + /** A player's units that are still in play. */ + public unitsOf(player: playerid): IUnit[] { + return [...this.units.values()].filter(u => u.owner === player && u.loc !== "X"); + } + + public reserveUnits(player: playerid): IUnit[] { + return [...this.units.values()].filter(u => u.owner === player && u.loc === RESERVES[player]); + } + + /** Neighbouring cells (edge or point) holding enemy artillery. Artillery in a reserve never pins. */ + private enemyArtilleryNear(loc: string, player: playerid): string[] { + return adjacentTo(loc).filter(n => { + const u = this.unitAt(n); + return u !== undefined && u.owner !== player && u.type === "A"; + }); + } + + /** Whether the unit is frozen for its owner's turn `offset` turns from the current one. */ + private isFrozen(u: IUnit, offset = 0): boolean { + return u.frozenTurn !== undefined && u.frozenTurn === this.turnNo[u.owner - 1] + offset; + } + + public canMove(u: IUnit): boolean { + return !this.isFrozen(u) && u.moveStreak < MAX_STREAK; + } + + public canAttack(u: IUnit): boolean { + return isCell(u.loc) && !this.isFrozen(u) && u.attackStreak < MAX_STREAK; + } + + public canSupport(u: IUnit): boolean { + return isCell(u.loc) && !this.isFrozen(u); + } + + private pinAllows(u: IUnit, target: string): boolean { + const pins = this.enemyArtilleryNear(u.loc, u.owner); + return pins.length === 0 || (pins.length === 1 && pins[0] === target); + } + + /** Run `fn` as if `u` were not on the board. */ + private without(u: IUnit, fn: () => T): T { + const saved = u.loc; + u.loc = "X"; + try { + return fn(); + } finally { + u.loc = saved; + } + } + + /* ---------------------------------------------------------- movement */ + + /** Destinations one step from `from` for a unit belonging to `player`. */ + private stepTargets(player: playerid, from: string, opts: { diagonal: boolean; forest: boolean }): string[] { + const own = RESERVES[player]; + const enemy = RESERVES[otherPlayer(player)]; + const cands = opts.diagonal ? adjacentTo(from) : connectedTo(from); + const out: string[] = []; + for (const n of cands) { + if (FORESTS.has(n)) { + if (opts.forest && this.unitAt(n) === undefined) { + out.push(n); + } + } else if (n === own) { + out.push(n); + } else if (n === enemy) { + if (this.reserveUnits(otherPlayer(player)).length === 0) { + out.push(n); + } + } else if (this.unitAt(n) === undefined) { + out.push(n); + } + } + return out; + } + + /** True when a move of `u` to `to` needs the whole double turn (forest entry, or an infantry diagonal). */ + public isDoubleCost(u: IUnit, to: string): boolean { + if (u.type === "C") { + return false; + } + return FORESTS.has(to) || (u.type === "I" && diagonalTo(u.loc).includes(to)); + } + + /** Every legal single-action move for one unit, as move strings. */ + private unitMoves(u: IUnit, doubleAvailable: boolean): string[] { + const out: string[] = []; + const p = u.owner; + const from = u.loc; + const label = (to: string): string => `${u.type}${from}-${to}`; + const canDouble = doubleAvailable && !this.isFrozen(u, 1); + if (u.type === "I") { + for (const n of this.stepTargets(p, from, { diagonal: false, forest: false })) { + out.push(label(n)); + } + if (canDouble) { + for (const n of connectedTo(from)) { + if (FORESTS.has(n) && this.unitAt(n) === undefined) { + out.push(label(n)); + } + } + for (const n of diagonalTo(from)) { + if (this.unitAt(n) === undefined) { + out.push(label(n)); + } + } + } + } else if (u.type === "A") { + for (const n of this.stepTargets(p, from, { diagonal: true, forest: false })) { + out.push(label(n)); + } + if (canDouble) { + for (const n of connectedTo(from)) { + if (FORESTS.has(n) && this.unitAt(n) === undefined) { + out.push(label(n)); + } + } + } + } else { + for (const n of this.stepTargets(p, from, { diagonal: true, forest: true })) { + out.push(label(n)); + } + const own = RESERVES[p]; + const enemy = RESERVES[otherPlayer(p)]; + for (const n1 of adjacentTo(from)) { + if (FORESTS.has(n1) || n1 === enemy) { + continue; + } + if (n1 !== own && this.unitAt(n1) !== undefined) { + continue; + } + for (const n2 of this.stepTargets(p, n1, { diagonal: true, forest: false })) { + if (n2 === from || n2 === n1) { + continue; + } + out.push(`${u.type}${from}-${n1}-${n2}`); + } + } + } + return out; + } + + /** Moves of two cavalry units one square each. Independent pairs are listed once, lower id first. */ + private twoCavalryMoves(player: playerid): string[] { + const cavs = this.unitsOf(player).filter(u => u.type === "C" && this.canMove(u)); + const out: string[] = []; + for (const u1 of cavs) { + for (const u2 of cavs) { + if (u1.id === u2.id) { + continue; + } + const from1 = u1.loc; + for (const s1 of this.stepTargets(player, from1, { diagonal: true, forest: false })) { + u1.loc = s1; + const seconds = this.stepTargets(player, u2.loc, { diagonal: true, forest: false }); + u1.loc = from1; + for (const s2 of seconds) { + if (u1.id < u2.id || s2 === from1) { + out.push(`C${from1}-${s1},C${u2.loc}-${s2}`); + } + } + } + } + } + return [...new Set(out)]; + } + + /* ---------------------------------------------------------- attacks */ + + /** Friendly units that could support an attack on `target`. */ + private supporters(attacker: IUnit, target: string): IUnit[] { + return this.unitsOf(attacker.owner).filter(s => + s.id !== attacker.id && this.canSupport(s) && adjacentTo(s.loc).includes(target) && this.pinAllows(s, target)); + } + + /** Every legal attack declaration for one unit, supported and unsupported. */ + private unitAttacks(u: IUnit): string[] { + const out: string[] = []; + const p = u.owner; + const enemyReserve = RESERVES[otherPlayer(p)]; + const pins = this.enemyArtilleryNear(u.loc, p); + if (pins.length > 1) { + return out; + } + for (const t of connectedTo(u.loc)) { + if (t === RESERVES[p]) { + continue; + } + if (pins.length === 1 && t !== pins[0]) { + continue; + } + let targetLabel: string; + let defender: IUnit | undefined; + if (t === enemyReserve) { + if (this.reserveUnits(otherPlayer(p)).length === 0) { + continue; + } + targetLabel = t; + } else { + defender = this.unitAt(t); + if (defender === undefined || defender.owner === p) { + continue; + } + if (u.type === "A" && defender.type !== "A") { + continue; + } + targetLabel = `${defender.type}${t}`; + } + if (!(u.type === "C" && defender !== undefined && defender.type === "I")) { + out.push(`${u.type}${u.loc}>${targetLabel}`); + } + for (const s of this.supporters(u, t)) { + out.push(`${u.type}${u.loc}+${s.type}${s.loc}>${targetLabel}`); + } + } + return out; + } + + /* ---------------------------------------------------------- retreats */ + + /** True if `u` has a clear path to its reserve: adjacent steps, each strictly closer, none enemy-occupied. */ + public hasClearPath(u: IUnit): boolean { + const p = u.owner; + const goal = RESERVES[p]; + const seen = new Set(); + const visit = (loc: string): boolean => { + if (loc === goal) { + return true; + } + if (seen.has(loc)) { + return false; + } + seen.add(loc); + for (const n of adjacentTo(loc)) { + if (!isCloser(n, loc, p)) { + continue; + } + if (n !== goal) { + const occ = this.unitAt(n); + if (occ !== undefined && occ.owner !== p) { + continue; + } + } + if (visit(n)) { + return true; + } + } + return false; + }; + return visit(u.loc); + } + + /** Forest squares a unit attacked in a forest may retreat into. */ + private forestRetreats(u: IUnit): string[] { + if (!FORESTS.has(u.loc)) { + return []; + } + return connectedTo(u.loc).filter(n => + FORESTS.has(n) && this.unitAt(n) === undefined && isCloser(n, u.loc, u.owner) && this.enemyArtilleryNear(n, u.owner).length === 0); + } + + /** Every legal retreat for a cavalry unit, each possibly a chain of displacements. */ + public cavalryRetreats(u: IUnit): IStep[][] { + const p = u.owner; + const own = RESERVES[p]; + const closer = adjacentTo(u.loc).filter(n => n !== RESERVES[otherPlayer(p)] && isCloser(n, u.loc, p)); + const vacant = closer.filter(n => n === own || this.unitAt(n) === undefined); + const step = (to: string): IStep => ({ unit: u.id, type: u.type, from: u.loc, to }); + if (vacant.length > 0) { + return vacant.map(n => [step(n)]); + } + const out: IStep[][] = []; + for (const n of closer) { + const occ = this.unitAt(n); + if (occ === undefined || occ.owner !== p) { + continue; + } + for (const tail of this.displacedRetreats(occ)) { + out.push([step(n), ...tail]); + } + } + return out; + } + + /** Retreats available to a unit displaced by friendly cavalry: its own rules, but never forest to forest. */ + private displacedRetreats(u: IUnit): IStep[][] { + if (u.type === "C") { + return this.cavalryRetreats(u); + } + if (this.hasClearPath(u)) { + return [[{ unit: u.id, type: u.type, from: u.loc, to: RESERVES[u.owner] }]]; + } + return []; + } + + /** Retreats for the unit under attack, for the first response and for the second chance. */ + private defenderRetreats(u: IUnit): IStep[][] { + const single = (to: string): IStep[] => [{ unit: u.id, type: u.type, from: u.loc, to }]; + switch (u.type) { + case "C": + return this.cavalryRetreats(u); + case "I": { + const out: IStep[][] = []; + if (this.hasClearPath(u)) { + out.push(single(RESERVES[u.owner])); + } + for (const f of this.forestRetreats(u)) { + out.push(single(f)); + } + return out; + } + case "A": + return this.forestRetreats(u).map(f => single(f)); + } + } + + /** Retreats for an attacking or supporting unit: always back to the reserve, or the cavalry procedure. */ + private supporterRetreats(u: IUnit): IStep[][] { + if (u.type === "C") { + return this.cavalryRetreats(u); + } + if (this.hasClearPath(u)) { + return [[{ unit: u.id, type: u.type, from: u.loc, to: RESERVES[u.owner] }]]; + } + return []; + } + + /** Resolve a retreat specification such as `CB1L-B2L,IB2L-BR` against the current board. */ + private parseSteps(spec: string): IStep[] | undefined { + if (spec.length === 0) { + return undefined; + } + const steps: IStep[] = []; + for (const part of spec.split(",")) { + const m = STEP_RE.exec(part); + if (m === null) { + return undefined; + } + const u = this.unitAt(m[2]); + if (u === undefined || u.type !== m[1] || !isLocation(m[3])) { + return undefined; + } + steps.push({ unit: u.id, type: u.type, from: m[2], to: m[3] }); + } + return steps; + } + + /* ---------------------------------------------------------- combat */ + + private defendingPlayer(): playerid { + return otherPlayer(this.unit(this.combat!.attacker).owner); + } + + /** Legal plies for whoever must act in the pending combat. */ + private combatOptions(): string[] { + const c = this.combat; + if (c === undefined) { + return []; + } + switch (c.stage) { + case "defend": + case "second": + return this.defenderResponses(); + case "resolve": + return this.attackerOptions(); + case "advance": + return ["advance", "stay"]; + case "lose": + return this.loseOptions(); + } + } + + private defenderResponses(): string[] { + const c = this.combat!; + const d = this.unit(c.defender!); + const a = this.unit(c.attacker); + const retreats = this.defenderRetreats(d); + const unsupportedCavalry = c.supporter === undefined && a.type === "C"; + let canStand: boolean; + if (c.flank) { + canStand = retreats.length === 0; + } else if (d.type === "C") { + canStand = retreats.length === 0 || unsupportedCavalry || FORESTS.has(d.loc); + } else { + canStand = true; + } + const out: string[] = []; + if (canStand) { + out.push("stand"); + } + for (const r of retreats) { + out.push(`retreat:${fmtSteps(r)}`); + } + return out; + } + + private attackerOptions(): string[] { + const c = this.combat!; + const a = this.unit(c.attacker); + const s = this.unit(c.supporter!); + const d = this.unit(c.defender!); + const out: string[] = []; + // Option 1 eliminates the defender before anyone retreats (unless the support is artillery, + // whose retreat comes first), so those retreats are judged with the defender gone. + const attackerCanRetreatAfterKill = a.type === "I" && this.without(d, () => this.hasClearPath(a)); + const supporterNow = this.supporterRetreats(s); + const supporterAfterKill = s.type === "A" ? supporterNow : this.without(d, () => this.supporterRetreats(s)); + const withSteps = (opt: string, steps: IStep[]): string => s.type === "C" ? `${opt}:${fmtSteps(steps)}` : opt; + if (attackerCanRetreatAfterKill) { + for (const r of supporterAfterKill) { + out.push(withSteps("option1", r)); + } + } + out.push("option2", "option3"); + if (a.type === "I" && this.hasClearPath(a)) { + out.push("option4"); + } + for (const r of supporterNow) { + out.push(withSteps("option5", r)); + } + return out; + } + + private loseOptions(): string[] { + const p = this.defendingPlayer(); + const present = new Set(this.reserveUnits(p).map(u => u.type)); + return UNIT_TYPES.filter(t => present.has(t)).map(t => `lose:${t}`); + } + + private freeze(u: IUnit): void { + u.frozenTurn = this.turnNo[u.owner - 1] + (u.owner === this.turnOwner ? 1 : 0); + } + + private eliminate(u: IUnit, how: string): void { + const where = u.loc; + u.loc = "X"; + this.results.push({ type: "capture", where, what: UNIT_NAMES[u.type], whose: u.owner, how }); + } + + private applyRetreat(steps: IStep[], how = "retreat"): void { + steps.forEach((s, idx) => { + const u = this.unit(s.unit); + u.loc = s.to; + this.results.push({ type: "move", from: s.from, to: s.to, what: UNIT_NAMES[u.type], how: idx === 0 ? how : "displaced", by: `${u.owner}` }); + if (u.type !== "C") { + this.freeze(u); + } + }); + } + + private homeStep(u: IUnit): IStep { + return { unit: u.id, type: u.type, from: u.loc, to: RESERVES[u.owner] }; + } + + /** Offer the attacker an advance into the vacated square, unless it is artillery. */ + private toAdvance(): void { + const c = this.combat!; + if (this.unit(c.attacker).type === "A") { + this.combat = undefined; + } else { + c.stage = "advance"; + } + } + + private declareAttack(a: IUnit, target: string, s?: IUnit): void { + const enemy = otherPlayer(a.owner); + a.attackedNow = true; + const defender = this.unitAt(target); + const flank = s !== undefined && defender !== undefined + && (isCloser(a.loc, target, enemy) || isCloser(s.loc, target, enemy)); + this.combat = { + attacker: a.id, + from: a.loc, + target, + supporter: s?.id, + supportFrom: s?.loc, + defender: defender?.id, + flank, + stage: "defend", + }; + this.results.push({ type: "move", from: a.loc, to: target, what: UNIT_NAMES[a.type], how: "attack", by: `${a.owner}` }); + if (s !== undefined) { + this.results.push({ type: "select", what: "support", where: s.loc, how: UNIT_NAMES[s.type], who: a.owner }); + } + if (flank) { + this.results.push({ type: "announce", payload: ["flank"] }); + } + if (defender === undefined) { + this.combat.stage = "lose"; + } else if (s === undefined && FORESTS.has(target)) { + // An unsupported attack on a forest just costs the attacker. + this.eliminate(a, "forest"); + this.combat = undefined; + } else if (s === undefined && defender.type === "A") { + // Artillery cannot retreat when attacked, and only other artillery can hurt it unsupported. + if (a.type === "A") { + this.eliminate(defender, "attack"); + } + this.eliminate(a, a.type === "A" ? "attack" : "artillery"); + this.combat = undefined; + } + this.runCombat(); + } + + /** Advance the combat through every stage that has a single legal outcome; stop where someone must choose. */ + private runCombat(): void { + while (this.combat !== undefined && !this.gameover) { + const c = this.combat; + const actor = (c.stage === "resolve" || c.stage === "advance") ? this.unit(c.attacker).owner : this.defendingPlayer(); + this.currplayer = actor; + const options = this.combatOptions(); + if (options.length !== 1) { + return; + } + this.applyCombatMove(options[0], true); + } + } + + private applyCombatMove(m: string, auto: boolean): void { + const c = this.combat!; + switch (c.stage) { + case "defend": + this.applyDefence(m, auto); + break; + case "second": + this.applySecondChance(m, auto); + break; + case "resolve": + this.applyOption(m); + break; + case "advance": + this.applyAdvance(m); + break; + case "lose": + this.applyLoss(m); + break; + } + } + + private applyDefence(m: string, auto: boolean): void { + const c = this.combat!; + const d = this.unit(c.defender!); + const a = this.unit(c.attacker); + if (m === "stand") { + this.results.push({ type: "select", what: "stand", where: d.loc, how: auto ? "forced" : "chosen", who: d.owner }); + if (c.supporter === undefined) { + this.eliminate(d, "attack"); + this.eliminate(a, "attack"); + this.combat = undefined; + } else if (c.flank) { + this.eliminate(d, "flank"); + this.toAdvance(); + } else { + c.stage = "resolve"; + } + return; + } + this.applyRetreat(this.parseSteps(m.slice("retreat:".length))!); + if (c.supporter === undefined) { + this.combat = undefined; + } else { + this.toAdvance(); + } + } + + private applySecondChance(m: string, auto: boolean): void { + const c = this.combat!; + const d = this.unit(c.defender!); + const a = this.unit(c.attacker); + if (m === "stand") { + this.results.push({ type: "select", what: "stand", where: d.loc, how: auto ? "forced" : "chosen", who: d.owner }); + this.eliminate(d, "attack"); + this.applyRetreat([this.homeStep(a)]); + this.combat = undefined; + return; + } + this.applyRetreat(this.parseSteps(m.slice("retreat:".length))!); + c.stage = "advance"; + } + + private applyOption(m: string): void { + const c = this.combat!; + const a = this.unit(c.attacker); + const s = this.unit(c.supporter!); + const d = this.unit(c.defender!); + const [opt, spec] = m.split(":"); + const supporterSteps = (): IStep[] => spec === undefined ? [this.homeStep(s)] : this.parseSteps(spec)!; + this.results.push({ type: "select", what: opt, who: a.owner }); + switch (opt) { + case "option1": + if (s.type === "A") { + // The guns limber up first, and the defender may then think again. + this.applyRetreat(supporterSteps()); + c.stage = "second"; + } else { + this.eliminate(d, "attack"); + this.applyRetreat(supporterSteps()); + this.applyRetreat([this.homeStep(a)]); + this.combat = undefined; + } + break; + case "option2": + this.eliminate(d, "attack"); + this.eliminate(s, "attack"); + this.toAdvance(); + break; + case "option3": + this.eliminate(d, "attack"); + this.eliminate(a, "attack"); + this.combat = undefined; + break; + case "option4": + this.applyRetreat([this.homeStep(a)]); + this.combat = undefined; + break; + case "option5": + this.applyRetreat(supporterSteps()); + this.combat = undefined; + break; + default: + throw new Error(`Unknown attacker option "${m}".`); + } + } + + private applyAdvance(m: string): void { + const c = this.combat!; + const a = this.unit(c.attacker); + if (m === "advance") { + a.loc = c.target; + this.results.push({ type: "move", from: c.from, to: c.target, what: UNIT_NAMES[a.type], how: "advance", by: `${a.owner}` }); + } else { + this.results.push({ type: "select", what: "stay", where: c.from, who: a.owner }); + } + this.combat = undefined; + } + + private applyLoss(m: string): void { + const c = this.combat!; + const p = this.defendingPlayer(); + const t = m.slice("lose:".length) as UnitType; + const candidates = this.reserveUnits(p).filter(u => u.type === t); + // The owner gives up the unit with the least left to give: frozen first, then the longest streak. + candidates.sort((x, y) => (Number(this.isFrozen(y)) - Number(this.isFrozen(x))) || (y.moveStreak - x.moveStreak)); + this.eliminate(candidates[0], "reserve"); + if (this.reserveUnits(p).length === 0 && c.supporter !== undefined) { + this.toAdvance(); + } else { + this.combat = undefined; + } + } + + /* ---------------------------------------------------------- plies */ + + /** + * Every legal ply. A ply is an action or a combat decision, followed by whatever further decisions + * the same player has to make before the opponent gets a say, separated by slashes. + */ + public moves(player?: playerid): string[] { + if (this.gameover) { + return []; + } + if (player === undefined) { + player = this.currplayer; + } + if (this.combat !== undefined && player !== this.currplayer) { + return []; + } + const heads = this.combat !== undefined ? this.combatOptions() : this.actionMoves(player); + if (player !== this.currplayer) { + return heads; + } + return heads.flatMap(h => this.completions(h)); + } + + /** The full plies that begin with `head`: it is extended by every decision the same player would still have to make. */ + private completions(head: string): string[] { + const actor = this.currplayer; + return this.simulate(() => { + this.applyHead(head); + return this.continuations(head, actor); + }); + } + + private continuations(prefix: string, actor: playerid): string[] { + if (!this.pending(actor)) { + return [prefix]; + } + return this.combatOptions().flatMap(d => this.simulate(() => { + this.applyDecision(d); + return this.continuations(`${prefix}/${d}`, actor); + })); + } + + /** Whether the ply being built still needs a decision from the player who began it. */ + private pending(actor: playerid): boolean { + return this.combat !== undefined && this.currplayer === actor; + } + + /** Run `fn` on the live position, then put everything back the way it was. */ + private simulate(fn: () => T): T { + const saved = { + units: this.units, + combat: this.combat, + currplayer: this.currplayer, + turnOwner: this.turnOwner, + actionsLeft: this.actionsLeft, + isDouble: this.isDouble, + pendingDouble: this.pendingDouble, + turnNo: this.turnNo, + results: this.results, + gameover: this.gameover, + winner: this.winner, + }; + this.units = cloneUnits(this.units); + this.combat = this.combat === undefined ? undefined : { ...this.combat }; + this.pendingDouble = [...this.pendingDouble]; + this.turnNo = [...this.turnNo]; + this.results = []; + this.winner = [...this.winner]; + try { + return fn(); + } finally { + Object.assign(this, saved); + } + } + + /** Every action open to `player`, as if it were their turn to act. */ + private actionMoves(player: playerid): string[] { + const mine = player === this.turnOwner; + const out: string[] = []; + if (!mine || !this.isDouble) { + out.push("pass"); + } + const doubleAvailable = mine && this.actionsLeft === 2; + for (const u of this.unitsOf(player)) { + if (this.canMove(u)) { + out.push(...this.unitMoves(u, doubleAvailable)); + } + if (this.canAttack(u)) { + out.push(...this.unitAttacks(u)); + } + } + out.push(...this.twoCavalryMoves(player)); + return [...new Set(out)]; + } + + public static normalise(m: string): string { + return m.replace(/\s+/g, "").split("/").map(seg => seg + .toUpperCase() + .replace(/^(PASS|STAND|ADVANCE|STAY)$/, s => s.toLowerCase()) + .replace(/^(RETREAT|LOSE|OPTION[1-5]?)(?=:|$)/, s => s.toLowerCase())).join("/"); + } + + /** The unit that leaves a reserve: the least restricted one of the requested type. */ + private pickFromReserve(player: playerid, type: UnitType, exclude?: string): IUnit | undefined { + const candidates = this.reserveUnits(player).filter(u => u.type === type && u.id !== exclude); + candidates.sort((x, y) => (Number(this.isFrozen(x)) - Number(this.isFrozen(y))) || (x.moveStreak - y.moveStreak)); + return candidates[0]; + } + + /** The unit named by a move segment such as `IB3L` or `CBR`, for the given player. */ + private unitFor(player: playerid, type: string, loc: string, exclude?: string): IUnit | undefined { + if (loc === RESERVES[player]) { + return this.pickFromReserve(player, type as UnitType, exclude); + } + const u = this.unitAt(loc); + if (u === undefined || u.owner !== player || u.type !== type) { + return undefined; + } + return u; + } + + /** Relocate units for a validated move string. Returns the unit whose move cost the whole double turn, if any. */ + private applyMove(move: string): IUnit | undefined { + let doubleUnit: IUnit | undefined; + let first: IUnit | undefined; + for (const seg of move.split(",")) { + const m = SEGMENT_RE.exec(seg)!; + const u = this.unitFor(this.turnOwner, m[1], m[2], first?.id)!; + first = first ?? u; + const path = m[3].slice(1).split("-"); + const to = path[path.length - 1]; + const double = this.isDoubleCost(u, to); + u.loc = to; + u.movedNow = true; + this.results.push({ type: "move", from: m[2], to, what: UNIT_NAMES[u.type], how: double ? "double" : "move", by: `${u.owner}` }); + if (double) { + doubleUnit = u; + } + } + return doubleUnit; + } + + public move(m: string, { partial = false, trusted = false } = {}): SquaresGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + const move = SquaresGame.normalise(m); + if (!trusted) { + const result = this.validateMove(move); + if (!result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message); + } + if (result.complete === -1 && !partial) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", { move: m })); + } + } + this.results = []; + this.preview = undefined; + if (partial) { + this.applyPreview(move); + return this; + } + const [head, ...decisions] = move.split("/"); + const walk = this.walkPly(head, decisions); + const open = walk.points[walk.played]; + if (open !== undefined && open.stage === "advance") { + // A ply that ends without saying whether to advance stays put. + this.applyDecision("stay"); + } + if (this.combat === undefined) { + this.finishAction(walk.doubleUnit); + } + if (!this.gameover && this.combat === undefined) { + this.checkRepetition(); + } + this.lastmove = move; + this.saveState(); + return this; + } + + /** + * Read a ply from the current position. The head is played (when there is one: a ply entered while + * a combat already waits for this player has none), then every decision that falls to the same + * player is visited in turn, and the segment entered for it is played as long as it is a legal + * choice. The walk stops at the first segment that is missing, unfinished or not a legal choice, + * or when nothing more is this player's to decide. The callers decide what that means: `move` + * plays on, the validator reports, the preview draws, and the clicks pick up where it stopped. + */ + private walkPly(head: string | undefined, decisions: string[]): IWalk { + const actor = this.currplayer; + const doubleUnit = head === undefined ? undefined : this.applyHead(head); + const points: IDecisionPoint[] = []; + let played = 0; + while (this.pending(actor)) { + const point: IDecisionPoint = { stage: this.combat!.stage, legal: this.combatOptions(), chosen: decisions[played] }; + points.push(point); + if (point.chosen === undefined || !point.legal.includes(point.chosen)) { + break; + } + this.applyDecision(point.chosen); + played += 1; + } + return { points, played, doubleUnit }; + } + + /** Play the first segment of a ply: an action, or the decision a pending combat is waiting for. */ + private applyHead(head: string): IUnit | undefined { + if (this.combat !== undefined) { + this.applyDecision(head); + } else if (head === "pass") { + this.results.push({ type: "pass", who: this.turnOwner }); + this.pendingDouble[this.turnOwner - 1] = true; + } else if (head.includes(">")) { + const parts = ATTACK_RE.exec(head)!; + const attacker = this.unitFor(this.turnOwner, parts[1], parts[2])!; + const supporter = parts[3] === undefined ? undefined : this.unitFor(this.turnOwner, parts[3], parts[4])!; + this.declareAttack(attacker, parts[7] ?? parts[6], supporter); + } else { + return this.applyMove(head); + } + return undefined; + } + + /** Play one combat decision, and everything it settles. */ + private applyDecision(decision: string): void { + this.applyCombatMove(decision, false); + this.runCombat(); + } + + /** Show a move that is still being entered: play out what is settled, and outline what is still being chosen. */ + private applyPreview(move: string): void { + const [head, ...decisions] = move.split("/"); + const before = cloneUnits(this.units); + const preview: IPreview = { outline: [] }; + this.preview = preview; + const headResult = this.combat !== undefined ? this.validateCombatMove(head) : this.validateAction(head); + if (headResult.valid && headResult.complete !== -1) { + // A legal head is played out, with every finished decision after it, to show where the ply leads. + const walk = this.walkPly(head, decisions); + preview.move = [head, ...decisions.slice(0, walk.played)].join("/"); + preview.ghosts = [...this.units.values()] + .filter(u => u.loc === "X" && before.get(u.id)!.loc !== "X") + .map(u => ({ ...u, loc: before.get(u.id)!.loc })); + const unfinished = decisions[walk.played]; + if (unfinished !== undefined) { + this.previewUnfinished(unfinished); + } + return; + } + if (this.combat !== undefined) { + this.previewUnfinished(head); + return; + } + if (head.includes(">")) { + const m = ATTACK_RE.exec(head); + if (m !== null) { + const target = m[7] ?? m[6]; + this.outlineCell(m[2]); + this.outlineCell(m[4]); + this.outlineCell(target); + preview.attack = { from: m[2], target, support: m[4] }; + } + return; + } + let first: IUnit | undefined; + for (const seg of head.split(",")) { + const m = SEGMENT_RE.exec(seg); + if (m === null) { + continue; + } + const u = this.unitFor(this.turnOwner, m[1], m[2], first?.id); + if (u === undefined) { + continue; + } + first = first ?? u; + this.outlineCell(m[2]); + const steps = m[3].length === 0 ? [] : m[3].slice(1).split("-"); + const to = steps[steps.length - 1]; + if (to !== undefined && isLocation(to)) { + u.loc = to; + this.outlineCell(to); + this.results.push({ type: "move", from: m[2], to, what: UNIT_NAMES[u.type], how: "move", by: `${u.owner}` }); + } + } + } + + private outlineCell(loc: string | undefined): void { + if (loc !== undefined && isCell(loc)) { + this.preview!.outline.push(loc); + } + } + + /** Sketch a decision that is still being entered. */ + private previewUnfinished(decision: string): void { + if (decision === "option" && this.combat?.stage === "resolve" && this.combat.defender !== undefined) { + // The defender has been marked for elimination; how the attack ends is still to be chosen. + const d = this.unit(this.combat.defender); + this.preview!.ghosts = [...(this.preview!.ghosts ?? []), { ...d }]; + return; + } + this.previewSteps(decision); + } + + /** Sketch a retreat that is still being entered: relocate the units named so far and outline the squares involved. */ + private previewSteps(decision: string): void { + const colon = decision.indexOf(":"); + const steps = colon < 0 ? undefined : this.parseSteps(decision.slice(colon + 1)); + if (steps === undefined) { + return; + } + for (const st of steps) { + const u = this.unit(st.unit); + u.loc = st.to; + this.outlineCell(st.from); + this.outlineCell(st.to); + this.results.push({ type: "move", from: st.from, to: st.to, what: UNIT_NAMES[u.type], how: "retreat", by: `${u.owner}` }); + } + } + + /** Position signature for the repetition rule: what stands where, whose action it is, and every restriction in force. */ + private static signature(state: IMoveState): string { + const head = `${state.turnOwner}${state.isDouble ? "d" : "s"}${state.actionsLeft}${state.pendingDouble.map(b => (b ? "1" : "0")).join("")}`; + const units = [...state.units.values()] + .filter(u => u.loc !== "X") + .map(u => { + const frozen = u.frozenTurn === undefined ? -1 : u.frozenTurn - state.turnNo[u.owner - 1]; + return `${u.loc}:${u.owner}${u.type}${u.moveStreak}${u.attackStreak}${frozen >= 0 ? `f${frozen}` : ""}`; + }) + .sort(); + return `${head}|${units.join("|")}`; + } + + /** Chess's rule, since the published rules have none: the same position with the same player to act, three times, is a draw. */ + private checkRepetition(): void { + const current = SquaresGame.signature(this.moveState()); + let seen = 0; + for (const state of this.stack) { + if (state.combat === undefined && SquaresGame.signature(state) === current) { + seen += 1; + } + } + if (seen >= 2) { + this.gameover = true; + this.winner = [1, 2]; + this.results.push({ type: "eog", reason: "repetition" }, { type: "winners", players: [1, 2] }); + } + } + + /* ---------------------------------------------------------- turns and scoring */ + + /** Wrap up a fully resolved action: score it, then spend the turn (or both halves for a double-cost move). */ + private finishAction(doubleUnit?: IUnit): void { + this.checkEOG(); + if (this.gameover) { + return; + } + this.endTurnUnit(doubleUnit !== undefined); + if (doubleUnit !== undefined && !this.gameover) { + doubleUnit.movedNow = true; + this.endTurnUnit(); + } + } + + /** Close one turn (or one half of a double turn) for the turn owner and hand play on. */ + private endTurnUnit(skipStuckCheck = false): void { + const p = this.turnOwner; + for (const u of this.unitsOf(p)) { + u.moveStreak = u.movedNow ? u.moveStreak + 1 : 0; + u.attackStreak = u.attackedNow ? u.attackStreak + 1 : 0; + u.movedNow = false; + u.attackedNow = false; + } + this.turnNo[p - 1] += 1; + this.actionsLeft -= 1; + if (this.actionsLeft > 0) { + this.currplayer = p; + if (!skipStuckCheck) { + this.checkStuck(); + } + return; + } + const q = otherPlayer(p); + this.turnOwner = q; + this.currplayer = q; + this.isDouble = this.pendingDouble[q - 1]; + this.pendingDouble[q - 1] = false; + this.actionsLeft = this.isDouble ? 2 : 1; + if (this.isDouble) { + this.checkStuck(); + } + } + + /** A player who cannot complete the actions a turn demands loses (only possible in a double turn). */ + private checkStuck(): void { + if (this.actionMoves(this.turnOwner).length === 0) { + this.gameover = true; + this.winner = [otherPlayer(this.turnOwner)]; + this.results.push({ type: "eog", reason: "stuck" }, { type: "winners", players: [...this.winner] }); + } + } + + /** Units `player` has lost, by type. */ + public losses(player: playerid): Record { + const out: Record = { I: 0, A: 0, C: 0 }; + for (const u of this.units.values()) { + if (u.owner === player && u.loc === "X") { + out[u.type] += 1; + } + } + return out; + } + + /** Elimination points scored by `player`: artillery counts double, cavalry doubles once three or more are lost. */ + public points(player: playerid): number { + const lost = this.losses(otherPlayer(player)); + return lost.I + 2 * lost.A + (lost.C >= 3 ? 2 : 1) * lost.C; + } + + protected checkEOG(): SquaresGame { + if (this.gameover) { + return this; + } + for (const p of [1, 2] as playerid[]) { + if (this.unitsOf(p).some(u => u.loc === RESERVES[otherPlayer(p)])) { + this.gameover = true; + this.winner = [p]; + this.results.push({ type: "eog", reason: "reserve" }, { type: "winners", players: [p] }); + return this; + } + } + const p1 = this.points(1); + const p2 = this.points(2); + if (p1 < WIN_POINTS && p2 < WIN_POINTS) { + return this; + } + this.gameover = true; + if (p1 >= WIN_POINTS && p2 >= WIN_POINTS) { + const count = (p: playerid): number => Object.values(this.losses(otherPlayer(p))).reduce((a, b) => a + b, 0); + if (p1 !== p2) { + this.winner = [p1 > p2 ? 1 : 2]; + } else if (count(1) !== count(2)) { + this.winner = [count(1) > count(2) ? 1 : 2]; + } else { + this.winner = [1, 2]; + } + } else { + this.winner = [p1 >= WIN_POINTS ? 1 : 2]; + } + this.results.push({ type: "eog", reason: "points" }, { type: "winners", players: [...this.winner] }); + return this; + } + + /* ---------------------------------------------------------- validation */ + + private instructions(): string { + if (this.combat === undefined) { + return i18next.t("apgames:validation.squares.INITIAL_ACTION", { context: this.isDouble ? "double" : "single", count: this.actionsLeft }); + } + return i18next.t(`apgames:validation.squares.INITIAL_${this.combat.stage.toUpperCase()}`); + } + + private static valid(complete: -1 | 0 | 1, message?: string): IValidationResult { + return { valid: true, complete, message: message ?? i18next.t("apgames:validation._general.VALID_MOVE") }; + } + + private static invalid(key: string, params?: Record): IValidationResult { + return { valid: false, message: i18next.t(`apgames:validation.squares.${key}`, params) }; + } + + public validateMove(m: string): IValidationResult { + const move = SquaresGame.normalise(m); + if (this.gameover) { + return { valid: false, message: i18next.t("apgames:MOVES_GAMEOVER") }; + } + if (move === "") { + return SquaresGame.valid(-1, this.instructions()); + } + const [head, ...decisions] = move.split("/"); + const headResult = this.combat !== undefined ? this.validateCombatMove(head) : this.validateAction(head); + if (!headResult.valid) { + return headResult; + } + let result = headResult; + if (headResult.complete === -1) { + if (decisions.length > 0) { + return SquaresGame.invalid("DECISION_EARLY", { decision: decisions[0] }); + } + } else { + result = this.validateDecisions(head, headResult, decisions); + } + if (result.valid) { + result.canrender = true; + } + return result; + } + + /** Play a legal head out and check the decisions that follow it, which must all fall to the same player. */ + private validateDecisions(head: string, headResult: IValidationResult, decisions: string[]): IValidationResult { + return this.simulate(() => { + const walk = this.walkPly(head, decisions); + const chosen = decisions.slice(0, walk.played); + const open = walk.points[walk.played]; + if (decisions.length > walk.played) { + // The first segment the walk did not play: nothing was left to decide, or it is no legal choice as it stands. + const d = decisions[walk.played]; + if (open === undefined) { + return SquaresGame.invalid("DECISION_EXTRA", { decision: d }); + } + const r = this.validateCombatMove(d); + if (!r.valid) { + return r; + } + // An unfinished decision (a retreat still needing its square): anything after it comes too early. + const next = decisions[walk.played + 1]; + return next === undefined ? r : SquaresGame.invalid("DECISION_EARLY", { decision: next }); + } + if (open !== undefined) { + // An advance left undecided means staying put, so only the options themselves are still needed. + return SquaresGame.valid(open.stage === "advance" ? 0 : -1, this.plyPrompt(chosen, open.stage)); + } + // Decisions are confirmed rather than submitted on the spot, so that clicks can still change them. + return chosen.length === 0 ? headResult : SquaresGame.valid(0, this.plyPrompt(chosen)); + }); + } + + /** Describe the decisions a ply has settled so far, and what its author still has to choose. */ + private plyPrompt(chosen: string[], pendingStage?: CombatStage): string { + const t = (key: string, params?: Record): string => i18next.t(`apgames:validation.squares.${key}`, params); + const parts: string[] = []; + const option = chosen.find(d => d.startsWith("option")); + const advance = chosen.find(d => d === "advance" || d === "stay"); + if (option !== undefined) { + parts.push(t("PLY_OPTION", { n: option[6], what: t(`OPTION_${option[6]}`) })); + } + if (pendingStage === "resolve") { + parts.push(t("PLY_CHOOSE_OPTION")); + return parts.join(" "); + } + if (pendingStage === "advance") { + parts.push(t("PLY_CHOOSE_ADVANCE")); + } else { + if (advance === "advance") { + parts.push(t("PLY_ADVANCED")); + } else if (advance === "stay") { + parts.push(t("PLY_STAYED")); + } + parts.push(t("PLY_COMPLETE")); + } + if (option !== undefined || advance !== undefined) { + parts.push(t("PLY_AGAIN")); + } + return parts.join(" "); + } + + private validateCombatMove(move: string): IValidationResult { + const legal = this.combatOptions(); + if (legal.includes(move)) { + return SquaresGame.valid(1); + } + if (move === "option" && this.combat!.stage === "resolve" && legal.some(l => /^option[123]/.test(l))) { + // The defender has been clicked: it will be eliminated, one way or another. + return SquaresGame.valid(-1, i18next.t("apgames:validation.squares.DECIDE_ELIMINATED")); + } + // A prefix of a legal choice: a bare option or `retreat:` still needing a square, or a chain still to be finished. + const boundary = (l: string): boolean => move.endsWith(":") || l[move.length] === ":" || l[move.length] === ","; + if (legal.some(l => l.startsWith(move) && boundary(l))) { + let key = "CHAIN_CONTINUE"; + if (this.combat!.stage === "lose") { + key = "CHOOSE_LOSS"; + } else if (!move.includes(":") || move.endsWith(":")) { + key = "CHOOSE_RETREAT"; + } + return SquaresGame.valid(-1, i18next.t(`apgames:validation.squares.${key}`)); + } + return this.diagnoseCombatMove(move, legal); + } + + /** Explain a rejected combat decision, in particular why a retreat square is not allowed. */ + private diagnoseCombatMove(move: string, legal: string[]): IValidationResult { + const c = this.combat!; + if (move === "stand" && (c.stage === "defend" || c.stage === "second")) { + return SquaresGame.invalid(c.flank ? "STAND_FLANK" : "STAND_CAVALRY"); + } + const m = /^(retreat|option1|option5):(.+)$/.exec(move); + if (m !== null && c.stage !== "lose" && c.stage !== "advance") { + const steps = this.parseSteps(m[2]); + if (steps !== undefined) { + let bad = steps.length - 1; + for (let i = 0; i < steps.length; i++) { + const prefix = `${m[1]}:${fmtSteps(steps.slice(0, i + 1))}`; + if (!legal.some(l => l === prefix || l.startsWith(`${prefix},`))) { + bad = i; + break; + } + } + const role = bad > 0 ? "displaced" : (m[1] === "retreat" ? "defender" : "supporter"); + return this.diagnoseRetreat(this.unit(steps[bad].unit), steps[bad].to, role); + } + } + if (c.stage === "resolve" && /^option[1-5]/.test(move)) { + return SquaresGame.invalid("OPTION_UNAVAILABLE"); + } + return SquaresGame.invalid("ILLEGAL_RESPONSE", { move }); + } + + private diagnoseRetreat(u: IUnit, to: string, role: "defender" | "supporter" | "displaced"): IValidationResult { + const p = u.owner; + const from = u.loc; + if (!isLocation(to)) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALIDCELL", { cell: to }) }; + } + if (to === from) { + return { valid: false, message: i18next.t("apgames:validation._general.SAME_FROM_TO") }; + } + if (to === RESERVES[otherPlayer(p)]) { + return SquaresGame.invalid("RETREAT_ENEMY_RESERVE"); + } + if (u.type === "C") { + if (!adjacentTo(from).includes(to)) { + return SquaresGame.invalid("RETREAT_NOT_ADJACENT", { from, to }); + } + if (!isCloser(to, from, p)) { + return SquaresGame.invalid("RETREAT_NOT_CLOSER", { from, to }); + } + const occ = this.unitAt(to); + if (occ !== undefined && occ.owner !== p) { + return SquaresGame.invalid("RETREAT_ENEMY_OCCUPIED", { to }); + } + if (occ !== undefined) { + const vacant = adjacentTo(from).filter(n => n !== RESERVES[otherPlayer(p)] && isCloser(n, from, p) && (n === RESERVES[p] || this.unitAt(n) === undefined)); + if (vacant.length > 0) { + return SquaresGame.invalid("RETREAT_VACANT_FIRST", { to, vacant: vacant.join(", ") }); + } + return SquaresGame.invalid("RETREAT_CHAIN_BLOCKED", { to }); + } + return SquaresGame.invalid("ILLEGAL_RESPONSE", { move: `${u.type}${from}-${to}` }); + } + if (isCell(to)) { + if (!FORESTS.has(to)) { + return SquaresGame.invalid("RETREAT_TO_RESERVE_ONLY"); + } + if (role !== "defender") { + return SquaresGame.invalid("RETREAT_FOREST_DISPLACED"); + } + if (!FORESTS.has(from)) { + return SquaresGame.invalid("RETREAT_FOREST_FROM_CLEAR"); + } + if (!connectedTo(from).includes(to)) { + return SquaresGame.invalid("RETREAT_FOREST_ADJACENT", { to }); + } + if (!isCloser(to, from, p)) { + return SquaresGame.invalid("RETREAT_NOT_CLOSER", { from, to }); + } + if (this.unitAt(to) !== undefined) { + return { valid: false, message: i18next.t("apgames:validation._general.OCCUPIED", { where: to }) }; + } + if (this.enemyArtilleryNear(to, p).length > 0) { + return SquaresGame.invalid("RETREAT_FOREST_GUNS", { to }); + } + return SquaresGame.invalid("ILLEGAL_RESPONSE", { move: `${u.type}${from}-${to}` }); + } + if (u.type === "A" && role === "defender") { + return SquaresGame.invalid("RETREAT_ARTILLERY"); + } + if (!this.hasClearPath(u)) { + return SquaresGame.invalid("RETREAT_NO_PATH"); + } + return SquaresGame.invalid("ILLEGAL_RESPONSE", { move: `${u.type}${from}-${to}` }); + } + + private validateAction(move: string): IValidationResult { + const p = this.currplayer; + if (move === "pass") { + if (this.isDouble) { + return SquaresGame.invalid("NO_PASS_DOUBLE"); + } + return SquaresGame.valid(1); + } + if (move.includes(">")) { + return this.validateAttack(move); + } + const segments = move.split(","); + const parsed = segments.map(seg => SEGMENT_RE.exec(seg)); + if (segments.length > 2 || parsed.some(x => x === null)) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALID_MOVE", { move }) }; + } + const t1 = parsed[0]![1] as UnitType; + const from1 = parsed[0]![2]; + const steps1 = parsed[0]![3].length === 0 ? [] : parsed[0]![3].slice(1).split("-"); + if (!isLocation(from1)) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALIDCELL", { cell: from1 }) }; + } + const u1 = this.unitFor(p, t1, from1); + if (u1 === undefined) { + return SquaresGame.invalid("NO_SUCH_UNIT", { what: UNIT_NAMES[t1], where: from1 }); + } + if (steps1.length === 0) { + if (segments.length > 1) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALID_MOVE", { move }) }; + } + if (!this.canMove(u1) && !this.canAttack(u1)) { + return this.isFrozen(u1) ? SquaresGame.invalid("FROZEN", { where: from1 }) : SquaresGame.invalid("EXHAUSTED", { where: from1 }); + } + return SquaresGame.valid(-1, i18next.t("apgames:validation.squares.PARTIAL_UNIT", { context: UNIT_NAMES[t1] })); + } + if (segments.length === 1) { + const legal = this.canMove(u1) ? this.unitMoves(u1, this.actionsLeft === 2) : []; + if (legal.includes(move)) { + if (u1.type === "C" && steps1.length === 1 && !FORESTS.has(steps1[0])) { + const extendable = legal.some(l => l.startsWith(`${move}-`)) + || this.unitsOf(p).some(o => o.type === "C" && o.id !== u1.id && this.canMove(o)); + return SquaresGame.valid(extendable ? 0 : 1, extendable ? i18next.t("apgames:validation.squares.PARTIAL_CAVALRY") : undefined); + } + return SquaresGame.valid(1); + } + return this.diagnoseMove(u1, steps1); + } + // two cavalry units, one square each, applied in order + const t2 = parsed[1]![1] as UnitType; + const from2 = parsed[1]![2]; + const steps2 = parsed[1]![3].length === 0 ? [] : parsed[1]![3].slice(1).split("-"); + if (t1 !== "C" || t2 !== "C") { + return SquaresGame.invalid("TWO_CAVALRY_ONLY"); + } + if (steps1.length !== 1 || FORESTS.has(steps1[0])) { + return SquaresGame.invalid("TWO_CAVALRY_ONE_STEP"); + } + if (!this.canMove(u1) || !this.stepTargets(p, from1, { diagonal: true, forest: false }).includes(steps1[0])) { + return this.diagnoseMove(u1, steps1); + } + const saved = u1.loc; + u1.loc = steps1[0]; + try { + if (!isLocation(from2)) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALIDCELL", { cell: from2 }) }; + } + const u2 = this.unitFor(p, t2, from2, u1.id); + if (u2 === undefined) { + return SquaresGame.invalid("NO_SUCH_UNIT", { what: UNIT_NAMES[t2], where: from2 }); + } + if (u2.id === u1.id) { + return SquaresGame.invalid("TWO_CAVALRY_SAME"); + } + if (steps2.length === 0) { + return SquaresGame.valid(-1, i18next.t("apgames:validation.squares.PARTIAL_UNIT", { context: UNIT_NAMES.C })); + } + if (steps2.length !== 1 || FORESTS.has(steps2[0])) { + return SquaresGame.invalid("TWO_CAVALRY_ONE_STEP"); + } + if (!this.canMove(u2) || !this.stepTargets(p, from2, { diagonal: true, forest: false }).includes(steps2[0])) { + return this.diagnoseMove(u2, steps2); + } + return SquaresGame.valid(1); + } finally { + u1.loc = saved; + } + } + + /** Explain why a movement of `u` along `steps` is not legal. */ + private diagnoseMove(u: IUnit, steps: string[]): IValidationResult { + const p = u.owner; + const to = steps[steps.length - 1]; + for (const s of steps) { + if (!isLocation(s)) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALIDCELL", { cell: s }) }; + } + } + if (this.isFrozen(u)) { + return SquaresGame.invalid("FROZEN", { where: u.loc }); + } + if (u.moveStreak >= MAX_STREAK) { + return SquaresGame.invalid("MOVE_LIMIT", { where: u.loc }); + } + if (steps.length > 2) { + return SquaresGame.invalid("TOO_FAR"); + } + if (steps.length === 2 && u.type !== "C") { + return SquaresGame.invalid("TWO_STEPS_CAVALRY"); + } + if (to === u.loc) { + return { valid: false, message: i18next.t("apgames:validation._general.SAME_FROM_TO") }; + } + if (to === RESERVES[otherPlayer(p)]) { + if (this.reserveUnits(otherPlayer(p)).length > 0) { + return SquaresGame.invalid("RESERVE_OCCUPIED"); + } + } else if (to !== RESERVES[p] && this.unitAt(to) !== undefined) { + return { valid: false, message: i18next.t("apgames:validation._general.OCCUPIED", { where: to }) }; + } + if (steps.length === 1) { + if (!adjacentTo(u.loc).includes(to)) { + return SquaresGame.invalid("NOT_ADJACENT", { from: u.loc, to }); + } + if (FORESTS.has(to)) { + if (this.actionsLeft !== 2) { + return SquaresGame.invalid("FOREST_DOUBLE"); + } + return SquaresGame.invalid("FROZEN", { where: u.loc }); + } + if (u.type === "I" && diagonalTo(u.loc).includes(to)) { + if (this.actionsLeft !== 2) { + return SquaresGame.invalid("DIAGONAL_DOUBLE"); + } + return SquaresGame.invalid("FROZEN", { where: u.loc }); + } + return SquaresGame.invalid("ILLEGAL_MOVE", { move: `${u.type}${u.loc}-${to}` }); + } + const mid = steps[0]; + if (!adjacentTo(u.loc).includes(mid) || !adjacentTo(mid).includes(to)) { + return SquaresGame.invalid("NOT_ADJACENT", { from: u.loc, to }); + } + if (FORESTS.has(mid) || FORESTS.has(to)) { + return SquaresGame.invalid("FOREST_TWO_STEPS"); + } + if (mid === RESERVES[otherPlayer(p)]) { + return SquaresGame.invalid("THROUGH_ENEMY_RESERVE"); + } + if (mid !== RESERVES[p] && this.unitAt(mid) !== undefined) { + return SquaresGame.invalid("PATH_BLOCKED", { where: mid }); + } + return SquaresGame.invalid("ILLEGAL_MOVE", { move: `${u.type}${u.loc}-${steps.join("-")}` }); + } + + private validateAttack(move: string): IValidationResult { + const p = this.currplayer; + const m = ATTACK_RE.exec(move); + if (m === null) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALID_MOVE", { move }) }; + } + const [, ta, la, ts, ls, td, ld, reserve] = m; + if (!isCell(la)) { + return SquaresGame.invalid("ATTACK_FROM_RESERVE"); + } + const a = this.unitFor(p, ta, la); + if (a === undefined) { + return SquaresGame.invalid("NO_SUCH_UNIT", { what: UNIT_NAMES[ta as UnitType], where: la }); + } + if (this.isFrozen(a)) { + return SquaresGame.invalid("FROZEN", { where: la }); + } + if (a.attackStreak >= MAX_STREAK) { + return SquaresGame.invalid("ATTACK_LIMIT", { where: la }); + } + const target = reserve ?? ld; + let defender: IUnit | undefined; + if (reserve !== undefined) { + if (reserve === RESERVES[p]) { + return SquaresGame.invalid("OWN_RESERVE"); + } + if (this.reserveUnits(otherPlayer(p)).length === 0) { + return SquaresGame.invalid("RESERVE_EMPTY"); + } + } else { + if (!isCell(ld)) { + return { valid: false, message: i18next.t("apgames:validation._general.INVALIDCELL", { cell: ld }) }; + } + defender = this.unitAt(ld); + if (defender === undefined || defender.owner === p) { + return SquaresGame.invalid("NO_TARGET", { where: ld }); + } + if (defender.type !== td) { + return SquaresGame.invalid("WRONG_TYPE", { where: ld, what: UNIT_NAMES[defender.type] }); + } + } + if (!connectedTo(la).includes(target)) { + return SquaresGame.invalid("NOT_CONNECTED", { from: la, to: target }); + } + if (a.type === "A" && defender !== undefined && defender.type !== "A") { + return SquaresGame.invalid("ARTILLERY_TARGET"); + } + if (!this.pinAllows(a, target)) { + return SquaresGame.invalid("PINNED", { where: la }); + } + let s: IUnit | undefined; + if (ts !== undefined) { + if (!isCell(ls)) { + return SquaresGame.invalid("SUPPORT_FROM_RESERVE"); + } + s = this.unitFor(p, ts, ls); + if (s === undefined) { + return SquaresGame.invalid("NO_SUCH_UNIT", { what: UNIT_NAMES[ts as UnitType], where: ls }); + } + if (s.id === a.id) { + return SquaresGame.invalid("SUPPORT_SELF"); + } + if (this.isFrozen(s)) { + return SquaresGame.invalid("FROZEN", { where: ls }); + } + if (!adjacentTo(ls).includes(target)) { + return SquaresGame.invalid("SUPPORT_NOT_ADJACENT", { where: ls, target }); + } + if (!this.pinAllows(s, target)) { + return SquaresGame.invalid("PINNED", { where: ls }); + } + } else if (a.type === "C" && defender !== undefined && defender.type === "I") { + // Illegal on its own, but a legitimate step on the way to a supported attack. + if (this.supporters(a, target).length > 0) { + return SquaresGame.valid(-1, i18next.t("apgames:validation.squares.CAVALRY_NEEDS_SUPPORT")); + } + return SquaresGame.invalid("CAVALRY_NO_SUPPORT"); + } + if (s === undefined && this.supporters(a, target).length > 0) { + return SquaresGame.valid(0, i18next.t("apgames:validation.squares.PARTIAL_SUPPORT")); + } + return SquaresGame.valid(1); + } + + /* ---------------------------------------------------------- clicks */ + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + let loc: string | undefined; + let clickedType: UnitType | undefined; + if (row >= 0 && col >= 0) { + loc = CELL_ROWS[row]?.[col]; + } else if (piece === "_reserves_N") { + loc = NORTH_RESERVE; + } else if (piece === "_reserves_S") { + loc = SOUTH_RESERVE; + } else if (piece !== undefined && /^[BG][IAC]/.test(piece)) { + // A piece in a strip, whether plain, framed as just arrived or faded as about to be lost. + loc = piece[0] === "B" ? NORTH_RESERVE : SOUTH_RESERVE; + clickedType = piece[1] as UnitType; + } + if (loc === undefined) { + return { move, valid: false, message: i18next.t("apgames:validation._general.UNKNOWN_CLICK") }; + } + const current = SquaresGame.normalise(move); + const newmove = this.clickPly(current, loc, clickedType); + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? newmove : current; + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", { move, row, col, piece, emessage: (e as Error).message }), + }; + } + } + + /** + * Route a click. While an action is being entered it goes to `clickAction`; while the defender is answering, + * to `clickDefence`. Once the attacker has decisions to make (after an attack just entered, or in a combat + * already waiting for them), it goes to `clickDecision`. + */ + private clickPly(move: string, loc: string, clickedType?: UnitType): string { + const segments = move === "" ? [] : move.split("/"); + let prefix: string[]; + let decisions: string[]; + let squares: ICombatSquares | undefined; + if (this.combat === undefined) { + const head = segments[0] ?? ""; + const headResult = head === "" ? undefined : this.validateAction(head); + if (headResult === undefined || !headResult.valid || headResult.complete === -1) { + return this.clickAction(head, loc, clickedType); + } + prefix = [head]; + decisions = segments.slice(1); + squares = SquaresGame.attackSquares(head); + } else if (this.combat.stage === "resolve" || this.combat.stage === "advance") { + prefix = []; + decisions = segments; + squares = { from: this.combat.from, target: this.combat.target, support: this.combat.supportFrom }; + } else { + return this.clickDefence(move, loc, clickedType); + } + if (squares === undefined) { + return this.clickAction(prefix[0], loc, clickedType); + } + const walk = this.simulate(() => this.walkPly(prefix[0], decisions)); + if (walk.points.length === 0) { + return this.clickAction(prefix[0] ?? "", loc, clickedType); + } + return this.clickDecision(move, prefix, decisions, walk.points, squares, loc); + } + + private static attackSquares(head: string): ICombatSquares | undefined { + const m = ATTACK_RE.exec(head); + return m === null ? undefined : { from: m[2], target: m[7] ?? m[6], support: m[4] }; + } + + /** + * The attacker's decisions, by clicking the units involved. Clicking the defender eliminates it; then clicking + * it again withdraws both attacking units afterwards (option 1), clicking the support gives that up instead + * (option 2), and clicking the attacker exchanges it (option 3). Clicking the attacker or the support + * straight away withdraws that unit (options 4 and 5), with its retreat square clicked next if it has a + * choice. Once the target square is empty, clicking it advances, and clicking the attacker's own square stays + * put. A click on any other square starts the decision again. + */ + private clickDecision(move: string, prefix: string[], decisions: string[], points: IDecisionPoint[], squares: ICombatSquares, loc: string): string { + const level = points.length - 1; + const last = points[level]; + const set = (at: number, decision?: string): string => [...prefix, ...decisions.slice(0, at), ...(decision === undefined ? [] : [decision])].join("/"); + const resolveAt = points.findIndex(p => p.stage === "resolve"); + const chosen = resolveAt < 0 ? undefined : points[resolveAt].chosen; + const number = chosen === undefined ? undefined : /^option([1-5])/.exec(chosen)?.[1]; + const pick = (n: number): string => SquaresGame.optionEntry(points[resolveAt].legal, n); + if (last.chosen !== undefined && !last.legal.includes(last.chosen)) { + // A retreat under option 1 or 5 still needs its square, or the next link of a displacement chain. + const extended = this.extendRetreat(last.chosen, squares, loc); + if (extended !== undefined) { + return set(level, extended); + } + } + if (resolveAt < 0) { + // Only the advance is left to decide. + if (loc === squares.target) { + return set(level, "advance"); + } + if (loc === squares.from) { + return set(level, "stay"); + } + return decisions.length === 0 ? move : set(0); + } + if (loc === squares.target) { + if (chosen === undefined) { + return set(resolveAt, "option"); + } + if (chosen === "option") { + return set(resolveAt, pick(1)); + } + return number === "2" ? set(resolveAt + 1, "advance") : set(resolveAt, "option"); + } + if (loc === squares.from) { + if (chosen === "option") { + return set(resolveAt, pick(3)); + } + return number === "2" ? set(resolveAt + 1, "stay") : set(resolveAt, pick(4)); + } + if (loc === squares.support) { + return set(resolveAt, pick(chosen === "option" ? 2 : 5)); + } + return decisions.length === 0 ? move : set(0); + } + + /** Extend a retreat still being entered for option 1 or 5 with the clicked square, when that is a plausible destination. */ + private extendRetreat(chosen: string, squares: ICombatSquares, loc: string): string | undefined { + const m = /^(option[15])(?::(.*))?$/.exec(chosen); + if (m === null || squares.support === undefined) { + return undefined; + } + const plausible = (u: IUnit): boolean => loc === RESERVES[u.owner] || adjacentTo(u.loc).includes(loc); + if (m[2] === undefined || m[2] === "") { + const s = this.unitAt(squares.support); + return s !== undefined && plausible(s) ? `${m[1]}:${s.type}${s.loc}-${loc}` : undefined; + } + const steps = this.parseSteps(m[2]); + if (steps === undefined) { + return undefined; + } + const lastTo = steps[steps.length - 1].to; + const occ = this.unitAt(lastTo); + if (occ !== undefined && occ.owner === this.currplayer && !steps.some(st => st.unit === occ.id) && plausible(occ)) { + return `${chosen},${occ.type}${lastTo}-${loc}`; + } + return undefined; + } + + private clickAction(move: string, loc: string, clickedType?: UnitType): string { + const p = this.currplayer; + const enemy = RESERVES[otherPlayer(p)]; + const clickedUnit = this.unitAt(loc); + const ownLabel = (): string | undefined => { + if (clickedUnit !== undefined && clickedUnit.owner === p) { + return `${clickedUnit.type}${loc}`; + } + if (loc === RESERVES[p] && clickedType !== undefined && this.reserveUnits(p).some(u => u.type === clickedType)) { + return `${clickedType}${loc}`; + } + return undefined; + }; + if (move === "") { + return ownLabel() ?? ""; + } + if (move.includes(">")) { + const m = ATTACK_RE.exec(move); + if (m !== null && m[3] === undefined && clickedUnit !== undefined && clickedUnit.owner === p && loc !== m[2]) { + return `${m[1]}${m[2]}+${clickedUnit.type}${loc}>${move.split(">")[1]}`; + } + return ownLabel() ?? ""; + } + const segments = move.split(","); + const last = segments[segments.length - 1]; + const parsed = SEGMENT_RE.exec(last); + if (parsed === null) { + return ownLabel() ?? ""; + } + const selType = parsed[1] as UnitType; + const selLoc = parsed[2]; + const steps = parsed[3].length === 0 ? [] : parsed[3].slice(1).split("-"); + const other = ownLabel(); + if (steps.length === 0) { + if (loc === selLoc && (clickedType === undefined || clickedType === selType)) { + return segments.length === 1 ? "" : segments[0]; + } + if (other !== undefined) { + return segments.length === 1 ? other : `${segments[0]},${other}`; + } + if (segments.length === 1 && isCell(selLoc)) { + if (clickedUnit !== undefined && clickedUnit.owner !== p) { + return `${last}>${clickedUnit.type}${loc}`; + } + if (loc === enemy && this.reserveUnits(otherPlayer(p)).length > 0) { + return `${last}>${loc}`; + } + } + if (segments.length === 1 && selType === "C") { + // Cavalry may be sent straight to a square two steps away; the engine picks the path. + const direct = `${last}-${loc}`; + const unit = this.unitFor(p, selType, selLoc); + if (unit !== undefined && this.canMove(unit)) { + const legal = this.unitMoves(unit, this.actionsLeft === 2); + if (!legal.includes(direct)) { + const twoStep = legal.find(m => m.startsWith(`${last}-`) && m.endsWith(`-${loc}`) && m.split("-").length === 3); + if (twoStep !== undefined) { + return twoStep; + } + } + } + } + return `${move}-${loc}`; + } + if (loc === steps[steps.length - 1]) { + // Clicking the square the piece has just been shown moving to changes nothing. + return move; + } + if (other !== undefined) { + if (other === `${selType}${selLoc}`) { + // The moving unit's own origin: a second cavalry still in the reserve joins the move; otherwise clear. + if (segments.length === 1 && selType === "C" && steps.length === 1 && isReserve(selLoc) + && this.reserveUnits(p).filter(u => u.type === "C").length > 1) { + return `${move},${other}`; + } + return ""; + } + if (segments.length === 1 && selType === "C" && other.startsWith("C") && steps.length === 1) { + return `${move},${other}`; + } + return other; + } + if (segments.length === 1 && selType === "C" && steps.length === 1) { + return `${move}-${loc}`; + } + return `${selType}${selLoc}-${loc}`; + } + + /** The defender's clicks: its own square to stand, a square or the reserve to retreat to (chains link on), or a piece in the strip to give up. */ + private clickDefence(move: string, loc: string, clickedType?: UnitType): string { + const c = this.combat!; + if (c.stage === "lose") { + return clickedType !== undefined && loc === RESERVES[this.defendingPlayer()] ? `lose:${clickedType}` : move; + } + const d = this.unit(c.defender!); + if (move.startsWith("retreat:")) { + const steps = this.parseSteps(move.slice("retreat:".length)); + if (steps !== undefined) { + const lastTo = steps[steps.length - 1].to; + const occ = this.unitAt(lastTo); + if (occ !== undefined && occ.owner === d.owner && !steps.some(st => st.unit === occ.id)) { + return `${move},${occ.type}${lastTo}-${loc}`; + } + } + } + return loc === d.loc ? "stand" : `retreat:${d.type}${d.loc}-${loc}`; + } + + /** + * How option `n` is entered, given the legal choices: its one legal form, `optionN:` when a retreat + * square must still follow, or `optionN` alone when it is not available (the validator says why). + */ + private static optionEntry(legal: string[], n: number): string { + const matches = legal.filter(l => l === `option${n}` || l.startsWith(`option${n}:`)); + if (matches.length === 1) { + return matches[0]; + } + return matches.length === 0 ? `option${n}` : `option${n}:`; + } + + /* ---------------------------------------------------------- buttons */ + + /** Buttons for the decisions that clicks alone cannot express; each label is an i18n key under `apgames:buttons`. */ + public getButtons(): ICustomButton[] { + if (this.gameover) { + return []; + } + // Part-way through a ply (the front asks the previewed position), only the decisions its author + // still has to make are offered, as continuations of what has been entered. + const prefix = this.preview?.move; + const c = this.combat; + if (c === undefined) { + return prefix === undefined && !this.isDouble ? [{ label: "apgames:buttons.pass", move: "pass" }] : []; + } + if (prefix !== undefined && this.currplayer !== this.stack[this.stack.length - 1].currplayer) { + return []; + } + const legal = this.combatOptions(); + const buttons: ICustomButton[] = []; + const add = (label: string, move: string): void => { + buttons.push({ label: `apgames:buttons.squares.${label}`, move: prefix === undefined ? move : `${prefix}/${move}` }); + }; + switch (c.stage) { + case "defend": + case "second": { + if (legal.includes("stand")) { + add("stand", "stand"); + } + const home = legal.find(l => /^retreat:[IAC][A-Z0-9]+-(BR|GR)$/.test(l)); + if (home !== undefined) { + add("retreatReserve", home); + } + if (legal.some(l => l.startsWith("retreat:") && l !== home)) { + add("retreat", "retreat:"); + } + break; + } + case "resolve": + for (const n of [1, 2, 3, 4, 5]) { + if (legal.some(l => l.startsWith(`option${n}`))) { + add(`option${n}`, SquaresGame.optionEntry(legal, n)); + } + } + break; + case "advance": + add("advance", "advance"); + add("stay", "stay"); + break; + case "lose": + for (const l of legal) { + add(`lose${l.slice("lose:".length)}`, l); + } + break; + } + return buttons; + } + + /* ---------------------------------------------------------- rendering */ + + public getPlayerColour(p: playerid): Colourfuncs { + return { func: "custom", default: p === 1 ? "#8c8c8c" : "#1f78b4", palette: p }; + } + + /** The board is only ever shown the right way up or upside down. */ + public getCustomRotation(): number | undefined { + return 180; + } + + public render(opts?: IRenderOpts): APRenderRep { + // Legend keys must not differ only by case: a page in quirks mode matches ids case-insensitively. + const key = (p: playerid, t: UnitType): string => `${p === 1 ? "G" : "B"}${t}`; + const legend: NonNullable = {}; + // Each unit glyph has two paint slots: `fill` takes the owner's colour, `border` keeps the context colour. + const unitGlyph = (p: playerid, t: UnitType) => ({ name: `nato-${UNIT_NAMES[t]}`, paint: { fill: this.getPlayerColour(p) } }); + for (const p of [1, 2] as playerid[]) { + for (const t of UNIT_TYPES) { + legend[key(p, t)] = unitGlyph(p, t); + } + } + // A unit that the move being entered would eliminate is still drawn where it stood, the whole glyph at half opacity. + const faded = (p: playerid, t: UnitType): string => { + const k = `${key(p, t)}x`; + legend[k] = { ...unitGlyph(p, t), opacity: 0.5 }; + return k; + }; + const ghosts = this.preview?.ghosts ?? []; + const pstr = CELL_ROWS.map(row => row.map(cell => { + const u = this.unitAt(cell); + const ghost = ghosts.find(x => x.loc === cell); + if (u !== undefined && (ghost === undefined || ghost.id !== u.id)) { + return key(u.owner, u.type); + } + return ghost === undefined ? "-" : faded(ghost.owner, ghost.type); + }).join(",")).join("\n"); + // A unit that arrived in a reserve this ply is framed there, as the enter mark frames a square. + const arrivals = new Map(); + for (const r of this.results) { + if (r.type === "move" && r.how !== "attack" && isReserve(r.to) && r.by !== undefined && r.what !== undefined) { + const k = `${r.to}${r.by}${TYPE_BY_NAME[r.what]}`; + arrivals.set(k, (arrivals.get(k) ?? 0) + 1); + } + } + const framed = (p: playerid, t: UnitType): string => { + const k = `${key(p, t)}e`; + legend[k] = [ + // Only the dashed border shows: the frame's fill is fully transparent, so the strip's tint comes through. + { name: "piece-square-dashed", paint: { fill: { colour: "_context_fill", opacity: 0 } }, scale: FRAME_SCALE }, + { ...unitGlyph(p, t), scale: FRAMED_PIECE_SCALE }, + ]; + return k; + }; + const reserves = (p: playerid): AreaReserves => { + const inside = [...this.units.values()].filter(u => u.loc === RESERVES[p]); + const group = (units: IUnit[]): string[] => { + const out = units.map(u => key(u.owner, u.type)); + for (let i = out.length - 1; i >= 0; i--) { + const k = `${RESERVES[p]}${units[i].owner}${units[i].type}`; + const n = arrivals.get(k) ?? 0; + if (n > 0) { + arrivals.set(k, n - 1); + out[i] = framed(units[i].owner, units[i].type); + } + } + return out; + }; + const own = UNIT_TYPES.flatMap(t => group(inside.filter(u => u.owner === p && u.type === t))); + // An enemy unit that has entered this reserve is shown in it: that is how the game was won. + const intruders = group(inside.filter(u => u.owner !== p)); + const lost = ghosts.filter(u => u.loc === RESERVES[p]).map(u => faded(u.owner, u.type)); + return { + type: "reserves", + side: p === 1 ? "S" : "N", + background: this.getPlayerColour(p), + pieces: [...own, ...intruders, ...lost], + }; + }; + const annotations: NonNullable = []; + const edges = new Set<"N" | "S">(); + const at = (loc: string | undefined): { row: number; col: number } | undefined => { + const rc = loc === undefined ? undefined : cellCoords.get(loc); + return rc === undefined ? undefined : { row: rc[0], col: rc[1] }; + }; + const markEdge = (loc: string | undefined): void => { + if (loc === NORTH_RESERVE) { + edges.add("N"); + } else if (loc === SOUTH_RESERVE) { + edges.add("S"); + } + }; + const outline = (loc: string | undefined, type: "enter" | "exit"): void => { + const cell = at(loc); + if (cell !== undefined) { + annotations.push({ type, targets: [cell] }); + } + }; + // An arrow between two squares. The reserves have no position on the board, so an arrow into or + // out of one runs to or from the back-line square nearest the way, and that edge is highlighted. + const arrow = (from: string | undefined, to: string | undefined, style: "solid" | "dashed"): void => { + if (from === undefined || to === undefined) { + return; + } + let a = from; + let b = to; + if (from === RESERVES[1] || from === RESERVES[2]) { + markEdge(from); + a = SquaresGame.edgeCell(to, from === RESERVES[1] ? 1 : 2) ?? to; + } + if (to === RESERVES[1] || to === RESERVES[2]) { + markEdge(to); + b = SquaresGame.edgeCell(from, to === RESERVES[1] ? 1 : 2) ?? from; + } + const pa = at(a); + const pb = at(b); + if (pa !== undefined && pb !== undefined && a !== b) { + annotations.push({ type: "move", targets: [pa, pb], style }); + } else if (isReserve(from)) { + outline(to, "enter"); + } + }; + const exits = new Set(); + for (const r of this.results) { + if (r.type === "move") { + if (r.how === "attack") { + continue; // drawn below, together with its support + } + const retreat = r.how === "retreat" || r.how === "displaced"; + if (retreat && isReserve(r.to)) { + // A unit falling back to its reserve simply leaves the board: mark the square it left and the edge it went to. + exits.add(r.from); + markEdge(r.to); + } else { + arrow(r.from, r.to, retreat ? "dashed" : "solid"); + } + } else if (r.type === "capture") { + if (r.how === "reserve") { + markEdge(r.where); + } else if (r.where !== undefined) { + exits.add(r.where); + } + } + } + for (const ghost of ghosts) { + exits.add(ghost.loc); + } + for (const cell of exits) { + outline(cell, "exit"); + } + // Attack arrows: solid for the attacker, dashed for the support. They follow a pending combat, + // an attack still being entered, or this ply's results when the attack was settled on the spot. + let attackFrom: string | undefined; + let attackTo: string | undefined; + let supportFrom: string | undefined; + if (this.combat !== undefined) { + attackFrom = this.combat.from; + attackTo = this.combat.target; + supportFrom = this.combat.supportFrom; + } else if (this.preview?.attack !== undefined) { + attackFrom = this.preview.attack.from; + attackTo = this.preview.attack.target; + supportFrom = this.preview.attack.support; + } else { + const attack = this.results.find(r => r.type === "move" && r.how === "attack"); + if (attack !== undefined && attack.type === "move") { + attackFrom = attack.from; + attackTo = attack.to; + } + const support = this.results.find(r => r.type === "select" && r.what === "support"); + if (support !== undefined && support.type === "select") { + supportFrom = support.where; + } + } + if (attackTo !== undefined) { + arrow(attackFrom, attackTo, "solid"); + arrow(supportFrom, attackTo, "dashed"); + if (this.combat !== undefined) { + outline(isCell(attackTo) ? attackTo : attackFrom, "enter"); + } + } + if (this.preview !== undefined) { + for (const cell of new Set(this.preview.outline)) { + outline(cell, "enter"); + } + } + + // Gray (player 1) sits at the south, as in the rulebook's diagram; each player sees their own side at the bottom. + const board: APRenderRep["board"] = opts?.perspective === 2 ? { style: "dvgc", rotate: 180 } : { style: "dvgc" }; + if (edges.size > 0) { + board.markers = [...edges].map(edge => ({ type: "edge" as const, edge, colour: this.getPlayerColour(edge === "N" ? 2 : 1) })); + } + const rep: APRenderRep = { + board, + legend, + pieces: pstr, + areas: [reserves(1), reserves(2)], + }; + if (annotations.length > 0) { + rep.annotations = annotations; + } + return rep; + } + + /** The back-line square on `player`'s side nearest to `from`: where an arrow to or from that reserve is anchored. */ + private static edgeCell(from: string, player: playerid): string | undefined { + const r = rects.get(from); + if (r === undefined) { + return undefined; + } + const line = player === 1 ? CELL_ROWS[0].slice(5, 8) : CELL_ROWS[0].slice(0, 3); + if (line.includes(from)) { + return undefined; + } + const x = (r.x1 + r.x2) / 2; + let best: string | undefined; + let bestDistance = Infinity; + for (const cell of line) { + const c = rects.get(cell)!; + const d = Math.abs((c.x1 + c.x2) / 2 - x); + if (d < bestDistance) { + bestDistance = d; + best = cell; + } + } + return best; + } + + public sidebarStatuses(): IStatus[] { + const out: IStatus[] = []; + const turn = this.isDouble + ? this.neutralAreaLabel("apgames:status.squares.DOUBLE", { count: this.actionsLeft }) + : this.neutralAreaLabel("apgames:status.squares.SINGLE"); + out.push({ key: this.neutralAreaLabel("apgames:status.squares.TURN"), value: [turn] }); + if (this.combat !== undefined) { + out.push({ + key: this.neutralAreaLabel("apgames:status.squares.COMBAT"), + value: [this.neutralAreaLabel(`apgames:status.squares.STAGE_${this.combat.stage.toUpperCase()}`)], + }); + } + for (const p of [1, 2] as playerid[]) { + const lost = this.losses(p); + const value: StatusValue[] = []; + for (const t of UNIT_TYPES) { + value.push(`${lost[t]} `, this.statusSheetGlyph(`nato-${UNIT_NAMES[t]}`, this.getPlayerColour(p)), " "); + } + out.push({ key: this.seatAreaLabel(p, "apgames:status.squares.LOSSES"), value }); + } + return out; + } + + /** Units of the opponent that `player` has eliminated, before any doubling. */ + public eliminated(player: playerid): number { + const lost = this.losses(otherPlayer(player)); + return lost.I + lost.A + lost.C; + } + + public sidebarScores(): IScores[] { + return [{ + name: this.neutralAreaLabel("apgames:status.squares.POINTS"), + scores: ([1, 2] as playerid[]).map(p => `${this.points(p)} (${this.eliminated(p)})`), + }]; + } + + public getPlayerScore(player: number): number { + return this.points(player as playerid); + } + + /* ---------------------------------------------------------- chat */ + + /** The seat a result names, when it names one. Every result this game records does. */ + private static seatOf(r: APMoveResult): number | undefined { + if ("by" in r && typeof r.by === "string") { + return parseInt(r.by, 10); + } + if ("who" in r && typeof r.who === "number") { + return r.who; + } + if ("whose" in r && typeof r.whose === "number") { + return r.whose; + } + return undefined; + } + + /** Plies do not alternate strictly, so a ply's speaker is read from its own first result rather than inferred from whose turn comes next. */ + public resolveChatSeat(r: APMoveResult, currplayer: number): number { + return SquaresGame.seatOf(r) ?? super.resolveChatSeat(r, currplayer); + } + + public collectChatLogLine(lines: ChatLogLine[], r: APMoveResult, ctx: ChatLogCollectContext): boolean { + const seat = SquaresGame.seatOf(r) ?? ctx.defaultSeat; + switch (r.type) { + case "pass": + this.pushSeatChatLine(lines, seat, "apresults:PASS.squares"); + return true; + case "move": + if (r.how === "attack") { + this.pushSeatChatLine(lines, seat, "apresults:FIRE.squares", { from: r.from, to: r.to, context: r.what }); + } else { + this.pushSeatChatLine(lines, seat, `apresults:MOVE.squares_${r.how ?? "move"}`, { from: r.from, to: r.to, context: r.what }); + } + return true; + case "capture": + this.pushSeatChatLine(lines, seat, r.how === "reserve" ? "apresults:CAPTURE.squares_reserve" : "apresults:CAPTURE.squares", { where: r.where, context: r.what }); + return true; + case "select": + if (r.what === "support") { + this.pushNeutralChatLine(lines, "apresults:SELECT.squares_support", { where: r.where, context: r.how }); + } else if (r.what === "stand") { + this.pushSeatChatLine(lines, seat, "apresults:SELECT.squares_stand", { where: r.where, context: r.how }); + } else { + this.pushSeatChatLine(lines, seat, `apresults:SELECT.squares_${r.what}`, { where: r.where }); + } + return true; + case "announce": + this.pushNeutralChatLine(lines, "apresults:ANNOUNCE.squares_flank"); + return true; + case "eog": + if (r.reason === undefined) { + return super.collectChatLogLine(lines, r, ctx); + } + this.pushNeutralChatLine(lines, `apresults:EOG.squares_${r.reason}`); + return true; + default: + return super.collectChatLogLine(lines, r, ctx); + } + } + + /* ---------------------------------------------------------- state */ + + public state(): ISquaresState { + return { + game: SquaresGame.gameinfo.uid, + numplayers: 2, + variants: [...this.variants], + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + const state: IMoveState = { + _version: SquaresGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + units: cloneUnits(this.units), + turnOwner: this.turnOwner, + actionsLeft: this.actionsLeft, + isDouble: this.isDouble, + pendingDouble: [...this.pendingDouble], + turnNo: [...this.turnNo], + }; + if (this.combat !== undefined) { + state.combat = { ...this.combat }; + } + return state; + } + + public clone(): SquaresGame { + return new SquaresGame(this.serialize()); + } +} diff --git a/test/games/squares.test.ts b/test/games/squares.test.ts new file mode 100644 index 00000000..adc51d4f --- /dev/null +++ b/test/games/squares.test.ts @@ -0,0 +1,1094 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import "mocha"; +import { expect } from "chai"; +import { addResource } from "../../src"; +import { assertChatLogParity } from "../fixtures/chat/helpers"; +import i18next from "i18next"; +import { + BOARD_CELLS, + RESERVES, + SquaresGame, + connectedTo, + diagonalTo, + distance, + type playerid, +} from "../../src/games/squares"; + +/** Put a unit somewhere (call `commit` afterwards if the position must survive a clone). */ +const place = (g: SquaresGame, id: string, loc: string): void => { + g.unit(id).loc = loc; +}; + +/** Spread a player's reserve over the given cells, leaving any extra units in the reserve. */ +const evacuate = (g: SquaresGame, player: playerid, cells: string[]): void => { + const pool = [...cells]; + for (const u of g.reserveUnits(player)) { + const cell = pool.shift(); + if (cell !== undefined) { + u.loc = cell; + } + } +}; + +const commit = (g: SquaresGame): SquaresGame => { + g.stack[0] = g.moveState(); + return g; +}; + +/** A position to play from: the units named stand on the given squares, everything else waits in its reserve. Committed, so clones and previews see it. */ +const setup = (units: Record): SquaresGame => { + const g = new SquaresGame(); + for (const [id, loc] of Object.entries(units)) { + g.unit(id).loc = loc; + } + return commit(g); +}; + +const alive = (g: SquaresGame, id: string): boolean => g.unit(id).loc !== "X"; + +describe("Squares", () => { + before(() => { + addResource("en"); + }); + + describe("board geometry", () => { + it("names thirty cells and two reserves", () => { + expect(BOARD_CELLS.length).to.equal(30); + expect(RESERVES[1]).to.equal("GR"); + expect(RESERVES[2]).to.equal("BR"); + }); + + it("connects cells that share any length of edge", () => { + expect(connectedTo("G3R")).to.include.members(["G3C", "G2R", "G2CR", "SEF", "GR"]); + expect(connectedTo("G3R")).to.not.include("G2CL"); + expect(connectedTo("G2L")).to.include.members(["G3L", "G2CL", "G1L", "SWF", "WF"]); + expect(connectedTo("G2R")).to.include.members(["G1R", "SEF", "EF"]); + expect(connectedTo("G1R")).to.include.members(["B1L", "G1CR", "EF", "G2R", "G2CR"]); + expect(connectedTo("G1R")).to.not.include("B1R"); + expect(connectedTo("SEF")).to.have.members(["G3R", "G2R", "EF", "NEF"]); + expect(connectedTo("EF")).to.have.members(["SEF", "NEF", "G2R", "G1R", "B1L", "B2L"]); + }); + + it("only has diagonals between the two centre rows", () => { + expect(diagonalTo("G1R")).to.have.members(["B1CL"]); + expect(diagonalTo("G1C")).to.have.members(["B1CL", "B1CR"]); + expect(diagonalTo("B1L")).to.have.members(["G1CR"]); + for (const cell of BOARD_CELLS) { + if (diagonalTo(cell).length > 0) { + expect(cell.startsWith("B1") || cell.startsWith("G1"), cell).to.be.true; + } + } + }); + + it("attaches each reserve to its three back-line squares only", () => { + expect(connectedTo("GR")).to.have.members(["G3R", "G3C", "G3L"]); + expect(connectedTo("BR")).to.have.members(["B3L", "B3C", "B3R"]); + expect(diagonalTo("GR")).to.be.empty; + }); + + it("measures distance from the centre of each square", () => { + expect(distance("G3C", 1)).to.equal(25); + expect(distance("G1L", 1)).to.equal(125); + expect(distance("SEF", 1)).to.equal(100); + expect(distance("EF", 1)).to.equal(150); + expect(distance("EF", 2)).to.equal(150); + expect(distance("NEF", 1)).to.equal(200); + expect(distance("B3C", 2)).to.equal(25); + expect(distance("GR", 1)).to.be.lessThan(distance("G3C", 1)); + }); + }); + + describe("setup and basic movement", () => { + it("starts with sixteen units per side in reserve and Gray to move", () => { + const g = new SquaresGame(); + for (const p of [1, 2] as playerid[]) { + const reserve = g.reserveUnits(p); + expect(reserve.length).to.equal(16); + expect(reserve.filter(u => u.type === "I").length).to.equal(9); + expect(reserve.filter(u => u.type === "A").length).to.equal(3); + expect(reserve.filter(u => u.type === "C").length).to.equal(4); + } + expect(g.currplayer).to.equal(1); + const moves = g.moves(); + expect(moves).to.include.members(["pass", "IGR-G3L", "AGR-G3C", "CGR-G3R", "CGR-G3L-G2L", "CGR-G3L,CGR-G3C"]); + expect(moves.some(m => m.includes(">"))).to.be.false; + expect(moves.filter(m => m === "IGR-G3L").length).to.equal(1); + }); + + it("moves infantry orthogonally only, one square", () => { + const g = setup({ "1I1": "G1C" }); + const mine = g.moves().filter(m => m.startsWith("IG1C")); + expect(mine).to.have.members(["IG1C-G1CL", "IG1C-G1CR", "IG1C-B1C", "IG1C-G2CL", "IG1C-G2CR"]); + expect(g.validateMove("IG1C-B1CL").valid).to.be.false; + }); + + it("lets cavalry go two squares, diagonally, or one square into a forest", () => { + const g = setup({ "1C1": "G2L" }); + const mine = g.moves().filter(m => m.startsWith("CG2L")); + expect(mine).to.include.members(["CG2L-G1L-B1R", "CG2L-G1L-B1CR", "CG2L-WF", "CG2L-G3L-GR", "CG2L-G1L"]); + expect(mine).to.not.include("CG2L-G1L-B1L"); + expect(mine.some(m => m.startsWith("CG2L-WF-"))).to.be.false; + expect(g.validateMove("CG2L-G1L-G2L").valid).to.be.false; + }); + + it("lets cavalry pass through its own reserve", () => { + const g = setup({ "1C1": "G3L" }); + expect(g.moves()).to.include("CG3L-GR-G3R"); + g.move("CG3L-GR-G3R"); + expect(g.unit("1C1").loc).to.equal("G3R"); + }); + + it("moves two cavalry in either order, and lets the second use the first's square", () => { + const g = new SquaresGame(); + expect(g.validateMove("CGR-G3L,CGR-G3C").valid).to.be.true; + expect(g.validateMove("CGR-G3C,CGR-G3L").valid).to.be.true; + place(g, "1C1", "G2L"); + place(g, "1C2", "G3L"); + expect(g.validateMove("CG2L-G1L,CG3L-G2L").valid).to.be.true; + expect(g.validateMove("CG3L-G2L,CG2L-G1L").valid).to.be.false; + expect(g.validateMove("CG2L-G1L,CG2L-G1CL").valid).to.be.false; + g.move("CG2L-G1L,CG3L-G2L"); + expect(g.unit("1C1").loc).to.equal("G1L"); + expect(g.unit("1C2").loc).to.equal("G2L"); + expect(g.currplayer).to.equal(2); + }); + + it("keeps infantry and artillery out of forests without a double turn", () => { + const g = setup({ "1I1": "G2R", "1A1": "G2L" }); + expect(g.validateMove("IG2R-SEF").valid).to.be.false; + expect(g.validateMove("AG2L-WF").valid).to.be.false; + expect(g.moves()).to.include("IG2R-G1R"); + g.move("pass"); + g.move("IBR-B3C"); + expect(g.isDouble).to.be.true; + expect(g.moves()).to.include.members(["IG2R-SEF", "AG2L-WF"]); + g.move("IG2R-SEF"); + expect(g.unit("1I1").loc).to.equal("SEF"); + // the whole double turn is spent + expect(g.currplayer).to.equal(2); + expect(g.unit("1I1").moveStreak).to.equal(2); + }); + + it("lets infantry move diagonally only with a double move", () => { + const g = setup({ "1I1": "G1C", "2I1": "B1C" }); + expect(g.validateMove("IG1C-B1CL").valid).to.be.false; + g.move("pass"); + g.move("IBR-B3C"); + expect(g.moves()).to.include("IG1C-B1CL"); + g.move("IG1C-B1CL"); + expect(g.unit("1I1").loc).to.equal("B1CL"); + expect(g.currplayer).to.equal(2); + }); + + it("forbids passing during a double turn and gives it two actions", () => { + const g = new SquaresGame(); + g.move("pass"); + expect(g.currplayer).to.equal(2); + g.move("IBR-B3C"); + expect(g.currplayer).to.equal(1); + expect(g.isDouble).to.be.true; + expect(g.actionsLeft).to.equal(2); + expect(g.validateMove("pass").valid).to.be.false; + g.move("IGR-G3C"); + expect(g.currplayer).to.equal(1); + expect(g.actionsLeft).to.equal(1); + g.move("IG3C-G2CL"); + expect(g.currplayer).to.equal(2); + expect(g.isDouble).to.be.false; + expect(g.validateMove("pass").valid).to.be.true; + }); + + it("wins by entering an empty enemy reserve", () => { + const g = new SquaresGame(); + evacuate(g, 2, ["B2L", "B2CL", "B2CR", "B2R", "B1L", "B1CL", "B1C", "B1CR", "B1R", "NEF", "NWF", "EF", "WF", "G2L", "G2R", "G3R"]); + place(g, "1I1", "B3C"); + expect(g.moves()).to.include("IB3C-BR"); + g.move("IB3C-BR"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + }); + }); + + describe("consecutive-turn limits and freezes", () => { + it("stops a unit moving on a third consecutive turn", () => { + const g = setup({ "1I1": "G3C" }); + g.move("IG3C-G2CL"); + g.move("IBR-B3C"); + g.move("IG2CL-G1C"); + g.move("IB3C-B2CL"); + expect(g.unit("1I1").moveStreak).to.equal(2); + expect(g.moves().some(m => m.startsWith("IG1C-"))).to.be.false; + expect(g.validateMove("IG1C-G1CL").valid).to.be.false; + g.move("IGR-G3L"); + g.move("IBR-B3C"); + expect(g.moves()).to.include("IG1C-G1CL"); + }); + + it("follows the rulebook example: a pass resets the count, a double turn counts as two turns", () => { + const g = setup({ "1C1": "G3C" }); + g.move("CG3C-G2CL"); + g.move("IBR-B3C"); + g.move("pass"); + g.move("IB3C-B2CL"); + g.move("CG2CL-G1C"); + g.move("CG1C-G1CL"); + expect(g.currplayer).to.equal(2); + g.move("IBR-B3C"); + expect(g.moves().some(m => m.startsWith("CG1CL-"))).to.be.false; + expect(g.canMove(g.unit("1C1"))).to.be.false; + expect(g.canAttack(g.unit("1C1"))).to.be.true; + }); + + it("freezes a retreated infantry for its owner's next turn only", () => { + const g = setup({ "1I1": "G1C", "2I1": "B1C" }); + evacuate(g, 2, ["B2L", "B2CL", "B2CR", "B2R", "B1L", "B1CL", "B1CR", "B1R", "NEF", "NWF", "EF", "WF", "SEF", "SWF", "G2R"]); + g.move("IG1C>IB1C"); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.have.members(["stand", "retreat:IB1C-BR"]); + g.move("retreat:IB1C-BR"); + expect(g.unit("2I1").loc).to.equal("BR"); + expect(g.currplayer).to.equal(2); + expect(g.canMove(g.unit("2I1"))).to.be.false; + expect(g.moves().some(m => m.startsWith("IBR-"))).to.be.false; + g.move("CG2R-G3R"); + g.move("IGR-G3L"); + expect(g.canMove(g.unit("2I1"))).to.be.true; + expect(g.moves()).to.include("IBR-B3C"); + }); + }); + + describe("attacks", () => { + it("eliminates both units when an unsupported attack is met by a stand", () => { + const g = setup({ "1I1": "G1C", "2I1": "B1C" }); + g.move("IG1C>IB1C"); + g.move("stand"); + expect(alive(g, "1I1")).to.be.false; + expect(alive(g, "2I1")).to.be.false; + expect(g.combat).to.be.undefined; + expect(g.currplayer).to.equal(2); + expect(g.points(1)).to.equal(1); + expect(g.points(2)).to.equal(1); + }); + + it("never lets cavalry attack infantry unsupported, and never lets artillery attack anything but artillery", () => { + const g = setup({ "1C1": "G1R", "1I1": "G1CR", "2I1": "B1L" }); + let moves = g.moves(); + expect(moves).to.not.include("CG1R>IB1L"); + expect(moves).to.include("CG1R+IG1CR>IB1L"); + // G1CR only touches B1L at a corner, so the infantry may support but never attack it + expect(moves).to.not.include("IG1CR>IB1L"); + // the unsupported attack is a legal step towards the supported one, but cannot be submitted + expect(g.validateMove("CG1R>IB1L").complete).to.equal(-1); + expect(() => g.move("CG1R>IB1L")).to.throw(); + let click = g.handleClick("", 2, 5); + click = g.handleClick(click.move, 2, 4); + expect(click.move).to.equal("CG1R>IB1L"); + expect(click.complete).to.equal(-1); + click = g.handleClick(click.move, 2, 6); + expect(click.move).to.equal("CG1R+IG1CR>IB1L"); + expect(click.complete).to.equal(1); + place(g, "1I1", "GR"); + expect(g.validateMove("CG1R>IB1L").valid).to.be.false; + + const h = setup({ "1A1": "G1C", "1A2": "G1L", "2A1": "B1C", "2I1": "B1R" }); + moves = h.moves(); + expect(moves).to.include("AG1C>AB1C"); + expect(moves).to.not.include("AG1L>IB1R"); + expect(moves).to.not.include("AG1L+AG1C>IB1R"); + }); + + it("pins units next to enemy artillery", () => { + const g = setup({ "1I1": "G1C", "1I2": "G1CR", "2I1": "B1CL", "2I2": "B1C", "2A1": "B1CR" }); + // G1C touches B1CR only at a corner, so it can neither attack the guns nor anything else + expect(g.moves().some(m => m.startsWith("IG1C>") || m.startsWith("IG1C+"))).to.be.false; + expect(g.moves()).to.not.include("IG1CR+IG1C>IB1CL"); + expect(g.moves()).to.include("IG1CR>IB1CL"); + // a unit sharing an edge with the guns may attack them and nothing else + place(g, "1I3", "G1CL"); + expect(g.moves()).to.include("IG1CL>AB1CR"); + expect(g.moves()).to.not.include("IG1CL>IB1C"); + // artillery in a reserve pins nothing + place(g, "2A1", "BR"); + expect(g.moves()).to.include("IG1C>IB1C"); + expect(g.moves()).to.include("IG1CR+IG1C>IB1CL"); + }); + + it("throws away an unsupported attacker against a forest or against artillery", () => { + const g = setup({ "1I1": "G1R", "2I1": "EF" }); + g.move("IG1R>IEF"); + expect(alive(g, "1I1")).to.be.false; + expect(alive(g, "2I1")).to.be.true; + expect(g.currplayer).to.equal(2); + g.move("pass"); + place(g, "1I2", "G1C"); + place(g, "2A1", "B1C"); + g.move("IG1C>AB1C"); + expect(alive(g, "1I2")).to.be.false; + expect(alive(g, "2A1")).to.be.true; + }); + + it("lets unsupported artillery and defending artillery destroy each other", () => { + const g = setup({ "1A1": "G1C", "2A1": "B1C" }); + g.move("AG1C>AB1C"); + expect(alive(g, "1A1")).to.be.false; + expect(alive(g, "2A1")).to.be.false; + }); + + it("offers the attacker the rulebook options when a supported attack is stood", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + g.move("IG1C+CG1CL>IB1C"); + expect(g.currplayer).to.equal(2); + g.move("stand"); + expect(g.currplayer).to.equal(1); + expect(g.combat!.stage).to.equal("resolve"); + expect(g.moves()).to.have.members(["option1:CG1CL-G2CL", "option2/advance", "option2/stay", "option3", "option4", "option5:CG1CL-G2CL"]); + // the advance is the attacker's decision too, so it belongs to the same ply; left unsaid, the attacker stays put + expect(g.validateMove("option2").complete).to.equal(0); + const stays = g.clone(); + stays.move("option2"); + expect(stays.unit("1I1").loc).to.equal("G1C"); + expect(stays.combat).to.be.undefined; + expect(stays.currplayer).to.equal(2); + g.move("option2/advance"); + expect(alive(g, "2I1")).to.be.false; + expect(alive(g, "1C1")).to.be.false; + expect(g.combat).to.be.undefined; + expect(g.unit("1I1").loc).to.equal("B1C"); + expect(g.currplayer).to.equal(2); + }); + + it("denies attacking cavalry the retreat options", () => { + const g = setup({ "1C1": "G1C", "1I1": "G1CL", "2I1": "B1C" }); + g.move("CG1C+IG1CL>IB1C"); + g.move("stand"); + expect(g.moves()).to.have.members(["option2/advance", "option2/stay", "option3", "option5"]); + g.move("option5"); + expect(g.unit("1I1").loc).to.equal("GR"); + expect(alive(g, "2I1")).to.be.true; + expect(g.combat).to.be.undefined; + }); + + it("gives the defender a second chance when supporting artillery withdraws under option 1", () => { + const g = setup({ "1I1": "G1C", "1A1": "G1CL", "2I1": "B1C" }); + g.move("IG1C+AG1CL>IB1C"); + g.move("stand"); + expect(g.moves()).to.include("option1"); + g.move("option1"); + expect(g.unit("1A1").loc).to.equal("GR"); + expect(g.combat!.stage).to.equal("second"); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.have.members(["stand", "retreat:IB1C-BR"]); + g.move("retreat:IB1C-BR"); + expect(alive(g, "2I1")).to.be.true; + expect(g.combat!.stage).to.equal("advance"); + g.move("stay"); + expect(g.unit("1I1").loc).to.equal("G1C"); + expect(g.currplayer).to.equal(2); + }); + + it("eliminates a defender that stands again after the second chance, and retreats the attacker", () => { + const g = setup({ "1I1": "G1C", "1A1": "G1CL", "2I1": "B1C" }); + g.move("IG1C+AG1CL>IB1C"); + g.move("stand"); + g.move("option1"); + g.move("stand"); + expect(alive(g, "2I1")).to.be.false; + expect(g.unit("1I1").loc).to.equal("GR"); + expect(g.combat).to.be.undefined; + }); + + it("resolves a flank attack: forced retreat, or elimination at no cost", () => { + const g = setup({ "1C1": "B3C", "1I1": "B1C", "2I1": "B2CL" }); + // the retreat is forced and unique, so it happens at once; advancing is decided in the same ply, staying put by default + expect(g.validateMove("CB3C+IB1C>IB2CL").complete).to.equal(0); + expect(g.moves()).to.include.members(["CB3C+IB1C>IB2CL/advance", "CB3C+IB1C>IB2CL/stay"]); + const stays = g.clone(); + stays.move("CB3C+IB1C>IB2CL"); + expect(stays.unit("2I1").loc).to.equal("BR"); + expect(stays.unit("1C1").loc).to.equal("B3C"); + expect(stays.currplayer).to.equal(2); + g.move("CB3C+IB1C>IB2CL/advance"); + expect(g.unit("2I1").loc).to.equal("BR"); + expect(g.combat).to.be.undefined; + expect(g.unit("1C1").loc).to.equal("B2CL"); + expect(g.currplayer).to.equal(2); + + const h = setup({ "1C1": "B3C", "1I1": "B1C", "1I2": "B3L", "2I1": "B2CL" }); + h.move("CB3C+IB1C>IB2CL/stay"); + expect(alive(h, "2I1")).to.be.false; + expect(alive(h, "1C1")).to.be.true; + expect(alive(h, "1I1")).to.be.true; + expect(h.combat).to.be.undefined; + expect(h.unit("1C1").loc).to.equal("B3C"); + }); + + it("answers the rulebook FAQ: flanked artillery outside a forest simply dies", () => { + const g = new SquaresGame(); + g.move("pass"); + place(g, "1A1", "G1L"); + place(g, "2C1", "G2L"); + place(g, "2C2", "G1CL"); + g.move("CG2L+CG1CL>AG1L/advance"); + expect(alive(g, "1A1")).to.be.false; + expect(alive(g, "2C1")).to.be.true; + expect(alive(g, "2C2")).to.be.true; + expect(g.combat).to.be.undefined; + expect(g.unit("2C1").loc).to.equal("G1L"); + }); + }); + + describe("retreats", () => { + it("makes attacked cavalry retreat, displacing friends when nothing is vacant", () => { + const g = setup({ "1C1": "G1C", "1I1": "G1CL", "2C1": "B1C", "2I1": "B2CL", "2I2": "B2CR" }); + g.move("CG1C+IG1CL>CB1C"); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.have.members(["retreat:CB1C-B2CL,IB2CL-BR", "retreat:CB1C-B2CR,IB2CR-BR"]); + g.move("retreat:CB1C-B2CL,IB2CL-BR"); + expect(g.unit("2C1").loc).to.equal("B2CL"); + expect(g.unit("2I1").loc).to.equal("BR"); + expect(g.canMove(g.unit("2I1"))).to.be.false; + expect(g.canMove(g.unit("2C1"))).to.be.true; + expect(g.combat!.stage).to.equal("advance"); + }); + + it("lets cavalry stand against unsupported cavalry, in a forest, or when it cannot retreat", () => { + const g = setup({ "1C1": "G1C", "2C1": "B1C" }); + g.move("CG1C>CB1C"); + expect(g.moves()).to.include("stand"); + expect(g.moves()).to.include("retreat:CB1C-B2CL"); + + const h = setup({ + "1C1": "G1C", + "1I1": "G1CL", + "2C1": "B1C", + "2A1": "B2CL", + "2A2": "B2CR", + "1I2": "B3L", + "1I3": "B3C", + "1I4": "B3R", + }); + // both squares behind are friendly artillery with no clear path, so the cavalry must stand + expect(h.validateMove("CG1C+IG1CL>CB1C").complete).to.equal(-1); + expect(h.moves()).to.include.members(["CG1C+IG1CL>CB1C/option3", "CG1C+IG1CL>CB1C/option2/advance"]); + h.move("CG1C+IG1CL>CB1C/option3"); + expect(alive(h, "2C1")).to.be.false; + expect(alive(h, "1C1")).to.be.false; + expect(h.currplayer).to.equal(2); + }); + + it("lets infantry attacked in a forest fall back into a closer forest", () => { + const g = setup({ "1I1": "G1R", "1C1": "G2R", "2I1": "EF" }); + g.move("IG1R+CG2R>IEF"); + expect(g.moves()).to.have.members(["stand", "retreat:IEF-BR", "retreat:IEF-NEF"]); + g.move("retreat:IEF-NEF"); + expect(g.unit("2I1").loc).to.equal("NEF"); + }); + + it("blocks a clear path with enemy units but not with friendly ones", () => { + const g = setup({ "2I1": "B1CL", "2I2": "B2CL" }); + expect(g.hasClearPath(g.unit("2I1"))).to.be.true; + place(g, "1I1", "B3L"); + place(g, "1I2", "B3C"); + expect(g.hasClearPath(g.unit("2I1"))).to.be.false; + place(g, "1I2", "X"); + expect(g.hasClearPath(g.unit("2I1"))).to.be.true; + place(g, "1I3", "B2CL"); + place(g, "2I2", "BR"); + expect(g.hasClearPath(g.unit("2I1"))).to.be.false; + }); + }); + + describe("reserves and winning", () => { + it("lets the reserve's owner choose which unit type is lost", () => { + const g = setup({ "1I1": "B3C" }); + g.move("IB3C>BR"); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.have.members(["lose:I", "lose:A", "lose:C"]); + g.move("lose:A"); + expect(g.reserveUnits(2).filter(u => u.type === "A").length).to.equal(2); + expect(alive(g, "1I1")).to.be.true; + expect(g.combat).to.be.undefined; + expect(g.currplayer).to.equal(2); + }); + + it("lets a supported attack that empties the reserve advance into it and win", () => { + const g = new SquaresGame(); + evacuate(g, 2, ["B2L", "B2CL", "B2CR", "B2R", "B1L", "B1CL", "B1C", "B1CR", "B1R", "G2L", "G2R", "G3R", "NEF", "NWF", "EF"]); + expect(g.reserveUnits(2).length).to.equal(1); + place(g, "1I1", "B3C"); + place(g, "1I2", "B3L"); + expect(g.moves()).to.include.members(["IB3C+IB3L>BR/advance", "IB3C+IB3L>BR/stay"]); + g.move("IB3C+IB3L>BR/advance"); + expect(g.reserveUnits(2).length).to.equal(0); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + }); + + it("scores artillery double and cavalry double once three are lost, per the rulebook example", () => { + const g = new SquaresGame(); + g.move("pass"); + // Gray has lost 1 artillery, 2 cavalry and 4 infantry; Blue 1 artillery, 2 cavalry and 5 infantry. + for (const id of ["1A1", "1C1", "1C2", "1I1", "1I2", "1I3", "1I4"]) { + place(g, id, "X"); + } + for (const id of ["2A1", "2C1", "2C2", "2I1", "2I2", "2I3", "2I4", "2I5"]) { + place(g, id, "X"); + } + expect(g.points(2)).to.equal(8); + expect(g.points(1)).to.equal(9); + place(g, "1C4", "G1C"); + place(g, "2I6", "B1C"); + place(g, "2A2", "B1CL"); + place(g, "2I7", "G2CL"); + place(g, "2I8", "G2CR"); + expect(g.validateMove("IB1C+AB1CL>CG1C").complete).to.equal(-1); + expect(g.validateMove("IB1C+AB1CL>CG1C/option2").complete).to.equal(0); + expect(g.validateMove("IB1C+AB1CL>CG1C/option2/stay").complete).to.equal(0); + g.move("IB1C+AB1CL>CG1C/option2/stay"); + expect(g.points(2)).to.equal(12); + expect(g.points(1)).to.equal(11); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + }); + + it("draws when both reach ten with the same totals and unit counts", () => { + const g = new SquaresGame(); + for (let i = 1; i <= 9; i++) { + place(g, `1I${i}`, "X"); + place(g, `2I${i}`, "X"); + } + place(g, "1C1", "G1C"); + place(g, "2C1", "B1C"); + g.move("CG1C>CB1C"); + g.move("stand"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1, 2]); + }); + + it("loses a player who cannot complete a double turn", () => { + const g = setup({ "2I1": "G3R", "2I2": "G3C", "2I3": "G3L" }); + expect(g.moves()).to.deep.equal(["pass"]); + g.move("pass"); + g.move("IBR-B3C"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + }); + }); + + describe("plumbing", () => { + it("lists only moves that validate and apply, and validates every listed move", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "1A1": "G2CL", "1C2": "G3L", "2I1": "B1C", "2C1": "B1CR", "2A1": "B2CR" }); + const moves = g.moves(); + expect(moves.length).to.be.greaterThan(20); + for (const m of moves) { + const result = g.validateMove(m); + expect(result.valid, m).to.be.true; + expect(result.complete, m).to.not.equal(-1); + const clone = g.clone(); + clone.move(m); + } + expect(new Set(moves).size).to.equal(moves.length); + }); + + it("survives serialisation with a combat pending", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + g.move("IG1C+CG1CL>IB1C"); + const clone = g.clone(); + expect(clone.currplayer).to.equal(2); + expect(clone.combat).to.deep.equal(g.combat); + expect(clone.moves()).to.have.members(g.moves()); + clone.move("stand"); + expect(clone.currplayer).to.equal(1); + expect(clone.moves()).to.include("option2/advance"); + }); + + it("closes an export round whenever the round's opener acts again", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + g.move("IG1C+CG1CL>IB1C"); + g.move("retreat:IB1C-BR"); + g.move("advance"); + g.move("IBR-B3C"); + g.move("pass"); + g.move("IB3C-B2CL"); + g.move("IGR-G3C"); + g.move("IG3C-G2CL"); + const plies = g.getPlies(); + expect(plies.map(p => p.actor)).to.deep.equal([1, 2, 1, 2, 1, 2, 1, 1]); + expect(plies.map(p => p.round)).to.deep.equal([0, 0, 1, 1, 2, 2, 3, 4]); + }); + + it("builds moves from clicks", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + let click = g.handleClick("", 2, 7); + expect(click.move).to.equal("IG1C"); + click = g.handleClick(click.move, 2, 2); + expect(click.move).to.equal("IG1C>IB1C"); + expect(click.complete).to.equal(0); + click = g.handleClick(click.move, 2, 8); + expect(click.move).to.equal("IG1C+CG1CL>IB1C"); + expect(click.complete).to.equal(1); + click = g.handleClick("", -1, -1, "GC"); + expect(click.move).to.equal("CGR"); + click = g.handleClick(click.move, 0, 7); + expect(click.move).to.equal("CGR-G3L"); + expect(click.complete).to.equal(0); + click = g.handleClick(click.move, 1, 8); + expect(click.move).to.equal("CGR-G3L-G2L"); + expect(click.complete).to.equal(1); + // clicking the square the cavalry was just shown on leaves the move as it is + click = g.handleClick("CGR-G3L", 0, 7); + expect(click.move).to.equal("CGR-G3L"); + expect(click.valid).to.be.true; + expect(click.complete).to.equal(0); + // a square two steps away can be clicked directly; the engine supplies the path + click = g.handleClick("CGR", 1, 8); + expect(click.move).to.equal("CGR-G3L-G2L"); + expect(click.complete).to.equal(1); + place(g, "1C1", "G1CR"); + click = g.handleClick("CG1CR", 1, 6); + expect(click.move).to.equal("CG1CR-G2CR"); + expect(click.complete).to.equal(0); + click = g.handleClick("CG1CR", 1, 7); + expect(click.move).to.match(/^CG1CR-[A-Z0-9]+-G2CL$/); + expect(click.complete).to.equal(1); + }); + + it("builds retreat chains and reserve losses from clicks", () => { + const g = setup({ "1C1": "G1C", "1I1": "G1CL", "2C1": "B1C", "2I1": "B2CL", "2I2": "B2CR" }); + g.move("CG1C+IG1CL>CB1C"); + let click = g.handleClick("", 1, 2); + expect(click.move).to.equal("retreat:CB1C-B2CL"); + expect(click.complete).to.equal(-1); + click = g.handleClick(click.move, -1, -1, "_reserves_N"); + expect(click.move).to.equal("retreat:CB1C-B2CL,IB2CL-BR"); + expect(click.complete).to.equal(1); + // a piece framed as just arrived, or faded as about to be lost, is still the piece it shows + const f = setup({ "1I1": "G1C", "2I1": "B1C" }); + ["B2L", "B2CL", "B2CR", "B2R", "B1L", "B1CL", "B1CR", "B1R"].forEach((cell, i) => place(f, `2I${i + 2}`, cell)); + f.move("IG1C>IB1C"); + f.move("retreat:IB1C-BR"); + expect(f.currplayer).to.equal(2); + expect((f.render().areas![1] as { pieces: string[] }).pieces.filter(k => k === "BIe").length).to.equal(1); + click = f.handleClick("", -1, -1, "BIe"); + expect(click.valid).to.be.false; + expect(click.message).to.contain("retreated and may not move"); + click = f.handleClick("", -1, -1, "BCx"); + expect(click.move).to.equal("CBR"); + + const h = setup({ "1I1": "B3C" }); + h.move("IB3C>BR"); + click = h.handleClick("", -1, -1, "BA"); + expect(click.move).to.equal("lose:A"); + expect(click.complete).to.equal(1); + }); + + it("renders the dvgc board with both reserves, each player's side at the bottom and Gray's view the default", () => { + const g = setup({ "1I1": "G1C", "2C1": "B1C" }); + const rep = g.render({ perspective: 1 }); + expect(rep.board).to.deep.equal({ style: "dvgc" }); + expect(rep.pieces).to.equal("-,-,-,-,-,-,-,-,-,-\n-,-,-,-,-,-,-,-,-,-\n-,-,BC,-,-,-,-,GI,-,-"); + expect(Object.keys(rep.legend!)).to.have.members(["GI", "GA", "GC", "BI", "BA", "BC"]); + expect(rep.areas!.length).to.equal(2); + const south = rep.areas![0] as { side: string; pieces: string[] }; + expect(south.side).to.equal("S"); + expect(south.pieces.length).to.equal(15); + expect(south.pieces.every(k => k.startsWith("G"))).to.be.true; + expect(g.render({ perspective: 2 }).board).to.deep.equal({ style: "dvgc", rotate: 180 }); + expect(g.render().board).to.deep.equal({ style: "dvgc" }); + expect(g.getCustomRotation()).to.equal(180); + }); + + it("writes a move log that matches the legacy formatter", () => { + const g = setup({ "1I1": "G1C", "1A1": "G1CL", "2I1": "B1C", "2C1": "B1L" }); + g.move("IG1C+AG1CL>IB1C"); + g.move("stand"); + g.move("option1"); + g.move("retreat:IB1C-BR"); + g.move("advance"); + g.move("CB1L-B2L"); + g.move("pass"); + const names = ["Alice", "Bob"]; + assertChatLogParity(g, names); + const text = g.chatLog(names).flat().join("\n"); + expect(text).to.contain("Alice's infantry at G1C attacked B1C."); + expect(text).to.contain("supported by artillery at G1CL"); + expect(text).to.contain("Bob's unit at B1C stands its ground."); + expect(text).to.contain("Alice's artillery retreated from G1CL to GR."); + expect(text).to.contain("Bob's infantry retreated from B1C to BR."); + expect(text).to.contain("Alice's infantry advanced from G1C into B1C."); + expect(text).to.contain("Bob moved cavalry from B1L to B2L."); + expect(text).to.contain("Alice passed and will take a double turn next."); + }); + }); + + describe("interface helpers", () => { + it("adds a second cavalry from the reserve by clicking the strip again", () => { + const g = new SquaresGame(); + let click = g.handleClick("", -1, -1, "GC"); + click = g.handleClick(click.move, 0, 7); + expect(click.move).to.equal("CGR-G3L"); + click = g.handleClick(click.move, -1, -1, "GC"); + expect(click.move).to.equal("CGR-G3L,CGR"); + expect(click.complete).to.equal(-1); + click = g.handleClick(click.move, 0, 6); + expect(click.move).to.equal("CGR-G3L,CGR-G3C"); + expect(click.complete).to.equal(1); + }); + + it("labels every button with a key that has English text", () => { + const labels = new Set(); + const collect = (g: SquaresGame): void => g.getButtons().forEach(b => labels.add(b.label)); + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + collect(g); + g.move("IG1C+CG1CL>IB1C"); + collect(g); + g.move("stand"); + collect(g); + const advance = g.clone(); + advance.move("option2", { partial: true }); + collect(advance); + const forest = setup({ "1I1": "G1R", "1C1": "G2R", "2I1": "EF" }); + forest.move("IG1R+CG2R>IEF"); + collect(forest); + const reserve = setup({ "1I1": "B3C" }); + reserve.move("IB3C>BR"); + collect(reserve); + const keys = ["pass", "stand", "retreatReserve", "retreat", "option1", "option2", "option3", "option4", "option5", "advance", "stay", "loseI", "loseA", "loseC"]; + expect([...labels]).to.have.members(keys.map(k => k === "pass" ? "apgames:buttons.pass" : `apgames:buttons.squares.${k}`)); + for (const label of labels) { + expect(i18next.exists(label), label).to.be.true; + } + }); + + it("offers buttons for every combat decision and finishes option prefixes by clicking", () => { + const g = new SquaresGame(); + expect(g.getButtons()).to.deep.equal([{ label: "apgames:buttons.pass", move: "pass" }]); + place(g, "1I1", "G1C"); + place(g, "1C1", "G1CL"); + place(g, "2I1", "B1C"); + g.move("IG1C+CG1CL>IB1C"); + expect(g.getButtons().map(b => [b.label, b.move])).to.deep.equal([["apgames:buttons.squares.stand", "stand"], ["apgames:buttons.squares.retreatReserve", "retreat:IB1C-BR"]]); + g.move("stand"); + const buttons = g.getButtons(); + expect(buttons.map(b => b.label)).to.deep.equal(["apgames:buttons.squares.option1", "apgames:buttons.squares.option2", "apgames:buttons.squares.option3", "apgames:buttons.squares.option4", "apgames:buttons.squares.option5"]); + expect(buttons[0].move).to.equal("option1:CG1CL-G2CL"); + expect(buttons[4].move).to.equal("option5:CG1CL-G2CL"); + expect(g.validateMove("option1").complete).to.equal(-1); + expect(g.validateMove("option5:").complete).to.equal(-1); + const click = g.handleClick("option1", 1, 7); + expect(click.move).to.equal("option1:CG1CL-G2CL"); + expect(click.complete).to.equal(1); + // option 2 leaves the advance to decide (staying put unless told otherwise); the previewed position offers it as buttons + expect(g.validateMove("option2").complete).to.equal(0); + const preview = g.clone(); + preview.move("option2", { partial: true }); + expect(preview.getButtons().map(b => [b.label, b.move])).to.deep.equal([["apgames:buttons.squares.advance", "option2/advance"], ["apgames:buttons.squares.stay", "option2/stay"]]); + expect(g.validateMove("option2/stay").complete).to.equal(0); + const done = g.clone(); + done.move("option2/stay", { partial: true }); + expect(done.getButtons()).to.deep.equal([]); + g.move("option2/stay"); + expect(g.getButtons()).to.deep.equal([{ label: "apgames:buttons.pass", move: "pass" }]); + const h = setup({ "1I1": "B3C" }); + h.move("IB3C>BR"); + expect(h.getButtons().map(b => [b.label, b.move])).to.deep.equal([["apgames:buttons.squares.loseI", "lose:I"], ["apgames:buttons.squares.loseA", "lose:A"], ["apgames:buttons.squares.loseC", "lose:C"]]); + expect(h.validateMove("lose:").complete).to.equal(-1); + h.move("pass".slice(0, 0) + "lose:C"); + expect(h.getButtons()).to.deep.equal([{ label: "apgames:buttons.pass", move: "pass" }]); + }); + + it("folds the attacker's own decisions into the attack's ply, entered by clicks or buttons", () => { + const g = setup({ "1I1": "G1C", "1A1": "G1CL", "2A1": "B1C" }); + // the artillery must stand, so the attack alone is not a whole ply + expect(g.validateMove("IG1C+AG1CL>AB1C").complete).to.equal(-1); + expect(g.moves().filter(m => m.startsWith("IG1C+AG1CL>AB1C"))).to.have.members([ + "IG1C+AG1CL>AB1C/option1", "IG1C+AG1CL>AB1C/option2/advance", "IG1C+AG1CL>AB1C/option2/stay", + "IG1C+AG1CL>AB1C/option3", "IG1C+AG1CL>AB1C/option4", "IG1C+AG1CL>AB1C/option5", + ]); + // clicking the defender marks it for elimination; the next click says how the attack ends + let click = g.handleClick("IG1C+AG1CL>AB1C", 2, 2); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option"); + expect(click.complete).to.equal(-1); + expect(click.message).to.contain("will be eliminated"); + const marked = g.clone(); + marked.move(click.move, { partial: true }); + expect(marked.render().pieces!.split("\n")[2].split(",")[2]).to.equal("BAx"); + expect(marked.render().annotations).to.deep.include({ type: "exit", targets: [{ row: 2, col: 2 }] }); + // the defender again: option 1 + click = g.handleClick(click.move, 2, 2); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option1"); + expect(click.complete).to.equal(0); + expect(click.message).to.contain("Option 1"); + // the support after the defender: option 2, which leaves the advance to decide + click = g.handleClick("IG1C+AG1CL>AB1C/option", 2, 8); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option2"); + expect(click.complete).to.equal(0); + expect(click.message).to.contain("click it to advance"); + click = g.handleClick(click.move, 2, 2); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option2/advance"); + expect(click.complete).to.equal(0); + click = g.handleClick(click.move, 2, 7); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option2/stay"); + // the attacker after the defender: option 3 + click = g.handleClick("IG1C+AG1CL>AB1C/option", 2, 7); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option3"); + expect(click.complete).to.equal(0); + // the attacker or the support straight away: options 4 and 5 + click = g.handleClick("IG1C+AG1CL>AB1C", 2, 7); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option4"); + click = g.handleClick("IG1C+AG1CL>AB1C", 2, 8); + expect(click.move).to.equal("IG1C+AG1CL>AB1C/option5"); + expect(click.complete).to.equal(0); + // any other square starts the decision again + expect(g.handleClick("IG1C+AG1CL>AB1C/option3", 0, 0).move).to.equal("IG1C+AG1CL>AB1C"); + expect(g.handleClick("IG1C+AG1CL>AB1C/option2/advance", 1, 1).move).to.equal("IG1C+AG1CL>AB1C"); + expect(g.handleClick("IG1C+AG1CL>AB1C/option", 1, 1).move).to.equal("IG1C+AG1CL>AB1C"); + expect(g.handleClick("IG1C+AG1CL>AB1C", 0, 0).move).to.equal("IG1C+AG1CL>AB1C"); + // the previewed position offers the pending decisions as buttons that continue the ply + const preview = g.clone(); + preview.move("IG1C+AG1CL>AB1C", { partial: true }); + expect(preview.getButtons().map(b => b.move)).to.deep.equal([1, 2, 3, 4, 5].map(n => `IG1C+AG1CL>AB1C/option${n}`)); + const advance = g.clone(); + advance.move("IG1C+AG1CL>AB1C/option2", { partial: true }); + expect(advance.getButtons().map(b => [b.label, b.move])).to.deep.equal([["apgames:buttons.squares.advance", "IG1C+AG1CL>AB1C/option2/advance"], ["apgames:buttons.squares.stay", "IG1C+AG1CL>AB1C/option2/stay"]]); + expect(advance.render().annotations).to.deep.include({ type: "exit", targets: [{ row: 2, col: 2 }] }); + // decisions in the wrong place are refused with a reason + expect(g.validateMove("IG1C/option2").message).to.contain("too early"); + expect(g.validateMove("IG1C-G2CL/advance").message).to.contain("not needed"); + expect(g.validateMove("IG1C+AG1CL>AB1C/option1/advance").message).to.contain("not needed"); + expect(g.validateMove("IG1C+AG1CL>AB1C/stand").valid).to.be.false; + // a ply completed with the advance undecided stays put + const stays = g.clone(); + stays.move("IG1C+AG1CL>AB1C/option2"); + expect(stays.unit("1I1").loc).to.equal("G1C"); + expect(alive(stays, "2A1")).to.be.false; + expect(stays.currplayer).to.equal(2); + // the whole thing is one ply, by the attacker + g.move("IG1C+AG1CL>AB1C/option2/advance"); + expect(g.unit("1I1").loc).to.equal("B1C"); + expect(alive(g, "1A1")).to.be.false; + expect(alive(g, "2A1")).to.be.false; + expect(g.stack.length).to.equal(2); + expect(g.getPlies().map(p => [p.actor, p.move])).to.deep.equal([[1, "IG1C+AG1CL>AB1C/option2/advance"]]); + expect(g.currplayer).to.equal(2); + }); + + it("lets a cavalry support's retreat square be clicked after stepping to option 1 or 5", () => { + const g = setup({ "1I1": "G1CL", "1C1": "G1C", "2A1": "B1CR" }); + expect(g.moves().filter(m => m.startsWith("IG1CL+CG1C>AB1CR/option5"))).to.have.members(["IG1CL+CG1C>AB1CR/option5:CG1C-G2CR", "IG1CL+CG1C>AB1CR/option5:CG1C-G2CL"]); + // the support straight away: option 5, then its square + let click = g.handleClick("IG1CL+CG1C>AB1CR", 2, 7); + expect(click.move).to.equal("IG1CL+CG1C>AB1CR/option5:"); + expect(click.complete).to.equal(-1); + click = g.handleClick(click.move, 1, 6); + expect(click.move).to.equal("IG1CL+CG1C>AB1CR/option5:CG1C-G2CR"); + expect(click.complete).to.equal(0); + // the defender twice: option 1, then the square + click = g.handleClick("IG1CL+CG1C>AB1CR", 2, 1); + expect(click.move).to.equal("IG1CL+CG1C>AB1CR/option"); + click = g.handleClick(click.move, 2, 1); + expect(click.move).to.equal("IG1CL+CG1C>AB1CR/option1:"); + expect(click.complete).to.equal(-1); + click = g.handleClick(click.move, 1, 7); + expect(click.move).to.equal("IG1CL+CG1C>AB1CR/option1:CG1C-G2CL"); + expect(click.complete).to.equal(0); + // a square that is no retreat square starts the decision again; a bad one nearby is explained + expect(g.handleClick("IG1CL+CG1C>AB1CR/option5:", 0, 0).move).to.equal("IG1CL+CG1C>AB1CR"); + expect(g.handleClick("IG1CL+CG1C>AB1CR/option5:", 2, 6).valid).to.be.false; + const preview = g.clone(); + preview.move("IG1CL+CG1C>AB1CR/option5:CG1C-G2CL", { partial: true }); + expect(preview.render().annotations).to.deep.include({ type: "move", targets: [{ row: 2, col: 7 }, { row: 1, col: 7 }], style: "dashed" }); + g.move("IG1CL+CG1C>AB1CR/option5:CG1C-G2CL"); + expect(g.unit("1C1").loc).to.equal("G2CL"); + expect(alive(g, "2A1")).to.be.true; + expect(g.currplayer).to.equal(2); + }); + + it("adds the advance to a ply whose defender was swept aside, by clicking the target or the attacker", () => { + const g = setup({ "1C1": "B3C", "1I1": "B1C", "2I1": "B2CL" }); + expect(g.validateMove("CB3C+IB1C>IB2CL").complete).to.equal(0); + let click = g.handleClick("CB3C+IB1C>IB2CL", 1, 2); + expect(click.move).to.equal("CB3C+IB1C>IB2CL/advance"); + expect(click.complete).to.equal(0); + click = g.handleClick(click.move, 0, 1); + expect(click.move).to.equal("CB3C+IB1C>IB2CL/stay"); + // there is no option to choose here, so any other square just starts again + expect(g.handleClick(click.move, 2, 2).move).to.equal("CB3C+IB1C>IB2CL"); + const preview = g.clone(); + preview.move("CB3C+IB1C>IB2CL", { partial: true }); + expect(preview.getButtons().map(b => b.move)).to.deep.equal(["CB3C+IB1C>IB2CL/advance", "CB3C+IB1C>IB2CL/stay"]); + expect(preview.render().annotations).to.deep.include({ type: "exit", targets: [{ row: 1, col: 2 }] }); + }); + + it("explains why a retreat square is not allowed", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + g.move("IG1C+CG1CL>IB1C"); + expect(g.validateMove("retreat:IB1C-B2CL").message).to.contain("retreat to their reserve"); + expect(g.validateMove("retreat:IB1C-WF").message).to.contain("attacked while in a forest"); + // lone cavalry attacking cavalry: the defender may stand or take the one vacant square + const h = setup({ "1C1": "G1C", "2C1": "B1C", "2I1": "B2CL" }); + h.move("CG1C>CB1C"); + expect(h.moves()).to.have.members(["stand", "retreat:CB1C-B2CR"]); + expect(h.validateMove("retreat:CB1C-B2CL,IB2CL-BR").message).to.contain("vacant"); + expect(h.validateMove("retreat:CB1C-G1CR").message).to.contain("not closer"); + expect(h.validateMove("retreat:CB1C-B3C").message).to.contain("touching"); + expect(h.validateMove("retreat:CB1C-B2CR").valid).to.be.true; + // supported attack with two vacant squares behind: the cavalry must retreat + const k = setup({ "1C1": "G1C", "1I1": "G1CL", "2C1": "B1C" }); + k.move("CG1C+IG1CL>CB1C"); + expect(k.moves()).to.have.members(["retreat:CB1C-B2CL", "retreat:CB1C-B2CR"]); + expect(k.validateMove("stand").message).to.contain("must retreat"); + }); + + it("previews selections, attacks and reserve-bound retreats", () => { + const g = setup({ "1I1": "G1C", "1C1": "G1CL", "2I1": "B1C" }); + expect(g.validateMove("IG1C").canrender).to.be.true; + const sel = g.clone(); + sel.move("IG1C", { partial: true }); + expect(sel.render().annotations).to.deep.include({ type: "enter", targets: [{ row: 2, col: 7 }] }); + const atk = g.clone(); + atk.move("IG1C>IB1C", { partial: true }); + expect(atk.render().annotations).to.deep.include({ type: "move", targets: [{ row: 2, col: 7 }, { row: 2, col: 2 }], style: "solid" }); + const sup = g.clone(); + sup.move("IG1C+CG1CL>IB1C", { partial: true }); + expect(sup.render().annotations).to.deep.include({ type: "move", targets: [{ row: 2, col: 8 }, { row: 2, col: 2 }], style: "dashed" }); + const r = g.clone(); + r.move("IG1C>IB1C"); + r.move("retreat:IB1C-BR"); + const rep = r.render({ perspective: 2 }); + expect(rep.board).to.deep.equal({ style: "dvgc", rotate: 180, markers: [{ type: "edge", edge: "N", colour: r.getPlayerColour(2) }] }); + expect(rep.annotations).to.deep.include({ type: "exit", targets: [{ row: 2, col: 2 }] }); + expect(rep.annotations!.some(a => a.type === "move")).to.be.false; + // the unit that came home is framed in its strip + const strip = rep.areas![1] as { pieces: string[] }; + expect(strip.pieces.filter(k => k === "BIe").length).to.equal(1); + expect(strip.pieces.length).to.equal(16); + expect(rep.legend!.BIe).to.be.an("array"); + expect(JSON.stringify(rep.annotations)).to.not.contain("#c00"); + const out = new SquaresGame(); + out.move("IGR-G3C"); + expect(out.render().board).to.deep.equal({ style: "dvgc", markers: [{ type: "edge", edge: "S", colour: out.getPlayerColour(1) }] }); + expect(out.render().annotations).to.deep.include({ type: "enter", targets: [{ row: 0, col: 6 }] }); + }); + + it("paints every legend glyph through its slots rather than the deprecated colour field", () => { + const g = setup({ "1I1": "G1R", "1C1": "G2R", "2I1": "EF" }); + const preview = g.clone(); + preview.move("IG1R>IEF", { partial: true }); + const played = g.clone(); + played.move("IG1R+CG2R>IEF"); + played.move("retreat:IEF-BR"); + const parts = [g, preview, played].flatMap(game => Object.values(game.render().legend!).flat()) as { colour?: unknown; paint?: unknown }[]; + expect(parts.length).to.be.greaterThan(6); + expect(parts.every(part => part.colour === undefined && part.paint !== undefined)).to.be.true; + }); + + it("fades a unit that the move being entered would lose, instead of removing it", () => { + const g = setup({ "1I1": "G1R", "1C1": "G2R", "2I1": "EF" }); + // an unsupported attack on a forest costs the attacker, so the preview shows it going + expect(g.validateMove("IG1R>IEF").complete).to.equal(0); + const preview = g.clone(); + preview.move("IG1R>IEF", { partial: true }); + const rep = preview.render(); + expect(rep.legend!.GIx).to.deep.equal({ name: "nato-infantry", paint: { fill: g.getPlayerColour(1) }, opacity: 0.5 }); + expect(rep.pieces!.split("\n")[2].split(",")[5]).to.equal("GIx"); + expect(rep.annotations).to.deep.include({ type: "exit", targets: [{ row: 2, col: 5 }] }); + // with support added, the attacker is back in full + const supported = g.clone(); + supported.move("IG1R+CG2R>IEF", { partial: true }); + expect(supported.render().pieces).to.not.contain("GIx"); + // played for real, it is gone + g.move("IG1R>IEF"); + expect(g.render().pieces).to.not.contain("GIx"); + expect(g.render().legend).to.not.have.property("GIx"); + // a forced loss from the reserve fades in the strip + const h = new SquaresGame(); + evacuate(h, 2, ["B2L", "B2CL", "B2CR", "B2R", "B1L", "B1CL", "B1C", "B1CR", "B1R", "G2L", "G2R", "G3R", "NEF", "NWF", "EF"]); + place(h, "1I1", "B3C"); + commit(h); + const strip = h.clone(); + strip.move("IB3C>BR", { partial: true }); + const north = strip.render().areas![1] as { pieces: string[] }; + expect(north.pieces).to.deep.equal(["BCx"]); + }); + + it("shows a unit that has entered the enemy reserve inside that reserve", () => { + const g = new SquaresGame(); + evacuate(g, 2, ["B2L", "B2CL", "B2CR", "B2R", "B1L", "B1CL", "B1C", "B1CR", "B1R", "G2L", "G2R", "G3R", "NEF", "NWF", "EF", "WF"]); + expect(g.reserveUnits(2).length).to.equal(0); + place(g, "1I1", "B3C"); + commit(g); + g.move("IB3C-BR"); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1]); + const rep = g.render(); + const areas = rep.areas as { side: string; pieces: string[] }[]; + expect(areas[1].side).to.equal("N"); + // framed, since it arrived this ply + expect(areas[1].pieces).to.deep.equal(["GIe"]); + const frame = rep.legend!.GIe as { name: string; paint: { fill: { opacity?: number } } }[]; + expect(frame.map(part => part.name)).to.deep.equal(["piece-square-dashed", "nato-infantry"]); + expect(frame[0].paint.fill.opacity).to.equal(0); + expect(g.render({ perspective: 2 }).areas![1]).to.deep.include({ pieces: ["GIe"] }); + expect(areas[0].pieces.length).to.equal(15); + }); + + it("reports losses with glyphs and points with unit counts", () => { + const g = setup({ "1I1": "G1C", "2I1": "B1C" }); + g.move("IG1C>IB1C"); + g.move("stand"); + expect(JSON.stringify(g.sidebarStatuses())).to.contain('{"kind":"sheet","name":"nato-infantry"'); + expect(g.sidebarScores()[0].scores).to.deep.equal(["1 (1)", "1 (1)"]); + }); + + it("draws by threefold repetition", () => { + const g = setup({ "1C1": "G1C", "1I1": "G2R", "2C1": "B1C", "2I1": "B2L" }); + const cycle = ["CG1C-G1CL", "CB1C-B1CL", "CG1CL-G1C", "CB1CL-B1C", "IG2R-G2CR", "IB2L-B2CL", "IG2CR-G2R", "IB2CL-B2L"]; + let plies = 0; + while (!g.gameover && plies < 40) { + g.move(cycle[plies % cycle.length]); + plies += 1; + } + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([1, 2]); + expect(plies).to.equal(18); + expect(g.stack[g.stack.length - 1]._results).to.deep.include({ type: "eog", reason: "repetition" }); + }); + }); + + describe("annotation safety", () => { + const sameEnds = (g: SquaresGame): boolean => (g.render().annotations ?? []).some(a => + a.type === "move" && a.targets.length === 2 && a.targets[0].row === a.targets[1].row && a.targets[0].col === a.targets[1].col); + + it("never draws an arrow from a square to itself", () => { + // out of the reserve straight onto the back line, previewed and played + const g = new SquaresGame(); + const preview = g.clone(); + preview.move("IGR-G3C", { partial: true }); + expect(sameEnds(preview)).to.be.false; + g.move("IGR-G3C"); + expect(sameEnds(g)).to.be.false; + // an attack on a reserve from the back line, previewed, pending and resolved + const h = setup({ "1I1": "B3C", "1I2": "B3L" }); + const hp = h.clone(); + hp.move("IB3C+IB3L>BR", { partial: true }); + expect(sameEnds(hp)).to.be.false; + h.move("IB3C+IB3L>BR"); + expect(sameEnds(h)).to.be.false; + h.move("lose:A"); + expect(sameEnds(h)).to.be.false; + // a retreat into the reserve from the back line itself + const k = setup({ "1I1": "B3C", "2I1": "B2CL", "1C1": "B1C" }); + const kp = k.clone(); + kp.move("IB3C+CB1C>IB2CL", { partial: true }); + expect(sameEnds(kp)).to.be.false; + k.move("IB3C+CB1C>IB2CL/stay"); + expect(sameEnds(k)).to.be.false; + const m = setup({ "1I1": "G1C", "2I1": "B3C" }); + m.move("IG1C-G2CL"); + m.move("IB3C-BR"); + expect(sameEnds(m)).to.be.false; + expect(m.render().board).to.deep.include({ markers: [{ type: "edge", edge: "N", colour: m.getPlayerColour(2) }] }); + }); + }); +});