Commit 0d17ecc5 authored by Brady James Garvin's avatar Brady James Garvin
Browse files

Initial commit.

parents
# Disable line-ending conversions for this repository.
* -text
# dependencies
/node_modules
# testing
/coverage
# production
/build
# environments
.env.local
.env.development.local
.env.test.local
.env.production.local
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# misc
*~
.DS_Store
[submodule "stylelint-config"]
path = stylelint-config
url = git@git.unl.edu:soft-core/soft-260/stylelint-config.git
[submodule "eslint-config"]
path = eslint-config
url = git@git.unl.edu:soft-core/soft-260/eslint-config.git
# Boost Board Game Application (Fall 2021 Version)
A progressive web app (PWA) for learning and playing the board game Boost.
Boost is a turn-based abstract strategy board game like checkers, chess,
Xiangqi, or Shōgi. It was designed to be new and interesting for humans to play
while still admitting a simple AI and supporting various homework assignments on
algorithms and data structures in the SOFT 260 course at UNL.
# Quick Start
Recursively clone this repository and `cd` into the root folder:
```
$ git clone --recursive git@git.unl.edu:soft-core/soft-260/boost-board-game.git
$ cd boost-board-game
```
(If you forget `--recursive` when cloning, you can `cd` into your clone and run
`git submodule update --init --recursive` instead.)
Install dependencies:
```
$ npm install
```
(Near the end you may see some warnings because `create-react-app` transitively
depends on some deprecated packages.)
Optionally run the test suites to make sure everything so far is okay:
```
$ npm run test
```
And then serve the application locally:
```
$ npm start
```
Once the app is running, click on the "Rules" button to read the rules of the
game.
When you are done, press control-c to stop the server.
# Development Workflow
The project includes a VSCode workspace named `boost-board-game.code-workspace`.
Most development tasks can be completed from within VSCode, though command-line
development is also possible. The subsections below describe how to perform
common development tasks.
## Code Generation
As described later in the architecture overview, some projects depend on code
generated by other projects. Normally this code generation happens
automatically when you lint, test, build, or run the app, but if you need to
force an update (for example, if you change the game or engine code while the
app is still running), you can use a project's `generate` script. In VSCode,
the `generate` scripts can be run from the "NPM SCRIPTS" tray, where the
`generate` script in the outermost `package.json` generates code for all
projects. Or, on the command line:
```
$ (cd …; npm run generate)
```
for a specific project or
```
$ npm run generate
```
for all projects.
## Linting
VSCode should automatically detect each project's stylelint and ESLint
configurations and dependencies and run those tools on the fly as you edit the
code, though you may have to tell VSCode to trust the project the first time you
open it.
Alternatively, you can also manually lint. In VSCode, the `lint` scripts can be
run from the "NPM SCRIPTS" tray, where the `lint` script in the outermost
`package.json` generates code for all projects. Or, on the command line:
```
$ (cd …; npm run lint)
```
for a specific project or
```
$ npm run lint
```
for all projects.
Because of a Git precommit hook, linting happens automatically when you commit,
and commits are blocked if there are any linting errors.
## Unit Testing
Individual projects' unit test suites are set up to run in "watch" mode by
default, which means that you can start the test script once, and it will rerun
the test suite every time you save a code change. In VSCode, the `test` scripts
can be run from the "NPM SCRIPTS" tray. Or, on the command line:
```
$ (cd …; npm run test)
```
If you want to run the tests only once and not watch for code changes, you can
use the `test-once` scripts. On the command line:
```
$ (cd …; npm run test-once)
```
Alternatively, to run the unit tests once for all projects, run either the
`test` or the `test-once` script from the outermost `package.json`. From the
command line:
```
$ npm run test
```
On the command line only, if you want to only run tests whose names match some
text, you can pass the standard `-t` option to a project-specific `test` or
`test-once` script:
```
$ (cd …; npm run test -- -t '[some test-name text]')
```
## System Testing
The main app is set up to run in "watch" mode, which means that you can start it
once, and it will refresh the page in your browser every time you save a code
change. From VSCode, run the outermost `package.json`'s `start` script from the
"NPM SCRIPTS" tray. Or, on the command line:
```
$ npm run start
```
Alternatively, the `start` script in the `boost-app` project also starts the
app, but without rebuilding the engine. It can be run either from the "NPM
SCRIPTS" tray or the command line:
```
$ (cd boost-app; npm run start)
```
Automatic refreshes due to code changes will *not* clear any persisted Redux
state. Because the app uses the local storage engine from `redux-persist` for
persistence, you can clear your data during development by running
`localStorage.clear()` in the developer console and then manually refreshing the
page.
## Deployment
The main app runs entirely client-side, so it can be deployed as a `build`
folder to be placed on any hosting platform. From VSCode, run the the outermost
`package.json`'s `build` script from the "NPM SCRIPTS" tray. Or, on the command
line:
```
$ npm run build
```
Alternatively, the `build` script in the `boost-app` project also builds the
app, but without rebuilding the engine. It can be run either from the "NPM
SCRIPTS" tray or the command line:
```
$ (cd boost-app; npm run build)
```
At the end of either command's output you should see a link to further
deployment instructions.
# Architecture Overview
The code for the Boost board game application is organized as follows:
* The project `@unlsoft/stylelint-config` contains the stylelint configuration
for the CSS coding style used across the other projects.
* The project `@unlsoft/eslint-config` contains the ESLint configuration for
the JavaScript coding style used across the other projects. Per
`create-react-app` convention, in a development build of the main app, a
separate, weaker coding style also warns at runtime about likely bugs.
* The project `@unlsoft/boost-game` is responsible for representing positions
as bit boards and for features that rely on bit-board operations to run
acceptably fast, primarily move generation and static evaluation. Because
so much of the bit-board logic can be precomputed and unrolled,
`@unlsoft/boost-game` does not implement game logic itself, but is actually
a parameterized code generator that writes game logic into separate
JavaScript files.
* The project `@unlsoft/boost-engine` contains the game-playing engine, the
application's AI. Like other abstract strategy board game engines, this
engine is designed to run in a separate thread or process from any UI, and
it communicates with a controller using a protocol called BEI (see the
protocol documentation further below). The engine has a library of games
that it knows how to play, and its build system automatically generates the
source code for each of these games using `@unlsoft/boost-game`.
* The project `@unlsoft/boost-app` is a `create-react-app` PWA that provides
the application's game controller and UI. Like `@unlsoft/boost-engine` it
relies on a game library generated by `@unlsoft/boost-game`, and it also
includes `@unlsoft/boost-engine` as a suite of web workers via symlink.
# Generating `Game` Objects with `boost-game`
When `@unlsoft/boost-game` is installed as a development dependency, it provides
a command `generate-boost-game` that writes a JavaScript implementation of a
Boost game to standard out. The exact code produced depends on a number of
command-line options, described below. You can also run `generate-boost-game
--help` to see a list of all of the above options, their short descriptions, and
their default values.
## Command-Line Options Affecting Game Rules
There are six main command-line options that can be passed to
`generate-boost-game` to control what kind of game is generated:
* `--board-width [number]` sets the width of the game board in points. If
omitted, the default width of nine points is used.
* `--board-height [number]` sets the height of the game board in points. If
omitted, the default height of nine points is used.
* `--player-count [number]` sets the number of players (and therefore the
number of non-dragon piece colors). If omitted, the default of two players
is used.
* `--starting-population-limit [number]` sets the maximum number of starting
pawns given to each player in a standard board setup. (It is only a maximum
because the game code may give players fewer starting pieces when the
board's perimeter is too crowded.) If omitted, the default limit of eight
pawns is used.
* `--tower-limit [number]` sets the maximum number of towers that each player
may build. (This limit only affects when construction moves are available
to a player; it does not preclude other code from placing extra towers on
the board.) If omitted, the default limit of two towers is used.
* `--demo` overrides the usual rules to allows players to move their pieces
even when they are defeated. This can be useful both in testing and
tutorials for keeping the number of pieces on the board down.
Based on these six options, the game will be assigned a unique string of the
form `boost-[width]-[height]-[players]-[population]-[towers]` or of the form
`boost-[width]-[height]-[players]-[population]-[towers]-demo`, which is called
its **game identifier**. For example, the game identifier for a standard Boost
game is `boost-9-9-2-8-2`.
## Command-Line Options Affecting the AI
Other command-line options are also available to tweak the weights in the static
evaluation function, which is the function used by `@unlsoft/boost-engine` to
estimate how favorable a position is for each player. The defaults values, used
when these options are omitted, were chosen by human intuition and are given in
decitempi (tenths of an extra turn). However, these defaults have not been
empirically validated yet. If they are changed, care must be taken to consider
what behavior is incentivized by their relative values. For example, if the
value for being in book is too high relative to the value of a tower, the AI
might forego its second tower to shuffle its pieces instead of exiting the book
line cleanly.
The command-line options for static evaluation weights are as follows:
* `--ai-pawn [number]` sets the estimated value of a pawn. The default value
is 200 decitempi (20 extra turns).
* `--ai-knight [number]` sets the estimated value of a knight when a player
has all of their towers. The default value is 220 decitempi (a pawn plus
two extra turns).
* `--ai-endgame-knight [number]` sets the estimated value of a knight when a
player does not have all of their towers. The game also computes an
appropriate fixed penalty for a player not having all of their towers so
that the AI is not incentivized to sacrifice its towers to make its knights
more valuable. The default value is 350 decitempi (a pawn plus 15 extra
turns).
* `--ai-knight-activity [number]` sets the estimated value of moving a knight
one point closer to the closest non-friendly piece (i.e., a dragon or a
foe). The default value is 5 decitempi (an extra half turn).
* `--ai-zero-activity-distance [number]` sets the distance, in points, that
must lie between a knight and the closest non-friendly piece for that knight
to have an activity score of zero. As a knight moves closer to the
non-friendly piece, its activity score becomes positive (at the rate given
by `--ai-knight-activity`), and as it moves farther away, its activity score
turns negative (at the same rate). The main use of this parameter is to
control the relative value of pawns and inactive knights; because an
inactive knight prevents a player from promoting a better-positioned pawn,
the knight can actually be a liability. The default value is four points
(so a knight eight points from the nearest non-friendly piece is estimated
to be worth the same as a pawn).
* `--ai-edge-knight-penalty [number]` sets the estimated decrease in value for
having a knight on the edge of the board. The default value is 30 decitempi
(a loss of three turns).
* `--ai-in-book [number]` sets the estimated value of remaining in book during
the opening. (An opening book is a precomputed policy for how to play
certain opening positions, and in Boost the opening book usually covers
lines up through construction of a second tower. If the AI is "in book", it
has a position covered by that policy, though it is still free to improvise
variations; if it is "out of book", then it must rely on search alone.) The
default value is 30 decitempi (3 turns).
* `--ai-construction-site [number]` sets the estimated value of a completed
construction site (a circle of four friendly pieces around an empty point).
Partial construction sites are awarded partial points: 1/16 value for a
construction site with one piece in place, 4/16 value for a construction
site with two pieces in place, and 9/16 value for a construction site with
three pieces in place. The number of construction sites that the AI can
score for each side is limited by the number of additional towers that the
player can usefully build. Additionally, construction sites are ineligible
for scoring if they are not within four points of four friendly mobile
pieces. The default value is 80 decitempi (8 turns).
* `--ai-tower [number]` sets the estimated value of a tower when a player has
at least four mobile pieces. The default value is 125 decitempi (12.5 extra
turns).
* `--ai-endgame-tower [number]` sets the estimated value of a tower when a
player does not have at least four mobile pieces. The game also computes an
appropriate fixed penalty for a player not having four mobile pieces so that
the AI is not incentivized to sacrifice its pawns and knights to make its
towers more valuable. The default value is 700 decitempi (70 extra turns).
* `--ai-dragon-proximity [number]` sets the estimated value of moving a dragon
one point closer to the closest friendly tower when there are at least four
dragons on the board. The default value is 10 decitempi (an extra turn).
* `--ai-tight-spot-penalty [number]` sets the estimated decrease in value for
having a dragon in a "tight spot"—diagonally between a tower and an edge of
the board. The default value is 30 decitempi (three extra turns).
* `--ai-circle-dragon [number]` sets the estimated additional value of moving
a dragon next to a friendly tower when there are at least four dragons on
the board. The default value is 100 decitempi (ten extra turns).
* `--ai-crowd-member [number]` sets the estimated additional value of a piece
that has at least three other friendly pieces at most four points away. The
default value is 1 decitempo (one tenth of an extra turn).
* `--ai-defeat [number]` sets the estimated value of defeating one opponent of
many. The default value is 1,000,000 decitempi (100,000 extra turns).
* `--ai-victory [number]` sets the estimated value of defeating all opponents.
The default value is 10,000,000 decitempi (1,000,000 extra turns).
## Typical Usage
By convention, one usually redirects the output of `generate-boost-game` to
JavaScript files in a `…/src/games` folder and then writes code like
```
import …_GAME from './games/….js';
import …_GAME from './games/….js';
const GAME_LIBRARY = new Map([
…_GAME,
…_GAME,
].map((game) => [game.identifier, game]));
export default GAME_LIBRARY;
```
in `…/src/gameLibrary.js` so that other code can import the game library and
look up games by their identifiers.
# Using `Game` Objects from `@unlsoft/boost-game`
`Game` objects from a game library provide the following fields and methods:
## Constants from Game Generation
These constants are set at code generation time, as described earlier:
* `game.identifier` is the identifier for the game, as described in the
previous section.
* `game.boardWidth` is the width, in points, of the board on which the game is
played.
* `game.boardHeight` is the height, in points, of the board on which the game is
played.
* `game.playerCount` is the number of players in the game.
* `game.populationLimit` is the maximum number of starting pawns given to each
player in a standard board setup.
* `game.towerLimit` is the maximum number of towers that each player may
build. (This limit only affects when construction moves are available to a
player; it does not preclude other code from placing extra towers on the
board.)
* `game.victory` is the static evaluation returned by `getStaticEvaluation`
(see its description in the section on static evaluation) if the player has
won the game.
## Piece Types
These constants represent the different piece types, which are returned by
`position.getColorAndPieceType` and passed to `position.modified` as described
later:
* `game.dragon` is a constant value used to represent a dragon.
* `game.pawn` is a constant value used to represent a pawn.
* `game.knight` is a constant value used to represent a knight.
* `game.tower` is a constant value used to represent a tower.
Although they must be serializable and are therefore not `Symbol`s, they should
still be considered opaque. Do not write code depending on them having
particular values.
## Algebraic Notation
The following fields and helper methods are useful for encoding and decoding
algebraic notation for files, ranks, points, and moves:
* `game.prettifyFile(file)` takes a file as a zero-based 𝑥 coordinate and
returns the corresponding letter in algebraic notation.
* `game.unprettifyFile(file)` takes a file as a letter in algebraic notation
and returns the corresponding zero-based 𝑥 coordinate.
* `game.prettifyRank(rank)` takes a rank as a zero-based 𝑦 coordinate and
returns the corresponding digits in algebraic notation.
* `game.unprettifyRank(rank)` takes a rank as a string of digits in algebraic
notation and returns the corresponding zero-based 𝑦 coordinate.
* `game.noPoint` is the constant string representing no point at all in
algebraic notation.
* `game.prettifyPoint(file, rank)` takes a point as zero-based 𝑥 and 𝑦
coordinates and returns the corresponding algebraic notation. If either
coordinate is `undefined`, it returns `game.noPoint`.
* `game.unprettifyPoint(point)` takes a point in algebraic notation and
returns the corresponding zero-based 𝑥 and 𝑦 coordinates in an `Array`. If
the point is `game.noPoint`, both coordinates will be `undefined`.
* `game.pass` is the constant string representing a pass in algebraic
notation.
* `game.joinPoints(fromPoint, toPoint)` takes two points in algebraic notation
and returns the algebraic notation for a move from `fromPoint` to `toPoint`.
For constructions and promotions, one point may be omitted or set to
`game.noPoint`, or the two points may be set equal to each other. (For
consistency with `game.splitMove` and the main app's UI, the preferred
practice is to give the same point for both arguments.) For passes, both
points should be omitted or set to `game.noPoint`. (For consistency with
`game.splitMove`, the preferred practice is to pass `game.noPoint` for both
arguments.)
* `game.splitMove(move)` takes a move in algebraic notation and returns the
algebraic notation for its "from" and "to" points in an `Array`. For
constructions and promotions, the "from" point will be the same as the "to"
point. For passes, both points will be `game.noPoint`.
* `prettifyMove(fromFile, fromRank, toFile, toRank)` takes a move as
zero-based 𝑥 and 𝑦 coordinates for its "from" and "to" points and returns
the algebraic notation for the move. Coordinates may be omitted or
`undefined` for special moves in the same places where `game.joinPoints`
would take `game.noPoint`.
* `unprettifyMove(move)` takes a move in algebraic notation and returns the
corresponding zero-based 𝑥 and 𝑦 coordinates for its "from" and "to" points.
Coordinates will be repeated for constructions and promotions, and will be
`undefined` for passes.
## Positions
The following fields and helper method are useful for obtaining `Position`
objects corresponding to the game:
* `game.blankPosition` is a position containing no pieces.
* `game.startingPosition` is the game's standard starting position without any
dragons.
* `game.deserializePosition(serialization)` deserializes a position previously
encoded as a string with `position.serialization`, a property described
further below.
# Using `Position` Objects from `@unlsoft/boost-game`
`Position` objects corresponding to a game provide the following fields and
methods:
## Encodings
* `position.signature` is the position encoded as a single `BigInt`. This
encoding is meant to be used as a reasonably fast hash-table key. (The
position itself cannot be a hash-table key because ES6 does not support
hash-by-value natively.)
* `position.nextSignature` is equivalent to `position.nextTurn.signature` (see
the description of `position.nextTurn` in the "Moves" subsection further
below), but runs faster because it does not actually advance the turn. It
is mostly useful for checking candidate moves for repetitions.
* `position.serialization` is the position serialized as a string. A position
can later be deserialized with `game.deserializePosition` as described
earlier.
## Pieces
* `position.getColorAndPieceType(x, y)` returns the color and piece type of
the piece at the zero-based coordinates `x` and `y` as a two-element
`Array`. Colors are represented by the number of turns until the
corresponding player will play (e.g., `0` for the player whose turn it is
and `1` for the other player in a two-player game), and piece types are
represented with the constants `game.dragon`, `game.pawn`, `game.knight`,
and `game.tower` described earlier. Dragon's colors are always `undefined`.
If there is no piece at the given coordinates, both the color and piece type
will be `undefined`.
* `position.modified(x, y, color, pieceType)` returns a new position
(`Position` objects are immutable) where the piece at the zero-based
coordinates `x` and `y` has been replaced with a piece of the given color
and piece type. As above, the color should be given as the number of turns
until the corresponding player moves next (e.g., `0` for the player whose
turn it is and `1` for the other player in a two-player game), and piece
types should be given as one of the constants `game.dragon`, `game.pawn`,
`game.knight`, or `game.tower`, which are also described earlier. As
special cases, `color` should be `undefined` if `pieceType` is
`game.dragon`, and a point can be cleared by passing `undefined` for both
`color` and `pieceType`.
## Static Evaluation
* `position.getStaticEvaluation(color)` is the static evaluation of the
position from the perspective of the player who is to play in `color` turns
(hence, the possible values for `color` are the same as the values returned
by `position.getColorAndPieceType` or passed to `position.modified`).
Higher static evaluation scores are more favorable for that player. A score
equal to `game.victory` means that the player to move next has already won.
* `position.inBookBonus` is the adjustment to make to a static evaluation when
a player is in book (see `--ai-in-book` above). This value is provided
separately because the opening book, if any, is the engine's responsibility,
and the `getStaticEvaluation` method only gives a bookless evaluation.
* `position.live` is `true` if the game is still ongoing, `false` if any
player has won.
## Moves
* `position.children` is the list of positions that the current player can
move to, not considering the repetition rule. Note that it is the same
player's turn in the children positions as in the parent position; a turn is
not considered complete until the code uses `position.nextTurn`.
* `position.getEncodedMoveTo(child)` returns an opaque representation of the
move from `position` to `child`. This method runs faster than `getMoveTo`,
described below, but the move is not human-readable.
* `position.getChildByEncodedMove(encodedMove)` returns the child reached by
playing the move given by its opaque representation.
* `position.getMoveTo(child)` returns the algebraic notation for the move from
`position` to `child`.
* `position.getChildByMove(move)` returns the child reached by playing the
move given in algebraic notation.
* `position.nextTurn` is the same as `position`, except that in
`position.nextTurn` it is the next player's turn. So, for example,
`position.getChildByMove('a1a3').nextTurn` would be the position after the
current player played the move `a1a3`.
## Side Statistics
The field `position.sides` is an array of `Side` objects, one for each player,
indexed by the number of turns until that player is to play (hence, the possible
values for the array index are the same as the values returned by
`position.getColorAndPieceType` or passed to `position.modified`). The field
`position.dragons` is a `Side` object for the neutral dragons. Each `Side`
object has the following fields:
* `side.openingSignature` is the player's pieces encoded as a single `BigInt`,
but without promotion information, so it treats pawns and knights
identically. This encoding is meant to be used as a reasonably fast
hash-table key in the code for an opening book. (The side itself cannot be
a hash-table key because ES6 does not support hash-by-value natively.)
* `side.pawnCount` is the number of pawns controlled by the player as a
`BigInt`. Dragons count as pawns for the dragon side.
* `side.knightCount` is the number of knights controlled by the player as a
`BigInt`. This count is always zero for the dragon side.
* `side.towerCount` is the number of towers controlled by the player as a
`BigInt`. This count is always zero for the dragon side.
* `side.population` is the total number of mobile pieces (pawns and knights)
controlled by the player as a `BigInt`. Dragons count as pawns for the
dragon side.
# Using `Controller` Objects from `@unlsoft/boost-engine`