Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • main
1 result

Target

Select target project
  • hain5252/boost-board-game
  • efox6/boost-board-game-2020
  • soft-core/soft-260/boost-board-game
  • jadengoter/boost-board-game
  • michael.westberg/boost-board-game
  • mthomas41/boost-board-game
6 results
Select Git revision
  • master
1 result
Show changes
Commits on Source (5)
Showing
with 1889 additions and 0 deletions
# 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 = https://git.unl.edu/soft-core/soft-260/stylelint-config.git
[submodule "eslint-config"]
path = eslint-config
url = https://git.unl.edu/soft-core/soft-260/eslint-config.git
# Boost Board Game Application (Fall 2022 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:
```bash
$ git clone --recursive git@git.unl.edu:soft-core/soft-260/boost-board-game.git
$ cd boost-board-game
```
(If you forget the `--recursive` flag when cloning, you can `cd` into your clone
and run `git submodule update --init --recursive` instead.)
Install dependencies:
```bash
$ npm install
```
Optionally, run the test suites to make sure everything so far is okay:
```bash
$ npm run test
```
Copy the file `./boost-server/.env.example` to `./boost-server/.env` and open it
an editor. Edit it as described the instructions at the top and also the
comments above each variable—you should at a minimum set `VAPID_EMAIL_ADDRESS`
to your email address, `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` to a freshly
generated key pair, `NOREPLY_ADDRESS` and `NOREPLY_PASSWORD` to the address and
password for a test email account, and `PEPPER` to a long, hard-to-guess string.
Save your changes.
Once the multiplayer server has been configured, start it on your local machine:
```bash
$ npm run multiplayer-server
```
Then, open another terminal, `cd` again into the `boost-board-game` directory,
and serve the application locally:
```bash
$ npm start
```
Once the app is running in your browser, click on the "Rules" button to read the
rules of the game. You might also try playing a local game against the AI to
make sure it is working (if not, you may have broken symlinks in
`…/boost-app/public/`) and try registering a network account to make sure that
the app can communicate with your multiplayer server.
When you are done, press control-c in each terminal to stop the multiplayer
server and the app 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 a particular project (for example, if you change the game or
engine code while the app is still running), you can use that project's
`generate` script. In VSCode, the `generate` scripts can be run from the "NPM
SCRIPTS" tray, and the `generate` script in the outermost `package.json` also
generates code for all projects. Or, on the command line:
```bash
$ (cd; npm run generate)
```
for a specific project or
```bash
$ 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 workspace 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:
```bash
$ (cd; npm run lint)
```
for a specific project or
```bash
$ 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:
```bash
$ (cd; npm run test)
```
If you want to run a project's tests only once and not watch for code changes,
you can use the `test-once` scripts. On the command line:
```bash
$ (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:
```bash
$ 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:
```bash
$ (cd; npm run test -- -t '[some test-name text]')
```
Other Jest options, like `-u`, can also be passed in a similar manner:
```bash
$ (cd; npm run test -- -u)
```
or combined:
```bash
$ (cd; npm run test -- -t '[some test-name text]' -u)
```
## System Testing
Both the multiplayer server and the main app are set up to run in "watch" mode,
which means that you can start them once, and every time you save a relevant
code change, the multiplayer server will restart and/or the main app will
refresh in your browser.
From VSCode, start the multiplayer server by running the outermost
`package.json`'s `multiplayer-server` script from the "NPM SCRIPTS" tray. Or,
on the command line:
```bash
$ npm run multiplayer-server
```
(You do not need to start the multiplayer server if you will not be using the
networked portions of the app or if you have changed the server URL in
`…/boost-app/.env` to point at a multiplayer server running elsewhere.)
Similarly, run the main app from VSCode using the outermost `package.json`'s
`start` script from the "NPM SCRIPTS" tray. Or, on the command line:
```bash
$ npm run start
```
Alternatively, the `start` script in the `boost-app` project also starts the
main app, but without rebuilding the engine, which means it runs faster. It can
be run either from VSCode's "NPM SCRIPTS" tray or the command line:
```bash
$ (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.
Certain app features, like the ability to continue running offline or the
ability to receive push notifications, depend on the app's service worker, which
is normally only available in a production build. To run a production build of
the main app locally, you can either use the outermost `package.json`'s
`production` script from the "NPM SCRIPTS" tray in VSCode or, on the command
line:
```bash
$ npm run production
```
Alternatively, the `production` script in the `boost-app` project also starts a
production build of the main app, but without rebuilding the engine, which means
it runs faster. It can be run either from VSCode's "NPM SCRIPTS" tray or the
command line:
```bash
$ (cd boost-app; npm run production)
```
There are some caveats to system testing with a production build.
First, the build will use settings from `…/boost-app/.env.production`, not
`…/boost-app/.env`, so make sure that that file is configured for your
environment.
Second, a production build does not run in watch mode, so it will not
automatically restart when you make code changes. You must manually stop it by
clicking the "trashcan" icon on its terminal in VSCode or pressing control-c if
you used an ordinary terminal and then rerun the `production` script.
Third, even if you restart the production server, the service worker may still
be caching old resources in your browser. To invalidate that cache, open your
browser's developer tools while viewing the main app and, under the
"Application" pane, unregister the service worker. Once it is unregistered,
make sure that you close all tabs where the main app is running, and finally
open the app again. You can then check that you have an updated service worker
by looking at its timestamp on the "Application" pane.
Be especially careful to always unregister the service worker before going back
to running a normal development build—if you are running both kinds of builds on
the same port, the production build's service worker can mask changes from the
development build.
Fourth, if your browser is running in HTTPS-only mode, you will need to add an
exception for the URL you are using (probably `localhost:3000`) to be able to
connect to the multiplayer server. (It is technically possible to support the
`wss` protocol on websocket connections to localhost by using a self-signed
certificate, but adding a exception in the browser is much easier.)
## Deployment
The code for the main app runs entirely client-side, so it can be deployed as a
`build` folder to be placed on any hosting platform. First, make sure that the
URL in `…/boost-app/.env.production` is pointing at a non-localhost multiplayer
server. Then from VSCode, run the outermost `package.json`'s `build` script
from the "NPM SCRIPTS" tray. Or, on the command line:
```bash
$ 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:
```bash
$ (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.
* The project `@unlsoft/boost-server` is a websocket server that coordinates
communication between clients for networked multiplayer games. Like other
projects, it relies on a game library generated by `@unlsoft/boost-game` to
generate starting positions and check move legality.
# 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
```javascript
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.
Because Node 18 has a bug where redirection does not work from a Node process in
a `package.json`, `generate-boost-game` also offers a `--output-file [path]`
option which will cause it to save the generated code directly to a given path.
The bug appears to be fixed in the Node 19 prerelease, so hopefully that
workaround will not be required in the future.
# 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.compensationFromPoint` is the point, as a file-rank pair, on the board
where a dragon compensation move would begin.
* `game.compensationToPoint` is the point, as a file-rank pair, on the board
where a dragon compensation move would end.
* `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.buildStartingPositionWithDragons(requestedDragonCount)` creates a
starting position for the game (no compensation move yet played) with up to
the requested number of randomly placed dragons, where the only possible
reductions from the requested count would be due to the following
restrictions:
* To maintain symmetry, a game board with even width or height will not be
given an odd number of dragons.
* To maintain playability, no more than 75% of the available points will
be filled with dragons. (The points just north and south of the center
do not count as available because they must remain open for compensation
moves.)
* `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.getCircleEvaluation(color)` is the contribution to the static
evaluation for `color` from that player's progress on a dragon circle. It
ignores all other factors, even other player's progress on dragon circles.
A score equal to `game.victory` means that the player has won by completing
a dragon circle.
* `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`
When `@unlsoft/boost-game` is installed as a development dependency, it provides
two minified files, `…/node_modules/@unlsoft/boost-engine/dist/engine.js` and
`…/node_modules/@unlsoft/boost-engine/dist/engineThread.js` that implement the
engine's web workers. These files should be included as-is in any app that
wants to use the engine; for a `create-react-app` app, the easiest approach is
to symlink them to the `public` folder.
For talking to these web workers, the default export from
`@unlsoft/boost-engine` is a `Controller` class, where `Controller` objects
provide the following methods:
* `new Controller(engineURL, engineThreadURL, gameIdentifier, propertyHandler,
moveHandler)` creates a controller for an engine whose web workers' source
code is located at the given URLs and that will play the game identified by
`gameIdentifier`. Two callbacks must be provided:
* `propertyHandler` will be called with a property name and a value
whenever the engine sends a description of itself (see the documentation
for the `id` BEI message below).
* `moveHandler` will be called with four arguments whenever the engine
makes a move (see the documentation for the `move` BEI message below):
* the engine's score for the position in decitempi,
* the chosen move in algebraic notation, and
* a list of the moves the engine considers best, including the chosen
move, also in algebraic notation.
* `controller.setStrength(strength)` sets the engine's strength as close as
possible to `strength` on a scale where `100` is the strength for giving a
new player an instructive but winnable game, `1500` is the strength of the
average experienced human player, and `2500` is the strength of a
grandmaster. At the moment, for performance reasons, the engine only
supports strengths up to 2000.
* `controller.setDepth(depth)` is an alternative to `controller.setStrength`
that sets the engine's search depth as close as possible to `depth`. At the
moment, for performance reasons, the engine only supports depths up to 5
ply.
* `controller.setLine(position, taboo)` sends a position and an array of
preceding positions (which are taboo under the repetition rule) to the
engine.
* `go()` tells the engine to choose a move to play in the last sent position.
The engine's reply will be sent to the `moveHandler` callback once the
engine is done thinking.
* `stop(wantMove)` tells the engine stop thinking early. If `wantMove` is
`true`, the controller will still call `moveHandler` with the best move the
engine found so far; otherwise that move will be discarded.
Under the hood, the `Controller` constructor creates associated web workers, and
its other methods communicate with these web workers using BEI, a protocol
described in the next section.
# The Boost Engine Interface (BEI) Protocol
BEI is a text-based protocol for communication between a controller (a program
that wants to incorporate a Boost-playing AI) and an engine (a Boost-playing
AI). It is based on and simplified from the Arimaa Engine Interface (AEI),
which in turn is based on the Universal Chess Interface (UCI).
BEI is built on asynchronous bidirectional communication of short strings called
**messages** between the controller and engine. For example, a controller might
send messages as lines of text to an engine's standard input and read messages
from the engine's standard output, or the controller and engine might exchange
BEI messages as string payloads in web worker messages.
(`@unlsoft/boost-engine` uses the latter approach.)
Generally the engine should not process later messages until it has finished
processing earlier ones. The two exceptions are pondering (speculatively
searching ahead during an opponent's turn) and thinking (deciding what move to
make during the engine's own turn), long-running operations that should
effectively happen "in the background" and not prevent the engine from
responding to other communication.
## Controller-to-Engine Messages
The following messages may be sent by a controller to an engine:
* `bei [value] [value] …` is sent to initiate communication and optionally to
provide engine-specific startup arguments. (It must be possible to
configure an engine to take no startup arguments so that the engine can be
used with an implementation-agnostic controller, but an engine may still opt
to take arguments in other settings.) The engine will reply with a
`protocol` message, possibly some `id` messages, and then `beiok`. Apart
from `isready` messages, the controller must not send any other
communication until it has confirmed that it is using a compatible protocol
version and has received a `beiok`.
* `isready` is sent to ping the engine and ensure that the engine has finished
processing any previous messages. The engine will reply with `readyok` once
all previous messages have been handled.
* `newgame [identifier]` is sent to start a new game with the given game
identifier. The engine will reply with `known` if it can play that game,
`unknown` if it cannot.
* `setoption [option] [value]` is sent to set the named engine option to the
given value. Currently there are only two supported option names:
* `strength` should be followed by a rating on a scale where `100` is the
strength for giving a new player an instructive but winnable game,
`1500` is the strength of the average experienced human player, and
`2500` is the strength of a grandmaster. If the engine strength is
never set, the engine should default to its strongest setting; if an
engine cannot play at the requested strength, it should set its strength
as close as possible.
* `depth` should be followed by a search depth and is an alternative way
to configure the engine's strength. Again, if the engine's search depth
is never set, the engine should default to its strongest setting; if an
engine cannot search to the requested depth, it should set its search
depth as close as possible.
* `setline [serialization] [tabooSerialization] [tabooSerialization] …` is
sent to tell the engine about a new board position, which is given by the
first serialization, and all of the previous board positions that affect the
repetition rule, which are give by the following serializations. The
serialization format is the same format as used by `position.serialization`
from `@unlsoft/boost-game`.
* `ponder [turns]` is sent to tell the engine that it will be playing after
the given number of turns and that it may ponder in the background. The
`newgame`, `setline`, `go`, and `stop` messages all stop pondering.
* `go` is sent to tell the engine that it should start thinking in the
background about what move to play in the current position. When it is done
thinking, the engine will respond with `move` message reporting the best
moves that it was able to find (or one move, a pass, if no real moves are
possible because the game has already been won or lost). The `newgame`,
`setline`, `ponder`, `go`, and `stop` messages all stop any previous
thinking and trigger a `move` message, even if the engine was not done
considering the position.
* `stop` is sent to tell the engine that it should stop any pondering or
thinking. If the engine is thinking, this will prompt it to reply with a
`move` message.
* `quit` is sent to tell the engine that no further messages will be sent and
that it may exit.
## Engine-to-Controller Messages
The following messages may be sent by an engine to a controller:
* `protocol [version]` is sent as the first message in response to a `bei`
message to tell the controller what version of BEI the engine uses. The
version described here is `2.0.0`.
* `id [property] [value]` is sent in response to a `bei` message to describe
the engine to the controller. Each property should be sent at most once.
Three property names are supported:
* The value for the `name` property is the name of the engine.
* The value for the `author` property is the name of the engine's author
(or the names of the engine's authors if there are more than one).
* The value for the `version` property is the version number of the
engine.
* `beiok` is sent to indicate the end of responses to a `bei` message.
* `readyok` is sent to reply to an `isready` message.
* `known` is sent to reply to a `newgame` message when the engine is able to
load the specified game from its game library.
* `unknown` is sent to reply to a `newgame` message when the engine is unable
to load the specified game from its game library.
* `move [score] [move] [move] …` is sent to report the best score and best
moves the engine was able to find whenever the engine stops thinking. The
first move is the one chosen by the engine to play. However, the engine
should not assume that this move will actually be made; if it is, the engine
will receive an appropriate `setline` message. If no moves are possible
because the game has already been won or lost, the engine must send a single
placeholder move: a pass.
* `log [text]` is sent to report a log message from the engine.
# The Networked Multiplayer API
Clients communicate with the multiplayer server over websockets by sending
stringified JSON objects where the object's `action` field determines the type
of message and then waiting for a matching response. For example, if a client
sends a message of the form
```json
{
action: 'foo',
}
```
then the server will reply with a matching response of either the form
```json
{
action: 'foo',
success: true,
}
```
or the form
```json
{
action: 'foo',
success: false,
}
```
depending upon whether or not the requested action was successful.
Therefore, from a client's perspective, using the API is very similar to using a
REST webservice where the `action` field is analogous to the resource part of a
REST URL, and the `success` field is analogous to the HTTP status code.
However, there are two important exceptions: (1) the server may also send
messages to the client unprompted, and (2) the server may also send the messages
as web push notifications if the client is not connected via a websocket.
To reduce friction (though at a slight reduction in security), any messages
delivered as web push notifications also include a `token` field with a
notification token that can be used to log in quickly without user interaction
(see the description of `claimToken` messages below).
## Conventions for Failure Responses
In most cases, if a request fails, the response will merely be
```json
{
action: ,
success: false,
}
```
with no explanatory details in order to avoid revealing information that could
be useful to someone attempting a cyberattack. The one exception is that a
message of the form
```json
{
action: ,
success: false,
recourse: 'logIn',
}
```
is always sent if the client's session is no longer valid and the user must log
in again.
## Conventions for Success Responses
Most success responses have a format specific to the action the correspond to,
but one recurring pattern across these responses is the set of fields used to
describe an invite or game. Those fields are as follows:
* `code` holds the invite or game code that uniquely identifies the invite or
game. This code can be used to refer to the invite or game in subsequent
communication, and the server furthermore guarantees that a game created by
accepting an invite will be the same as the original invite code, so this
code persists across acceptances.
* `timestamp` holds the time the invite or game was created as a number of
milliseconds since the ECMAScript epoch (January 1, 1970, UTC, the same as
the UNIX epoch). A game created by invitation is considered to be created
at the time the invitation was accepted, not the time it was extended.
* `gameIdentifier` holds the game identifier describing the rules of the game
(see the section "Command-Line Options Affecting Game Rules" from earlier in
this document).
* `timeControls` holds the game's time controls in seconds per side and is
always a multiple of 60 so that the time can be accurately displayed as an
integer number of minutes.
* `timeUsed` holds a two-element list where the first entry is the number of
seconds already used by the first player and likewise for the second entry
and second player. These numbers may be slightly out of date when they
reach the client due to network lag.
* `winnerOnTime` holds the index of the player that won because their
opponent's time ran out: `0` for the first player, `1` for the second
player, or `null` if neither player has won for that reason. This field is
necessary because players can continue playing even after someone's time has
expired, and it would not be possible to determine who won if both players
thereby both used up all of their time.
* `winnerByResignation` holds the index of the player that won because their
opponent resigned: `0` for the first player, `1` for the second player, or
`null` if neither player has won for that reason.
* `dragons` holds a list of strings, each being the name of a point where a
dragon was initially placed in algebraic notation (see the `unprettify*`
methods in the section on algebraic notation). It does not account for the
subsequently played compensation move.
* `violet` holds the name of the first player or `undefined` if they have not
accepted their invitation yet.
* `cyan` holds the name of the second player or `undefined` if they have not
accepted their invitation yet.
* `color` is the color that the client is playing in the game: the string
`'violet'` if they are the first player, the string `'cyan'` if they are the
second, or `undefined` if they are an observer.
* `record` is a list of all moves played in algebraic notation (see the
`unprettify*` methods in the section on algebraic notation). It does not
include the compensation move; client may assume that the compensation move
has been played once both the `violet` and `cyan` fields have been filled
with player names.
## Client-to-Server Messages and their Responses
The following messages may be sent by an client to a server:
* `{ action: 'logInAsGuest' }` is sent to log in as a guest, which will grant
the client a newly created guest account and start a one-time session under
that account. On success, the response will take the form `{ action:
'logInAsGuest', success: true, name: …, email: null, session: … }` where
the `name` field holds the guest account's username as a string and the
`session` field holds the session key also as a string.
* `{ action: 'register', name: …, email: …, password: … }` is sent to register
a new non-guest account using the name, email address, and password provided
as strings and immediately log in and start a session under that account.
The name must not contain spaces or at signs (`@`), and the email address
must be valid. On success, the response will take the form
`{ action: 'register', success: true, name: …, email: …, session: …,
vapidPublicKey: … }` where the `name` and `email` fields repeat the name
and email address from the request (possibly normalized), the `session`
field holds the session key as a string, and the `vapidPublicKey` field
holds the server's VAPID public key as a string (which the interested client
could use to subscribe to push notifications).
* `{ action: 'logIn', identity: …, password: … }` is sent to log in to an
existing account using a name or email address (case insensitive) and a
password. On success, the response will take the form `{ action: 'logIn',
success: true, name: …, email: …, session: …, vapidPublicKey: … }` where the
`name` and `email` fields contain the name and email address from the
account (possibly normalized), the `session` field holds the session key as
a string, and the `vapidPublicKey` field holds the server's VAPID public key
as a string (which the interested client could use to subscribe to push
notifications).
* `{ action: 'claimToken', session: …, token: … }` is sent to log in without
user interaction in response to a user tapping a notification. The session
key is optional, but should be provided if the client has one to avoid an
unnecessary logout/login cycle. The token must be a notification token
provided in a recent web push notification. On success, the response will
take the form `{ action: 'claimToken', success: true, name: …, email: …,
session: …, }` where the `name` and `email` fields contain the name and
email address from the account logged into (possibly normalized), and the
`session` field holds the session key as a string.
* `{ action: 'logOut', session: … }` is sent to log out of an account's active
session by providing the session key. The response will always take the
form `{ action: 'logOut', success: true }`.
* `{ action: 'changeEmail', session: …, email: …, password: … }` is sent to
change the email address associated with the logged-in account indicated by
the given session key. The account's password is also required to confirm
the change. On success, the response will take the form `{ action:
'changeEmail', success: true, name: …, email: null, session: … }` where the
`name` field holds the account's username, the `email` field repeats the new
email address (possibly normalized), and the `session` field repeats the
provided session key.
* `{ action: 'changePassword', session: …, oldPassword: …, newPassword: … }`
is sent to change the password for the logged-in account indicated by the
given session key. The account's old password is also required to confirm
the change. On success, the response will take the form `{ action:
'changePassword', success: true }`.
* `{ action: 'requestPasswordResetEmail', identity: …, }` is sent to request a
password-reset email be sent for an account identified by the provided name
or email address (case insensitive). On success, the response will take the
form `{ action: 'requestPasswordResetEmail', success: true }`.
* `{ action: 'resetPassword', identity: …, passwordResetCode: …,
password: … }` is sent to reset an account's password using a code from a
recent password-reset email. On success, the response will take the form `{
action: 'resetPassword', success: true }`.
* `{ action: 'subscribe', session: …, subscription: … }` is sent to subscribe
to push notifications from the server under the account indicated by the
given session key. The subscription should be a valid JSON-encoded
`PushSubscription`, which can be created with
`registration.pushManager.subscribe(…)` or retrieved with `await
registration.pushManager.getSubscription()` and then encoded with
`subscription.toJSON()` (see
<https://developer.mozilla.org/en-US/docs/Web/API/Push_API> for details).
On success, the response will take the form `{ action: 'subscribe', success:
true }`.
* `{ action: 'seek', session: …, fastestTimeControls: …,
slowestTimeControls: …, colorSought: … }` is sent to seek a new game through
the server's autopairing system under the account indicated by the given
session key. The fastest and slowest time controls should be given in
seconds per side, must be positive, and must be multiples of 60 so that the
time can be accurately displayed as an integer number of minutes. The color
sought should be `undefined` if the player is willing to play either color,
the string `'violet'` if the player wants their opponent to move first, or
the string `'cyan'` if the player wants their opponent to move second. On
success, the seek will be created and the response will take the form `{
action: 'seek', success: true }`. If the seek is eventually matched, the
server will at that time also send a `seekReady` message (see its
description in the next section).
* `{ action: 'unseek', session: … }` is sent to cancel any created but still
unresolved seek associated with the provided session. On success, the
response will take the form `{ action: 'unseek', success: true }`.
* `{ action: 'invite', session: …, dragons: …, timeControls: …,
colorSought: … }` is sent to create an invite under the account indicated
by the given session key. The initial dragon placements (before any
compensation move) should be given as a list of strings, each being the name
of an otherwise open point on the board in algebraic notation (see the
`prettify*` methods in the section on algebraic notation), and the color
sought should either be the string `'violet'` if the inviting player wants
their opponent to move first or the string `'cyan'` if they want their
opponent to move second. The time controls should be given in seconds per
side and must be multiples of 60 so that the time can be accurately
displayed as an integer number of minutes. On success, the response will
take the form `{ action: 'invite', success: true, … }` where the ellipses
indicates fields describing the created invite (see the previous section).
* `{ action: 'uninvite', session: …, code: … }` is sent to cancel a still-open
invite made by the account identified by the provided session and also
having the given invite code. On success, the response will take the form
`{ action: 'uninvite', success: true, code: … }` where the `code` field
repeats the given code.
* `{ action: 'accept', session: …, code: … }` is sent to accept an invite with
the provided invite code using the account indicated by the given session
key. On success, the response will take the form `{ action: 'accept',
success: true, … }` where the ellipses indicates fields describing the
created game (see the previous section).
* `{ action: 'observeLiveGames', session: …, }` is sent to begin observing all
games that are live (or were recently live) on the server, the session
required only to verify that the request is from a logged-in account. (Note
that the server tracks whether a connection is observing all live games
separately from the individual games it is observing, so a connection
observing all live games will see updates even about live games it has
chosen to unobserve individually.) On success, the response will take the
form `{ action: 'observeLiveGames', success: true, games: … }` where the
`games` field holds of list of game description object (see the previous
section for a complete description of their fields) covering not only these
games but also any invites or games belonging to the player. Whenever moves
or resignations are made in observed games, the server will at that time
also send `move` and `resign` messages (see their descriptions in the next
section).
* `{ action: 'unobserveLiveGames' }` is sent to stop observing all live games.
(Note that the server tracks whether a connection is observing all live
games separately from the individual games it is observing, so a connection
unobserving all live games will still see updates about live games it has
chosen to observe individually.) On success, the response will take the
form `{ action: 'unobserveLiveGames', success: true }`.
* `{ action: 'observeGame', session: …, code: … }` is sent to observe a
specific game identified by its code, the session required only to verify
that the request is from a logged-in account. (Note that the server tracks
whether a connection is observing all live games separately from the
individual games it is observing, so a connection will still see updates a
specifically observed live game even if it requests to unobserve all live
games.) On success, the response will take the form `{ action:
'observeGame', success: true, … }` where the ellipses indicates fields
describing the game (see the previous section).
* `{ action: 'unobserveGame', code: …, }` is sent to stop observing a specific
game. (Note that the server tracks whether a connection is observing all
live games separately from the individual games it is observing, so a
connection will still see updates about a game it has unobserved if it has
also requested to observe all live games.) On success, the response will
take the form `{ action: 'unobserveGame', success: true }`.
* `{ action: 'makeMove', session: …, code: …, move: … }` is sent to make a
move given in algebraic notation in the `move` field in a game identified by
the `code` field as a player whose account is identified by the provided
session key. On success, the response will take the form `{ action:
'makeMove', success: true, … }` where the ellipses indicates fields
describing the updated game (see the previous section).
* `{ action: 'resign', session: …, code: … }` is sent to resign a game
identified by the `code` field as a player whose account is identified by
the provided session key. On success, the response will take the form `{
action: 'resign', success: true, … }` where the ellipses indicates fields
describing the updated game (see the previous section).
## Spontaneous Server-to-Client Messages
The following messages may also be sent unprompted by an server to a client:
* `{ action: 'seekReady', success: true, … }` is sent to indicate that the
autopairing has found a match for the client's outstanding seek and has
started the new game. The ellipses indicates fields describing the created
game (see their explanation two sections prior).
* `{ action: 'accept', success: true, … }` is sent to indicate that an opponent
has accepted one of the client's outstanding invites and has started the new
game. The ellipses indicates fields describing the created game (see their
explanation two sections prior).
* `{ action: 'move, success: true, … }` is sent to indicate that another player
has made a move in a game the client is playing in or observing. The
ellipses indicates fields describing the updated game (see their explanation
two sections prior).
* `{ action: 'resign', success: true, … }` is sent to indicate that another
player has resigned in a game the client is playing in or observing. The
ellipses indicates fields describing the updated game (see their explanation
two sections prior).
# NETWORKING
################################################################################
# `REACT_APP_SERVER_URL` is the URL where the server is listening for websocket
# connections, including the port. `ws://localhost:4000` is the typical choice
# during development, but in production the `localhost` should be replaced with
# the server's domain name or IP address, and the port should match the `PORT`
# variable in the multiplayer server's `.env` file.
REACT_APP_SERVER_URL=ws://localhost:4000
# IMPORTANT!
################################################################################
# You should always change all of these variables before building the app for
# deployment to production.
# NETWORKING
################################################################################
# `REACT_APP_SERVER_URL` is the URL where the server is listening for websocket
# connections, including the port. `ws://localhost:4000` is the typical choice
# during development, but in production the `localhost` should be replaced with
# the server's domain name or IP address, and the port should match the `PORT`
# variable in the multiplayer server's `.env` file.
REACT_APP_SERVER_URL=ws://localhost:4000
# 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
Source diff could not be displayed: it is too large. Options to address this: view the blob.
{
"name": "@unlsoft/boost-app",
"version": "3.0.0",
"description": "A PWA for playing the board game Boost.",
"private": true,
"license": "UNLICENSED",
"scripts": {
"generate:game:demo": "generate-boost-game --demo --output-file ./src/games/demo.js",
"generate:game:two-player": "generate-boost-game --output-file ./src/games/twoPlayer.js",
"generate": "run-p generate:**",
"prelint": "run-s generate",
"lint:css": "stylelint \"**/*.css\" \"**/*.module.css\" \"!coverage/**\" \"!build/**\"",
"lint:js": "eslint --max-warnings 0 ./src",
"lint": "run-s --continue-on-error lint:**",
"pretest-once": "run-s generate",
"test-once": "react-scripts test --watchAll=false --coverage",
"pretest": "run-s generate",
"test": "react-scripts test --watchAll --coverage",
"prestart": "run-s generate",
"start": "react-scripts start",
"prebuild": "run-s generate",
"build": "react-scripts build && workbox injectManifest",
"preproduction": "run-s build",
"production": "npx serve --no-clipboard build",
"eject": "react-scripts eject"
},
"homepage": ".",
"dependencies": {
"@reduxjs/toolkit": "^1.8.3",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.3.0",
"@unlsoft/boost-engine": "file:../boost-engine",
"@unlsoft/boost-game": "file:../boost-game",
"classnames": "^2.3.1",
"npm-run-all": "^4.1.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-flip-toolkit": "^7.0.14",
"react-modal": "^3.15.1",
"react-redux": "^8.0.2",
"react-router-dom": "^6.3.0",
"react-scripts": "^5.0.1",
"react-switch": "^7.0.0",
"react-transition-group": "^4.4.2",
"redux-persist": "^6.0.0",
"reselect-map": "^1.0.6",
"workbox-background-sync": "^6.5.3",
"workbox-broadcast-update": "^6.5.3",
"workbox-cacheable-response": "^6.5.3",
"workbox-cli": "^6.5.3",
"workbox-core": "^6.5.3",
"workbox-expiration": "^6.5.3",
"workbox-navigation-preload": "^6.5.3",
"workbox-precaching": "^6.5.3",
"workbox-range-requests": "^6.5.3",
"workbox-routing": "^6.5.3",
"workbox-strategies": "^6.5.3",
"workbox-streams": "^6.5.3"
},
"devDependencies": {
"@unlsoft/eslint-config": "file:../eslint-config",
"@unlsoft/stylelint-config": "file:../stylelint-config",
"eslint-plugin-jest-dom": "^4.0.2",
"stylelint": "^14.9.1"
},
"stylelint": {
"extends": "@unlsoft/stylelint-config"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"plugin:jest-dom/recommended",
"plugin:testing-library/react",
"@unlsoft/eslint-config/react"
],
"ignorePatterns": [
"**/games/*.js"
]
},
"jest": {
"clearMocks": true,
"collectCoverageFrom": [
"src/client.js",
"src/page.js",
"src/widgets/**/*.js",
"src/features/**/*.js"
],
"resetMocks": false,
"restoreMocks": false
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
boost-app/public/badge.png

13 KiB

../node_modules/@unlsoft/boost-engine/dist/engine.js
\ No newline at end of file
../node_modules/@unlsoft/boost-engine/dist/engineThread.js
\ No newline at end of file
boost-app/public/favicon.ico

1.12 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta
name="description"
content="Boost is a turn-based abstract strategy board game like checkers, chess, Xiangqi, or Shōgi."
/>
<meta name="theme-color" content="#efefef" />
<link rel="icon" href="%PUBLIC_URL%/logo.png" />
<link rel="icon" href="%PUBLIC_URL%/logo.svg" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.svg" />
<link rel="apple-touch-startup-image" href="%PUBLIC_URL%/logo.png" />
<link rel="apple-touch-startup-image" href="%PUBLIC_URL%/logo.svg" />
<title>Boost</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
boost-app/public/logo.png

16.7 KiB

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect x="0" y="0" width="512" height="512" fill="rgba(0, 0, 0, 1)" />
<path d="M 256 416
C 316 399 365 304 365 147
C 331 177 274 126 256 96
C 238 126 181 177 147 147
C 147 304 196 399 256 416
z" fill="rgba(255, 255, 255, 1)" stroke="rgba(255, 255, 255, 1)" stroke-width="12" stroke-linejoin="round" />
<path d="M 256 352
C 292 342 321 285 321 191
C 301 209 267 178 256 160
C 245 178 211 209 191 191
C 191 285 220 342 256 352
z" fill="rgba(0, 0, 0, 1)" stroke="rgba(0, 0, 0, 1)" stroke-width="12" stroke-linejoin="round" />
</svg>
{
"short_name": "Boost",
"name": "Boost",
"description": "Boost is a turn-based abstract strategy board game like checkers, chess, Xiangqi, or Shōgi.",
"categories": [
"entertainment",
"games",
"social"
],
"icons": [
{
"src": "logo.svg",
"type": "image/svg+xml",
"sizes": "192x192 512x512",
"purpose": "any maskable"
},
{
"src": "logo.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": ".",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#efefef",
"background_color": "#efefef"
}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`the Page component renders a styled div 1`] = `
<div>
<div
class="page"
>
abc
</div>
</div>
`;
nav {
flex: 1 1 auto;
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
align-content: center;
align-items: center;
margin: var(--spacing);
margin-top: 0;
}
nav > div:last-child {
height: 25%;
}
.subpage-enter {
opacity: 0%;
}
.subpage-enter.subpage-enter-active {
opacity: 100%;
transition: opacity var(--animation-duration);
}
.subpage-exit {
opacity: 100%;
}
.subpage-exit.subpage-exit-active {
opacity: 0%;
transition: opacity var(--animation-duration);
}
import { useNavigate, Routes, Route } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useClientIdentity } from './client.js';
import { Page } from './page.js';
import { Heading } from './widgets/heading.js';
import { ButtonBar, Button } from './widgets/buttonBar.js';
import { Tutorial } from './features/tutorial/tutorial.js';
import { RULES_TUTORIAL } from './tutorials/rules.js';
import { NotificationHandler } from './features/lobby/notificationHandler.js';
import { Account } from './features/lobby/account.js';
import { Invitation } from './features/lobby/invitation.js';
import { GameMenu } from './features/lobby/gameMenu.js';
import { SettingsMenu } from './features/lobby/settingsMenu.js';
import { Player } from './features/play/player.js';
import { Editor } from './features/play/editor.js';
import { AbruptShutdownRecovery } from './features/play/abruptShutdownRecovery.js';
import { Network } from './features/play/network.js';
import { OneGameMonitor } from './features/play/monitor.js';
import './app.css';
import homeIcon from './icons/home.svg';
import rulesIcon from './icons/rules.svg';
import localIcon from './icons/local.svg';
import editIcon from './icons/edit.svg';
import analysisIcon from './icons/analysis.svg';
import remoteIcon from './icons/remote.svg';
import GAME_LIBRARY from './gameLibrary.js';
const GAME = GAME_LIBRARY.get('boost-9-9-2-8-2');
const DRAGON_COUNT = 7;
const ANALYSIS_DEPTH = 5;
function PageWithNavigationBar(props) {
const {
homeAltText,
homeTo,
rulesAltText,
rulesTo,
localAltText,
localTo,
editAltText,
editTo,
analysisAltText,
analysisTo,
remoteAltText,
remoteTo,
} = {
homeAltText: 'Home',
homeTo: '/',
rulesAltText: 'Learn the Rules',
rulesTo: '/rules',
localAltText: 'Play a Game on this Device',
localTo: '/local/gameMenu',
editAltText: 'Edit a Position',
editTo: '/edit/gameMenu',
analysisAltText: 'Analyze a Game',
analysisTo: '/analysis/gameMenu',
remoteAltText: 'Play a Network Game',
remoteTo: '/remote/gameMenu',
...props,
};
const navigate = useNavigate();
return (
<Page>
<ButtonBar>
<Button
image={homeIcon}
altText={homeAltText}
disabled={homeTo === undefined}
onClick={homeTo !== undefined ? () => navigate(homeTo) : undefined} />
<Button
image={rulesIcon}
altText={rulesAltText}
disabled={rulesTo === undefined}
onClick={rulesTo !== undefined ? () => navigate(rulesTo) : undefined} />
<Button
image={localIcon}
altText={localAltText}
disabled={localTo === undefined}
onClick={localTo !== undefined ? () => navigate(localTo) : undefined} />
<Button
image={editIcon}
altText={editAltText}
disabled={editTo === undefined}
onClick={editTo !== undefined ? () => navigate(editTo) : undefined} />
<Button
image={analysisIcon}
altText={analysisAltText}
disabled={analysisTo === undefined}
onClick={analysisTo !== undefined ? () => navigate(analysisTo) : undefined} />
<Button
image={remoteIcon}
altText={remoteAltText}
disabled={remoteTo === undefined}
onClick={remoteTo !== undefined ? () => navigate(remoteTo) : undefined} />
</ButtonBar>
{props.children}
</Page>
);
}
PageWithNavigationBar.propTypes = {
homeAltText: PropTypes.string,
homeTo: PropTypes.string,
rulesAltText: PropTypes.string,
rulesTo: PropTypes.string,
localAltText: PropTypes.string,
localTo: PropTypes.string,
editAltText: PropTypes.string,
editTo: PropTypes.string,
analysisAltText: PropTypes.string,
analysisTo: PropTypes.string,
remoteAltText: PropTypes.string,
remoteTo: PropTypes.string,
};
export function App() {
const clientIdentity = useClientIdentity();
const invitationTarget = clientIdentity !== undefined ? '/remote/invitation' : '/remote/invitationLogin';
const navigate = useNavigate();
const home =
<PageWithNavigationBar homeTo={undefined}>
<nav>
<Heading text={'Boost Board Game'} />
<ButtonBar>
<Button text={'Learn the Rules'} autofocus={true} onClick={() => navigate('/rules')} />
</ButtonBar>
<ButtonBar>
<Button text={'Play a Game on this Device'} onClick={() => navigate('/local/gameMenu')} />
</ButtonBar>
<ButtonBar>
<Button text={'Play a Network Game'} onClick={() => navigate('/remote/gameMenu')} />
</ButtonBar>
<ButtonBar>
<Button text={'Accept a Game Invitation'} onClick={() => navigate(invitationTarget)} />
</ButtonBar>
<div />
</nav>
</PageWithNavigationBar>;
const rulesTutorial =
<PageWithNavigationBar rulesTo={undefined}>
<Heading text={RULES_TUTORIAL.title} />
<Tutorial slot={'rules'} tutorial={RULES_TUTORIAL} />
</PageWithNavigationBar>;
const localGameMenu =
<PageWithNavigationBar localTo={undefined}>
<GameMenu
slot={'local'}
gameIdentifier={GAME.identifier}
dragons={DRAGON_COUNT}
remote={false}
newGameTo={'/local/settingsMenu'}
loadGameTo={'/local'} />
</PageWithNavigationBar>;
const localSettingsMenu =
<PageWithNavigationBar>
<Heading text={'Choose Settings'} />
<SettingsMenu slot={'local'} remote={false} backTo={'/local/gameMenu'} to={'/local'} />
</PageWithNavigationBar>;
const localBoard =
<PageWithNavigationBar
localAltText={'Play another Game on this Device'}
editAltText={'Edit this Position'}
editTo={'/edit'}
analysisAltText={'Analyze from this Position'}
analysisTo={'/analysis'}>
<Heading text={'Local Game'} />
<Player
slot={'local'}
remote={false}
analysisOnly={false}
analysisDepth={ANALYSIS_DEPTH}
settingsTo={'/local/settingsMenu'} />
</PageWithNavigationBar>;
const editGameMenu =
<PageWithNavigationBar editTo={undefined}>
<GameMenu
slot={'local'}
gameIdentifier={GAME.identifier}
dragons={DRAGON_COUNT}
remote={false}
newGameTo={'/edit'}
loadGameTo={'/edit'} />
</PageWithNavigationBar>;
const editBoard =
<PageWithNavigationBar
localAltText={'Play from this Position'}
localTo={'/local'}
editAltText={'Edit another Position'}
analysisAltText={'Analyze from this Position'}
analysisTo={'/analysis'}>
<Heading text={'Editor'} />
<Editor slot={'local'} />
</PageWithNavigationBar>;
const analyzeGameMenu =
<PageWithNavigationBar analysisTo={undefined}>
<GameMenu
slot={'local'}
gameIdentifier={GAME.identifier}
dragons={DRAGON_COUNT}
remote={false}
newGameTo={'/analysis'}
loadGameTo={'/analysis'} />
</PageWithNavigationBar>;
const analyzeBoard =
<PageWithNavigationBar
localAltText={'Play from this Position'}
localTo={'/local'}
editAltText={'Edit this Position'}
editTo={'/edit'}
analysisAltText={'Analyze another Game'}>
<Heading text={'Analysis'} />
<Player slot={'local'} remote={false} analysisOnly={true} analysisDepth={ANALYSIS_DEPTH} />
</PageWithNavigationBar>;
const accountPage =
<PageWithNavigationBar>
<Heading text={'Account'} />
<Account listTo={'/remote/gameMenu'} />
</PageWithNavigationBar>;
const remoteGameMenu =
<PageWithNavigationBar remoteTo={undefined}>
<GameMenu
slot={'remote'}
gameIdentifier={GAME.identifier}
dragons={DRAGON_COUNT}
remote={true}
accountTo={'/remote/account'}
acceptTo={'/remote/invitation'}
newGameTo={'/remote/settingsMenu'}
loadGameTo={'/remote'} />
</PageWithNavigationBar>;
const invitationLoginPage =
<PageWithNavigationBar>
<Heading text={'Account'} />
<Account listTo={'/remote/invitation'} />
</PageWithNavigationBar>;
const invitationAcceptancePage =
<PageWithNavigationBar>
<Heading text={'Accept Invitation'} />
<Invitation slot={'remote'} to={'/remote'} backTo={'/remote/gameMenu'} />
</PageWithNavigationBar>;
const remoteSettingsMenu =
<PageWithNavigationBar>
<Heading text={'Choose Settings'} />
<SettingsMenu slot={'remote'} remote={true} backTo={'/remote/gameMenu'} to={'/remote'} />
</PageWithNavigationBar>;
const remoteBoard =
<PageWithNavigationBar>
<Heading text={'Network Game'} />
<Player
slot={'remote'}
remote={true}
analysisOnly={false}
analysisDepth={ANALYSIS_DEPTH}
backTo={'/remote/gameMenu'} />
<OneGameMonitor slot={'remote'} />
</PageWithNavigationBar>;
const notification =
<PageWithNavigationBar>
<NotificationHandler slot={'remote'} backTo={'/'} accountTo={'/notificationLogin'} to={'/remote'} />
</PageWithNavigationBar>;
const notificationLoginPage =
<PageWithNavigationBar>
<Heading text={'Account'} />
<Account listTo={'/remote'} />
</PageWithNavigationBar>;
return (
<>
<Routes>
<Route path={'/'} element={home} />
<Route path={'/rules/*'} element={rulesTutorial} />
<Route path={'/local/gameMenu/*'} element={localGameMenu} />
<Route path={'/local/settingsMenu/*'} element={localSettingsMenu} />
<Route path={'/local/*'} element={localBoard} />
<Route path={'/edit/gameMenu/*'} element={editGameMenu} />
<Route path={'/edit/*'} element={editBoard} />
<Route path={'/analysis/gameMenu/*'} element={analyzeGameMenu} />
<Route path={'/analysis/*'} element={analyzeBoard} />
<Route path={'/remote/account/*'} element={accountPage} />
<Route path={'/remote/gameMenu/*'} element={remoteGameMenu} />
<Route path={'/remote/invitationLogin/*'} element={invitationLoginPage} />
<Route path={'/remote/invitation/*'} element={invitationAcceptancePage} />
<Route path={'/remote/settingsMenu/*'} element={remoteSettingsMenu} />
<Route path={'/remote/*'} element={remoteBoard} />
<Route path={'/notification/*'} element={notification} />
<Route path={'/notificationLogin/*'} element={notificationLoginPage} />
</Routes>
<AbruptShutdownRecovery />
<Network slot={'remote'} />
</>
);
}
if (navigator.serviceWorker) {
console.log('Listening to service worker messages…');
navigator.serviceWorker.addEventListener('message', (message) => {
if (typeof message.data.hash === 'string') {
globalThis.location.hash = message.data.hash;
}
});
}