From 1ef5b2371226364b48488af0cbb4c36d27259a7a Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 26 Oct 2026 16:25:07 -0400 Subject: [PATCH] Wyrm begins: Gygax's Dragonchess transcribed from Dragon No. 100, the three boards and fifteen pieces as an engine, and the drake to play them Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx --- docs/source.md | 703 +++++++++++++++++++++++++++++++++++++ src/lib/game/board.ts | 363 +++++++++++++++++++ src/lib/game/bot.ts | 151 ++++++++ src/lib/game/index.test.ts | 367 +++++++++++++++++-- src/lib/game/index.ts | 253 +++++++++---- 5 files changed, 1751 insertions(+), 86 deletions(-) create mode 100644 docs/source.md create mode 100644 src/lib/game/board.ts create mode 100644 src/lib/game/bot.ts diff --git a/docs/source.md b/docs/source.md new file mode 100644 index 0000000..30d6bce --- /dev/null +++ b/docs/source.md @@ -0,0 +1,703 @@ +# The source text + +Wyrm follows "Dragonchess" by Gary Gygax, Dragon Magazine No. 100 (volume +X, number 3), August 1985, pages 34 to 40, published by TSR, Inc. The +keeper owns the issue. This transcription was made from photographs of it +with Apple's text recognition and then read against the pages by hand. The +movement diagrams are described in words after each piece; where a +diagram and the text disagree, or the text disagrees with itself, the +section "Decisions" at the end says what the engine does. + +Page breaks and the magazine's running heads are left out. The article's +own headings are kept. Nothing has been reworded. + +--- + +## DRAGONCHESS + +by Gary Gygax + +The DRAGONCHESS™ game is a fantasy-based variation of chess, in which the +object of the game (as in standard chess) is to checkmate the opposing +King. An understanding of the rules of chess is helpful in order to +understand how DRAGONCHESS is played. + +The playing surface for DRAGONCHESS is a three-tiered structure of +rectangular boards, with each board composed of a grid 12 squares wide and +8 squares deep. The squares are alternately colored light and dark (or +other color tones that contrast with each other), configured so that a +light-colored square is in the near right-hand corner when the board is +viewed from the longer side. The upper board represents the air, the +middle board the land, and the lower board the subterranean world. To +simulate the various environments, it is suggested that the squares on the +upper board be colored light blue and white; on the middle board, light +green and amber; and on the lower board, red and brown. Each board is the +starting place for pieces particular to its environment; some pieces +cannot move up or down off the board they begin on, but other pieces can +travel between two or even all three boards. The opposing forces are +designated as Gold and Scarlet, with Gold always moving first. + +### Setting up + +At the start of the game, each player's pieces are positioned as shown in +the accompanying diagram. Each force on the upper board consists of six +Sylphs, two Griffons, and one Dragon. On the middle board, each side +possesses one King, one Mage, one Paladin, one Cleric, two Heroes, two +Unicorns, two Oliphants, and twelve Warriors. Each side's force on the +lower board is made up of six Dwarves, two Basilisks, and one Elemental. +In all, there are 42 pieces per side, or a total of 84 pieces in the game. + +The diagram "Starting position, upper board", Scarlet at the top and Gold +at the bottom, files a to l left to right: + + 8 . . G . . . Dr . . . G . + 7 S . S . S . S . S . S . + 6 . . . . . . . . . . . . + 5 . . . . . . . . . . . . + 4 . . . . . . . . . . . . + 3 . . . . . . . . . . . . + 2 S . S . S . S . S . S . + 1 . . G . . . Dr . . . G . + a b c d e f g h i j k l + +"Starting position, middle board": + + 8 O U H T C M K P T H U O + 7 W W W W W W W W W W W W + 6 . . . . . . . . . . . . + 5 . . . . . . . . . . . . + 4 . . . . . . . . . . . . + 3 . . . . . . . . . . . . + 2 W W W W W W W W W W W W + 1 O U H T C M K P T H U O + a b c d e f g h i j k l + +"Starting position, lower board": + + 8 . . B . . . E . . . B . + 7 . D . D . D . D . D . D + 6 . . . . . . . . . . . . + 5 . . . . . . . . . . . . + 4 . . . . . . . . . . . . + 3 . . . . . . . . . . . . + 2 . D . D . D . D . D . D + 1 . . B . . . E . . . B . + a b c d e f g h i j k l + +### Notation and terminology + +In the following descriptions of how pieces move and capture, squares on +the board(s) are described in algebraic notation, with files (columns) +labeled "a" through "l" reading left to right from Gold's viewpoint, and +ranks (rows) numbered 1 (nearest to the Gold player) through 8 (nearest to +the Scarlet player). A numerical prefix of 1 (upper board), 2 (middle +board), or 3 (lower board) designates the board on which a certain square +is located. Thus, "1a1" is the square in the near left-hand corner of the +upper board, as viewed from the Gold player's position, and "3l8" is the +square in the far right-hand corner of the lower board. Each piece is +identified by the initial letter of its name ("S" for Sylph, etc.) except +for the Dragon, which is identified by "Dr" to distinguish it from a Dwarf +piece. + +In all other respects, movement and capture notation is essentially the +same as for standard chess. A full description of this notation is given +near the end of this text. + +Standard chess pieces are referred to frequently in the descriptions of +how DRAGONCHESS pieces move and capture; in such cases, their names (king, +rook, etc.) are not capitalized, while the names of DRAGONCHESS pieces are +always capitalized. In the diagrams of movement that accompany the +description of each piece, "M" designates a square that a piece can move +into, if that square is unoccupied (i.e., no capture is permitted); "C" +designates a square that can be occupied only if a capture is involved; +and "X" designates a square that can be occupied either by a capturing or +a non-capturing move. + +Because many pieces can move in three dimensions, nomenclature for +movement between squares has been standardized as follows: "horizontal" +refers to a side-to-side move as viewed by the player moving the piece; +"vertical" is a move forward (toward the opponent's side of the board) or +backward (toward one's own side) along the file occupied by the piece; and +"up" and "down" refer to movement from one board to another. + +### THE UPPER BOARD + +**Sylph:** The Sylph has a move on the upper board which is essentially +the converse of that of a pawn. It moves forward only, one square +diagonally either to the left or right, and it can capture a piece on the +upper board which lies directly ahead of it by moving forward into that +square and supplanting the opposing piece that was located there. Thus, +every time a Sylph makes a capture on the upper board, it changes the +color of the diagonal along which it makes further (non-capturing) moves, +from dark to light or vice versa. Gold's Sylphs begin the game on the +light-colored squares of the second rank, while the Sylphs of the Scarlet +side start on the dark-colored squares of the seventh rank. + +In addition to its movement and capture powers on the upper board, the +Sylph can capture an opposing piece on the middle board which lies +directly below it. This is the only way in which the Sylph can move to the +middle board. It cannot move to, nor make a capture upon, the lower board. +Once located on the middle board, the Sylph cannot move except to return +to the upper board. It may do this by moving directly upward to the square +it previously occupied, or by moving upward to any of the six starting +squares for Sylphs of the appropriate color. A Sylph cannot capture by +means of an upward move; it must return to the upper board by moving into +an unoccupied square. + +A Sylph located at 3c4 can move to 3b5 or 3d5. It can occupy 3c5 or 2c4 by +capturing an opposing piece, if one is located on either of those squares. +After making a capture at 2c4, the Sylph can move back to 3c4 on a +subsequent turn, if that square is empty, or it can move up to any +unoccupied starting square. For a Gold Sylph, the legal destinations are +3a2, 3c2, 3e2, 3g2, 3i2, and 3k2. + +A Sylph that reaches the eighth rank on the upper board can move no +farther on that board, but retains the power to capture downward. If such +a capture is accomplished, the Sylph can regain mobility on the upper +board by moving up into one of the starting squares for Sylphs of the +appropriate color. + +*Diagram, "Sylph (upper board)": a Gold Sylph with M on the two squares +diagonally ahead of it and C on the square directly ahead.* + +**Griffon:** Starting squares for the Gold Griffons are 3c1 and 3k1; the +Scarlet Griffons begin at 3c8 and 3k8. When on the upper board, a Griffon +moves one square horizontally or vertically and two squares diagonally. +Like the knight, it vaults over intervening squares (even if they are +occupied) and only exerts control over the destination square, not the +intervening ones. (This is identical to the move of the elephant in Korean +chess.) A Griffon at 3d4 can move to or capture a piece at 3a2, 3b1, 3f1, +3g2, 3g6, 3f7, 3b7, or 3a6. + +In addition, a Griffon may occupy the middle board by a move down to one +of the four squares on the diagonal from the upper-board square it started +from. (Note that a Griffon located on the edge of the upper board only +commands two squares on the middle board instead of four.) A Griffon +returns to the upper board by a move to any of the four (or two) squares +diagonally above the Griffon. It can move between the upper and middle +boards whether or not a capture is involved. A Griffon at 3d4 can move to +or capture at 2c5, 2c3, 2e3, or 2e5. + +While a Griffon is on the middle board, it forfeits the power of flight, +and as such it can only move and capture in the squares diagonally +adjacent to it. A Griffon located at 2e5 commands all of the following +squares: 2f6, 2f4, 2d4, 2d6, 3f6, 3f4, 3d4, and 3d6. + +*Diagrams: "Griffon (upper board)" marks X on the eight squares two files +and three ranks away, or three files and two ranks away. "Griffon (middle +board)" marks X on the four diagonally adjacent squares.* + +**Dragon:** The starting square for the Gold Dragon is 3g1, and the Scarlet +Dragon begins at 3g8. A Dragon's movement and capture powers on the upper +board are a combination of the moves of the king and the bishop: unlimited +along the diagonals, or one square in any horizontal or vertical +direction. While the Dragon cannot move downward, it does have the unique +ability to capture an opposing piece on the middle board which lies in the +square directly beneath it or on any of the squares horizontally or +vertically adjacent to that square. The captured piece is removed from the +middle board, but the Dragon remains on the upper board. A Dragon at 3c4 +could capture "from afar" an opposing piece located at 2c4, 2c5, 2d4, 2c3, +or 2b4. + +*Diagram, "Dragon (upper board)": X along all four diagonals to the edge +and on the eight squares around the Dragon.* + +### THE MIDDLE BOARD + +**Warrior:** At the start of the game, the twelve Gold Warriors are +positioned along the second rank, and their Scarlet counterparts begin on +the seventh rank. The move of the Warrior is identical to that of a pawn, +except that a Warrior cannot advance two squares on its first move. The +Warrior can move into an unoccupied square vertically ahead of its +location, and it can capture an opposing piece located on either adjacent +diagonal square ahead of it. The Gold Warrior which starts the game at 2c2 +can move to 2c3, or it can occupy either 2b3 or 2d3 by capturing an +opposing piece located on one of those squares. A Warrior that reaches the +opponent's back rank is promoted to a Hero, in the same fashion that a +pawn is promoted. However, unlike standard chess, the owning player does +not have a choice; a pawn can be promoted to a queen, bishop, knight, or +rook, but a Warrior can only become a Hero. A Warrior cannot move to or +capture pieces located on the upper or lower boards. + +*Diagram, "Warrior (middle board)": M directly ahead, C on the two squares +diagonally ahead.* + +**Oliphant:** The Oliphants for each side start on the corner squares of +the middle board — 2a1 and 2l1 for Gold, 2a8 and 2l8 for Scarlet. The move +of an Oliphant is identical to that of a rook — that is, as many squares +horizontally or vertically as desired, as long as no piece interferes with +its path of movement. An Oliphant cannot move to or capture pieces located +on the upper or lower boards. + +*Diagram, "Oliphant (middle board)": X along the whole rank and file.* + +**Unicorn:** The Unicorns for each side start on the squares horizontally +adjacent to the Oliphants — 2b1 and 2k1 for Gold, 2b8 and 2k8 for Scarlet. +A Unicorn moves and captures in the same way as a knight, including the +ability to vault over pieces on intervening squares. It cannot move to or +capture pieces located on the upper or lower boards. + +*Diagram, "Unicorn (middle board)": X on the eight knight squares.* + +**Hero:** The Heroes for each side start adjacent to the Unicorns — 2c1 and +2j1 for Gold, 2c8 and 2j8 for Scarlet. On the middle board, a Hero moves +either one or two squares in any diagonal direction, with the ability to +vault over an intervening piece on a two-square move. Capture is +accomplished in the same manner. + +A Hero can also move up or down one board at a time, going to any square +diagonally adjacent to the square directly above or below its former +location. A move back to the middle board is accomplished the same way. +When located on the upper or lower board, a Hero's only move is to return +to the middle board; it cannot travel to any square other than the one to +which it ascended or descended. A move from one board to another can +involve a capture if so desired. + +*Diagram, "Hero (middle board)": X on the squares one and two steps away +along each diagonal.* + +**Thief:** The two Thief pieces for each side start the game adjacent to +the Heroes — 2d1 and 2i1 for Gold, 2d8 and 2i8 for Scarlet. A Thief moves +and captures exactly as a bishop, going any number of squares diagonally +as long as no piece interferes with its path of movement. It cannot move +to or capture pieces located on the upper and lower boards. + +*Diagram, "Thief (middle board)": X along all four diagonals.* + +**Cleric:** The Gold Cleric starts on 2e1, the Scarlet Cleric on 2e8. The +basic, two-dimensional move of a Cleric is the same as that of the king: +one square in any direction. In addition, a Cleric can move one square +directly up or down, and retains its full movement (and capture) abilities +on both the upper and lower boards. + +*Diagram, "Cleric (all boards)": X on the eight squares around the +Cleric.* + +**Mage:** The Mage for each side starts on the square adjacent to the +Cleric — 2f1 for Gold, 2f8 for Scarlet. On the middle board, a Mage moves +and captures in the same way as the queen, able to follow an unobstructed +path in any horizontal, vertical, or diagonal direction. In addition, a +Mage can move (and capture, if applicable) directly upward or downward +across one or more boards. However, a Mage on either the upper or lower +board is limited to a move of one square in a horizontal or vertical +direction. A Mage located at 2c4 could move up to 3c4, and on its next +move could go downward two boards to 1c4. If the Mage then remained on the +lower board, it could only move to 1c5, 1d4, 1c3, or 1b4. + +*Diagrams: "Mage (middle board)" marks X along the whole rank, file and +all four diagonals. "Mage (upper or lower board)" marks X on the four +orthogonally adjacent squares.* + +**King:** The King for each side begins on the square adjacent to the Mage +— 2g1 for Gold, 2g8 for Scarlet. The King moves and captures similarly to +a king in standard chess. In addition, the King may move (and capture) by +shifting one square directly up or down. A King which has moved to the +upper or lower board cannot move upon those boards, and must return to the +middle board before it can again move and capture normally. + +*Diagram, "King (middle board)": X on the eight squares around the King.* + +**Paladin:** The Paladin for each side begins in the square adjacent to the +King — 2h1 for Gold, 2h8 for Scarlet. On the middle board, a Paladin's +movement and capture abilities are a combination of those of a king and a +knight. It can also move upward or downward with a knight-like move, i.e. +one board up and two squares horizontally or vertically, or two boards up +and one square horizontally or vertically, and it can vault over +intervening pieces with this sort of move, just as if the move did not +involve a change of boards. Upon the upper or lower board, the Paladin is +limited to a king-like move (and capture) of one square in any direction. +A Paladin located at 3c4 can move between boards to any of the following +squares: 2c6, 2e4, 2c2, 2a4, 1c5, 1d4, 1c3, or 1b4. + +*Diagrams: "Paladin (middle board)" marks X on the eight squares around +the Paladin and the eight knight squares. "Paladin (upper or lower board)" +marks X on the eight squares around it.* + +### THE LOWER BOARD + +**Dwarf:** The six Gold Dwarves start on the dark-colored squares of the +second rank on the lower board; the six Scarlet Dwarves begin the game on +the light-colored squares of the seventh rank on the lower board. Similar +to a Warrior, a Dwarf can make a non-capturing move one square vertically +ahead, and captures ahead diagonally. In addition, a Dwarf can make a +non-capturing move one square in either horizontal direction, and it can +capture an opposing piece on the middle board that lies directly above the +Dwarf. If a Dwarf moves to the middle board by means of a capture, it +retains the movement and capture powers it has on the lower board, but a +Dwarf cannot move to or capture pieces located on the upper board. It may +return to the lower board by a move directly down to an unoccupied square; +i.e., the downward move cannot involve a capture. A Dwarf on 1d4 could move +(without capturing) into 1d5, 1c4, or 1e4; it could capture an opposing +piece located on 1c5, 1e5, or 2d4. A Dwarf on 2d4 could return to the +lower board by moving to 1d4, but only if that square is unoccupied. A +Dwarf that reaches the opponent's back rank is limited to horizontal moves +on the board it occupies, and also retains the ability to move between the +lower and middle boards in the manner described above. + +*Diagram, "Dwarf (lower board)": M directly ahead and on the two squares +beside the Dwarf, C on the two squares diagonally ahead.* + +**Basilisk:** The two Gold Basilisks start on 1c1 and 1k1; the Scarlet +Basilisks start on 1c8 and 1k8. A Basilisk moves one square at a time, +either ahead diagonally or vertically or backward vertically. It captures +by forward movement only. A Basilisk also has the unique ability of +"freezing" an opposing piece — but not a friendly piece — located directly +above it on the middle board, such that the opposing piece cannot move +until the Basilisk is moved or captured. (This "freezing" is automatic and +involuntary; the player owning the Basilisk cannot choose not to +immobilize the piece, and does not have to specifically declare that the +"freeze" is in effect.) If the opposing King is the piece being "frozen," +the King is checkmated if any other piece attacks it and the opponent is +unable to capture the attacking piece or interpose a piece to blunt the +attack. + +A Basilisk located on 1d4 can make a move or a capture into 1c5, 1d5, or +1e5. It can move to 1d3 if that square is unoccupied, and it automatically +"freezes" any opposing piece on 2d4, or one that is subsequently moved into +2d4. A Basilisk cannot move off the lower board or (with the exception of +the "freezing" power) capture a piece on any other board. + +*Diagram, "Basilisk (lower board)": X on the three squares ahead of the +Basilisk, M on the square behind it.* + +**Elemental:** The Gold Elemental starts on 1g1; the Scarlet Elemental +begins the game on 1g8. An Elemental can move one square diagonally or one +or two squares horizontally or vertically. It captures only on horizontal +or vertical moves. It may move up to the middle board to make a capture by +moving one square horizontally or vertically and then directly upward. It +may remain unmoving on the middle board if so desired, or it can return to +the lower board by a reverse of the upward move, i.e., directly down and +then one square horizontally or vertically. The upward move can only be +made if a capture is involved, but the downward move can be made without +capturing. + +An Elemental on 1d4 can move (but not capture) into 1c5, 1e5, 1e3, or 1c3. +It can move or capture into 1d5, 1d6, 1e4, 1f4, 1d3, 1d2, 1c4, or 1b4. It +can capture upward into 2d5, 2e4, 2d3, or 2c4. An Elemental on 2d4 can +move or capture downward into 1d5, 1e4, 1d3, or 1c4. + +*Diagram, "Elemental (lower board)": X on the first and second squares in +each of the four orthogonal directions, M on the four diagonally adjacent +squares.* + +### Movement between boards + +At first, it may be difficult for players to envision and properly execute +the upward or downward movement of pieces. However, "thinking in three +dimensions" will come easily after one or two practice games are played. +Perhaps the hardest single concept to grasp is the idea of intervening +squares on moves from one board to another. The general rule is this: If a +piece is unable to vault over intervening squares in its basic +(two-dimensional) move, then it is likewise unable to move between boards +if a piece blocks the path it must take. Specific applications for +particular pieces are as follows: + +The Griffon, the Hero, and the Paladin all have the knight-like ability to +vault intervening squares, and this applies on any upward or downward move +made by those pieces. (The square directly above or below the piece in +question need not be vacant.) + +The Mage can move from the lower board to the upper board (or vice versa) +in a single move, but it cannot vault intervening squares. Thus, in order +for the Mage to travel upward from 1d4 to 3d4, the square at 2d4 must be +vacant. + +The Elemental is something of a special case. Its move between boards is a +two-step process, and it cannot vault over intervening squares. Its upward +move begins with a shift of one square horizontally or vertically on the +lower board; thus, at least one of the squares horizontally or vertically +adjacent to the Elemental must be vacant in order for it to make an upward +move. The Elemental is even more restricted when moving down from the +middle board; since the horizontal or vertical move is preceded by a move +directly down, the square immediately beneath the Elemental must be vacant +in order for it to make a downward move. + +### Chess rules that do not apply + +Two conventions of standard chess are not part of the DRAGONCHESS rules. +The Warrior is unable to move two squares ahead on its first move, and as +such it is not possible for a Warrior to capture en passant as a pawn +does. Also, because of the multiple boards, greater area of play, and +larger number of men, castling is not possible in DRAGONCHESS. + +### Values of the pieces + +The complexities of multiple-board play and the varying powers of the +pieces in DRAGONCHESS makes absolute valuation far more difficult than it +is for standard chess pieces. In chess, a pawn is valued at 1, knights and +bishops at 3, rooks at 5, and the queen at 9. (If the king were not of +infinite value, its movement and capturing power would give it a value of +4.) The approximate relative values of the pieces in DRAGONCHESS is +detailed below, to give players an idea of which exchanges are beneficial +and which are not. For instance, the trade of a Hero for a Griffon is +roughly an even exchange, while the trade of a Basilisk for an Oliphant +will generally result in a material advantage for the player losing the +Basilisk. However, it should always be kept in mind that the true "value" +of a piece in any game situation depends primarily on the piece's location +and its role in the game, rather than on its theoretical movement and +capture powers. + +| Piece | Value | Piece | Value | +|---|---|---|---| +| King | ** | Thief | 4 | +| Mage | 11 | Elemental | 4 | +| Paladin | 10 | Basilisk | 3 | +| Cleric | 9 | Unicorn | 2½ | +| Dragon | 8 | Dwarf | 2 | +| Griffon | 5 | Sylph | 1 | +| Oliphant | 5 | Warrior | 1 | +| Hero | 4½ | | | + +\*\* — In practice, the value of the King is infinite, since the loss of +this piece means the loss of the game. + +### Movement and capture notation + +The method of recording moves in DRAGONCHESS is basically the same as for +standard chess, with certain differences (explained earlier) to account +for the different names of the pieces and the multiple boards. The +following examples cover all the situations that could occur in a game: + +1) W2f3 +2) S/3e2-3d3 + +A basic non-capturing move; notation 1 is the first non-capturing move +that the Gold Warrior starting at 2f2 can make. (Read it as "Warrior moves +to 2f3.") In most cases, a non-capturing move can be noted simply by +naming the type of piece being moved (W) and the square it moves to (2f3). +When more than one piece of the same type is capable of making the same +move, the moving piece is specifically identified by naming the square it +is moving from, using a "/" to indicate "at." (Read notation 2 as "Sylph +at 3e2 moves to 3d3.") In this notation, a hyphen is used to separate the +location square from the destination square. + +3) W2e3 +4) Wx2e3 +5) W/2d2x2e3 +6) WxU2e3 +7) WxU + +Five ways of describing a capture; the correct notation depends upon the +position of the pieces and, to some extent, the preference of the players. +Notation 3 can be used if only one Warrior is capable of moving to 2e3, +and if that move involves a capture. Notation 4 is more specific, using +the "x" to indicate a capture. (Read as "Warrior captures (unspecified +piece) at 2e3.") Notation 5 is necessary when more than one Warrior would +be capable of making a capture at 2e3, again using the "/" (as in notation +2 above) to indicate "at." (Read as "Warrior at 2d2 captures at 2e3.") +Notation 6 includes the identification of the type of piece captured — in +this case, the Unicorn located at 2e3. Notation 7, read simply as "Warrior +captures Unicorn," can be used if only one such capture is possible. In +most cases, the proper notation would be 4, 5, or 6, or a minor variation +on one of those. The important thing is to describe each move in such a +way that the notation could only refer to one specific move by one +specific piece. This will avoid possible confusion if the moves of a game +are to be studied or replayed. + +8) W2f8(H) + +The move and promotion of a Warrior. (Read as "Warrior moves to 2f8 and is +promoted to a Hero.") + +9) Drx2c4 + +A capture "from afar" by a Dragon. (Read as "Dragon captures (unspecified +piece) at 2c4.") To avoid the awkward "Drx" usage, this move could be +written as "Dr2c4" since any move by the Dragon involving a square on the +middle board must necessarily be a capture "from afar." + +10) UxOch + +Illustrating the use of "ch" to indicate a move that puts the opposing +King in check. (Read as "Unicorn captures Oliphant with check.") + +Other symbols: dbl ch, dis ch, mate + +These three terms, taken from standard chess notation, indicate certain +special occurrences. Double check (dbl ch) occurs whenever a move enables +one or more pieces to give check at the same time. Discovered check (dis +ch) occurs when the move of one piece opens a path that enables a +different piece to give check. And "mate," of course, is checkmate — noted +on the final move of a game, when the King is attacked in such a way that +it cannot avoid being captured on the attacker's next move. + +No special notation is given for the Basilisk's "freezing" power, since it +is not technically a move or a capture, and since it is automatically +assumed that any piece occupying the square above the Basilisk is frozen. +Thus, a notation of B1e3 makes it clear that (a) the Basilisk now occupies +square 1e3 and (b) any opposing piece located at 2e3 is thereby +immobilized as long as the Basilisk remains where it is. + +### Strategic and tactical tips + +No single piece of text — even one many times longer than this — could +fully describe and analyze the myriad positions and situations that can +occur in DRAGONCHESS. However, some general strictures are apparent from +an examination of how the various pieces move and capture, and certain +basic facts of chess strategy apply equally to DRAGONCHESS. + +Mobility is all-important, and, just as in chess, control of the central +squares is usually a prerequisite for victory. The rectangular board of +DRAGONCHESS makes the "central squares" area somewhat more difficult to +define than it is in chess — but it certainly is a larger area. It could be +described as a 2x6 rectangle with 2d4, 2d5, 2i5, and 2i4 as its corners; +or, a 2x8 rectangle extended out on either side; or, a 4x4 rectangle in +the center of the middle board. In any event, the side that does the +better job of controlling the center squares will have a greater amount of +mobility and will more easily be able to bring pieces to bear upon +critical squares anywhere on the playing surfaces. + +In many instances, the square that a piece stands on is not as important +as the squares that the piece controls. Players should be mindful of +moving their pieces into positions where they command a large number of +squares, thereby limiting the opponent's ability to occupy those same +locations. + +In general, it is wise not to bring the "big guns" (particularly the Mage +and the Paladin) into active positions until some of the minor pieces have +been developed. Making a lot of moves with the Mage (for instance) early +in the game is usually a bad idea, because this delays the development of +other pieces that could have been moved out instead, and because this +could subject the Mage to a series of harassing attacks from the +opponent's minor pieces — which are being developed at the same time that +they are doing the harassing. (In chess, this phenomenon is known as a +"queen hunt," and it has led to the downfall of many a player who had +visions of ravaging the opposition with sweeping moves of the queen — but +instead ended up without a queen to move.) + +It is no accident that each side's Dragon begins the game directly above +the King of the same side. While the Dragons are obviously powerful +offensive pieces, each must play predominantly a defensive role to begin +with — for if one Dragon sallies forth on the upper board to wreak havoc +upon the opponent's middle-board pieces, the King may be left vulnerable +to a quick but deadly attack from the opposing Dragon. + +The power of the Basilisk to immobilize an opposing piece has a lot of +potential for use in offensive and defensive strategy. To help protect one +of your own pieces, move it to the square above one of your Basilisks. +Then, if it is captured, the capturing piece is "frozen," and you can +capture it more or less at your leisure without necessarily having to make +the return capture immediately. A well-timed Basilisk move can lead to an +abrupt checkmate even if the opposing King is not the piece that is +frozen, in a case where the Basilisk immobilizes a piece that was crucial +to the defense of the King. Except for the fact that it occupies a square +on the board, an immobilized piece can be treated as if it did not exist. +An immobilized Mage may be better than no Mage at all — but not by much. + +In general, the pieces on the lower board (especially the Dwarves) do not +play critical roles in the opening or the middle game, but if the Dwarves +haven't already been moved too far forward, they can be very useful in the +endgame (when relatively few pieces remain on the board) as defensive +outposts, to prevent the opponent's middle-board pieces from penetrating +too deeply into the territory nearest your side of the board. + +Do not underestimate the power of the Cleric. Of the five types of pieces +that can move up or down to any of the three boards, the Cleric is the +only one which moves and captures in the same fashion regardless of which +board it occupies. The Paladin and Mage are limited in their mobility when +not on the middle board, to such an extent that they will not generally +spend much time on the upper or lower board — and the Heroes and the King, +although they can move to the upper or lower board, cannot move on those +boards, and the King especially can be easily trapped if left on the upper +or lower board for any longer than necessary. + +### Putting together a game + +For reasons that should be obvious, this presentation could not include +actual components for a DRAGONCHESS game — making a board and filling it +with pieces must be left up to you. For our playtesting and development +purposes, we constructed a board from sheets of plexiglass with contact +paper applied to form the checkerboard patterns. The boards were spaced +approximately six inches apart, allowing enough room for hands to reach in +and manipulate pieces. The squares on our board are 1½" on a side, making +a playing surface 18" wide and 12" deep. The plexiglass sheets were cut +large enough to allow another 1½" all around, and had ¼" holes drilled in +each corner. Through these holes we inserted threaded metal rods, fastened +into place with connectors to space the boards properly and capped on each +end with plastic knobs to prevent scratching the surface on which the +entire board is placed during play. It probably isn't necessary to build a +three-dimensional board; three flat rectangles placed in a row on the +tabletop could be used to represent upper, middle, and lower levels. But +it's a lot easier to visualize the up-and-down moves of pieces if the +playing surface is actually composed of three levels. + +The pieces came from our collection of metal miniatures, and they do not +in all cases exactly resemble the pieces they represent. (For instance, we +used centaur figures for Oliphants because we couldn't find any 25mm scale +elephants — and even if we could have, they would have been too large for +the board.) Any sort of pieces will do, even simple tokens, as long as +each type of piece looks distinctly different from the others. + +Good luck, and may all your moves be ones that Rexfelis would envy. . . . + +--- + +## Decisions + +Where the text contradicts itself, leaves a case open, or needs a rule +chess players take for granted, the engine does the following. Each is a +reading of the text above, not a change to it, except the two marked as +options the host may choose. + +1. **Board numbers.** The notation paragraph makes the upper board 1 and + the lower 3, but every piece description, every example and the + diagrams' labels use 3 for the upper board and 1 for the lower (the + Griffons start at 3c1, the Dwarves on 1d4, the Elemental on 1g1). The + engine and every page use 3 for the air, 2 for the land and 1 for the + underworld. Wikipedia's article and the Chess Variant Pages' second + article read it the same way. +2. **The Thieves.** The "Setting up" paragraph lists the middle board's + pieces without the two Thieves. The diagram shows them on d and i, the + Thief has its own entry, and the count of 42 a side needs them. They + are in. +3. **Light and dark.** With a light square in Gold's near right corner, + square l1 is light and a1 dark. Gold's Sylphs therefore start on a2, + c2, e2, g2, i2 and k2 and Scarlet's on a7, c7, e7, g7, i7 and k7 (the + same files, as the diagram shows); the Dwarves start on b, d, f, h, j + and l of their second rank. +4. **Captures on board-changing moves.** The text says "move" for the + Mage's step on the upper or lower board, for the Paladin's knight-like + move between boards, and for the Cleric's and the King's step up or + down, and says "move (and capture)" or "move or capture" elsewhere. The + engine lets every one of those capture, since the text forbids capture + only where it says so (the Sylph's return upward, the Dwarf's move + down, the Elemental's diagonal step, the Basilisk's step back). The + Mage's "limited to a move of one square" is read as a limit on + distance. The King's return to the middle board may capture. +5. **Blocked vertical moves.** The Mage's move up or down two boards needs + the square on the middle board vacant. The Elemental's two-square + orthogonal step needs the square between vacant, as the general rule + says. The Elemental's move up needs its first, sideways square vacant + and the square above that to hold an enemy; its move down needs the + square beneath vacant and lands by a further orthogonal step, with or + without a capture. +6. **The Dragon's capture from afar** is a whole move. The Dragon stays + where it is and the piece below or beside-below is removed. It attacks + those five squares for the purposes of check. +7. **A frozen piece** cannot move, capture or give check, and protects + nothing. "An immobilized piece can be treated as if it did not exist", + except that it still occupies its square. A frozen King attacked by + another piece is in check and may be rescued only by capturing the + attacker or interposing. +8. **Check, checkmate, stalemate, repetition.** As in chess: a move may + not leave one's own King attacked, a King attacked with no escape is + mated and the game is lost, a player with no legal move and not in + check is stalemated and the game is drawn, and a position seen three + times with the same side to move is a draw. The text names only + checkmate; the other endings follow "as in standard chess". +9. **Promotion** happens the moment a Warrior arrives on the far rank, to + a Hero, with no choice. A Sylph on the far rank keeps its downward + capture and comes back to life by moving up into a vacant home square. + A Dwarf on the far rank keeps its sideways moves and its moves between + boards. +10. **The Sylph's return** from the middle board is to the square directly + above it, or to any vacant one of its own six home squares, without + capture. +11. **Option, the Sylphs' files.** As printed, Gold's Dragon on g1 looks + along the diagonal to a7 and can take Scarlet's Sylph there on the + first move, with nothing defending it. Jean-Louis Cazaux suggested on + the Chess Variant Pages that the Sylph rank was misprinted and belongs + on b, d, f, h, j and l like the Dwarves beneath them, which closes the + diagonal with one change. The host may choose "as printed" or + "Sylphs shifted". The printed setup is the default. +12. **Option, which side you play.** Gold moves first. diff --git a/src/lib/game/board.ts b/src/lib/game/board.ts new file mode 100644 index 0000000..3f6eb5e --- /dev/null +++ b/src/lib/game/board.ts @@ -0,0 +1,363 @@ +// The three boards and how every piece moves on them, on a bare array of +// cells so the engine, the bot and the board drawing share one source. The +// rules are docs/source.md, Gygax's text, read as its "Decisions" section +// says: board 3 is the air, 2 the land, 1 the underworld. + +export type Side = 'gold' | 'scarlet'; + +/** One letter a piece: Gygax's initials, with R for the Dragon (he wrote Dr). */ +export type Kind = 'S' | 'G' | 'R' | 'W' | 'O' | 'U' | 'H' | 'T' | 'C' | 'M' | 'K' | 'P' | 'D' | 'B' | 'E'; + +/** A cell holds '' or a side letter and a kind: 'gK' is Gold's King, 'sR' Scarlet's Dragon. */ +export type Cell = string; + +export type Setup = 'printed' | 'shifted'; + +export const FILES = 12; +export const RANKS = 8; +export const LEVELS = 3; +export const PER_LEVEL = FILES * RANKS; +export const CELLS = PER_LEVEL * LEVELS; + +export const KINDS: Kind[] = ['S', 'G', 'R', 'W', 'O', 'U', 'H', 'T', 'C', 'M', 'K', 'P', 'D', 'B', 'E']; + +export const NAMES: Record = { + S: 'Sylph', G: 'Griffon', R: 'Dragon', W: 'Warrior', O: 'Oliphant', U: 'Unicorn', H: 'Hero', T: 'Thief', + C: 'Cleric', M: 'Mage', K: 'King', P: 'Paladin', D: 'Dwarf', B: 'Basilisk', E: 'Elemental' +}; + +/** The letters the pieces are written with: Gygax's initials, and Dr for the Dragon. */ +export const LETTERS: Record = { + S: 'S', G: 'G', R: 'Dr', W: 'W', O: 'O', U: 'U', H: 'H', T: 'T', C: 'C', M: 'M', K: 'K', P: 'P', D: 'D', B: 'B', E: 'E' +}; + +/** Gygax's values, with the King beyond price. */ +export const VALUES: Record = { + K: 0, M: 11, P: 10, C: 9, R: 8, G: 5, O: 5, H: 4.5, T: 4, E: 4, B: 3, U: 2.5, D: 2, S: 1, W: 1 +}; + +export const SIDES: Side[] = ['gold', 'scarlet']; +export const other = (side: Side): Side => (side === 'gold' ? 'scarlet' : 'gold'); + +/** A cell index from level 1 to 3, file 0 to 11 and rank 0 to 7. */ +export const idx = (level: number, file: number, rank: number): number => (level - 1) * PER_LEVEL + rank * FILES + file; +export const levelOf = (i: number): number => Math.floor(i / PER_LEVEL) + 1; +export const fileOf = (i: number): number => i % FILES; +export const rankOf = (i: number): number => Math.floor((i % PER_LEVEL) / FILES); +const inBounds = (file: number, rank: number): boolean => file >= 0 && file < FILES && rank >= 0 && rank < RANKS; + +/** The square written as Gygax writes it: 3c4 is the air, file c, rank 4. */ +export function squareName(i: number): string { + return `${levelOf(i)}${'abcdefghijkl'[fileOf(i)]}${rankOf(i) + 1}`; +} + +/** The index of a square written as Gygax writes it. */ +export function square(name: string): number { + const level = Number(name[0]); + const file = 'abcdefghijkl'.indexOf(name[1]); + const rank = Number(name.slice(2)) - 1; + if (!(level >= 1 && level <= 3) || file < 0 || !(rank >= 0 && rank < RANKS)) throw new Error(`no square ${name}`); + return idx(level, file, rank); +} + +/** Light squares: a light square sits in Gold's near right corner, l1. */ +export const isLight = (i: number): boolean => (fileOf(i) + rankOf(i)) % 2 === 1; + +export const sideOf = (cell: Cell): Side | null => (cell === '' ? null : cell[0] === 'g' ? 'gold' : 'scarlet'); +export const kindOf = (cell: Cell): Kind => cell[1] as Kind; +export const cellOf = (side: Side, kind: Kind): Cell => (side === 'gold' ? 'g' : 's') + kind; + +/** Which way is forward: Gold climbs the ranks, Scarlet descends them. */ +export const forward = (side: Side): number => (side === 'gold' ? 1 : -1); + +/** The home files of the Sylphs: as printed, or shifted onto the Dwarves' files. */ +export const sylphFiles = (setup: Setup): number[] => (setup === 'shifted' ? [1, 3, 5, 7, 9, 11] : [0, 2, 4, 6, 8, 10]); + +/** The opening position. */ +export function opening(setup: Setup = 'printed'): Cell[] { + const cells: Cell[] = new Array(CELLS).fill(''); + const backRank: Kind[] = ['O', 'U', 'H', 'T', 'C', 'M', 'K', 'P', 'T', 'H', 'U', 'O']; + for (const side of SIDES) { + const home = side === 'gold' ? 0 : RANKS - 1; + const second = side === 'gold' ? 1 : RANKS - 2; + for (let f = 0; f < FILES; f++) { + cells[idx(2, f, home)] = cellOf(side, backRank[f]); + cells[idx(2, f, second)] = cellOf(side, 'W'); + } + for (const f of sylphFiles(setup)) cells[idx(3, f, second)] = cellOf(side, 'S'); + cells[idx(3, 2, home)] = cellOf(side, 'G'); + cells[idx(3, 10, home)] = cellOf(side, 'G'); + cells[idx(3, 6, home)] = cellOf(side, 'R'); + for (const f of [1, 3, 5, 7, 9, 11]) cells[idx(1, f, second)] = cellOf(side, 'D'); + cells[idx(1, 2, home)] = cellOf(side, 'B'); + cells[idx(1, 10, home)] = cellOf(side, 'B'); + cells[idx(1, 6, home)] = cellOf(side, 'E'); + } + return cells; +} + +/** A piece on the middle board with an enemy Basilisk beneath it cannot move, capture or give check. */ +export function frozen(cells: Cell[], i: number): boolean { + if (levelOf(i) !== 2) return false; + const below = cells[i - PER_LEVEL]; + return below !== '' && kindOf(below) === 'B' && sideOf(below) !== sideOf(cells[i]); +} + +const KNIGHT = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]]; +const GRIFFON = [[2, 3], [3, 2], [-2, 3], [-3, 2], [2, -3], [3, -2], [-2, -3], [-3, -2]]; +const ORTHOGONAL = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const DIAGONAL = [[1, 1], [1, -1], [-1, 1], [-1, -1]]; +const AROUND = [...ORTHOGONAL, ...DIAGONAL]; + +/** + * Every square the piece on `from` may move to or capture on, by the rules + * alone: its own King may be left in check. The Dragon's capture from afar + * is a move to the middle-board square it empties. A frozen piece has none. + */ +export function movesFrom(cells: Cell[], from: number, setup: Setup = 'printed'): number[] { + const cell = cells[from]; + if (cell === '' || frozen(cells, from)) return []; + const side = sideOf(cell)!; + const kind = kindOf(cell); + const level = levelOf(from); + const f = fileOf(from); + const r = rankOf(from); + const fw = forward(side); + const out: number[] = []; + const empty = (i: number) => cells[i] === ''; + const enemy = (i: number) => cells[i] !== '' && sideOf(cells[i]) !== side; + const open = (i: number) => cells[i] === '' || sideOf(cells[i]) !== side; + /** Add the square if it is on the board and passes the test. */ + const add = (lv: number, file: number, rank: number, ok: (i: number) => boolean) => { + if (lv < 1 || lv > LEVELS || !inBounds(file, rank)) return false; + const i = idx(lv, file, rank); + if (ok(i)) out.push(i); + return true; + }; + /** Slide until something is in the way; a piece of the other side may be taken. */ + const slide = (dirs: number[][]) => { + for (const [df, dr] of dirs) { + let file = f + df; + let rank = r + dr; + while (inBounds(file, rank)) { + const i = idx(level, file, rank); + if (cells[i] === '') out.push(i); + else { + if (sideOf(cells[i]) !== side) out.push(i); + break; + } + file += df; + rank += dr; + } + } + }; + const step = (dirs: number[][], lv = level, ok = open) => { + for (const [df, dr] of dirs) add(lv, f + df, r + dr, ok); + }; + const lastRank = side === 'gold' ? RANKS - 1 : 0; + + switch (kind) { + case 'S': + if (level === 3) { + add(3, f - 1, r + fw, empty); + add(3, f + 1, r + fw, empty); + add(3, f, r + fw, enemy); + add(2, f, r, enemy); + } else if (level === 2) { + add(3, f, r, empty); + const homeRank = side === 'gold' ? 1 : RANKS - 2; + for (const file of sylphFiles(setup)) add(3, file, homeRank, empty); + } + break; + case 'G': + if (level === 3) { + step(GRIFFON); + step(DIAGONAL, 2); + } else if (level === 2) { + step(DIAGONAL); + step(DIAGONAL, 3); + } + break; + case 'R': + if (level === 3) { + slide(DIAGONAL); + step(ORTHOGONAL); + add(2, f, r, enemy); + step(ORTHOGONAL, 2, enemy); + } + break; + case 'W': + if (level === 2) { + add(2, f, r + fw, empty); + add(2, f - 1, r + fw, enemy); + add(2, f + 1, r + fw, enemy); + } + break; + case 'O': + if (level === 2) slide(ORTHOGONAL); + break; + case 'U': + if (level === 2) step(KNIGHT); + break; + case 'T': + if (level === 2) slide(DIAGONAL); + break; + case 'H': + if (level === 2) { + step(DIAGONAL); + step(DIAGONAL.map(([df, dr]) => [df * 2, dr * 2])); + step(DIAGONAL, 1); + step(DIAGONAL, 3); + } else step(DIAGONAL, 2); + break; + case 'C': + step(AROUND); + add(level + 1, f, r, open); + add(level - 1, f, r, open); + break; + case 'M': + if (level === 2) { + slide(ORTHOGONAL); + slide(DIAGONAL); + add(3, f, r, open); + add(1, f, r, open); + } else { + step(ORTHOGONAL); + add(2, f, r, open); + if (cells[idx(2, f, r)] === '') add(level === 3 ? 1 : 3, f, r, open); + } + break; + case 'K': + if (level === 2) { + step(AROUND); + add(3, f, r, open); + add(1, f, r, open); + } else add(2, f, r, open); + break; + case 'P': + step(AROUND); + if (level === 2) { + step(KNIGHT); + for (const lv of [1, 3]) step(ORTHOGONAL.map(([df, dr]) => [df * 2, dr * 2]), lv); + } else { + step(ORTHOGONAL.map(([df, dr]) => [df * 2, dr * 2]), 2); + step(ORTHOGONAL, level === 3 ? 1 : 3); + } + break; + case 'D': + if (level === 1 || level === 2) { + if (r !== lastRank) { + add(level, f, r + fw, empty); + add(level, f - 1, r + fw, enemy); + add(level, f + 1, r + fw, enemy); + } + add(level, f - 1, r, empty); + add(level, f + 1, r, empty); + if (level === 1) add(2, f, r, enemy); + else add(1, f, r, empty); + } + break; + case 'B': + if (level === 1) { + add(1, f, r + fw, open); + add(1, f - 1, r + fw, open); + add(1, f + 1, r + fw, open); + add(1, f, r - fw, empty); + } + break; + case 'E': + if (level === 1) { + for (const [df, dr] of ORTHOGONAL) { + if (!inBounds(f + df, r + dr)) continue; + const near = idx(1, f + df, r + dr); + if (open(near)) out.push(near); + if (cells[near] === '') { + add(1, f + df * 2, r + dr * 2, open); + add(2, f + df, r + dr, enemy); + } + } + step(DIAGONAL, 1, empty); + } else if (level === 2 && cells[idx(1, f, r)] === '') step(ORTHOGONAL, 1); + break; + } + return out; +} + +/** Whether `side` attacks square `i` in this position: one of its pieces could capture there now. */ +export function attacked(cells: Cell[], i: number, by: Side): boolean { + for (let from = 0; from < CELLS; from++) { + const cell = cells[from]; + if (cell === '' || sideOf(cell) !== by) continue; + if (movesFrom(cells, from).includes(i)) return true; + } + return false; +} + +export function kingSquare(cells: Cell[], side: Side): number { + const king = cellOf(side, 'K'); + for (let i = 0; i < CELLS; i++) if (cells[i] === king) return i; + return -1; +} + +export function inCheck(cells: Cell[], side: Side): boolean { + const k = kingSquare(cells, side); + return k >= 0 && attacked(cells, k, other(side)); +} + +export interface Played { + cells: Cell[]; + captured: Kind | null; + promoted: boolean; + /** The Dragon's capture from afar: the Dragon stays and the square it struck is emptied. */ + afar: boolean; +} + +/** The position after the move, on a copy. The move is taken to be one `movesFrom` offered. */ +export function playOn(cells: Cell[], from: number, to: number): Played { + const next = cells.slice(); + const piece = cells[from]; + const side = sideOf(piece)!; + const kind = kindOf(piece); + const captured = cells[to] === '' ? null : kindOf(cells[to]); + if (kind === 'R' && levelOf(to) === 2) { + next[to] = ''; + return { cells: next, captured, promoted: false, afar: true }; + } + next[from] = ''; + const promoted = kind === 'W' && rankOf(to) === (side === 'gold' ? RANKS - 1 : 0); + next[to] = promoted ? cellOf(side, 'H') : piece; + return { cells: next, captured, promoted, afar: false }; +} + +/** The moves of the piece on `from` that leave its own King out of check. */ +export function legalMovesFrom(cells: Cell[], from: number, setup: Setup = 'printed'): number[] { + const side = sideOf(cells[from]); + if (!side) return []; + return movesFrom(cells, from, setup).filter((to) => !inCheck(playOn(cells, from, to).cells, side)); +} + +/** Every legal move for a side, as [from, to] pairs. */ +export function legalMoves(cells: Cell[], side: Side, setup: Setup = 'printed'): [number, number][] { + const out: [number, number][] = []; + for (let from = 0; from < CELLS; from++) { + if (cells[from] === '' || sideOf(cells[from]) !== side) continue; + for (const to of legalMovesFrom(cells, from, setup)) out.push([from, to]); + } + return out; +} + +/** Whether a side has any legal move at all, found without listing them. */ +export function hasLegalMove(cells: Cell[], side: Side, setup: Setup = 'printed'): boolean { + for (let from = 0; from < CELLS; from++) { + if (cells[from] === '' || sideOf(cells[from]) !== side) continue; + for (const to of movesFrom(cells, from, setup)) if (!inCheck(playOn(cells, from, to).cells, side)) return true; + } + return false; +} + +/** The pieces a side has left, by kind. */ +export function census(cells: Cell[], side: Side): Partial> { + const count: Partial> = {}; + for (const cell of cells) if (cell !== '' && sideOf(cell) === side) count[kindOf(cell)] = (count[kindOf(cell)] ?? 0) + 1; + return count; +} diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts new file mode 100644 index 0000000..e82f64b --- /dev/null +++ b/src/lib/game/bot.ts @@ -0,0 +1,151 @@ +// The drake: the house player. An alpha-beta search over pseudo-legal +// moves with a quiescence search on captures, deepened one ply at a time +// until a fixed budget of nodes is spent. The budget is counted, not +// timed, so the same position always yields the same move and a ledger +// replays to the same choice. + +import { + CELLS, PER_LEVEL, VALUES, type Cell, type Kind, type Side, type Setup, + frozen, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves +} from './board'; + +/** Nodes a move may cost. Around a second on a small server. */ +export const NODE_BUDGET = 80_000; +const MAX_DEPTH = 8; +const QUIESCE_DEPTH = 4; +const MATE = 100_000; + +/** Centipawn-ish weights: Gygax's values times twenty, the Dragon raised as Jackman advises. */ +const WEIGHT: Record = { + K: 0, M: 220, P: 200, C: 180, R: 200, G: 100, O: 100, H: 90, T: 80, E: 80, B: 60, U: 50, D: 40, S: 20, W: 20 +}; + +/** How the position looks to `side`: material, a little shape, and the cost of being frozen or off the land. */ +export function evaluate(cells: Cell[], side: Side): number { + let score = 0; + for (let i = 0; i < CELLS; i++) { + const cell = cells[i]; + if (cell === '') continue; + const kind = kindOf(cell); + const own = sideOf(cell) === side; + let v = WEIGHT[kind]; + const level = levelOf(i); + const rank = rankOf(i); + const file = fileOf(i); + if (kind === 'W') { + const advance = sideOf(cell) === 'gold' ? rank - 1 : 6 - rank; + v += advance * 4; + } else if (kind === 'K') { + if (level !== 2) v -= 40; + } else if (level === 2 && kind !== 'D' && kind !== 'S' && kind !== 'E') { + // Pieces of the land like the middle of it. + v += 6 - Math.abs(file - 5.5) - Math.abs(rank - 3.5); + } + if (frozen(cells, i)) v = Math.floor(v / 3); + score += own ? v : -v; + } + return score; +} + +interface Search { + nodes: number; + budget: number; + setup: Setup; + out: boolean; +} + +/** Captures first, the fattest victim first and the cheapest attacker before a dearer one. */ +function ordered(cells: Cell[], side: Side, setup: Setup, capturesOnly = false): [number, number][] { + const moves: [number, number, number][] = []; + for (let from = 0; from < CELLS; from++) { + const cell = cells[from]; + if (cell === '' || sideOf(cell) !== side) continue; + const attacker = WEIGHT[kindOf(cell)]; + for (const to of movesFrom(cells, from, setup)) { + const target = cells[to]; + if (target === '') { + if (!capturesOnly) moves.push([from, to, 0]); + } else moves.push([from, to, 1000 + WEIGHT[kindOf(target)] * 10 - attacker / 10]); + } + } + moves.sort((a, b) => b[2] - a[2]); + return moves.map(([from, to]) => [from, to]); +} + +/** Stand pat or keep capturing until the dust settles. */ +export function quiesce(cells: Cell[], side: Side, alpha: number, beta: number, depth: number, s: Search): number { + s.nodes++; + if (s.nodes > s.budget) s.out = true; + const stand = evaluate(cells, side); + if (depth === 0 || s.out) return stand; + if (stand >= beta) return stand; + if (stand > alpha) alpha = stand; + for (const [from, to] of ordered(cells, side, s.setup, true)) { + if (kindOf(cells[to]) === 'K') return MATE; + const next = playOn(cells, from, to).cells; + const score = -quiesce(next, other(side), -beta, -alpha, depth - 1, s); + if (s.out) return alpha; + if (score >= beta) return score; + if (score > alpha) alpha = score; + } + return alpha; +} + +export function search(cells: Cell[], side: Side, depth: number, alpha: number, beta: number, ply: number, s: Search): number { + s.nodes++; + if (s.nodes > s.budget) s.out = true; + if (s.out) return 0; + if (depth === 0) return quiesce(cells, side, alpha, beta, QUIESCE_DEPTH, s); + const moves = ordered(cells, side, s.setup); + if (moves.length === 0) return inCheck(cells, side) ? -MATE + ply : 0; + let best = -Infinity; + for (const [from, to] of moves) { + // Taking the King means the move before was illegal: the best thing that can happen. + if (kindOf(cells[to]) === 'K') return MATE - ply; + const next = playOn(cells, from, to).cells; + const score = -search(next, other(side), depth - 1, -beta, -alpha, ply + 1, s); + if (s.out) return best === -Infinity ? 0 : best; + if (score > best) best = score; + if (score > alpha) alpha = score; + if (alpha >= beta) break; + } + // Every move loses the King: mate, unless none of them was a real check and it is only stalemate. + if (best <= -MATE + MAX_DEPTH + 1 && !inCheck(cells, side)) return 0; + return best; +} + +/** + * The drake's move: the best it finds within the budget, from the legal + * moves alone. The first legal move stands if nothing better is found. + */ +export function chooseMove(cells: Cell[], side: Side, setup: Setup = 'printed', budget = NODE_BUDGET): { from: number; to: number } | null { + const legal = legalMoves(cells, side, setup); + if (legal.length === 0) return null; + if (legal.length === 1) return { from: legal[0][0], to: legal[0][1] }; + const legalSet = new Set(legal.map(([a, b]) => a * CELLS + b)); + const s: Search = { nodes: 0, budget, setup, out: false }; + let chosen = legal[0]; + let order = ordered(cells, side, setup).filter(([a, b]) => legalSet.has(a * CELLS + b)); + for (let depth = 1; depth <= MAX_DEPTH && !s.out; depth++) { + let bestScore = -Infinity; + let best: [number, number] | null = null; + const scored: [number, number, number][] = []; + for (const [from, to] of order) { + const next = playOn(cells, from, to).cells; + const score = -search(next, other(side), depth - 1, -Infinity, -bestScore, 1, s); + if (s.out) break; + scored.push([from, to, score]); + if (score > bestScore) { + bestScore = score; + best = [from, to]; + } + } + if (s.out || !best) break; + chosen = best; + if (bestScore >= MATE - MAX_DEPTH) break; + // The next depth tries this depth's best moves first. + order = scored.sort((a, b) => b[2] - a[2]).map(([from, to]) => [from, to]); + } + return { from: chosen[0], to: chosen[1] }; +} + diff --git a/src/lib/game/index.test.ts b/src/lib/game/index.test.ts index 6adefe2..58befe6 100644 --- a/src/lib/game/index.test.ts +++ b/src/lib/game/index.test.ts @@ -1,36 +1,351 @@ import { describe, expect, it } from 'vitest'; -import { CURRENT_RULES, createGame, game, resolveRound } from './index'; +import { CURRENT_RULES, applyMove, createGame, game, describe as tell, notation, positionKey, resolveRound, validateMove, type State } from './index'; +import { CELLS, census, frozen, inCheck, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board'; +import { chooseMove } from './bot'; -describe('High Card', () => { - it('scores the highest number named by exactly one seat', () => { - let s = createGame({ A: 'Ann', B: 'Bo', C: 'Cy' }, 1); - s = resolveRound(s, { A: { pick: 5 }, B: { pick: 5 }, C: { pick: 2 } }); - expect(s.rounds[0].scorer).toBe('C'); - expect(s.players.C.points).toBe(1); - }); +/** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */ +function position(pieces: Record, toMove: Side = 'gold'): State { + const s = createGame({ A: 'Aud', B: 'Bjorn' }); + s.cells = new Array(CELLS).fill(''); + for (const [name, cell] of Object.entries(pieces)) s.cells[square(name)] = cell; + if (!s.cells.includes('gK')) s.cells[square('2l1')] = s.cells[square('2l1')] || 'gK'; + if (!s.cells.includes('sK')) s.cells[square('2a8')] = s.cells[square('2a8')] || 'sK'; + s.toMove = toMove; + s.seen = { [positionKey(s.cells, toMove)]: 1 }; + return s; +} - it('ends when a seat reaches the target', () => { - let s = createGame({ A: 'Ann', B: 'Bo' }, 1); - for (let i = 0; i < 3; i++) s = resolveRound(s, { A: { pick: 3 }, B: { pick: 1 } }); - expect(s.over?.winner).toBe('A'); - expect(game.needsInput(s, 'A')).toBe(false); - }); +const names = (cells: Cell[], from: string) => movesFrom(cells, square(from)).map(squareName).sort(); +const sorted = (list: string[]) => [...list].sort(); - it('draws the same bot picks from the same seed, and different ones as rounds pass', () => { - const a = createGame({ A: 'Ann', B: 'Bo' }, 42); - const b = createGame({ A: 'Ann', B: 'Bo' }, 42); - expect(game.botInput(a, 'B')).toEqual(game.botInput(b, 'B')); - const picks = new Set(); - let s = a; - for (let i = 0; i < 12; i++) { - picks.add(game.botInput(s, 'B').pick); - s = resolveRound(s, { A: { pick: 1 }, B: game.botInput(s, 'B') }); +describe('the opening', () => { + it('seats 42 pieces a side where the diagrams put them', () => { + const s = createGame({ A: 'Aud', B: 'Bjorn' }); + for (const side of ['gold', 'scarlet'] as const) { + const count = census(s.cells, side); + expect(Object.values(count).reduce((a, b) => a + b, 0)).toBe(42); + expect(count).toEqual({ S: 6, G: 2, R: 1, W: 12, O: 2, U: 2, H: 2, T: 2, C: 1, M: 1, K: 1, P: 1, D: 6, B: 2, E: 1 }); } - expect(picks.size).toBeGreaterThan(1); + const at = (name: string) => s.cells[square(name)]; + expect(at('3g1')).toBe('gR'); + expect(at('3c1')).toBe('gG'); + expect(at('3a2')).toBe('gS'); + expect(at('3b2')).toBe(''); + expect(at('3a7')).toBe('sS'); + expect(at('2g1')).toBe('gK'); + expect(at('2f1')).toBe('gM'); + expect(at('2h1')).toBe('gP'); + expect(at('2e1')).toBe('gC'); + expect(at('2d1')).toBe('gT'); + expect(at('2i8')).toBe('sT'); + expect(at('2a8')).toBe('sO'); + expect(at('1g1')).toBe('gE'); + expect(at('1c1')).toBe('gB'); + expect(at('1b2')).toBe('gD'); + expect(at('1a2')).toBe(''); + expect(at('1b7')).toBe('sD'); + expect(s.toMove).toBe('gold'); + expect(s.players.A.side).toBe('gold'); + expect(s.players.B.side).toBe('scarlet'); + }); + + it('lets the host take Scarlet, and shifts the Sylphs when asked', () => { + const s = createGame({ A: 'Aud', B: 'Bjorn' }, 0, CURRENT_RULES, { side: 'scarlet', setup: 'shifted' }); + expect(s.players.A.side).toBe('scarlet'); + expect(s.players.B.side).toBe('gold'); + expect(s.cells[square('3b2')]).toBe('gS'); + expect(s.cells[square('3a2')]).toBe(''); + expect(s.cells[square('3l7')]).toBe('sS'); + }); + + it('as printed, Gold’s Dragon can take the Sylph on a7 at once; shifted, it cannot', () => { + const printed = createGame({ A: 'Aud', B: 'Bjorn' }); + expect(names(printed.cells, '3g1')).toContain('3a7'); + const shifted = createGame({ A: 'Aud', B: 'Bjorn' }, 0, CURRENT_RULES, { setup: 'shifted' }); + expect(names(shifted.cells, '3g1')).not.toContain('3a7'); + expect(names(shifted.cells, '3g1')).not.toContain('3b8'); }); it('stamps the rules revision', () => { - expect(createGame({ A: 'Ann', B: 'Bo' }).rules).toBe(CURRENT_RULES); - expect(createGame({ A: 'Ann', B: 'Bo' }, 1, 1).rules).toBe(1); + expect(createGame({ A: 'Aud', B: 'Bjorn' }).rules).toBe(CURRENT_RULES); + }); +}); + +describe('the upper board', () => { + it('a Sylph at 3c4 moves to 3b5 or 3d5 and captures at 3c5 or 2c4', () => { + const quiet = position({ '3c4': 'gS' }); + expect(names(quiet.cells, '3c4')).toEqual(['3b5', '3d5']); + const busy = position({ '3c4': 'gS', '3c5': 'sW', '2c4': 'sU', '3b5': 'sW' }); + expect(names(busy.cells, '3c4')).toEqual(['2c4', '3c5', '3d5']); + }); + + it('a Sylph on the middle board only climbs, to the square above or a vacant home square', () => { + const s = position({ '3c4': 'gS', '2c4': 'sU' }); + const after = playOn(s.cells, square('3c4'), square('2c4')).cells; + expect(after[square('2c4')]).toBe('gS'); + expect(names(after, '2c4')).toEqual(sorted(['3c4', '3a2', '3c2', '3e2', '3g2', '3i2', '3k2'])); + after[square('3c4')] = 'sW'; + after[square('3a2')] = 'gW'; + expect(names(after, '2c4')).toEqual(sorted(['3c2', '3e2', '3g2', '3i2', '3k2'])); + }); + + it('a Sylph on the far rank keeps only its capture downward', () => { + const s = position({ '3c8': 'gS', '2c8': 'sW' }); + expect(names(s.cells, '3c8')).toEqual(['2c8']); + }); + + it('a Griffon at 3d4 reaches the eight squares Gygax lists and four below', () => { + const s = position({ '3d4': 'gG' }); + expect(names(s.cells, '3d4')).toEqual(sorted(['3a2', '3b1', '3f1', '3g2', '3g6', '3f7', '3b7', '3a6', '2c5', '2c3', '2e3', '2e5'])); + }); + + it('a Griffon at 2e5 commands the diagonals beside and above it', () => { + const s = position({ '2e5': 'gG' }); + expect(names(s.cells, '2e5')).toEqual(sorted(['2f6', '2f4', '2d4', '2d6', '3f6', '3f4', '3d4', '3d6'])); + }); + + it('a Dragon at 3c4 strikes from afar at 2c4, 2c5, 2d4, 2c3 and 2b4, and stays in the air', () => { + const s = position({ '3c4': 'gR', '2c4': 'sW', '2c5': 'sW', '2d4': 'sW', '2c3': 'sW', '2b4': 'sW', '2b5': 'sW', '2c6': 'gW' }); + const afar = names(s.cells, '3c4').filter((n) => n.startsWith('2')); + expect(afar).toEqual(sorted(['2c4', '2c5', '2d4', '2c3', '2b4'])); + const played = playOn(s.cells, square('3c4'), square('2d4')); + expect(played.afar).toBe(true); + expect(played.cells[square('3c4')]).toBe('gR'); + expect(played.cells[square('2d4')]).toBe(''); + }); + + it('a Dragon slides the diagonals and steps one square orthogonally', () => { + const s = position({ '3g1': 'gR' }); + expect(names(s.cells, '3g1')).toEqual(sorted(['3f1', '3h1', '3g2', '3f2', '3e3', '3d4', '3c5', '3b6', '3a7', '3h2', '3i3', '3j4', '3k5', '3l6'])); + }); +}); + +describe('the middle board', () => { + it('a Warrior at 2c2 moves to 2c3 and captures at 2b3 or 2d3, never two squares', () => { + const s = position({ '2c2': 'gW', '2b3': 'sW', '2d3': 'sW' }); + expect(names(s.cells, '2c2')).toEqual(['2b3', '2c3', '2d3']); + }); + + it('a Warrior on the far rank becomes a Hero, with no choice', () => { + const s = position({ '2c7': 'gW' }); + const played = playOn(s.cells, square('2c7'), square('2c8')); + expect(played.promoted).toBe(true); + expect(played.cells[square('2c8')]).toBe('gH'); + }); + + it('the Oliphant, Unicorn and Thief are the rook, knight and bishop of the land alone', () => { + const s = position({ '2d4': 'gO', '2h4': 'gU', '2d7': 'gT', '3d4': 'sW', '1h4': 'sW' }); + expect(names(s.cells, '2d4')).toEqual(sorted(['2a4', '2b4', '2c4', '2e4', '2f4', '2g4', '2d1', '2d2', '2d3', '2d5', '2d6'])); + expect(names(s.cells, '2h4')).toEqual(sorted(['2g6', '2i6', '2f5', '2j5', '2f3', '2j3', '2g2', '2i2'])); + expect(names(s.cells, '2d7').every((n) => n.startsWith('2'))).toBe(true); + }); + + it('a Hero leaps one or two squares along a diagonal and between boards', () => { + const s = position({ '2c6': 'gH', '2d7': 'gW', '2e8': 'sW' }); + expect(names(s.cells, '2c6')).toEqual(sorted(['2b7', '2a8', '2b5', '2a4', '2d5', '2e4', '2e8', '3b7', '3d7', '3b5', '3d5', '1b7', '1d7', '1b5', '1d5'])); + const up = position({ '3d5': 'gH', '2e6': 'sW' }); + expect(names(up.cells, '3d5')).toEqual(sorted(['2c6', '2e6', '2c4', '2e4'])); + }); + + it('a Cleric moves as a king on any board and steps straight up or down', () => { + const s = position({ '1d4': 'gC', '2d4': 'sW' }); + expect(names(s.cells, '1d4')).toEqual(sorted(['1c3', '1c4', '1c5', '1d3', '1d5', '1e3', '1e4', '1e5', '2d4'])); + }); + + it('a Mage is a queen on the land, a step on the other boards, and cannot vault the land between', () => { + const s = position({ '1d4': 'gM' }); + expect(names(s.cells, '1d4')).toEqual(sorted(['1c4', '1e4', '1d3', '1d5', '2d4', '3d4'])); + s.cells[square('2d4')] = 'gW'; + expect(names(s.cells, '1d4')).toEqual(sorted(['1c4', '1e4', '1d3', '1d5'])); + const mid = position({ '2c4': 'gM' }); + expect(names(mid.cells, '2c4')).toContain('3c4'); + expect(names(mid.cells, '2c4')).toContain('1c4'); + expect(names(mid.cells, '2c4')).toContain('2c8'); + }); + + it('a King off the land can only come back to it', () => { + const s = position({ '3g4': 'gK', '2g4': 'sW' }); + expect(names(s.cells, '3g4')).toEqual(['2g4']); + const mid = position({ '2g4': 'gK' }); + expect(names(mid.cells, '2g4')).toEqual(sorted(['2f3', '2f4', '2f5', '2g3', '2g5', '2h3', '2h4', '2h5', '3g4', '1g4'])); + }); + + it('a Paladin at 3c4 moves between boards to the squares Gygax lists', () => { + const s = position({ '3c4': 'gP' }); + const crossing = names(s.cells, '3c4').filter((n) => !n.startsWith('3')); + expect(crossing).toEqual(sorted(['2c6', '2e4', '2c2', '2a4', '1c5', '1d4', '1c3', '1b4'])); + const mid = position({ '2e4': 'gP' }); + expect(names(mid.cells, '2e4')).toContain('2f6'); + expect(names(mid.cells, '2e4')).toContain('2d4'); + expect(names(mid.cells, '2e4')).toContain('3e6'); + expect(names(mid.cells, '2e4')).toContain('1c4'); + }); +}); + +describe('the lower board', () => { + it('a Dwarf at 1d4 walks to 1d5, 1c4 or 1e4 and captures at 1c5, 1e5 or 2d4', () => { + const s = position({ '1d4': 'gD', '1c5': 'sW', '1e5': 'sW', '2d4': 'sW' }); + expect(names(s.cells, '1d4')).toEqual(sorted(['1d5', '1c4', '1e4', '1c5', '1e5', '2d4'])); + }); + + it('a Dwarf on the land comes down only to an empty square, and keeps its walk', () => { + const s = position({ '2d4': 'gD' }); + expect(names(s.cells, '2d4')).toEqual(sorted(['2d5', '2c4', '2e4', '1d4'])); + s.cells[square('1d4')] = 'sW'; + expect(names(s.cells, '2d4')).toEqual(sorted(['2d5', '2c4', '2e4'])); + }); + + it('a Dwarf on the far rank moves sideways only', () => { + const s = position({ '1d8': 'gD', '2d8': 'sW' }); + expect(names(s.cells, '1d8')).toEqual(sorted(['1c8', '1e8', '2d8'])); + }); + + it('a Basilisk at 1d4 goes ahead to 1c5, 1d5 or 1e5, back to 1d3 only when empty, and never leaves', () => { + const s = position({ '1d4': 'gB', '1d5': 'sW', '1d3': 'sW' }); + expect(names(s.cells, '1d4')).toEqual(sorted(['1c5', '1d5', '1e5'])); + }); + + it('a Basilisk freezes the enemy above it, and a frozen piece neither moves nor gives check', () => { + const s = position({ '1d4': 'gB', '2d4': 'sO', '2d3': 'gW' }); + expect(frozen(s.cells, square('2d4'))).toBe(true); + expect(names(s.cells, '2d4')).toEqual([]); + // The frozen Oliphant looks along the rank at Gold's King on l... put the King in its line. + const line = position({ '1d4': 'gB', '2d4': 'sO', '2l4': 'gK' }); + expect(inCheck(line.cells, 'gold')).toBe(false); + line.cells[square('1d4')] = ''; + expect(inCheck(line.cells, 'gold')).toBe(true); + const friendly = position({ '1d4': 'gB', '2d4': 'gO' }); + expect(frozen(friendly.cells, square('2d4'))).toBe(false); + }); + + it('an Elemental at 1d4 moves and captures as Gygax lists, and climbs only to capture', () => { + const empty = position({ '1d4': 'gE' }); + expect(names(empty.cells, '1d4')).toEqual(sorted(['1c5', '1e5', '1e3', '1c3', '1d5', '1d6', '1e4', '1f4', '1d3', '1d2', '1c4', '1b4'])); + const up = position({ '1d4': 'gE', '2d5': 'sW', '2e4': 'sW', '2d3': 'sW', '2c4': 'sW', '2d4': 'sW', '1c5': 'sW' }); + const climbs = names(up.cells, '1d4').filter((n) => n.startsWith('2')); + expect(climbs).toEqual(sorted(['2d5', '2e4', '2d3', '2c4'])); + expect(names(up.cells, '1d4')).not.toContain('1c5'); + up.cells[square('1d5')] = 'gW'; + expect(names(up.cells, '1d4').filter((n) => n.startsWith('2'))).toEqual(sorted(['2e4', '2d3', '2c4'])); + expect(names(up.cells, '1d4')).not.toContain('1d6'); + }); + + it('an Elemental on 2d4 comes down to 1d5, 1e4, 1d3 or 1c4 only when 1d4 is vacant', () => { + const s = position({ '2d4': 'gE', '1e4': 'sW' }); + expect(names(s.cells, '2d4')).toEqual(sorted(['1d5', '1e4', '1d3', '1c4'])); + s.cells[square('1d4')] = 'sW'; + expect(names(s.cells, '2d4')).toEqual([]); + }); +}); + +describe('check and the ends', () => { + it('refuses a move that leaves the King in check, with the reason', () => { + const s = position({ '2g1': 'gK', '2g2': 'gO', '2g8': 'sO' }); + s.cells[square('2l1')] = ''; + expect(validateMove(s, 'A', { from: square('2g2'), to: square('2a2') })).toBe('That would leave your King in check.'); + expect(validateMove(s, 'A', { from: square('2g2'), to: square('2g5') })).toBeNull(); + expect(validateMove(s, 'B', { from: square('2g8'), to: square('2g7') })).toBe('It is Aud’s move.'); + expect(validateMove(s, 'A', { from: square('2g1'), to: square('2e1') })).toBe('The King cannot go to 2e1.'); + }); + + it('the Dragon gives check from the air', () => { + const s = position({ '2g1': 'gK', '3g2': 'sR' }); + s.cells[square('2l1')] = ''; + expect(inCheck(s.cells, 'gold')).toBe(true); + // 2f2, 2h2 and 2g2 are under the Dragon's strike and 3g1 is a step from it, so the King goes along the rank or underground. + expect(legalMovesFrom(s.cells, square('2g1')).map(squareName).sort()).toEqual(sorted(['2f1', '2h1', '1g1'])); + }); + + it('checkmate ends the game for the side that gave it', () => { + // The Oliphant takes the back rank; the Cleric below and the Sylph above close the King's ways down and up. + const s = position({ '2a1': 'gK', '2a2': 'gW', '2b2': 'gW', '2h8': 'sO', '1b1': 'sC', '3a2': 'sS' }, 'scarlet'); + const after = applyMove(s, square('2h8'), square('2h1')); + expect(after.moves[0].check).toBe(true); + expect(after.end).toBe('mate'); + expect(after.over?.winner).toBe('B'); + expect(after.over?.reason).toBe('Aud’s King has nowhere left to go. Checkmate, and the game is Bjorn’s.'); + expect(game.needsInput(after, 'A')).toBe(false); + }); + + it('a frozen King is mated by any attack it cannot answer', () => { + const s = position({ '2d4': 'gK', '1d4': 'sB', '2h8': 'sO', '2a7': 'sU' }, 'scarlet'); + s.cells[square('2l1')] = ''; + const after = applyMove(s, square('2h8'), square('2h4')); + expect(after.end).toBe('mate'); + }); + + it('stalemate is a draw', () => { + // Gold has only its King. The Oliphants cover rank 2 and file b, the Cleric 1a1 and the Sylph 3a1, and none of them touches 2a1. + const s = position({ '2a1': 'gK', '2c5': 'sO', '2b8': 'sO', '1b2': 'sC', '3a2': 'sS' }, 'scarlet'); + const after = applyMove(s, square('2c5'), square('2c2')); + expect(inCheck(after.cells, 'gold')).toBe(false); + expect(after.end).toBe('stalemate'); + expect(after.over?.winner).toBeNull(); + expect(after.over?.reason).toBe('Aud\u2019s King is not in check, but nothing of Aud\u2019s can move. Stalemate, and the game is drawn.'); + }); + + it('the third time a position comes round, the game is drawn', () => { + let s = position({ '2g1': 'gK', '2g8': 'sK', '2a1': 'gO', '2l8': 'sO' }); + s.cells[square('2l1')] = ''; + s.cells[square('2a8')] = ''; + const shuffle: [string, string][] = [['2a1', '2a2'], ['2l8', '2l7'], ['2a2', '2a1'], ['2l7', '2l8']]; + for (let round = 0; round < 2; round++) for (const [from, to] of shuffle) s = applyMove(s, square(from), square(to)); + expect(s.end).toBe('repetition'); + expect(s.over?.winner).toBeNull(); + }); + + it('resolves only the side to move, and only a legal move', () => { + const s = createGame({ A: 'Aud', B: 'Bjorn' }); + expect(resolveRound(s, { B: { from: square('2c7'), to: square('2c6') } })).toBe(s); + expect(resolveRound(s, { A: { from: square('2c2'), to: square('2c5') } })).toBe(s); + const after = resolveRound(s, { A: { from: square('2c2'), to: square('2c3') } }); + expect(after.toMove).toBe('scarlet'); + expect(after.moves).toHaveLength(1); + expect(tell(after, after.moves[0])).toBe('Aud’s Warrior goes from 2c2 to 2c3.'); + expect(notation(after.moves[0])).toBe('W/2c2-2c3'); + }); + + it('tells a capture, a climb and a strike from afar', () => { + const s = position({ '3c4': 'gR', '2c4': 'sW', '2e4': 'gH' }); + const afar = applyMove(s, square('3c4'), square('2c4')); + expect(tell(afar, afar.moves[0])).toBe('Aud’s Dragon strikes down from 3c4 and takes the Warrior at 2c4.'); + expect(notation(afar.moves[0])).toBe('Drx2c4'); + const climb = applyMove(s, square('2e4'), square('3d5')); + expect(tell(climb, climb.moves[0])).toBe('Aud’s Hero climbs from 2e4 to 3d5, in the air.'); + }); +}); + +describe('the drake', () => { + it('chooses the same move from the same position every time', () => { + const s = createGame({ A: 'Aud', B: 'Bjorn' }); + const a = chooseMove(s.cells, 'gold', 'printed', 4000); + const b = chooseMove(s.cells, 'gold', 'printed', 4000); + expect(a).toEqual(b); + expect(legalMoves(s.cells, 'gold').some(([from, to]) => from === a!.from && to === a!.to)).toBe(true); + }); + + it('takes a free Mage when one is offered', () => { + const s = position({ '2d4': 'gO', '2d7': 'sM' }); + const move = chooseMove(s.cells, 'gold', 'printed', 4000); + expect(move).toEqual({ from: square('2d4'), to: square('2d7') }); + }); + + it('delivers a mate in one', () => { + const s = position({ '2a1': 'gK', '2a2': 'gW', '2b2': 'gW', '2h8': 'sO', '1b1': 'sC', '3a2': 'sS', '2g5': 'sW' }, 'scarlet'); + const move = chooseMove(s.cells, 'scarlet', 'printed', 20000); + expect(move).toEqual({ from: square('2h8'), to: square('2h1') }); + }); + + it('plays a whole game against itself on a small budget and the game ends', { timeout: 120_000 }, () => { + let s = createGame({ A: 'Aud', B: 'Bjorn' }); + let plies = 0; + while (!s.over && plies < 600) { + const seat = s.toMove === s.players.A.side ? 'A' : 'B'; + const move = chooseMove(s.cells, s.toMove, s.setup, 1500)!; + expect(validateMove(s, seat, move)).toBeNull(); + s = resolveRound(s, { [seat]: move }); + plies++; + } + expect(s.over).not.toBeNull(); }); }); diff --git a/src/lib/game/index.ts b/src/lib/game/index.ts index 82b943f..b70d876 100644 --- a/src/lib/game/index.ts +++ b/src/lib/game/index.ts @@ -1,94 +1,227 @@ -// The demo game, "High Card": every round each seat names a number from one -// to five; the highest number named by exactly one seat scores it a point, -// and the first to three points wins. It exists to show the shape of a game -// this kit can run: simultaneous hidden moves, a seeded bot, a view that -// withholds the round in progress, and a rules revision. Replace this file -// with your own game and keep the GameSpec shape. +// Wyrm: Gary Gygax's Dragonchess (Dragon Magazine No. 100, August 1985) +// on three boards of twelve by eight. Two sides, Gold and Scarlet, Gold +// first, checkmate to win. The text the engine follows is docs/source.md, +// and the mechanics of every piece are in board.ts; this file is the game +// around them: seats, turns, the ends, and the GameSpec the kit runs. -import { SPECTATOR, type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec'; +import { type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec'; +import { + type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom, + levelOf, movesFrom, opening, other, playOn, sideOf, squareName +} from './board'; +import { chooseMove } from './bot'; export const CURRENT_RULES = 1; -export const TARGET = 3; -export const HIGHEST = 5; + +/** Plies without a capture before the game is drawn: fifty moves by each side. */ +export const QUIET_LIMIT = 100; + +export type { Cell, Kind, Side, Setup }; +export { NAMES, LETTERS, squareName, legalMovesFrom, movesFrom }; export interface Player { id: SeatId; name: string; - points: number; + side: Side; } -export interface Round { - picks: Record; - scorer: SeatId | null; +/** One ply as the record keeps it. */ +export interface Move { + by: Side; + from: number; + to: number; + kind: Kind; + captured: Kind | null; + /** The Dragon struck from the air and stayed where it was. */ + afar: boolean; + /** A Warrior arrived on the far rank and is a Hero now. */ + promoted: boolean; + /** The move put the other King in check. */ + check: boolean; } +export type End = 'mate' | 'stalemate' | 'repetition' | 'quiet'; + export interface State { rules: number; + setup: Setup; seats: SeatId[]; players: Record; - rounds: Round[]; + cells: Cell[]; + toMove: Side; + moves: Move[]; + /** How often each position has stood with the same side to move, for the threefold draw. */ + seen: Record; + /** Plies since the last capture. */ + quiet: number; + end: End | null; over: Outcome | null; - rng: number; } -export type Input = { pick: number }; +export type Input = { from: number; to: number }; -/** Mulberry32 on a seed: a small generator, so a game replays to the same bot picks. */ -function random(seed: number): number { - let t = (seed + 0x6d2b79f5) >>> 0; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; -} +export const SIDES: { value: Side; label: string; note: string }[] = [ + { value: 'gold', label: 'Gold, and move first', note: 'Gold opens the game.' }, + { value: 'scarlet', label: 'Scarlet', note: 'Scarlet answers.' } +]; -export function createGame(names: Record, seed = Date.now(), rules = CURRENT_RULES, _options?: TableOptions): State { +export const SETUPS: { value: Setup; label: string; note: string }[] = [ + { value: 'printed', label: 'As printed in 1985', note: 'The Sylphs stand on a, c, e, g, i and k. Gold’s Dragon can take one on the first move.' }, + { value: 'shifted', label: 'Sylphs shifted one file', note: 'The Sylphs stand over the Dwarves, on b, d, f, h, j and l, out of the Dragons’ reach.' } +]; + +export const sideName = (side: Side): string => (side === 'gold' ? 'Gold' : 'Scarlet'); + +export function createGame(names: Record, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State { const seats = Object.keys(names); - return { - rules, - seats, - players: Object.fromEntries(seats.map((id) => [id, { id, name: names[id], points: 0 }])), - rounds: [], - over: null, - rng: seed >>> 0 || 1 - }; + const hostSide: Side = options?.side === 'scarlet' ? 'scarlet' : 'gold'; + const setup: Setup = options?.setup === 'shifted' ? 'shifted' : 'printed'; + const players: Record = {}; + seats.forEach((id, n) => { + players[id] = { id, name: names[id], side: n === 0 ? hostSide : other(hostSide) }; + }); + const cells = opening(setup); + return { rules, setup, seats, players, cells, toMove: 'gold', moves: [], seen: { [positionKey(cells, 'gold')]: 1 }, quiet: 0, end: null, over: null }; } -export function resolveRound(previous: State, inputs: Record): State { +export const positionKey = (cells: Cell[], toMove: Side): string => cells.join(',') + '|' + toMove; + +export function seatOf(state: State, side: Side): SeatId | null { + return state.seats.find((id) => state.players[id].side === side) ?? null; +} + +export function playerName(state: State, side: Side): string { + const seat = seatOf(state, side); + return seat ? state.players[seat].name : sideName(side); +} + +/** The end of the game in words a player reads, and the key the tally counts. */ +function finish(state: State, end: End, winner: Side | null): void { + state.end = end; + const loser = winner ? other(winner) : state.toMove; + const reason = + end === 'mate' + ? `${playerName(state, loser)}’s King has nowhere left to go. Checkmate, and the game is ${playerName(state, winner!)}’s.` + : end === 'stalemate' + ? `${playerName(state, loser)}’s King is not in check, but nothing of ${playerName(state, loser)}’s can move. Stalemate, and the game is drawn.` + : end === 'repetition' + ? 'The same position has come round for the third time. The game is drawn.' + : 'Fifty moves have passed on each side without a capture. The game is drawn.'; + state.over = { winner: winner ? seatOf(state, winner) : null, reason }; +} + +/** Play one move, taken to be legal, and settle whether the game is over. */ +export function applyMove(previous: State, from: number, to: number): State { const state = structuredClone(previous); - if (state.over) return state; - const picks: Record = {}; - for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1; - const counts = new Map(); - for (const id of state.seats) counts.set(picks[id], [...(counts.get(picks[id]) ?? []), id]); - let scorer: SeatId | null = null; - for (let n = HIGHEST; n >= 1 && !scorer; n--) { - const who = counts.get(n); - if (who?.length === 1) scorer = who[0]; - } - if (scorer) state.players[scorer].points += 1; - state.rounds.push({ picks, scorer }); - if (scorer && state.players[scorer].points >= TARGET) { - state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` }; - } + const by = state.toMove; + const kind = kindOf(state.cells[from]); + const played = playOn(state.cells, from, to); + state.cells = played.cells; + const next = other(by); + const check = inCheck(state.cells, next); + state.moves.push({ by, from, to, kind, captured: played.captured, afar: played.afar, promoted: played.promoted, check }); + state.toMove = next; + state.quiet = played.captured ? 0 : state.quiet + 1; + const key = positionKey(state.cells, next); + state.seen[key] = (state.seen[key] ?? 0) + 1; + if (!hasLegalMove(state.cells, next, state.setup)) finish(state, check ? 'mate' : 'stalemate', check ? by : null); + else if (state.seen[key] >= 3) finish(state, 'repetition', null); + else if (state.quiet >= QUIET_LIMIT) finish(state, 'quiet', null); return state; } +/** Why a seat may not make this move now, in words; null when it may. */ +export function validateMove(state: State, seat: SeatId, input: Input): string | null { + if (state.over) return 'The game is over.'; + const player = state.players[seat]; + if (!player) return 'You have no seat at this table.'; + if (player.side !== state.toMove) return `It is ${playerName(state, state.toMove)}’s move.`; + const { from, to } = input; + if (!Number.isInteger(from) || from < 0 || from >= state.cells.length || !Number.isInteger(to) || to < 0 || to >= state.cells.length) return 'That is not a square.'; + const cell = state.cells[from]; + if (cell === '' || sideOf(cell) !== player.side) return 'That is not one of your pieces.'; + if (!movesFrom(state.cells, from, state.setup).includes(to)) return `The ${NAMES[kindOf(cell)]} cannot go to ${squareName(to)}.`; + if (!legalMovesFrom(state.cells, from, state.setup).includes(to)) return 'That would leave your King in check.'; + return null; +} + +export function resolveRound(previous: State, inputs: Record): State { + if (previous.over) return previous; + const seat = seatOf(previous, previous.toMove); + const input = seat ? inputs[seat] : undefined; + if (!seat || !input || validateMove(previous, seat, input)) return previous; + return applyMove(previous, input.from, input.to); +} + +/** The move told as the chronicle tells it. */ +export function describe(state: State, move: Move): string { + const who = playerName(state, move.by); + const piece = NAMES[move.kind]; + const level = (i: number) => (levelOf(i) === 3 ? 'in the air' : levelOf(i) === 1 ? 'underground' : 'on the land'); + let text: string; + if (move.afar) text = `${who}’s Dragon strikes down from ${squareName(move.from)} and takes the ${NAMES[move.captured!]} at ${squareName(move.to)}.`; + else if (move.captured) { + const crossing = levelOf(move.from) !== levelOf(move.to); + text = `${who}’s ${piece} ${crossing ? (levelOf(move.to) > levelOf(move.from) ? 'climbs' : 'drops') : 'goes'} from ${squareName(move.from)} to ${squareName(move.to)} and takes the ${NAMES[move.captured]}.`; + } else if (levelOf(move.from) !== levelOf(move.to)) { + text = `${who}’s ${piece} ${levelOf(move.to) > levelOf(move.from) ? 'climbs' : 'drops'} from ${squareName(move.from)} to ${squareName(move.to)}, ${level(move.to)}.`; + } else text = `${who}’s ${piece} goes from ${squareName(move.from)} to ${squareName(move.to)}.`; + if (move.promoted) text += ` It reaches the far rank and stands up a Hero.`; + if (move.check) text += ' Check.'; + return text; +} + +/** The move in Gygax's notation: piece, from, x, to, with (H), "afar" and ch as he wrote them. */ +export function notation(move: Move): string { + const piece = LETTERS[move.kind]; + let text = move.afar ? `${piece}x${squareName(move.to)}` : `${piece}/${squareName(move.from)}${move.captured ? 'x' : '-'}${squareName(move.to)}`; + if (move.promoted) text += '(H)'; + if (move.check) text += ' ch'; + return text; +} + export const game: GameSpec = { - seatIds: 'ABCDEFGH'.split(''), + seatIds: ['A', 'B'], minSeats: 2, - botNames: ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'], + botNames: ['Fafnir', 'Nidhogg', 'Ladon', 'Vritra', 'Apep', 'Jormungand'], currentRules: CURRENT_RULES, + options: [ + { key: 'side', label: 'You play', choices: SIDES, default: 'gold' }, + { key: 'setup', label: 'The Sylphs', choices: SETUPS, default: 'printed' } + ], + preferences: [ + { key: 'coordinates', label: 'Square names on the boards', kind: 'toggle', default: true }, + { key: 'letters', label: 'Pieces as letters', kind: 'toggle', default: false, note: 'Gygax’s initials instead of the drawn pieces.' } + ], create: createGame, resolve: resolveRound, - needsInput: (state) => !state.over, + needsInput: (state, seat) => !state.over && state.players[seat]?.side === state.toMove, over: (state) => state.over, - turn: (state) => state.rounds.length + 1, - // Every finished round is public; nothing is hidden, so the gallery sees what a seat sees. - view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)), - // A function of the state alone: the seed, the round and the seat, so a replay draws the same pick. - botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }), - cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }), - validate: (_state, _seat, input) => - Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`, - nameOf: (state, seat) => state.players[seat]?.name ?? seat + turn: (state) => state.moves.length + 1, + // Nothing is hidden on any board, so every viewer sees the whole state. + view: (state) => state, + botInput: (state, seat) => { + const side = state.players[seat]?.side ?? state.toMove; + return chooseMove(state.cells, side, state.setup) ?? { from: -1, to: -1 }; + }, + cleanInput: (raw) => { + const r = raw as { from?: unknown; to?: unknown }; + const n = (v: unknown) => (Number.isInteger(v) ? (v as number) : -1); + return { from: n(r?.from), to: n(r?.to) }; + }, + validate: validateMove, + nameOf: (state, seat) => state.players[seat]?.name ?? seat, + tally: (state) => { + const lines: Record = {}; + if (state.end === 'mate') lines['ended in checkmate'] = 1; + else if (state.end === 'stalemate') lines['ended in stalemate'] = 1; + else if (state.end === 'repetition') lines['drawn by repetition'] = 1; + else if (state.end === 'quiet') lines['drawn after fifty quiet moves'] = 1; + if (state.over?.winner) lines[`won by ${sideName(state.players[state.over.winner].side)}`] = 1; + lines['moves played'] = state.moves.length; + return lines; + }, + tallyBegun: (options) => ({ [options.setup === 'shifted' ? 'begun with the Sylphs shifted' : 'begun as printed']: 1 }) }; + +export { cellOf };