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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
d91b95cfab
commit
1ef5b23712
5 files changed
+1751
-86
No files matched your search
+703
@@ -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.
|
||||||
@@ -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<Kind, string> = {
|
||||||
|
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<Kind, string> = {
|
||||||
|
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<Kind, number> = {
|
||||||
|
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<Record<Kind, number>> {
|
||||||
|
const count: Partial<Record<Kind, number>> = {};
|
||||||
|
for (const cell of cells) if (cell !== '' && sideOf(cell) === side) count[kindOf(cell)] = (count[kindOf(cell)] ?? 0) + 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
@@ -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<Kind, number> = {
|
||||||
|
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] };
|
||||||
|
}
|
||||||
|
|
||||||
+341
-26
@@ -1,36 +1,351 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
/** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */
|
||||||
it('scores the highest number named by exactly one seat', () => {
|
function position(pieces: Record<string, string>, toMove: Side = 'gold'): State {
|
||||||
let s = createGame({ A: 'Ann', B: 'Bo', C: 'Cy' }, 1);
|
const s = createGame({ A: 'Aud', B: 'Bjorn' });
|
||||||
s = resolveRound(s, { A: { pick: 5 }, B: { pick: 5 }, C: { pick: 2 } });
|
s.cells = new Array(CELLS).fill('');
|
||||||
expect(s.rounds[0].scorer).toBe('C');
|
for (const [name, cell] of Object.entries(pieces)) s.cells[square(name)] = cell;
|
||||||
expect(s.players.C.points).toBe(1);
|
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', () => {
|
const names = (cells: Cell[], from: string) => movesFrom(cells, square(from)).map(squareName).sort();
|
||||||
let s = createGame({ A: 'Ann', B: 'Bo' }, 1);
|
const sorted = (list: string[]) => [...list].sort();
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws the same bot picks from the same seed, and different ones as rounds pass', () => {
|
describe('the opening', () => {
|
||||||
const a = createGame({ A: 'Ann', B: 'Bo' }, 42);
|
it('seats 42 pieces a side where the diagrams put them', () => {
|
||||||
const b = createGame({ A: 'Ann', B: 'Bo' }, 42);
|
const s = createGame({ A: 'Aud', B: 'Bjorn' });
|
||||||
expect(game.botInput(a, 'B')).toEqual(game.botInput(b, 'B'));
|
for (const side of ['gold', 'scarlet'] as const) {
|
||||||
const picks = new Set<number>();
|
const count = census(s.cells, side);
|
||||||
let s = a;
|
expect(Object.values(count).reduce((a, b) => a + b, 0)).toBe(42);
|
||||||
for (let i = 0; i < 12; i++) {
|
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 });
|
||||||
picks.add(game.botInput(s, 'B').pick);
|
|
||||||
s = resolveRound(s, { A: { pick: 1 }, B: game.botInput(s, 'B') });
|
|
||||||
}
|
}
|
||||||
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', () => {
|
it('stamps the rules revision', () => {
|
||||||
expect(createGame({ A: 'Ann', B: 'Bo' }).rules).toBe(CURRENT_RULES);
|
expect(createGame({ A: 'Aud', B: 'Bjorn' }).rules).toBe(CURRENT_RULES);
|
||||||
expect(createGame({ A: 'Ann', B: 'Bo' }, 1, 1).rules).toBe(1);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+193
-60
@@ -1,94 +1,227 @@
|
|||||||
// The demo game, "High Card": every round each seat names a number from one
|
// Wyrm: Gary Gygax's Dragonchess (Dragon Magazine No. 100, August 1985)
|
||||||
// to five; the highest number named by exactly one seat scores it a point,
|
// on three boards of twelve by eight. Two sides, Gold and Scarlet, Gold
|
||||||
// and the first to three points wins. It exists to show the shape of a game
|
// first, checkmate to win. The text the engine follows is docs/source.md,
|
||||||
// this kit can run: simultaneous hidden moves, a seeded bot, a view that
|
// and the mechanics of every piece are in board.ts; this file is the game
|
||||||
// withholds the round in progress, and a rules revision. Replace this file
|
// around them: seats, turns, the ends, and the GameSpec the kit runs.
|
||||||
// with your own game and keep the GameSpec shape.
|
|
||||||
|
|
||||||
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 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 {
|
export interface Player {
|
||||||
id: SeatId;
|
id: SeatId;
|
||||||
name: string;
|
name: string;
|
||||||
points: number;
|
side: Side;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Round {
|
/** One ply as the record keeps it. */
|
||||||
picks: Record<SeatId, number>;
|
export interface Move {
|
||||||
scorer: SeatId | null;
|
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 {
|
export interface State {
|
||||||
rules: number;
|
rules: number;
|
||||||
|
setup: Setup;
|
||||||
seats: SeatId[];
|
seats: SeatId[];
|
||||||
players: Record<SeatId, Player>;
|
players: Record<SeatId, Player>;
|
||||||
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<string, number>;
|
||||||
|
/** Plies since the last capture. */
|
||||||
|
quiet: number;
|
||||||
|
end: End | null;
|
||||||
over: Outcome | 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. */
|
export const SIDES: { value: Side; label: string; note: string }[] = [
|
||||||
function random(seed: number): number {
|
{ value: 'gold', label: 'Gold, and move first', note: 'Gold opens the game.' },
|
||||||
let t = (seed + 0x6d2b79f5) >>> 0;
|
{ value: 'scarlet', label: 'Scarlet', note: 'Scarlet answers.' }
|
||||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
];
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGame(names: Record<SeatId, string>, 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<SeatId, string>, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State {
|
||||||
const seats = Object.keys(names);
|
const seats = Object.keys(names);
|
||||||
return {
|
const hostSide: Side = options?.side === 'scarlet' ? 'scarlet' : 'gold';
|
||||||
rules,
|
const setup: Setup = options?.setup === 'shifted' ? 'shifted' : 'printed';
|
||||||
seats,
|
const players: Record<SeatId, Player> = {};
|
||||||
players: Object.fromEntries(seats.map((id) => [id, { id, name: names[id], points: 0 }])),
|
seats.forEach((id, n) => {
|
||||||
rounds: [],
|
players[id] = { id, name: names[id], side: n === 0 ? hostSide : other(hostSide) };
|
||||||
over: null,
|
});
|
||||||
rng: seed >>> 0 || 1
|
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<SeatId, Input>): 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);
|
const state = structuredClone(previous);
|
||||||
if (state.over) return state;
|
const by = state.toMove;
|
||||||
const picks: Record<SeatId, number> = {};
|
const kind = kindOf(state.cells[from]);
|
||||||
for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1;
|
const played = playOn(state.cells, from, to);
|
||||||
const counts = new Map<number, SeatId[]>();
|
state.cells = played.cells;
|
||||||
for (const id of state.seats) counts.set(picks[id], [...(counts.get(picks[id]) ?? []), id]);
|
const next = other(by);
|
||||||
let scorer: SeatId | null = null;
|
const check = inCheck(state.cells, next);
|
||||||
for (let n = HIGHEST; n >= 1 && !scorer; n--) {
|
state.moves.push({ by, from, to, kind, captured: played.captured, afar: played.afar, promoted: played.promoted, check });
|
||||||
const who = counts.get(n);
|
state.toMove = next;
|
||||||
if (who?.length === 1) scorer = who[0];
|
state.quiet = played.captured ? 0 : state.quiet + 1;
|
||||||
}
|
const key = positionKey(state.cells, next);
|
||||||
if (scorer) state.players[scorer].points += 1;
|
state.seen[key] = (state.seen[key] ?? 0) + 1;
|
||||||
state.rounds.push({ picks, scorer });
|
if (!hasLegalMove(state.cells, next, state.setup)) finish(state, check ? 'mate' : 'stalemate', check ? by : null);
|
||||||
if (scorer && state.players[scorer].points >= TARGET) {
|
else if (state.seen[key] >= 3) finish(state, 'repetition', null);
|
||||||
state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
|
else if (state.quiet >= QUIET_LIMIT) finish(state, 'quiet', null);
|
||||||
}
|
|
||||||
return state;
|
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<SeatId, Input>): 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<State, Input> = {
|
export const game: GameSpec<State, Input> = {
|
||||||
seatIds: 'ABCDEFGH'.split(''),
|
seatIds: ['A', 'B'],
|
||||||
minSeats: 2,
|
minSeats: 2,
|
||||||
botNames: ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'],
|
botNames: ['Fafnir', 'Nidhogg', 'Ladon', 'Vritra', 'Apep', 'Jormungand'],
|
||||||
currentRules: CURRENT_RULES,
|
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,
|
create: createGame,
|
||||||
resolve: resolveRound,
|
resolve: resolveRound,
|
||||||
needsInput: (state) => !state.over,
|
needsInput: (state, seat) => !state.over && state.players[seat]?.side === state.toMove,
|
||||||
over: (state) => state.over,
|
over: (state) => state.over,
|
||||||
turn: (state) => state.rounds.length + 1,
|
turn: (state) => state.moves.length + 1,
|
||||||
// Every finished round is public; nothing is hidden, so the gallery sees what a seat sees.
|
// Nothing is hidden on any board, so every viewer sees the whole state.
|
||||||
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
|
view: (state) => state,
|
||||||
// A function of the state alone: the seed, the round and the seat, so a replay draws the same pick.
|
botInput: (state, seat) => {
|
||||||
botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
|
const side = state.players[seat]?.side ?? state.toMove;
|
||||||
cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }),
|
return chooseMove(state.cells, side, state.setup) ?? { from: -1, to: -1 };
|
||||||
validate: (_state, _seat, input) =>
|
},
|
||||||
Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`,
|
cleanInput: (raw) => {
|
||||||
nameOf: (state, seat) => state.players[seat]?.name ?? seat
|
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<string, number> = {};
|
||||||
|
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 };
|
||||||
Reference in new issue
Block a user