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:
Eric WagonerandClaude Fable 5.1 committed 2026-10-26 16:25:07 -04:00
1 parent d91b95cfab
commit 1ef5b23712
5 files changed
+1751 -86

No files matched your search

+703
View File
@@ -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.
+363
View File
@@ -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;
}
+151
View File
@@ -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
View File
@@ -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
View File
@@ -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 };