Repository: boardgameio/boardgame.io Branch: main Commit: 4f3c90df0d89 Files: 332 Total size: 3.8 MB Directory structure: gitextract_hy_6v4bz/ ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .empty_module.js ├── .eslintignore ├── .eslintrc ├── .github/ │ ├── FUNDING.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── npm-publish.yml │ └── test.yml ├── .gitignore ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── AUTHORS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── benchmark/ │ └── index.js ├── docs/ │ ├── CNAME │ ├── documentation/ │ │ ├── .nojekyll │ │ ├── CHANGELOG.md │ │ ├── api/ │ │ │ ├── Client.md │ │ │ ├── Game.md │ │ │ ├── Lobby.md │ │ │ └── Server.md │ │ ├── chat.md │ │ ├── concepts.md │ │ ├── debugging.md │ │ ├── deployment.md │ │ ├── events.md │ │ ├── immutability.md │ │ ├── index.html │ │ ├── multiplayer.md │ │ ├── notable_projects.md │ │ ├── phases.md │ │ ├── plugins.md │ │ ├── random.md │ │ ├── secret-state.md │ │ ├── sidebar.md │ │ ├── snippets/ │ │ │ ├── example-1/ │ │ │ │ └── index.html │ │ │ ├── example-1.c952ec6d.js │ │ │ ├── example-2/ │ │ │ │ └── index.html │ │ │ ├── example-2.e4675089.js │ │ │ ├── example-3/ │ │ │ │ └── index.html │ │ │ ├── example-3.1fa4f5db.js │ │ │ ├── multiplayer/ │ │ │ │ └── index.html │ │ │ ├── multiplayer.54b541fd.js │ │ │ ├── phases-1/ │ │ │ │ └── index.html │ │ │ ├── phases-1.0d4500d6.js │ │ │ ├── phases-1.490dcd4c.css │ │ │ ├── phases-2/ │ │ │ │ └── index.html │ │ │ ├── phases-2.a59f38ac.js │ │ │ ├── phases-2.fa21cb61.css │ │ │ ├── stages-1/ │ │ │ │ └── index.html │ │ │ ├── stages-1.1524ef02.js │ │ │ └── stages-1.bcf7ab84.css │ │ ├── stages.md │ │ ├── storage.md │ │ ├── testing.md │ │ ├── theme.css │ │ ├── turn-order.md │ │ ├── tutorial.md │ │ ├── typescript.md │ │ └── undo.md │ ├── index.css │ └── index.html ├── examples/ │ ├── react-native/ │ │ ├── .gitignore │ │ ├── .watchmanconfig │ │ ├── App.js │ │ ├── README.md │ │ ├── app.json │ │ ├── board.js │ │ ├── game.js │ │ ├── package.json │ │ └── rn-cli.config.js │ ├── react-web/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── server.js │ │ └── src/ │ │ ├── app.css │ │ ├── app.js │ │ ├── app.test.js │ │ ├── chess/ │ │ │ ├── board.js │ │ │ ├── chat.js │ │ │ ├── checkerboard.js │ │ │ ├── checkerboard.test.js │ │ │ ├── game.js │ │ │ ├── grid.js │ │ │ ├── index.js │ │ │ ├── multiplayer.js │ │ │ ├── pieces/ │ │ │ │ ├── CREDITS │ │ │ │ ├── bishop.js │ │ │ │ ├── king.js │ │ │ │ ├── knight.js │ │ │ │ ├── pawn.js │ │ │ │ ├── queen.js │ │ │ │ └── rook.js │ │ │ ├── singleplayer.js │ │ │ └── token.js │ │ ├── index.html │ │ ├── index.js │ │ ├── li-navlink.js │ │ ├── lobby/ │ │ │ ├── index.js │ │ │ ├── lobby.css │ │ │ ├── lobby.js │ │ │ └── routes.js │ │ ├── random/ │ │ │ ├── board.js │ │ │ ├── game.js │ │ │ └── index.js │ │ ├── redacted-move/ │ │ │ ├── board.css │ │ │ ├── board.js │ │ │ ├── game.js │ │ │ ├── index.js │ │ │ └── multiview.js │ │ ├── routes.js │ │ ├── secret-state/ │ │ │ ├── board.css │ │ │ ├── board.js │ │ │ ├── game.js │ │ │ ├── index.js │ │ │ └── multiview.js │ │ ├── simulator/ │ │ │ ├── example-all-once.js │ │ │ ├── example-all.js │ │ │ ├── example-others-once.js │ │ │ ├── example-others.js │ │ │ ├── index.js │ │ │ ├── simulator.css │ │ │ └── simulator.js │ │ ├── threejs/ │ │ │ ├── index.js │ │ │ └── main.css │ │ ├── tic-tac-toe/ │ │ │ ├── advanced-ai.js │ │ │ ├── authenticated.js │ │ │ ├── board.css │ │ │ ├── board.js │ │ │ ├── bots.js │ │ │ ├── game.js │ │ │ ├── index.js │ │ │ ├── multiplayer.js │ │ │ ├── singleplayer.js │ │ │ └── spectator.js │ │ └── undo/ │ │ ├── board.js │ │ ├── game.js │ │ └── index.js │ └── snippets/ │ ├── .gitignore │ ├── README.md │ ├── install.sh │ ├── package.json │ └── src/ │ ├── example-1/ │ │ ├── index.html │ │ └── index.js │ ├── example-2/ │ │ ├── index.html │ │ └── index.js │ ├── example-3/ │ │ ├── index.html │ │ └── index.js │ ├── multiplayer/ │ │ ├── index.html │ │ └── index.js │ ├── phases-1/ │ │ ├── App.svelte │ │ ├── Player.svelte │ │ ├── game.js │ │ ├── index.html │ │ └── index.js │ ├── phases-2/ │ │ ├── App.svelte │ │ ├── Player.svelte │ │ ├── game.js │ │ ├── index.html │ │ └── index.js │ └── stages-1/ │ ├── App.svelte │ ├── Player.svelte │ ├── game.js │ ├── index.html │ └── index.js ├── integration/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public/ │ │ └── index.html │ └── src/ │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── board.css │ ├── board.js │ ├── game.js │ ├── index.css │ └── index.js ├── package.json ├── packages/ │ ├── ai.ts │ ├── client.ts │ ├── core.ts │ ├── debug.ts │ ├── internal.ts │ ├── main.js │ ├── master.ts │ ├── multiplayer.ts │ ├── plugins.ts │ ├── react-native.ts │ ├── react.ts │ ├── server.ts │ └── testing.ts ├── python/ │ ├── .gitignore │ ├── boardgameio.py │ ├── examples/ │ │ └── tic-tac-toe/ │ │ └── tictactoebot.py │ └── test_boardgameio.py ├── roadmap.md ├── rollup.config.js ├── scripts/ │ ├── changelog.js │ ├── clean.js │ ├── dev-client.js │ ├── install-examples.js │ ├── integration.js │ └── proxy-dirs.js ├── src/ │ ├── ai/ │ │ ├── ai.test.ts │ │ ├── ai.ts │ │ ├── bot.ts │ │ ├── mcts-bot.ts │ │ └── random-bot.ts │ ├── client/ │ │ ├── client.test.ts │ │ ├── client.ts │ │ ├── debug/ │ │ │ ├── Debug.svelte │ │ │ ├── Menu.svelte │ │ │ ├── ai/ │ │ │ │ ├── AI.svelte │ │ │ │ └── Options.svelte │ │ │ ├── info/ │ │ │ │ ├── Info.svelte │ │ │ │ └── Item.svelte │ │ │ ├── log/ │ │ │ │ ├── Log.svelte │ │ │ │ ├── LogEvent.svelte │ │ │ │ ├── LogMetadata.svelte │ │ │ │ ├── PhaseMarker.svelte │ │ │ │ └── TurnMarker.svelte │ │ │ ├── main/ │ │ │ │ ├── ClientSwitcher.svelte │ │ │ │ ├── Controls.svelte │ │ │ │ ├── Hotkey.svelte │ │ │ │ ├── InteractiveFunction.svelte │ │ │ │ ├── Main.svelte │ │ │ │ ├── Move.svelte │ │ │ │ └── PlayerInfo.svelte │ │ │ ├── mcts/ │ │ │ │ ├── Action.svelte │ │ │ │ ├── MCTS.svelte │ │ │ │ └── Table.svelte │ │ │ ├── tests/ │ │ │ │ ├── JSONTree.mock.svelte │ │ │ │ └── debug.test.ts │ │ │ └── utils/ │ │ │ ├── shortcuts.js │ │ │ └── shortcuts.test.js │ │ ├── manager.ts │ │ ├── react-native.js │ │ ├── react-native.test.js │ │ ├── react.ssr.test.tsx │ │ ├── react.test.tsx │ │ ├── react.tsx │ │ └── transport/ │ │ ├── dummy.ts │ │ ├── local.test.ts │ │ ├── local.ts │ │ ├── socketio.test.ts │ │ ├── socketio.ts │ │ ├── transport.test.ts │ │ └── transport.ts │ ├── core/ │ │ ├── action-creators.ts │ │ ├── action-types.ts │ │ ├── backwards-compatibility.ts │ │ ├── constants.ts │ │ ├── errors.ts │ │ ├── flow.test.ts │ │ ├── flow.ts │ │ ├── game-methods.ts │ │ ├── game.test.ts │ │ ├── game.ts │ │ ├── initialize.ts │ │ ├── logger.test.js │ │ ├── logger.ts │ │ ├── player-view.test.ts │ │ ├── player-view.ts │ │ ├── reducer.test.ts │ │ ├── reducer.ts │ │ ├── turn-order.test.ts │ │ └── turn-order.ts │ ├── lobby/ │ │ ├── client.test.ts │ │ ├── client.ts │ │ ├── connection.test.ts │ │ ├── connection.ts │ │ ├── create-match-form.tsx │ │ ├── login-form.tsx │ │ ├── match-instance.tsx │ │ ├── react.ssr.test.tsx │ │ ├── react.test.tsx │ │ └── react.tsx │ ├── master/ │ │ ├── filter-player-view.test.ts │ │ ├── filter-player-view.ts │ │ ├── master.test.ts │ │ └── master.ts │ ├── plugins/ │ │ ├── events/ │ │ │ ├── events.test.ts │ │ │ └── events.ts │ │ ├── main.test.ts │ │ ├── main.ts │ │ ├── plugin-events.ts │ │ ├── plugin-immer.test.ts │ │ ├── plugin-immer.ts │ │ ├── plugin-log.test.ts │ │ ├── plugin-log.ts │ │ ├── plugin-player.test.ts │ │ ├── plugin-player.ts │ │ ├── plugin-random.ts │ │ ├── plugin-serializable.test.ts │ │ ├── plugin-serializable.ts │ │ └── random/ │ │ ├── random.alea.ts │ │ ├── random.test.ts │ │ └── random.ts │ ├── server/ │ │ ├── api.test.ts │ │ ├── api.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── cors.test.ts │ │ ├── cors.ts │ │ ├── db/ │ │ │ ├── base.ts │ │ │ ├── flatfile.test.ts │ │ │ ├── flatfile.ts │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── inmemory.test.ts │ │ │ ├── inmemory.ts │ │ │ ├── localstorage.test.ts │ │ │ └── localstorage.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── transport/ │ │ │ ├── pubsub/ │ │ │ │ ├── generic-pub-sub.ts │ │ │ │ ├── in-memory-pub-sub.test.ts │ │ │ │ └── in-memory-pub-sub.ts │ │ │ ├── socketio-simultaneous.test.ts │ │ │ ├── socketio.test.ts │ │ │ └── socketio.ts │ │ └── util.ts │ ├── testing/ │ │ ├── mock-random.test.ts │ │ └── mock-random.ts │ └── types.ts ├── subpackages.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/typescript-node/.devcontainer/base.Dockerfile # [Choice] Node.js version: 16, 14, 12 ARG VARIANT="16-buster" FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends # [Optional] Uncomment if you want to install an additional version of node using nvm # ARG EXTRA_NODE_VERSION=10 # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" # [Optional] Uncomment if you want to install more global node packages # RUN su node -c "npm install -g " ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/typescript-node { "name": "Node.js & TypeScript", "build": { "dockerfile": "Dockerfile", // Update 'VARIANT' to pick a Node version: 12, 14, 16 "args": { "VARIANT": "16" } }, // Set *default* container specific settings.json values on container create. "settings": {}, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "dsznajder.es7-react-js-snippets", "eamodio.gitlens", "github.vscode-pull-request-github" ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [3000, 8000], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "npm ci", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "node" } ================================================ FILE: .empty_module.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; const Null = () => null; export default Null; ================================================ FILE: .eslintignore ================================================ examples/ dist/ node_modules/ coverage/ npm/ docs/ integration/ ================================================ FILE: .eslintrc ================================================ extends: - eslint:recommended - plugin:jest/recommended - plugin:unicorn/recommended - plugin:react/recommended - plugin:@typescript-eslint/recommended - plugin:prettier/recommended plugins: - "@typescript-eslint" parser: "@typescript-eslint/parser" env: node: true browser: true es6: true rules: # eslint no-console: off prefer-const: - error - destructuring: all # plugin:unicorn unicorn/consistent-function-scoping: off unicorn/no-array-for-each: off unicorn/no-array-reduce: off unicorn/no-fn-reference-in-iterator: off unicorn/no-null: off unicorn/no-reduce: off unicorn/no-useless-undefined: off unicorn/prevent-abbreviations: off # plugin:@typescript-eslint "@typescript-eslint/consistent-type-imports": error "@typescript-eslint/explicit-module-boundary-types": off "@typescript-eslint/no-empty-function": off "@typescript-eslint/no-explicit-any": off "@typescript-eslint/no-namespace": off "@typescript-eslint/no-unused-vars": - warn - args: after-used ignoreRestSiblings: true "@typescript-eslint/no-var-requires": off settings: react: version: detect ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [boardgameio] open_collective: boardgameio ================================================ FILE: .github/pull_request_template.md ================================================ #### Checklist - [ ] Use a separate branch in your local repo (not `main`). - [ ] Test coverage is 100% (or you have a story for why it's ok). ================================================ FILE: .github/workflows/npm-publish.yml ================================================ name: Publish on: push: tags: [ 'v0.[0-9]+.[0-9]+' ] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Use Node v12 uses: actions/setup-node@v1 with: node-version: 12 - name: Install Dependencies run: npm ci - name: Run Tests run: | npm run lint npm run test - name: Publish to NPM id: publish uses: JS-DevTools/npm-publish@v1 with: token: ${{ secrets.NPM_TOKEN }} - name: Version already published if: steps.publish.outputs.type == 'none' run: | echo "Version ${{ steps.publish.outputs.old-version }} already exists." - name: New version published if: steps.publish.outputs.type != 'none' run: | echo "New ${{ steps.publish.outputs.type }} version ${{ steps.publish.outputs.version }} published. Was ${{ steps.publish.outputs.old-version }}." ================================================ FILE: .github/workflows/test.yml ================================================ name: Tests on: push: branches: [ main ] pull_request: branches: [ main ] jobs: unit: runs-on: ubuntu-latest strategy: matrix: node-version: [10.x, 12.x, 14.x, 16.x] steps: - uses: actions/checkout@v2 - name: Use Node v${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies run: npm install - name: Run Tests run: | npm run lint npm run test:coverage - name: Coveralls uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} integration: runs-on: ubuntu-latest strategy: matrix: node-version: [10.x, 12.x, 14.x, 16.x] steps: - uses: actions/checkout@v2 - name: Use Node v${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies run: npm install - name: Run Integration Test run: npm run test:integration ================================================ FILE: .gitignore ================================================ .cache yarn.lock .DS_Store *.swp npm-debug.log* dist node_modules coverage .coveralls.yml .npm npm .idea/ .vscode/* .rpt2_cache/ .rush/ ================================================ FILE: .lintstagedrc ================================================ { "*.{ts,js,css,md}": "prettier --write" } ================================================ FILE: .prettierignore ================================================ *.bundle.js *.min.js node_modules dist Game.md package.json docs ================================================ FILE: .prettierrc ================================================ { "printWidth": 80, "singleQuote": true, } ================================================ FILE: AUTHORS ================================================ # List of contributors. This is by no means meant to be # comprehensive (git history is a good way to get the # exhaustive list). Feel free to add your name here whenever # you send a Pull Request, though. Nicolo John Davis Google Inc. Saeid Alidadi Lee Allen Vinicius Felizardo Joshua Christman Selim Ajimi Robert Sandu Rifat Nabi Satana Charuwichitratana Pete Nykänen Philihp Busby Jason Harrison Brendon Roberto Luca Vallisa ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Community Guidelines A safe, respectful, productive and collaborative environment is important for the boardgame.io community. These guidelines apply to all community communications channels (such as the official Gitter group, GitHub issues and pull requests etc.). 1. Be welcoming to all. Help newcomers with questions about the project (even if these are answered elsewhere). 1. Do not make personal attacks or disparaging personal remarks. 1. When interpreting the words and actions of others, assume good faith and intentions. 1. Do not engage in harassing or bullying behavior of any kind. ## What to do if you see a violation of these guidelines You may do either or both of the following: 1. Politely message the perpetrator to steer them in the right direction. 1. Contact a moderator privately (or email moderators@boardgame.io) to report bad behavior and request intervention. Moderators will encourage better behavior or issue a warning as appropriate. Further action (like banning a member) may be necessary as a last resort when other measures fail. ## Moderators - Chris Swithinbank ================================================ FILE: CONTRIBUTING.md ================================================ # How to Contribute ## Finding things to contribute to Please use the [Issue Tracker](https://github.com/boardgameio/boardgame.io/issues) to discuss potential improvements you want to make before sending a Pull Request. The [roadmap](roadmap.md) is probably the best place to find areas where help would most be appreciated. The Issue Tracker may contain items labelled [**good first issue**][gfi] or [**help wanted**][hw] from time to time. [hw]: https://github.com/boardgameio/boardgame.io/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22+ [gfi]: https://github.com/boardgameio/boardgame.io/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 ## Pull Requests [Pull Requests](https://help.github.com/articles/about-pull-requests/) are used for contributions. Code must be well-tested and not decrease the test coverage significantly. #### Use a separate branch (not `main`) Please commit changes to a separate branch in your fork so that we can work together making changes to it before it is ready to be merged. Name your branch something like `/feature`. Once you are ready, you can create a Pull Request for it to be merged into the `main` branch in this repo. #### Testing The following commands must pass for a Pull Request to be considered: ``` $ npm test $ npm run lint ``` You can also check the test coverage by running: ``` $ npm run test:coverage ``` #### If you make changes to the docs Use the following command to preview them: ``` $ npm run docs ``` ## VS Code remote dev container support For minimal effort, the repository is configured to run in a remote dev container from VS Code. - No need to install Node.js or any other project-specific tooling and dependencies - No risk of your local machine environment getting in the way - Consistent development environment no matter what OS is used - Useful extensions preinstalled in the container, independent of your local VS Code settings ### Prerequisites - [VS Code](https://code.visualstudio.com/) + [Remote Development](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) extension - [git](https://git-scm.com/) - [Docker](https://www.docker.com/) ### Getting started - Launch VS Code - Click the Remote Development icon in the bottom left corner of the UI, then "Clone repository in Container Volume..." - Paste `https://github.com/boardgameio/boardgame.io` or use your own fork, any branch, or a pull request - The container will start up and install all required dependencies automatically - Terminal output will cease when everything is set up and ready to go ### Running the examples from the VS Code Explorer - Open "NPM Scripts" panel in the sidebar - Click on `package.json > start` If the NPM scripts panel is not visible in the Explorer sidebar, open the Explorer settings (3 dots) and check "NPM Scripts". ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 The boardgame.io Authors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

boardgame.io

npm version Build Status Coverage Status Gitter

Read the Documentation

boardgame.io is an engine for creating turn-based games using JavaScript.

Write simple functions that describe how the game state changes when a particular move is made. This is automatically converted into a playable game complete with online multiplayer features, all without requiring you to write a single line of networking or storage code. ### Features - **State Management**: Game state is managed seamlessly across clients, server and storage automatically. - **Multiplayer**: Game state is kept in sync in realtime and across platforms. - **AI**: Automatically generated bots that can play your game. - **Game Phases**: with different game rules and turn orders per phase. - **Lobby**: Player matchmaking and game creation. - **Prototyping**: Interface to simulate moves even before you render the game. - **Extendable**: Plugin system that allows creating new abstractions. - **View-layer Agnostic**: Use the vanilla JS client or the bindings for React / React Native. - **Logs**: Game logs with the ability to time travel (viewing the board at an earlier state). ## Usage ### Installation ```sh npm install boardgame.io ``` ### Documentation Read our [Full Documentation](https://boardgame.io/documentation/) to learn how to use boardgame.io, and join the [community on gitter](https://gitter.im/boardgame-io/General) to ask your questions! ### Running examples in this repository ```sh npm install npm start ``` The examples can be found in the [examples](examples/) folder. #### Using VS Code? This repository is ready to run in a dev container in VS Code. See [the contributing guidelines for details](CONTRIBUTING.md). ## Changelog See [changelog](docs/documentation/CHANGELOG.md). ## Get involved We welcome contributions of all kinds! Please take a moment to review our [Code of Conduct](CODE_OF_CONDUCT.md). 🐛 **Found a bug?** Let us know by [creating an issue][new-issue]. ❓ **Have a question?** Our [Gitter channel][gitter] and [GitHub Discussions][discussions] are good places to start. ⚙️ **Interested in fixing a [bug][bugs] or adding a [feature][features]?** Check out the [contributing guidelines](CONTRIBUTING.md) and the [project roadmap](roadmap.md). 📖 **Can we improve [our documentation][docs]?** Pull requests even for small changes can be helpful. Each page in the docs can be edited by clicking the “Edit on GitHub” link at the top right. [new-issue]: https://github.com/boardgameio/boardgame.io/issues/new/choose [gitter]: https://gitter.im/boardgame-io/General [discussions]: https://github.com/boardgameio/boardgame.io/discussions [bugs]: https://github.com/boardgameio/boardgame.io/issues?q=is%3Aissue+is%3Aopen+label%3Abug [features]: https://github.com/boardgameio/boardgame.io/issues?q=is%3Aissue+is%3Aopen+label%3Afeature [docs]: https://boardgame.io/documentation/ [sponsors]: https://github.com/sponsors/boardgameio [collective]: https://opencollective.com/boardgameio#support ## License [MIT](LICENSE) ================================================ FILE: babel.config.js ================================================ module.exports = { presets: [ [ '@babel/preset-env', { modules: false, exclude: ['transform-regenerator', 'transform-async-to-generator'], }, ], '@babel/preset-react', '@babel/typescript', ], env: { test: { plugins: ['@babel/plugin-transform-modules-commonjs'], }, }, plugins: [ [ 'module-resolver', { alias: { 'boardgame.io': './packages', }, }, ], '@babel/plugin-proposal-class-properties', '@babel/proposal-object-rest-spread', ], }; ================================================ FILE: benchmark/index.js ================================================ /* * Copyright 2019 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import Benchmark from 'benchmark'; import { Client } from '../dist/esm/client'; import { InitializeGame } from '../src/core/initialize'; import { CreateGameReducer } from '../src/core/reducer'; import { makeMove, gameEvent } from '../src/core/action-creators'; const game = { moves: { A: ({ G }) => G, }, endIf: () => false, }; const reducer = CreateGameReducer({ game }); const state = InitializeGame({ game }); const client = Client({ game }); new Benchmark.Suite() .add('reducer::makeMove', function () { reducer(state, makeMove('A')); }) .add('reducer::endTurn', function () { reducer(state, gameEvent('endTurn')); }) .add('client::move', function () { client.moves.A(); }) .add('client::endTurn', function () { client.events.endTurn(); }) .on('cycle', function (event) { console.log(String(event.target)); }) .on('complete', function () { console.log('Fastest is ' + this.filter('fastest').map('name')); }) .run({ async: true }); ================================================ FILE: docs/CNAME ================================================ boardgame.io ================================================ FILE: docs/documentation/.nojekyll ================================================ ================================================ FILE: docs/documentation/CHANGELOG.md ================================================ ### v0.50.2 This release includes dependency upgrades only. ### v0.50.1 This release fixes compatibility with React v18. Thanks [@mbrinkl](https://github.com/mbrinkl)! #### Bugfixes * [[2afffb13](https://github.com/boardgameio/boardgame.io/commit/2afffb13)] Fix React 18 compatibility ([#1104](https://github.com/boardgameio/boardgame.io/pull/1104)) * [[74722165](https://github.com/boardgameio/boardgame.io/commit/74722165)] Use correct CSS margin syntax in debug panel ## v0.50.0 This release includes a large refactor in boardgame.io API. Callbacks that used to be `(G, ctx) => {}` now becomes `({ G, ctx }) => {}` ... Thanks [@delucis](https://github.com/delucis) for the great contribution! ### Features * [[da1ccb1](https://github.com/boardgameio/boardgame.io/commit/da1ccb18819fa265144da075a445e003d8a2fcc8)] feat: Change move and hook signature ([#891](https://github.com/boardgameio/boardgame.io/pull/891)) ### v0.49.13 ### Features * [[aa99a9c](https://github.com/boardgameio/boardgame.io/commit/aa99a9cce28012cb747fa6db8b3f8ad73c28be0a)] feat: Conditional log redacting in long form move ([#1089](https://github.com/boardgameio/boardgame.io/pull/1089)) * [[4bf203c](https://github.com/boardgameio/boardgame.io/commit/4bf203c1c1ec42e3193935a39e4cfb54a5658627)] TypeScript: AiEnumerate return type ([#1080](https://github.com/boardgameio/boardgame.io/pull/1080)) ### v0.49.12 #### Bugfixes * [[96b26bb9](https://github.com/boardgameio/boardgame.io/commit/96b26bb9)] lobby: block creation of matches with invalid player counts ([#1060](https://github.com/boardgameio/boardgame.io/pull/1060)) * [[453f530c](https://github.com/boardgameio/boardgame.io/commit/453f530c)] types: Use correct socket.io options typing * [[3692199](https://github.com/boardgameio/boardgame.io/commit/3692199), [1e57a9c](https://github.com/boardgameio/boardgame.io/commit/1e57a9c)] Update dependencies: socket.io and koa-body ### v0.49.11 #### Bugfixes * [[453f530c](https://github.com/boardgameio/boardgame.io/commit/453f530c)] types: Use correct socket.io options typing * [[7e55d118](https://github.com/boardgameio/boardgame.io/commit/7e55d118), [75428111](https://github.com/boardgameio/boardgame.io/commit/75428111), [4fa2c4f1](https://github.com/boardgameio/boardgame.io/commit/4fa2c4f1), [52450607](https://github.com/boardgameio/boardgame.io/commit/52450607)] Update dependencies: engine.io, nanoid, ajv, and node-fetch ### v0.49.10 #### Bugfixes * [[6756419a](https://github.com/boardgameio/boardgame.io/commit/6756419a)] Include `testing/package.json` in npm files ### v0.49.9 #### Features * [[636ce8f6](https://github.com/boardgameio/boardgame.io/commit/636ce8f6)] Add testing utility for mocking the randomness API #### Bugfixes * [[99639c41](https://github.com/boardgameio/boardgame.io/commit/99639c41)] lobby: only poll matches when match list is displayed ([#1044](https://github.com/boardgameio/boardgame.io/pull/1044)) ### v0.49.8 #### Features * [[bd34bc39](https://github.com/boardgameio/boardgame.io/commit/bd34bc39)] debug: Add collapse on load & hide toggle button options (PR [#1040](https://github.com/boardgameio/boardgame.io/pull/1040), Issue [#1039](https://github.com/boardgameio/boardgame.io/issues/1039)) #### Bugfixes * [[ee230c14](https://github.com/boardgameio/boardgame.io/commit/ee230c14)] types: Always allow player ID array as active players argument (Issue [#1041](https://github.com/boardgameio/boardgame.io/issues/1041)) ### v0.49.7 #### Bugfixes * [[39e1f187](https://github.com/boardgameio/boardgame.io/commit/39e1f187)] Bump `rfc6902` dependency to address prototype pollution vulnerability ### v0.49.6 #### Bugfixes * [[cf6ade54](https://github.com/boardgameio/boardgame.io/commit/cf6ade54)] Add `ctx` type parameter to client ([#1035](https://github.com/boardgameio/boardgame.io/pull/1035)) ### v0.49.5 #### Bugfixes * [[279a822f](https://github.com/boardgameio/boardgame.io/commit/279a822f)] flow: Pass correct `ctx` to `onMove` hooks ### v0.49.4 #### Features * [[94472c0a](https://github.com/boardgameio/boardgame.io/commit/94472c0a)] react-native: handle multiplayer loading state in client ([#1026](https://github.com/boardgameio/boardgame.io/pull/1026)) #### Bugfixes * [[43019562](https://github.com/boardgameio/boardgame.io/commit/43019562)] Update gameover metadata when `null` ([#1025](https://github.com/boardgameio/boardgame.io/pull/1025)) ### v0.49.3 #### Bugfixes * [[220823bb](https://github.com/boardgameio/boardgame.io/commit/220823bb)] Update gameover metadata when `undefined` ([#1023](https://github.com/boardgameio/boardgame.io/pull/1023)) * [[df6b7c40](https://github.com/boardgameio/boardgame.io/commit/df6b7c40)] include `chatMessages` property in React client types ([#1022](https://github.com/boardgameio/boardgame.io/pull/1022)) ### v0.49.2 #### Features * [[ff4c7564](https://github.com/boardgameio/boardgame.io/commit/ff4c7564)] core: Expose `ctx.playerID` in `onMove` hook ([#1019](https://github.com/boardgameio/boardgame.io/pull/1019)) #### Bugfixes * [[fa7544f5](https://github.com/boardgameio/boardgame.io/commit/fa7544f5)] api: Update query string types and handle array queries * [[4813fa15](https://github.com/boardgameio/boardgame.io/commit/4813fa15), [0f6076b5](https://github.com/boardgameio/boardgame.io/commit/0f6076b5), [06b4172d](https://github.com/boardgameio/boardgame.io/commit/06b4172d), [54c0b4c0](https://github.com/boardgameio/boardgame.io/commit/54c0b4c0)] Update dependencies ### v0.49.1 #### Features * [[7b151798](https://github.com/boardgameio/boardgame.io/commit/7b151798)] Expose `getFilterPlayerView` from `internal` package #### Bugfixes * [[20817aa3](https://github.com/boardgameio/boardgame.io/commit/20817aa3)] transport: More accurately type `TransportOpts` ## v0.49.0 #### Features * [[604d12e6](https://github.com/boardgameio/boardgame.io/commit/604d12e6)] lobby: use first available `playerID` when joining a match ([#1013](https://github.com/boardgameio/boardgame.io/pull/1013)) * [[510a082a](https://github.com/boardgameio/boardgame.io/commit/510a082a)] transport: Consolidate transport interface ([#1002](https://github.com/boardgameio/boardgame.io/pull/1002)) * [[d30d5776](https://github.com/boardgameio/boardgame.io/commit/d30d5776)] Expose `createMatch` utility #### Bugfixes * [[ca94f3a5](https://github.com/boardgameio/boardgame.io/commit/ca94f3a5)] lobby: Prevent error accessing fetch response twice ([#1005](https://github.com/boardgameio/boardgame.io/pull/1005)) ## v0.48.0 #### Features * [[4165d45d](https://github.com/boardgameio/boardgame.io/commit/4165d45d)] Deprecate `moveLimit` in favour of `minMoves`/`maxMoves` ([#985](https://github.com/boardgameio/boardgame.io/pull/985)) Migration: - Replace `turn.moveLimit` with both `turn.minMoves` and `turn.maxMoves`. - Replace `moveLimit` in `setStage` and `setActivePlayers` with `maxMoves`. ### v0.47.10 #### Bugfixes * [[ad78eded](https://github.com/boardgameio/boardgame.io/commit/ad78eded)] ai: Run AI iterations using `setImmediate` for improved performance ([#999](https://github.com/boardgameio/boardgame.io/pull/999)) * [[feb08a12](https://github.com/boardgameio/boardgame.io/commit/feb08a12)] lobby: Clean up & update refresh polling interval properly ([#996](https://github.com/boardgameio/boardgame.io/pull/996)) ### v0.47.9 #### Bugfixes * [[a240bbee](https://github.com/boardgameio/boardgame.io/commit/a240bbee)] client: Fix React Native support * [[8b871ab5](https://github.com/boardgameio/boardgame.io/commit/8b871ab5)] server: Support custom Lobby API middleware ([#992](https://github.com/boardgameio/boardgame.io/pull/992)) ### v0.47.8 #### Bugfixes * [[06bc7479](https://github.com/boardgameio/boardgame.io/commit/06bc7479)] debug: Improve AI panel accessibility * [[d2b611d7](https://github.com/boardgameio/boardgame.io/commit/d2b611d7)] debug: Stop panel intercepting clicks in transparent parts ### v0.47.7 #### Features * [[98c860ec](https://github.com/boardgameio/boardgame.io/commit/98c860ec)] debug: Support toggling debug panel visibility without a keyboard ([#991](https://github.com/boardgameio/boardgame.io/pull/991)) #### Bugfixes * [[f18c63a1](https://github.com/boardgameio/boardgame.io/commit/f18c63a1)] types: playerID in `playerView` can be string or null ([#990](https://github.com/boardgameio/boardgame.io/pull/990)) ### v0.47.6 #### Bugfixes * [[62f97e54](https://github.com/boardgameio/boardgame.io/commit/62f97e54)] Allow plugins to use events in `fnWrap` ### v0.47.5 #### Bugfixes * [[fa30fcca](https://github.com/boardgameio/boardgame.io/commit/fa30fcca)] types: Expect `Game.setup` method to return `G` ([#987](https://github.com/boardgameio/boardgame.io/pull/987)) ### v0.47.4 #### Bugfixes * [[d54af1f4](https://github.com/boardgameio/boardgame.io/commit/d54af1f4)] events: Don’t use const enum for better backwards compatibility ### v0.47.3 * Dependency changes only ### v0.47.2 #### Features * [[8267e36c](https://github.com/boardgameio/boardgame.io/commit/8267e36c)] events: Add stack traces to events plugin errors ### v0.47.1 #### Features * [[f97a08d8](https://github.com/boardgameio/boardgame.io/commit/f97a08d8)] Improve events errors & expose method types to plugin `fnWrap` ([#980](https://github.com/boardgameio/boardgame.io/pull/980)) - See [the Events guide](events#calling-events-from-hooks) for details of event support in game hooks * [[95be8b90](https://github.com/boardgameio/boardgame.io/commit/95be8b90)] events: Accurately type events API arguments ## v0.47.0 #### Features * [[b6a4fed](https://github.com/boardgameio/boardgame.io/commit/b6a4fed)] Adds pub-sub support for horizontally scaling bgio server ([#978](https://github.com/boardgameio/boardgame.io/pull/978)) #### Bugfixes * [[241701f](https://github.com/boardgameio/boardgame.io/commit/241701f)] master: Don’t crash on missing `chatMessage` ([#977](https://github.com/boardgameio/boardgame.io/pull/977)) ### v0.46.2 #### Features * [[064b7507](https://github.com/boardgameio/boardgame.io/commit/064b7507)] Support setting next phase with a function ([#972](https://github.com/boardgameio/boardgame.io/pull/972)) #### Bugfixes * [[bff1d294](https://github.com/boardgameio/boardgame.io/commit/bff1d294)] flow: Run `turn.endIf` after `setActivePlayers` event ### v0.46.1 #### Bugfixes * [[f0bc8b9](https://github.com/boardgameio/boardgame.io/commit/f0bc8b9)] flow: Run `turn.endIf` hook after updating stages ## v0.46.0 #### Features * [[91cf25e](https://github.com/boardgameio/boardgame.io/commit/91cf25e)] Events Plugin: Don’t leak stage events across turns & allow self-ending turns/phases ([#957](https://github.com/boardgameio/boardgame.io/pull/957)) * [[afee0b7](https://github.com/boardgameio/boardgame.io/commit/afee0b7), [1e435c2](https://github.com/boardgameio/boardgame.io/commit/1e435c2), [1078b13](https://github.com/boardgameio/boardgame.io/commit/1078b13)] Allow plugins to declare an action invalid ([#963](https://github.com/boardgameio/boardgame.io/pull/963), [#970](https://github.com/boardgameio/boardgame.io/pull/970)) * [[262d867](https://github.com/boardgameio/boardgame.io/commit/262d867)] Server: Decouple player view calculation from Master ([#966](https://github.com/boardgameio/boardgame.io/pull/966)) #### Bugfixes * [[dcaca7f](https://github.com/boardgameio/boardgame.io/commit/dcaca7f)] types: Remove `turn.moves` from `Game` type * [[b753094](https://github.com/boardgameio/boardgame.io/commit/b753094), [2aa9db5](https://github.com/boardgameio/boardgame.io/commit/2aa9db5)] Update dependencies ([#965](https://github.com/boardgameio/boardgame.io/pull/965), [#968](https://github.com/boardgameio/boardgame.io/pull/968)) #### Other * [[be63602](https://github.com/boardgameio/boardgame.io/commit/be63602)] Update development dependencies ([#969](https://github.com/boardgameio/boardgame.io/pull/969)) * [[4efec1a](https://github.com/boardgameio/boardgame.io/commit/4efec1a)] Update linter tooling & refactor errors ([#967](https://github.com/boardgameio/boardgame.io/pull/967)) ### v0.45.2 #### Bugfixes * [[9753c0e](https://github.com/boardgameio/boardgame.io/commit/9753c0e)] fix: Don’t leak `STRIP_TRANSIENTS` action ([#961](https://github.com/boardgameio/boardgame.io/pull/961)) ### v0.45.1 #### Breaking Changes Please see notes for v0.45.0. This release extends CORS security restrictions to the Lobby API (0.45.0 only applied the `origins` config to the socket.io server). #### Features * [[8b950a0](https://github.com/boardgameio/boardgame.io/commit/8b950a0)] server: Use `origins` option to configure Lobby API CORS ([#955](https://github.com/boardgameio/boardgame.io/pull/955)) #### Bugfixes * [[2b1d013](https://github.com/boardgameio/boardgame.io/commit/2b1d013)] server: Update to latest `@types/cors` to provide better origins configuration defaults ## v0.45.0 #### Breaking Changes Previously boardgame.io servers allowed CORS requests from all origins by default. After updating socket.io in 0.45.0, an `origins` option must now be provided when creating the server to enable cross-origin requests: ```js const { Server } = require('boardgame.io/server'); Server({ origins: ['https://www.mygame.com'], // ... }); ``` See [the Server reference page](https://boardgame.io/documentation/#/api/Server) for more details. #### Features * [[dffcb18](https://github.com/boardgameio/boardgame.io/commit/dffcb18)] chore(deps): Upgrade socket.io packages ([#946](https://github.com/boardgameio/boardgame.io/pull/946)) ### v0.44.4 #### Features * [[2eca252](https://github.com/boardgameio/boardgame.io/commit/2eca252)] Improve error handling (work in progress) (PR [#940](https://github.com/boardgameio/boardgame.io/pull/940), Issue [#723](https://github.com/boardgameio/boardgame.io/issues/723)) * [[cceb0f2](https://github.com/boardgameio/boardgame.io/commit/cceb0f2)] package: Add funding field #### Bugfixes * [[49c2c12](https://github.com/boardgameio/boardgame.io/commit/49c2c12)] reducer: Don’t crash when undoing stage events ([#942](https://github.com/boardgameio/boardgame.io/pull/942)) ### v0.44.3 #### Bugfixes * [[f1c60ee](https://github.com/boardgameio/boardgame.io/commit/f1c60ee), [3718124](https://github.com/boardgameio/boardgame.io/commit/3718124), [2685470](https://github.com/boardgameio/boardgame.io/commit/2685470), [ac78e82](https://github.com/boardgameio/boardgame.io/commit/ac78e82), [5b0de40](https://github.com/boardgameio/boardgame.io/commit/5b0de40)] Dependency updates ### v0.44.2 #### Bugfixes * [[b832b07](https://github.com/boardgameio/boardgame.io/commit/b832b07)] Make custom plugins available in event hooks ([#932](https://github.com/boardgameio/boardgame.io/pull/932)) ### v0.44.1 #### Bugfixes * [[1d20e7e](https://github.com/boardgameio/boardgame.io/commit/1d20e7e)] client: Correct signature type for `sendChatMessage` * [[a7c8776](https://github.com/boardgameio/boardgame.io/commit/a7c8776)] debug panel: Handle all possible argument types in the log pane ## v0.44.0 #### Features * [[abc516b](https://github.com/boardgameio/boardgame.io/commit/abc516b)] Add an option to use JSON patches for state updates ([#920](https://github.com/boardgameio/boardgame.io/pull/920)) * [[0bab885](https://github.com/boardgameio/boardgame.io/commit/0bab885)] Support spying on logs after framework is loaded ([#918](https://github.com/boardgameio/boardgame.io/pull/918)) * [[a57fe19](https://github.com/boardgameio/boardgame.io/commit/a57fe19)] Export additional game API types ([#927](https://github.com/boardgameio/boardgame.io/pull/927)) #### Bugfixes * [[e94d476](https://github.com/boardgameio/boardgame.io/commit/e94d476)] Improve TypeScript typings for the player plugin and React client ([#922](https://github.com/boardgameio/boardgame.io/pull/922)) ### v0.43.3 #### Features * [[01c522c](https://github.com/boardgameio/boardgame.io/commit/01c522c)] Throw error in development if non-serializable state is used in a move ([#896](https://github.com/boardgameio/boardgame.io/pull/896)) * [[ccc9ada](https://github.com/boardgameio/boardgame.io/commit/ccc9ada)] Add details to exceptions raised in LobbyClient ([#898](https://github.com/boardgameio/boardgame.io/pull/898)) #### Bugfixes * [[ae790e8](https://github.com/boardgameio/boardgame.io/commit/ae790e8)] dependencies: bump immer from 7.0.8 to 8.0.1 ([#895](https://github.com/boardgameio/boardgame.io/pull/895)) * [[6f9bc27](https://github.com/boardgameio/boardgame.io/commit/6f9bc27)] dependencies: Run `npm audit fix` ### v0.43.2 #### Bugfixes * [[3d614b8](https://github.com/boardgameio/boardgame.io/commit/3d614b8)] server: Improve security of socket.io transport layer ([#894](https://github.com/boardgameio/boardgame.io/pull/894)) * [[dc96b26](https://github.com/boardgameio/boardgame.io/commit/dc96b26)] master: Disallow `onSync` match creation if game requires `setupData` ([#890](https://github.com/boardgameio/boardgame.io/pull/890)) ### v0.43.1 #### Bugfixes * [[b14ea29](https://github.com/boardgameio/boardgame.io/commit/b14ea29)] client: Always include `chatMessages` and `sendChatMessage` on client instances ## v0.43.0 #### Features * [[0ad1d6d](https://github.com/boardgameio/boardgame.io/commit/0ad1d6d)] [[c178a41](https://github.com/boardgameio/boardgame.io/commit/c178a41)] Add in-game chat support ([#871](https://github.com/boardgameio/boardgame.io/pull/871), [#879](https://github.com/boardgameio/boardgame.io/pull/879)) * [[3918cc9](https://github.com/boardgameio/boardgame.io/commit/3918cc9)] Add plugin to allow adding custom metadata to logs ([#865](https://github.com/boardgameio/boardgame.io/pull/865)) * [[243388d](https://github.com/boardgameio/boardgame.io/commit/243388d)] Centralise lobby API and master authentication logic ([#853](https://github.com/boardgameio/boardgame.io/pull/853)) #### Bugfixes * [[e515573](https://github.com/boardgameio/boardgame.io/commit/e515573)] Handle random plugin redacted state on multiplayer clients ([#885](https://github.com/boardgameio/boardgame.io/pull/885)) * [[3b8ac79](https://github.com/boardgameio/boardgame.io/commit/3b8ac79)] Prevent `TypeError: state.deltalog is not iterable` ([#888](https://github.com/boardgameio/boardgame.io/pull/888)) * [[2cc6104](https://github.com/boardgameio/boardgame.io/commit/2cc6104)] Support latest Safari & Firefox when running examples ### v0.42.2 #### Features * [[8f8d30e](https://github.com/boardgameio/boardgame.io/commit/8f8d30e)] plugins: Add `playerView` option to plugin API (closes [#671](https://github.com/boardgameio/boardgame.io/issues/671)) ([#857](https://github.com/boardgameio/boardgame.io/pull/857)) #### Bugfixes * [[c51bc09](https://github.com/boardgameio/boardgame.io/commit/c51bc09)] debug: prevent `endStage` event crashing debug log panel ([#856](https://github.com/boardgameio/boardgame.io/pull/856)) ### v0.42.1 #### Features * [[6c4e94f](https://github.com/boardgameio/boardgame.io/commit/6c4e94f)] Add option to long-form move to ignore stale `stateID` (closes [#828](https://github.com/boardgameio/boardgame.io/issues/828)) ([#832](https://github.com/boardgameio/boardgame.io/pull/832)) #### Bugfixes * [[5207be5](https://github.com/boardgameio/boardgame.io/commit/5207be5)] core: `setStage(Stage.NULL)` makes player active (closes [#848](https://github.com/boardgameio/boardgame.io/issues/848)) ([#849](https://github.com/boardgameio/boardgame.io/pull/849)) ## v0.42.0 #### Features * [[6ba5536](https://github.com/boardgameio/boardgame.io/commit/6ba5536)] Add localStorage support to Local master ([#691](https://github.com/boardgameio/boardgame.io/pull/691)) * [[257a735](https://github.com/boardgameio/boardgame.io/commit/257a735)] Track players connection status ([#839](https://github.com/boardgameio/boardgame.io/pull/839)) * [[d071e98](https://github.com/boardgameio/boardgame.io/commit/d071e98)] Add game method to validate setup data ([#831](https://github.com/boardgameio/boardgame.io/pull/831)) #### Bugfixes * [[ace1144](https://github.com/boardgameio/boardgame.io/commit/ace1144)] local: Use shared local master with bots ([#838](https://github.com/boardgameio/boardgame.io/pull/838)) * [[9fcbed7](https://github.com/boardgameio/boardgame.io/commit/9fcbed7)] client: Correctly type state returned by client to subscribers ### v0.41.1 Push another release to fix NPM weirdness. ## v0.41.0 #### Features * [[c8c648e](https://github.com/boardgameio/boardgame.io/commit/c8c648e)] Add global manager to track client & debug panel instances ([#816](https://github.com/boardgameio/boardgame.io/pull/816)) * [[af43561](https://github.com/boardgameio/boardgame.io/commit/af43561)] Add log entries for undo/redo actions ([#825](https://github.com/boardgameio/boardgame.io/pull/825)) * [[2595399](https://github.com/boardgameio/boardgame.io/commit/2595399)] Improve undo/redo ([#823](https://github.com/boardgameio/boardgame.io/pull/823)) * [[3d2131e](https://github.com/boardgameio/boardgame.io/commit/3d2131e)] master: Use `createMatch` for implicit match creation ([#821](https://github.com/boardgameio/boardgame.io/pull/821)) * [[f74f953](https://github.com/boardgameio/boardgame.io/commit/f74f953)] Improve match term consistency ([#806](https://github.com/boardgameio/boardgame.io/pull/806)) #### Bugfixes * [[f5d3a97](https://github.com/boardgameio/boardgame.io/commit/f5d3a97)] debug: Improve debug panel accessibility ([#827](https://github.com/boardgameio/boardgame.io/pull/827)) * [[0040a5d](https://github.com/boardgameio/boardgame.io/commit/0040a5d)] reducer: Restore plugin state on undo/redo * [[4f2ffc4](https://github.com/boardgameio/boardgame.io/commit/4f2ffc4)] debug: Include plugin data in log rewind gamestate override * [[197a9bb](https://github.com/boardgameio/boardgame.io/commit/197a9bb)] reducer: Fix stateID increment & build deltalog on server only ([#817](https://github.com/boardgameio/boardgame.io/pull/817)) * [[600caa8](https://github.com/boardgameio/boardgame.io/commit/600caa8)] debug: Use local reducer in Log rewind * [[3c2aadd](https://github.com/boardgameio/boardgame.io/commit/3c2aadd)] client: Don’t run playerView locally on multiplayer clients ([#819](https://github.com/boardgameio/boardgame.io/pull/819)) * [[abd9695](https://github.com/boardgameio/boardgame.io/commit/abd9695)] fix: Simplify local transport typing * [[9a2a609](https://github.com/boardgameio/boardgame.io/commit/9a2a609)] fix: improve socketio.ts typings ([#811](https://github.com/boardgameio/boardgame.io/pull/811)) * [[0f8882a](https://github.com/boardgameio/boardgame.io/commit/0f8882a)] core: Fix EndPhase deltalog ([#812](https://github.com/boardgameio/boardgame.io/pull/812)) * [[4bd283c](https://github.com/boardgameio/boardgame.io/commit/4bd283c)] fix: FlatFile.listGames filter ([#802](https://github.com/boardgameio/boardgame.io/pull/802)) * [[0062e55](https://github.com/boardgameio/boardgame.io/commit/0062e55)] client: Improve client types ([#801](https://github.com/boardgameio/boardgame.io/pull/801)) ## v0.40.0 #### Breaking Changes See [PR #709 on GitHub](https://github.com/boardgameio/boardgame.io/pull/709) for a full list and migration guide. #### Features * [[43c7fbb](https://github.com/boardgameio/boardgame.io/commit/43c7fbb)] Add plain JS lobby client ([#728](https://github.com/boardgameio/boardgame.io/pull/728)) * [[adb251d](https://github.com/boardgameio/boardgame.io/commit/adb251d)] [[6fcf695](https://github.com/boardgameio/boardgame.io/commit/6fcf695)] Use “match” instead of “game”/“room” in Lobby API ([#704](https://github.com/boardgameio/boardgame.io/pull/704), [#765](https://github.com/boardgameio/boardgame.io/pull/765)) * [[88b0ee7](https://github.com/boardgameio/boardgame.io/commit/88b0ee7)] debug: Display live object view in debug panel ([#781](https://github.com/boardgameio/boardgame.io/pull/781), [#790](https://github.com/boardgameio/boardgame.io/pull/790)) * [[3984ee7](https://github.com/boardgameio/boardgame.io/commit/3984ee7)] debug: Events shortcuts, stages support & minor fixes ([#791](https://github.com/boardgameio/boardgame.io/pull/791)) * [[3c8777b](https://github.com/boardgameio/boardgame.io/commit/3c8777b)] server: Timestamp metadata and match filtering in the Lobby API ([#740](https://github.com/boardgameio/boardgame.io/pull/740)) * [[a0c34fd](https://github.com/boardgameio/boardgame.io/commit/a0c34fd)] server: Expose API router ([#698](https://github.com/boardgameio/boardgame.io/pull/698)) #### Bugfixes * [[2586022](https://github.com/boardgameio/boardgame.io/commit/2586022)] debug: Keep debug panel above other page elements ([#780](https://github.com/boardgameio/boardgame.io/pull/780)) * [[f32dc76](https://github.com/boardgameio/boardgame.io/commit/f32dc76)] api: Expose gameover metadata in lobby endpoints ([#666](https://github.com/boardgameio/boardgame.io/pull/666)) * [[fa865da](https://github.com/boardgameio/boardgame.io/commit/fa865da)] fixes build in rushjs monorepo context ([#550](https://github.com/boardgameio/boardgame.io/pull/550)) ### v0.39.16 #### Bugfixes * [[84e93b4](https://github.com/boardgameio/boardgame.io/commit/84e93b4)] build: Exclude svelte from Rollup’s external module list (#767) * [[72ed591](https://github.com/boardgameio/boardgame.io/commit/72ed591)] fix: Tidy up type provision (#764) ### v0.39.15 #### Bugfixes * [[271aecd](https://github.com/boardgameio/boardgame.io/commit/271aecd)] fix: Fix client types & move BoardProps to React package (#752) ### v0.39.14 #### Features * [[c115087](https://github.com/boardgameio/boardgame.io/commit/c115087)] game: add disableUndo flag to game config (#742) #### Bugfixes * [[a6698e6](https://github.com/boardgameio/boardgame.io/commit/a6698e6)] debug: Fix save & restore in Debug panel (#746) ### v0.39.13 #### Features * [[2b1bd19](https://github.com/boardgameio/boardgame.io/commit/2b1bd19)] ai: Convert to Typescript (#734) * [[872b58b](https://github.com/boardgameio/boardgame.io/commit/872b58b)] client: Make argument optional in Local transport (#731) #### Bugfixes * [[0088cb5](https://github.com/boardgameio/boardgame.io/commit/0088cb5)] fix: Re-sync client on reconnect, fixes #713 (#727) ### v0.39.12 #### Features * [[0ca1cef](https://github.com/boardgameio/boardgame.io/commit/0ca1cef)] lobby: use previous game config as defaults in playAgain (#719) * [[60b32e5](https://github.com/boardgameio/boardgame.io/commit/60b32e5)] Add support for socket.io-adapter implementations (#706) * [[eb236df](https://github.com/boardgameio/boardgame.io/commit/eb236df)] db: FlatFile per-file request queues enhancement (#705) #### Bugfixes * [[ef97441](https://github.com/boardgameio/boardgame.io/commit/ef97441)] debug: Save all state to localStorage, not just G and ctx (#716) * [[4d45ff1](https://github.com/boardgameio/boardgame.io/commit/4d45ff1)] client: Fix restore in debug controls (#712) * [[d728425](https://github.com/boardgameio/boardgame.io/commit/d728425)] core: Fix undo/redo if the move changed the stage (#701) * [[3a622f1](https://github.com/boardgameio/boardgame.io/commit/3a622f1)] Use Promise chaining to enforce read/write queue (#699) ### v0.39.11 #### Features * [[cfeaf67](https://github.com/boardgameio/boardgame.io/commit/cfeaf67)] plugins: Let moves return INVALID_MOVE after mutating G (#688) * [[b2d6b06](https://github.com/boardgameio/boardgame.io/commit/b2d6b06)] server: Lobby API improvements (#675) * [[dc668ec](https://github.com/boardgameio/boardgame.io/commit/dc668ec)] server: Expose SocketIO transport & convert to TS (#658) #### Bugfixes * [[1483a08](https://github.com/boardgameio/boardgame.io/commit/1483a08)] fix UX issues in move debugger (#640) * [[814621b](https://github.com/boardgameio/boardgame.io/commit/814621b)] Make package.json scripts work on Windows (#657) ### v0.39.10 #### Features * [[cf96955](https://github.com/boardgameio/boardgame.io/commit/cf96955)] Add option to exclude games from public listing (#653) * [[2e5b902](https://github.com/boardgameio/boardgame.io/commit/2e5b902)] core: Support moves that don’t contribute to numMoves (#646) * [[e4fc7bd](https://github.com/boardgameio/boardgame.io/commit/e4fc7bd)] master: Update metadata with gameover value on game end (#645) * [[05eacb8](https://github.com/boardgameio/boardgame.io/commit/05eacb8)] Enable adding additional metadata to players in Lobby (#642) #### Bugfixes * [[d2f668b](https://github.com/boardgameio/boardgame.io/commit/d2f668b)] Fix plugins in hooks triggered by moves (#656) * [[334f8d6](https://github.com/boardgameio/boardgame.io/commit/334f8d6)] [Documentation] Remove references to removed MongoDB adapter (#659) * [[a4c4c7c](https://github.com/boardgameio/boardgame.io/commit/a4c4c7c)] Test warning is logged when using deprecated `/rename` API endpoint. (#655) * [[6aff09c](https://github.com/boardgameio/boardgame.io/commit/6aff09c)] Add playAgain endpoint to Lobby documentation (#652) * [[9f4acfe](https://github.com/boardgameio/boardgame.io/commit/9f4acfe)] Add link to Azure Storage database connector (#651) * [[78113aa](https://github.com/boardgameio/boardgame.io/commit/78113aa)] Add mosaic to notable_projects.md (#649) * [[c51b277](https://github.com/boardgameio/boardgame.io/commit/c51b277)] expose log as a prop in the React client (#641) ### v0.39.9 #### Bugfixes * [[b4bd8b7](https://github.com/boardgameio/boardgame.io/commit/b4bd8b7)] package: update npm files field for new server bundle (#639) * [[0552efb](https://github.com/boardgameio/boardgame.io/commit/0552efb)] add src/ to NPM ### v0.39.8 #### Bugfixes * [[3569408](https://github.com/boardgameio/boardgame.io/commit/3569408)] Add option to run server over HTTPS (#631) * [[c56d9b9](https://github.com/boardgameio/boardgame.io/commit/c56d9b9)] Adding playerID to Ctx (#627) * [[882a25d](https://github.com/boardgameio/boardgame.io/commit/882a25d)] export only the client in the browser-minified package * [[3d1c07c](https://github.com/boardgameio/boardgame.io/commit/3d1c07c)] server: Proxy server module with package.json (#622) * [[bd44678](https://github.com/boardgameio/boardgame.io/commit/bd44678)] Fix passing params to db adapter (#621) ### v0.39.7 #### Bugfixes * [[44d0d4f](https://github.com/boardgameio/boardgame.io/commit/44d0d4f)] fix bad merge that undid https://github.com/boardgameio/boardgame.io/pull/614 ### v0.39.6 #### Features * [[c5211c2](https://github.com/boardgameio/boardgame.io/commit/c5211c2)] Typescript enhancements (#612) #### Bugfixes * [[eb1e060](https://github.com/boardgameio/boardgame.io/commit/eb1e060)] make plugins available in turn order functions * [[0688f4d](https://github.com/boardgameio/boardgame.io/commit/0688f4d)] Include credentials in undo/redo actions (#595) * [[f34f46b](https://github.com/boardgameio/boardgame.io/commit/f34f46b)] core: Don’t error if turn.order.next returns undefined (#614) ### v0.39.5 #### Features * [[78729eb](https://github.com/boardgameio/boardgame.io/commit/78729eb)] core: More Typescript conversion (#597) * [[3a41cf7](https://github.com/boardgameio/boardgame.io/commit/3a41cf7)] plugins: Make player plugin a factory function (#604) #### Bugfixes * [[1877268](https://github.com/boardgameio/boardgame.io/commit/1877268)] plugins: More Typescript & pass playerID to Enhance (#598) * [[5696dc4](https://github.com/boardgameio/boardgame.io/commit/5696dc4)] server: Correctly wait for server.listen event (#589) ### v0.39.4 #### Features * [[167690c](https://github.com/boardgameio/boardgame.io/commit/167690c)] add plugin types to ctx interface (#579) * [[618618e](https://github.com/boardgameio/boardgame.io/commit/618618e)] db: Make listGames options optional (#585) * [[f3c62a3](https://github.com/boardgameio/boardgame.io/commit/f3c62a3)] db: Make log handling explicit in StorageAPI.setState (#581) * [[c7dad76](https://github.com/boardgameio/boardgame.io/commit/c7dad76)] add the ability for plugins to define their own actions #### Bugfixes * [[9a21fee](https://github.com/boardgameio/boardgame.io/commit/9a21fee)] Remove namespacing in gameIDs on client side (#583) ### v0.39.3 #### Features * [[c507cf0](https://github.com/boardgameio/boardgame.io/commit/c507cf0)] Typescript improvements (#578) ### v0.39.1 #### Bugfixes * [[ca3cc0f](https://github.com/boardgameio/boardgame.io/commit/ca3cc0f)] avoid document reference error in some versions of Node ## v0.39.0 #### Features * [[ca52b01](https://github.com/boardgameio/boardgame.io/commit/ca52b01)] export some types in the NPM * [[6a091de](https://github.com/boardgameio/boardgame.io/commit/6a091de)] retrieve initial state using a separate code path * [[62f58d2](https://github.com/boardgameio/boardgame.io/commit/62f58d2)] add createGame to StorageAPI * [[21c3ef4](https://github.com/boardgameio/boardgame.io/commit/21c3ef4)] make listGames take an opts argument * [[bff685d](https://github.com/boardgameio/boardgame.io/commit/bff685d)] rename remove to wipe * [[d4de9e2](https://github.com/boardgameio/boardgame.io/commit/d4de9e2)] move log out of game state * [[045a8f5](https://github.com/boardgameio/boardgame.io/commit/045a8f5)] rename list to listGames * [[7b70cab](https://github.com/boardgameio/boardgame.io/commit/7b70cab)] remove MongoDB * [[0fb67fe](https://github.com/boardgameio/boardgame.io/commit/0fb67fe)] remove Firebase * [[c96e228](https://github.com/boardgameio/boardgame.io/commit/c96e228)] separate metadata and state in storage API #### Bugfixes * [[a75605d](https://github.com/boardgameio/boardgame.io/commit/a75605d)] remove unused has() * [[4157bc1](https://github.com/boardgameio/boardgame.io/commit/4157bc1)] remove namespacing in gameIDs * [[8c80785](https://github.com/boardgameio/boardgame.io/commit/8c80785)] remove namespace ### v0.38.1 #### Features * [[0d59c2c](https://github.com/boardgameio/boardgame.io/commit/0d59c2c)] move Events code into plugin * [[4b1c135](https://github.com/boardgameio/boardgame.io/commit/4b1c135)] move Random code into plugin #### Bugfixes * [[a624c9e](https://github.com/boardgameio/boardgame.io/commit/a624c9e)] update @babel/preset-env * [[c1dba9f](https://github.com/boardgameio/boardgame.io/commit/c1dba9f)] update prettier * [[60c6d88](https://github.com/boardgameio/boardgame.io/commit/60c6d88)] update rollup-plugin-terser * [[6378659](https://github.com/boardgameio/boardgame.io/commit/6378659)] npm run audit ## v0.38.0 #### Breaking Changes The Plugin API is revamped. This also includes changing the way `PluginPlayer` works. Please take a look at the [documentation](https://boardgame.io/documentation/#/plugins). Feel free to comment on the public Gitter channel if you have use-cases that are not covered by the rewrite or need help migrating. #### Features * [[d84e6af](https://github.com/boardgameio/boardgame.io/commit/d84e6af)] add onEnd hook for Game * [[94b69cb](https://github.com/boardgameio/boardgame.io/commit/94b69cb)] Plugin API cleanup (#560) #### Bugfixes * [[aede3b6](https://github.com/boardgameio/boardgame.io/commit/aede3b6)] check that document exists before mounting debug panel * [[ec7f0ad](https://github.com/boardgameio/boardgame.io/commit/ec7f0ad)] master: Remove credentials from action payloads after use (#556) * [[a080ce3](https://github.com/boardgameio/boardgame.io/commit/a080ce3)] fix: #552 (#553) * [[79ebcc3](https://github.com/boardgameio/boardgame.io/commit/79ebcc3)] remove graceful-fs patch * [[9370366](https://github.com/boardgameio/boardgame.io/commit/9370366)] remove some unused Svelte props ### v0.37.2 #### Bugfixes * [[8c120d2](https://github.com/boardgameio/boardgame.io/commit/8c120d2)] trigger bot if it needs to play at game start * [[aed5cd1](https://github.com/boardgameio/boardgame.io/commit/aed5cd1)] don't run bot once game is over * [[7c65046](https://github.com/boardgameio/boardgame.io/commit/7c65046)] fix redacted move example ### v0.37.1 #### Bugfixes * [[66021f7](https://github.com/boardgameio/boardgame.io/commit/66021f7)] fix bug causing AI section to not activate * [[fd34df9](https://github.com/boardgameio/boardgame.io/commit/fd34df9)] plugins: Fix PluginPlayer setup (#543) ## v0.37.0 #### Breaking Changes The `ai` section has been moved from the `Client` to the game config: ```js const game = { moves: { ... }, ... ai: { ... } } ``` #### Features * [[0eff1c6](https://github.com/boardgameio/boardgame.io/commit/0eff1c6)] make the lobby assign bots to remaining players when there is only one human player * [[ef8df65](https://github.com/boardgameio/boardgame.io/commit/ef8df65)] add ability for Local multiplayer mode to run bots ## v0.36.0 #### Features * [[b974260](https://github.com/boardgameio/boardgame.io/commit/b974260)] Improve Lobby API: room instances (#542) * [[afdb79e](https://github.com/boardgameio/boardgame.io/commit/afdb79e)] refactor: Harmonise Master’s auth signature with authenticateCredentials (#539) * [[61a45ee](https://github.com/boardgameio/boardgame.io/commit/61a45ee)] rename optimistic to client and document it * [[4d33faa](https://github.com/boardgameio/boardgame.io/commit/4d33faa)] server: Lobby server improvements (#532) * [[08404e2](https://github.com/boardgameio/boardgame.io/commit/08404e2)] change MCTS visualization to table format #### Bugfixes * [[2d931e9](https://github.com/boardgameio/boardgame.io/commit/2d931e9)] server: Use namespaced ID to delete persisted game data (#531) * [[9ce176c](https://github.com/boardgameio/boardgame.io/commit/9ce176c)] client: Scope global CSS selectors in Debug panel (#527) * [[ef4f24d](https://github.com/boardgameio/boardgame.io/commit/ef4f24d)] Fix events in hooks triggered by a move (#525) * [[a2c64f8](https://github.com/boardgameio/boardgame.io/commit/a2c64f8)] increment turn before calling turn.onBegin ### v0.35.1 #### Bugfixes * [[26a73e4](https://github.com/boardgameio/boardgame.io/commit/26a73e4)] fix error in AI panel ## v0.35.0 #### Features - [[e7d47ee](https://github.com/boardgameio/boardgame.io/commit/e7d47ee)] export Debug Panel in boardgame.io/debug - [[cae05fd](https://github.com/boardgameio/boardgame.io/commit/cae05fd)] Replace `player` with `currentPlayer` option in `setActivePlayers` (#523) - [[2a7435a](https://github.com/boardgameio/boardgame.io/commit/2a7435a)] rename step to play - [[05572ca](https://github.com/boardgameio/boardgame.io/commit/05572ca)] add progress bar to AI panel - [[ad08b8a](https://github.com/boardgameio/boardgame.io/commit/ad08b8a)] Increment current player at start of phase in TurnOrder.DEFAULT (#521) - [[3cd5667](https://github.com/boardgameio/boardgame.io/commit/3cd5667)] speed up bot async mode by running 25 iterations per chunk - [[f19f1de](https://github.com/boardgameio/boardgame.io/commit/f19f1de)] add async mode to MCTS bot - [[7d22a47](https://github.com/boardgameio/boardgame.io/commit/7d22a47)] make bot play functions async - [[4efddb4](https://github.com/boardgameio/boardgame.io/commit/4efddb4)] lobby auto refresh + leave game ready to play (#510) - [[a8b7028](https://github.com/boardgameio/boardgame.io/commit/a8b7028)] add sliders to adjust iterations and playoutDepth of MCTS bot - [[1687ff8](https://github.com/boardgameio/boardgame.io/commit/1687ff8)] Add pass event (#492) - [[5fb3c4c](https://github.com/boardgameio/boardgame.io/commit/5fb3c4c)] allow switching between MCTS and Random bots in AI panel - [[9d74966](https://github.com/boardgameio/boardgame.io/commit/9d74966)] allow setting bot options from Debug Panel - [[bbfa304](https://github.com/boardgameio/boardgame.io/commit/bbfa304)] add AI tab #### Bugfixes - [[ba9dca8](https://github.com/boardgameio/boardgame.io/commit/ba9dca8)] add server.js to files section - [[457b29d](https://github.com/boardgameio/boardgame.io/commit/457b29d)] call notifySubscribers in update{Player,Game}ID - [[b4edd55](https://github.com/boardgameio/boardgame.io/commit/b4edd55)] Add server to proxy-dirs and clean scripts to fix #518 (#519) - [[6c0a9b7](https://github.com/boardgameio/boardgame.io/commit/6c0a9b7)] allow switching playerID from Debug Panel ## v0.34.0 The main feature in this release is that the Debug Panel is now baked into the Vanilla JS client. This means that non-React users will have access to it as well! It is guarded by process.env.NODE_ENV !== 'production', which means that most bundlers will strip it out in a production build. The other big change is that the NPM package now contains both CJS and ES builds for every subpackage. This should have no user visible impact, but might break some non-standard bundler configurations. #### Features - [[e9351dc](https://github.com/boardgameio/boardgame.io/commit/e9351dc)] log a message when INVALID_MOVE is returned - [[2f86d92](https://github.com/boardgameio/boardgame.io/commit/2f86d92)] rename mount/unmount to start/stop - [[1ad87d0](https://github.com/boardgameio/boardgame.io/commit/1ad87d0)] remove INFO log in production, but not ERROR logs - [[83810ea](https://github.com/boardgameio/boardgame.io/commit/83810ea)] guard Debug Panel with process.env.NODE_ENV - [[156cf07](https://github.com/boardgameio/boardgame.io/commit/156cf07)] generate CJS and ES version of main package - [[881278a](https://github.com/boardgameio/boardgame.io/commit/881278a)] Migrate Debug Panel + Log + MCTS Visualizer to Svelte (#498) - [[49f5a52](https://github.com/boardgameio/boardgame.io/commit/49f5a52)] allow multiple client subscriptions #### Bugfixes - [[3206548](https://github.com/boardgameio/boardgame.io/commit/3206548)] don't invoke callback on subscribe in multiplayer mode unless client is already connected - [[9596fa4](https://github.com/boardgameio/boardgame.io/commit/9596fa4)] only notify the latest subscriber during client.subscribe - [[5a13f00](https://github.com/boardgameio/boardgame.io/commit/5a13f00)] fix bug in the way the transport notifies client subscribers of connection changes - [[c77ba53](https://github.com/boardgameio/boardgame.io/commit/c77ba53)] handle multiple subscriptions correctly - [[b045de3](https://github.com/boardgameio/boardgame.io/commit/b045de3)] use Parcel instead of Webpack in examples ### v0.33.2 #### Features - [[18d9be5](https://github.com/boardgameio/boardgame.io/commit/18d9be5)] Allowing support for both numbers and functions for MCTS bot iterations and playoutDepth (#475) - [[901c746](https://github.com/boardgameio/boardgame.io/commit/901c746)] feat: Apply `value` argument last in `setActivePlayers` (#489) #### Bugfixes - [[bed18ce](https://github.com/boardgameio/boardgame.io/commit/bed18ce)] reintroduce InitializeGame in boardgame.io/core ### v0.33.1 #### Features - [[6eb4ebd](https://github.com/boardgameio/boardgame.io/commit/6eb4ebd)] rewrite one of the snippets in Svelte - [[86e65fe](https://github.com/boardgameio/boardgame.io/commit/86e65fe)] fix: Move player to “next” stage on `endStage` (#484) ## v0.33.0 Huge release with a more streamlined API and the much awaited feature: Stages! Check out this [migration guide](https://nicolodavis.com/blog/boardgame.io-0.33/). #### Features - [[6762219](https://github.com/boardgameio/boardgame.io/commit/6762219)] refactor: Change moveLimit syntax in setActivePlayers (#481) - [[64971ee](https://github.com/boardgameio/boardgame.io/commit/64971ee)] Disallow game names with spaces (#474) - [[d43d239](https://github.com/boardgameio/boardgame.io/commit/d43d239)] short form syntax for literal value in setActivePlayers - [[462f452](https://github.com/boardgameio/boardgame.io/commit/462f452)] allow all players to call events - [[2409729](https://github.com/boardgameio/boardgame.io/commit/2409729)] enable all events by default - [[1261475](https://github.com/boardgameio/boardgame.io/commit/1261475)] remove UI toolkit - [[b2f5160](https://github.com/boardgameio/boardgame.io/commit/b2f5160)] feat: Add `endStage` and `setStage` events (#458) - [[ca61bf6](https://github.com/boardgameio/boardgame.io/commit/ca61bf6)] feat: Support move limits in `setActivePlayers` (#452) - [[ec15ad2](https://github.com/boardgameio/boardgame.io/commit/ec15ad2)] TurnOrder.RESET - [[d251f4a](https://github.com/boardgameio/boardgame.io/commit/d251f4a)] set phase to null instead of empty string - [[9c6f55d](https://github.com/boardgameio/boardgame.io/commit/9c6f55d)] set currentPlayer to null instead of empty string - [[da2f0ea](https://github.com/boardgameio/boardgame.io/commit/da2f0ea)] add stages - [[d5e2b55](https://github.com/boardgameio/boardgame.io/commit/d5e2b55)] start turn at 1 - [[35a34a0](https://github.com/boardgameio/boardgame.io/commit/35a34a0)] nest turns inside phases - [[cff284b](https://github.com/boardgameio/boardgame.io/commit/cff284b)] convert startingPhase into boolean option - [[b9ce7f1](https://github.com/boardgameio/boardgame.io/commit/b9ce7f1)] move event disablers inside separate section in config - [[61eb8d8](https://github.com/boardgameio/boardgame.io/commit/61eb8d8)] rename movesPerTurn to moveLimit - [[3a97a16](https://github.com/boardgameio/boardgame.io/commit/3a97a16)] make optimistic an option in long-form move syntax - [[10ef457](https://github.com/boardgameio/boardgame.io/commit/10ef457)] retire Game(). call it internally instead. - [[3d46a4a](https://github.com/boardgameio/boardgame.io/commit/3d46a4a)] rename endGameIf to endIf - [[33ac684](https://github.com/boardgameio/boardgame.io/commit/33ac684)] retire flow section - [[d75fe44](https://github.com/boardgameio/boardgame.io/commit/d75fe44)] move undoableMoves into boolean inside long form move syntax - [[8924e84](https://github.com/boardgameio/boardgame.io/commit/8924e84)] move redactedMoves into a boolean option in the long form move syntax - [[4b202ee](https://github.com/boardgameio/boardgame.io/commit/4b202ee)] long form move syntax - [[f00e736](https://github.com/boardgameio/boardgame.io/commit/f00e736)] rename some hooks - [[19ca21f](https://github.com/boardgameio/boardgame.io/commit/19ca21f)] move onTurnBegin/onTurnEnd/endTurnIf/movesPerTurn into turn object - [[53b7ac7](https://github.com/boardgameio/boardgame.io/commit/53b7ac7)] convert turnOrder into a turn object - [[fa58e5b](https://github.com/boardgameio/boardgame.io/commit/fa58e5b)] retire allowedMoves - [[7a411c9](https://github.com/boardgameio/boardgame.io/commit/7a411c9)] introduce namespaced moves that are defined within phases - [[a0d5f36](https://github.com/boardgameio/boardgame.io/commit/a0d5f36)] Surface game metadata and player nicknames in client / react props (#436) - [[221b0d5](https://github.com/boardgameio/boardgame.io/commit/221b0d5)] add benchmark #### Bugfixes - [[fd70ed5](https://github.com/boardgameio/boardgame.io/commit/fd70ed5)] No payload is not an authentic player (#430) ### v0.32.1 #### Features - [[9cff03e](https://github.com/boardgameio/boardgame.io/commit/9cff03e)] Create play again endpoint (#428) #### Bugfixes - [[0a75e4b](https://github.com/boardgameio/boardgame.io/commit/0a75e4b)] fix: Fix join/leave a room when playerID is 0 (#425) ## v0.32.0 #### Features - [[2b98fb6](https://github.com/boardgameio/boardgame.io/commit/2b98fb6)] change custom client transport to a constructor (#417) - [[89faece](https://github.com/boardgameio/boardgame.io/commit/89faece)] Rename playerCredentials to credentials for /leave endpoint, update src/lobby/connection.js accordingly (#416) - [[25c2263](https://github.com/boardgameio/boardgame.io/commit/25c2263)] Fix #345; restrict undo/redo to currentPlayer (#408) - [[6de7b64](https://github.com/boardgameio/boardgame.io/commit/6de7b64)] Add /rename endpoint for lobby (#414) ### v0.31.7 #### Features - [[febb1c0](https://github.com/boardgameio/boardgame.io/commit/febb1c0)] Check if required parameters are passed to API (#407) #### Bugfixes - [[a6145a5](https://github.com/boardgameio/boardgame.io/commit/a6145a5)] upgrade koa and koa-body ### v0.31.6 #### Bugfixes - [[5ad5c3f](https://github.com/boardgameio/boardgame.io/commit/5ad5c3f)] Remove some secrets from client in multiplayer game (#400) - [[3e50dca](https://github.com/boardgameio/boardgame.io/commit/3e50dca)] Get specific instance of a room by its ID (#405) - [[4964e3f](https://github.com/boardgameio/boardgame.io/commit/4964e3f)] Creating lobby API config and making the UUID customizable (#396) - [[efece0c](https://github.com/boardgameio/boardgame.io/commit/efece0c)] Auto-add trailing slash to server only if needed (#403) - [[f289379](https://github.com/boardgameio/boardgame.io/commit/f289379)] Rename gameInstances to rooms (#402) - [[1d5586c](https://github.com/boardgameio/boardgame.io/commit/1d5586c)] export FlatFile in server.js - [[eda9728](https://github.com/boardgameio/boardgame.io/commit/eda9728)] update undo to reflect current ctx (#393) - [[e46f195](https://github.com/boardgameio/boardgame.io/commit/e46f195)] add turn and phase to log entries ### v0.31.5 #### Features - [[3982150](https://github.com/boardgameio/boardgame.io/commit/3982150)] synchronous mode for game master - [[8732d9f](https://github.com/boardgameio/boardgame.io/commit/8732d9f)] Add adminClient option for Firebase storage (#386) #### Bugfixes - [[8ed812e](https://github.com/boardgameio/boardgame.io/commit/8ed812e)] handle default number of players bigger than 2 for 1st game of the list (#392) - [[ec7dde5](https://github.com/boardgameio/boardgame.io/commit/ec7dde5)] Don't leak undefined ctx properties from turnOrder.actionPlayers (#382) ### v0.31.4 #### Features - [[3bde0ca](https://github.com/boardgameio/boardgame.io/commit/3bde0ca)] Adding step props to the Board (#376) - [[4c3056c](https://github.com/boardgameio/boardgame.io/commit/4c3056c)] Making step accept a Promise from bot.play() (#375) #### Bugfixes - [[c24e0cd](https://github.com/boardgameio/boardgame.io/commit/c24e0cd)] upgrade Expo and fix React Native example - [[c1ee6f3](https://github.com/boardgameio/boardgame.io/commit/c1ee6f3)] python bot: fix #379 (#380) ### v0.31.3 #### Features - [[94b1d65](https://github.com/boardgameio/boardgame.io/commit/94b1d65)] Add flatfile database with node-persist (#372) - [[f6e70fd](https://github.com/boardgameio/boardgame.io/commit/f6e70fd)] Add custom renderer parameter to lobby + clean up code (#353) ### v0.31.2 #### Features - [[01a7e79](https://github.com/boardgameio/boardgame.io/commit/01a7e79)] 3D Grid and Token (#352) - [[1f33d43](https://github.com/boardgameio/boardgame.io/commit/1f33d43)] Serve API and Game Server on same port with option to split (#343) #### Bugfixes - [[87d1e5b](https://github.com/boardgameio/boardgame.io/commit/87d1e5b)] Changed default Firebase return value to undefined (#361) - [[d7d6b44](https://github.com/boardgameio/boardgame.io/commit/d7d6b44)] Fix lobby example (#351) - [[a285fbf](https://github.com/boardgameio/boardgame.io/commit/a285fbf)] Allow https urls to be passed to lobby (#350) ### v0.31.1 #### Bugfixes - [[4a796dc](https://github.com/boardgameio/boardgame.io/commit/4a796dc)] remove three from minified rollup bundle ## v0.31.0 #### Features - [[a32d3d5](https://github.com/boardgameio/boardgame.io/commit/a32d3d5)] Generic lobby (#294) - [[fb19e9b](https://github.com/boardgameio/boardgame.io/commit/fb19e9b)] move examples into a create-react-app package (#335) - [[1f71bbd](https://github.com/boardgameio/boardgame.io/commit/1f71bbd)] Upgrade Babel 7 (#332) #### Bugfixes - [[3334d38](https://github.com/boardgameio/boardgame.io/commit/3334d38)] fix race condition in game instantiation inside onSync - [[f544511](https://github.com/boardgameio/boardgame.io/commit/f544511)] Allow result of onPhaseBegin to influence turn order (#341) - [[e1c1f6b](https://github.com/boardgameio/boardgame.io/commit/e1c1f6b)] fail integration test if any subcommand fails ## v0.30.0 #### Features - [[6cf81e8](https://github.com/boardgameio/boardgame.io/commit/6cf81e8)] create initial game state outside reducer - [[8d08381](https://github.com/boardgameio/boardgame.io/commit/8d08381)] add a loading component for multiplayer clients #### Bugfixes - [[d20d26c](https://github.com/boardgameio/boardgame.io/commit/d20d26c)] make master write to proper namepspaced keys ### v0.29.5 #### Features - [[7188222](https://github.com/boardgameio/boardgame.io/commit/7188222)] add plugin.onPhaseBegin ### v0.29.4 #### Features - [[c1b4a03](https://github.com/boardgameio/boardgame.io/commit/c1b4a03)] add playerSetup option to PluginPlayer ### v0.29.3 #### Features - [[da1eac6](https://github.com/boardgameio/boardgame.io/commit/da1eac6)] rename plugin api functions - [[659007a](https://github.com/boardgameio/boardgame.io/commit/659007a)] pass game object to plugins ### v0.29.2 #### Bugfixes - [[5d74c95](https://github.com/boardgameio/boardgame.io/commit/5d74c95)] fix immer plugin order ### v0.29.1 #### Features - [[ff749e3](https://github.com/boardgameio/boardgame.io/commit/ff749e3)] add addTo / removeFrom to plugin API - [[9df8145](https://github.com/boardgameio/boardgame.io/commit/9df8145)] split plugin.setup into setupG and setupCtx - [[d2d44f9](https://github.com/boardgameio/boardgame.io/commit/d2d44f9)] rename plugin.wrapper to plugin.fnWrap - [[ca5da32](https://github.com/boardgameio/boardgame.io/commit/ca5da32)] Passing arbitrary data to game setup (#315) ## v0.29.0 #### Features - [[d1bd1d1](https://github.com/boardgameio/boardgame.io/commit/d1bd1d1)] Plugin API ### v0.28.1 #### Features - [[10de6f8](https://github.com/boardgameio/boardgame.io/commit/10de6f8)] Turn order active player changes (#320) - [[58cbd1e](https://github.com/boardgameio/boardgame.io/commit/58cbd1e)] Redact Log Events (#268) - [[ed165a8](https://github.com/boardgameio/boardgame.io/commit/ed165a8)] Add a server sync status field (#307) #### Bugfixes - [[b8ec845](https://github.com/boardgameio/boardgame.io/commit/b8ec845)] package refactor - [[2b5920f](https://github.com/boardgameio/boardgame.io/commit/2b5920f)] Add Immer to other events (#327) - [[873e1f5](https://github.com/boardgameio/boardgame.io/commit/873e1f5)] server: fix name of property 'credentials' in server API handler for 'leave' (#326) ## v0.28.0 We now support an alternative style for moves that allows modifying `G` directly. The old style is still supported. #### Features - [[6bdfb11](https://github.com/boardgameio/boardgame.io/commit/6bdfb11)] add immer #### Breaking Changes `undefined` is no longer used to indicate invalid moves. Use the new `INVALID_MOVE` constant to accomplish this. ```js import { INVALID_MOVE } from 'boardgame.io/core'; const TicTacToe = Game({ moves: { clickCell: (G, ctx, id) => { if (G.cells[id] !== null) { return INVALID_MOVE; } G.cells[id] = ctx.currentPlayer; }, }, }); ``` ### v0.27.1 #### Features - [[2d02558](https://github.com/boardgameio/boardgame.io/commit/2d02558)] add TurnOrder.CUSTOM and TurnOrder.CUSTOM_FROM #### Bugfixes - [[8699350](https://github.com/boardgameio/boardgame.io/commit/8699350)] Prohibit second log event during Update (#303) ## v0.27.0 This is a pretty exciting release with lots of goodies but with some breaking changes, so make sure to read the section at the end with tips on migration. The main theme in this release is the reworking of Phases and Turn Orders to support more complex game types and other common patterns like the ability to quickly pop into a phase and back. #### Features - [[b7abc57](https://github.com/boardgameio/boardgame.io/commit/b7abc57)] more turn orders - [[5fb663a](https://github.com/boardgameio/boardgame.io/commit/5fb663a)] allow calling setActionPlayers via TurnOrder objects - [[53473ef](https://github.com/boardgameio/boardgame.io/commit/53473ef)] change semantics of enabling/disabling events - [[992416a](https://github.com/boardgameio/boardgame.io/commit/992416a)] change format of args to endPhase and endTurn - [[0568857](https://github.com/boardgameio/boardgame.io/commit/0568857)] change phases syntax #### Bugfixes - [[96def53](https://github.com/boardgameio/boardgame.io/commit/96def53)] add MONGO_DATABASE env variable (#290) #### Breaking Changes 1. The syntax for phases has changed: ``` // old phases: [ { name: 'A', ...opts }, { name: 'B', ...opts }, ] // new phases: { 'A': { ...opts }, 'B': { ...opts }, } ``` 2. There is no implicit ordering of phases. You can specify an explicit order via `next` (optional). Note that this allows you to create more complex graphs of phases compared to the previous linear approach. ``` phases: { 'A': { next: 'B' }, 'B': { next: 'A' }, } ``` Take a look at [phases.md](phases.md) to see how `endPhase` determines which phase to move to. 3. A phase called `default` is always created. This is the phase that the game begins in. This is also the phase that the game reverts to in case it detects an infinite loop of `endPhase` events caused by a cycle. You can have the game start in a phase different from `default` using `startingPhase`: ``` flow: { startingPhase: 'A', phases: { A: {}, B: {}, } } ``` 4. The format of the argument to `endPhase` or the return value of `endPhaseIf` is now an object of type `{ next: 'phase name' }` ``` // old endPhase('new phase') endPhaseIf: () => 'new phase' // new endPhase({ next: 'new phase' }) endPhaseIf: () => ({ next: 'new phase' }) ``` 5. The format of the argument to `endTurn` or the return value of `endTurnIf` is now an object of type `{ next: playerID }` ``` // old endTurn(playerID) endTurnIf: () => playerID // new endTurn({ next: playerID }) endTurnIf: () => ({ next: playerID }) ``` 6. The semantics of enabling / disabling events has changed a bit: see https://boardgame.io/#/events for more details. 7. TurnOrder objects now support `setActionPlayers` args. Instead of returning `actionPlayers` in `first` / `next`, add an `actionPlayers` section instead. ``` // old { first: (G, ctx) => { playOrderPos: 0, actionPlayers: [...ctx.playOrder], } next: (G, ctx) => { playOrderPos: ctx.playOrderPos + 1, actionPlayers: [...ctx.playOrder], }, } // new { first: (G, ctx) => 0, next: (G, ctx) => ctx.playOrderPos + 1, actionPlayers: { all: true }, } ``` ### v0.26.3 #### Features - [[d50015d](https://github.com/boardgameio/boardgame.io/commit/d50015d)] turn order simulator #### Bugfixes - [[58e135b](https://github.com/boardgameio/boardgame.io/commit/58e135b)] fix bug that was causing ctx.events to be undefined - [[ea3754b](https://github.com/boardgameio/boardgame.io/commit/ea3754b)] player needs to be in actionPlayers in order to call events ### v0.26.2 #### Features - [[a352d1e](https://github.com/boardgameio/boardgame.io/commit/a352d1e)] decouple once and allOthers ### v0.26.1 #### Bugfixes - [[aa5f2cf](https://github.com/boardgameio/boardgame.io/commit/aa5f2cf)] added the useNewUrlParser option to the Mongo connect() (#285) ## v0.26.0 #### Features - [[e8f165a](https://github.com/boardgameio/boardgame.io/commit/e8f165a)] server: add new API endpoints 'list' and 'leave' (#276) - [[8ff4745](https://github.com/boardgameio/boardgame.io/commit/8ff4745)] drag-n-drop for cards and decks - [[a558092](https://github.com/boardgameio/boardgame.io/commit/a558092)] return state as first argument to client.subscribe callback - [[965f9b7](https://github.com/boardgameio/boardgame.io/commit/965f9b7)] Allow to set payload onto a log event (#267) - [[2efdbc1](https://github.com/boardgameio/boardgame.io/commit/2efdbc1)] utils for working with hexagonal boards (#271) - [[137dd7c](https://github.com/boardgameio/boardgame.io/commit/137dd7c)] allow overriding client-side transport - [[63311ac](https://github.com/boardgameio/boardgame.io/commit/63311ac)] local game master - [[0b7a0a0](https://github.com/boardgameio/boardgame.io/commit/0b7a0a0)] add allOthers option to setActionPlayers (#269) #### Bugfixes - [[d1a1a8a](https://github.com/boardgameio/boardgame.io/commit/d1a1a8a)] shouldEndPhase can see the results of onTurnEnd - [[b4874a6](https://github.com/boardgameio/boardgame.io/commit/b4874a6)] call the client subscribe callback after LogMiddleware has run - [[9b9d735](https://github.com/boardgameio/boardgame.io/commit/9b9d735)] reset deltalog properly ### v0.25.5 #### Features - [[4ed6b94](https://github.com/boardgameio/boardgame.io/commit/4ed6b94)] add server startup message - [[1688639](https://github.com/boardgameio/boardgame.io/commit/1688639)] decouple transport layer from server logic ### v0.25.4 #### Bugfixes - Fixed babelHelpers error in npm. ### v0.25.3 Broken, do not use (complains about babelHelpers missing). #### Bugfixes - [[ebf7e73](https://github.com/boardgameio/boardgame.io/commit/ebf7e73)] fix bug that was preventing playerID from being overriden by the debug ui ### v0.25.2 #### Bugfixes - [[a42e07b](https://github.com/boardgameio/boardgame.io/commit/a42e07b)] npm audit fix --only=prod - [[cfe7296](https://github.com/boardgameio/boardgame.io/commit/cfe7296)] update koa and socket.io ### v0.25.1 #### Bugfixes - [[09b523e](https://github.com/boardgameio/boardgame.io/commit/09b523e)] require mongo and firebase only if used ## v0.25.0 #### Features - [[fe8a9d0](https://github.com/boardgameio/boardgame.io/commit/fe8a9d0)] Added ability to specify server protocol (#247) - [[43dcaac](https://github.com/boardgameio/boardgame.io/commit/43dcaac)] write turn / phase stats in ctx.stats - [[bd8208a](https://github.com/boardgameio/boardgame.io/commit/bd8208a)] fabricate playerID in singleplayer mode - [[b4e3e09](https://github.com/boardgameio/boardgame.io/commit/b4e3e09)] { all: true } option for setActionPlayers - [[5d3a34d](https://github.com/boardgameio/boardgame.io/commit/5d3a34d)] { once: true } option for setActionPlayers - [[75a274c](https://github.com/boardgameio/boardgame.io/commit/75a274c)] rename changeActionPlayers to setActionPlayers - [[4ec3a61](https://github.com/boardgameio/boardgame.io/commit/4ec3a61)] end phase when a turn order runs out - [[cb6111b](https://github.com/boardgameio/boardgame.io/commit/cb6111b)] retire the string constant 'any' - [[36fc47f](https://github.com/boardgameio/boardgame.io/commit/36fc47f)] basic support for objective-based AI - [[d1f0a3e](https://github.com/boardgameio/boardgame.io/commit/d1f0a3e)] improved rendering of turns and phases in the log - [[0bc31d6](https://github.com/boardgameio/boardgame.io/commit/0bc31d6)] better MCTS visualization - [[14a5ad7](https://github.com/boardgameio/boardgame.io/commit/14a5ad7)] update redux to 4.0.0 #### Bugfixes - [[84f07c6](https://github.com/boardgameio/boardgame.io/commit/84f07c6)] Do not fabricate playerID for playerView - [[c4a11a7](https://github.com/boardgameio/boardgame.io/commit/c4a11a7)] ignore events from all but currentPlayer - [[6a8b657](https://github.com/boardgameio/boardgame.io/commit/6a8b657)] move mongodb and firebase deps to devDependencies - [[239f8dd](https://github.com/boardgameio/boardgame.io/commit/239f8dd)] Use parse/stringify from flatted lib to support circular structures (fixes #222) (#240) - [[edd1df0](https://github.com/boardgameio/boardgame.io/commit/edd1df0)] Differentiate automatic game events in the log - [[570f40e](https://github.com/boardgameio/boardgame.io/commit/570f40e)] don't render AI metadata if visualize is not specified - [[a8431c7](https://github.com/boardgameio/boardgame.io/commit/a8431c7)] set default RNG seed once per game, not game type - [[5090429](https://github.com/boardgameio/boardgame.io/commit/5090429)] API: check secret _before_ handling the request (#231) - [[1a24791](https://github.com/boardgameio/boardgame.io/commit/1a24791)] attach events API early so that it can be used on the first onTurnBegin - [[acb9d8c](https://github.com/boardgameio/boardgame.io/commit/acb9d8c)] enable events API in initial onTurnBegin/onPhaseBegin #### Breaking Changes - `changeActionPlayers` is now `setActionPlayers`. It also supports more advanced [options](http://boardgame.io/#/events?id=setactionplayers). - Returning `undefined` from a `TurnOrder` results in the phase ending, not setting `currentPlayer` to `any`. - Only the `currentPlayer` can call events (`endTurn`, `endPhase` etc.). ## v0.24.0 #### Features - [[b28ee74](https://github.com/boardgameio/boardgame.io/commit/b28ee74)] ability to change playerID from Debug UI - [[fe1230e](https://github.com/boardgameio/boardgame.io/commit/fe1230e)] Firebase integration (#223) ### v0.23.3 #### Bugfixes - [[6194986](https://github.com/boardgameio/boardgame.io/commit/6194986)] remove async/await from client code ### v0.23.2 #### Bugfixes - [[7a61f09](https://github.com/boardgameio/boardgame.io/commit/7a61f09)] make Random API present in first onTurnBegin and onPhaseBegin #### Features - [[99b9844](https://github.com/boardgameio/boardgame.io/commit/99b9844)] Python Bots - [[a7134a5](https://github.com/boardgameio/boardgame.io/commit/a7134a5)] List available games API ### v0.23.1 #### Bugfixes - [[f26328c](https://github.com/boardgameio/boardgame.io/commit/f26328c)] add ai.js to rollup config ## v0.23.0 #### Features - [[dda540a](https://github.com/boardgameio/boardgame.io/commit/dda540a)] AI framework - [[8e2f8c4](https://github.com/boardgameio/boardgame.io/commit/8e2f8c4)] lobby API support (#189) #### Bugfixes - [[7a80f66](https://github.com/boardgameio/boardgame.io/commit/7a80f66)] make changeActionPlayers an opt-in event - [[40cd4b8](https://github.com/boardgameio/boardgame.io/commit/40cd4b8)] Add config update on phase change Fixes #211 (#212) ### v0.22.1 #### Bugfixes - [[bb39ca7](https://github.com/boardgameio/boardgame.io/commit/bb39ca7)] fix bug that was causing isActive to return false - [[81ed088](https://github.com/boardgameio/boardgame.io/commit/81ed088)] ensure endTurn is called only once after a move - [[ca9f6ca](https://github.com/boardgameio/boardgame.io/commit/ca9f6ca)] disable move if playerID is null ## v0.22.0 #### Features - [[5362955](https://github.com/boardgameio/boardgame.io/commit/5362955)] React Native Client (#128) - [[b329df2](https://github.com/boardgameio/boardgame.io/commit/b329df2)] Pass through props (#173) ### v0.21.5 #### Bugfixes - [[55715c9](https://github.com/boardgameio/boardgame.io/commit/55715c9)] Fix undefined ctx in onPhaseBegin ### v0.21.4 #### Features - [[387d413](https://github.com/boardgameio/boardgame.io/commit/387d413)] Debug UI CSS improvements - [[2105f46](https://github.com/boardgameio/boardgame.io/commit/2105f46)] call endTurnIf inside endPhase - [[9b0324c](https://github.com/boardgameio/boardgame.io/commit/9b0324c)] allow setting the next player via endTurn - [[f76f97e](https://github.com/boardgameio/boardgame.io/commit/f76f97e)] correct isMultiplayer #### Bugfixes - [[278b369](https://github.com/boardgameio/boardgame.io/commit/278b369)] Fix bug that was ending phase incorrectly (#176) ### v0.21.3 #### Features - [[dc31a66](https://github.com/boardgameio/boardgame.io/commit/dc31a66)] expose allowedMoves in ctx - [[da4711a](https://github.com/boardgameio/boardgame.io/commit/da4711a)] make allowedMoves both global and phase-specific - [[9324c58](https://github.com/boardgameio/boardgame.io/commit/9324c58)] Allowed moves as function (#164) #### Bugfixes - [[5e49448](https://github.com/boardgameio/boardgame.io/commit/5e49448)] convert multiplayer move whitelist to blacklist ### v0.21.2 #### Bugfixes - [[27705d5](https://github.com/boardgameio/boardgame.io/commit/27705d5)] pass Events API correctly inside events.update ### v0.21.1 #### Bugfixes - [[87e77c1](https://github.com/boardgameio/boardgame.io/commit/87e77c1)] correctly detach APIs from ctx in startTurn ## v0.21.0 #### Features - [[2ee244e](https://github.com/boardgameio/boardgame.io/commit/2ee244e)] Reset Game (#155) - [[9cd3fdf](https://github.com/boardgameio/boardgame.io/commit/9cd3fdf)] allow to modify actionPlayers via Events (#157) - [[767362f](https://github.com/boardgameio/boardgame.io/commit/767362f)] endGame event - [[78634ee](https://github.com/boardgameio/boardgame.io/commit/78634ee)] Events API - [[a240e45](https://github.com/boardgameio/boardgame.io/commit/a240e45)] undoableMoves implementation (#149) - [[c12e911](https://github.com/boardgameio/boardgame.io/commit/c12e911)] Process only known moves (#151) - [[7fcdbfe](https://github.com/boardgameio/boardgame.io/commit/7fcdbfe)] Custom turn order (#130) - [[748f36f](https://github.com/boardgameio/boardgame.io/commit/748f36f)] UI: add mouse hover action props to grid, hex, and token (#153) - [[f664237](https://github.com/boardgameio/boardgame.io/commit/f664237)] Add notion of actionPlayers (#145) ### v0.20.2 #### Features - [[43ba0ff](https://github.com/boardgameio/boardgame.io/commit/43ba0ff)] allow optional redux enhancer (#139) - [[dd6c110](https://github.com/boardgameio/boardgame.io/commit/dd6c110)] Run endPhase event (analogue to endTurn) when game ends (#144) #### Bugfixes - [[8969433](https://github.com/boardgameio/boardgame.io/commit/8969433)] Fix bug that was causing Random code to return the same numbers. #### Breaking Changes - The `Random` API is different. There is no longer a `Random` package that you need to import. The API is attached to the `ctx` parameter that is passed to the moves. Take a look at http://boardgame.io/#/random for more details. ### v0.20.1 #### Bugfixes - [[06d78e2](https://github.com/boardgameio/boardgame.io/commit/06d78e2)] Enable SSR - [[ed09f51](https://github.com/boardgameio/boardgame.io/commit/ed09f51)] Allow calling Random during setup - [[c50d5ea](https://github.com/boardgameio/boardgame.io/commit/c50d5ea)] fix log rendering of phases ## v0.20.0 #### Features - [[eec8896](https://github.com/boardgameio/boardgame.io/commit/eec8896)] undo/redo ## v0.19.0 #### Features - MongoDB connector - [[eaa372f](https://github.com/boardgameio/boardgame.io/commit/eaa372f)] add Mongo to package - [[63c3cdf](https://github.com/boardgameio/boardgame.io/commit/63c3cdf)] mongo race condition checks - [[65cefdf](https://github.com/boardgameio/boardgame.io/commit/65cefdf)] allow setting Mongo location using MONGO_URI - [[557b66c](https://github.com/boardgameio/boardgame.io/commit/557b66c)] add run() to Server - [[2a85b40](https://github.com/boardgameio/boardgame.io/commit/2a85b40)] replace lru-native with lru-cache - [[003fe46](https://github.com/boardgameio/boardgame.io/commit/003fe46)] MongoDB connector #### Breaking Changes - `boardgame.io/server` no longer has a default export, but returns `Server` and `Mongo`. ``` // v0.19 const Server = require('boardgame.io/server').Server; ``` ``` // v0.18 const Server = require('boardgame.io/server'); ``` ### v0.18.1 #### Bugfixes [[0c894bd](https://github.com/boardgameio/boardgame.io/commit/0c894bd)] add react.js to rollup config ## v0.18.0 #### Features - [[4b90e84](https://github.com/boardgameio/boardgame.io/commit/4b90e84)] decouple client from React This adds a new package `boardgame.io/react`. Migrate all your calls from: ``` import { Client } from 'boardgame.io/client' ``` to: ``` import { Client } from 'boardgame.io/react' ``` `boardgame.io/client` exposes a raw JS client that isn't tied to any particular UI framework. - Random API: - [[ebe7758](https://github.com/boardgameio/boardgame.io/commit/ebe7758)] allow to throw multiple dice (#120) - [[8c88b70](https://github.com/boardgameio/boardgame.io/commit/8c88b70)] Simplify Random API (#119) - [[45599e5](https://github.com/boardgameio/boardgame.io/commit/45599e5)] Server-side array shuffling. (#116) - [[d296b36](https://github.com/boardgameio/boardgame.io/commit/d296b36)] Random API (#103) - [[f510b69](https://github.com/boardgameio/boardgame.io/commit/f510b69)] onTurnBegin (#109) #### Bugfixes - [[6a010c8](https://github.com/boardgameio/boardgame.io/commit/6a010c8)] Debug UI: fixes related to errors in arguments (#123) ### v0.17.2 #### Features - [[0572210](https://github.com/boardgameio/boardgame.io/commit/0572210)] Exposing Client connection status to board. (#97) - [[c2ea197](https://github.com/boardgameio/boardgame.io/commit/c2ea197)] make db interface async (#86) - [[9e507ce](https://github.com/boardgameio/boardgame.io/commit/9e507ce)] exclude dependencies from package #### Bugfixes - [[a768f1f](https://github.com/boardgameio/boardgame.io/commit/a768f1f)] remove entries from clientInfo and roomInfo on disconnect ### v0.17.1 #### Features - [[f23c5dd](https://github.com/boardgameio/boardgame.io/commit/f23c5dd)] Card and Deck (#74) - [[a21c1dd](https://github.com/boardgameio/boardgame.io/commit/a21c1dd)] prevent endTurn when movesPerTurn have not been made #### Bugfixes - [[11e215e](https://github.com/boardgameio/boardgame.io/commit/11e215e)] fix bug that was using the wrong playerID when calculating playerView ## v0.17.0 #### Features - [[0758c7e](https://github.com/boardgameio/boardgame.io/commit/0758c7e)] cascade endPhase - [[cc7d44f](https://github.com/boardgameio/boardgame.io/commit/cc7d44f)] retire triggers and introduce onMove instead - [[17e88ce](https://github.com/boardgameio/boardgame.io/commit/17e88ce)] convert events whitelist to boolean options - [[e315b9e](https://github.com/boardgameio/boardgame.io/commit/e315b9e)] add ui to NPM package - [[5b34c5d](https://github.com/boardgameio/boardgame.io/commit/5b34c5d)] remove pass event and make it a standard move - [[f3da742](https://github.com/boardgameio/boardgame.io/commit/f3da742)] make playerID available in ctx - [[cb09d9a](https://github.com/boardgameio/boardgame.io/commit/cb09d9a)] make turnOrder a globally configurable option ### v0.16.8 #### Features - [[a482469](https://github.com/boardgameio/boardgame.io/commit/a482469b2f6a317a50fb25f23b7ffc0c2f597c1e)] ability to specify socket server #### Bugfixes - [[2ab3dfc](https://github.com/boardgameio/boardgame.io/commit/2ab3dfc6928eb8f0bfdf1ce319ac53021a2f905b)] end turn automatically when game ends ### v0.16.7 #### Bugfixes - [[c65580d](https://github.com/boardgameio/boardgame.io/commit/c65580d)] Fix bug introduced in af3a7b5. ### v0.16.6 #### Bugfixes - [[af3a7b5](https://github.com/boardgameio/boardgame.io/commit/af3a7b5)] Only process move reducers (on the client) and nothing else when in multiplayer mode. Buggy fix (fixed in 0.16.7). #### Features - [[2721ad4](https://github.com/boardgameio/boardgame.io/commit/2721ad4)] Allow overriding `db` implementation in Server. ### v0.16.5 #### Features - `PlayerView.STRIP_SECRETS` ### v0.16.4 #### Bugfixes - `endPhaseIf` is called after each move (in addition to at the end of a turn). - `gameID` is namespaced on the server so that there are no clashes across game types. #### Breaking Changes - `props.game` is now `props.events` (to avoid confusing it with the `game` object). ``` // OLD onClick() { this.props.game.endTurn(); } // NEW onClick() { this.props.events.endTurn(); } ``` ### v0.16.3 #### Features - Multiple game types per server! #### Breaking Changes - `Server` now accepts an array `games`, and no longer takes `game` and `numPlayers`. ``` const app = Server({ games: [ TicTacToe, Chess ] }; ``` ### v0.16.2 #### Bugfixes - [[a61ceca](https://github.com/boardgameio/boardgame.io/commit/a61ceca8cc8e973d786678e1bcc7ec50739ebeaa)]: Log turn ends correctly (even when triggered automatically by `endTurnIf`) #### Features - [[9ce42b2](https://github.com/boardgameio/boardgame.io/commit/9ce42b297372160f3ece4203b4c92000334d85e0)]: Change color in `GameLog` based on the player that made the move. ### v0.16.1 #### Bugfixes - [[23d9726](https://github.com/boardgameio/boardgame.io/commit/23d972677c6ff43b77d5c30352dd9959b517a93c)]: Fix bug that was causing `log` to be erased after `flow.processMove`. #### Features - [Triggers](https://github.com/boardgameio/boardgame.io/commit/774e540b20d7402184a00abdb7c512d7c8e85995) - [movesPerTurn](https://github.com/boardgameio/boardgame.io/commit/73d5b73d00eaba9aaf73a3576dfcfb25fc2b311d) ## v0.16.0 #### Features - [Phases](http://boardgame.io/#/phases) #### Breaking Changes - `boardgame.io/game` is now `boardgame.io/core`, and does not have a default export. - `boardgame.io/client` no longer has a default export. ``` // v0.16 import { Game } from 'boardgame.io/core' import { Client } from 'boardgame.io/client' ``` ``` // v0.15 import Game from 'boardgame.io/game' import Client from 'boardgame.io/client' ``` - `victory` is now `endGameIf`, and goes inside a `flow` section. - The semantics of `endGameIf` are subtly different. The game ends if the function returns anything at all. - `ctx.winner` is now `ctx.gameover`, and contains the return value of `endGameIf`. - `props.endTurn` is now `props.game.endTurn`. ================================================ FILE: docs/documentation/api/Client.md ================================================ # Client Creates a `boardgame.io` client. This is the entry point for the client application. ### **Plain JS** #### Import ```js import { Client } from 'boardgame.io/client'; ``` ### Creating a client #### Arguments 1. `options` (_object_): An object with the following options: ```js const client = Client({ // A game definition object. game: game, // The number of players. numPlayers: 2, // Set this to one of the following to enable multiplayer: // // SocketIO // Implementation that talks to a remote server using socket.io. // // How to import: // import { SocketIO } from 'boardgame.io/multiplayer' // // Arguments: // Object with 2 parameters // 1. 'socketOpts' options to pass directly to socket.io client. // 2. 'server' specifies the server location in the format: [http[s]://]hostname[:port]; // defaults to current page host. // // Local // Special local mode that uses an in-memory game master. Useful // for testing multiplayer interactions locally without having to // connect to a server. // // How to import: // import { Local } from 'boardgame.io/multiplayer' // // Additionally, you can write your own transport implementation. // See `src/client/client.js` for details. multiplayer: false, // Match to connect to (multiplayer). matchID: 'matchID', // Associate the client with a player (multiplayer). playerID: 'playerID', // The player’s authentication credentials (multiplayer). credentials: 'credentials', // Set to false to disable the Debug Panel debug: true/false, // Add a Redux enhancer to the internal store. // See “Debugging” guide for more details enhancer: enhancer, }); ``` ### Using a client #### Properties The following properties are available on a client instance: - `moves`: An object containing functions to dispatch the moves that you have defined. The functions are named after the moves you created in your [game object](/api/Game.md). Each function can take any number of arguments, and they are passed to the move function after `G` and `ctx`. - `events`: An object containing functions to dispatch various game events like `endTurn` and `endPhase`. - `log`: The game log. - `matchID`: The match ID associated with the client. - `playerID`: The player ID associated with the client. - `credentials`: Multiplayer authentication credentials for this player. - `matchData`: An array containing the players that have joined the current match via the [Lobby API](/api/Lobby.md). Example: ```js [ { id: 0, name: 'Alice' }, { id: 1, name: 'Bob', isConnected: true } ] ``` - `chatMessages`: An array containing chat messages this client has received. Each message is an object with the following properties: - `id`: a unique ID string - `sender`: the `playerID` of the sender - `payload`: the value passed to `sendChatMessage` Example: ```js [ { id: 'foo', sender: '0', payload: 'Ready to play?' }, { id: 'bar', sender: '1', payload: 'Let’s go!' }, ] ``` #### Methods The following methods are available on a client instance: - `start()`: Start running the client. Connects to the multiplayer transport and creates the Debug Panel. - `stop()`: Stop running the client. Disconnects the multiplayer transport and unmounts the Debug Panel. - `getState()`: Get the current game state. Returns `null` if the client still needs to sync with a remote master, otherwise an object: ```js { // The game state object `G`. G: { /* ... */ }, // The game `ctx` (turn, currentPlayer, etc.) ctx: { /* ... */ }, // State for plugins. plugins: { /* ... */ }, // The game log. log: [ /* ... */ ], // `true` if the client is able to currently make // a move or interact with the game. isActive: true/false, // `true` if connection to the server is active. isConnected: true/false, } ``` - `subscribe(callback)`: Add a callback for every state change. The passed function will be called with the same value as returned by `getState`. `subscribe` returns an unsubscribe function. ```js const unsubscribe = client.subscribe(state => { // use updated state }); // unsubscribe from the client unsubscribe(); ``` - `reset()`: Function that resets the game. - `undo()`: Function that undoes the last move. - `redo()`: Function that redoes the previously undone move. - `sendChatMessage(message)`: Function that sends a chat message to other players. The `message` argument can be a string or you can send objects to include more metadata. - `updateMatchID(id)`: Function to update the client’s match ID. - `updatePlayerID(id)`: Function to update the client’s player ID. - `updateCredentials(credentials)`: Function to update the client’s credentials. ### **React** #### Import ```js import { Client } from 'boardgame.io/react'; ``` #### Arguments 1. `options` (_object_): An object with the options shown below under ‘Usage’. #### Returns A React component that runs the client. The component supports the following `props`: 1. `matchID` (_string_): Connect to a particular match (multiplayer). 2. `playerID` (_string_): Associate the client with a player (multiplayer). 3. `credentials` (_string_): The player’s authentication credentials (multiplayer). 4. `debug` (_boolean_): Set to `false` to disable the Debug UI. ### Usage ```js const App = Client({ // A game object. game: game, // The number of players. numPlayers: 2, // Your React component representing the game board. // The props that this component receives are listed below. // When using TypeScript, type the component's properties as // extending BoardProps. board: Board, // Optional: React component to display while the client // is in the "loading" state prior to the initial sync // with the game master. Relevant only in multiplayer mode. // If this is not provided, the client displays "connecting...". loading: LoadingComponent, // Set this to one of the following to enable multiplayer: // // SocketIO // Implementation that talks to a remote server using socket.io. // // How to import: // import { SocketIO } from 'boardgame.io/multiplayer' // // Arguments: // Object with 2 parameters // 1. 'socketOpts' options to pass directly to socket.io client. // 2. 'server' specifies the server location in the format: [http[s]://]hostname[:port]; // defaults to current page host. // // Local // Special local mode that uses an in-memory game master. Useful // for testing multiplayer interactions locally without having to // connect to a server. // // How to import: // import { Local } from 'boardgame.io/multiplayer' // // Additionally, you can write your own transport implementation. // See `src/client/client.js` for details. multiplayer: false, // Set to false to disable the Debug UI. debug: true, // An optional Redux store enhancer. // This is useful for augmenting the Redux store // for purposes of debugging or simply intercepting // events in order to kick off other side-effects in // response to moves. enhancer: applyMiddleware(your_middleware), }); ReactDOM.render(, document.getElementById('app')); ``` #### Board Props The component you pass as the `board` option will receive the following as `props`: - `G`: The game state. - `ctx`: The game metadata. - `moves`: An object containing functions to dispatch various moves that you have defined. The functions are named after the moves you created in your [game object](/api/Game.md). Each function can take any number of arguments, and they are passed to the move function after `G` and `ctx`. - `events`: An object containing functions to dispatch various game events like `endTurn` and `endPhase`. - `reset`: Function that resets the game. - `undo`: Function that undoes the last move. - `redo`: Function that redoes the previously undone move. - `sendChatMessage(message)`: Function that sends a chat message to other players. The `message` argument can be a string or you can send objects to include more metadata. - `chatMessages`: An array containing chat messages this client has received. Each message is an object with the following properties: - `id`: a unique ID string - `sender`: the `playerID` of the sender - `payload`: the value passed to `sendChatMessage` Example: ```js [ { id: 'foo', sender: '0', payload: 'Ready to play?' }, { id: 'bar', sender: '1', payload: 'Let’s go!' }, ] ``` - `log`: The game log. - `matchID`: The match ID associated with the client. - `playerID`: The player ID associated with the client. - `matchData`: An array containing the players that have joined the current match via the [Lobby API](/api/Lobby.md). Example: ```js [ { id: 0, name: 'Alice' }, { id: 1, name: 'Bob', isConnected: true } ] ``` - `isActive`: `true` if the client is able to currently make a move or interact with the game. - `isMultiplayer`: `true` if it is a multiplayer game. - `isConnected`: `true` if connection to the server is active. - `credentials`: Authentication token for this player when using the [Lobby REST API](/api/Lobby.md#server-side-api). ================================================ FILE: docs/documentation/api/Game.md ================================================ # Game ?> Using TypeScript? Check out [the TypeScript docs](typescript.md) on how to type your game object. ```js { // The name of the game. name: 'tic-tac-toe', // Function that returns the initial value of G. // setupData is an optional custom object that is // passed through the Game Creation API. setup: ({ ctx, ...plugins }, setupData) => G, // Optional function to validate the setupData before // matches are created. If this returns a value, // an error will be reported to the user and match // creation is aborted. validateSetupData: (setupData, numPlayers) => 'setupData is not valid!', moves: { // short-form move. A: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, // long-form move. B: { // The move function. move: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, // Prevents undoing the move. // Can also be a function: ({ G, ctx }) => true/false undoable: false, // Prevents the move arguments from showing up in the log. redact: true, // Prevents the move from running on the client. client: false, // Prevents the move counting towards a player’s number of moves. noLimit: true, // Processes the move even if it was dispatched from an out-of-date client. // This can be risky; check the validity of the state update in your move. ignoreStaleStateID: true, }, }, // Everything below is OPTIONAL. // Function that allows you to tailor the game state to a specific player. playerView: ({ G, ctx, playerID }) => G, // The seed used by the pseudo-random number generator. seed: 'random-string', turn: { // The turn order. order: TurnOrder.DEFAULT, // Called at the beginning of a turn. onBegin: ({ G, ctx, events, random, ...plugins }) => G, // Called at the end of a turn. onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the turn if this returns true. // Returning { next }, sets next playerID. endIf: ({ G, ctx, random, ...plugins }) => ( true | { next: '0' } ), // Called after each move. onMove: ({ G, ctx, events, random, ...plugins }) => G, // Prevents ending the turn before a minimum number of moves. minMoves: 1, // Ends the turn automatically after a number of moves. maxMoves: 1, // Calls setActivePlayers with this as argument at the // beginning of the turn. activePlayers: { ... }, stages: { A: { // Players in this stage are restricted to moves defined here. moves: { ... }, // Players in this stage will be moved to the stage specified // here when the endStage event is called. next: 'B' }, ... }, }, phases: { A: { // Called at the beginning of a phase. onBegin: ({ G, ctx, events, random, ...plugins }) => G, // Called at the end of a phase. onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the phase if this returns true. endIf: ({ G, ctx, random, ...plugins }) => true, // Overrides `moves` for the duration of this phase. moves: { ... }, // Overrides `turn` for the duration of this phase. turn: { ... }, // Make this phase the first phase of the game. start: true, // Set the phase to enter when this phase ends. // Can also be a function: ({ G, ctx }) => 'nextPhaseName' next: 'nextPhaseName', }, ... }, // The minimum and maximum number of players supported // (This is only enforced when using the Lobby server component.) minPlayers: 1, maxPlayers: 4, // Ends the game if this returns anything. // The return value is available in `ctx.gameover`. endIf: ({ G, ctx, random, ...plugins }) => obj, // Called at the end of the game. // `ctx.gameover` is available at this point. onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Disable undo feature for all the moves in the game disableUndo: true, // Transfer delta state with JSON Patch in multiplayer deltaState: true, } ``` ================================================ FILE: docs/documentation/api/Lobby.md ================================================ # Lobby The [Server](/api/Server) hosts the Lobby REST API that can be used to create and join matches. It is particularly useful when you want to authenticate clients to prove that they have the right to send actions on behalf of a player. Authenticated matches are created with server-side tokens for each player. You can create a match with the `create` API call, and join a player to a match with the `join` API call. A match that is authenticated will not accept moves from a client on behalf of a player without the appropriate credential token. Use the `create` API call to create a match that requires credential tokens. When you call the `join` API, you can retrieve the credential token for a particular player. ## Clients ### **Plain JS** boardgame.io provides a lightweight wrapper around the Fetch API to simplify using a Lobby API server from the client. ```js import { LobbyClient } from 'boardgame.io/client'; const lobbyClient = new LobbyClient({ server: 'http://localhost:8000' }); lobbyClient.listGames() .then(console.log) // => ['chess', 'tic-tac-toe'] .catch(console.error); ``` ### **React** The React lobby component provides a more high-level client, including UI for listing, joining, and creating matches. ```js import { Lobby } from 'boardgame.io/react'; import { TicTacToe } from './Game'; import { TicTacToeBoard } from './Board'; ; ``` `gameComponents` expects an array of objects with these fields: - `game`: A boardgame.io `Game` definition. - `board`: The React component that will render the board. ## REST API ### Listing available game types #### GET `/games` Returns an array of names for the games this server is running. #### Using a LobbyClient instance ```js const games = await lobbyClient.listGames(); ``` ### Listing all matches for a given game #### GET `/games/{name}` Returns all match instances of the game named `name`. Returns an array of `matches`. Each instance has fields: - `matchID`: the ID of the match instance. - `players`: the list of seats and players that have joined the game, if any. - `setupData` (optional): custom object that was passed to the game `setup` function. #### Using a LobbyClient instance ```js const { matches } = await lobbyClient.listMatches('tic-tac-toe'); ``` ### Getting a specific match by its ID #### GET `/games/{name}/{id}` Returns a match instance given its matchID. Returns a match instance. Each instance has fields: - `matchID`: the ID of the match instance. - `players`: the list of seats and players that have joined the match, if any. - `setupData` (optional): custom object that was passed to the game `setup` function. #### Using a LobbyClient instance ```js const match = await lobbyClient.getMatch('tic-tac-toe', 'matchID'); ``` ### Creating a match #### POST `/games/{name}/create` Creates a new authenticated match for a game named `name`. Accepts three parameters: - `numPlayers` (required): the number of players. - `setupData` (optional): custom object that is passed to the game `setup` function. - `unlisted` (optional): if set to `true`, the match will be excluded from the public list of match instances. Returns `matchID`, which is the ID of the newly created game instance. #### Using a LobbyClient instance ```js const { matchID } = await lobbyClient.createMatch('tic-tac-toe', { numPlayers: 2 }); ``` ### Joining a match #### POST `/games/{name}/{id}/join` Allows a player to join a particular match instance `id` of a game named `name`. Accepts three JSON body parameters: - `playerName` (required): the display name of the player joining the match. - `playerID` (optional): the ordinal player in the match that is being joined (`'0'`, `'1'`...). If not sent, will be automatically assigned to the first available ordinal. - `data` (optional): additional metadata to associate with the player. Returns `playerCredentials` which is the token this player will require to authenticate their actions in the future and `playerID`, which can be useful if you didn’t specify a `playerID` when making the request. #### Using a LobbyClient instance ```js const { playerCredentials } = await lobbyClient.joinMatch( 'tic-tac-toe', 'matchID', { playerID: '0', playerName: 'Alice', } ); ``` ### Updating a player’s metadata #### POST `/games/{name}/{id}/update` Rename and/or update additional metadata for a player in the match instance `id` of a game named `name` previously joined by the player. Accepts four JSON body parameters, requires at least one of the two optional parameters: - `playerID` (required): the ID used by the player in the match (0,1...). - `credentials` (required): the authentication token of the player. - `newName` (optional): the new name of the player. - `data` (optional): additional metadata to associate with the player. #### Using a LobbyClient instance ```js await lobbyClient.updatePlayer('tic-tac-toe', 'matchID', { playerID: '0', credentials: 'playerCredentials', newName: 'Al', }); ``` ### Leaving a match #### POST `/games/{name}/{id}/leave` Leave the match instance `id` of a game named `name` previously joined by the player. Accepts two JSON body parameters, all required: - `playerID`: the ID used by the player in the match (0, 1...). - `credentials`: the authentication token of the player. #### Using a LobbyClient instance ```js await lobbyClient.leaveMatch('tic-tac-toe', 'matchID', { playerID: '0', credentials: 'playerCredentials', }); ``` ### Playing again #### POST `/games/{name}/{id}/playAgain` - `{name}` (required): the name of the game being played again. - `{id}` (required): the ID of the previous finished match. Given a previous match, generates a match ID where users should go if they want to play again. Creates this new match if it didn't exist before. Accepts these parameters: - `playerID` (required): the player ID of the player in the previous match. - `credentials` (required): player's credentials. - `numPlayers` (optional): the number of players. Defaults to the `numPlayers` value of the previous match. - `setupData` (optional): custom object that was passed to the game `setup` function. Defaults to the `setupData` object of the previous room. Returns `nextMatchID`, which is the ID of the newly created match that the user should go to play again. #### Using a LobbyClient instance ```js const { nextMatchID } = await lobbyClient.playAgain('tic-tac-toe', 'matchID', { playerID: '0', credentials: 'playerCredentials', }); ``` ================================================ FILE: docs/documentation/api/Server.md ================================================ # Server Creates a `boardgame.io` server. This is only required when `multiplayer` is set to `true` on the client. It creates a [Koa](http://koajs.com/) app that keeps track of the game states of the various clients connected to it, and also broadcasts updates to those clients so that all browsers that are connected to the same game are kept in sync in realtime. The server also hosts a REST [API](https://boardgame.io/documentation/#/api/Lobby?id=server-side-api) that is used for creating and joining games. This is hosted on the same port, but can be configured to run on a separate port. #### Arguments A config object with the following options: 1. `games` (_array_) (required): a list of game implementations (each should be an object conforming to the [Game API](/api/Game.md)). 2. `origins` (_array_) (required): a list of allowed origins for [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS "Cross-Origin Resource Sharing"). The list can contain strings or regular expressions, matching the origins that are allowed to access the game server. For example, this could be `['https://example.com']` if that’s where your game is running. While developing locally you probably want to allow any page running on localhost to connect. See [Usage](#usage) below for an example. [cors]: https://github.com/expressjs/cors#configuration-options 3. `db` (_object_): the [database connector](/storage). If not provided, an in-memory implementation is used. 4. `transport` (_object_): the transport implementation. If not provided, socket.io is used. 5. `uuid` (_function_): an optional function that returns a unique identifier, used to create new game IDs and — if `generateCredentials` is not specified — player credentials. Defaults to [nanoid](https://www.npmjs.com/package/nanoid). 6. `generateCredentials` (_function_): an optional function that returns player credentials to store in the game metadata and validate against. If not specified, the `uuid` function will be used. 7. `authenticateCredentials` (_function_): an optional function that tests if a player’s move is made with the correct credentials when using the default socket.io transport implementation. 8. `apiOrigins` (_array_): a list of allowed origins for requests to the Lobby API. Defaults to the value provided as the `origins` option (which also applies to the socket transport). #### Returns An object that contains: 1. `run` (_function_): A function to run the server. `(portOrConfig, callback) => ({ apiServer, appServer })` 2. `kill` (_function_): A function to stop the server. `({ apiServer, appServer }) => void` 3. `app` (_object_): The Koa app. 4. `db` (_object_): The database implementation. 5. `router` (_object_): The Koa Router for the server API. ### Usage #### Basic ```js const { Server, Origins } = require('boardgame.io/server'); const server = Server({ // Provide the definitions for your game(s). games: [game1, game2, ...], // Provide the database storage class to use. db: new DbConnector(), origins: [ // Allow your game site to connect. 'https://www.mygame.domain', // Allow localhost to connect, except when NODE_ENV is 'production'. Origins.LOCALHOST_IN_DEVELOPMENT ], }); server.run(8000); ``` #### With callback ```js server.run(8000, () => console.log("server running...")); ``` #### With custom Lobby settings You can pass `lobbyConfig` to configure the Lobby API during server startup: ```js const lobbyConfig = { apiPort: 8080, apiCallback: () => console.log('Running Lobby API on port 8080...'), }; server.run({ port: 8000, lobbyConfig }); ``` Options are: - `apiPort`: If specified, it runs the Lobby API in a separate Koa server on this port. Otherwise, it shares the same Koa server running on the default boardgame.io `port`. - `apiCallback`: Called when the Koa server is ready. Only applicable if `apiPort` is specified. #### With HTTPS ```js const { Server } = require('boardgame.io/server'); const fs = require('fs'); const server = Server({ games: [game1, game2, ...], https: { cert: fs.readFileSync('/path/to/cert'), key: fs.readFileSync('/path/to/key'), }, }); server.run(8000); ``` #### With custom authentication `generateCredentials` is called when a player joins a game with: - `ctx`: The Koa context object, which can be used to generate tailored credentials from request headers etc. `authenticateCredentials` is called when a player makes a move with: - `credentials`: The credentials sent from the player’s client - `playerMetadata`: The metadata object for the `playerID` making a move Below is an example of how you might implement custom authentication with a hypothetical `authService` library. The `generateCredentials` method checks for the Authorization header on incoming requests and tries to use it to decode a token. It returns an ID from the result, storing a public user ID as “credentials” in the game metadata. The `authenticateCredentials` method passed to the `Server` also expects a similar token, which when decoded matches the ID stored in game metadata. ```js const { Server } = require('boardgame.io/server'); const generateCredentials = async ctx => { const authHeader = ctx.request.headers['authorization']; const token = await authService.decodeToken(authHeader); return token.uid; } const authenticateCredentials = async (credentials, playerMetadata) => { if (credentials) { const token = await authService.decodeToken(credentials); if (token.uid === playerMetadata.credentials) return true; } return false; } const server = Server({ games: [game1, game2, ...], generateCredentials, authenticateCredentials, }); server.run(8000); ``` !> N.B. This approach is not currently compatible with how the React `` provides credentials. ### Extending the server The boardgame.io server uses [Koa](koajs.com/) and [`@koa/router`](https://github.com/koajs/router). You can customise the Lobby API by accessing the router instance, for example to add routes or to add custom middleware for existing routes. See an example of customising the entire Koa app [in the Heroku deployment guide](/deployment.md#frontend-and-backend). #### Add a custom route ```js const server = Server({ /* options */ }); server.router.get('/custom-endpoint', (ctx, next) => { ctx.body = 'Hello World!'; }); server.run(8000); ``` #### Add middleware ```js const server = Server({ /* options */ }); // Add middleware to the create game route. server.router.use('/games/:name/create', async (ctx, next) => { // Decide number of players etc. based on some other API. const { numPlayers, setupData } = await fetchDataFromSomeCustomAPI(); // Set request body to be used by the create game route. ctx.request.body.numPlayers = numPlayers; ctx.request.body.setupData = setupData; next(); }); server.run(8000); ``` ================================================ FILE: docs/documentation/chat.md ================================================ # Chat The boardgame.io client provides a basic API for sending chat messages between players in a match using the [multiplayer server](multiplayer?id=remote-master). The [plain JS client](api/Client?id=properties) and the [React client](api/Client?id=board-props) (via board props) both provide the following properties: - `sendChatMessage(message)`: Function that sends a chat message to other players. The message argument can be a string or you can send objects to include more metadata. For example, you might decide to include a timestamp along with message text: ```js sendChatMessage({ message: 'Hello', time: Date.now() }); ``` - `chatMessages`: An array containing chat messages this client has received. Each message is an object with the following properties: - `id`: a unique message ID string - `sender`: the `playerID` of the message’s sender - `payload`: the value of the `message` argument passed to `sendChatMessage` Example `chatMessages` array: ```js [ { id: 'foo', sender: '0', payload: 'Ready to play?' }, { id: 'bar', sender: '1', payload: 'Let’s go!' }, ] ``` ### Notes - **Chat messages are ephemeral and are not stored by the boardgame.io server.** A client only receives messages sent while it is connected to the server. If messages are sent amongst players before another player has connected, the new player will not receive those prior messages. Similarly, if the page is refreshed, any previously received messages will be lost. - **Only players can send chat messages.** Assuming the match is authenticated via [the Lobby server](api/Lobby), only players are permitted to send messages, which are authenticated using the same logic as other game actions. Spectator clients can receive and view chat messages, but not send messages of their own. ================================================ FILE: docs/documentation/concepts.md ================================================ # Concepts ### State boardgame.io captures game state in two objects: `G` and `ctx`. ```js { // The game state (managed by you). G: {}, // Read-only metadata (managed by the framework). ctx: { turn: 0, currentPlayer: '0', numPlayers: 2, } } ``` These state objects are passed around everywhere and maintained on both client and server seamlessly. The state in `ctx` is incrementally adoptable, meaning that you can manage all the state manually in `G` if you so desire. ?> `ctx` contains other fields not shown here that games can take advantage of, including support for game phases and complex turn orders. !> Because state can be sent between client and server, `G` must be a JSON-serializable object; in particular, it must not contain classes or functions. ### Moves These are functions that tell the framework how to change `G` when a particular game move is made. They must not depend on external state or have any side-effects (except modifying `G`). See the guide on [Immutability](immutability.md) for how immutability is handled by the framework. ```js moves: { drawCard: ({ G, ctx }) => { const card = G.deck.pop(); G.hand.push(card); }, // ... } ``` On the client, you use a `moves` object to dispatch your move functions. #### **Plain JS** You can access `moves` from an instance of the plain JavaScript client: ```js client.moves.drawCard(); ``` #### **React** Using React, `moves` is provided through your component’s `props`: ```js props.moves.drawCard(); ``` ### Events These are framework-provided functions that are analogous to moves, except that they work on `ctx`. These typically advance the game state by doing things like ending the turn, changing the game phase etc. Events are dispatched from the client in a similar way to moves. #### **Plain JS** ```js client.events.endTurn(); ``` #### **React** ```js props.events.endTurn(); ``` For more details, see the guide on [Events](events.md). ### Phase A phase is a period in the game that overrides the game configuration while it is active. For example, you can use a different set of moves or a different turn order during a phase. The game can transition between different phases, and turns occur inside phases. See the guide on [Phases](phases.md) for more details. ### Turn A turn is a period of the game that is associated with an individual player. It typically consists of one or more moves made by that player before it passes on to another player. You can also allow other players to play during your turn, although this is less common. See the guide on [Turn Orders](turn-order.md) for more details. ### Stage A stage is similar to a phase, except that it happens within a turn, and applies to individual players rather than the game as a whole. A turn may be subdivided into many stages, each allowing a different set of moves and overriding other game configuration options while that stage is active. Also, different players can be in different stages during a turn. See the guide on [Stages](stages.md) for more details. ================================================ FILE: docs/documentation/debugging.md ================================================ # Debugging ### Using the Debug Panel in production boardgame.io comes bundled with a debug panel that lets you interact with your game and game clients. When you build your app for production (i.e. when `NODE_ENV === 'production'`) this is stripped out from the final bundle. If you want to include the debug panel in a production build you can do so explicitly when creating your client: ```js import { Debug } from 'boardgame.io/debug'; const client = Client({ // ... debug: { impl: Debug }, }); ``` ### Debug Panel options You can use the `collapseOnLoad` option to hide the panel by default when the client loads. The `hideToggleButton` option removes the toggle button on the side of the panel which means you can only use the keyboard shortcut to toggle its visibility. ```js const client = Client({ // ... debug: { // ... collapseOnLoad: true/false, hideToggleButton: true/false }, }); ``` ### Custom metadata in game logs It can sometimes be helpful to surface some metadata during a move. You can do this by using the log plugin. For example, ```js const move = ({ log }) => { log.setMetadata('metadata for this move'); }; ``` This metadata is stored in the `log` client property and displayed in the Log section of the debug panel. ### Redux The framework uses Redux under the hood. You may sometimes want to debug this Redux store directly. In order to do so, you can pass along a Redux store enhancer with your client. For example, ```js import logger from 'redux-logger'; import { applyMiddleware } from 'redux'; Client({ // ... enhancer: applyMiddleware(logger), }); ``` Doing so will `console.log` on state changes. This can also hook into the [Chrome Redux DevTools](http://extension.remotedev.io/) browser extension like this: ```js Client({ // ... enhancer: ( window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() ), }) ``` or both ```js import logger from 'redux-logger'; import { applyMiddleware, compose } from 'redux'; Client({ // ... enhancer: compose( applyMiddleware(logger), (window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()) ), }) ``` ### Server + Sockets The Koa-server can be debugged by setting the `DEBUG` environment variable before starting it. This will give you access to logs of incoming requests as well as the socket.io logs. To set the environment variable prepend your npm script to run the server like so: ``` DEBUG=* node server.js ``` > NOTE: For various debugging scopes have a look at the [socket.io-docs](https://socket.io/docs/v4/logging-and-debugging/#available-debugging-scopes) ================================================ FILE: docs/documentation/deployment.md ================================================ # Deployment ## Serverless Options For one-player or pass-and-play games, you may not need the boardgame.io game server and prefer to serve an app that runs entirely on the client. If you don’t need multiplayer features, this can be a lot simpler than getting a Node.js server deployed. There are many services that can help deploy a static app, including some that offer free options like [Netlify](https://www.netlify.com/) and [Render](https://render.com/). ### **Plain JS** If you followed along with the Plain JS tutorial, you can also use Parcel to build your app for production. Add a build script to your `package.json`: ```json { "scripts": { "build": "parcel build index.html --out-dir build", } } ``` Running `npm run build` will now create an optimised production build in `/build`, which you can host just about anywhere. #### Deployment configuration Both Netlify and Render offer options to continuously deploy the latest version of your app from a Git repository. These configurations should help you get up and running with these services.
Netlify 1. Create a new deployment (see [Netlify docs](https://docs.netlify.com/site-deploys/create-deploys/)). 2. Use the following values for the deployment: | Option | Value | |-------------------|-----------------| | Build Command | `npm run build` | | Publish Directory | `build` |
Render 1. Create a new Web Service on Render and connect it to your project repository. 2. Use the following values during creation: | Option | Value | |-------------------|-----------------| | Environment | `Static Site` | | Build Command | `npm run build` | | Publish Directory | `build` |
### **React** Running `npm run build` in a Create React App project will create an optimised production build in `/build`, which you can host just about anywhere. #### Deployment guides - **Netlify:** See [the guide on how to deploy to Netlify](https://create-react-app.dev/docs/deployment/#netlify) in the Create React App docs. - **Render:** See [“Deploy a Create React App Static Site”](https://render.com/docs/deploy-create-react-app) in the Render docs. ## Heroku [Heroku](https://heroku.com) uses 2 different ways to determine the run command of a node application. It is possible to either: - Add a Procfile to the project root directory with the following line `web: node -r esm server.js` - Update the start script in the package.json to `"start": "node -r esm server.js"` On Heroku, a regular heroku/nodejs buildpack is necessary to build your app which is usually selected by default for node applications. ### Frontend and Backend In order to deploy a game to Heroku, the game has to be running on a single port. To do so, the [Server](/api/Server.md) has to handle both the API requests and serving the pages. Below is an example of how to achieve that. First install these extra dependencies: ``` npm i koa-static ``` Then adjust your `server.js` file like this: ```js // server.js import { Server } from 'boardgame.io/server'; import path from 'path'; import serve from 'koa-static'; import { TicTacToe } from './game'; const server = Server({ games: [TicTacToe] }); const PORT = process.env.PORT || 8000; // Build path relative to the server.js file const frontEndAppBuildPath = path.resolve(__dirname, './build'); server.app.use(serve(frontEndAppBuildPath)) server.run(PORT, () => { server.app.use( async (ctx, next) => await serve(frontEndAppBuildPath)( Object.assign(ctx, { path: 'index.html' }), next ) ) }); ``` The [Lobby](/api/Lobby.md) might be as follows: ```jsx import React from 'react'; import { Lobby } from 'boardgame.io/react'; import { TicTacToeBoard } from './board'; import { TicTacToe } from './game'; const { protocol, hostname, port } = window.location; const server = `${protocol}//${hostname}:${port}`; const importedGames = [{ game: TicTacToe, board: TicTacToeBoard }]; export default () => (

Lobby

); ``` Or, without the lobby, pass the server address when calling `SocketIO`: ```js import { SocketIO } from 'boardgame.io/multiplayer'; const { protocol, hostname, port } = window.location; const server = `${protocol}//${hostname}:${port}`; const GameClient = Client({ // ... multiplayer: SocketIO({ server }), }); ``` ### Backend Only If you only need to publish your backend to Heroku, your `server.js` can be simplified to this: ```js // server.js import { Server } from 'boardgame.io/server'; import { TicTacToe } from './game'; const server = Server({ games: [TicTacToe] }); const PORT = process.env.PORT || 8000; server.run(PORT); ``` And your [Lobby](/api/Lobby.md) would now be pointing to your Heroku app URL: ```jsx import React from 'react'; import { Lobby } from 'boardgame.io/react'; import { TicTacToeBoard } from './board'; import { TicTacToe } from './game'; const server = `https://yourapplication.herokuapp.com`; const importedGames = [{ game: TicTacToe, board: TicTacToeBoard }]; export default () => (

Lobby

); ``` Or, without the lobby, pass the Heroku app URL when calling `SocketIO`: ```js import { SocketIO } from 'boardgame.io/multiplayer'; const GameClient = Client({ // ... multiplayer: SocketIO({ server: 'https://yourapplication.herokuapp.com' }), }); ``` ================================================ FILE: docs/documentation/events.md ================================================ # Events An event is used to advance the game state. It is somewhat analogous to a move, except that while a move changes `G`, an event changes `ctx`. Also, events are provided by the framework (as opposed to moves, which are written by you). ### Event Types #### endStage This event takes the player that called it out of the stage that they are in. If the definition for the current stage in the game object specifies a `next` option, then the player is taken to the next stage. If not, the player is returned to a state where they are not in any stage. ```js endStage(); ``` #### endTurn This event ends the turn. The default behavior is to increment `ctx.turn` by `1` and advance `currentPlayer` to the next player according to the configured [turn order](turn-order.md) (the default being a round-robin). This event also accepts an argument, which (if provided) switches the turn to the specified player instead. ```js endTurn(); // without argument endTurn({ next: '2' }); // Player 2 is the next player. ``` #### endPhase This event ends the current phase. If the definition for the current phase in the game object specifies a `next` option, then the game moves to that phase. If not, the game returns to a state where no phase is active. ```js endPhase(); ``` #### endGame This event ends the game. If you pass an argument to it, then that argument is made available in `ctx.gameover`. After the game is over, further state changes to the game (via a move or event) are not possible. ```js endGame(); ``` #### setStage Takes the player that called the event into the stage specified. ```js setStage('stage-name'); ``` #### setPhase Takes the game into the phase specified. Ends the active phase first. ```js setPhase('phase-name'); ``` #### setActivePlayers Allows adding additional players to the set of "active players", and also any stages that you want to put them in. See the guide on [Stages](stages.md) for more details. ### Triggering an event from game logic. You can trigger events from a move or code inside your game logic (a phase’s `onBegin` hook, for example). This is done through the `events` API in the object passed as the first argument to moves: ```js moves: { drawCard: ({ G, ctx, events }) => { events.endPhase(); }; } ``` !> Events are queued up and triggered **after** a move. Any changes you make to `G` will be applied before events are triggered, even if the event is called first in your move function. ### Triggering an event from the client #### **Plain JS** Events are available inside the `events` property of a boardgame.io client instance. For example: ```js import { Client } from 'boardgame.io/client'; const client = Client({ /* options */ }); const clickHandler = () => { client.events.endTurn(); } ``` #### **React** Events are available through `props` inside the `events` object. For example: ```js import React from 'react'; function Board({ events }) { const onClick = () => { events.endTurn(); }; return ; } ``` ### Disabling events Events can be disabled. For example, you might not want a player to be able to end the game directly by simply calling the `endGame` event. In order to disable an event, just add `eventName: false` to the `events` section in your game config. ```js const game = { events: { endGame: false, // ... }, }; ``` !> This doesn't apply to events in moves or hooks, but just the ability to call an event directly from a client. ### Calling events from hooks The events API is available in game hooks like it is inside moves. However, because of how hooks and events interact, certain events cannot be called from certain hooks. The following table shows which hooks support which events. | | turn
`onMove` | turn
`onBegin` | turn
`onEnd` | phase
`onBegin` | phase
`onEnd` | game
`onEnd` | |-------------------:|:----------------:|:-----------------:|:---------------:|:------------------:|:----------------:|:---------------:| | `setStage` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | `endStage` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | `setActivePlayers` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | `endTurn` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | | `setPhase` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | `endPhase` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | `endGame` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ = supported     ❌ = not supported ================================================ FILE: docs/documentation/immutability.md ================================================ # Immutability The principle of immutability as applied to state changing functions like moves in [boardgame.io](https://boardgame.io/) mandates that they be pure functions. What this means is that you cannot depend on any **external state**, nor can you have any **side-effects**, i.e. you cannot modify anything that isn't a local variable (not even the arguments). The benefits of architecting a system with this principle are that you can ensure repeatability (moves can be replayed over a particular state value multiple times in different places) and you can do cheap comparisons to check if something changed. A traditional pure function just accepts arguments and then returns the new state. Something like this: ```js function move({ G }) { // Return new value of G without modifying the arguments. return { ...G, hand: G.hand + 1 }; } ``` ?> The example above uses the [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) to create a new object. [boardgame.io](https://boardgame.io/) provides a more convenient syntax by allowing you to mutate `G` directly while using a [library](https://github.com/mweststrate/immer) under the hood to convert your move into a pure function that respects the immutability principle. Both styles are supported interchangeably, so use the one that you prefer. ```js function move({ G }) { G.hand++; } ``` ?> Note that in this style you do not return the new state. In fact, returning something while also mutating `G` is considered an error. !> You can only modify `G`. Other values passed to your moves are read-only and should never be modified in either style. Changes to `ctx` can be made using [events](events.md). ### Invalid moves In both styles, invalid moves are indicated by returning a special constant. This tells the framework that the current set of arguments passed in is illegal and that the move ought to be discarded. For example, you might do this if the user tries to click on an already filled cell in Tic-Tac-Toe. ```js import { INVALID_MOVE } from 'boardgame.io/core'; moves: { clickCell: function({ G, ctx }, id) { // Illegal move: Cell is filled. if (G.cells[id] !== null) { return INVALID_MOVE; } // Fill cell with 0 or 1 depending on the current player. G.cells[id] = ctx.currentPlayer; } } ``` ### Additional Reading [Immutable Update Patterns](https://redux.js.org/recipes/structuring-reducers/immutable-update-patterns) ================================================ FILE: docs/documentation/index.html ================================================ boardgame.io
================================================ FILE: docs/documentation/multiplayer.md ================================================ # Multiplayer In this section, we'll explain how the framework converts your game logic into a multiplayer implementation without requiring you to write any networking or storage layer code. We will continue working with our Tic-Tac-Toe example from the [tutorial](tutorial.md). ### Clients and Masters A boardgame.io client is what you create using the `Client` call. You initialize it with your game object (which contains the moves), so it has all the information that is needed to run the game. This is where the story ends in a single player setup. In a multiplayer setup, clients no longer act as authoritative stores of the game state. Instead, they delegate the running of the game to a game master. In this mode clients emit moves / events, but the game logic runs on the master, which computes the next game state before broadcasting it to other clients. However, since clients are aware of the game rules, they also run the game in parallel (this is called an optimistic update and is an optimization that provides a lag-free experience). In case a particular client computes the new game state incorrectly, it is overridden by the master eventually, so the entire setup still has a single source of authority. If a move accesses state that is not accessible to the client (for instance secret state), then optimistic updates may need to be disabled for that move. See the [secret state documentation](secret-state.md) for more details. ## Local Master The game master can run completely on the browser. This is useful to set up pass-and-play multiplayer or for prototyping the multiplayer experience without having to set up a server to test it. To do this `import { Local } from 'boardgame.io/multiplayer'`, and add `multiplayer: Local()` to the client options. Now you can instantiate as many of these clients in your app as you like and you will notice that they’re all kept in sync, sharing the same state. #### **Plain JS** Let’s update our `TicTacToeClient` to receive an additional `playerID` option in its constructor. We’ll use this so that each client knows which player it is playing for. Then, we’ll update how we create the boardgame.io client, passing `playerID` and setting `multiplayer` to use the Local Master. ```js import { Client } from 'boardgame.io/client'; import { Local } from 'boardgame.io/multiplayer'; import { TicTacToe } from './Game'; class TicTacToeClient { constructor(rootElement, { playerID } = {}) { this.client = Client({ game: TicTacToe, multiplayer: Local(), playerID, }); // ... } // ... } ```` Now instead of rendering one client into our app, we’ll render one for each player ID: ```js const appElement = document.getElementById('app'); const playerIDs = ['0', '1']; const clients = playerIDs.map(playerID => { const rootElement = document.createElement('div'); appElement.append(rootElement); return new TicTacToeClient(rootElement, { playerID }); }); ``` [![Edit bgio-plain-js-multiplayer](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/bgio-plain-js-multiplayer-re48t?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.js&theme=dark) #### **React** ```js // src/App.js import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import { TicTacToe } from './Game'; import { TicTacToeBoard } from './Board'; const TicTacToeClient = Client({ game: TicTacToe, board: TicTacToeBoard, multiplayer: Local(), }); const App = () => (
); export default App; ``` [![Edit boardgame.io](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/boardgameio-dibw3) ?> You may be wondering what the `playerID` parameter is from the example above. Clients needs to be associated with a particular player seat in order to make moves in a multiplayer setup. (If a client doesn’t have a `playerID` it is a spectator that can see the live game state, but can't actually make any moves.) ```react ``` In the example above you can play as Player 0 and Player 1 alternately on the two boards. Clicking on a particular board when it is not that player's turn has no effect. ### Storing state in the browser If you want game state to be saved in the browser using `localStorage`, you can pass additional options when creating a local master: ```js Local({ // Enable localStorage cache. persist: true, // Set custom prefix to store data under. Default: 'bgio'. storageKey: 'bgio', }); ``` ## Remote Master You can also run the game master on a separate server. Any boardgame.io client can connect to this master (whether it is a browser, an Android app etc.) and it will be kept in sync with other clients in realtime. In order to connect a client to a remote master, we use the `multiplayer` option again, but this time we import `SocketIO` instead of `Local`, and specify the location of the server. #### **Plain JS** ```js import { SocketIO } from 'boardgame.io/multiplayer' class TicTacToeClient { constructor(rootElement, { playerID } = {}) { this.client = Client({ game: TicTacToe, multiplayer: SocketIO({ server: 'localhost:8000' }), playerID, }); // ... } // ... } ``` We also need to make a small tweak to our `update` method. When using a remote master, the client won’t know the game state when it first runs, so `update` will be called first with `null`, then with the full game state after it connects to the server. In a real implementation you might show a loading spinner to indicate this, but we’ll just skip our `update` for now if state is `null`: ```js update(state) { if (state === null) return; // ... } ``` #### **React** ```js import { SocketIO } from 'boardgame.io/multiplayer' const TicTacToeClient = Client({ game: TicTacToe, board: TicTacToeBoard, multiplayer: SocketIO({ server: 'localhost:8000' }), }); ``` Behind the scenes, the client now sends updates to the remote master via a WebSocket whenever you make a move. Of course, we now need to run a server at the location specified, which is discussed below. ### Setting up the server We’ll create a new file at `src/server.js` to write our server code. boardgame.io provides a server module that simplifies running the game master on a Node server. We import that module and configure it with our `TicTacToe` game object and a list of URL origins we want to allow to connect to the server. Later you would set `origins` with your game’s domain name, but for now we’ll import a default value that allows any locally served page to connect. ```js // src/server.js const { Server, Origins } = require('boardgame.io/server'); const { TicTacToe } = require('./Game'); const server = Server({ games: [TicTacToe], origins: [Origins.LOCALHOST], }); server.run(8000); ``` ?> See [the Server reference page](api/Server.md) for more detail on the various configuration options. Because `Game.js` is an ES module, we will use [esm](https://github.com/standard-things/esm) which enables us to use `import` statements in a Node environment: ``` npm install esm ``` We can then add a new script to our `package.json` to simplify running the server: ```json { "scripts": { "serve": "node -r esm src/server.js" } } ``` We can now run `npm run serve` in one terminal to start the server and `npm start` in another to serve our web app. You can connect multiple clients to the same game by opening your app in several different browser tabs. You will notice that everything is kept in sync as you play (state is not lost even if you refresh the page). This example still has both players on the same screen. A more natural setup would be to have each client just have a single (but distinct) player. #### **Plain JS** You want one client to render: ```js new TicTacToeClient(appElement, { playerID: '0' }); ``` and another to render: ```js new TicTacToeClient(appElement, { playerID: '1' }); ``` #### **React** You want one client to render: ``` ``` and another to render: ``` ``` One way to do this is to ask the player which seat they want to take when they open your app and then set the `playerID` accordingly. You can also use a URL path to determine the player or use a matchmaking lobby. Complete code from this section is available on CodeSandbox for both [React](https://codesandbox.io/s/boardgameio-fsl8y) and [Plain JS](https://codesandbox.io/s/bgio-plain-js-multiplayer-server-742oh) versions. To run the server, you can click **File** > **Export to ZIP** to download the project, then run the server and client as described above. Don't forget to run `npm install` in the project directory first! ?> **TIP** You can also set the `playerID` to point to any player while prototyping by clicking on the box of that respective player on the debug UI. ### Multiple Game Types You can serve multiple types of games from the same server: ```js const app = Server({ games: [TicTacToe, Chess] }); ``` For this to work correctly, make sure that each game implementation specifies a name: ```js const TicTacToe = { name: 'tic-tac-toe', // ... }; ``` ### Game Instances By default all client instances connect to a game with an ID `'default'`. To play a new game instance, you can pass `matchID` to your client. All clients that use this ID will now see the same game state. #### **Plain JS** Pass `matchID` when creating your boardgame.io client: ```js const client = Client({ game: TicTacToe, matchID: 'matchID', // ... }); ``` You an also update a `matchID` on an already instantiated client: ```js client.updateMatchID('newID'); ``` #### **React** ``` ``` The `matchID`, similar to the `playerID` can again be determined either by a URL path or a lobby implementation. ### Storage The default storage implementation is an in-memory map. If you want something that's more persistent, you can use one of the available database connectors, or even implement your own. See [the storage docs](storage.md) for more details. ================================================ FILE: docs/documentation/notable_projects.md ================================================ # Projects Nonexhaustive list of notable projects using boardgame.io. Feel free to send a PR to add your project to this list, but please have a demo that's accessible via a URL. #### [![GitHub stars][b-ark]][c-ark] [Arknights: The Card Game (明日方舟: 采掘行动)][p-ark] \[[code][c-ark]\]    A challenging single-player roguelike card game that is played locally, in Chinese. [b-ark]: https://img.shields.io/github/stars/dadiaogames/arknights-card-game?label=%E2%98%85&logo=github [p-ark]: https://dadiaogames.github.io/arknights-card-game/ [c-ark]: https://github.com/dadiaogames/arknights-card-game #### [![GitHub stars][b1]][c1] Bad Flamingo \[[code][c1]\]    Fool the computer, but not your friends! Adversarial "Quick, Draw". [b1]: https://img.shields.io/github/stars/jayelm/bad-flamingo?label=%E2%98%85&logo=github [c1]: https://github.com/jayelm/bad-flamingo #### [![GitHub stars][b-bl]][c-bl] [Battle Line][p-bl] \[[code][c-bl]\]    Clone of Battle Line, a 2 player card game. [b-bl]: https://img.shields.io/github/stars/rsandzimier/battleline?label=%E2%98%85&logo=github [c-bl]: https://github.com/rsandzimier/battleline [p-bl]: https://rsandzimier.github.io/battleline/ #### [![GitHub stars][jwbj-3]][jwbj-2] [Black Jack (John Wick theme)][jwbj-1] \[[code][jwbj-2]\]    A John Wick-themed Black Jack card game with custom graphics and sound. [jwbj-3]: https://img.shields.io/github/stars/ipkevin/Blackjack-Builder?label=%E2%98%85&logo=github [jwbj-2]: https://github.com/ipkevin/Blackjack-Builder [jwbj-1]: https://johnwickblackjack.netlify.app/ #### [![GitHub stars][b2]][c2] boardgame.io-angular \[[code][c2] | [demo][d2]\]    Unofficial Angular client for boardgame.io. [b2]: https://img.shields.io/github/stars/turn-based/boardgame.io-angular?label=%E2%98%85&logo=github [c2]: https://github.com/turn-based/boardgame.io-angular [d2]: https://turn-based-209306.firebaseapp.com/ #### [![GitHub stars][b-bri]][c-bri] [Briscola][p-bri] \[[code][c-bri] | [demo][d-bri]\]    Online 2-player variant of the Briscola card game. [p-bri]: https://instant-briscola.herokuapp.com/ [c-bri]: https://github.com/aflorj/briscola [d-bri]: https://instant-briscola.herokuapp.com/demo [b-bri]: https://img.shields.io/github/stars/aflorj/briscola?label=%E2%98%85&logo=github #### [![GitHub stars][b3]][c3] [Camelot][p3] \[[code][c3]\]    Play the Camelot board game online. [b3]: https://img.shields.io/github/stars/blunket/camelot?label=%E2%98%85&logo=github [c3]: https://github.com/blunket/camelot [p3]: https://www.playcamelot.com #### [![GitHub stars][b4]][c4] [Can't Stop!][p4] \[[code][c4]\]    The classic "push your luck" dice game. [b4]: https://img.shields.io/github/stars/simlmx/cantstop?label=%E2%98%85&logo=github [c4]: https://github.com/simlmx/cantstop [p4]: https://cantstop.fun #### [![GitHub stars][b5]][c5] [Cardman Multiplayer][p5] \[[code][c5]\]    A cross between Hangman and a card game. [p5]: http://cardman-multiplayer.herokuapp.com [c5]: https://github.com/VengelStudio/cardman-multiplayer [b5]: https://img.shields.io/github/stars/VengelStudio/cardman-multiplayer?label=%E2%98%85&logo=github #### [![GitHub stars][b-chessweeper]][c-chessweeper] [Chessweeper][p-chessweeper] \[[code][c-chessweeper]\]    A mix between Chess and Minesweeper [p-chessweeper]: https://chessweeper.zirk.eu [c-chessweeper]: https://github.com/Xwilarg/Chessweeper [b-chessweeper]: https://img.shields.io/github/stars/xwilarg/chessweeper?label=%E2%98%85&logo=github #### [![GitHub stars][b26]][c26] [Chinchon][p26] \[[code][c26]\]    Multiplayer online card game similar to gin rummy. [p26]: https://chinchon-game.herokuapp.com/ [c26]: https://github.com/maxpaulus43/chinchon [b26]: https://img.shields.io/github/stars/maxpaulus43/chinchon?label=%E2%98%85&logo=github #### [![GitHub stars][b21]][c21] [Coup][p21] \[[code][c21]\]    Online multiplayer version of Coup, a strategy board game. [p21]: https://online-coup.herokuapp.com/ [c21]: https://github.com/vyang1222/online-coup [b21]: https://img.shields.io/github/stars/vyang1222/online-coup?label=%E2%98%85&logo=github #### [![GitHub stars][b6]][c6] [Elevation of Privilege][p6] \[[code][c6]\]    An online multiplayer version of the threat modeling card game. [b6]: https://img.shields.io/github/stars/dehydr8/elevation-of-privilege?label=%E2%98%85&logo=github [p6]: https://elevation-of-privilege.herokuapp.com/ [c6]: https://github.com/dehydr8/elevation-of-privilege #### [![GitHub stars][b7]][c7] [Fields of Arle simulator][p7] \[[code][c7]\]    Open source simulator of Fields of Arle. [b7]: https://img.shields.io/github/stars/philihp/fields-of-arle?label=%E2%98%85&logo=github [p7]: https://arle.philihp.com [c7]: https://github.com/philihp/fields-of-arle #### [![GitHub stars][b-fd]][c-fd] [Forbidden Desert][p-fd] \[[code][c-fd]\]    Clone of Forbidden Desert, a 2-5 player cooperative board game played locally. [b-fd]: https://img.shields.io/github/stars/hwabis/forbidden-desert?label=%E2%98%85&logo=github [p-fd]: https://hwabis.github.io/forbidden-desert/ [c-fd]: https://github.com/hwabis/forbidden-desert #### [![GitHub stars][b8]][c8] Four in a row \[[code][c8] | [tutorial][t8]\]    Four in a Row using boardgame.io. [c8]: https://github.com/PJohannessen/four-in-a-row [t8]: https://www.lonesomecrowdedweb.com/blog/four-in-a-row-boardgameio/ [b8]: https://img.shields.io/github/stars/PJohannessen/four-in-a-row?label=%E2%98%85&logo=github #### [![GitHub stars][b9]][c9] [FreeBoardGames.org][p9] \[[code][c9]\]    PWA framework for publishing board games. [p9]: https://www.freeboardgames.org [c9]: https://github.com/freeboardgames/FreeBoardGames.org [b9]: https://img.shields.io/github/stars/freeboardgames/FreeBoardGames.org?label=%E2%98%85&logo=github #### [![GitHub stars][b28]][c28] [Garden][p28] \[[code][c28]\]    A single-player puzzle game. [p28]: https://0x682.itch.io/garden [c28]: https://github.com/steambap/garden [b28]: https://img.shields.io/github/stars/steambap/garden?label=%E2%98%85&logo=github #### [2048 Game][c27]    The classic 2048 puzzle game. Implemented using React and Greensock. [c27]: https://2048-online.io/ #### [![GitHub stars][b10]][c10] [Hex game][p10] \[[code][c10]\]    Simple hexagonal board game. [p10]: https://korla.github.io/hexgame/build/ [c10]: https://github.com/Korla/hexgame [b10]: https://img.shields.io/github/stars/Korla/hexgame?label=%E2%98%85&logo=github #### [![GitHub stars][b-lhog]][c-lhog] [Lewis' House of Games][p-lhog] \[[code][c-lhog]\]    Lobby framework for boardgame.io games. Play Splendor or Powergrid clones here. [p-lhog]: https://lhog.lewissilletto.com/ [c-lhog]: https://github.com/sillle14/lhog [b-lhog]: https://img.shields.io/github/stars/sillle14/lhog?label=%E2%98%85&logo=github #### [![GitHub stars][b11]][c11] [Matchimals.fun][p11] \[[code][c11]\]    An animal matching puzzle card game. [p11]: https://www.matchimals.fun/ [c11]: https://github.com/chrisheninger/matchimals.fun [b11]: https://img.shields.io/github/stars/chrisheninger/matchimals.fun?label=%E2%98%85&logo=github #### [![GitHub stars][b12]][c12] [Mosaic Multiplayer][p12] \[[code][c12]\]    Azul board game clone you can play online with friends. [p12]: https://playmosaic.online/ [c12]: https://github.com/maciejmatu/mosaic [b12]: https://img.shields.io/github/stars/maciejmatu/mosaic?label=%E2%98%85&logo=github #### [![GitHub stars][b13]][c13] [Multibuzzer][p13] \[[code][c13]\]    Simple multiplayer buzzer system for trivia night or quiz bowl. [p13]: https://multibuzz.app [c13]: https://github.com/wsun/multibuzzer [b13]: https://img.shields.io/github/stars/wsun/multibuzzer?label=%E2%98%85&logo=github #### [![GitHub stars][b14]][c14] [Pong420's Boardgame][p14] \[[code][c14]\]    A project for building board games with React and boardgame.io. [p14]: http://play-boardgame.herokuapp.com [c14]: https://github.com/Pong420/Boardgame [b14]: https://img.shields.io/github/stars/Pong420/Boardgame?label=%E2%98%85&logo=github #### [![GitHub stars][b25]][c25] [Santorini][p25] \[[code][c25]\]    Multiplayer online boardgame with 3D board using three.js. [p25]: https://santorini.onrender.com [c25]: https://github.com/mbrinkl/santorini [b25]: https://img.shields.io/github/stars/mbrinkl/santorini?label=%E2%98%85&logo=github #### [![GitHub stars][bsixpieces]][csixpieces] [SixPieces][psixpieces] \[[code][csixpieces]\]    A 3-D online version of the tile-based boardgame "Qwirkle" for two to four players. [psixpieces]: https://zwo.uber.space/SixPieces/ [csixpieces]: https://github.com/fuenfundachtzig/SixPieces [bsixpieces]: https://img.shields.io/github/stars/fuenfundachtzig/SixPieces?label=%E2%98%85&logo=github #### [Splendor][p15]    A minimal splendor game you can play with up to 4 players. [p15]: http://bestboards.ir #### [Steel Civilizations][p16]    Turn-based mobile strategy game for Android with real-time online multiplayer ranking ladder system. [p16]: https://play.google.com/store/apps/details?id=com.hydra.steelcivs #### [![GitHub stars][b17]][c17] [Territories][p17] \[[code][c17]\]    Simple board game Territories. [p17]: https://lehasvv2009.github.io/territories/ [c17]: https://github.com/lehaSVV2009/territories [b17]: https://img.shields.io/github/stars/lehaSVV2009/territories?label=%E2%98%85&logo=github #### [![GitHub stars][b18]][c18] [Thinktank][p18] \[[code][c18]\]    A 2-player strategy game inspired by Conundrum. [p18]: https://thinktank.crespi.dev [c18]: https://github.com/averycrespi/thinktank [b18]: https://img.shields.io/github/stars/averycrespi/thinktank?label=%E2%98%85&logo=github #### [![GitHub stars][b19]][c19] [Tiến Lên][p19] \[[code][c19]\]    The 4-player Vietnamese game that uses a standard 52-card deck, in English, with online multiplayer. [p19]: http://tienlen-en.herokuapp.com/ [c19]: https://github.com/nguyenank/tien-len [b19]: https://img.shields.io/github/stars/nguyenank/tien-len?label=%E2%98%85&logo=github #### [![GitHub stars][b20]][c20] [Udaipur][p20] \[[code][c20]\]    Clone of Jaipur, a 2 Player Card game, with online multiplayer support. [p20]: https://udaipur-game.herokuapp.com/ [c20]: https://github.com/skvrahul/UdaipurGame [b20]:https://img.shields.io/github/stars/skvrahul/UdaipurGame?label=%E2%98%85&logo=github #### [![GitHub stars][b24]][c24] [Unmuted: 2021][p24] \[[code][c24]\]    A single-player deckbuilder game. [p24]: https://shaoster.github.io/unmuted2021 [c24]: https://github.com/shaoster/unmuted2021 [b24]: https://img.shields.io/github/stars/shaoster/unmuted2021?label=%E2%98%85&logo=github #### [![GitHub stars][b23]][c23] [Unstable Unicorns][p23] \[[code][c23]\]    Online game variant of the popular card game Unstable Unicorns 🦄. Playable with your friends. [p23]: https://unstable-unicorns-online.herokuapp.com/hello-world/6/0 [c23]: https://github.com/geniegeist/unstable-unicorns [b23]: https://img.shields.io/github/stars/geniegeist/unstable-unicorns?label=%E2%98%85&logo=github #### [![GitHub stars][b22]][c22] [Yatzy][p22] \[[code][c22] | [tutorial][t22]\]    A 1-4 player dice game played locally. [p22]: https://www.lonesomecrowdedweb.com/yatzy/ [c22]: https://github.com/pjohannessen/yatzy [t22]: https://www.lonesomecrowdedweb.com/blog/yatzy-boardgameio/ [b22]: https://img.shields.io/github/stars/pjohannessen/yatzy?label=%E2%98%85&logo=github #### [![GitHub stars][b-wd]][c-wd] [Wizard Duel][p-wd] \[[code][c-wd]\]    A single-player battle card game featuring epic fantasy card art and sound effects. [b-wd]: https://img.shields.io/github/stars/ruichen199801/wizard-duel?label=%E2%98%85&logo=github [p-wd]: https://wizard-duel-ten.vercel.app/ [c-wd]: https://github.com/ruichen199801/wizard-duel ================================================ FILE: docs/documentation/phases.md ================================================ # Phases Most games beyond very simple ones tend to have different behaviors at various phases. A game might have a phase at the beginning where players are drafting cards before entering a playing phase, for example. Each phase in [boardgame.io](https://boardgame.io/) defines a set of game configuration options that are applied for the duration of that phase. This includes the ability to define a different set of moves, use a different turn order etc. Turns happen inside phases. ### Card Game Let us start with a contrived example of a game that has exactly two moves: - draw a card from the deck into your hand. - play a card from your hand onto the deck. ```js function DrawCard({ G, playerID }) { G.deck--; G.hand[playerID]++; } function PlayCard({ G, playerID }) { G.deck++; G.hand[playerID]--; } const game = { setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), moves: { DrawCard, PlayCard }, turn: { minMoves: 1, maxMoves: 1 }, }; ``` ?> Notice how we moved the moves out into standalone functions instead of inlining them in the game object. We'll ignore the rendering part of this game, but this is how it might look. Note that you can draw or play a card at any time, including taking a card when the deck is empty. ```react ``` ### Phases Now let's say we want the game to work in two phases: - a first phase where the players only draw cards (until the deck is empty). - a second phase where the players only play cards. In order to do this, we define two `phases`. Each phase can specify its own list of moves, which come into effect during that phase: ```js const game = { setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), turn: { minMoves: 1, maxMoves: 1 }, phases: { draw: { moves: { DrawCard }, }, play: { moves: { PlayCard }, }, }, }; ``` !> A phase that doesn't specify any moves just uses moves from the main `moves` section in the game. However, if it does, then the `moves` section in the phase overrides the global one. The game doesn't begin in any of these phases. In order to begin in the "draw" phase, we add a `start: true` to its config. Only one phase can have `start: true`. ```js phases: { draw: { moves: { DrawCard }, + start: true, }, play: { moves: { PlayCard }, }, } ``` Let's also end the "draw" phase automatically once the deck is empty. ```js phases: { draw: { moves: { DrawCard }, + endIf: ({ G }) => (G.deck <= 0), + next: 'play', start: true, }, play: { moves: { PlayCard }, }, } ``` `endIf` ends the phase that it is defined in when it returns `true`. The game is returned to a state where no phase is active. However, for this game, we want to move to the "play" phase once the "draw" phase is done. We specify a `next` option for this, which tells the framework to go to that phase. Watch our game in action (now with phases). Notice that you can only draw cards in the first phase, and you can only play cards in the second phase. ```react ``` ### Setup and Cleanup hooks You can also run code automatically at the beginning or end of a phase. These are specified just like normal moves in `onBegin` and `onEnd`. ```js phases: { phaseA: { onBegin: ({ G, ctx }) => { ... }, onEnd: ({ G, ctx }) => { ... }, }, }; ``` ?> Hooks like `onBegin` and `onEnd` are run only on the server in multiplayer games. Moves, on the other hand, run on both client and server. They are run on the client in order to facilitate a lag-free experience, and are run on the server to calculate the authoritative game state. ### Moving between Phases #### Using events The two primary ways of moving between phases are by calling the following events: 1. `endPhase`: This ends the current phase and returns the game to a state where no phase is active. If the phase specifies a `next` option, then the game will move into that phase instead. 2. `setPhase`: This ends the current phase and moves the game into the phase specified by the argument. #### Using an `endIf` condition You can also end a phase by returning a truthy value from its `endIf` method: ```js phases: { phaseA: { next: 'phaseB', endIf: ({ G, ctx }) => true, }, phaseB: { ... }, }, ``` !> Whenever a phase ends, the current player's turn is first ended automatically. ### Setting the next phase dynamically Instead of setting a phase’s `next` option with a string, you can provide a function that will return the next phase based on game state at the end of the phase: ```js phases: { phaseA: { next: ({ G }) => { return G.condition ? 'phaseC' : 'phaseB'; }, }, phaseB: { ... }, phaseC: { ... }, }, ``` ### Override Behavior As observed above, a phase can specify its own `moves` section which comes into effect when the phase is active. This `moves` section completely replaces the global `moves` section for the duration of the phase. The moves may have the same name as their global equivalents, but they are not related to them in any way. A phase can similarly also override the `turn` section. You will typically do this if you want to use a different [Turn Order](turn-order.md) during the phase. ================================================ FILE: docs/documentation/plugins.md ================================================ # Plugins The Plugin API allows you to create objects that expose custom functionality to [boardgame.io](https://boardgame.io/). You can create wrappers around moves, add API's to `ctx` etc. ### Creating a Plugin A plugin is an object that contains the following fields. ```js { // Required. name: 'plugin-name', // Initialize the plugin's data. // This is stored in a special area of the state object // and not exposed to the move functions. setup: ({ G, ctx, game }) => data object, // Create an object that becomes available in `ctx` // under `ctx['plugin-name']`. // This is called at the beginning of a move or event. // This object will be held in memory until flush (below) // is called. api: ({ G, ctx, game, data, playerID }) => api object, // Return an updated version of data that is persisted // in the game's state object. flush: ({ G, ctx, game, data, api }) => data object, // Function that accepts a move / trigger function // and returns another function that wraps it. This // wrapper can modify G before passing it down to // the wrapped function. It is a good practice to // undo the change at the end of the call. // `fnType` gives the type of hook being wrapped // and will be one of the `GameMethod` values — // import { GameMethod } from 'boardgame.io/core' fnWrap: (fn, fnType) => ({ G, ...rest }, ...args) => { G = preprocess(G); G = fn({ G, ...rest }, ...args); if (fnType === GameMethod.TURN_ON_END) { // only run when wrapping a turn’s onEnd function } G = postprocess(G); return G; }, // Function that allows the plugin to indicate that it // should not be run on the client. If it returns true, // the client will discard the state update and wait // for the master instead. noClient: ({ G, ctx, game, data, api }) => boolean, // Function that allows the plugin to indicate that the // current action should be declared invalid and cancelled. // If `isInvalid` returns an error message, the whole update // will be abandoned and an error returned to the client. isInvalid: ({ G, ctx, game, data, api }) => false | string, // Function that can filter `data` to hide secret state // before sending it to a specific client. // `playerID` could also be null or undefined for spectators. playerView: ({ G, ctx, game, data, playerID }) => filtered data object, } ``` ### Adding Plugins to Games The list of plugins is specified in the game spec. ```js import { PluginA, PluginB } from 'boardgame.io/plugins'; const game = { name: 'my-game', plugins: [PluginA, PluginB], // ... }; ``` ?> Plugins are applied one after the other in the order that they are specified (from left to right). ### Configuring Plugins Some plugins may need a user to provide some configuration. The recommended way to do that is to design the plugin as a factory function that takes configuration as its arguments and returns a plugin object. ```js import { ConfigurablePlugin } from './plugins'; const game = { name: 'my-game', plugins: [ ConfigurablePlugin(options), ], } ``` ?> See `PluginPlayer` below for an example of this in practice. ### Available Plugins #### PluginPlayer ```js import { PluginPlayer } from 'boardgame.io/plugins'; // define a function to initialize each player’s state const playerSetup = (playerID) => ({ ... }); // filter data returned to each client to hide secret state (OPTIONAL) const playerView = (players, playerID) => ({ [playerID]: players[playerID], }); const game = { plugins: [ // pass your function to the player plugin PluginPlayer({ setup: playerSetup, playerView: playerView, }), ], }; ``` `PluginPlayer` makes it easy to manage player state. It creates an object `players` that stores state for individual players. This object is stored in the plugin's private storage area: ``` players: { '0': { ... }, '1': { ... }, '2': { ... }, ... } ``` The initial values of these states are determined by the `setup` function in its options object, which creates the state for a particular `playerID`. The record associated with the current player can be accessed via `ctx.player.get()`. If this is a 2 player game, then the opponent's record is available using `ctx.player.opponent.get()`. These fields can be modified using their corresponding `set()` versions. ```js ctx.player.get() // Get the current player's record. ctx.player.set() // Update the current player's record. ctx.player.opponent.get() // Get the opponent player's record. ctx.player.opponent.set() // Update the opponent player's record. ``` ================================================ FILE: docs/documentation/random.md ================================================ # Randomness Many games allow moves whose outcome depends on shuffled cards or rolled dice. Take e.g. the game [Yahtzee](https://en.wikipedia.org/wiki/Yahtzee). A player rolls dice, chooses some, rolls another time, chooses some more, and does a final dice roll. Depending on the face-up sides the player now must choose where they will score. This poses interesting challenges regarding the implementation. - **AI**. Randomness makes games interesting since you cannot predict the future, but it needs to be controlled in order for allowing games that can be replayed exactly (e.g. for AI purposes). - **PRNG State**. The game runs on both the server and client. All code and data on the client can be viewed and used to a player's advantage. If a client could predict the next random numbers that are to be generated, the future flow of a game stops being unpredictable. The library must not allow such a scenario. The RNG and its state must stay on the server. - **Pure Functions**. The library is built using Redux. This is important for games since each move is a [reducer](https://redux.js.org/docs/basics/Reducers.html), and thus must be pure. Calling `Math.random()` and other functions that maintain external state would make the game logic impure and not idempotent. ### Using Randomness in Games The object passed to moves and other game logic contains an object `random`, which exposes a range of functions for generating randomness. For example, the `random.D6` function is similar to rolling six-sided dice: ```js { moves: { rollDie: ({ G, random }) => { G.dieRoll = random.D6(); // dieRoll = 1–6 }, rollThreeDice: ({ G, random }) => { G.diceRoll = random.D6(3); // diceRoll = [1–6, 1–6, 1–6] } }, } ``` You can see details for all the available random functions below. ### Seed You can set the initial `seed` used for the random number generator on your game object: ```js const game = { seed: 42, // ... }; ``` ?> `seed` can be either a string or a number. ## API Reference ### 1. Die #### Arguments 1. `spotvalue` (_number_): The die dimension (_default: 6_). 2. `diceCount` (_number_): The number of dice to throw. #### Returns The die roll value (or an array of values if `diceCount` is greater than `1`). #### Usage ```js const game = { moves: { move({ random }) { const die = random.Die(6); // die = 1-6 const dice = random.Die(6, 3); // dice = [1-6, 1-6, 1-6] }, } }; ``` ### 2. Number Returns a random number between `0` and `1`. #### Usage ```js const game = { moves: { move({ random }) { const n = random.Number(); }, } }; ``` ### 3. Shuffle #### Arguments 1. `deck` (_array_): An array to shuffle. #### Returns The shuffled array. #### Usage ```js const game = { moves: { move({ G, random }) { G.deck = random.Shuffle(G.deck); }, }, }; ``` ### 4. Wrappers `D4`, `D6`, `D8`, `D10`, `D12` and `D20` are wrappers around `Die(n)`. #### Arguments 1. `diceCount` (_number_): The number of dice to throw. #### Usage ```js const game = { moves: { move({ random }) { const die = random.D6(); }, } }; ``` ================================================ FILE: docs/documentation/secret-state.md ================================================ # Secret State In some games you might need to hide information from players or spectators. For example, you might not want to reveal the hands of opponents in card games. This is easily accomplished at the UI layer (by not rendering secret information), but the framework also provides support for not even sending such data to the client. In order to do this, use the `playerView` setting in the game object. It accepts a function that receives an object containing `G`, `ctx`, and `playerID`, and returns a version of `G` that is stripped of any information that should be hidden from that specific player. ```js const game = { // `playerID` could also be null or undefined for spectators. playerView: ({ G, ctx, playerID }) => { return StripSecrets(G, playerID); }, // ... }; ``` !> Make sure that you associate the game clients with individual players (as discussed in the [Multiplayer](multiplayer.md) section). ### PlayerView.STRIP_SECRETS The framework comes bundled with an implementation of `playerView` that does the following: - It removes a key named `secret` from `G`. - If `G` contains a `players` object, it removes all keys except for the one that matches `playerID`. ```js G: { secret: { ... }, players: { '0': { ... }, '1': { ... }, '2': { ... }, } } ``` becomes the following for player `1`: ```js G: { players: { '1': { ... }, } } ``` Usage: ```js import { PlayerView } from 'boardgame.io/core'; const game = { // ... playerView: PlayerView.STRIP_SECRETS, }; ``` ### Disabling moves that manipulate secret state on the client Moves that manipulate secret state often cannot run on the client because the client doesn't have all the necessary data to process such moves. These can be marked as server-only by setting `client: false` on move: ```js moves: { moveThatUsesSecret: { move: ({ G, random }) => { G.secret.value = random.Number(); }, client: false, } } ``` ================================================ FILE: docs/documentation/sidebar.md ================================================ - **Getting Started** - [Concepts](/) - [Tutorial](tutorial.md) - **Guides** - [Multiplayer](multiplayer.md) - [Turn Order](turn-order.md) - [Phases](phases.md) - [Stages](stages.md) - [Events](events.md) - [Undo / Redo](undo.md) - [Randomness](random.md) - [Secret State](secret-state.md) - [Immutability](immutability.md) - [Plugins](plugins.md) - [Debugging](debugging.md) - [Testing](testing.md) - [Deployment](deployment.md) - [Storage](storage.md) - [Chat](chat.md) - [TypeScript](typescript.md) - **Reference** - [Game](api/Game.md) - [Client](api/Client.md) - [Server](api/Server.md) - [Lobby](api/Lobby.md) - **More** - [Changelog](/CHANGELOG.md) - [Projects](/notable_projects.md) ================================================ FILE: docs/documentation/snippets/example-1/index.html ================================================
interactive (not an image)
================================================ FILE: docs/documentation/snippets/example-1.c952ec6d.js ================================================ parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;function f(t,n){if(!r[t]){if(!e[t]){var i="function"==typeof parcelRequire&&parcelRequire;if(!n&&i)return i(t,!0);if(o)return o(t,!0);if(u&&"string"==typeof t)return u(t);var c=new Error("Cannot find module '"+t+"'");throw c.code="MODULE_NOT_FOUND",c}p.resolve=function(r){return e[t][1][r]||r},p.cache={};var l=r[t]=new f.Module(t);e[t][0].call(l.exports,p,l,l.exports,this)}return r[t].exports;function p(e){return f(p.resolve(e))}}f.isParcelRequire=!0,f.Module=function(e){this.id=e,this.bundle=f,this.exports={}},f.modules=e,f.cache=r,f.parent=o,f.register=function(r,t){e[r]=[function(e,r){r.exports=t},{}]};for(var c=0;cP.length&&P.push(e)}function A(e,r,o,u){var f=typeof e;"undefined"!==f&&"boolean"!==f||(e=null);var c=!1;if(null===e)c=!0;else switch(f){case"string":case"number":c=!0;break;case"object":switch(e.$$typeof){case t:case n:c=!0}}if(c)return o(u,e,""===r?"."+U(e,0):r),1;if(c=0,r=""===r?".":r+":",Array.isArray(e))for(var l=0;l=y},o=function(){},exports.unstable_forceFrameRate=function(e){0>e||125>>1,o=e[r];if(!(void 0!==o&&0P(l,t))void 0!==u&&0>P(u,l)?(e[r]=u,e[i]=t,r=i):(e[r]=l,e[a]=t,r=a);else{if(!(void 0!==u&&0>P(u,t)))break e;e[r]=u,e[i]=t,r=i}}}return n}return null}function P(e,n){var t=e.sortIndex-n.sortIndex;return 0!==t?t:e.id-n.id}var F=[],I=[],M=1,C=null,A=3,L=!1,q=!1,D=!1;function R(e){for(var n=T(I);null!==n;){if(null===n.callback)g(I);else{if(!(n.startTime<=e))break;g(I),n.sortIndex=n.expirationTime,k(F,n)}n=T(I)}}function j(t){if(D=!1,R(t),!q)if(null!==T(F))q=!0,e(E);else{var r=T(I);null!==r&&n(j,r.startTime-t)}}function E(e,o){q=!1,D&&(D=!1,t()),L=!0;var a=A;try{for(R(o),C=T(F);null!==C&&(!(C.expirationTime>o)||e&&!r());){var l=C.callback;if(null!==l){C.callback=null,A=C.priorityLevel;var i=l(C.expirationTime<=o);o=exports.unstable_now(),"function"==typeof i?C.callback=i:C===T(F)&&g(F),R(o)}else g(F);C=T(F)}if(null!==C)var u=!0;else{var s=T(I);null!==s&&n(j,s.startTime-o),u=!1}return u}finally{C=null,A=a,L=!1}}function N(e){switch(e){case 1:return-1;case 2:return 250;case 5:return 1073741823;case 4:return 1e4;default:return 5e3}}var B=o;exports.unstable_IdlePriority=5,exports.unstable_ImmediatePriority=1,exports.unstable_LowPriority=4,exports.unstable_NormalPriority=3,exports.unstable_Profiling=null,exports.unstable_UserBlockingPriority=2,exports.unstable_cancelCallback=function(e){e.callback=null},exports.unstable_continueExecution=function(){q||L||(q=!0,e(E))},exports.unstable_getCurrentPriorityLevel=function(){return A},exports.unstable_getFirstCallbackNode=function(){return T(F)},exports.unstable_next=function(e){switch(A){case 1:case 2:case 3:var n=3;break;default:n=A}var t=A;A=n;try{return e()}finally{A=t}},exports.unstable_pauseExecution=function(){},exports.unstable_requestPaint=B,exports.unstable_runWithPriority=function(e,n){switch(e){case 1:case 2:case 3:case 4:case 5:break;default:e=3}var t=A;A=e;try{return n()}finally{A=t}},exports.unstable_scheduleCallback=function(r,o,a){var l=exports.unstable_now();if("object"==typeof a&&null!==a){var i=a.delay;i="number"==typeof i&&0l?(r.sortIndex=i,k(I,r),null===T(F)&&r===T(I)&&(D?t():D=!0,n(j,i-l))):(r.sortIndex=a,k(F,r),q||L||(q=!0,e(E))),r},exports.unstable_shouldYield=function(){var e=exports.unstable_now();R(e);var n=T(F);return n!==C&&null!==C&&null!==n&&null!==n.callback&&n.startTime<=e&&n.expirationTimet}return!1}function $(e,t,n,r,l,i){this.acceptsBooleans=2===t||3===t||4===t,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i}var q={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){q[e]=new $(e,0,!1,e,null,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];q[t]=new $(t,1,!1,e[1],null,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){q[e]=new $(e,2,!1,e.toLowerCase(),null,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){q[e]=new $(e,2,!1,e,null,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){q[e]=new $(e,3,!1,e.toLowerCase(),null,!1)}),["checked","multiple","muted","selected"].forEach(function(e){q[e]=new $(e,3,!0,e,null,!1)}),["capture","download"].forEach(function(e){q[e]=new $(e,4,!1,e,null,!1)}),["cols","rows","size","span"].forEach(function(e){q[e]=new $(e,6,!1,e,null,!1)}),["rowSpan","start"].forEach(function(e){q[e]=new $(e,5,!1,e.toLowerCase(),null,!1)});var Y=/[\-:]([a-z])/g;function X(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Y,X);q[t]=new $(t,1,!1,e,null,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Y,X);q[t]=new $(t,1,!1,e,"http://www.w3.org/1999/xlink",!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Y,X);q[t]=new $(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1)}),["tabIndex","crossOrigin"].forEach(function(e){q[e]=new $(e,1,!1,e.toLowerCase(),null,!1)}),q.xlinkHref=new $("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0),["src","href","action","formAction"].forEach(function(e){q[e]=new $(e,1,!1,e.toLowerCase(),null,!0)});var G=e.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;function Z(e,t,n,r){var l=q.hasOwnProperty(t)?q[t]:null;(null!==l?0===l.type:!r&&(2=n.length))throw Error(r(93));n=n[0]}t=n}null==t&&(t=""),n=t}e._wrapperState={initialValue:we(n)}}function De(e,t){var n=we(t.value),r=we(t.defaultValue);null!=n&&((n=""+n)!==e.value&&(e.value=n),null==t.defaultValue&&e.defaultValue!==n&&(e.defaultValue=n)),null!=r&&(e.defaultValue=""+r)}function Le(e){var t=e.textContent;t===e._wrapperState.initialValue&&""!==t&&null!==t&&(e.value=t)}var Ue={html:"http://www.w3.org/1999/xhtml",mathml:"http://www.w3.org/1998/Math/MathML",svg:"http://www.w3.org/2000/svg"};function Ae(e){switch(e){case"svg":return"http://www.w3.org/2000/svg";case"math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}function Ve(e,t){return null==e||"http://www.w3.org/1999/xhtml"===e?Ae(t):"http://www.w3.org/2000/svg"===e&&"foreignObject"===t?"http://www.w3.org/1999/xhtml":e}var Qe,We=function(e){return"undefined"!=typeof MSApp&&MSApp.execUnsafeLocalFunction?function(t,n,r,l){MSApp.execUnsafeLocalFunction(function(){return e(t,n)})}:e}(function(e,t){if(e.namespaceURI!==Ue.svg||"innerHTML"in e)e.innerHTML=t;else{for((Qe=Qe||document.createElement("div")).innerHTML=""+t.valueOf().toString()+"",t=Qe.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function He(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&3===n.nodeType)return void(n.nodeValue=t)}e.textContent=t}function je(e,t){var n={};return n[e.toLowerCase()]=t.toLowerCase(),n["Webkit"+e]="webkit"+t,n["Moz"+e]="moz"+t,n}var Be={animationend:je("Animation","AnimationEnd"),animationiteration:je("Animation","AnimationIteration"),animationstart:je("Animation","AnimationStart"),transitionend:je("Transition","TransitionEnd")},Ke={},$e={};function qe(e){if(Ke[e])return Ke[e];if(!Be[e])return e;var t,n=Be[e];for(t in n)if(n.hasOwnProperty(t)&&t in $e)return Ke[e]=n[t];return e}S&&($e=document.createElement("div").style,"AnimationEvent"in window||(delete Be.animationend.animation,delete Be.animationiteration.animation,delete Be.animationstart.animation),"TransitionEvent"in window||delete Be.transitionend.transition);var Ye=qe("animationend"),Xe=qe("animationiteration"),Ge=qe("animationstart"),Ze=qe("transitionend"),Je="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),et=new("function"==typeof WeakMap?WeakMap:Map);function tt(e){var t=et.get(e);return void 0===t&&(t=new Map,et.set(e,t)),t}function nt(e){var t=e,n=e;if(e.alternate)for(;t.return;)t=t.return;else{e=t;do{0!=(1026&(t=e).effectTag)&&(n=t.return),e=t.return}while(e)}return 3===t.tag?n:null}function rt(e){if(13===e.tag){var t=e.memoizedState;if(null===t&&(null!==(e=e.alternate)&&(t=e.memoizedState)),null!==t)return t.dehydrated}return null}function lt(e){if(nt(e)!==e)throw Error(r(188))}function it(e){var t=e.alternate;if(!t){if(null===(t=nt(e)))throw Error(r(188));return t!==e?null:e}for(var n=e,l=t;;){var i=n.return;if(null===i)break;var a=i.alternate;if(null===a){if(null!==(l=i.return)){n=l;continue}break}if(i.child===a.child){for(a=i.child;a;){if(a===n)return lt(i),e;if(a===l)return lt(i),t;a=a.sibling}throw Error(r(188))}if(n.return!==l.return)n=i,l=a;else{for(var o=!1,u=i.child;u;){if(u===n){o=!0,n=i,l=a;break}if(u===l){o=!0,l=i,n=a;break}u=u.sibling}if(!o){for(u=a.child;u;){if(u===n){o=!0,n=a,l=i;break}if(u===l){o=!0,l=a,n=i;break}u=u.sibling}if(!o)throw Error(r(189))}}if(n.alternate!==l)throw Error(r(190))}if(3!==n.tag)throw Error(r(188));return n.stateNode.current===n?e:t}function at(e){if(!(e=it(e)))return null;for(var t=e;;){if(5===t.tag||6===t.tag)return t;if(t.child)t.child.return=t,t=t.child;else{if(t===e)break;for(;!t.sibling;){if(!t.return||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}}return null}function ot(e,t){if(null==t)throw Error(r(30));return null==e?t:Array.isArray(e)?Array.isArray(t)?(e.push.apply(e,t),e):(e.push(t),e):Array.isArray(t)?[e].concat(t):[e,t]}function ut(e,t,n){Array.isArray(e)?e.forEach(t,n):e&&t.call(n,e)}var ct=null;function st(e){if(e){var t=e._dispatchListeners,n=e._dispatchInstances;if(Array.isArray(t))for(var r=0;rmt.length&&mt.push(e)}function gt(e,t,n,r){if(mt.length){var l=mt.pop();return l.topLevelType=e,l.eventSystemFlags=r,l.nativeEvent=t,l.targetInst=n,l}return{topLevelType:e,eventSystemFlags:r,nativeEvent:t,targetInst:n,ancestors:[]}}function vt(e){var t=e.targetInst,n=t;do{if(!n){e.ancestors.push(n);break}var r=n;if(3===r.tag)r=r.stateNode.containerInfo;else{for(;r.return;)r=r.return;r=3!==r.tag?null:r.stateNode.containerInfo}if(!r)break;5!==(t=n.tag)&&6!==t||e.ancestors.push(n),n=Un(r)}while(n);for(n=0;n=t)return{node:r,offset:t-e};e=n}e:{for(;r;){if(r.nextSibling){r=r.nextSibling;break e}r=r.parentNode}r=void 0}r=vn(r)}}function bn(e,t){return!(!e||!t)&&(e===t||(!e||3!==e.nodeType)&&(t&&3===t.nodeType?bn(e,t.parentNode):"contains"in e?e.contains(t):!!e.compareDocumentPosition&&!!(16&e.compareDocumentPosition(t))))}function wn(){for(var e=window,t=gn();t instanceof e.HTMLIFrameElement;){try{var n="string"==typeof t.contentWindow.location.href}catch(r){n=!1}if(!n)break;t=gn((e=t.contentWindow).document)}return t}function kn(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&("input"===t&&("text"===e.type||"search"===e.type||"tel"===e.type||"url"===e.type||"password"===e.type)||"textarea"===t||"true"===e.contentEditable)}var xn="$",Tn="/$",En="$?",Sn="$!",Cn=null,Pn=null;function _n(e,t){switch(e){case"button":case"input":case"select":case"textarea":return!!t.autoFocus}return!1}function Nn(e,t){return"textarea"===e||"option"===e||"noscript"===e||"string"==typeof t.children||"number"==typeof t.children||"object"==typeof t.dangerouslySetInnerHTML&&null!==t.dangerouslySetInnerHTML&&null!=t.dangerouslySetInnerHTML.__html}var zn="function"==typeof setTimeout?setTimeout:void 0,Mn="function"==typeof clearTimeout?clearTimeout:void 0;function In(e){for(;null!=e;e=e.nextSibling){var t=e.nodeType;if(1===t||3===t)break}return e}function Fn(e){e=e.previousSibling;for(var t=0;e;){if(8===e.nodeType){var n=e.data;if(n===xn||n===Sn||n===En){if(0===t)return e;t--}else n===Tn&&t++}e=e.previousSibling}return null}var On=Math.random().toString(36).slice(2),Rn="__reactInternalInstance$"+On,Dn="__reactEventHandlers$"+On,Ln="__reactContainere$"+On;function Un(e){var t=e[Rn];if(t)return t;for(var n=e.parentNode;n;){if(t=n[Ln]||n[Rn]){if(n=t.alternate,null!==t.child||null!==n&&null!==n.child)for(e=Fn(e);null!==e;){if(n=e[Rn])return n;e=Fn(e)}return t}n=(e=n).parentNode}return null}function An(e){return!(e=e[Rn]||e[Ln])||5!==e.tag&&6!==e.tag&&13!==e.tag&&3!==e.tag?null:e}function Vn(e){if(5===e.tag||6===e.tag)return e.stateNode;throw Error(r(33))}function Qn(e){return e[Dn]||null}function Wn(e){do{e=e.return}while(e&&5!==e.tag);return e||null}function Hn(e,t){var n=e.stateNode;if(!n)return null;var l=d(n);if(!l)return null;n=l[t];e:switch(t){case"onClick":case"onClickCapture":case"onDoubleClick":case"onDoubleClickCapture":case"onMouseDown":case"onMouseDownCapture":case"onMouseMove":case"onMouseMoveCapture":case"onMouseUp":case"onMouseUpCapture":case"onMouseEnter":(l=!l.disabled)||(l=!("button"===(e=e.type)||"input"===e||"select"===e||"textarea"===e)),e=!l;break e;default:e=!1}if(e)return null;if(n&&"function"!=typeof n)throw Error(r(231,t,typeof n));return n}function jn(e,t,n){(t=Hn(e,n.dispatchConfig.phasedRegistrationNames[t]))&&(n._dispatchListeners=ot(n._dispatchListeners,t),n._dispatchInstances=ot(n._dispatchInstances,e))}function Bn(e){if(e&&e.dispatchConfig.phasedRegistrationNames){for(var t=e._targetInst,n=[];t;)n.push(t),t=Wn(t);for(t=n.length;0this.eventPool.length&&this.eventPool.push(e)}function lr(e){e.eventPool=[],e.getPooled=nr,e.release=rr}t(tr.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():"unknown"!=typeof e.returnValue&&(e.returnValue=!1),this.isDefaultPrevented=Jn)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():"unknown"!=typeof e.cancelBubble&&(e.cancelBubble=!0),this.isPropagationStopped=Jn)},persist:function(){this.isPersistent=Jn},isPersistent:er,destructor:function(){var e,t=this.constructor.Interface;for(e in t)this[e]=null;this.nativeEvent=this._targetInst=this.dispatchConfig=null,this.isPropagationStopped=this.isDefaultPrevented=er,this._dispatchInstances=this._dispatchListeners=null}}),tr.Interface={type:null,target:null,currentTarget:function(){return null},eventPhase:null,bubbles:null,cancelable:null,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:null,isTrusted:null},tr.extend=function(e){function n(){}function r(){return l.apply(this,arguments)}var l=this;n.prototype=l.prototype;var i=new n;return t(i,r.prototype),r.prototype=i,r.prototype.constructor=r,r.Interface=t({},l.Interface,e),r.extend=l.extend,lr(r),r},lr(tr);var ir=tr.extend({data:null}),ar=tr.extend({data:null}),or=[9,13,27,32],ur=S&&"CompositionEvent"in window,cr=null;S&&"documentMode"in document&&(cr=document.documentMode);var sr=S&&"TextEvent"in window&&!cr,fr=S&&(!ur||cr&&8=cr),dr=String.fromCharCode(32),pr={beforeInput:{phasedRegistrationNames:{bubbled:"onBeforeInput",captured:"onBeforeInputCapture"},dependencies:["compositionend","keypress","textInput","paste"]},compositionEnd:{phasedRegistrationNames:{bubbled:"onCompositionEnd",captured:"onCompositionEndCapture"},dependencies:"blur compositionend keydown keypress keyup mousedown".split(" ")},compositionStart:{phasedRegistrationNames:{bubbled:"onCompositionStart",captured:"onCompositionStartCapture"},dependencies:"blur compositionstart keydown keypress keyup mousedown".split(" ")},compositionUpdate:{phasedRegistrationNames:{bubbled:"onCompositionUpdate",captured:"onCompositionUpdateCapture"},dependencies:"blur compositionupdate keydown keypress keyup mousedown".split(" ")}},mr=!1;function hr(e,t){switch(e){case"keyup":return-1!==or.indexOf(t.keyCode);case"keydown":return 229!==t.keyCode;case"keypress":case"mousedown":case"blur":return!0;default:return!1}}function gr(e){return"object"==typeof(e=e.detail)&&"data"in e?e.data:null}var vr=!1;function yr(e,t){switch(e){case"compositionend":return gr(t);case"keypress":return 32!==t.which?null:(mr=!0,dr);case"textInput":return(e=t.data)===dr&&mr?null:e;default:return null}}function br(e,t){if(vr)return"compositionend"===e||!ur&&hr(e,t)?(e=Zn(),Gn=Xn=Yn=null,vr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=document.documentMode,tl={select:{phasedRegistrationNames:{bubbled:"onSelect",captured:"onSelectCapture"},dependencies:"blur contextmenu dragend focus keydown keyup mousedown mouseup selectionchange".split(" ")}},nl=null,rl=null,ll=null,il=!1;function al(e,t){var n=t.window===t?t.document:9===t.nodeType?t:t.ownerDocument;return il||null==nl||nl!==gn(n)?null:("selectionStart"in(n=nl)&&kn(n)?n={start:n.selectionStart,end:n.selectionEnd}:n={anchorNode:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset},ll&&Jr(ll,n)?null:(ll=n,(e=tr.getPooled(tl.select,rl,e,t)).type="select",e.target=nl,qn(e),e))}var ol={eventTypes:tl,extractEvents:function(e,t,n,r,l,i){if(!(i=!(l=i||(r.window===r?r.document:9===r.nodeType?r:r.ownerDocument)))){e:{l=tt(l),i=T.onSelect;for(var a=0;axl||(e.current=kl[xl],kl[xl]=null,xl--)}function El(e,t){kl[++xl]=e.current,e.current=t}var Sl={},Cl={current:Sl},Pl={current:!1},_l=Sl;function Nl(e,t){var n=e.type.contextTypes;if(!n)return Sl;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l,i={};for(l in n)i[l]=t[l];return r&&((e=e.stateNode).__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function zl(e){return null!=(e=e.childContextTypes)}function Ml(){Tl(Pl),Tl(Cl)}function Il(e,t,n){if(Cl.current!==Sl)throw Error(r(168));El(Cl,t),El(Pl,n)}function Fl(e,n,l){var i=e.stateNode;if(e=n.childContextTypes,"function"!=typeof i.getChildContext)return l;for(var a in i=i.getChildContext())if(!(a in e))throw Error(r(108,ye(n)||"Unknown",a));return t({},l,{},i)}function Ol(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Sl,_l=Cl.current,El(Cl,e),El(Pl,Pl.current),!0}function Rl(e,t,n){var l=e.stateNode;if(!l)throw Error(r(169));n?(e=Fl(e,t,_l),l.__reactInternalMemoizedMergedChildContext=e,Tl(Pl),Tl(Cl),El(Cl,e)):Tl(Pl),El(Pl,n)}var Dl=n.unstable_runWithPriority,Ll=n.unstable_scheduleCallback,Ul=n.unstable_cancelCallback,Al=n.unstable_requestPaint,Vl=n.unstable_now,Ql=n.unstable_getCurrentPriorityLevel,Wl=n.unstable_ImmediatePriority,Hl=n.unstable_UserBlockingPriority,jl=n.unstable_NormalPriority,Bl=n.unstable_LowPriority,Kl=n.unstable_IdlePriority,$l={},ql=n.unstable_shouldYield,Yl=void 0!==Al?Al:function(){},Xl=null,Gl=null,Zl=!1,Jl=Vl(),ei=1e4>Jl?Vl:function(){return Vl()-Jl};function ti(){switch(Ql()){case Wl:return 99;case Hl:return 98;case jl:return 97;case Bl:return 96;case Kl:return 95;default:throw Error(r(332))}}function ni(e){switch(e){case 99:return Wl;case 98:return Hl;case 97:return jl;case 96:return Bl;case 95:return Kl;default:throw Error(r(332))}}function ri(e,t){return e=ni(e),Dl(e,t)}function li(e,t,n){return e=ni(e),Ll(e,t,n)}function ii(e){return null===Xl?(Xl=[e],Gl=Ll(Wl,oi)):Xl.push(e),$l}function ai(){if(null!==Gl){var e=Gl;Gl=null,Ul(e)}oi()}function oi(){if(!Zl&&null!==Xl){Zl=!0;var e=0;try{var t=Xl;ri(99,function(){for(;e=t&&(ja=!0),e.firstContext=null)}function yi(e,t){if(pi!==e&&!1!==t&&0!==t)if("number"==typeof t&&1073741823!==t||(pi=e,t=1073741823),t={context:e,observedBits:t,next:null},null===di){if(null===fi)throw Error(r(308));di=t,fi.dependencies={expirationTime:0,firstContext:t,responders:null}}else di=di.next=t;return e._currentValue}var bi=!1;function wi(e){e.updateQueue={baseState:e.memoizedState,baseQueue:null,shared:{pending:null},effects:null}}function ki(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,baseQueue:e.baseQueue,shared:e.shared,effects:e.effects})}function xi(e,t){return(e={expirationTime:e,suspenseConfig:t,tag:0,payload:null,callback:null,next:null}).next=e}function Ti(e,t){if(null!==(e=e.updateQueue)){var n=(e=e.shared).pending;null===n?t.next=t:(t.next=n.next,n.next=t),e.pending=t}}function Ei(e,t){var n=e.alternate;null!==n&&ki(n,e),null===(n=(e=e.updateQueue).baseQueue)?(e.baseQueue=t.next=t,t.next=t):(t.next=n.next,n.next=t)}function Si(e,n,r,l){var i=e.updateQueue;bi=!1;var a=i.baseQueue,o=i.shared.pending;if(null!==o){if(null!==a){var u=a.next;a.next=o.next,o.next=u}a=o,i.shared.pending=null,null!==(u=e.alternate)&&(null!==(u=u.updateQueue)&&(u.baseQueue=o))}if(null!==a){u=a.next;var c=i.baseState,s=0,f=null,d=null,p=null;if(null!==u)for(var m=u;;){if((o=m.expirationTime)s&&(s=o)}else{null!==p&&(p=p.next={expirationTime:1073741823,suspenseConfig:m.suspenseConfig,tag:m.tag,payload:m.payload,callback:m.callback,next:null}),Fu(o,m.suspenseConfig);e:{var g=e,v=m;switch(o=n,h=r,v.tag){case 1:if("function"==typeof(g=v.payload)){c=g.call(h,c,o);break e}c=g;break e;case 3:g.effectTag=-4097&g.effectTag|64;case 0:if(null==(o="function"==typeof(g=v.payload)?g.call(h,c,o):g))break e;c=t({},c,o);break e;case 2:bi=!0}}null!==m.callback&&(e.effectTag|=32,null===(o=i.effects)?i.effects=[m]:o.push(m))}if(null===(m=m.next)||m===u){if(null===(o=i.shared.pending))break;m=a.next=o.next,o.next=u,i.baseQueue=a=o,i.shared.pending=null}}null===p?f=c:p.next=d,i.baseState=f,i.baseQueue=p,Ou(s),e.expirationTime=s,e.memoizedState=c}}function Ci(e,t,n){if(e=t.effects,t.effects=null,null!==e)for(t=0;th?(g=f,f=null):g=f.sibling;var v=p(r,f,o[h],u);if(null===v){null===f&&(f=g);break}e&&f&&null===v.alternate&&t(r,f),i=a(v,i,h),null===s?c=v:s.sibling=v,s=v,f=g}if(h===o.length)return n(r,f),c;if(null===f){for(;hg?(v=h,h=null):v=h.sibling;var b=p(i,h,y.value,c);if(null===b){null===h&&(h=v);break}e&&h&&null===b.alternate&&t(i,h),o=a(b,o,g),null===f?s=b:f.sibling=b,f=b,h=v}if(y.done)return n(i,h),s;if(null===h){for(;!y.done;g++,y=u.next())null!==(y=d(i,y.value,c))&&(o=a(y,o,g),null===f?s=y:f.sibling=y,f=y);return s}for(h=l(i,h);!y.done;g++,y=u.next())null!==(y=m(h,i,g,y.value,c))&&(e&&null!==y.alternate&&h.delete(null===y.key?g:y.key),o=a(y,o,g),null===f?s=y:f.sibling=y,f=y);return e&&h.forEach(function(e){return t(i,e)}),s}return function(e,l,a,u){var c="object"==typeof a&&null!==a&&a.type===re&&null===a.key;c&&(a=a.props.children);var s="object"==typeof a&&null!==a;if(s)switch(a.$$typeof){case te:e:{for(s=a.key,c=l;null!==c;){if(c.key===s){switch(c.tag){case 7:if(a.type===re){n(e,c.sibling),(l=i(c,a.props.children)).return=e,e=l;break e}break;default:if(c.elementType===a.type){n(e,c.sibling),(l=i(c,a.props)).ref=Di(e,c,a),l.return=e,e=l;break e}}n(e,c);break}t(e,c),c=c.sibling}a.type===re?((l=lc(a.props.children,e.mode,u,a.key)).return=e,e=l):((u=rc(a.type,a.key,a.props,null,e.mode,u)).ref=Di(e,l,a),u.return=e,e=u)}return o(e);case ne:e:{for(c=a.key;null!==l;){if(l.key===c){if(4===l.tag&&l.stateNode.containerInfo===a.containerInfo&&l.stateNode.implementation===a.implementation){n(e,l.sibling),(l=i(l,a.children||[])).return=e,e=l;break e}n(e,l);break}t(e,l),l=l.sibling}(l=ac(a,e.mode,u)).return=e,e=l}return o(e)}if("string"==typeof a||"number"==typeof a)return a=""+a,null!==l&&6===l.tag?(n(e,l.sibling),(l=i(l,a)).return=e,e=l):(n(e,l),(l=ic(a,e.mode,u)).return=e,e=l),o(e);if(Ri(a))return h(e,l,a,u);if(ge(a))return g(e,l,a,u);if(s&&Li(e,a),void 0===a&&!c)switch(e.tag){case 1:case 0:throw e=e.type,Error(r(152,e.displayName||e.name||"Component"))}return n(e,l)}}var Ai=Ui(!0),Vi=Ui(!1),Qi={},Wi={current:Qi},Hi={current:Qi},ji={current:Qi};function Bi(e){if(e===Qi)throw Error(r(174));return e}function Ki(e,t){switch(El(ji,t),El(Hi,e),El(Wi,Qi),e=t.nodeType){case 9:case 11:t=(t=t.documentElement)?t.namespaceURI:Ve(null,"");break;default:t=Ve(t=(e=8===e?t.parentNode:t).namespaceURI||null,e=e.tagName)}Tl(Wi),El(Wi,t)}function $i(){Tl(Wi),Tl(Hi),Tl(ji)}function qi(e){Bi(ji.current);var t=Bi(Wi.current),n=Ve(t,e.type);t!==n&&(El(Hi,e),El(Wi,n))}function Yi(e){Hi.current===e&&(Tl(Wi),Tl(Hi))}var Xi={current:0};function Gi(e){for(var t=e;null!==t;){if(13===t.tag){var n=t.memoizedState;if(null!==n&&(null===(n=n.dehydrated)||n.data===En||n.data===Sn))return t}else if(19===t.tag&&void 0!==t.memoizedProps.revealOrder){if(0!=(64&t.effectTag))return t}else if(null!==t.child){t.child.return=t,t=t.child;continue}if(t===e)break;for(;null===t.sibling;){if(null===t.return||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function Zi(e,t){return{responder:e,props:t}}var Ji=G.ReactCurrentDispatcher,ea=G.ReactCurrentBatchConfig,ta=0,na=null,ra=null,la=null,ia=!1;function aa(){throw Error(r(321))}function oa(e,t){if(null===t)return!1;for(var n=0;na))throw Error(r(301));a+=1,la=ra=null,t.updateQueue=null,Ji.current=Fa,e=n(l,i)}while(t.expirationTime===ta)}if(Ji.current=za,t=null!==ra&&null!==ra.next,ta=0,la=ra=na=null,ia=!1,t)throw Error(r(300));return e}function ca(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return null===la?na.memoizedState=la=e:la=la.next=e,la}function sa(){if(null===ra){var e=na.alternate;e=null!==e?e.memoizedState:null}else e=ra.next;var t=null===la?na.memoizedState:la.next;if(null!==t)la=t,ra=e;else{if(null===e)throw Error(r(310));e={memoizedState:(ra=e).memoizedState,baseState:ra.baseState,baseQueue:ra.baseQueue,queue:ra.queue,next:null},null===la?na.memoizedState=la=e:la=la.next=e}return la}function fa(e,t){return"function"==typeof t?t(e):t}function da(e){var t=sa(),n=t.queue;if(null===n)throw Error(r(311));n.lastRenderedReducer=e;var l=ra,i=l.baseQueue,a=n.pending;if(null!==a){if(null!==i){var o=i.next;i.next=a.next,a.next=o}l.baseQueue=i=a,n.pending=null}if(null!==i){i=i.next,l=l.baseState;var u=o=a=null,c=i;do{var s=c.expirationTime;if(sna.expirationTime&&(na.expirationTime=s,Ou(s))}else null!==u&&(u=u.next={expirationTime:1073741823,suspenseConfig:c.suspenseConfig,action:c.action,eagerReducer:c.eagerReducer,eagerState:c.eagerState,next:null}),Fu(s,c.suspenseConfig),l=c.eagerReducer===e?c.eagerState:e(l,c.action);c=c.next}while(null!==c&&c!==i);null===u?a=l:u.next=o,Gr(l,t.memoizedState)||(ja=!0),t.memoizedState=l,t.baseState=a,t.baseQueue=u,n.lastRenderedState=l}return[t.memoizedState,n.dispatch]}function pa(e){var t=sa(),n=t.queue;if(null===n)throw Error(r(311));n.lastRenderedReducer=e;var l=n.dispatch,i=n.pending,a=t.memoizedState;if(null!==i){n.pending=null;var o=i=i.next;do{a=e(a,o.action),o=o.next}while(o!==i);Gr(a,t.memoizedState)||(ja=!0),t.memoizedState=a,null===t.baseQueue&&(t.baseState=a),n.lastRenderedState=a}return[a,l]}function ma(e){var t=ca();return"function"==typeof e&&(e=e()),t.memoizedState=t.baseState=e,e=(e=t.queue={pending:null,dispatch:null,lastRenderedReducer:fa,lastRenderedState:e}).dispatch=Na.bind(null,na,e),[t.memoizedState,e]}function ha(e,t,n,r){return e={tag:e,create:t,destroy:n,deps:r,next:null},null===(t=na.updateQueue)?(t={lastEffect:null},na.updateQueue=t,t.lastEffect=e.next=e):null===(n=t.lastEffect)?t.lastEffect=e.next=e:(r=n.next,n.next=e,e.next=r,t.lastEffect=e),e}function ga(){return sa().memoizedState}function va(e,t,n,r){var l=ca();na.effectTag|=e,l.memoizedState=ha(1|t,n,void 0,void 0===r?null:r)}function ya(e,t,n,r){var l=sa();r=void 0===r?null:r;var i=void 0;if(null!==ra){var a=ra.memoizedState;if(i=a.destroy,null!==r&&oa(r,a.deps))return void ha(t,n,i,r)}na.effectTag|=e,l.memoizedState=ha(1|t,n,i,r)}function ba(e,t){return va(516,4,e,t)}function wa(e,t){return ya(516,4,e,t)}function ka(e,t){return ya(4,2,e,t)}function xa(e,t){return"function"==typeof t?(e=e(),t(e),function(){t(null)}):null!=t?(e=e(),t.current=e,function(){t.current=null}):void 0}function Ta(e,t,n){return n=null!=n?n.concat([e]):null,ya(4,2,xa.bind(null,t,e),n)}function Ea(){}function Sa(e,t){return ca().memoizedState=[e,void 0===t?null:t],e}function Ca(e,t){var n=sa();t=void 0===t?null:t;var r=n.memoizedState;return null!==r&&null!==t&&oa(t,r[1])?r[0]:(n.memoizedState=[e,t],e)}function Pa(e,t){var n=sa();t=void 0===t?null:t;var r=n.memoizedState;return null!==r&&null!==t&&oa(t,r[1])?r[0]:(e=e(),n.memoizedState=[e,t],e)}function _a(e,t,n){var r=ti();ri(98>r?98:r,function(){e(!0)}),ri(97<\/script>",e=e.removeChild(e.firstChild)):"string"==typeof i.is?e=u.createElement(a,{is:i.is}):(e=u.createElement(a),"select"===a&&(u=e,i.multiple?u.multiple=!0:i.size&&(u.size=i.size))):e=u.createElementNS(e,a),e[Rn]=n,e[Dn]=i,eo(e,n,!1,!1),n.stateNode=e,u=dn(a,i),a){case"iframe":case"object":case"embed":Jt("load",e),c=i;break;case"video":case"audio":for(c=0;ci.tailExpiration&&1t)&&hu.set(e,t))}}function xu(e,t){e.expirationTime=(e=n>(e=e.nextKnownPendingLevel)?n:e)&&t!==e?0:e}function Eu(e){if(0!==e.lastExpiredTime)e.callbackExpirationTime=1073741823,e.callbackPriority=99,e.callbackNode=ii(Cu.bind(null,e));else{var t=Tu(e),n=e.callbackNode;if(0===t)null!==n&&(e.callbackNode=null,e.callbackExpirationTime=0,e.callbackPriority=90);else{var r=bu();if(1073741823===t?r=99:1===t||2===t?r=95:r=0>=(r=10*(1073741821-t)-10*(1073741821-r))?99:250>=r?98:5250>=r?97:95,null!==n){var l=e.callbackPriority;if(e.callbackExpirationTime===t&&l>=r)return;n!==$l&&Ul(n)}e.callbackExpirationTime=t,e.callbackPriority=r,t=1073741823===t?ii(Cu.bind(null,e)):li(r,Su.bind(null,e),{timeout:10*(1073741821-t)-ei()}),e.callbackNode=t}}}function Su(e,t){if(yu=0,t)return fc(e,t=bu()),Eu(e),null;var n=Tu(e);if(0!==n){if(t=e.callbackNode,(Yo&(Qo|Wo))!==Ao)throw Error(r(327));if(Hu(),e===Xo&&n===Zo||zu(e,n),null!==Go){var l=Yo;Yo|=Qo;for(var i=Iu();;)try{Du();break}catch(u){Mu(e,u)}if(mi(),Yo=l,Lo.current=i,Jo===jo)throw t=eu,zu(e,n),cc(e,n),Eu(e),t;if(null===Go)switch(i=e.finishedWork=e.current.alternate,e.finishedExpirationTime=n,l=Jo,Xo=null,l){case Ho:case jo:throw Error(r(345));case Bo:fc(e,2=n){e.lastPingedTime=n,zu(e,n);break}}if(0!==(a=Tu(e))&&a!==n)break;if(0!==l&&l!==n){e.lastPingedTime=l;break}e.timeoutHandle=zn(Vu.bind(null,e),i);break}Vu(e);break;case $o:if(cc(e,n),n===(l=e.lastSuspendedTime)&&(e.nextKnownPendingLevel=Au(i)),iu&&(0===(i=e.lastPingedTime)||i>=n)){e.lastPingedTime=n,zu(e,n);break}if(0!==(i=Tu(e))&&i!==n)break;if(0!==l&&l!==n){e.lastPingedTime=l;break}if(1073741823!==nu?l=10*(1073741821-nu)-ei():1073741823===tu?l=0:(l=10*(1073741821-tu)-5e3,0>(l=(i=ei())-l)&&(l=0),(n=10*(1073741821-n)-i)<(l=(120>l?120:480>l?480:1080>l?1080:1920>l?1920:3e3>l?3e3:4320>l?4320:1960*Do(l/1960))-l)&&(l=n)),10=(l=0|o.busyMinDurationMs)?l=0:(i=0|o.busyDelayMs,l=(a=ei()-(10*(1073741821-a)-(0|o.timeoutMs||5e3)))<=i?0:i+l-a),10 component higher in the tree to provide a loading indicator or placeholder to display."+be(a))}Jo!==qo&&(Jo=Bo),o=mo(o,a),f=i;do{switch(f.tag){case 3:u=o,f.effectTag|=4096,f.expirationTime=t,Ei(f,Fo(f,u,t));break e;case 1:u=o;var w=f.type,k=f.stateNode;if(0==(64&f.effectTag)&&("function"==typeof w.getDerivedStateFromError||null!==k&&"function"==typeof k.componentDidCatch&&(null===fu||!fu.has(k)))){f.effectTag|=4096,f.expirationTime=t,Ei(f,Oo(f,u,t));break e}}f=f.return}while(null!==f)}Go=Uu(Go)}catch(x){t=x;continue}break}}function Iu(){var e=Lo.current;return Lo.current=za,null===e?za:e}function Fu(e,t){elu&&(lu=e)}function Ru(){for(;null!==Go;)Go=Lu(Go)}function Du(){for(;null!==Go&&!ql();)Go=Lu(Go)}function Lu(e){var t=Ro(e.alternate,e,Zo);return e.memoizedProps=e.pendingProps,null===t&&(t=Uu(e)),Uo.current=null,t}function Uu(e){Go=e;do{var t=Go.alternate;if(e=Go.return,0==(2048&Go.effectTag)){if(t=fo(t,Go,Zo),1===Zo||1!==Go.childExpirationTime){for(var n=0,r=Go.child;null!==r;){var l=r.expirationTime,i=r.childExpirationTime;l>n&&(n=l),i>n&&(n=i),r=r.sibling}Go.childExpirationTime=n}if(null!==t)return t;null!==e&&0==(2048&e.effectTag)&&(null===e.firstEffect&&(e.firstEffect=Go.firstEffect),null!==Go.lastEffect&&(null!==e.lastEffect&&(e.lastEffect.nextEffect=Go.firstEffect),e.lastEffect=Go.lastEffect),1(e=e.childExpirationTime)?t:e}function Vu(e){var t=ti();return ri(99,Qu.bind(null,e,t)),null}function Qu(e,t){do{Hu()}while(null!==pu);if((Yo&(Qo|Wo))!==Ao)throw Error(r(327));var n=e.finishedWork,l=e.finishedExpirationTime;if(null===n)return null;if(e.finishedWork=null,e.finishedExpirationTime=0,n===e.current)throw Error(r(177));e.callbackNode=null,e.callbackExpirationTime=0,e.callbackPriority=90,e.nextKnownPendingLevel=0;var i=Au(n);if(e.firstPendingTime=i,l<=e.lastSuspendedTime?e.firstSuspendedTime=e.lastSuspendedTime=e.nextKnownPendingLevel=0:l<=e.firstSuspendedTime&&(e.firstSuspendedTime=l-1),l<=e.lastPingedTime&&(e.lastPingedTime=0),l<=e.lastExpiredTime&&(e.lastExpiredTime=0),e===Xo&&(Go=Xo=null,Zo=0),1u&&(s=u,u=o,o=s),s=yn(w,o),f=yn(w,u),s&&f&&(1!==x.rangeCount||x.anchorNode!==s.node||x.anchorOffset!==s.offset||x.focusNode!==f.node||x.focusOffset!==f.offset)&&((k=k.createRange()).setStart(s.node,s.offset),x.removeAllRanges(),o>u?(x.addRange(k),x.extend(f.node,f.offset)):(k.setEnd(f.node,f.offset),x.addRange(k))))),k=[];for(x=w;x=x.parentNode;)1===x.nodeType&&k.push({element:x,left:x.scrollLeft,top:x.scrollTop});for("function"==typeof w.focus&&w.focus(),w=0;w=n?io(e,t,n):(El(Xi,1&Xi.current),null!==(t=co(e,t,n))?t.sibling:null);El(Xi,1&Xi.current);break;case 19:if(l=t.childExpirationTime>=n,0!=(64&e.effectTag)){if(l)return uo(e,t,n);t.effectTag|=64}if(null!==(i=t.memoizedState)&&(i.rendering=null,i.tail=null),El(Xi,Xi.current),!l)return null}return co(e,t,n)}ja=!1}}else ja=!1;switch(t.expirationTime=0,t.tag){case 2:if(l=t.type,null!==e&&(e.alternate=null,t.alternate=null,t.effectTag|=2),e=t.pendingProps,i=Nl(t,Cl.current),vi(t,n),i=ua(null,t,l,e,i,n),t.effectTag|=1,"object"==typeof i&&null!==i&&"function"==typeof i.render&&void 0===i.$$typeof){if(t.tag=1,t.memoizedState=null,t.updateQueue=null,zl(l)){var a=!0;Ol(t)}else a=!1;t.memoizedState=null!==i.state&&void 0!==i.state?i.state:null,wi(t);var o=l.getDerivedStateFromProps;"function"==typeof o&&Ni(t,l,o,e),i.updater=zi,t.stateNode=i,i._reactInternalFiber=t,Oi(t,l,e,n),t=Za(null,t,l,!0,a,n)}else t.tag=0,Ba(null,t,i,n),t=t.child;return t;case 16:e:{if(i=t.elementType,null!==e&&(e.alternate=null,t.alternate=null,t.effectTag|=2),e=t.pendingProps,ve(i),1!==i._status)throw i._result;switch(i=i._result,t.type=i,a=t.tag=tc(i),e=ci(i,e),a){case 0:t=Xa(null,t,i,e,n);break e;case 1:t=Ga(null,t,i,e,n);break e;case 11:t=Ka(null,t,i,e,n);break e;case 14:t=$a(null,t,i,ci(i.type,e),l,n);break e}throw Error(r(306,i,""))}return t;case 0:return l=t.type,i=t.pendingProps,Xa(e,t,l,i=t.elementType===l?i:ci(l,i),n);case 1:return l=t.type,i=t.pendingProps,Ga(e,t,l,i=t.elementType===l?i:ci(l,i),n);case 3:if(Ja(t),l=t.updateQueue,null===e||null===l)throw Error(r(282));if(l=t.pendingProps,i=null!==(i=t.memoizedState)?i.element:null,ki(e,t),Si(t,l,null,n),(l=t.memoizedState.element)===i)Wa(),t=co(e,t,n);else{if((i=t.stateNode.hydrate)&&(Ra=In(t.stateNode.containerInfo.firstChild),Oa=t,i=Da=!0),i)for(n=Vi(t,null,l,n),t.child=n;n;)n.effectTag=-3&n.effectTag|1024,n=n.sibling;else Ba(e,t,l,n),Wa();t=t.child}return t;case 5:return qi(t),null===e&&Aa(t),l=t.type,i=t.pendingProps,a=null!==e?e.memoizedProps:null,o=i.children,Nn(l,i)?o=null:null!==a&&Nn(l,a)&&(t.effectTag|=16),Ya(e,t),4&t.mode&&1!==n&&i.hidden?(t.expirationTime=t.childExpirationTime=1,t=null):(Ba(e,t,o,n),t=t.child),t;case 6:return null===e&&Aa(t),null;case 13:return io(e,t,n);case 4:return Ki(t,t.stateNode.containerInfo),l=t.pendingProps,null===e?t.child=Ai(t,null,l,n):Ba(e,t,l,n),t.child;case 11:return l=t.type,i=t.pendingProps,Ka(e,t,l,i=t.elementType===l?i:ci(l,i),n);case 7:return Ba(e,t,t.pendingProps,n),t.child;case 8:case 12:return Ba(e,t,t.pendingProps.children,n),t.child;case 10:e:{l=t.type._context,i=t.pendingProps,o=t.memoizedProps,a=i.value;var u=t.type._context;if(El(si,u._currentValue),u._currentValue=a,null!==o)if(u=o.value,0===(a=Gr(u,a)?0:0|("function"==typeof l._calculateChangedBits?l._calculateChangedBits(u,a):1073741823))){if(o.children===i.children&&!Pl.current){t=co(e,t,n);break e}}else for(null!==(u=t.child)&&(u.return=t);null!==u;){var c=u.dependencies;if(null!==c){o=u.child;for(var s=c.firstContext;null!==s;){if(s.context===l&&0!=(s.observedBits&a)){1===u.tag&&((s=xi(n,null)).tag=2,Ti(u,s)),u.expirationTime=t&&e<=t}function cc(e,t){var n=e.firstSuspendedTime,r=e.lastSuspendedTime;nt||0===n)&&(e.lastSuspendedTime=t),t<=e.lastPingedTime&&(e.lastPingedTime=0),t<=e.lastExpiredTime&&(e.lastExpiredTime=0)}function sc(e,t){t>e.firstPendingTime&&(e.firstPendingTime=t);var n=e.firstSuspendedTime;0!==n&&(t>=n?e.firstSuspendedTime=e.lastSuspendedTime=e.nextKnownPendingLevel=0:t>=e.lastSuspendedTime&&(e.lastSuspendedTime=t+1),t>e.nextKnownPendingLevel&&(e.nextKnownPendingLevel=t))}function fc(e,t){var n=e.lastExpiredTime;(0===n||n>t)&&(e.lastExpiredTime=t)}function dc(e,t,n,l){var i=t.current,a=bu(),o=Pi.suspense;a=wu(a,i,o);e:if(n){t:{if(nt(n=n._reactInternalFiber)!==n||1!==n.tag)throw Error(r(170));var u=n;do{switch(u.tag){case 3:u=u.stateNode.context;break t;case 1:if(zl(u.type)){u=u.stateNode.__reactInternalMemoizedMergedChildContext;break t}}u=u.return}while(null!==u);throw Error(r(171))}if(1===n.tag){var c=n.type;if(zl(c)){n=Fl(n,c,u);break e}}n=u}else n=Sl;return null===t.context?t.context=n:t.pendingContext=n,(t=xi(a,o)).payload={element:e},null!==(l=void 0===l?null:l)&&(t.callback=l),Ti(i,t),ku(i,a),a}function pc(e){if(!(e=e.current).child)return null;switch(e.child.tag){case 5:default:return e.child.stateNode}}function mc(e,t){null!==(e=e.memoizedState)&&null!==e.dehydrated&&e.retryTime()=>{let o="",r=t;for(;r--;)o+=e[Math.random()*e.length|0];return o};exports.customAlphabet=t;let o=(t=21)=>{let o="",r=t;for(;r--;)o+=e[64*Math.random()|0];return o};exports.nanoid=o; },{}],"VB7z":[function(require,module,exports) { "use strict";function e(e){for(var t=arguments.length,r=Array(t>1?t-1:0),n=1;n3?t.i-4:t.i:Array.isArray(e)?1:s(e)?2:l(e)?3:0}function a(e,t){return 2===i(e)?e.has(t):Object.prototype.hasOwnProperty.call(e,t)}function u(e,t){return 2===i(e)?e.get(t):e[t]}function c(e,t,r){var n=i(e);2===n?e.set(t,r):3===n?(e.delete(t),e.add(r)):e[t]=r}function f(e,t){return e===t?0!==e||1/e==1/t:e!=e&&t!=t}function s(e){return X&&e instanceof Map}function l(e){return q&&e instanceof Set}function p(e){return e.o||e.t}function h(e){if(Array.isArray(e))return Array.prototype.slice.call(e);var t=te(e);delete t[Q];for(var r=ee(t),n=0;n1&&(e.set=e.add=e.clear=e.delete=d),Object.freeze(e),n&&o(e,function(e,t){return v(t,!0)},!0),e)}function d(){e(2)}function y(e){return null==e||"object"!=typeof e||Object.isFrozen(e)}function b(t){var r=re[t];return r||e(18,t),r}function g(e,t){re[e]||(re[e]=t)}function m(){return J}function P(e,t){t&&(b("Patches"),e.u=[],e.s=[],e.v=t)}function O(e){x(e),e.p.forEach(j),e.p=null}function x(e){e===J&&(J=e.l)}function w(e){return J={p:[],l:J,h:e,m:!0,_:0}}function j(e){var t=e[Q];0===t.i||1===t.i?t.j():t.O=!0}function A(t,n){n._=n.p.length;var o=n.p[0],i=void 0!==t&&t!==o;return n.h.g||b("ES5").S(n,t,i),i?(o[Q].P&&(O(n),e(4)),r(t)&&(t=D(n,t),n.l||_(n,t)),n.u&&b("Patches").M(o[Q],t,n.u,n.s)):t=D(n,o,[]),O(n),n.u&&n.v(n.u,n.s),t!==H?t:void 0}function D(e,t,r){if(y(t))return t;var n=t[Q];if(!n)return o(t,function(o,i){return S(e,n,t,o,i,r)},!0),t;if(n.A!==e)return t;if(!n.P)return _(e,n.t,!0),n.t;if(!n.I){n.I=!0,n.A._--;var i=4===n.i||5===n.i?n.o=h(n.k):n.o;o(3===n.i?new Set(i):i,function(t,o){return S(e,n,i,t,o,r)}),_(e,i,!1),r&&e.u&&b("Patches").R(n,r,e.u,e.s)}return n.o}function S(e,n,o,i,u,f){if(t(u)){var s=D(e,u,f&&n&&3!==n.i&&!a(n.D,i)?f.concat(i):void 0);if(c(o,i,s),!t(s))return;e.m=!1}if(r(u)&&!y(u)){if(!e.h.F&&e._<1)return;D(e,u),n&&n.A.l||_(e,u)}}function _(e,t,r){void 0===r&&(r=!1),e.h.F&&e.m&&v(t,r)}function k(e,t){var r=e[Q];return(r?p(r):e)[t]}function I(e,t){if(t in e)for(var r=Object.getPrototypeOf(e);r;){var n=Object.getOwnPropertyDescriptor(r,t);if(n)return n;r=Object.getPrototypeOf(r)}}function z(e){e.P||(e.P=!0,e.l&&z(e.l))}function E(e){e.o||(e.o=h(e.t))}function M(e,t,r){var n=s(t)?b("MapSet").N(t,r):l(t)?b("MapSet").T(t,r):e.g?function(e,t){var r=Array.isArray(e),n={i:r?1:0,A:t?t.A:m(),P:!1,I:!1,D:{},l:t,t:e,k:null,o:null,j:null,C:!1},o=n,i=ne;r&&(o=[n],i=oe);var a=Proxy.revocable(o,i),u=a.revoke,c=a.proxy;return n.k=c,n.j=u,c}(t,r):b("ES5").J(t,r);return(r?r.A:m()).p.push(n),n}function F(n){return t(n)||e(22,n),function e(t){if(!r(t))return t;var n,a=t[Q],f=i(t);if(a){if(!a.P&&(a.i<4||!b("ES5").K(a)))return a.t;a.I=!0,n=R(t,f),a.I=!1}else n=R(t,f);return o(n,function(t,r){a&&u(a.t,t)===r||c(n,t,e(r))}),3===f?new Set(n):n}(n)}function R(e,t){switch(t){case 2:return new Map(e);case 3:return Array.from(e)}return h(e)}function C(){function e(e,t){var r=u[e];return r?r.enumerable=t:u[e]=r={configurable:!0,enumerable:t,get:function(){var t=this[Q];return ne.get(t,e)},set:function(t){var r=this[Q];ne.set(r,e,t)}},r}function r(e){for(var t=e.length-1;t>=0;t--){var r=e[t][Q];if(!r.P)switch(r.i){case 5:i(r)&&z(r);break;case 4:n(r)&&z(r)}}}function n(e){for(var t=e.t,r=e.k,n=ee(r),o=n.length-1;o>=0;o--){var i=n[o];if(i!==Q){var u=t[i];if(void 0===u&&!a(t,i))return!0;var c=r[i],s=c&&c[Q];if(s?s.t!==u:!f(c,u))return!0}}var l=!!t[Q];return n.length!==ee(t).length+(l?0:1)}function i(e){var t=e.k;if(t.length!==e.t.length)return!0;var r=Object.getOwnPropertyDescriptor(t,t.length-1);return!(!r||r.get)}var u={};g("ES5",{J:function(t,r){var n=Array.isArray(t),o=function(t,r){if(t){for(var n=Array(r.length),o=0;o1?r-1:0),i=1;i1?r-1:0),i=1;i=0;n--){var o=r[n];if(0===o.path.length&&"replace"===o.op){e=o.value;break}}var i=b("Patches").$;return t(e)?i(e,r):this.produce(e,function(e){return i(e,r.slice(n+1))})},n}(),ae=new ie,ue=ae.produce,ce=ae.produceWithPatches.bind(ae),fe=ae.setAutoFreeze.bind(ae),se=ae.setUseProxies.bind(ae),le=ae.applyPatches.bind(ae),pe=ae.createDraft.bind(ae),he=ae.finishDraft.bind(ae);exports.finishDraft=he,exports.createDraft=pe,exports.applyPatches=le,exports.setUseProxies=se,exports.setAutoFreeze=fe,exports.produceWithPatches=ce,exports.produce=ue,exports.Immer=ie;var ve=ue;exports.default=ve; },{}],"Sn21":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.a=r,exports.R=void 0;class t{constructor(t){const e=s();this.c=1,this.s0=e(" "),this.s1=e(" "),this.s2=e(" "),this.s0-=e(t),this.s0<0&&(this.s0+=1),this.s1-=e(t),this.s1<0&&(this.s1+=1),this.s2-=e(t),this.s2<0&&(this.s2+=1)}next(){const t=2091639*this.s0+2.3283064365386963e-10*this.c;return this.s0=this.s1,this.s1=this.s2,this.s2=t-(this.c=Math.trunc(t))}}function s(){let t=4022871197;return function(s){const e=s.toString();for(let r=0;r>>0,t=(s*=t)>>>0,t+=4294967296*(s-=t)}return 2.3283064365386963e-10*(t>>>0)}}function e(t,s){return s.c=t.c,s.s0=t.s0,s.s1=t.s1,s.s2=t.s2,s}function r(s,r){const n=new t(s),i=n.next.bind(n);return r&&e(r,n),i.state=(()=>e(n,{})),i}class n{constructor(t){this.state=t||{seed:"0"},this.used=!1}static seed(){return Date.now().toString(36).slice(-10)}isUsed(){return this.used}getState(){return this.state}_random(){this.used=!0;const t=this.state,s=r(t.prngstate?"":t.seed,t.prngstate),e=s();return this.state={...t,prngstate:s.state()},e}api(){const t=this._random.bind(this),s={D4:4,D6:6,D8:8,D10:10,D12:12,D20:20},e={};for(const r in s){const n=s[r];e[r]=(s=>void 0===s?Math.floor(t()*n)+1:Array.from({length:s}).map(()=>Math.floor(t()*n)+1))}return{...e,Die:function(s=6,e){return void 0===e?Math.floor(t()*s)+1:Array.from({length:e}).map(()=>Math.floor(t()*s)+1)},Number:()=>t(),Shuffle:s=>{const e=[...s];let r=s.length,n=0;const i=Array.from({length:r});for(;r;){const s=Math.trunc(r*t());i[n++]=e[s],e[s]=e[--r]}return i},_private:this}}}const i={name:"random",noClient:({api:t})=>t._private.isUsed(),flush:({api:t})=>t._private.getState(),api:({data:t})=>{return new n(t).api()},setup:({game:t})=>{let{seed:s}=t;return void 0===s&&(s=n.seed()),{seed:s}},playerView:()=>void 0};exports.R=i; },{}],"B6zW":[function(require,module,exports) { var t="[object Object]";function n(t){var n=!1;if(null!=t&&"function"!=typeof t.toString)try{n=!!(t+"")}catch(r){}return n}function r(t,n){return function(r){return t(n(r))}}var o=Function.prototype,c=Object.prototype,e=o.toString,u=c.hasOwnProperty,f=e.call(Object),i=c.toString,l=r(Object.getPrototypeOf,Object);function a(t){return!!t&&"object"==typeof t}function p(r){if(!a(r)||i.call(r)!=t||n(r))return!1;var o=l(r);if(null===o)return!0;var c=u.call(o,"constructor")&&o.constructor;return"function"==typeof c&&c instanceof c&&e.call(c)==f}module.exports=p; },{}],"MZmr":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.I=ae,exports.U=ne,exports.a=X,exports.b=Z,exports.c=ee,exports.e=B,exports.i=C,exports.z=exports.y=exports.x=exports.w=exports.v=exports.u=exports.t=exports.s=exports.r=exports.q=exports.p=exports.o=exports.n=exports.m=exports.l=exports.k=exports.j=exports.h=exports.g=exports.f=exports.d=exports.T=exports.S=exports.R=exports.P=exports.N=exports.M=exports.G=exports.F=exports.E=exports.C=exports.B=exports.A=void 0;var e=a(require("immer")),t=require("./plugin-random-087f861e.js"),r=a(require("lodash.isplainobject"));function a(e){return e&&e.__esModule?e:{default:e}}const n="MAKE_MOVE";exports.M=n;const s="GAME_EVENT";exports.o=s;const o="REDO";exports.R=o;const i="RESET";exports.l=i;const l="SYNC";exports.j=l;const p="UNDO";exports.h=p;const c="UPDATE";exports.k=c;const d="PATCH";exports.P=d;const u="PLUGIN";exports.d=u;const y="STRIP_TRANSIENTS";exports.p=y;const v=(e,t,r,a)=>({type:n,payload:{type:e,args:t,playerID:r,credentials:a}});exports.B=v;const g=(e,t,r,a)=>({type:s,payload:{type:e,args:t,playerID:r,credentials:a}});exports.g=g;const x=(e,t,r,a)=>({type:s,payload:{type:e,args:t,playerID:r,credentials:a},automatic:!0}),h=e=>({type:l,state:e.state,log:e.log,initialState:e.initialState,clientOnly:!0});exports.s=h;const m=(e,t,r,a)=>({type:d,prevStateID:e,stateID:t,patch:r,deltalog:a,clientOnly:!0});exports.y=m;const f=(e,t)=>({type:c,state:e,deltalog:t,clientOnly:!0});exports.z=f;const P=e=>({type:i,state:e,clientOnly:!0});exports.u=P;const E=(e,t)=>({type:p,payload:{type:null,args:null,playerID:e,credentials:t}});exports.v=E;const O=(e,t)=>({type:o,payload:{type:null,args:null,playerID:e,credentials:t}});exports.w=O;const _=(e,t,r,a)=>({type:u,payload:{type:e,args:t,playerID:r,credentials:a}}),M=()=>({type:y});exports.r=M;var N=Object.freeze({__proto__:null,makeMove:v,gameEvent:g,automaticGameEvent:x,sync:h,patch:m,update:f,reset:P,undo:E,redo:O,plugin:_,stripTransients:M});exports.A=N;const T="INVALID_MOVE";exports.n=T;const I={name:"plugin-immer",fnWrap:t=>(r,a,...n)=>{let s=!1;const o=(0,e.default)(r,e=>{const r=t(e,a,...n);if(r!==T)return r;s=!0});return s?T:o}};var A,S;exports.G=A,function(e){e.MOVE="MOVE",e.GAME_ON_END="GAME_ON_END",e.PHASE_ON_BEGIN="PHASE_ON_BEGIN",e.PHASE_ON_END="PHASE_ON_END",e.TURN_ON_BEGIN="TURN_ON_BEGIN",e.TURN_ON_MOVE="TURN_ON_MOVE",e.TURN_ON_END="TURN_ON_END"}(A||(exports.G=A={})),function(e){e.CalledOutsideHook="Events must be called from moves or the `onBegin`, `onEnd`, and `onMove` hooks.\nThis error probably means you called an event from other game code, like an `endIf` trigger or one of the `turn.order` methods.",e.EndTurnInOnEnd="`endTurn` is disallowed in `onEnd` hooks — the turn is already ending.",e.MaxTurnEndings="Maximum number of turn endings exceeded for this update.\nThis likely means game code is triggering an infinite loop.",e.PhaseEventInOnEnd="`setPhase` & `endPhase` are disallowed in a phase’s `onEnd` hook — the phase is already ending.\nIf you’re trying to dynamically choose the next phase when a phase ends, use the phase’s `next` trigger.",e.StageEventInOnEnd="`setStage`, `endStage` & `setActivePlayers` are disallowed in `onEnd` hooks.",e.StageEventInPhaseBegin="`setStage`, `endStage` & `setActivePlayers` are disallowed in a phase’s `onBegin` hook.\nUse `setActivePlayers` in a `turn.onBegin` hook or declare stages with `turn.activePlayers` instead.",e.StageEventInTurnBegin="`setStage` & `endStage` are disallowed in `turn.onBegin`.\nUse `setActivePlayers` or declare stages with `turn.activePlayers` instead."}(S||(S={}));class b{constructor(e,t,r){this.flow=e,this.playerID=r,this.dispatch=[],this.initialTurn=t.turn,this.updateTurnContext(t,void 0),this.maxEndedTurnsPerAction=100*t.numPlayers}api(){const e={_private:this};for(const t of this.flow.eventNames)e[t]=((...e)=>{this.dispatch.push({type:t,args:e,phase:this.currentPhase,turn:this.currentTurn,calledFrom:this.currentMethod,error:new Error("Events Plugin Error")})});return e}isUsed(){return this.dispatch.length>0}updateTurnContext(e,t){this.currentPhase=e.phase,this.currentTurn=e.turn,this.currentMethod=t}unsetCurrentMethod(){this.currentMethod=void 0}update(e){const t=e,r=({stack:e},r)=>({...t,plugins:{...t.plugins,events:{...t.plugins.events,data:{error:r+"\n"+e}}}});e:for(let a=0;a=this.maxEndedTurnsPerAction)return r(t.error,S.MaxTurnEndings);if(void 0===t.calledFrom)return r(t.error,S.CalledOutsideHook);if(e.ctx.gameover)break e;switch(t.type){case"endStage":case"setStage":case"setActivePlayers":switch(t.calledFrom){case A.TURN_ON_END:case A.PHASE_ON_END:return r(t.error,S.StageEventInOnEnd);case A.PHASE_ON_BEGIN:return r(t.error,S.StageEventInPhaseBegin);case A.TURN_ON_BEGIN:if("setActivePlayers"===t.type)break;return r(t.error,S.StageEventInTurnBegin)}if(n)continue e;break;case"endTurn":if(t.calledFrom===A.TURN_ON_END||t.calledFrom===A.PHASE_ON_END)return r(t.error,S.EndTurnInOnEnd);if(n)continue e;break;case"endPhase":case"setPhase":if(t.calledFrom===A.PHASE_ON_END)return r(t.error,S.PhaseEventInOnEnd);if(t.phase!==e.ctx.phase)continue e}const s=x(t.type,t.args,this.playerID);e=this.flow.processEvent(e,s)}return e}}const D={name:"events",noClient:({api:e})=>e._private.isUsed(),isInvalid:({data:e})=>e.error||!1,fnWrap:(e,t)=>(r,a,...n)=>{const s=a.events;return s&&s._private.updateTurnContext(a,t),r=e(r,a,...n),s&&s._private.unsetCurrentMethod(),r},dangerouslyFlushRawState:({state:e,api:t})=>t._private.update(e),api:({game:e,ctx:t,playerID:r})=>new b(e.flow,t,r).api()},G={name:"log",flush:()=>({}),api:({data:e})=>({setMetadata:t=>{e.metadata=t}}),setup:()=>({})};function U(e){if(null==e||"boolean"==typeof e||"number"==typeof e||"string"==typeof e)return!0;if(!(0,r.default)(e)&&!Array.isArray(e))return!1;for(const t in e)if(!U(e[t]))return!1;return!0}const R={name:"plugin-serializable",fnWrap:e=>(t,r,...a)=>{const n=e(t,r,...a);return n}},k=!0,L=()=>{},w=(...e)=>console.error(...e);function C(e){L(`INFO: ${e}`)}function B(e){w("ERROR:",e)}const j=[I,t.R,G,R],F=[...j,D],H=(e,t,r)=>(r.game.plugins.filter(e=>void 0!==e.action).filter(e=>e.name===t.payload.type).forEach(r=>{const a=r.name,n=e.plugins[a]||{data:{}},s=r.action(n.data,t.payload);e={...e,plugins:{...e.plugins,[a]:{...n,data:s}}}}),e);exports.f=H;const V=e=>{const t={...e.ctx},r=e.plugins||{};return Object.entries(r).forEach(([e,{api:r}])=>{t[e]=r}),t};exports.E=V;const $=(e,t,r)=>[...j,...r,D].filter(e=>void 0!==e.fnWrap).reduce((e,{fnWrap:r})=>r(e,t),e);exports.F=$;const q=(e,t)=>([...F,...t.game.plugins].filter(e=>void 0!==e.setup).forEach(r=>{const a=r.name,n=r.setup({G:e.G,ctx:e.ctx,game:t.game});e={...e,plugins:{...e.plugins,[a]:{data:n}}}}),e);exports.t=q;const W=(e,t)=>([...F,...t.game.plugins].filter(e=>void 0!==e.api).forEach(r=>{const a=r.name,n=e.plugins[a]||{data:{}},s=r.api({G:e.G,ctx:e.ctx,data:n.data,game:t.game,playerID:t.playerID});e={...e,plugins:{...e.plugins,[a]:{...n,api:s}}}}),e);exports.m=W;const z=(e,t)=>([...j,...t.game.plugins,D].reverse().forEach(r=>{const a=r.name,n=e.plugins[a]||{data:{}};if(r.flush){const a=r.flush({G:e.G,ctx:e.ctx,game:t.game,api:n.api,data:n.data});e={...e,plugins:{...e.plugins,[r.name]:{data:a}}}}else if(r.dangerouslyFlushRawState){const s=(e=r.dangerouslyFlushRawState({state:e,game:t.game,api:n.api,data:n.data})).plugins[a].data;e={...e,plugins:{...e.plugins,[r.name]:{data:s}}}}}),e),K=(e,t)=>[...F,...t.game.plugins].filter(e=>void 0!==e.noClient).map(r=>{const a=r.name,n=e.plugins[a];return!!n&&r.noClient({G:e.G,ctx:e.ctx,game:t.game,api:n.api,data:n.data})}).includes(!0);exports.N=K;const Y=(e,t)=>{return[...F,...t.game.plugins].filter(e=>void 0!==e.isInvalid).map(r=>{const{name:a}=r,n=e.plugins[a],s=r.isInvalid({G:e.G,ctx:e.ctx,game:t.game,data:n&&n.data});return!!s&&{plugin:a,message:s}}).find(e=>e)||!1},J=(e,t)=>{const r=z(e,t),a=Y(r,t);if(!a)return[r];const{plugin:n,message:s}=a;return B(`${n} plugin declared action invalid:\n${s}`),[e,a]};exports.q=J;const Q=({G:e,ctx:t,plugins:r={}},{game:a,playerID:n})=>([...F,...a.plugins].forEach(({name:s,playerView:o})=>{if(!o)return;const{data:i}=r[s]||{data:{}},l=o({G:e,ctx:t,game:a,data:i,playerID:n});r={...r,[s]:{data:l}}}),r);function X(e,t=!1){e.moveLimit&&(t&&(e.minMoves=e.moveLimit),e.maxMoves=e.moveLimit,delete e.moveLimit)}function Z(e,t){let r={},a=[],n=null,s={},o={};if(Array.isArray(t)){const e={};t.forEach(t=>e[t]=oe.NULL),r=e}else{if(X(t),t.next&&(n=t.next),t.revert&&(a=[...e._prevActivePlayers,{activePlayers:e.activePlayers,_activePlayersMinMoves:e._activePlayersMinMoves,_activePlayersMaxMoves:e._activePlayersMaxMoves,_activePlayersNumMoves:e._activePlayersNumMoves}]),void 0!==t.currentPlayer&&te(r,s,o,e.currentPlayer,t.currentPlayer),void 0!==t.others)for(let a=0;a0){const e=s.length-1;({activePlayers:t,_activePlayersMinMoves:r,_activePlayersMaxMoves:a,_activePlayersNumMoves:n}=s[e]),s=s.slice(0,e)}else t=null,r=null,a=null;return{...e,activePlayers:t,_activePlayersMinMoves:r,_activePlayersMaxMoves:a,_activePlayersNumMoves:n,_prevActivePlayers:s}}function te(e,t,r,a,n){"object"==typeof n&&n!==oe.NULL||(n={stage:n}),void 0!==n.stage&&(X(n),e[a]=n.stage,n.minMoves&&(t[a]=n.minMoves),n.maxMoves&&(r[a]=n.maxMoves))}function re(e,t){return e[t]+""}function ae(e,t){let{G:r,ctx:a}=e;const{numPlayers:n}=a,s=V(e),o=t.order;let i=[...Array.from({length:n})].map((e,t)=>t+"");void 0!==o.playOrder&&(i=o.playOrder(r,s));const l=o.first(r,s),p=typeof l;"number"!==p&&B(`invalid value returned by turn.order.first — expected number got ${p} “${l}”.`);const c=re(i,l);return a=Z(a={...a,currentPlayer:c,playOrderPos:l,playOrder:i},t.activePlayers||{})}function ne(e,t,r,a){const n=r.order;let{G:s,ctx:o}=e,i=o.playOrderPos,l=!1;if(a&&!0!==a)"object"!=typeof a&&B(`invalid argument to endTurn: ${a}`),Object.keys(a).forEach(e=>{switch(e){case"remove":t=re(o.playOrder,i);break;case"next":i=o.playOrder.indexOf(a.next),t=a.next;break;default:B(`invalid argument to endTurn: ${e}`)}});else{const r=V(e),a=n.next(s,r),p=typeof a;void 0!==a&&"number"!==p&&B(`invalid value returned by turn.order.next — expected number or undefined got ${p} “${a}”.`),void 0===a?l=!0:(i=a,t=re(o.playOrder,i))}return{endPhase:l,ctx:o={...o,playOrderPos:i,currentPlayer:t}}}exports.x=Q;const se={DEFAULT:{first:(e,t)=>0===t.turn?t.playOrderPos:(t.playOrderPos+1)%t.playOrder.length,next:(e,t)=>(t.playOrderPos+1)%t.playOrder.length},RESET:{first:()=>0,next:(e,t)=>(t.playOrderPos+1)%t.playOrder.length},CONTINUE:{first:(e,t)=>t.playOrderPos,next:(e,t)=>(t.playOrderPos+1)%t.playOrder.length},ONCE:{first:()=>0,next:(e,t)=>{if(t.playOrderPos({playOrder:()=>e,first:()=>0,next:(e,t)=>(t.playOrderPos+1)%t.playOrder.length}),CUSTOM_FROM:e=>({playOrder:t=>t[e],first:()=>0,next:(e,t)=>(t.playOrderPos+1)%t.playOrder.length})};exports.T=se;const oe={NULL:null};exports.S=oe;const ie={ALL:{all:oe.NULL},ALL_ONCE:{all:oe.NULL,minMoves:1,maxMoves:1},OTHERS:{others:oe.NULL},OTHERS_ONCE:{others:oe.NULL,minMoves:1,maxMoves:1}};exports.C=ie; },{"immer":"VB7z","./plugin-random-087f861e.js":"Sn21","lodash.isplainobject":"B6zW"}],"Al58":[function(require,module,exports) { "use strict";function t(t){return t.replace(/~1/g,"/").replace(/~0/g,"~")}function e(t){return t.replace(/~/g,"~0").replace(/\//g,"~1")}Object.defineProperty(exports,"__esModule",{value:!0}),exports.Pointer=void 0;var n=function(){function n(t){void 0===t&&(t=[""]),this.tokens=t}return n.fromJSON=function(e){var o=e.split("/").map(t);if(""!==o[0])throw new Error("Invalid JSON Pointer: "+e);return new n(o)},n.prototype.toString=function(){return this.tokens.map(e).join("/")},n.prototype.evaluate=function(t){for(var e=null,n="",o=t,r=1,i=this.tokens.length;r0&&a>0&&!n(e[i-1],t[a-1],new r.Pointer).length)s=o(i-1,a-1);else{var f=[];if(i>0){var v=o(i-1,a),d={op:"remove",index:i-1};f.push(p(v,d))}if(a>0){var l=o(i,a-1),h={op:"add",index:i-1,value:t[a-1]};f.push(p(l,h))}if(i>0&&a>0){var x=o(i-1,a-1),g={op:"replace",index:i-1,original:e[i-1],value:t[a-1]};f.push(p(x,g))}s=f.sort(function(r,e){return r.cost-e.cost})[0]}c[u]=s}return s}(u,f).operations.reduce(function(r,e){var t=r[0],p=r[1];if(i(e)){var c=e.index+1+p,s=c=n.parent.length)return new o(t.path)}else if(void 0===n.value)return new o(t.path);return n.parent[n.key]=t.value,null}function v(r,t){var n=e.Pointer.fromJSON(t.from).evaluate(r);if(void 0===n.value)return new o(t.from);var a=e.Pointer.fromJSON(t.path).evaluate(r);return void 0===a.parent?new o(t.path):(u(n.parent,n.key),i(a.parent,a.key,n.value),null)}function c(r,n){var a=e.Pointer.fromJSON(n.from).evaluate(r);if(void 0===a.value)return new o(n.from);var u=e.Pointer.fromJSON(n.path).evaluate(r);return void 0===u.parent?new o(n.path):(i(u.parent,u.key,t.clone(a.value)),null)}function f(r,t){var o=e.Pointer.fromJSON(t.path).evaluate(r);return n.diffAny(o.value,t.value,new e.Pointer).length?new a(o.value,t.value):null}exports.TestError=a,exports.add=p,exports.remove=l,exports.replace=s,exports.move=v,exports.copy=c,exports.test=f;var h=function(e){function t(r){var t=e.call(this,"Invalid operation: "+r.op)||this;return t.operation=r,t.name="InvalidOperationError",t}return r(t,e),t}(Error);function y(r,e){switch(e.op){case"add":return p(r,e);case"remove":return l(r,e);case"replace":return s(r,e);case"move":return v(r,e);case"copy":return c(r,e);case"test":return f(r,e)}return new h(e)}exports.InvalidOperationError=h,exports.apply=y; },{"./pointer":"Al58","./util":"HHTq","./diff":"gukC"}],"B6py":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.createTests=exports.createPatch=exports.applyPatch=void 0;var r=require("./pointer"),e=require("./patch"),t=require("./diff");function n(r,t){return t.map(function(t){return e.apply(r,t)})}function a(r){return function e(n,a,i){var o=r(n,a,i);return Array.isArray(o)?o:t.diffAny(n,a,i,e)}}function i(e,n,i){var o=new r.Pointer;return(i?a(i):t.diffAny)(e,n,o)}function o(e,t){var n=r.Pointer.fromJSON(t).evaluate(e);if(void 0!==n)return{op:"test",path:t,value:n.value}}function u(r,e){var n=new Array;return e.filter(t.isDestructive).forEach(function(e){var t=o(r,e.path);if(t&&n.push(t),"from"in e){var a=o(r,e.from);a&&n.push(a)}}),n}exports.applyPatch=n,exports.createPatch=i,exports.createTests=u; },{"./pointer":"Al58","./patch":"datJ","./diff":"gukC"}],"iEGk":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.C=g,exports.I=i,exports.P=s,exports.T=void 0;var e,t,n=require("./turn-order-0b7dce3d.js"),a=require("rfc6902");function r({moves:e,phases:t,endIf:a,onEnd:r,turn:o,events:s,plugins:i}){void 0===e&&(e={}),void 0===s&&(s={}),void 0===i&&(i=[]),void 0===t&&(t={}),a||(a=(()=>void 0)),r||(r=(e=>e)),o||(o={});const c={...t};""in c&&(0,n.e)("cannot specify phase with empty name"),c[""]={};const u={},l=new Set;let d=null;Object.keys(e).forEach(e=>l.add(e));const p=(e,t)=>{const a=(0,n.F)(e,t,i);return e=>{const t=(0,n.E)(e);return a(e.G,t)}},v=e=>t=>{const a=(0,n.E)(t);return e(t.G,a)},f={onEnd:p(r,n.G.GAME_ON_END),endIf:v(a)};for(const B in c){const e=c[B];if(!0===e.start&&(d=B),void 0!==e.moves)for(const t of Object.keys(e.moves))u[B+"."+t]=e.moves[t],l.add(t);void 0===e.endIf&&(e.endIf=(()=>void 0)),void 0===e.onBegin&&(e.onBegin=(e=>e)),void 0===e.onEnd&&(e.onEnd=(e=>e)),void 0===e.turn&&(e.turn=o),void 0===e.turn.order&&(e.turn.order=n.T.DEFAULT),void 0===e.turn.onBegin&&(e.turn.onBegin=(e=>e)),void 0===e.turn.onEnd&&(e.turn.onEnd=(e=>e)),void 0===e.turn.endIf&&(e.turn.endIf=(()=>!1)),void 0===e.turn.onMove&&(e.turn.onMove=(e=>e)),void 0===e.turn.stages&&(e.turn.stages={}),(0,n.a)(e.turn,!0);for(const t in e.turn.stages){const n=e.turn.stages[t].moves||{};for(const e of Object.keys(n)){u[B+"."+t+"."+e]=n[e],l.add(e)}}if(e.wrapped={onBegin:p(e.onBegin,n.G.PHASE_ON_BEGIN),onEnd:p(e.onEnd,n.G.PHASE_ON_END),endIf:v(e.endIf)},e.turn.wrapped={onMove:p(e.turn.onMove,n.G.TURN_ON_MOVE),onBegin:p(e.turn.onBegin,n.G.TURN_ON_BEGIN),onEnd:p(e.turn.onEnd,n.G.TURN_ON_END),endIf:v(e.turn.endIf)},"function"!=typeof e.next){const{next:t}=e;e.next=(()=>t||null)}e.wrapped.next=v(e.next)}function y(e){return e.phase?c[e.phase]:c[""]}function g(e){return e}function m(e,t){const n=new Set,a=new Set;for(let r=0;r=t.turn.maxMoves)||t.turn.wrapped.endIf(e)}function w(e,{arg:t,phase:n}){e=N(e,{phase:n}),void 0===t&&(t=!0),e={...e,ctx:{...e.ctx,gameover:t}};const a=f.onEnd(e);return{...e,G:a}}function N(e,{arg:t,next:a,turn:r,automatic:o}){e=A(e,{turn:r,force:!0,automatic:!0});const{phase:s,turn:i}=e.ctx;if(a&&a.push({fn:P,arg:t,phase:s}),null===s)return e;const c=y(e.ctx).wrapped.onEnd(e),u={...e.ctx,phase:null},l=(0,n.g)("endPhase",t),{_stateID:d}=e,p={action:l,_stateID:d,turn:i,phase:s};o&&(p.automatic=!0);const v=[...e.deltalog||[],p];return{...e,G:c,ctx:u,deltalog:v}}function A(e,{arg:t,next:a,turn:r,force:o,automatic:s,playerID:i}){if(r!==e.ctx.turn)return e;const{currentPlayer:c,numMoves:u,phase:l,turn:d}=e.ctx,p=y(e.ctx),v=u||0;if(!o&&p.turn.minMoves&&ve!=i),n=g.playOrderPos>t.length-1?0:g.playOrderPos;if(g={...g,playOrder:t,playOrderPos:n},0===t.length)return a.push({fn:N,turn:d,phase:l}),e}const m=(0,n.g)("endTurn",t),{_stateID:x}=e,h={action:m,_stateID:x,turn:d,phase:l};s&&(h.automatic=!0);const I=[...e.deltalog||[],h];return{...e,G:f,ctx:g,deltalog:I,_undo:[],_redo:[]}}function O(e,{arg:t,next:a,automatic:r,playerID:o}){o=o||e.ctx.currentPlayer;let{ctx:s,_stateID:i}=e,{activePlayers:c,_activePlayersNumMoves:u,_activePlayersMinMoves:l,_activePlayersMaxMoves:d,phase:p,turn:v}=s;const f=null!==c&&o in c,g=y(s);if(!t&&f){const e=g.turn.stages[c[o]];e&&e.next&&(t=e.next)}if(a&&a.push({fn:M,arg:t,playerID:o}),!f)return e;const m=u[o]||0;if(l&&l[o]&&m({numPlayers:e,turn:0,currentPlayer:"0",playOrder:[...Array.from({length:e})].map((e,t)=>t+""),playOrderPos:0,phase:d,activePlayers:null}),init:e=>m(e,[{fn:x}]),isPlayerActive:function(e,t,n){return t.activePlayers?n in t.activePlayers:t.currentPlayer===n},eventHandlers:U,eventNames:Object.keys(U),enabledEventNames:T,moveMap:u,moveNames:[...l.values()],processMove:function(e,t){const{playerID:n,type:a}=t,{currentPlayer:r,activePlayers:o,_activePlayersMaxMoves:s}=e.ctx,i=S(e.ctx,a,n),c=!i||"function"==typeof i||!0!==i.noLimit;let{numMoves:u,_activePlayersNumMoves:l}=e.ctx;c&&(n===r&&u++,o&&l[n]++),e={...e,ctx:{...e.ctx,numMoves:u,_activePlayersNumMoves:l}},s&&l[n]>=s[n]&&(e=O(e,{playerID:n,automatic:!0}));const d=y(e.ctx).turn.wrapped.onMove({...e,ctx:{...e.ctx,playerID:n}});return m(e={...e,G:d},[{fn:g}])},processEvent:function(e,t){const{type:n,playerID:a,args:r}=t.payload;return"function"!=typeof U[n]?e:U[n](e,a,...Array.isArray(r)?r:[r])},getMove:S}}function o(e){return void 0!==e.processMove}function s(e){if(o(e))return e;if(void 0===e.name&&(e.name="default"),void 0===e.deltaState&&(e.deltaState=!1),void 0===e.disableUndo&&(e.disableUndo=!1),void 0===e.setup&&(e.setup=(()=>({}))),void 0===e.moves&&(e.moves={}),void 0===e.playerView&&(e.playerView=(e=>e)),void 0===e.plugins&&(e.plugins=[]),e.plugins.forEach(e=>{if(void 0===e.name)throw new Error("Plugin missing name attribute");if(e.name.includes(" "))throw new Error(e.name+": Plugin name must not include spaces")}),e.name.includes(" "))throw new Error(e.name+": Game name must not include spaces");const t=r(e);return{...e,flow:t,moveNames:t.moveNames,pluginNames:e.plugins.map(e=>e.name),processMove:(a,r)=>{let o=t.getMove(a.ctx,r.type,r.playerID);if(i(o)&&(o=o.move),o instanceof Function){const t=(0,n.F)(o,n.G.MOVE,e.plugins),s={...(0,n.E)(a),playerID:r.playerID};let i=[];return void 0!==r.args&&(i=Array.isArray(r.args)?r.args:[r.args]),t(a.G,s,...i)}return(0,n.e)(`invalid move object: ${r.type}`),a.G}}}function i(e){return e instanceof Object&&void 0!==e.move}!function(e){e.UnauthorizedAction="update/unauthorized_action",e.MatchNotFound="update/match_not_found",e.PatchFailed="update/patch_failed"}(e||(e={})),function(e){e.StaleStateId="action/stale_state_id",e.UnavailableMove="action/unavailable_move",e.InvalidMove="action/invalid_move",e.InactivePlayer="action/inactive_player",e.GameOver="action/gameover",e.ActionDisabled="action/action_disabled",e.ActionInvalid="action/action_invalid",e.PluginActionInvalid="action/plugin_invalid"}(t||(t={}));const c=e=>null!==e.payload.playerID&&void 0!==e.payload.playerID,u=(e,t,n)=>{return!function(e){return void 0!==e.undoable}(n)||(function(e){return e instanceof Function}(n.undoable)?n.undoable(e,t):n.undoable)};function l(e,t){if(t.game.disableUndo)return e;const n={G:e.G,ctx:e.ctx,plugins:e.plugins,playerID:t.action.payload.playerID||e.ctx.currentPlayer};return"MAKE_MOVE"===t.action.type&&(n.moveType=t.action.payload.type),{...e,_undo:[...e._undo,n],_redo:[]}}function d(e,t,n){const a={action:t,_stateID:e._stateID,turn:e.ctx.turn,phase:e.ctx.phase},r=e.plugins.log.data.metadata;return void 0!==r&&(a.metadata=r),"object"==typeof n&&!0===n.redact&&(a.redact=!0),{...e,deltalog:[a]}}function p(e,a,r){const[o,s]=(0,n.q)(e,r);return s?[o,f(a,t.PluginActionInvalid,s)]:[o]}function v(e){if(!e)return[null,void 0];const{transients:t,...n}=e;return[n,t]}function f(e,t,n){return{...e,transients:{error:{type:t,payload:n}}}}const y=e=>t=>a=>{const r=t(a);switch(a.type){case n.p:return r;default:{const[,t]=v(e.getState());return void 0!==t?(e.dispatch((0,n.r)()),{...r,transients:t}):r}}};function g({game:r,isClient:o}){return r=s(r),(s=null,i)=>{let[y]=v(s);switch(i.type){case n.p:return y;case n.o:{if(y={...y,deltalog:[]},o)return y;if(void 0!==y.ctx.gameover)return(0,n.e)("cannot call event after game end"),f(y,t.GameOver);if(c(i)&&!r.flow.isPlayerActive(y.G,y.ctx,i.payload.playerID))return(0,n.e)(`disallowed event: ${i.payload.type}`),f(y,t.InactivePlayer);y=(0,n.m)(y,{game:r,isClient:!1,playerID:i.payload.playerID});let e,a=r.flow.processEvent(y,i);return[a,e]=p(a,y,{game:r,isClient:!1}),e?e:(a=l(a,{game:r,action:i}),{...a,_stateID:y._stateID+1})}case n.M:{const e=y={...y,deltalog:[]},a=r.flow.getMove(y.ctx,i.payload.type,i.payload.playerID||y.ctx.currentPlayer);if(null===a)return(0,n.e)(`disallowed move: ${i.payload.type}`),f(y,t.UnavailableMove);if(o&&!1===a.client)return y;if(void 0!==y.ctx.gameover)return(0,n.e)("cannot make move after game end"),f(y,t.GameOver);if(c(i)&&!r.flow.isPlayerActive(y.G,y.ctx,i.payload.playerID))return(0,n.e)(`disallowed move: ${i.payload.type}`),f(y,t.InactivePlayer);y=(0,n.m)(y,{game:r,isClient:o,playerID:i.payload.playerID});const s=r.processMove(y,i.payload);if(s===n.n)return(0,n.e)(`invalid move: ${i.payload.type} args: ${i.payload.args}`),f(y,t.InvalidMove);const u={...y,G:s};if(o&&(0,n.N)(u,{game:r}))return y;if(y=u,o){let t;return[y,t]=p(y,e,{game:r,isClient:!0}),t||{...y,_stateID:y._stateID+1}}let v;return y=d(y,i,a),y=r.flow.processMove(y,i.payload),[y,v]=p(y,e,{game:r}),v?v:(y=l(y,{game:r,action:i}),{...y,_stateID:y._stateID+1})}case n.l:case n.k:case n.j:return i.state;case n.h:{if(y={...y,deltalog:[]},r.disableUndo)return(0,n.e)("Undo is not enabled"),f(y,t.ActionDisabled);const{G:e,ctx:a,_undo:o,_redo:s,_stateID:l}=y;if(o.length<2)return(0,n.e)("No moves to undo"),f(y,t.ActionInvalid);const p=o[o.length-1],v=o[o.length-2];if(c(i)&&i.payload.playerID!==p.playerID)return(0,n.e)("Cannot undo other players' moves"),f(y,t.ActionInvalid);if(p.moveType){const o=r.flow.getMove(v.ctx,p.moveType,p.playerID);if(!u(e,a,o))return(0,n.e)("Move cannot be undone"),f(y,t.ActionInvalid)}return y=d(y,i),{...y,G:v.G,ctx:v.ctx,plugins:v.plugins,_stateID:l+1,_undo:o.slice(0,-1),_redo:[p,...s]}}case n.R:{if(y={...y,deltalog:[]},r.disableUndo)return(0,n.e)("Redo is not enabled"),f(y,t.ActionDisabled);const{_undo:e,_redo:a,_stateID:o}=y;if(0===a.length)return(0,n.e)("No moves to redo"),f(y,t.ActionInvalid);const s=a[0];return c(i)&&i.payload.playerID!==s.playerID?((0,n.e)("Cannot redo other players' moves"),f(y,t.ActionInvalid)):(y=d(y,i),{...y,G:s.G,ctx:s.ctx,plugins:s.plugins,_stateID:o+1,_undo:[...e,s],_redo:a.slice(1)})}case n.d:return(0,n.f)(y,i,{game:r});case n.P:{const t=y,r=JSON.parse(JSON.stringify(t)),o=(0,a.applyPatch)(r,i.patch);return o.some(e=>null!==e)?((0,n.e)(`Patch ${JSON.stringify(i.patch)} apply failed`),f(t,e.PatchFailed,o)):r}default:return y}}}exports.T=y; },{"./turn-order-0b7dce3d.js":"MZmr","rfc6902":"B6py"}],"O5av":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.fromJSON=exports.toJSON=exports.stringify=exports.parse=void 0;const{parse:t,stringify:e}=JSON,{keys:s}=Object,n=String,o="string",r={},c="object",l=(t,e)=>e,p=t=>t instanceof n?n(t):t,i=(t,e)=>typeof e===o?new n(e):e,a=(t,e,o,l)=>{const p=[];for(let i=s(o),{length:a}=i,f=0;f{const o=n(e.push(s)-1);return t.set(s,o),o},u=(e,s)=>{const n=t(e,i).map(p),o=n[0],r=s||l,f=typeof o===c&&o?a(n,new Set,o,r):o;return r.call({"":f},"",f)};exports.parse=u;const y=(t,s,n)=>{const r=s&&typeof s===c?(t,e)=>""===t||-1t(y(e));exports.toJSON=x;const g=t=>u(e(t));exports.fromJSON=g; },{}],"pBGv":[function(require,module,exports) { var t,e,n=module.exports={};function r(){throw new Error("setTimeout has not been defined")}function o(){throw new Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(n){try{return t.call(null,e,0)}catch(n){return t.call(this,e,0)}}}function u(t){if(e===clearTimeout)return clearTimeout(t);if((e===o||!e)&&clearTimeout)return e=clearTimeout,clearTimeout(t);try{return e(t)}catch(n){try{return e.call(null,t)}catch(n){return e.call(this,t)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(n){t=r}try{e="function"==typeof clearTimeout?clearTimeout:o}catch(n){e=o}}();var c,s=[],l=!1,a=-1;function f(){l&&c&&(l=!1,c.length?s=c.concat(s):a=-1,s.length&&h())}function h(){if(!l){var t=i(f);l=!0;for(var e=s.length;e;){for(c=s,s=[];++a1)for(var n=1;n"payload"in e?e:"move"in e?(0,t.B)(e.move,e.args,a):"event"in e?(0,t.g)(e.event,e.args,a):void 0)}random(t){let r;if(void 0!==this.seed){const t=this.prngstate?"":this.seed,a=(0,e.a)(t,this.prngstate);r=a(),this.prngstate=a.state()}else r=Math.random();if(t){if(Array.isArray(t)){return t[Math.floor(r*t.length)]}return Math.floor(r*t)}return r}}exports.B=a;const s=25;class i extends a{constructor({enumerate:t,seed:e,objectives:a,game:s,iterations:i,playoutDepth:n,iterationCallback:o}){super({enumerate:t,seed:e}),void 0===a&&(a=(()=>({}))),this.objectives=a,this.iterationCallback=o||(()=>{}),this.reducer=(0,r.C)({game:s}),this.iterations=i,this.playoutDepth=n,this.addOpt({key:"async",initial:!1}),this.addOpt({key:"iterations",initial:"number"==typeof i?i:1e3,range:{min:1,max:2e3}}),this.addOpt({key:"playoutDepth",initial:"number"==typeof n?n:50,range:{min:1,max:100}})}createNode({state:t,parentAction:e,parent:r,playerID:a}){const{G:s,ctx:i}=t;let n=[],o=[];if(void 0!==a)n=this.enumerate(s,i,a),o=this.objectives(s,i,a);else if(i.activePlayers)for(const c in i.activePlayers)n.push(...this.enumerate(s,i,c)),o.push(this.objectives(s,i,c));else n=this.enumerate(s,i,i.currentPlayer),o=this.objectives(s,i,i.currentPlayer);return{state:t,parent:r,parentAction:e,actions:n,objectives:o,children:[],visits:0,value:0}}select(t){if(t.actions.length>0)return t;if(0===t.children.length)return t;let e=null,r=0;for(const a of t.children){const s=a.visits+Number.EPSILON,i=a.value/s+Math.sqrt(2*Math.log(t.visits)/s);(null==e||i>r)&&(r=i,e=a)}return this.select(e)}expand(t){const e=t.actions;if(0===e.length||void 0!==t.state.ctx.gameover)return t;const r=this.random(e.length),a=e[r];t.actions.splice(r,1);const s=this.reducer(t.state,a),i=this.createNode({state:s,parentAction:a,parent:t});return t.children.push(i),i}playout({state:t}){let e=this.getOpt("playoutDepth");"function"==typeof this.playoutDepth&&(e=this.playoutDepth(t.G,t.ctx));for(let r=0;r{const s=i[a];return s.checker(e,r)?t+s.weight:t},0);if(n>0)return{score:n};if(!s||0===s.length)return;const o=this.random(s.length);t=this.reducer(t,s[o])}return t.ctx.gameover}backpropagate(t,e={}){t.visits++,void 0!==e.score&&(t.value+=e.score),!0===e.draw&&(t.value+=.5),t.parentAction&&e.winner===t.parentAction.payload.playerID&&t.value++,t.parent&&this.backpropagate(t.parent,e)}play(t,e){const r=this.createNode({state:t,playerID:e});let a=this.getOpt("iterations");"function"==typeof this.iterations&&(a=this.iterations(t.G,t.ctx));const i=()=>{let t=null;for(const e of r.children)(null==t||e.visits>t.visits)&&(t=e);return{action:t&&t.parentAction,metadata:r}};return new Promise(t=>{const e=()=>{for(let t=0;t{this.iterationCountere;function a(e,t){for(const n in t)e[n]=t[n];return e}function s(e){return e()}function i(){return Object.create(null)}function c(e){e.forEach(s)}function u(e){return"function"==typeof e}function d(e,t){return e!=e?t==t:e!==t||e&&"object"==typeof e||"function"==typeof e}function f(e){return 0===Object.keys(e).length}function p(e,...t){if(null==e)return l;const n=e.subscribe(...t);return n.unsubscribe?()=>n.unsubscribe():n}function m(e,t,n){e.$$.on_destroy.push(p(t,n))}function g(e,t,n,r){if(e){const l=v(e,t,n,r);return e[0](l)}}function v(e,t,n,r){return e[1]&&r?a(n.ctx.slice(),e[1](r(t))):n.ctx}function $(e,t,n,r){if(e[2]&&r){const l=e[2](r(n));if(void 0===t.dirty)return l;if("object"==typeof l){const e=[],n=Math.max(t.dirty.length,l.length);for(let r=0;r32){const t=[],n=e.ctx.length/32;for(let e=0;ewindow.performance.now():()=>Date.now(),P=w?e=>requestAnimationFrame(e):l;const j=new Set;function E(e){j.forEach(t=>{t.c(e)||(j.delete(t),t.f())}),0!==j.size&&P(E)}function O(e){let t;return 0===j.size&&P(E),{promise:new Promise(n=>{j.add(t={c:e,f:n})}),abort(){j.delete(t)}}}function A(e,t){e.appendChild(t)}function z(e,t,n){const r=_(e);if(!r.getElementById(t)){const e=M("style");e.id=t,e.textContent=n,C(r,e)}}function _(e){if(!e)return document;const t=e.getRootNode?e.getRootNode():e.ownerDocument;return t.host?t:document}function S(e){const t=M("style");return C(_(e),t),t}function C(e,t){A(e.head||e,t)}function q(e,t,n){e.insertBefore(t,n||null)}function I(e){e.parentNode.removeChild(e)}function T(e,t){for(let n=0;ne.removeEventListener(t,n,r)}function K(e){return function(t){return t.stopPropagation(),e.call(this,t)}}function G(e,t,n){null==n?e.removeAttribute(t):e.getAttribute(t)!==n&&e.setAttribute(t,n)}function J(e){return""===e?null:+e}function L(e){return Array.from(e.childNodes)}function F(e,t){t=""+t,e.wholeText!==t&&(e.data=t)}function H(e,t){e.value=null==t?"":t}function Z(e,t){for(let n=0;n>>0}function ne(e,t,n,r,l,o,a,s=0){const i=16.666/r;let c="{\n";for(let v=0;v<=1;v+=i){const e=t+(n-t)*o(v);c+=100*v+`%{${a(e,1-e)}}\n`}const u=c+`100% {${a(n,1-n)}}\n}`,d=`__svelte_${te(u)}_${s}`,f=_(e);Y.add(f);const p=f.__svelte_stylesheet||(f.__svelte_stylesheet=S(e).sheet),m=f.__svelte_rules||(f.__svelte_rules={});m[d]||(m[d]=!0,p.insertRule(`@keyframes ${d} ${u}`,p.cssRules.length));const g=e.style.animation||"";return e.style.animation=`${g?`${g}, `:""}${d} ${r}ms linear ${l}ms 1 both`,ee+=1,d}function re(e,t){const n=(e.style.animation||"").split(", "),r=n.filter(t?e=>e.indexOf(t)<0:e=>-1===e.indexOf("__svelte")),l=n.length-r.length;l&&(e.style.animation=r.join(", "),(ee-=l)||le())}function le(){P(()=>{ee||(Y.forEach(e=>{const t=e.__svelte_stylesheet;let n=t.cssRules.length;for(;n--;)t.deleteRule(n);e.__svelte_rules={}}),Y.clear())})}function oe(e){Q=e}function ae(){if(!Q)throw new Error("Function called outside component initialization");return Q}function se(e){ae().$$.after_update.push(e)}function ie(e){ae().$$.on_destroy.push(e)}function ce(){const e=ae();return(t,n)=>{const r=e.$$.callbacks[t];if(r){const l=X(t,n);r.slice().forEach(t=>{t.call(e,l)})}}}function ue(e,t){ae().$$.context.set(e,t)}function de(e){return ae().$$.context.get(e)}function fe(e,t){const n=e.$$.callbacks[t.type];n&&n.slice().forEach(e=>e.call(this,t))}const pe=[],me=[],ge=[],ve=[],$e=Promise.resolve();let ye=!1;function he(){ye||(ye=!0,$e.then(ke))}function be(e){ge.push(e)}let xe=!1;const we=new Set;function ke(){if(!xe){xe=!0;do{for(let e=0;e{je=null}),je}function Oe(e,t,n){e.dispatchEvent(X(`${t?"intro":"outro"}${n}`))}const Ae=new Set;let ze;function _e(){ze={r:0,c:[],p:ze}}function Se(){ze.r||c(ze.c),ze=ze.p}function Ce(e,t){e&&e.i&&(Ae.delete(e),e.i(t))}function qe(e,t,n,r){if(e&&e.o){if(Ae.has(e))return;Ae.add(e),ze.c.push(()=>{Ae.delete(e),r&&(n&&e.d(1),r())}),e.o(t)}}const Ie={duration:0};function Te(e,t,n){let r,a,s=t(e,n),i=!1,c=0;function d(){r&&re(e,r)}function f(){const{delay:t=0,duration:n=300,easing:u=o,tick:f=l,css:p}=s||Ie;p&&(r=ne(e,0,1,n,t,u,p,c++)),f(0,1);const m=k()+t,g=m+n;a&&a.abort(),i=!0,be(()=>Oe(e,!0,"start")),a=O(t=>{if(i){if(t>=g)return f(1,0),Oe(e,!0,"end"),d(),i=!1;if(t>=m){const e=u((t-m)/n);f(e,1-e)}}return i})}let p=!1;return{start(){p||(p=!0,re(e),u(s)?(s=s(),Ee().then(f)):f())},invalidate(){p=!1},end(){i&&(d(),i=!1)}}}function Me(e,t,n){let r,a=t(e,n),s=!0;const i=ze;function d(){const{delay:t=0,duration:n=300,easing:u=o,tick:d=l,css:f}=a||Ie;f&&(r=ne(e,1,0,n,t,u,f));const p=k()+t,m=p+n;be(()=>Oe(e,!1,"start")),O(t=>{if(s){if(t>=m)return d(0,1),Oe(e,!1,"end"),--i.r||c(i.c),!1;if(t>=p){const e=u((t-p)/n);d(1-e,e)}}return s})}return i.r+=1,u(a)?Ee().then(()=>{a=a(),d()}):d(),{end(t){t&&a.tick&&a.tick(1,0),s&&(r&&re(e,r),s=!1)}}}function De(e,t,n,r){let a=t(e,n),s=r?0:1,i=null,d=null,f=null;function p(){f&&re(e,f)}function m(e,t){const n=e.b-s;return t*=Math.abs(n),{a:s,b:e.b,d:n,duration:t,start:e.start,end:e.start+t,group:e.group}}function g(t){const{delay:n=0,duration:r=300,easing:u=o,tick:g=l,css:v}=a||Ie,$={start:k()+n,b:t};t||($.group=ze,ze.r+=1),i||d?d=$:(v&&(p(),f=ne(e,s,t,r,n,u,v)),t&&g(0,1),i=m($,r),be(()=>Oe(e,t,"start")),O(t=>{if(d&&t>d.start&&(i=m(d,r),d=null,Oe(e,i.b,"start"),v&&(p(),f=ne(e,s,i.b,i.duration,0,u,a.css))),i)if(t>=i.end)g(s=i.b,1-s),Oe(e,i.b,"end"),d||(i.b?p():--i.group.r||c(i.group.c)),i=null;else if(t>=i.start){const e=t-i.start;s=i.a+i.d*u(e/i.duration),g(s,1-s)}return!(!i&&!d)}))}return{run(e){u(a)?Ee().then(()=>{a=a(),g(e)}):g(e)},end(){p(),i=d=null}}}function Ne(e,t){const n={},r={},l={$$scope:1};let o=e.length;for(;o--;){const a=e[o],s=t[o];if(s){for(const e in a)e in s||(r[e]=1);for(const e in s)l[e]||(n[e]=s[e],l[e]=1);e[o]=s}else for(const e in a)l[e]=1}for(const a in r)a in n||(n[a]=void 0);return n}function Ve(e){return"object"==typeof e&&null!==e?e:{}}function Be(e){e&&e.c()}function Re(e,t,n,r){const{fragment:l,on_mount:o,on_destroy:a,after_update:i}=e.$$;l&&l.m(t,n),r||be(()=>{const t=o.map(s).filter(u);a?a.push(...t):c(t),e.$$.on_mount=[]}),i.forEach(be)}function Ke(e,t){const n=e.$$;null!==n.fragment&&(c(n.on_destroy),n.fragment&&n.fragment.d(t),n.on_destroy=n.fragment=null,n.ctx=[])}function Ge(e,t){-1===e.$$.dirty[0]&&(pe.push(e),he(),e.$$.dirty.fill(0)),e.$$.dirty[t/31|0]|=1<{const l=r.length?r[0]:n;return f.ctx&&o(f.ctx[t],f.ctx[t]=l)&&(!f.skip_bound&&f.bound[t]&&f.bound[t](l),p&&Ge(e,t)),n}):[],f.update(),p=!0,c(f.before_update),f.fragment=!!r&&r(f.ctx),t.target){if(t.hydrate){const e=L(t.target);f.fragment&&f.fragment.l(e),e.forEach(I)}else f.fragment&&f.fragment.c();t.intro&&Ce(e.$$.fragment),Re(e,t.target,t.anchor,t.customElement),ke()}oe(d)}class Le{$destroy(){Ke(this,1),this.$destroy=l}$on(e,t){const n=this.$$.callbacks[e]||(this.$$.callbacks[e]=[]);return n.push(t),()=>{const e=n.indexOf(t);-1!==e&&n.splice(e,1)}}$set(e){this.$$set&&!f(e)&&(this.$$.skip_bound=!0,this.$$set(e),this.$$.skip_bound=!1)}}const Fe=[];function He(e,t=l){let n;const r=new Set;function o(t){if(d(e,t)&&(e=t,n)){const t=!Fe.length;for(const n of r)n[1](),Fe.push(n,e);if(t){for(let e=0;e{r.delete(i),0===r.size&&(n(),n=null)}}}}function Ze(e){const t=e-1;return t*t*t+1}function Ue(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var l=0;for(r=Object.getOwnPropertySymbols(e);l`\n\t\t\ttransform: ${c} translate(${(1-e)*l}px, ${(1-e)*o}px);\n\t\t\topacity: ${i-u*t}`}}function Xe(e){var{fallback:t}=e,n=Ue(e,["fallback"]);const r=new Map,l=new Map;function o(e,r,l){return(o,s)=>(e.set(s.key,{rect:o.getBoundingClientRect()}),()=>{if(r.has(s.key)){const{rect:e}=r.get(s.key);return r.delete(s.key),function(e,t,r){const{delay:l=0,duration:o=(e=>30*Math.sqrt(e)),easing:s=Ze}=a(a({},n),r),i=t.getBoundingClientRect(),c=e.left-i.left,d=e.top-i.top,f=e.width/i.width,p=e.height/i.height,m=Math.sqrt(c*c+d*d),g=getComputedStyle(t),v="none"===g.transform?"":g.transform,$=+g.opacity;return{delay:l,duration:u(o)?o(m):o,easing:s,css:(e,t)=>`\n\t\t\t\topacity: ${e*$};\n\t\t\t\ttransform-origin: top left;\n\t\t\t\ttransform: ${v} translate(${t*c}px,${t*d}px) scale(${e+(1-e)*f}, ${e+(1-e)*p});\n\t\t\t`}}(e,o,s)}return e.delete(s.key),t&&t(o,s,l)})}return[o(l,r,!1),o(r,l,!0)]}function Ye(e){z(e,"svelte-c8tyih","svg.svelte-c8tyih{stroke:currentColor;fill:currentColor;stroke-width:0;width:100%;height:auto;max-height:100%}")}function Qe(e){let t,n;return{c(){t=D("title"),n=N(e[0])},m(e,r){q(e,t,r),A(t,n)},p(e,t){1&t&&F(n,e[0])},d(e){e&&I(t)}}}function et(e){let t,n,r,l=e[0]&&Qe(e);const o=e[3].default,a=g(o,e,e[2],null);return{c(){t=D("svg"),l&&l.c(),n=B(),a&&a.c(),G(t,"xmlns","http://www.w3.org/2000/svg"),G(t,"viewBox",e[1]),G(t,"class","svelte-c8tyih")},m(e,o){q(e,t,o),l&&l.m(t,null),A(t,n),a&&a.m(t,null),r=!0},p(e,[s]){e[0]?l?l.p(e,s):((l=Qe(e)).c(),l.m(t,n)):l&&(l.d(1),l=null),a&&a.p&&(!r||4&s)&&y(a,o,e,e[2],r?$(o,e[2],s,null):h(e[2]),null),(!r||2&s)&&G(t,"viewBox",e[1])},i(e){r||(Ce(a,e),r=!0)},o(e){qe(a,e),r=!1},d(e){e&&I(t),l&&l.d(),a&&a.d(e)}}}function tt(e,t,n){let{$$slots:r={},$$scope:l}=t,{title:o=null}=t,{viewBox:a}=t;return e.$$set=(e=>{"title"in e&&n(0,o=e.title),"viewBox"in e&&n(1,a=e.viewBox),"$$scope"in e&&n(2,l=e.$$scope)}),[o,a,l,r]}class nt extends Le{constructor(e){super(),Je(this,e,tt,et,d,{title:0,viewBox:1},Ye)}}function rt(e){let t;return{c(){G(t=D("path"),"d","M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z")},m(e,n){q(e,t,n)},d(e){e&&I(t)}}}function lt(e){let t,n;const r=[{viewBox:"0 0 320 512"},e[0]];let l={$$slots:{default:[rt]},$$scope:{ctx:e}};for(let o=0;o{n(0,t=a(a({},t),b(e)))}),[t=b(t)]}class at extends Le{constructor(e){super(),Je(this,e,ot,lt,d,{})}}function st(e){z(e,"svelte-1xg9v5h",".menu.svelte-1xg9v5h{display:flex;margin-top:43px;flex-direction:row-reverse;border:1px solid #ccc;border-radius:5px 5px 0 0;height:25px;line-height:25px;margin-right:-500px;transform-origin:bottom right;transform:rotate(-90deg) translate(0, -500px)}.menu-item.svelte-1xg9v5h{line-height:25px;cursor:pointer;border:0;background:#fefefe;color:#555;padding-left:15px;padding-right:15px;text-align:center}.menu-item.svelte-1xg9v5h:first-child{border-radius:0 5px 0 0}.menu-item.svelte-1xg9v5h:last-child{border-radius:5px 0 0 0}.menu-item.active.svelte-1xg9v5h{cursor:default;font-weight:bold;background:#ddd;color:#555}.menu-item.svelte-1xg9v5h:hover,.menu-item.svelte-1xg9v5h:focus{background:#eee;color:#555}")}function it(e,t,n){const r=e.slice();return r[4]=t[n][0],r[5]=t[n][1].label,r}function ct(e){let t,n,r,l,o,a=e[5]+"";function s(){return e[3](e[4])}return{c(){t=M("button"),n=N(a),r=V(),G(t,"class","menu-item svelte-1xg9v5h"),W(t,"active",e[0]==e[4])},m(e,a){q(e,t,a),A(t,n),A(t,r),l||(o=R(t,"click",s),l=!0)},p(r,l){e=r,2&l&&a!==(a=e[5]+"")&&F(n,a),3&l&&W(t,"active",e[0]==e[4])},d(e){e&&I(t),l=!1,o()}}}function ut(e){let t,n=Object.entries(e[1]),r=[];for(let l=0;l{"pane"in e&&n(0,r=e.pane),"panes"in e&&n(1,l=e.panes)}),[r,l,o,e=>o("change",e)]}class ft extends Le{constructor(e){super(),Je(this,e,dt,ut,d,{pane:0,panes:1},st)}}var pt={};function mt(e){z(e,"svelte-1vyml86",".container.svelte-1vyml86{display:inline-block;cursor:pointer;transform:translate(calc(0px - var(--li-identation)), -50%);position:absolute;top:50%;padding-right:100%}.arrow.svelte-1vyml86{transform-origin:25% 50%;position:relative;line-height:1.1em;font-size:0.75em;margin-left:0;transition:150ms;color:var(--arrow-sign);user-select:none;font-family:'Courier New', Courier, monospace}.expanded.svelte-1vyml86{transform:rotateZ(90deg) translateX(-3px)}")}function gt(e){let t,n,r,o;return{c(){t=M("div"),(n=M("div")).textContent="▶",G(n,"class","arrow svelte-1vyml86"),W(n,"expanded",e[0]),G(t,"class","container svelte-1vyml86")},m(l,a){q(l,t,a),A(t,n),r||(o=R(t,"click",e[1]),r=!0)},p(e,[t]){1&t&&W(n,"expanded",e[0])},i:l,o:l,d(e){e&&I(t),r=!1,o()}}}function vt(e,t,n){let{expanded:r}=t;return e.$$set=(e=>{"expanded"in e&&n(0,r=e.expanded)}),[r,function(t){fe.call(this,e,t)}]}class $t extends Le{constructor(e){super(),Je(this,e,vt,gt,d,{expanded:0},mt)}}function yt(e){z(e,"svelte-1vlbacg","label.svelte-1vlbacg{display:inline-block;color:var(--label-color);padding:0}.spaced.svelte-1vlbacg{padding-right:var(--li-colon-space)}")}function ht(e){let t,n,r,l,o,a;return{c(){t=M("label"),n=M("span"),r=N(e[0]),l=N(e[2]),G(t,"class","svelte-1vlbacg"),W(t,"spaced",e[1])},m(s,i){q(s,t,i),A(t,n),A(n,r),A(n,l),o||(a=R(t,"click",e[5]),o=!0)},p(e,n){1&n&&F(r,e[0]),4&n&&F(l,e[2]),2&n&&W(t,"spaced",e[1])},d(e){e&&I(t),o=!1,a()}}}function bt(e){let t,n=e[3]&&e[0]&&ht(e);return{c(){n&&n.c(),t=B()},m(e,r){n&&n.m(e,r),q(e,t,r)},p(e,[r]){e[3]&&e[0]?n?n.p(e,r):((n=ht(e)).c(),n.m(t.parentNode,t)):n&&(n.d(1),n=null)},i:l,o:l,d(e){n&&n.d(e),e&&I(t)}}}function xt(e,t,n){let r,{key:l,isParentExpanded:o,isParentArray:a=!1,colon:s=":"}=t;return e.$$set=(e=>{"key"in e&&n(0,l=e.key),"isParentExpanded"in e&&n(1,o=e.isParentExpanded),"isParentArray"in e&&n(4,a=e.isParentArray),"colon"in e&&n(2,s=e.colon)}),e.$$.update=(()=>{19&e.$$.dirty&&n(3,r=o||!a||l!=+l)}),[l,o,s,r,a,function(t){fe.call(this,e,t)}]}class wt extends Le{constructor(e){super(),Je(this,e,xt,bt,d,{key:0,isParentExpanded:1,isParentArray:4,colon:2},yt)}}function kt(e){z(e,"svelte-rwxv37","label.svelte-rwxv37{display:inline-block}.indent.svelte-rwxv37{padding-left:var(--li-identation)}.collapse.svelte-rwxv37{--li-display:inline;display:inline;font-style:italic}.comma.svelte-rwxv37{margin-left:-0.5em;margin-right:0.5em}label.svelte-rwxv37{position:relative}")}function Pt(e,t,n){const r=e.slice();return r[12]=t[n],r[20]=n,r}function jt(e){let t,n;return(t=new $t({props:{expanded:e[0]}})).$on("click",e[15]),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};1&n&&(r.expanded=e[0]),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Et(e){let t;return{c(){(t=M("span")).textContent="…"},m(e,n){q(e,t,n)},p:l,i:l,o:l,d(e){e&&I(t)}}}function Ot(e){let t,n,r,l,o,a=e[13],s=[];for(let u=0;uqe(s[e],1,1,()=>{s[e]=null});let c=e[13].length{h=null}),Se());const a={};4096&o&&(a.key=e[12]),4&o&&(a.isParentExpanded=e[2]),8&o&&(a.isParentArray=e[3]),l.$set(a),(!v||2&o)&&F(i,e[1]),(!v||32&o)&&F(c,e[5]);let s=d;(d=w(e))===s?x[d].p(e,o):(_e(),qe(x[s],1,1,()=>{x[s]=null}),Se(),(f=x[d])?f.p(e,o):(f=x[d]=b[d](e)).c(),Ce(f,1),f.m(t,p)),(!v||64&o)&&F(g,e[6]),4&o&&W(t,"indent",e[2])},i(e){v||(Ce(h),Ce(l.$$.fragment,e),Ce(f),v=!0)},o(e){qe(h),qe(l.$$.fragment,e),qe(f),v=!1},d(e){e&&I(t),h&&h.d(),Ke(l),x[d].d(),$=!1,y()}}}function Ct(e,t,n){let r,{key:l,keys:o,colon:a=":",label:s="",isParentExpanded:i,isParentArray:c,isArray:u=!1,bracketOpen:d,bracketClose:f}=t,{previewKeys:p=o}=t,{getKey:m=(e=>e)}=t,{getValue:g=(e=>e)}=t,{getPreviewValue:v=g}=t,{expanded:$=!1,expandable:y=!0}=t;const h=de(pt);return ue(pt,{...h,colon:a}),e.$$set=(e=>{"key"in e&&n(12,l=e.key),"keys"in e&&n(17,o=e.keys),"colon"in e&&n(18,a=e.colon),"label"in e&&n(1,s=e.label),"isParentExpanded"in e&&n(2,i=e.isParentExpanded),"isParentArray"in e&&n(3,c=e.isParentArray),"isArray"in e&&n(4,u=e.isArray),"bracketOpen"in e&&n(5,d=e.bracketOpen),"bracketClose"in e&&n(6,f=e.bracketClose),"previewKeys"in e&&n(7,p=e.previewKeys),"getKey"in e&&n(8,m=e.getKey),"getValue"in e&&n(9,g=e.getValue),"getPreviewValue"in e&&n(10,v=e.getPreviewValue),"expanded"in e&&n(0,$=e.expanded),"expandable"in e&&n(11,y=e.expandable)}),e.$$.update=(()=>{4&e.$$.dirty&&(i||n(0,$=!1)),131201&e.$$.dirty&&n(13,r=$?o:p.slice(0,5))}),[$,s,i,c,u,d,f,p,m,g,v,y,l,r,h,function(){n(0,$=!$)},function(){n(0,$=!0)},o,a]}class qt extends Le{constructor(e){super(),Je(this,e,Ct,St,d,{key:12,keys:17,colon:18,label:1,isParentExpanded:2,isParentArray:3,isArray:4,bracketOpen:5,bracketClose:6,previewKeys:7,getKey:8,getValue:9,getPreviewValue:10,expanded:0,expandable:11},kt)}}function It(e){let t,n;return t=new qt({props:{key:e[0],expanded:e[4],isParentExpanded:e[1],isParentArray:e[2],keys:e[5],previewKeys:e[5],getValue:e[6],label:e[3]+" ",bracketOpen:"{",bracketClose:"}"}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,[n]){const r={};1&n&&(r.key=e[0]),16&n&&(r.expanded=e[4]),2&n&&(r.isParentExpanded=e[1]),4&n&&(r.isParentArray=e[2]),32&n&&(r.keys=e[5]),32&n&&(r.previewKeys=e[5]),8&n&&(r.label=e[3]+" "),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Tt(e,t,n){let r,{key:l,value:o,isParentExpanded:a,isParentArray:s,nodeType:i}=t,{expanded:c=!0}=t;return e.$$set=(e=>{"key"in e&&n(0,l=e.key),"value"in e&&n(7,o=e.value),"isParentExpanded"in e&&n(1,a=e.isParentExpanded),"isParentArray"in e&&n(2,s=e.isParentArray),"nodeType"in e&&n(3,i=e.nodeType),"expanded"in e&&n(4,c=e.expanded)}),e.$$.update=(()=>{128&e.$$.dirty&&n(5,r=Object.getOwnPropertyNames(o))}),[l,a,s,i,c,r,function(e){return o[e]},o]}class Mt extends Le{constructor(e){super(),Je(this,e,Tt,It,d,{key:0,value:7,isParentExpanded:1,isParentArray:2,nodeType:3,expanded:4})}}function Dt(e){let t,n;return t=new qt({props:{key:e[0],expanded:e[4],isParentExpanded:e[2],isParentArray:e[3],isArray:!0,keys:e[5],previewKeys:e[6],getValue:e[7],label:"Array("+e[1].length+")",bracketOpen:"[",bracketClose:"]"}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,[n]){const r={};1&n&&(r.key=e[0]),16&n&&(r.expanded=e[4]),4&n&&(r.isParentExpanded=e[2]),8&n&&(r.isParentArray=e[3]),32&n&&(r.keys=e[5]),64&n&&(r.previewKeys=e[6]),2&n&&(r.label="Array("+e[1].length+")"),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Nt(e,t,n){let r,l,{key:o,value:a,isParentExpanded:s,isParentArray:i}=t,{expanded:c=JSON.stringify(a).length<1024}=t;const u=new Set(["length"]);return e.$$set=(e=>{"key"in e&&n(0,o=e.key),"value"in e&&n(1,a=e.value),"isParentExpanded"in e&&n(2,s=e.isParentExpanded),"isParentArray"in e&&n(3,i=e.isParentArray),"expanded"in e&&n(4,c=e.expanded)}),e.$$.update=(()=>{2&e.$$.dirty&&n(5,r=Object.getOwnPropertyNames(a)),32&e.$$.dirty&&n(6,l=r.filter(e=>!u.has(e)))}),[o,a,s,i,c,r,l,function(e){return a[e]}]}class Vt extends Le{constructor(e){super(),Je(this,e,Nt,Dt,d,{key:0,value:1,isParentExpanded:2,isParentArray:3,expanded:4})}}function Bt(e){let t,n;return t=new qt({props:{key:e[0],isParentExpanded:e[1],isParentArray:e[2],keys:e[4],getKey:Rt,getValue:Kt,isArray:!0,label:e[3]+"("+e[4].length+")",bracketOpen:"{",bracketClose:"}"}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,[n]){const r={};1&n&&(r.key=e[0]),2&n&&(r.isParentExpanded=e[1]),4&n&&(r.isParentArray=e[2]),16&n&&(r.keys=e[4]),24&n&&(r.label=e[3]+"("+e[4].length+")"),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Rt(e){return String(e[0])}function Kt(e){return e[1]}function Gt(e,t,n){let{key:r,value:l,isParentExpanded:o,isParentArray:a,nodeType:s}=t,i=[];return e.$$set=(e=>{"key"in e&&n(0,r=e.key),"value"in e&&n(5,l=e.value),"isParentExpanded"in e&&n(1,o=e.isParentExpanded),"isParentArray"in e&&n(2,a=e.isParentArray),"nodeType"in e&&n(3,s=e.nodeType)}),e.$$.update=(()=>{if(32&e.$$.dirty){let e=[],t=0;for(const n of l)e.push([t++,n]);n(4,i=e)}}),[r,o,a,s,i,l]}class Jt extends Le{constructor(e){super(),Je(this,e,Gt,Bt,d,{key:0,value:5,isParentExpanded:1,isParentArray:2,nodeType:3})}}class Lt{constructor(e,t){this.key=e,this.value=t}}function Ft(e){let t,n;return t=new qt({props:{key:e[0],isParentExpanded:e[1],isParentArray:e[2],keys:e[4],getKey:Ht,getValue:Zt,label:e[3]+"("+e[4].length+")",colon:"",bracketOpen:"{",bracketClose:"}"}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,[n]){const r={};1&n&&(r.key=e[0]),2&n&&(r.isParentExpanded=e[1]),4&n&&(r.isParentArray=e[2]),16&n&&(r.keys=e[4]),24&n&&(r.label=e[3]+"("+e[4].length+")"),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Ht(e){return e[0]}function Zt(e){return e[1]}function Ut(e,t,n){let{key:r,value:l,isParentExpanded:o,isParentArray:a,nodeType:s}=t,i=[];return e.$$set=(e=>{"key"in e&&n(0,r=e.key),"value"in e&&n(5,l=e.value),"isParentExpanded"in e&&n(1,o=e.isParentExpanded),"isParentArray"in e&&n(2,a=e.isParentArray),"nodeType"in e&&n(3,s=e.nodeType)}),e.$$.update=(()=>{if(32&e.$$.dirty){let e=[],t=0;for(const n of l)e.push([t++,new Lt(n[0],n[1])]);n(4,i=e)}}),[r,o,a,s,i,l]}class Wt extends Le{constructor(e){super(),Je(this,e,Ut,Ft,d,{key:0,value:5,isParentExpanded:1,isParentArray:2,nodeType:3})}}function Xt(e){let t,n;return t=new qt({props:{expanded:e[4],isParentExpanded:e[2],isParentArray:e[3],key:e[2]?String(e[0]):e[1].key,keys:e[5],getValue:e[6],label:e[2]?"Entry ":"=> ",bracketOpen:"{",bracketClose:"}"}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,[n]){const r={};16&n&&(r.expanded=e[4]),4&n&&(r.isParentExpanded=e[2]),8&n&&(r.isParentArray=e[3]),7&n&&(r.key=e[2]?String(e[0]):e[1].key),4&n&&(r.label=e[2]?"Entry ":"=> "),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Yt(e,t,n){let{key:r,value:l,isParentExpanded:o,isParentArray:a}=t,{expanded:s=!1}=t;return e.$$set=(e=>{"key"in e&&n(0,r=e.key),"value"in e&&n(1,l=e.value),"isParentExpanded"in e&&n(2,o=e.isParentExpanded),"isParentArray"in e&&n(3,a=e.isParentArray),"expanded"in e&&n(4,s=e.expanded)}),[r,l,o,a,s,["key","value"],function(e){return l[e]}]}class Qt extends Le{constructor(e){super(),Je(this,e,Yt,Xt,d,{key:0,value:1,isParentExpanded:2,isParentArray:3,expanded:4})}}function en(e){z(e,"svelte-3bjyvl","li.svelte-3bjyvl{user-select:text;word-wrap:break-word;word-break:break-all}.indent.svelte-3bjyvl{padding-left:var(--li-identation)}.String.svelte-3bjyvl{color:var(--string-color)}.Date.svelte-3bjyvl{color:var(--date-color)}.Number.svelte-3bjyvl{color:var(--number-color)}.Boolean.svelte-3bjyvl{color:var(--boolean-color)}.Null.svelte-3bjyvl{color:var(--null-color)}.Undefined.svelte-3bjyvl{color:var(--undefined-color)}.Function.svelte-3bjyvl{color:var(--function-color);font-style:italic}.Symbol.svelte-3bjyvl{color:var(--symbol-color)}")}function tn(e){let t,n,r,l,o,a,s,i=(e[2]?e[2](e[1]):e[1])+"";return n=new wt({props:{key:e[0],colon:e[6],isParentExpanded:e[3],isParentArray:e[4]}}),{c(){t=M("li"),Be(n.$$.fragment),r=V(),l=M("span"),o=N(i),G(l,"class",a=x(e[5])+" svelte-3bjyvl"),G(t,"class","svelte-3bjyvl"),W(t,"indent",e[3])},m(e,a){q(e,t,a),Re(n,t,null),A(t,r),A(t,l),A(l,o),s=!0},p(e,[r]){const c={};1&r&&(c.key=e[0]),8&r&&(c.isParentExpanded=e[3]),16&r&&(c.isParentArray=e[4]),n.$set(c),(!s||6&r)&&i!==(i=(e[2]?e[2](e[1]):e[1])+"")&&F(o,i),(!s||32&r&&a!==(a=x(e[5])+" svelte-3bjyvl"))&&G(l,"class",a),8&r&&W(t,"indent",e[3])},i(e){s||(Ce(n.$$.fragment,e),s=!0)},o(e){qe(n.$$.fragment,e),s=!1},d(e){e&&I(t),Ke(n)}}}function nn(e,t,n){let{key:r,value:l,valueGetter:o=null,isParentExpanded:a,isParentArray:s,nodeType:i}=t;const{colon:c}=de(pt);return e.$$set=(e=>{"key"in e&&n(0,r=e.key),"value"in e&&n(1,l=e.value),"valueGetter"in e&&n(2,o=e.valueGetter),"isParentExpanded"in e&&n(3,a=e.isParentExpanded),"isParentArray"in e&&n(4,s=e.isParentArray),"nodeType"in e&&n(5,i=e.nodeType)}),[r,l,o,a,s,i,c]}class rn extends Le{constructor(e){super(),Je(this,e,nn,tn,d,{key:0,value:1,valueGetter:2,isParentExpanded:3,isParentArray:4,nodeType:5},en)}}function ln(e){z(e,"svelte-1ca3gb2","li.svelte-1ca3gb2{user-select:text;word-wrap:break-word;word-break:break-all}.indent.svelte-1ca3gb2{padding-left:var(--li-identation)}.collapse.svelte-1ca3gb2{--li-display:inline;display:inline;font-style:italic}")}function on(e,t,n){const r=e.slice();return r[8]=t[n],r[10]=n,r}function an(e){let t,n;return(t=new $t({props:{expanded:e[0]}})).$on("click",e[7]),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};1&n&&(r.expanded=e[0]),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function sn(e){let t,n,r=e[0]&&cn(e);return{c(){t=M("ul"),r&&r.c(),G(t,"class","svelte-1ca3gb2"),W(t,"collapse",!e[0])},m(e,l){q(e,t,l),r&&r.m(t,null),n=!0},p(e,n){e[0]?r?(r.p(e,n),1&n&&Ce(r,1)):((r=cn(e)).c(),Ce(r,1),r.m(t,null)):r&&(_e(),qe(r,1,1,()=>{r=null}),Se()),1&n&&W(t,"collapse",!e[0])},i(e){n||(Ce(r),n=!0)},o(e){qe(r),n=!1},d(e){e&&I(t),r&&r.d()}}}function cn(e){let t,n,r,l,o,a,s;t=new $n({props:{key:"message",value:e[2].message}}),l=new wt({props:{key:"stack",colon:":",isParentExpanded:e[3]}});let i=e[5],c=[];for(let u=0;u0)},m(e,l){q(e,t,l),A(t,n),q(e,r,l)},p(e,t){32&t&&l!==(l=e[8]+"")&&F(n,l)},d(e){e&&I(t),e&&I(r)}}}function dn(e){let t,n,r,l,o,a,s,i,c,u,d,f=(e[0]?"":e[2].message)+"",p=e[3]&&an(e);r=new wt({props:{key:e[1],colon:e[6].colon,isParentExpanded:e[3],isParentArray:e[4]}});let m=e[3]&&sn(e);return{c(){t=M("li"),p&&p.c(),n=V(),Be(r.$$.fragment),l=V(),o=M("span"),a=N("Error: "),s=N(f),i=V(),m&&m.c(),G(t,"class","svelte-1ca3gb2"),W(t,"indent",e[3])},m(f,g){q(f,t,g),p&&p.m(t,null),A(t,n),Re(r,t,null),A(t,l),A(t,o),A(o,a),A(o,s),A(t,i),m&&m.m(t,null),c=!0,u||(d=R(o,"click",e[7]),u=!0)},p(e,[l]){e[3]?p?(p.p(e,l),8&l&&Ce(p,1)):((p=an(e)).c(),Ce(p,1),p.m(t,n)):p&&(_e(),qe(p,1,1,()=>{p=null}),Se());const o={};2&l&&(o.key=e[1]),8&l&&(o.isParentExpanded=e[3]),16&l&&(o.isParentArray=e[4]),r.$set(o),(!c||5&l)&&f!==(f=(e[0]?"":e[2].message)+"")&&F(s,f),e[3]?m?(m.p(e,l),8&l&&Ce(m,1)):((m=sn(e)).c(),Ce(m,1),m.m(t,null)):m&&(_e(),qe(m,1,1,()=>{m=null}),Se()),8&l&&W(t,"indent",e[3])},i(e){c||(Ce(p),Ce(r.$$.fragment,e),Ce(m),c=!0)},o(e){qe(p),qe(r.$$.fragment,e),qe(m),c=!1},d(e){e&&I(t),p&&p.d(),Ke(r),m&&m.d(),u=!1,d()}}}function fn(e,t,n){let r,{key:l,value:o,isParentExpanded:a,isParentArray:s}=t,{expanded:i=!1}=t;const c=de(pt);return ue(pt,{...c,colon:":"}),e.$$set=(e=>{"key"in e&&n(1,l=e.key),"value"in e&&n(2,o=e.value),"isParentExpanded"in e&&n(3,a=e.isParentExpanded),"isParentArray"in e&&n(4,s=e.isParentArray),"expanded"in e&&n(0,i=e.expanded)}),e.$$.update=(()=>{4&e.$$.dirty&&n(5,r=o.stack.split("\n")),8&e.$$.dirty&&(a||n(0,i=!1))}),[i,l,o,a,s,r,c,function(){n(0,i=!i)}]}class pn extends Le{constructor(e){super(),Je(this,e,fn,dn,d,{key:1,value:2,isParentExpanded:3,isParentArray:4,expanded:0},ln)}}function mn(e){const t=Object.prototype.toString.call(e).slice(8,-1);return"Object"===t?"function"==typeof e[Symbol.iterator]?"Iterable":e.constructor.name:t}function gn(e){let t,n,r;var l=e[6];function o(e){return{props:{key:e[0],value:e[1],isParentExpanded:e[2],isParentArray:e[3],nodeType:e[4],valueGetter:e[5]}}}return l&&(t=new l(o(e))),{c(){t&&Be(t.$$.fragment),n=B()},m(e,l){t&&Re(t,e,l),q(e,n,l),r=!0},p(e,[r]){const a={};if(1&r&&(a.key=e[0]),2&r&&(a.value=e[1]),4&r&&(a.isParentExpanded=e[2]),8&r&&(a.isParentArray=e[3]),16&r&&(a.nodeType=e[4]),32&r&&(a.valueGetter=e[5]),l!==(l=e[6])){if(t){_e();const e=t;qe(e.$$.fragment,1,0,()=>{Ke(e,1)}),Se()}l?(Be((t=new l(o(e))).$$.fragment),Ce(t.$$.fragment,1),Re(t,n.parentNode,n)):t=null}else l&&t.$set(a)},i(e){r||(t&&Ce(t.$$.fragment,e),r=!0)},o(e){t&&qe(t.$$.fragment,e),r=!1},d(e){e&&I(n),t&&Ke(t,e)}}}function vn(e,t,n){let r,l,o,{key:a,value:s,isParentExpanded:i,isParentArray:c}=t;return e.$$set=(e=>{"key"in e&&n(0,a=e.key),"value"in e&&n(1,s=e.value),"isParentExpanded"in e&&n(2,i=e.isParentExpanded),"isParentArray"in e&&n(3,c=e.isParentArray)}),e.$$.update=(()=>{2&e.$$.dirty&&n(4,r=mn(s)),16&e.$$.dirty&&n(6,l=function(e){switch(e){case"Object":return Mt;case"Error":return pn;case"Array":return Vt;case"Iterable":case"Map":case"Set":return"function"==typeof s.set?Wt:Jt;case"MapEntry":return Qt;default:return rn}}(r)),16&e.$$.dirty&&n(5,o=function(e){switch(e){case"Object":case"Error":case"Array":case"Iterable":case"Map":case"Set":case"MapEntry":case"Number":return;case"String":return e=>`"${e}"`;case"Boolean":return e=>e?"true":"false";case"Date":return e=>e.toISOString();case"Null":return()=>"null";case"Undefined":return()=>"undefined";case"Function":case"Symbol":return e=>e.toString();default:return()=>`<${e}>`}}(r))}),[a,s,i,c,r,o,l]}class $n extends Le{constructor(e){super(),Je(this,e,vn,gn,d,{key:0,value:1,isParentExpanded:2,isParentArray:3})}}function yn(e){z(e,"svelte-773n60","ul.svelte-773n60{--string-color:var(--json-tree-string-color, #cb3f41);--symbol-color:var(--json-tree-symbol-color, #cb3f41);--boolean-color:var(--json-tree-boolean-color, #112aa7);--function-color:var(--json-tree-function-color, #112aa7);--number-color:var(--json-tree-number-color, #3029cf);--label-color:var(--json-tree-label-color, #871d8f);--arrow-color:var(--json-tree-arrow-color, #727272);--null-color:var(--json-tree-null-color, #8d8d8d);--undefined-color:var(--json-tree-undefined-color, #8d8d8d);--date-color:var(--json-tree-date-color, #8d8d8d);--li-identation:var(--json-tree-li-indentation, 1em);--li-line-height:var(--json-tree-li-line-height, 1.3);--li-colon-space:0.3em;font-size:var(--json-tree-font-size, 12px);font-family:var(--json-tree-font-family, 'Courier New', Courier, monospace)}ul.svelte-773n60 li{line-height:var(--li-line-height);display:var(--li-display, list-item);list-style:none}ul.svelte-773n60,ul.svelte-773n60 ul{padding:0;margin:0}")}function hn(e){let t,n,r;return n=new $n({props:{key:e[0],value:e[1],isParentExpanded:!0,isParentArray:!1}}),{c(){t=M("ul"),Be(n.$$.fragment),G(t,"class","svelte-773n60")},m(e,l){q(e,t,l),Re(n,t,null),r=!0},p(e,[t]){const r={};1&t&&(r.key=e[0]),2&t&&(r.value=e[1]),n.$set(r)},i(e){r||(Ce(n.$$.fragment,e),r=!0)},o(e){qe(n.$$.fragment,e),r=!1},d(e){e&&I(t),Ke(n)}}}function bn(e,t,n){ue(pt,{});let{key:r="",value:l}=t;return e.$$set=(e=>{"key"in e&&n(0,r=e.key),"value"in e&&n(1,l=e.value)}),[r,l]}class xn extends Le{constructor(e){super(),Je(this,e,bn,hn,d,{key:0,value:1},yn)}}function wn(e){z(e,"svelte-jvfq3i",".svelte-jvfq3i{box-sizing:border-box}section.switcher.svelte-jvfq3i{position:sticky;bottom:0;transform:translateY(20px);margin:40px -20px 0;border-top:1px solid #999;padding:20px;background:#fff}label.svelte-jvfq3i{display:flex;align-items:baseline;gap:5px;font-weight:bold}select.svelte-jvfq3i{min-width:140px}")}function kn(e,t,n){const r=e.slice();return r[7]=t[n],r[9]=n,r}function Pn(e){let t,n,r,l,o,a,s=e[1],i=[];for(let c=0;ce[6].call(l)),G(n,"class","svelte-jvfq3i"),G(t,"class","switcher svelte-jvfq3i")},m(s,c){q(s,t,c),A(t,n),A(n,r),A(n,l);for(let e=0;e1&&Pn(e);return{c(){n&&n.c(),t=B()},m(e,r){n&&n.m(e,r),q(e,t,r)},p(e,[r]){e[1].length>1?n?n.p(e,r):((n=Pn(e)).c(),n.m(t.parentNode,t)):n&&(n.d(1),n=null)},i:l,o:l,d(e){n&&n.d(e),e&&I(t)}}}const On="bgio-debug-select-client";function An(e,t,n){let r,o,a,s,i=l,c=()=>(i(),i=p(u,e=>n(5,s=e)),u);e.$$.on_destroy.push(()=>i());let{clientManager:u}=t;c();return e.$$set=(e=>{"clientManager"in e&&c(n(0,u=e.clientManager))}),e.$$.update=(()=>{32&e.$$.dirty&&n(4,({client:r,debuggableClients:o}=s),r,(n(1,o),n(5,s))),18&e.$$.dirty&&n(2,a=o.indexOf(r))}),[u,o,a,e=>{const t=o[e.target.value];u.switchToClient(t);const n=document.getElementById(On);n&&n.focus()},r,s,function(){a=U(this),n(2,a),n(1,o),n(4,r),n(5,s)}]}class zn extends Le{constructor(e){super(),Je(this,e,An,En,d,{clientManager:0},wn)}}function _n(e){z(e,"svelte-1vfj1mn",".key.svelte-1vfj1mn.svelte-1vfj1mn{display:flex;flex-direction:row;align-items:center}button.svelte-1vfj1mn.svelte-1vfj1mn{cursor:pointer;min-width:10px;padding-left:5px;padding-right:5px;height:20px;line-height:20px;text-align:center;border:1px solid #ccc;box-shadow:1px 1px 1px #888;background:#eee;color:#444}button.svelte-1vfj1mn.svelte-1vfj1mn:hover{background:#ddd}.key.active.svelte-1vfj1mn button.svelte-1vfj1mn{background:#ddd;border:1px solid #999;box-shadow:none}label.svelte-1vfj1mn.svelte-1vfj1mn{margin-left:10px}")}function Sn(e){let t,n,r,l,o,a=`(shortcut: ${e[0]})`+"";return{c(){t=M("label"),n=N(e[1]),r=V(),l=M("span"),o=N(a),G(l,"class","screen-reader-only"),G(t,"for",e[5]),G(t,"class","svelte-1vfj1mn")},m(e,a){q(e,t,a),A(t,n),A(t,r),A(t,l),A(l,o)},p(e,t){2&t&&F(n,e[1]),1&t&&a!==(a=`(shortcut: ${e[0]})`+"")&&F(o,a)},d(e){e&&I(t)}}}function Cn(e){let t,n,r,o,a,s,i=e[1]&&Sn(e);return{c(){t=M("div"),n=M("button"),r=N(e[0]),o=V(),i&&i.c(),G(n,"id",e[5]),n.disabled=e[2],G(n,"class","svelte-1vfj1mn"),G(t,"class","key svelte-1vfj1mn"),W(t,"active",e[3])},m(l,c){q(l,t,c),A(t,n),A(n,r),A(t,o),i&&i.m(t,null),a||(s=[R(window,"keydown",e[7]),R(n,"click",e[6])],a=!0)},p(e,[l]){1&l&&F(r,e[0]),4&l&&(n.disabled=e[2]),e[1]?i?i.p(e,l):((i=Sn(e)).c(),i.m(t,null)):i&&(i.d(1),i=null),8&l&&W(t,"active",e[3])},i:l,o:l,d(e){e&&I(t),i&&i.d(),a=!1,c(s)}}}function qn(e,t,n){let r,{value:l}=t,{onPress:o=null}=t,{label:a=null}=t,{disable:s=!1}=t;const{disableHotkeys:i}=de("hotkeys");m(e,i,e=>n(9,r=e));let c=!1,u=`key-${l}`;function d(){n(3,c=!1)}function f(){n(3,c=!0),setTimeout(d,200),o&&setTimeout(o,1)}return e.$$set=(e=>{"value"in e&&n(0,l=e.value),"onPress"in e&&n(8,o=e.onPress),"label"in e&&n(1,a=e.label),"disable"in e&&n(2,s=e.disable)}),[l,a,s,c,i,u,f,function(e){r||s||e.ctrlKey||e.metaKey||e.key!=l||(e.preventDefault(),f())},o]}class In extends Le{constructor(e){super(),Je(this,e,qn,Cn,d,{value:0,onPress:8,label:1,disable:2},_n)}}function Tn(e){z(e,"svelte-1mppqmp",".move.svelte-1mppqmp{display:flex;flex-direction:row;cursor:pointer;margin-left:10px;color:#666}.move.svelte-1mppqmp:hover{color:#333}.move.active.svelte-1mppqmp{color:#111;font-weight:bold}.arg-field.svelte-1mppqmp{outline:none;font-family:monospace}")}function Mn(e){let t,n,r,o,a,s,i,d,f,p,m;return{c(){t=M("div"),n=M("span"),r=N(e[2]),o=V(),(a=M("span")).textContent="(",s=V(),i=M("span"),d=V(),(f=M("span")).textContent=")",G(i,"class","arg-field svelte-1mppqmp"),G(i,"contenteditable",""),G(t,"class","move svelte-1mppqmp"),W(t,"active",e[3])},m(l,c){q(l,t,c),A(t,n),A(n,r),A(t,o),A(t,a),A(t,s),A(t,i),e[6](i),A(t,d),A(t,f),p||(m=[R(i,"focus",function(){u(e[0])&&e[0].apply(this,arguments)}),R(i,"blur",function(){u(e[1])&&e[1].apply(this,arguments)}),R(i,"keypress",K(Dn)),R(i,"keydown",e[5]),R(t,"click",function(){u(e[0])&&e[0].apply(this,arguments)})],p=!0)},p(n,[l]){e=n,4&l&&F(r,e[2]),8&l&&W(t,"active",e[3])},i:l,o:l,d(n){n&&I(t),e[6](null),p=!1,c(m)}}}const Dn=()=>{};function Nn(e,t,n){let r,{Activate:l}=t,{Deactivate:o}=t,{name:a}=t,{active:s}=t;const i=ce();return se(()=>{s?r.focus():r.blur()}),e.$$set=(e=>{"Activate"in e&&n(0,l=e.Activate),"Deactivate"in e&&n(1,o=e.Deactivate),"name"in e&&n(2,a=e.name),"active"in e&&n(3,s=e.active)}),[l,o,a,s,r,function(e){"Enter"==e.key&&(e.preventDefault(),function(){try{const t=r.innerText;let n=new Function(`return [${t}]`)();i("submit",n)}catch(e){i("error",e)}n(4,r.innerText="",r)}()),"Escape"==e.key&&(e.preventDefault(),o())},function(e){me[e?"unshift":"push"](()=>{n(4,r=e)})}]}class Vn extends Le{constructor(e){super(),Je(this,e,Nn,Mn,d,{Activate:0,Deactivate:1,name:2,active:3},Tn)}}function Bn(e){z(e,"svelte-smqssc",".move-error.svelte-smqssc{color:#a00;font-weight:bold}.wrapper.svelte-smqssc{display:flex;flex-direction:row;align-items:center}")}function Rn(e){let t,n;return{c(){t=M("span"),n=N(e[2]),G(t,"class","move-error svelte-smqssc")},m(e,r){q(e,t,r),A(t,n)},p(e,t){4&t&&F(n,e[2])},d(e){e&&I(t)}}}function Kn(e){let t,n,r,l,o,a,s;r=new In({props:{value:e[0],onPress:e[4]}}),(o=new Vn({props:{Activate:e[4],Deactivate:e[5],name:e[1],active:e[3]}})).$on("submit",e[6]),o.$on("error",e[7]);let i=e[2]&&Rn(e);return{c(){t=M("div"),n=M("div"),Be(r.$$.fragment),l=V(),Be(o.$$.fragment),a=V(),i&&i.c(),G(n,"class","wrapper svelte-smqssc")},m(e,c){q(e,t,c),A(t,n),Re(r,n,null),A(n,l),Re(o,n,null),A(t,a),i&&i.m(t,null),s=!0},p(e,[n]){const l={};1&n&&(l.value=e[0]),r.$set(l);const a={};2&n&&(a.name=e[1]),8&n&&(a.active=e[3]),o.$set(a),e[2]?i?i.p(e,n):((i=Rn(e)).c(),i.m(t,null)):i&&(i.d(1),i=null)},i(e){s||(Ce(r.$$.fragment,e),Ce(o.$$.fragment,e),s=!0)},o(e){qe(r.$$.fragment,e),qe(o.$$.fragment,e),s=!1},d(e){e&&I(t),Ke(r),Ke(o),i&&i.d()}}}function Gn(t,n,r){let{shortcut:l}=n,{name:o}=n,{fn:a}=n;const{disableHotkeys:s}=de("hotkeys");let i="",c=!1;function u(){s.set(!1),r(2,i=""),r(3,c=!1)}return t.$$set=(e=>{"shortcut"in e&&r(0,l=e.shortcut),"name"in e&&r(1,o=e.name),"fn"in e&&r(8,a=e.fn)}),[l,o,i,c,function(){s.set(!0),r(3,c=!0)},u,function(e){r(2,i=""),u(),a.apply(this,e.detail)},function(t){r(2,i=t.detail),(0,e.e)(t.detail)},a]}class Jn extends Le{constructor(e){super(),Je(this,e,Gn,Kn,d,{shortcut:0,name:1,fn:8},Bn)}}function Ln(e){z(e,"svelte-c3lavh","ul.svelte-c3lavh{padding-left:0}li.svelte-c3lavh{list-style:none;margin:none;margin-bottom:5px}")}function Fn(e){let t,n,r,l,o,a,s,i,c,u,d,f,p;return r=new In({props:{value:"1",onPress:e[0].reset,label:"reset"}}),a=new In({props:{value:"2",onPress:e[2],label:"save"}}),c=new In({props:{value:"3",onPress:e[3],label:"restore"}}),f=new In({props:{value:".",onPress:e[1],label:"hide"}}),{c(){t=M("ul"),n=M("li"),Be(r.$$.fragment),l=V(),o=M("li"),Be(a.$$.fragment),s=V(),i=M("li"),Be(c.$$.fragment),u=V(),d=M("li"),Be(f.$$.fragment),G(n,"class","svelte-c3lavh"),G(o,"class","svelte-c3lavh"),G(i,"class","svelte-c3lavh"),G(d,"class","svelte-c3lavh"),G(t,"id","debug-controls"),G(t,"class","controls svelte-c3lavh")},m(e,m){q(e,t,m),A(t,n),Re(r,n,null),A(t,l),A(t,o),Re(a,o,null),A(t,s),A(t,i),Re(c,i,null),A(t,u),A(t,d),Re(f,d,null),p=!0},p(e,[t]){const n={};1&t&&(n.onPress=e[0].reset),r.$set(n);const l={};2&t&&(l.onPress=e[1]),f.$set(l)},i(e){p||(Ce(r.$$.fragment,e),Ce(a.$$.fragment,e),Ce(c.$$.fragment,e),Ce(f.$$.fragment,e),p=!0)},o(e){qe(r.$$.fragment,e),qe(a.$$.fragment,e),qe(c.$$.fragment,e),qe(f.$$.fragment,e),p=!1},d(e){e&&I(t),Ke(r),Ke(a),Ke(c),Ke(f)}}}function Hn(t,r,l){let{client:o}=r,{ToggleVisibility:a}=r;return t.$$set=(e=>{"client"in e&&l(0,o=e.client),"ToggleVisibility"in e&&l(1,a=e.ToggleVisibility)}),[o,a,function(){const e=o.getState(),t=(0,n.stringify)({...e,_undo:[],_redo:[],deltalog:[]});window.localStorage.setItem("gamestate",t),window.localStorage.setItem("initialState",(0,n.stringify)(o.initialState))},function(){const t=window.localStorage.getItem("gamestate"),r=window.localStorage.getItem("initialState");if(null!==t&&null!==r){const l=(0,n.parse)(t),a=(0,n.parse)(r);o.store.dispatch((0,e.s)({state:l,initialState:a}))}}]}class Zn extends Le{constructor(e){super(),Je(this,e,Hn,Fn,d,{client:0,ToggleVisibility:1},Ln)}}function Un(e){z(e,"svelte-19aan9p",".player-box.svelte-19aan9p{display:flex;flex-direction:row}.player.svelte-19aan9p{cursor:pointer;text-align:center;width:30px;height:30px;line-height:30px;background:#eee;border:3px solid #fefefe;box-sizing:content-box;padding:0}.player.current.svelte-19aan9p{background:#555;color:#eee;font-weight:bold}.player.active.svelte-19aan9p{border:3px solid #ff7f50}")}function Wn(e,t,n){const r=e.slice();return r[7]=t[n],r}function Xn(e){let t,n,r,l,o,a,s=e[7]+"";function i(){return e[5](e[7])}return{c(){t=M("button"),n=N(s),r=V(),G(t,"class","player svelte-19aan9p"),G(t,"aria-label",l=e[4](e[7])),W(t,"current",e[7]==e[0].currentPlayer),W(t,"active",e[7]==e[1])},m(e,l){q(e,t,l),A(t,n),A(t,r),o||(a=R(t,"click",i),o=!0)},p(r,o){e=r,4&o&&s!==(s=e[7]+"")&&F(n,s),4&o&&l!==(l=e[4](e[7]))&&G(t,"aria-label",l),5&o&&W(t,"current",e[7]==e[0].currentPlayer),6&o&&W(t,"active",e[7]==e[1])},d(e){e&&I(t),o=!1,a()}}}function Yn(e){let t,n=e[2],r=[];for(let l=0;l{"ctx"in e&&n(0,r=e.ctx),"playerID"in e&&n(1,l=e.playerID)}),e.$$.update=(()=>{1&e.$$.dirty&&n(2,s=r?[...Array(r.numPlayers).keys()].map(e=>e.toString()):[])}),[r,l,s,a,function(e){const t=[];e==r.currentPlayer&&t.push("current"),e==l&&t.push("active");let n=`Player ${e}`;return t.length&&(n+=` (${t.join(", ")})`),n},e=>a(e)]}class er extends Le{constructor(e){super(),Je(this,e,Qn,Yn,d,{ctx:0,playerID:1},Un)}}function tr(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function nr(e){for(var t=1;t=0||(l[n]=e[n]);return l}function fr(e,t){if(null==e)return{};var n,r,l=dr(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(l[n]=e[n])}return l}function pr(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}function mr(e,t){if(t&&("object"==typeof t||"function"==typeof t))return t;if(void 0!==t)throw new TypeError("Derived constructors may only return object or undefined");return pr(e)}function gr(e){var t=ur();return function(){var n,r=ir(e);if(t){var l=ir(this).constructor;n=Reflect.construct(r,arguments,l)}else n=r.apply(this,arguments);return mr(this,n)}}function vr(e,t){if(e){if("string"==typeof e)return $r(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?$r(e,t):void 0}}function $r(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:l}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,a=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){s=!0,o=e},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw o}}}}function hr(e,t){var n,r={},l={},o=yr(t);try{for(o.s();!(n=o.n()).done;){l[n.value]=!0}}catch(p){o.e(p)}finally{o.f()}var a=l,s=!0;for(var i in e){var c=i[0];if(a[c]){s=!1;break}a[c]=!0,r[i]=c}if(s)return r;a=l;var u=97;for(var d in r={},e){for(var f=String.fromCharCode(u);a[f];)u++,f=String.fromCharCode(u);a[f]=!0,r[d]=f}return r}function br(e){z(e,"svelte-146sq5f",".tree.svelte-146sq5f{--json-tree-font-family:monospace;--json-tree-font-size:14px;--json-tree-null-color:#757575}.label.svelte-146sq5f{margin-bottom:0;text-transform:none}h3.svelte-146sq5f{text-transform:uppercase}ul.svelte-146sq5f{padding-left:0}li.svelte-146sq5f{list-style:none;margin:0;margin-bottom:5px}")}function xr(e,t,n){const r=e.slice();return r[10]=t[n][0],r[11]=t[n][1],r}function wr(e){let t,n,r,l;return n=new Jn({props:{shortcut:e[8][e[10]],fn:e[11],name:e[10]}}),{c(){t=M("li"),Be(n.$$.fragment),r=V(),G(t,"class","svelte-146sq5f")},m(e,o){q(e,t,o),Re(n,t,null),A(t,r),l=!0},p(e,t){const r={};16&t&&(r.shortcut=e[8][e[10]]),16&t&&(r.fn=e[11]),16&t&&(r.name=e[10]),n.$set(r)},i(e){l||(Ce(n.$$.fragment,e),l=!0)},o(e){qe(n.$$.fragment,e),l=!1},d(e){e&&I(t),Ke(n)}}}function kr(e){let t,n,r;return n=new Jn({props:{name:"endStage",shortcut:7,fn:e[5].endStage}}),{c(){t=M("li"),Be(n.$$.fragment),G(t,"class","svelte-146sq5f")},m(e,l){q(e,t,l),Re(n,t,null),r=!0},p(e,t){const r={};32&t&&(r.fn=e[5].endStage),n.$set(r)},i(e){r||(Ce(n.$$.fragment,e),r=!0)},o(e){qe(n.$$.fragment,e),r=!1},d(e){e&&I(t),Ke(n)}}}function Pr(e){let t,n,r;return n=new Jn({props:{name:"endTurn",shortcut:8,fn:e[5].endTurn}}),{c(){t=M("li"),Be(n.$$.fragment),G(t,"class","svelte-146sq5f")},m(e,l){q(e,t,l),Re(n,t,null),r=!0},p(e,t){const r={};32&t&&(r.fn=e[5].endTurn),n.$set(r)},i(e){r||(Ce(n.$$.fragment,e),r=!0)},o(e){qe(n.$$.fragment,e),r=!1},d(e){e&&I(t),Ke(n)}}}function jr(e){let t,n,r;return n=new Jn({props:{name:"endPhase",shortcut:9,fn:e[5].endPhase}}),{c(){t=M("li"),Be(n.$$.fragment),G(t,"class","svelte-146sq5f")},m(e,l){q(e,t,l),Re(n,t,null),r=!0},p(e,t){const r={};32&t&&(r.fn=e[5].endPhase),n.$set(r)},i(e){r||(Ce(n.$$.fragment,e),r=!0)},o(e){qe(n.$$.fragment,e),r=!1},d(e){e&&I(t),Ke(n)}}}function Er(e){let t,n,r,l,o,a,s,i,c,u,d,f,p,m,g,v,$,y,h,b,x,w,k,P,j,E,O,z,_,S,C,D,N,B;l=new Zn({props:{client:e[0],ToggleVisibility:e[2]}}),(c=new er({props:{ctx:e[6],playerID:e[3]}})).$on("change",e[9]);let R=Object.entries(e[4]),K=[];for(let A=0;Aqe(K[e],1,1,()=>{K[e]=null});let L=e[6].activePlayers&&e[5].endStage&&kr(e),F=e[5].endTurn&&Pr(e),H=e[6].phase&&e[5].endPhase&&jr(e);return E=new xn({props:{value:e[7]}}),C=new xn({props:{value:Or(e[6])}}),N=new zn({props:{clientManager:e[1]}}),{c(){t=M("section"),(n=M("h3")).textContent="Controls",r=V(),Be(l.$$.fragment),o=V(),a=M("section"),(s=M("h3")).textContent="Players",i=V(),Be(c.$$.fragment),u=V(),d=M("section"),(f=M("h3")).textContent="Moves",p=V(),m=M("ul");for(let e=0;e{L=null}),Se()),e[5].endTurn?F?(F.p(e,t),32&t&&Ce(F,1)):((F=Pr(e)).c(),Ce(F,1),F.m(h,x)):F&&(_e(),qe(F,1,1,()=>{F=null}),Se()),e[6].phase&&e[5].endPhase?H?(H.p(e,t),96&t&&Ce(H,1)):((H=jr(e)).c(),Ce(H,1),H.m(h,null)):H&&(_e(),qe(H,1,1,()=>{H=null}),Se());const o={};128&t&&(o.value=e[7]),E.$set(o);const a={};64&t&&(a.value=Or(e[6])),C.$set(a);const s={};2&t&&(s.clientManager=e[1]),N.$set(s)},i(e){if(!B){Ce(l.$$.fragment,e),Ce(c.$$.fragment,e);for(let e=0;e{e&&n(7,({G:d,ctx:u}=e),d,n(6,u)),n(3,({playerID:s,moves:i,events:c}=r),s,n(4,i),n(5,c))});return e.$$set=(e=>{"client"in e&&n(0,r=e.client),"clientManager"in e&&n(1,l=e.clientManager),"ToggleVisibility"in e&&n(2,o=e.ToggleVisibility)}),[r,l,o,s,i,c,u,d,a,e=>l.switchPlayerID(e.detail.playerID)]}class zr extends Le{constructor(e){super(),Je(this,e,Ar,Er,d,{client:0,clientManager:1,ToggleVisibility:2},br)}}function _r(e){z(e,"svelte-13qih23",".item.svelte-13qih23.svelte-13qih23{padding:10px}.item.svelte-13qih23.svelte-13qih23:not(:first-child){border-top:1px dashed #aaa}.item.svelte-13qih23 div.svelte-13qih23{float:right;text-align:right}")}function Sr(e){let t,n,r,o,a,s,i=JSON.stringify(e[1])+"";return{c(){t=M("div"),n=M("strong"),r=N(e[0]),o=V(),a=M("div"),s=N(i),G(a,"class","svelte-13qih23"),G(t,"class","item svelte-13qih23")},m(e,l){q(e,t,l),A(t,n),A(n,r),A(t,o),A(t,a),A(a,s)},p(e,[t]){1&t&&F(r,e[0]),2&t&&i!==(i=JSON.stringify(e[1])+"")&&F(s,i)},i:l,o:l,d(e){e&&I(t)}}}function Cr(e,t,n){let{name:r}=t,{value:l}=t;return e.$$set=(e=>{"name"in e&&n(0,r=e.name),"value"in e&&n(1,l=e.value)}),[r,l]}class qr extends Le{constructor(e){super(),Je(this,e,Cr,Sr,d,{name:0,value:1},_r)}}function Ir(e){z(e,"svelte-1yzq5o8",".gameinfo.svelte-1yzq5o8{padding:10px}")}function Tr(e){let t,n;return t=new qr({props:{name:"isConnected",value:e[1].isConnected}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};2&n&&(r.value=e[1].isConnected),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Mr(e){let t,n,r,l,o,a,s,i;n=new qr({props:{name:"matchID",value:e[0].matchID}}),l=new qr({props:{name:"playerID",value:e[0].playerID}}),a=new qr({props:{name:"isActive",value:e[1].isActive}});let c=e[0].multiplayer&&Tr(e);return{c(){t=M("section"),Be(n.$$.fragment),r=V(),Be(l.$$.fragment),o=V(),Be(a.$$.fragment),s=V(),c&&c.c(),G(t,"class","gameinfo svelte-1yzq5o8")},m(e,u){q(e,t,u),Re(n,t,null),A(t,r),Re(l,t,null),A(t,o),Re(a,t,null),A(t,s),c&&c.m(t,null),i=!0},p(e,[r]){const o={};1&r&&(o.value=e[0].matchID),n.$set(o);const s={};1&r&&(s.value=e[0].playerID),l.$set(s);const i={};2&r&&(i.value=e[1].isActive),a.$set(i),e[0].multiplayer?c?(c.p(e,r),1&r&&Ce(c,1)):((c=Tr(e)).c(),Ce(c,1),c.m(t,null)):c&&(_e(),qe(c,1,1,()=>{c=null}),Se())},i(e){i||(Ce(n.$$.fragment,e),Ce(l.$$.fragment,e),Ce(a.$$.fragment,e),Ce(c),i=!0)},o(e){qe(n.$$.fragment,e),qe(l.$$.fragment,e),qe(a.$$.fragment,e),qe(c),i=!1},d(e){e&&I(t),Ke(n),Ke(l),Ke(a),c&&c.d()}}}function Dr(e,t,n){let r,o=l,a=()=>(o(),o=p(s,e=>n(1,r=e)),s);e.$$.on_destroy.push(()=>o());let{client:s}=t;a();let{clientManager:i}=t,{ToggleVisibility:c}=t;return e.$$set=(e=>{"client"in e&&a(n(0,s=e.client)),"clientManager"in e&&n(2,i=e.clientManager),"ToggleVisibility"in e&&n(3,c=e.ToggleVisibility)}),[s,r,i,c]}class Nr extends Le{constructor(e){super(),Je(this,e,Dr,Mr,d,{client:0,clientManager:2,ToggleVisibility:3},Ir)}}function Vr(e){z(e,"svelte-6eza86",".turn-marker.svelte-6eza86{display:flex;justify-content:center;align-items:center;grid-column:1;background:#555;color:#eee;text-align:center;font-weight:bold;border:1px solid #888}")}function Br(e){let t,n;return{c(){t=M("div"),n=N(e[0]),G(t,"class","turn-marker svelte-6eza86"),G(t,"style",e[1])},m(e,r){q(e,t,r),A(t,n)},p(e,[t]){1&t&&F(n,e[0])},i:l,o:l,d(e){e&&I(t)}}}function Rr(e,t,n){let{turn:r}=t,{numEvents:l}=t;const o=`grid-row: span ${l}`;return e.$$set=(e=>{"turn"in e&&n(0,r=e.turn),"numEvents"in e&&n(2,l=e.numEvents)}),[r,o,l]}class Kr extends Le{constructor(e){super(),Je(this,e,Rr,Br,d,{turn:0,numEvents:2},Vr)}}function Gr(e){z(e,"svelte-1t4xap",".phase-marker.svelte-1t4xap{grid-column:3;background:#555;border:1px solid #888;color:#eee;text-align:center;font-weight:bold;padding-top:10px;padding-bottom:10px;text-orientation:sideways;writing-mode:vertical-rl;line-height:30px;width:100%}")}function Jr(e){let t,n,r=(e[0]||"")+"";return{c(){t=M("div"),n=N(r),G(t,"class","phase-marker svelte-1t4xap"),G(t,"style",e[1])},m(e,r){q(e,t,r),A(t,n)},p(e,[t]){1&t&&r!==(r=(e[0]||"")+"")&&F(n,r)},i:l,o:l,d(e){e&&I(t)}}}function Lr(e,t,n){let{phase:r}=t,{numEvents:l}=t;const o=`grid-row: span ${l}`;return e.$$set=(e=>{"phase"in e&&n(0,r=e.phase),"numEvents"in e&&n(2,l=e.numEvents)}),[r,o,l]}class Fr extends Le{constructor(e){super(),Je(this,e,Lr,Jr,d,{phase:0,numEvents:2},Gr)}}function Hr(e){let t;return{c(){(t=M("div")).textContent=`${e[0]}`},m(e,n){q(e,t,n)},p:l,i:l,o:l,d(e){e&&I(t)}}}function Zr(e,t,n){let{metadata:r}=t;const l=void 0!==r?JSON.stringify(r,null,4):"";return e.$$set=(e=>{"metadata"in e&&n(1,r=e.metadata)}),[l,r]}class Ur extends Le{constructor(e){super(),Je(this,e,Zr,Hr,d,{metadata:1})}}function Wr(e){z(e,"svelte-vajd9z",".log-event.svelte-vajd9z{grid-column:2;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;justify-content:center;background:#fff;border:1px dotted #ccc;border-left:5px solid #ccc;padding:5px;text-align:center;color:#666;font-size:14px;min-height:25px;line-height:25px}.log-event.svelte-vajd9z:hover,.log-event.svelte-vajd9z:focus{border-style:solid;background:#eee}.log-event.pinned.svelte-vajd9z{border-style:solid;background:#eee;opacity:1}.args.svelte-vajd9z{text-align:left;white-space:pre-wrap}.player0.svelte-vajd9z{border-left-color:#ff851b}.player1.svelte-vajd9z{border-left-color:#7fdbff}.player2.svelte-vajd9z{border-left-color:#0074d9}.player3.svelte-vajd9z{border-left-color:#39cccc}.player4.svelte-vajd9z{border-left-color:#3d9970}.player5.svelte-vajd9z{border-left-color:#2ecc40}.player6.svelte-vajd9z{border-left-color:#01ff70}.player7.svelte-vajd9z{border-left-color:#ffdc00}.player8.svelte-vajd9z{border-left-color:#001f3f}.player9.svelte-vajd9z{border-left-color:#ff4136}.player10.svelte-vajd9z{border-left-color:#85144b}.player11.svelte-vajd9z{border-left-color:#f012be}.player12.svelte-vajd9z{border-left-color:#b10dc9}.player13.svelte-vajd9z{border-left-color:#111111}.player14.svelte-vajd9z{border-left-color:#aaaaaa}.player15.svelte-vajd9z{border-left-color:#dddddd}")}function Xr(e){let t,n;return t=new Ur({props:{metadata:e[2]}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};4&n&&(r.metadata=e[2]),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Yr(e){let t,n,r;var l=e[3];function o(e){return{props:{metadata:e[2]}}}return l&&(t=new l(o(e))),{c(){t&&Be(t.$$.fragment),n=B()},m(e,l){t&&Re(t,e,l),q(e,n,l),r=!0},p(e,r){const a={};if(4&r&&(a.metadata=e[2]),l!==(l=e[3])){if(t){_e();const e=t;qe(e.$$.fragment,1,0,()=>{Ke(e,1)}),Se()}l?(Be((t=new l(o(e))).$$.fragment),Ce(t.$$.fragment,1),Re(t,n.parentNode,n)):t=null}else l&&t.$set(a)},i(e){r||(t&&Ce(t.$$.fragment,e),r=!0)},o(e){t&&qe(t.$$.fragment,e),r=!1},d(e){e&&I(n),t&&Ke(t,e)}}}function Qr(e){let t,n,r,l,o,a,s,i,u,d,f,p,m;const g=[Yr,Xr],v=[];function $(e,t){return e[3]?0:1}return i=$(e),u=v[i]=g[i](e),{c(){t=M("button"),n=M("div"),r=N(e[4]),l=N("("),o=N(e[6]),a=N(")"),s=V(),u.c(),G(n,"class","args svelte-vajd9z"),G(t,"class",d="log-event player"+e[7]+" svelte-vajd9z"),W(t,"pinned",e[1])},m(c,u){q(c,t,u),A(t,n),A(n,r),A(n,l),A(n,o),A(n,a),A(t,s),v[i].m(t,null),f=!0,p||(m=[R(t,"click",e[9]),R(t,"mouseenter",e[10]),R(t,"focus",e[11]),R(t,"mouseleave",e[12]),R(t,"blur",e[13])],p=!0)},p(e,[n]){(!f||16&n)&&F(r,e[4]);let l=i;(i=$(e))===l?v[i].p(e,n):(_e(),qe(v[l],1,1,()=>{v[l]=null}),Se(),(u=v[i])?u.p(e,n):(u=v[i]=g[i](e)).c(),Ce(u,1),u.m(t,null)),2&n&&W(t,"pinned",e[1])},i(e){f||(Ce(u),f=!0)},o(e){qe(u),f=!1},d(e){e&&I(t),v[i].d(),p=!1,c(m)}}}function el(e,t,n){let{logIndex:r}=t,{action:l}=t,{pinned:o}=t,{metadata:a}=t,{metadataComponent:s}=t;const i=ce(),c=l.payload.args,u=Array.isArray(c)?c.map(e=>JSON.stringify(e,null,2)).join(","):JSON.stringify(c,null,2)||"",d=l.payload.playerID;let f;switch(l.type){case"UNDO":f="undo";break;case"REDO":f="redo";case"GAME_EVENT":case"MAKE_MOVE":default:f=l.payload.type}return e.$$set=(e=>{"logIndex"in e&&n(0,r=e.logIndex),"action"in e&&n(8,l=e.action),"pinned"in e&&n(1,o=e.pinned),"metadata"in e&&n(2,a=e.metadata),"metadataComponent"in e&&n(3,s=e.metadataComponent)}),[r,o,a,s,f,i,u,d,l,()=>i("click",{logIndex:r}),()=>i("mouseenter",{logIndex:r}),()=>i("mouseenter",{logIndex:r}),()=>i("mouseleave"),()=>i("mouseleave")]}class tl extends Le{constructor(e){super(),Je(this,e,el,Qr,d,{logIndex:0,action:8,pinned:1,metadata:2,metadataComponent:3},Wr)}}function nl(e){let t;return{c(){G(t=D("path"),"d","M504 256c0 137-111 248-248 248S8 393 8 256 119 8 256 8s248 111 248 248zM212 140v116h-70.9c-10.7 0-16.1 13-8.5 20.5l114.9 114.3c4.7 4.7 12.2 4.7 16.9 0l114.9-114.3c7.6-7.6 2.2-20.5-8.5-20.5H300V140c0-6.6-5.4-12-12-12h-64c-6.6 0-12 5.4-12 12z")},m(e,n){q(e,t,n)},d(e){e&&I(t)}}}function rl(e){let t,n;const r=[{viewBox:"0 0 512 512"},e[0]];let l={$$slots:{default:[nl]},$$scope:{ctx:e}};for(let o=0;o{n(0,t=a(a({},t),b(e)))}),[t=b(t)]}class ol extends Le{constructor(e){super(),Je(this,e,ll,rl,d,{})}}function al(e){z(e,"svelte-1a7time","div.svelte-1a7time{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:500px}")}function sl(e){let t,n;return{c(){t=M("div"),n=N(e[0]),G(t,"alt",e[0]),G(t,"class","svelte-1a7time")},m(e,r){q(e,t,r),A(t,n)},p(e,[r]){1&r&&F(n,e[0]),1&r&&G(t,"alt",e[0])},i:l,o:l,d(e){e&&I(t)}}}function il(e,t,n){let r,{action:l}=t;return e.$$set=(e=>{"action"in e&&n(1,l=e.action)}),e.$$.update=(()=>{if(2&e.$$.dirty){const{type:e,args:t}=l.payload,o=(t||[]).join(",");n(0,r=`${e}(${o})`)}}),[r,l]}class cl extends Le{constructor(e){super(),Je(this,e,il,sl,d,{action:1},al)}}function ul(e){z(e,"svelte-ztcwsu","table.svelte-ztcwsu.svelte-ztcwsu{font-size:12px;border-collapse:collapse;border:1px solid #ddd;padding:0}tr.svelte-ztcwsu.svelte-ztcwsu{cursor:pointer}tr.svelte-ztcwsu:hover td.svelte-ztcwsu{background:#eee}tr.selected.svelte-ztcwsu td.svelte-ztcwsu{background:#eee}td.svelte-ztcwsu.svelte-ztcwsu{padding:10px;height:10px;line-height:10px;font-size:12px;border:none}th.svelte-ztcwsu.svelte-ztcwsu{background:#888;color:#fff;padding:10px;text-align:center}")}function dl(e,t,n){const r=e.slice();return r[10]=t[n],r[12]=n,r}function fl(e){let t,n,r,l,o,a,s,i,u,d,f,p,m,g=e[10].value+"",v=e[10].visits+"";function $(){return e[6](e[10],e[12])}function y(){return e[7](e[12])}function h(){return e[8](e[10],e[12])}return u=new cl({props:{action:e[10].parentAction}}),{c(){t=M("tr"),n=M("td"),r=N(g),l=V(),o=M("td"),a=N(v),s=V(),i=M("td"),Be(u.$$.fragment),d=V(),G(n,"class","svelte-ztcwsu"),G(o,"class","svelte-ztcwsu"),G(i,"class","svelte-ztcwsu"),G(t,"class","svelte-ztcwsu"),W(t,"clickable",e[1].length>0),W(t,"selected",e[12]===e[0])},m(e,c){q(e,t,c),A(t,n),A(n,r),A(t,l),A(t,o),A(o,a),A(t,s),A(t,i),Re(u,i,null),A(t,d),f=!0,p||(m=[R(t,"click",$),R(t,"mouseout",y),R(t,"mouseover",h)],p=!0)},p(n,l){e=n,(!f||2&l)&&g!==(g=e[10].value+"")&&F(r,g),(!f||2&l)&&v!==(v=e[10].visits+"")&&F(a,v);const o={};2&l&&(o.action=e[10].parentAction),u.$set(o),2&l&&W(t,"clickable",e[1].length>0),1&l&&W(t,"selected",e[12]===e[0])},i(e){f||(Ce(u.$$.fragment,e),f=!0)},o(e){qe(u.$$.fragment,e),f=!1},d(e){e&&I(t),Ke(u),p=!1,c(m)}}}function pl(e){let t,n,r,l,o,a=e[1],s=[];for(let c=0;cqe(s[e],1,1,()=>{s[e]=null});return{c(){t=M("table"),(n=M("thead")).innerHTML='Value \n Visits \n Action',r=V(),l=M("tbody");for(let e=0;e{"root"in e&&n(4,r=e.root),"selectedIndex"in e&&n(0,l=e.selectedIndex)}),e.$$.update=(()=>{if(48&e.$$.dirty){let e=r;for(n(5,a=[]);e.parent;){const t=e.parent,{type:n,args:r}=e.parentAction.payload,l=`${n}(${(r||[]).join(",")})`;a.push({parent:t,arrowText:l}),e=t}a.reverse(),n(1,s=[...r.children].sort((e,t)=>e.visitsi(e,t),e=>c(null),(e,t)=>c(e)]}class gl extends Le{constructor(e){super(),Je(this,e,ml,pl,d,{root:4,selectedIndex:0},ul)}}function vl(e){z(e,"svelte-1f0amz4",".visualizer.svelte-1f0amz4{display:flex;flex-direction:column;align-items:center;padding:50px}.preview.svelte-1f0amz4{opacity:0.5}.icon.svelte-1f0amz4{color:#777;width:32px;height:32px;margin-bottom:20px}")}function $l(e,t,n){const r=e.slice();return r[9]=t[n].node,r[10]=t[n].selectedIndex,r[12]=n,r}function yl(e){let t,n,r;return n=new ol({}),{c(){t=M("div"),Be(n.$$.fragment),G(t,"class","icon svelte-1f0amz4")},m(e,l){q(e,t,l),Re(n,t,null),r=!0},i(e){r||(Ce(n.$$.fragment,e),r=!0)},o(e){qe(n.$$.fragment,e),r=!1},d(e){e&&I(t),Ke(n)}}}function hl(e){let t,n;return(t=new gl({props:{root:e[9],selectedIndex:e[10]}})).$on("select",function(...t){return e[7](e[12],...t)}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(n,r){e=n;const l={};1&r&&(l.root=e[9]),1&r&&(l.selectedIndex=e[10]),t.$set(l)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function bl(e){let t,n;return(t=new gl({props:{root:e[9]}})).$on("select",function(...t){return e[5](e[12],...t)}),t.$on("preview",function(...t){return e[6](e[12],...t)}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(n,r){e=n;const l={};1&r&&(l.root=e[9]),t.$set(l)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function xl(e){let t,n,r,l,o,a=0!==e[12]&&yl();const s=[bl,hl],i=[];function c(e,t){return e[12]===e[0].length-1?0:1}return r=c(e),l=i[r]=s[r](e),{c(){a&&a.c(),t=V(),n=M("section"),l.c()},m(e,l){a&&a.m(e,l),q(e,t,l),q(e,n,l),i[r].m(n,null),o=!0},p(e,t){let o=r;(r=c(e))===o?i[r].p(e,t):(_e(),qe(i[o],1,1,()=>{i[o]=null}),Se(),(l=i[r])?l.p(e,t):(l=i[r]=s[r](e)).c(),Ce(l,1),l.m(n,null))},i(e){o||(Ce(a),Ce(l),o=!0)},o(e){qe(a),qe(l),o=!1},d(e){a&&a.d(e),e&&I(t),e&&I(n),i[r].d()}}}function wl(e){let t,n,r,l,o,a;return n=new ol({}),o=new gl({props:{root:e[1]}}),{c(){t=M("div"),Be(n.$$.fragment),r=V(),l=M("section"),Be(o.$$.fragment),G(t,"class","icon svelte-1f0amz4"),G(l,"class","preview svelte-1f0amz4")},m(e,s){q(e,t,s),Re(n,t,null),q(e,r,s),q(e,l,s),Re(o,l,null),a=!0},p(e,t){const n={};2&t&&(n.root=e[1]),o.$set(n)},i(e){a||(Ce(n.$$.fragment,e),Ce(o.$$.fragment,e),a=!0)},o(e){qe(n.$$.fragment,e),qe(o.$$.fragment,e),a=!1},d(e){e&&I(t),Ke(n),e&&I(r),e&&I(l),Ke(o)}}}function kl(e){let t,n,r,l=e[0],o=[];for(let i=0;iqe(o[e],1,1,()=>{o[e]=null});let s=e[1]&&wl(e);return{c(){t=M("div");for(let e=0;e{s=null}),Se())},i(e){if(!r){for(let e=0;e{"metadata"in e&&n(4,r=e.metadata)}),e.$$.update=(()=>{16&e.$$.dirty&&n(0,l=[{node:r}])}),[l,o,a,s,r,(e,t)=>a(t.detail,e),(e,t)=>s(t.detail),(e,t)=>a(t.detail,e)]}class jl extends Le{constructor(e){super(),Je(this,e,Pl,kl,d,{metadata:4},vl)}}function El(e){z(e,"svelte-1pq5e4b",".gamelog.svelte-1pq5e4b{display:grid;grid-template-columns:30px 1fr 30px;grid-auto-rows:auto;grid-auto-flow:column}")}function Ol(e,t,n){const r=e.slice();return r[16]=t[n].phase,r[18]=n,r}function Al(e,t,n){const r=e.slice();return r[19]=t[n].action,r[20]=t[n].metadata,r[18]=n,r}function zl(e,t,n){const r=e.slice();return r[22]=t[n].turn,r[18]=n,r}function _l(e){let t,n;return t=new Kr({props:{turn:e[22],numEvents:e[3][e[18]]}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};2&n&&(r.turn=e[22]),8&n&&(r.numEvents=e[3][e[18]]),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Sl(e){let t,n,r=e[18]in e[3]&&_l(e);return{c(){r&&r.c(),t=B()},m(e,l){r&&r.m(e,l),q(e,t,l),n=!0},p(e,n){e[18]in e[3]?r?(r.p(e,n),8&n&&Ce(r,1)):((r=_l(e)).c(),Ce(r,1),r.m(t.parentNode,t)):r&&(_e(),qe(r,1,1,()=>{r=null}),Se())},i(e){n||(Ce(r),n=!0)},o(e){qe(r),n=!1},d(e){r&&r.d(e),e&&I(t)}}}function Cl(e){let t,n;return(t=new tl({props:{pinned:e[18]===e[2],logIndex:e[18],action:e[19],metadata:e[20]}})).$on("click",e[5]),t.$on("mouseenter",e[6]),t.$on("mouseleave",e[7]),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};4&n&&(r.pinned=e[18]===e[2]),2&n&&(r.action=e[19]),2&n&&(r.metadata=e[20]),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function ql(e){let t,n;return t=new Fr({props:{phase:e[16],numEvents:e[4][e[18]]}}),{c(){Be(t.$$.fragment)},m(e,r){Re(t,e,r),n=!0},p(e,n){const r={};2&n&&(r.phase=e[16]),16&n&&(r.numEvents=e[4][e[18]]),t.$set(r)},i(e){n||(Ce(t.$$.fragment,e),n=!0)},o(e){qe(t.$$.fragment,e),n=!1},d(e){Ke(t,e)}}}function Il(e){let t,n,r=e[18]in e[4]&&ql(e);return{c(){r&&r.c(),t=B()},m(e,l){r&&r.m(e,l),q(e,t,l),n=!0},p(e,n){e[18]in e[4]?r?(r.p(e,n),16&n&&Ce(r,1)):((r=ql(e)).c(),Ce(r,1),r.m(t.parentNode,t)):r&&(_e(),qe(r,1,1,()=>{r=null}),Se())},i(e){n||(Ce(r),n=!0)},o(e){qe(r),n=!1},d(e){r&&r.d(e),e&&I(t)}}}function Tl(e){let t,n,r,l,o,a,s=e[1],i=[];for(let v=0;vqe(i[e],1,1,()=>{i[e]=null});let u=e[1],d=[];for(let v=0;vqe(d[e],1,1,()=>{d[e]=null});let p=e[1],m=[];for(let v=0;vqe(m[e],1,1,()=>{m[e]=null});return{c(){t=M("div");for(let e=0;e(a(),a=p(i,e=>r(10,o=e)),i);e.$$.on_destroy.push(()=>a());let{client:i}=n;s();const{secondaryPane:c}=de("secondaryPane"),u=(0,t.C)({game:i.game}),d=i.getInitialState();let f,{log:m}=o,g=null;function v(e){let t=d;for(let n=0;n{"client"in e&&s(r(0,i=e.client))}),e.$$.update=(()=>{if(1538&e.$$.dirty){r(9,m=o.log),r(1,f=m.filter(e=>!e.automatic));let e=0,t=0;r(3,y={}),r(4,h={});for(let n=0;n!e.automatic);if(i.overrideGameState(n),g==t)r(2,g=null),c.set(null);else{r(2,g=t);const{metadata:e}=l[t].action.payload;e&&c.set({component:jl,metadata:e})}},function(e){const{logIndex:t}=e.detail;if(null===g){const e=v(t);i.overrideGameState(e)}},function(){null===g&&i.overrideGameState(null)},function(e){27==e.keyCode&&$()},m,o]}class Dl extends Le{constructor(e){super(),Je(this,e,Ml,Tl,d,{client:0},El)}}function Nl(e){z(e,"svelte-1fu900w","label.svelte-1fu900w{color:#666}.option.svelte-1fu900w{margin-bottom:20px}.value.svelte-1fu900w{font-weight:bold;color:#000}input[type='checkbox'].svelte-1fu900w{vertical-align:middle}")}function Vl(e,t,n){const r=e.slice();return r[6]=t[n][0],r[7]=t[n][1],r[8]=t,r[9]=n,r}function Bl(e){let t,n,r,l;function o(){e[5].call(t,e[6])}return{c(){G(t=M("input"),"id",n=e[3](e[6])),G(t,"type","checkbox"),G(t,"class","svelte-1fu900w")},m(n,a){q(n,t,a),t.checked=e[1][e[6]],r||(l=[R(t,"change",o),R(t,"change",e[2])],r=!0)},p(r,l){e=r,1&l&&n!==(n=e[3](e[6]))&&G(t,"id",n),3&l&&(t.checked=e[1][e[6]])},d(e){e&&I(t),r=!1,c(l)}}}function Rl(e){let t,n,r,l,o,a,s,i,u,d=e[1][e[6]]+"";function f(){e[4].call(l,e[6])}return{c(){t=M("span"),n=N(d),r=V(),l=M("input"),G(t,"class","value svelte-1fu900w"),G(l,"id",o=e[3](e[6])),G(l,"type","range"),G(l,"min",a=e[7].range.min),G(l,"max",s=e[7].range.max)},m(o,a){q(o,t,a),A(t,n),q(o,r,a),q(o,l,a),H(l,e[1][e[6]]),i||(u=[R(l,"change",f),R(l,"input",f),R(l,"change",e[2])],i=!0)},p(t,r){e=t,3&r&&d!==(d=e[1][e[6]]+"")&&F(n,d),1&r&&o!==(o=e[3](e[6]))&&G(l,"id",o),1&r&&a!==(a=e[7].range.min)&&G(l,"min",a),1&r&&s!==(s=e[7].range.max)&&G(l,"max",s),3&r&&H(l,e[1][e[6]])},d(e){e&&I(t),e&&I(r),e&&I(l),i=!1,c(u)}}}function Kl(e){let t,n,r,l,o,a,s=e[6]+"";function i(e,t){return e[7].range?Rl:"boolean"==typeof e[7].value?Bl:void 0}let c=i(e),u=c&&c(e);return{c(){t=M("div"),n=M("label"),r=N(s),o=V(),u&&u.c(),a=V(),G(n,"for",l=e[3](e[6])),G(n,"class","svelte-1fu900w"),G(t,"class","option svelte-1fu900w")},m(e,l){q(e,t,l),A(t,n),A(n,r),A(t,o),u&&u.m(t,null),A(t,a)},p(e,o){1&o&&s!==(s=e[6]+"")&&F(r,s),1&o&&l!==(l=e[3](e[6]))&&G(n,"for",l),c===(c=i(e))&&u?u.p(e,o):(u&&u.d(1),(u=c&&c(e))&&(u.c(),u.m(t,a)))},d(e){e&&I(t),u&&u.d()}}}function Gl(e){let t,n=Object.entries(e[0].opts()),r=[];for(let l=0;l{"bot"in e&&n(0,r=e.bot)}),[r,l,function(){for(let[e,t]of Object.entries(l))r.setOpt(e,t)},e=>"ai-option-"+e,function(e){l[e]=J(this.value),n(1,l),n(0,r)},function(e){l[e]=this.checked,n(1,l),n(0,r)}]}class Ll extends Le{constructor(e){super(),Je(this,e,Jl,Gl,d,{bot:0},Nl)}}function Fl(e){z(e,"svelte-lifdi8","ul.svelte-lifdi8{padding-left:0}li.svelte-lifdi8{list-style:none;margin:none;margin-bottom:5px}h3.svelte-lifdi8{text-transform:uppercase}label.svelte-lifdi8{color:#666}input[type='checkbox'].svelte-lifdi8{vertical-align:middle}")}function Hl(e,t,n){const r=e.slice();return r[7]=t[n],r}function Zl(e){let t,n,r;return{c(){(t=M("p")).textContent="No bots available.",n=V(),(r=M("p")).innerHTML='Follow the instructions\n here\n to set up bots.'},m(e,l){q(e,t,l),q(e,n,l),q(e,r,l)},p:l,i:l,o:l,d(e){e&&I(t),e&&I(n),e&&I(r)}}}function Ul(e){let t;return{c(){(t=M("p")).textContent="The bot debugger is only available in singleplayer mode."},m(e,n){q(e,t,n)},p:l,i:l,o:l,d(e){e&&I(t)}}}function Wl(e){let t,n,r,l,o,a,s,i,u,d,f,p,m,g,v,$,y,h,b,x,w,k,P,j=Object.keys(e[7].opts()).length;a=new In({props:{value:"1",onPress:e[13],label:"reset"}}),u=new In({props:{value:"2",onPress:e[11],label:"play"}}),p=new In({props:{value:"3",onPress:e[12],label:"simulate"}});let E=Object.keys(e[8]),O=[];for(let c=0;ce[17].call(y))},m(c,j){q(c,t,j),A(t,n),A(t,r),A(t,l),A(l,o),Re(a,o,null),A(l,s),A(l,i),Re(u,i,null),A(l,d),A(l,f),Re(p,f,null),q(c,m,j),q(c,g,j),A(g,v),A(g,$),A(g,y);for(let e=0;e{z=null}),Se()),e[5]||e[3]?_?_.p(e,t):((_=Ql(e)).c(),_.m(x.parentNode,x)):_&&(_.d(1),_=null)},i(e){w||(Ce(a.$$.fragment,e),Ce(u.$$.fragment,e),Ce(p.$$.fragment,e),Ce(z),w=!0)},o(e){qe(a.$$.fragment,e),qe(u.$$.fragment,e),qe(p.$$.fragment,e),qe(z),w=!1},d(e){e&&I(t),Ke(a),Ke(u),Ke(p),e&&I(m),e&&I(g),T(O,e),e&&I(h),z&&z.d(e),e&&I(b),_&&_.d(e),e&&I(x),k=!1,c(P)}}}function Xl(e){let t,n,r,o=e[7]+"";return{c(){t=M("option"),n=N(o),t.__value=r=e[7],t.value=t.__value},m(e,r){q(e,t,r),A(t,n)},p:l,d(e){e&&I(t)}}}function Yl(e){let t,n,r,l,o,a,s,i,u,d,f;return i=new Ll({props:{bot:e[7]}}),{c(){t=M("section"),(n=M("h3")).textContent="Options",r=V(),(l=M("label")).textContent="debug",o=V(),a=M("input"),s=V(),Be(i.$$.fragment),G(n,"class","svelte-lifdi8"),G(l,"for","ai-option-debug"),G(l,"class","svelte-lifdi8"),G(a,"id","ai-option-debug"),G(a,"type","checkbox"),G(a,"class","svelte-lifdi8")},m(c,p){q(c,t,p),A(t,n),A(t,r),A(t,l),A(t,o),A(t,a),a.checked=e[1],A(t,s),Re(i,t,null),u=!0,d||(f=[R(a,"change",e[18]),R(a,"change",e[9])],d=!0)},p(e,t){2&t&&(a.checked=e[1]);const n={};128&t&&(n.bot=e[7]),i.$set(n)},i(e){u||(Ce(i.$$.fragment,e),u=!0)},o(e){qe(i.$$.fragment,e),u=!1},d(e){e&&I(t),Ke(i),d=!1,c(f)}}}function Ql(e){let t,n,r,l,o=e[2]&&e[2]<1&&eo(e),a=e[5]&&to(e);return{c(){t=M("section"),(n=M("h3")).textContent="Result",r=V(),o&&o.c(),l=V(),a&&a.c(),G(n,"class","svelte-lifdi8")},m(e,s){q(e,t,s),A(t,n),A(t,r),o&&o.m(t,null),A(t,l),a&&a.m(t,null)},p(e,n){e[2]&&e[2]<1?o?o.p(e,n):((o=eo(e)).c(),o.m(t,l)):o&&(o.d(1),o=null),e[5]?a?a.p(e,n):((a=to(e)).c(),a.m(t,null)):a&&(a.d(1),a=null)},d(e){e&&I(t),o&&o.d(),a&&a.d()}}}function eo(e){let t;return{c(){(t=M("progress")).value=e[2]},m(e,n){q(e,t,n)},p(e,n){4&n&&(t.value=e[2])},d(e){e&&I(t)}}}function to(e){let t,n,r,l,o,a,s,i,c=JSON.stringify(e[6])+"";return{c(){t=M("ul"),n=M("li"),r=N("Action: "),l=N(e[5]),o=V(),a=M("li"),s=N("Args: "),i=N(c),G(n,"class","svelte-lifdi8"),G(a,"class","svelte-lifdi8"),G(t,"class","svelte-lifdi8")},m(e,c){q(e,t,c),A(t,n),A(n,r),A(n,l),A(t,o),A(t,a),A(a,s),A(a,i)},p(e,t){32&t&&F(l,e[5]),64&t&&c!==(c=JSON.stringify(e[6])+"")&&F(i,c)},d(e){e&&I(t)}}}function no(e){let t,n,r,l,o,a;const s=[Wl,Ul,Zl],i=[];function c(e,t){return e[0].game.ai&&!e[0].multiplayer?0:e[0].multiplayer?1:2}return n=c(e),r=i[n]=s[n](e),{c(){t=M("section"),r.c()},m(r,s){q(r,t,s),i[n].m(t,null),l=!0,o||(a=R(window,"keydown",e[14]),o=!0)},p(e,[l]){let o=n;(n=c(e))===o?i[n].p(e,l):(_e(),qe(i[o],1,1,()=>{i[o]=null}),Se(),(r=i[n])?r.p(e,l):(r=i[n]=s[n](e)).c(),Ce(r,1),r.m(t,null))},i(e){l||(Ce(r),l=!0)},o(e){qe(r),l=!1},d(e){e&&I(t),i[n].d(),o=!1,a()}}}function ro(e,t,n){let{client:l}=t,{clientManager:o}=t,{ToggleVisibility:a}=t;const{secondaryPane:s}=de("secondaryPane"),i={MCTS:r.M,Random:r.R};let c=!1,u=null,d=0,f=null;const p=({iterationCounter:e,numIterations:t,metadata:r})=>{n(3,d=e),n(2,u=e/t),f=r,c&&f&&s.set({component:jl,metadata:f})};let m,g,v,$;function y(){l.overrideGameState(null),s.set(null),n(1,c=!1)}return l.game.ai&&(m=new r.M({game:l.game,enumerate:l.game.ai.enumerate,iterationCallback:p})).setOpt("async",!0),ie(y),e.$$set=(e=>{"client"in e&&n(0,l=e.client),"clientManager"in e&&n(15,o=e.clientManager),"ToggleVisibility"in e&&n(16,a=e.ToggleVisibility)}),[l,c,u,d,g,v,$,m,i,function(){c&&f?s.set({component:jl,metadata:f}):s.set(null)},function(){const e=i[g];n(7,m=new e({game:l.game,enumerate:l.game.ai.enumerate,iterationCallback:p})),m.setOpt("async",!0),n(5,v=null),f=null,s.set(null),n(3,d=0)},async function(){n(5,v=null),f=null,n(3,d=0);const e=await(0,r.S)(l,m);e&&(n(5,v=e.payload.type),n(6,$=e.payload.args))},function(e=1e4,t=100){return n(5,v=null),f=null,n(3,d=0),(async()=>{for(let n=0;nsetTimeout(e,t))})()},function(){l.reset(),n(5,v=null),f=null,n(3,d=0),y()},function(e){27==e.keyCode&&y()},o,a,function(){g=U(this),n(4,g),n(8,i)},function(){c=this.checked,n(1,c)}]}class lo extends Le{constructor(e){super(),Je(this,e,ro,no,d,{client:0,clientManager:15,ToggleVisibility:16},Fl)}}function oo(e){z(e,"svelte-8ymctk",".debug-panel.svelte-8ymctk.svelte-8ymctk{position:fixed;color:#555;font-family:monospace;right:0;top:0;height:100%;font-size:14px;opacity:0.9;z-index:99999}.panel.svelte-8ymctk.svelte-8ymctk{display:flex;position:relative;flex-direction:row;height:100%}.visibility-toggle.svelte-8ymctk.svelte-8ymctk{position:absolute;box-sizing:border-box;top:7px;border:1px solid #ccc;border-radius:5px;width:48px;height:48px;padding:8px;background:white;color:#555;box-shadow:0 0 5px rgba(0, 0, 0, 0.2)}.visibility-toggle.svelte-8ymctk.svelte-8ymctk:hover,.visibility-toggle.svelte-8ymctk.svelte-8ymctk:focus{background:#eee}.opener.svelte-8ymctk.svelte-8ymctk{right:10px}.closer.svelte-8ymctk.svelte-8ymctk{left:-326px}@keyframes svelte-8ymctk-rotateFromZero{from{transform:rotateZ(0deg)}to{transform:rotateZ(180deg)}}.icon.svelte-8ymctk.svelte-8ymctk{display:flex;height:100%;animation:svelte-8ymctk-rotateFromZero 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55) 0s 1\n normal forwards}.closer.svelte-8ymctk .icon.svelte-8ymctk{animation-direction:reverse}.pane.svelte-8ymctk.svelte-8ymctk{flex-grow:2;overflow-x:hidden;overflow-y:scroll;background:#fefefe;padding:20px;border-left:1px solid #ccc;box-shadow:-1px 0 5px rgba(0, 0, 0, 0.2);box-sizing:border-box;width:280px}.secondary-pane.svelte-8ymctk.svelte-8ymctk{background:#fefefe;overflow-y:scroll}.debug-panel.svelte-8ymctk button,.debug-panel.svelte-8ymctk select{cursor:pointer;font-size:14px;font-family:monospace}.debug-panel.svelte-8ymctk select{background:#eee;border:1px solid #bbb;color:#555;padding:3px;border-radius:3px}.debug-panel.svelte-8ymctk section{margin-bottom:20px}.debug-panel.svelte-8ymctk .screen-reader-only{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}")}function ao(e){let t,n,r,l,o,a,s,i,c,u=e[10]&&io(e);(r=new ft({props:{panes:e[6],pane:e[2]}})).$on("change",e[8]);var d=e[6][e[2]].component;function f(e){return{props:{client:e[4],clientManager:e[0],ToggleVisibility:e[9]}}}d&&(a=new d(f(e)));let p=e[5]&&co(e);return{c(){t=M("div"),u&&u.c(),n=V(),Be(r.$$.fragment),l=V(),o=M("div"),a&&Be(a.$$.fragment),s=V(),p&&p.c(),G(o,"class","pane svelte-8ymctk"),G(o,"role","region"),G(o,"aria-label",e[2]),G(o,"tabindex","-1"),G(t,"class","panel svelte-8ymctk")},m(i,d){q(i,t,d),u&&u.m(t,null),A(t,n),Re(r,t,null),A(t,l),A(t,o),a&&Re(a,o,null),e[16](o),A(t,s),p&&p.m(t,null),c=!0},p(n,l){(e=n)[10]&&u.p(e,l);const s={};4&l&&(s.pane=e[2]),r.$set(s);const i={};if(16&l&&(i.client=e[4]),1&l&&(i.clientManager=e[0]),d!==(d=e[6][e[2]].component)){if(a){_e();const e=a;qe(e.$$.fragment,1,0,()=>{Ke(e,1)}),Se()}d?(Be((a=new d(f(e))).$$.fragment),Ce(a.$$.fragment,1),Re(a,o,null)):a=null}else d&&a.$set(i);(!c||4&l)&&G(o,"aria-label",e[2]),e[5]?p?(p.p(e,l),32&l&&Ce(p,1)):((p=co(e)).c(),Ce(p,1),p.m(t,null)):p&&(_e(),qe(p,1,1,()=>{p=null}),Se())},i(n){c||(Ce(u),Ce(r.$$.fragment,n),a&&Ce(a.$$.fragment,n),Ce(p),be(()=>{i||(i=De(t,We,{x:400,...e[12]},!0)),i.run(1)}),c=!0)},o(n){qe(u),qe(r.$$.fragment,n),a&&qe(a.$$.fragment,n),qe(p),i||(i=De(t,We,{x:400,...e[12]},!1)),i.run(0),c=!1},d(n){n&&I(t),u&&u.d(),Ke(r),a&&Ke(a),e[16](null),p&&p.d(),n&&i&&i.end()}}}function so(e){let t,n,r=e[10]&&uo(e);return{c(){r&&r.c(),t=B()},m(e,l){r&&r.m(e,l),q(e,t,l),n=!0},p(e,t){e[10]&&r.p(e,t)},i(e){n||(Ce(r),n=!0)},o(e){qe(r),n=!1},d(e){r&&r.d(e),e&&I(t)}}}function io(e){let t,n,r,o,a,s,i,c;return r=new at({}),{c(){t=M("button"),n=M("span"),Be(r.$$.fragment),G(n,"class","icon svelte-8ymctk"),G(n,"aria-hidden","true"),G(t,"class","visibility-toggle closer svelte-8ymctk"),G(t,"title","Hide Debug Panel")},m(l,o){q(l,t,o),A(t,n),Re(r,n,null),s=!0,i||(c=R(t,"click",e[9]),i=!0)},p:l,i(n){s||(Ce(r.$$.fragment,n),be(()=>{a&&a.end(1),(o=Te(t,e[14],{key:"toggle"})).start()}),s=!0)},o(n){qe(r.$$.fragment,n),o&&o.invalidate(),a=Me(t,e[13],{key:"toggle"}),s=!1},d(e){e&&I(t),Ke(r),e&&a&&a.end(),i=!1,c()}}}function co(e){let t,n,r;var l=e[5].component;function o(e){return{props:{metadata:e[5].metadata}}}return l&&(n=new l(o(e))),{c(){t=M("div"),n&&Be(n.$$.fragment),G(t,"class","secondary-pane svelte-8ymctk")},m(e,l){q(e,t,l),n&&Re(n,t,null),r=!0},p(e,r){const a={};if(32&r&&(a.metadata=e[5].metadata),l!==(l=e[5].component)){if(n){_e();const e=n;qe(e.$$.fragment,1,0,()=>{Ke(e,1)}),Se()}l?(Be((n=new l(o(e))).$$.fragment),Ce(n.$$.fragment,1),Re(n,t,null)):n=null}else l&&n.$set(a)},i(e){r||(n&&Ce(n.$$.fragment,e),r=!0)},o(e){n&&qe(n.$$.fragment,e),r=!1},d(e){e&&I(t),n&&Ke(n)}}}function uo(e){let t,n,r,o,a,s,i,c;return r=new at({}),{c(){t=M("button"),n=M("span"),Be(r.$$.fragment),G(n,"class","icon svelte-8ymctk"),G(n,"aria-hidden","true"),G(t,"class","visibility-toggle opener svelte-8ymctk"),G(t,"title","Show Debug Panel")},m(l,o){q(l,t,o),A(t,n),Re(r,n,null),s=!0,i||(c=R(t,"click",e[9]),i=!0)},p:l,i(n){s||(Ce(r.$$.fragment,n),be(()=>{a&&a.end(1),(o=Te(t,e[14],{key:"toggle"})).start()}),s=!0)},o(n){qe(r.$$.fragment,n),o&&o.invalidate(),a=Me(t,e[13],{key:"toggle"}),s=!1},d(e){e&&I(t),Ke(r),e&&a&&a.end(),i=!1,c()}}}function fo(e){let t,n,r,l,o,a;const s=[so,ao],i=[];function c(e,t){return e[3]?1:0}return n=c(e),r=i[n]=s[n](e),{c(){t=M("section"),r.c(),G(t,"aria-label","boardgame.io Debug Panel"),G(t,"class","debug-panel svelte-8ymctk")},m(r,s){q(r,t,s),i[n].m(t,null),l=!0,o||(a=R(window,"keypress",e[11]),o=!0)},p(e,[l]){let o=n;(n=c(e))===o?i[n].p(e,l):(_e(),qe(i[o],1,1,()=>{i[o]=null}),Se(),(r=i[n])?r.p(e,l):(r=i[n]=s[n](e)).c(),Ce(r,1),r.m(t,null))},i(e){l||(Ce(r),l=!0)},o(e){qe(r),l=!1},d(e){e&&I(t),i[n].d(),o=!1,a()}}}function po(e,t,n){let r,o,a,s=l,i=()=>(s(),s=p(c,e=>n(15,o=e)),c);e.$$.on_destroy.push(()=>s());let{clientManager:c}=t;i();const u={main:{label:"Main",shortcut:"m",component:zr},log:{label:"Log",shortcut:"l",component:Dl},info:{label:"Info",shortcut:"i",component:Nr},ai:{label:"AI",shortcut:"a",component:lo}},d=He(!1),f=He(null);let g;m(e,f,e=>n(5,a=e)),ue("hotkeys",{disableHotkeys:d}),ue("secondaryPane",{secondaryPane:f});let v="main";function $(){n(3,h=!h)}const y=o.client.debugOpt;let h=!y||!y.collapseOnLoad;const b=!y||!y.hideToggleButton;const x={duration:150,easing:Ze},[w,k]=Xe(x);return e.$$set=(e=>{"clientManager"in e&&i(n(0,c=e.clientManager))}),e.$$.update=(()=>{32768&e.$$.dirty&&n(4,r=o.client)}),[c,g,v,h,r,a,u,f,function(e){n(2,v=e.detail),g.focus()},$,b,function(e){"."!=e.key?h&&Object.entries(u).forEach(([t,{shortcut:r}])=>{e.key==r&&n(2,v=t)}):$()},x,w,k,o,function(e){me[e?"unshift":"push"](()=>{n(1,g=e)})}]}class mo extends Le{constructor(e){super(),Je(this,e,po,fo,d,{clientManager:0},oo)}}exports.D=mo; },{"./turn-order-0b7dce3d.js":"MZmr","./reducer-07c7b307.js":"iEGk","flatted":"O5av","./ai-3099ce9a.js":"pO2S"}],"KkrQ":[function(require,module,exports) { "use strict";function e(e,r,t){return r in e?Object.defineProperty(e,r,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[r]=t,e}Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=e; },{}],"e8DE":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=n;var e=r(require("./defineProperty.js"));function r(e){return e&&e.__esModule?e:{default:e}}function t(e,r){var t=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);r&&(n=n.filter(function(r){return Object.getOwnPropertyDescriptor(e,r).enumerable})),t.push.apply(t,n)}return t}function n(r){for(var n=1;n0?"Unexpected "+(a.length>1?"keys":"key")+' "'+a.join('", "')+'" found in '+c+'. Expected to find one of the known reducer keys instead: "'+o.join('", "')+'". Unexpected keys will be ignored.':void 0}function s(e){Object.keys(e).forEach(function(r){var n=e[r];if(void 0===n(void 0,{type:i.INIT}))throw new Error(t(12));if(void 0===n(void 0,{type:i.PROBE_UNKNOWN_ACTION()}))throw new Error(t(13))})}function d(e){for(var r=Object.keys(e),n={},o=0;o{}),this.isConnected=!1,this.transportDataCallback=t,this.gameName=a||"default",this.playerID=s||null,this.matchID=e||"default",this.credentials=n,this.numPlayers=i||2}subscribeToConnectionStatus(t){this.connectionStatusCallback=t}setConnectionStatus(t){this.isConnected=t,this.connectionStatusCallback()}notifyClient(t){this.transportDataCallback(t)}}exports.T=t; },{}],"FkTq":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.C=y;var t=require("nanoid/non-secure"),e=require("./Debug-fd09b8bc.js"),s=require("redux"),i=require("./turn-order-0b7dce3d.js"),r=require("./reducer-07c7b307.js"),a=require("./initialize-9ac1bbf5.js"),n=require("./transport-ce07b771.js");class h extends n.T{connect(){}disconnect(){}sendAction(){}sendChatMessage(){}requestSync(){}updateCredentials(){}updateMatchID(){}updatePlayerID(){}}const c=t=>new h(t);class l{constructor(){this.debugPanel=null,this.currentClient=null,this.clients=new Map,this.subscribers=new Map}register(t){this.clients.set(t,t),this.mountDebug(t),this.notifySubscribers()}unregister(t){if(this.clients.delete(t),this.currentClient===t){this.unmountDebug();for(const[t]of this.clients){if(this.debugPanel)break;this.mountDebug(t)}}this.notifySubscribers()}subscribe(t){const e=Symbol();return this.subscribers.set(e,t),t(this.getState()),()=>{this.subscribers.delete(e)}}switchPlayerID(t){if(this.currentClient.multiplayer)for(const[e]of this.clients)if(e.playerID===t&&!1!==e.debugOpt&&e.multiplayer===this.currentClient.multiplayer)return void this.switchToClient(e);this.currentClient.updatePlayerID(t),this.notifySubscribers()}switchToClient(t){t!==this.currentClient&&(this.unmountDebug(),this.mountDebug(t),this.notifySubscribers())}notifySubscribers(){const t=this.getState();this.subscribers.forEach(e=>{e(t)})}getState(){return{client:this.currentClient,debuggableClients:this.getDebuggableClients()}}getDebuggableClients(){return[...this.clients.values()].filter(t=>!1!==t.debugOpt)}mountDebug(t){if(!1===t.debugOpt||null!==this.debugPanel||"undefined"==typeof document)return;let e,s=document.body;t.debugOpt&&!0!==t.debugOpt&&(e=t.debugOpt.impl||e,s=t.debugOpt.target||s),e&&(this.currentClient=t,this.debugPanel=new e({target:s,props:{clientManager:this}}))}unmountDebug(){this.debugPanel.$destroy(),this.debugPanel=null,this.currentClient=null}}const u=new l;function o(t,e,s){if(!s&&null==t){t=e.getState().ctx.currentPlayer}return t}function g(t,e,s,r,a,n){const h={};for(const c of e)h[c]=((...e)=>{const h=i.A[t](c,e,o(r,s,n),a);s.dispatch(h)});return h}const b=g.bind(null,"makeMove"),d=g.bind(null,"gameEvent"),p=g.bind(null,"plugin");class m{constructor({game:e,debug:n,numPlayers:h,multiplayer:l,matchID:g,playerID:b,credentials:d,enhancer:p}){this.game=(0,r.P)(e),this.playerID=b,this.matchID=g||"default",this.credentials=d,this.multiplayer=l,this.debugOpt=n,this.manager=u,this.gameStateOverride=null,this.subscribers={},this._running=!1,this.reducer=(0,r.C)({game:this.game,isClient:void 0!==l}),this.initialState=null,l||(this.initialState=(0,a.I)({game:this.game,numPlayers:h})),this.reset=(()=>{this.store.dispatch((0,i.u)(this.initialState))}),this.undo=(()=>{const t=(0,i.v)(o(this.playerID,this.store,this.multiplayer),this.credentials);this.store.dispatch(t)}),this.redo=(()=>{const t=(0,i.w)(o(this.playerID,this.store,this.multiplayer),this.credentials);this.store.dispatch(t)}),this.log=[];const m=(0,s.applyMiddleware)(r.T,()=>t=>e=>{const s=t(e);return this.notifySubscribers(),s},t=>e=>s=>{const r=t.getState(),a=e(s);return"clientOnly"in s||s.type===i.p||this.transport.sendAction(r,s),a},t=>e=>s=>{const r=e(s),a=t.getState();switch(s.type){case i.M:case i.o:case i.h:case i.R:{const t=a.deltalog;this.log=[...this.log,...t];break}case i.l:this.log=[];break;case i.P:case i.k:{let t=-1;this.log.length>0&&(t=this.log[this.log.length-1]._stateID);let e=s.deltalog||[];e=e.filter(e=>e._stateID>t),this.log=[...this.log,...e];break}case i.j:this.initialState=s.initialState,this.log=s.log||[]}return r});p=void 0!==p?(0,s.compose)(m,p):m,this.store=(0,s.createStore)(this.reducer,this.initialState,p),l||(l=c),this.transport=l({transportDataCallback:t=>this.receiveTransportData(t),gameKey:e,game:this.game,matchID:g,playerID:b,credentials:d,gameName:this.game.name,numPlayers:h}),this.createDispatchers(),this.chatMessages=[],this.sendChatMessage=(e=>{this.transport.sendChatMessage(this.matchID,{id:(0,t.nanoid)(7),sender:this.playerID,payload:e})})}receiveMatchData(t){this.matchData=t,this.notifySubscribers()}receiveChatMessage(t){this.chatMessages=[...this.chatMessages,t],this.notifySubscribers()}receiveTransportData(t){const[e]=t.args;if(e===this.matchID)switch(t.type){case"sync":{const[,e]=t.args,s=(0,i.s)(e);this.receiveMatchData(e.filteredMetadata),this.store.dispatch(s);break}case"update":{const[,e,s]=t.args,r=this.store.getState();if(e._stateID>=r._stateID){const t=(0,i.z)(e,s);this.store.dispatch(t)}break}case"patch":{const[,e,s,r,a]=t.args,n=this.store.getState()._stateID;if(e!==n)break;const h=(0,i.y)(e,s,r,a);this.store.dispatch(h),this.store.getState()._stateID===n&&this.transport.requestSync();break}case"matchData":{const[,e]=t.args;this.receiveMatchData(e);break}case"chat":{const[,e]=t.args;this.receiveChatMessage(e);break}}}notifySubscribers(){Object.values(this.subscribers).forEach(t=>t(this.getState()))}overrideGameState(t){this.gameStateOverride=t,this.notifySubscribers()}start(){this.transport.connect(),this._running=!0,this.manager.register(this)}stop(){this.transport.disconnect(),this._running=!1,this.manager.unregister(this)}subscribe(t){const e=Object.keys(this.subscribers).length;return this.subscribers[e]=t,this.transport.subscribeToConnectionStatus(()=>this.notifySubscribers()),!this._running&&this.multiplayer||t(this.getState()),()=>{delete this.subscribers[e]}}getInitialState(){return this.initialState}getState(){let t=this.store.getState();if(null!==this.gameStateOverride&&(t=this.gameStateOverride),null===t)return t;let e=!0;const s=this.game.flow.isPlayerActive(t.G,t.ctx,this.playerID);return this.multiplayer&&!s&&(e=!1),this.multiplayer||null===this.playerID||void 0===this.playerID||s||(e=!1),void 0!==t.ctx.gameover&&(e=!1),this.multiplayer||(t={...t,G:this.game.playerView(t.G,t.ctx,this.playerID),plugins:(0,i.x)(t,this)}),{...t,log:this.log,isActive:e,isConnected:this.transport.isConnected}}createDispatchers(){this.moves=b(this.game.moveNames,this.store,this.playerID,this.credentials,this.multiplayer),this.events=d(this.game.flow.enabledEventNames,this.store,this.playerID,this.credentials,this.multiplayer),this.plugins=p(this.game.pluginNames,this.store,this.playerID,this.credentials,this.multiplayer)}updatePlayerID(t){this.playerID=t,this.createDispatchers(),this.transport.updatePlayerID(t),this.notifySubscribers()}updateMatchID(t){this.matchID=t,this.createDispatchers(),this.transport.updateMatchID(t),this.notifySubscribers()}updateCredentials(t){this.credentials=t,this.createDispatchers(),this.transport.updateCredentials(t),this.notifySubscribers()}}function y(t){return new m(t)} },{"nanoid/non-secure":"zm2Q","./Debug-fd09b8bc.js":"uvSB","redux":"OV4J","./turn-order-0b7dce3d.js":"MZmr","./reducer-07c7b307.js":"iEGk","./initialize-9ac1bbf5.js":"Wibm","./transport-ce07b771.js":"zA0v"}],"mOPV":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.a=exports.L=void 0;const e=(e,t)=>{if(!e||"string"!=typeof e)throw new Error(`Expected ${t} string, got "${e}".`)},t=t=>e(t,"game name"),s=t=>e(t,"match ID"),r=(e,t)=>{if(!e)throw new Error(`Expected body, got “${e}”.`);for(const s in t){const r=t[s],a=Array.isArray(r)?r:[r],n=e[s];if(!a.includes(typeof n)){const e=a.join("|");throw new TypeError(`Expected body.${s} to be of type ${e}, got “${n}”.`)}}};class a extends Error{constructor(e,t){super(e),this.details=t}}exports.a=a;class n{constructor({server:e=""}={}){this.server=e.replace(/\/$/,"")}async request(e,t){const s=await fetch(this.server+e,t);if(!s.ok){let e;try{e=await s.clone().json()}catch{try{e=await s.text()}catch(r){e=r.message}}throw new a(`HTTP status ${s.status}`,e)}return s.json()}async post(e,t){let s={method:"post",body:JSON.stringify(t.body),headers:{"Content-Type":"application/json"}};return t.init&&(s={...s,...t.init,headers:{...s.headers,...t.init.headers}}),this.request(e,s)}async listGames(e){return this.request("/games",e)}async listMatches(e,s,r){t(e);let a="";if(s){const e=[],{isGameover:t,updatedBefore:r,updatedAfter:n}=s;void 0!==t&&e.push(`isGameover=${t}`),r&&e.push(`updatedBefore=${r}`),n&&e.push(`updatedAfter=${n}`),e.length>0&&(a="?"+e.join("&"))}return this.request(`/games/${e}${a}`,r)}async getMatch(e,r,a){return t(e),s(r),this.request(`/games/${e}/${r}`,a)}async createMatch(e,s,a){return t(e),r(s,{numPlayers:"number"}),this.post(`/games/${e}/create`,{body:s,init:a})}async joinMatch(e,a,n,i){return t(e),s(a),r(n,{playerID:["string","undefined"],playerName:"string"}),this.post(`/games/${e}/${a}/join`,{body:n,init:i})}async leaveMatch(e,a,n,i){t(e),s(a),r(n,{playerID:"string",credentials:"string"}),await this.post(`/games/${e}/${a}/leave`,{body:n,init:i})}async updatePlayer(e,a,n,i){t(e),s(a),r(n,{playerID:"string",credentials:"string"}),await this.post(`/games/${e}/${a}/update`,{body:n,init:i})}async playAgain(e,a,n,i){return t(e),s(a),r(n,{playerID:"string",credentials:"string"}),this.post(`/games/${e}/${a}/playAgain`,{body:n,init:i})}}exports.L=n; },{}],"L8uO":[function(require,module,exports) { "use strict";var e=require("object-assign"),r="function"==typeof Symbol&&Symbol.for,t=r?Symbol.for("react.element"):60103,n=r?Symbol.for("react.portal"):60106,o=r?Symbol.for("react.fragment"):60107,u=r?Symbol.for("react.strict_mode"):60108,f=r?Symbol.for("react.profiler"):60114,c=r?Symbol.for("react.provider"):60109,l=r?Symbol.for("react.context"):60110,i=r?Symbol.for("react.forward_ref"):60112,s=r?Symbol.for("react.suspense"):60113,a=r?Symbol.for("react.memo"):60115,p=r?Symbol.for("react.lazy"):60116,y="function"==typeof Symbol&&Symbol.iterator;function d(e){for(var r="https://reactjs.org/docs/error-decoder.html?invariant="+e,t=1;tP.length&&P.push(e)}function A(e,r,o,u){var f=typeof e;"undefined"!==f&&"boolean"!==f||(e=null);var c=!1;if(null===e)c=!0;else switch(f){case"string":case"number":c=!0;break;case"object":switch(e.$$typeof){case t:case n:c=!0}}if(c)return o(u,e,""===r?"."+U(e,0):r),1;if(c=0,r=""===r?".":r+":",Array.isArray(e))for(var l=0;l{const n={gameName:e.name,unlisted:!!t,players:{},createdAt:Date.now(),updatedAt:Date.now()};void 0!==a&&(n.setupData=a);for(let o=0;o{a&&"number"==typeof a||(a=2);const r=e.validateSetupData&&e.validateSetupData(s,a);return void 0!==r?{setupDataError:r}:{metadata:o({game:e,numPlayers:a,setupData:s,unlisted:n}),initialState:(0,t.I)({game:e,numPlayers:a,setupData:s})}};exports.c=r; },{"./initialize-9ac1bbf5.js":"Wibm"}],"gTRl":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.M=void 0;var t=require("redux"),a=require("./turn-order-0b7dce3d.js"),e=require("./reducer-07c7b307.js"),r=require("./util-b1699aa1.js");const s=t=>Object.values(t.players).map(t=>{const{credentials:a,...e}=t;return e}),i=t=>{const{credentials:a,...e}=t.payload;return{...t,payload:e}};class o{constructor(t,a,r,s){this.game=(0,e.P)(t),this.storageAPI=a,this.transportAPI=r,this.subscribeCallback=(()=>{}),this.auth=s}subscribe(t){this.subscribeCallback=t}async onUpdate(s,o,n,c){if(!s||!s.payload)return{error:"missing action or action payload"};let l;if((0,r.i)(this.storageAPI)?({metadata:l}=this.storageAPI.fetch(n,{metadata:!0})):({metadata:l}=await this.storageAPI.fetch(n,{metadata:!0})),this.auth){if(!(await this.auth.authenticateCredentials({playerID:c,credentials:s.payload.credentials,metadata:l})))return{error:"unauthorized action"}}const d=i(s),h=n;let u;if((0,r.i)(this.storageAPI)?({state:u}=this.storageAPI.fetch(h,{state:!0})):({state:u}=await this.storageAPI.fetch(h,{state:!0})),void 0===u)return(0,a.e)(`game not found, matchID=[${h}]`),{error:"game not found"};if(void 0!==u.ctx.gameover)return void(0,a.e)(`game over - matchID=[${h}] - playerID=[${c}]`+` - action[${d.payload.type}]`);const p=(0,e.C)({game:this.game}),g=(0,t.applyMiddleware)(e.T),y=(0,t.createStore)(p,u,g);if(d.type==a.h||d.type==a.R){const t=null!==u.ctx.activePlayers,e=u.ctx.currentPlayer===c;if(!t&&!e||t&&(void 0===u.ctx.activePlayers[c]||Object.keys(u.ctx.activePlayers).length>1))return void(0,a.e)(`playerID=[${c}] cannot undo / redo right now`)}if(!this.game.flow.isPlayerActive(u.G,u.ctx,c))return void(0,a.e)(`player not active - playerID=[${c}]`+` - action[${d.payload.type}]`);const m=d.type==a.M?this.game.flow.getMove(u.ctx,d.payload.type,c):null;if(d.type==a.M&&!m)return void(0,a.e)(`move not processed - canPlayerMakeMove=false - playerID=[${c}]`+` - action[${d.payload.type}]`);if(u._stateID!==o&&!(m&&(0,e.I)(m)&&m.ignoreStaleStateID))return void(0,a.e)(`invalid stateID, was=[${o}], expected=[${u._stateID}]`+` - playerID=[${c}] - action[${d.payload.type}]`);const I=y.getState();y.dispatch(d),u=y.getState(),this.subscribeCallback({state:u,action:d,matchID:n}),this.game.deltaState?this.transportAPI.sendAll({type:"patch",args:[n,o,I,u]}):this.transportAPI.sendAll({type:"update",args:[n,u]});const{deltalog:f,...P}=u;let A;if(!l||void 0!==l.gameover&&null!==l.gameover||(A={...l,updatedAt:Date.now()},void 0!==u.ctx.gameover&&(A.gameover=u.ctx.gameover)),(0,r.i)(this.storageAPI))this.storageAPI.setState(h,P,f),A&&this.storageAPI.setMetadata(h,A);else{const t=[this.storageAPI.setState(h,P,f)];A&&t.push(this.storageAPI.setMetadata(h,A)),await Promise.all(t)}}async onSync(t,a,e,i=2){const o=t,n={state:!0,metadata:!0,log:!0,initialState:!0},c=(0,r.i)(this.storageAPI)?this.storageAPI.fetch(o,n):await this.storageAPI.fetch(o,n);let{state:l,initialState:d,log:h,metadata:u}=c;if(this.auth&&null!=a){if(!(await this.auth.authenticateCredentials({playerID:a,credentials:e,metadata:u})))return{error:"unauthorized"}}if(void 0===l){const a=(0,r.c)({game:this.game,unlisted:!0,numPlayers:i,setupData:void 0});if("setupDataError"in a)return{error:"game requires setupData"};d=l=a.initialState,u=a.metadata,this.subscribeCallback({state:l,matchID:t}),(0,r.i)(this.storageAPI)?this.storageAPI.createMatch(o,{initialState:d,metadata:u}):await this.storageAPI.createMatch(o,{initialState:d,metadata:u})}const p={state:l,log:h,filteredMetadata:u?s(u):void 0,initialState:d};this.transportAPI.send({playerID:a,type:"sync",args:[t,p]})}async onConnectionChange(t,e,i,o){const n=t;if(null==e)return;let c;if((0,r.i)(this.storageAPI)?({metadata:c}=this.storageAPI.fetch(n,{metadata:!0})):({metadata:c}=await this.storageAPI.fetch(n,{metadata:!0})),void 0===c)return(0,a.e)(`metadata not found for matchID=[${n}]`),{error:"metadata not found"};if(void 0===c.players[e])return(0,a.e)(`Player not in the match, matchID=[${n}] playerID=[${e}]`),{error:"player not in the match"};if(this.auth){if(!(await this.auth.authenticateCredentials({playerID:e,credentials:i,metadata:c})))return{error:"unauthorized"}}c.players[e].isConnected=o;const l=s(c);this.transportAPI.sendAll({type:"matchData",args:[t,l]}),(0,r.i)(this.storageAPI)?this.storageAPI.setMetadata(n,c):await this.storageAPI.setMetadata(n,c)}async onChatMessage(t,a,e){const r=t;if(this.auth){const{metadata:t}=await this.storageAPI.fetch(r,{metadata:!0});if(!a||"string"!=typeof a.sender)return{error:"unauthorized"};if(!(await this.auth.authenticateCredentials({playerID:a.sender,credentials:e,metadata:t})))return{error:"unauthorized"}}this.transportAPI.sendAll({type:"chat",args:[t,a]})}}exports.M=o; },{"redux":"OV4J","./turn-order-0b7dce3d.js":"MZmr","./reducer-07c7b307.js":"iEGk","./util-b1699aa1.js":"pSNY"}],"AbzV":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.g=void 0;var e=require("./turn-order-0b7dce3d.js"),t=require("rfc6902");const r=(t,r,a)=>({...a,G:t.playerView(a.G,a.ctx,r),plugins:(0,e.x)(a,{playerID:r,game:t}),deltalog:void 0,_undo:[],_redo:[]}),a=e=>(a,s)=>{switch(s.type){case"patch":{const[c,n,l,u]=s.args,d=o(u.deltalog,a),p=r(e,a,u),i=u._stateID,g=r(e,a,l);return{type:"patch",args:[c,n,i,(0,t.createPatch)(g,p),d]}}case"update":{const[t,c]=s.args,n=o(c.deltalog,a);return{type:"update",args:[t,r(e,a,c),n]}}case"sync":{const[t,c]=s.args,n=r(e,a,c.state),l=o(c.log,a);return{type:"sync",args:[t,{...c,state:n,log:l}]}}default:return s}};function o(e,t){return void 0===e?e:e.map(e=>{if(null!==t&&+t==+e.action.payload.playerID)return e;if(!0!==e.redact)return e;const r={...e.action.payload,args:null},a={...e,action:{...e.action,payload:r}},{redact:o,...s}=a;return s})}exports.g=a; },{"./turn-order-0b7dce3d.js":"MZmr","rfc6902":"B6py"}],"A28J":[function(require,module,exports) { var r=/^(?:(?![^:@]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/,e=["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"];function t(r,e){var t=e.replace(/\/{2,9}/g,"/").split("/");return"/"!=e.substr(0,1)&&0!==e.length||t.splice(0,1),"/"==e.substr(e.length-1,1)&&t.splice(t.length-1,1),t}function s(r,e){var t={};return e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,function(r,e,s){e&&(t[e]=s)}),t}module.exports=function(u){var a=u,n=u.indexOf("["),o=u.indexOf("]");-1!=n&&-1!=o&&(u=u.substring(0,n)+u.substring(n,o).replace(/:/g,";")+u.substring(o,u.length));for(var i=r.exec(u||""),p={},c=14;c--;)p[e[c]]=i[c]||"";return-1!=n&&-1!=o&&(p.source=a,p.host=p.host.substring(1,p.host.length-1).replace(/;/g,":"),p.authority=p.authority.replace("[","").replace("]","").replace(/;/g,":"),p.ipv6uri=!0),p.pathNames=t(p,p.path),p.queryKey=s(p,p.query),p}; },{}],"EmkX":[function(require,module,exports) { var s=1e3,e=60*s,r=60*e,a=24*r,n=7*a,c=365.25*a;function t(t){if(!((t=String(t)).length>100)){var u=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(t);if(u){var i=parseFloat(u[1]);switch((u[2]||"ms").toLowerCase()){case"years":case"year":case"yrs":case"yr":case"y":return i*c;case"weeks":case"week":case"w":return i*n;case"days":case"day":case"d":return i*a;case"hours":case"hour":case"hrs":case"hr":case"h":return i*r;case"minutes":case"minute":case"mins":case"min":case"m":return i*e;case"seconds":case"second":case"secs":case"sec":case"s":return i*s;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return i;default:return}}}}function u(n){var c=Math.abs(n);return c>=a?Math.round(n/a)+"d":c>=r?Math.round(n/r)+"h":c>=e?Math.round(n/e)+"m":c>=s?Math.round(n/s)+"s":n+"ms"}function i(n){var c=Math.abs(n);return c>=a?o(n,c,a,"day"):c>=r?o(n,c,r,"hour"):c>=e?o(n,c,e,"minute"):c>=s?o(n,c,s,"second"):n+" ms"}function o(s,e,r,a){var n=e>=1.5*r;return Math.round(s/r)+" "+a+(n?"s":"")}module.exports=function(s,e){e=e||{};var r=typeof s;if("string"===r&&s.length>0)return t(s);if("number"===r&&isFinite(s))return e.long?i(s):u(s);throw new Error("val is not a non-empty string or a valid number. val="+JSON.stringify(s))}; },{}],"sQiI":[function(require,module,exports) { function e(e){function n(e){let r,s,o,a=null;function l(...e){if(!l.enabled)return;const t=l,s=Number(new Date),o=s-(r||s);t.diff=o,t.prev=r,t.curr=s,r=s,e[0]=n.coerce(e[0]),"string"!=typeof e[0]&&e.unshift("%O");let a=0;e[0]=e[0].replace(/%([a-zA-Z%])/g,(r,s)=>{if("%%"===r)return"%";a++;const o=n.formatters[s];if("function"==typeof o){const n=e[a];r=o.call(t,n),e.splice(a,1),a--}return r}),n.formatArgs.call(t,e),(t.log||n.log).apply(t,e)}return l.namespace=e,l.useColors=n.useColors(),l.color=n.selectColor(e),l.extend=t,l.destroy=n.destroy,Object.defineProperty(l,"enabled",{enumerable:!0,configurable:!1,get:()=>null!==a?a:(s!==n.namespaces&&(s=n.namespaces,o=n.enabled(e)),o),set:e=>{a=e}}),"function"==typeof n.init&&n.init(l),l}function t(e,t){const r=n(this.namespace+(void 0===t?":":t)+e);return r.log=this.log,r}function r(e){return e.toString().substring(2,e.toString().length-2).replace(/\.\*\?$/,"*")}return n.debug=n,n.default=n,n.coerce=function(e){if(e instanceof Error)return e.stack||e.message;return e},n.disable=function(){const e=[...n.names.map(r),...n.skips.map(r).map(e=>"-"+e)].join(",");return n.enable(""),e},n.enable=function(e){let t;n.save(e),n.namespaces=e,n.names=[],n.skips=[];const r=("string"==typeof e?e:"").split(/[\s,]+/),s=r.length;for(t=0;t{n[t]=e[t]}),n.names=[],n.skips=[],n.formatters={},n.selectColor=function(e){let t=0;for(let n=0;n=31||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/))}function t(e){if(e[0]=(this.useColors?"%c":"")+this.namespace+(this.useColors?" %c":" ")+e[0]+(this.useColors?"%c ":" ")+"+"+module.exports.humanize(this.diff),!this.useColors)return;const o="color: "+this.color;e.splice(1,0,o,"color: inherit");let t=0,C=0;e[0].replace(/%[a-zA-Z%]/g,e=>{"%%"!==e&&(t++,"%c"===e&&(C=t))}),e.splice(C,0,o)}function C(e){try{e?exports.storage.setItem("debug",e):exports.storage.removeItem("debug")}catch(o){}}function r(){let o;try{o=exports.storage.getItem("debug")}catch(t){}return!o&&void 0!==e&&"env"in e&&(o=void 0),o}function n(){try{return localStorage}catch(e){}}exports.formatArgs=t,exports.save=C,exports.load=r,exports.useColors=o,exports.storage=n(),exports.destroy=(()=>{let e=!1;return()=>{e||(e=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."))}})(),exports.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"],exports.log=console.debug||console.log||(()=>{}),module.exports=require("./common")(exports);const{formatters:s}=module.exports;s.j=function(e){try{return JSON.stringify(e)}catch(o){return"[UnexpectedJSONParseError]: "+o.message}}; },{"./common":"sQiI","process":"pBGv"}],"U1mP":[function(require,module,exports) { "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.url=void 0;const t=require("parseuri"),o=require("debug")("socket.io-client:url");function r(r,e="",s){let p=r;s=s||"undefined"!=typeof location&&location,null==r&&(r=s.protocol+"//"+s.host),"string"==typeof r&&("/"===r.charAt(0)&&(r="/"===r.charAt(1)?s.protocol+r:s.host+r),/^(https?|wss?):\/\//.test(r)||(o("protocol-less url %s",r),r=void 0!==s?s.protocol+"//"+r:"https://"+r),o("parse %s",r),p=t(r)),p.port||(/^(http|ws)$/.test(p.protocol)?p.port="80":/^(http|ws)s$/.test(p.protocol)&&(p.port="443")),p.path=p.path||"/";const l=-1!==p.host.indexOf(":")?"["+p.host+"]":p.host;return p.id=p.protocol+"://"+l+":"+p.port+e,p.href=p.protocol+"://"+l+(s&&s.port===p.port?"":":"+p.port),p}exports.url=r; },{"parseuri":"A28J","debug":"fhQu"}],"cnu0":[function(require,module,exports) { try{module.exports="undefined"!=typeof XMLHttpRequest&&"withCredentials"in new XMLHttpRequest}catch(e){module.exports=!1} },{}],"gHSz":[function(require,module,exports) { module.exports=(()=>"undefined"!=typeof self?self:"undefined"!=typeof window?window:Function("return this")())(); },{}],"jhGE":[function(require,module,exports) { const e=require("has-cors"),t=require("./globalThis");module.exports=function(n){const c=n.xdomain,o=n.xscheme,r=n.enablesXDR;try{if("undefined"!=typeof XMLHttpRequest&&(!c||e))return new XMLHttpRequest}catch(i){}try{if("undefined"!=typeof XDomainRequest&&!o&&r)return new XDomainRequest}catch(i){}if(!c)try{return new(t[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(i){}}; },{"has-cors":"cnu0","./globalThis":"gHSz"}],"c8qu":[function(require,module,exports) { const e=Object.create(null);e.open="0",e.close="1",e.ping="2",e.pong="3",e.message="4",e.upgrade="5",e.noop="6";const o=Object.create(null);Object.keys(e).forEach(r=>{o[e[r]]=r});const r={type:"error",data:"parser error"};module.exports={PACKET_TYPES:e,PACKET_TYPES_REVERSE:o,ERROR_PACKET:r}; },{}],"h2jv":[function(require,module,exports) { const{PACKET_TYPES:e}=require("./commons"),o="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),r="function"==typeof ArrayBuffer,t=e=>"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer instanceof ArrayBuffer,f=({type:f,data:a},u,i)=>o&&a instanceof Blob?u?i(a):n(a,i):r&&(a instanceof ArrayBuffer||t(a))?u?i(a instanceof ArrayBuffer?a:a.buffer):n(new Blob([a]),i):i(e[f]+(a||"")),n=(e,o)=>{const r=new FileReader;return r.onload=function(){const e=r.result.split(",")[1];o("b"+e)},r.readAsDataURL(e)};module.exports=f; },{"./commons":"c8qu"}],"VBf3":[function(require,module,exports) { !function(n){"use strict";exports.encode=function(e){var r,t=new Uint8Array(e),i=t.length,f="";for(r=0;r>2],f+=n[(3&t[r])<<4|t[r+1]>>4],f+=n[(15&t[r+1])<<2|t[r+2]>>6],f+=n[63&t[r+2]];return i%3==2?f=f.substring(0,f.length-1)+"=":i%3==1&&(f=f.substring(0,f.length-2)+"=="),f},exports.decode=function(e){var r,t,i,f,g,o=.75*e.length,u=e.length,s=0;"="===e[e.length-1]&&(o--,"="===e[e.length-2]&&o--);var d=new ArrayBuffer(o),h=new Uint8Array(d);for(r=0;r>4,h[s++]=(15&i)<<4|f>>2,h[s++]=(3&f)<<6|63&g;return d}}("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"); },{}],"zzjK":[function(require,module,exports) { const{PACKET_TYPES_REVERSE:e,ERROR_PACKET:r}=require("./commons"),t="function"==typeof ArrayBuffer;let a;t&&(a=require("base64-arraybuffer"));const s=(t,a)=>{if("string"!=typeof t)return{type:"message",data:u(t,a)};const s=t.charAt(0);return"b"===s?{type:"message",data:n(t.substring(1),a)}:e[s]?t.length>1?{type:e[s],data:t.substring(1)}:{type:e[s]}:r},n=(e,r)=>{if(a){const t=a.decode(e);return u(t,r)}return{base64:!0,data:e}},u=(e,r)=>{switch(r){case"blob":return e instanceof ArrayBuffer?new Blob([e]):e;case"arraybuffer":default:return e}};module.exports=s; },{"./commons":"c8qu","base64-arraybuffer":"VBf3"}],"c8NG":[function(require,module,exports) { const e=require("./encodePacket"),o=require("./decodePacket"),r=String.fromCharCode(30),t=(o,t)=>{const c=o.length,d=new Array(c);let n=0;o.forEach((o,a)=>{e(o,!1,e=>{d[a]=e,++n===c&&t(d.join(r))})})},c=(e,t)=>{const c=e.split(r),d=[];for(let r=0;r{if("%%"===r)return"%";a++;const o=n.formatters[s];if("function"==typeof o){const n=e[a];r=o.call(t,n),e.splice(a,1),a--}return r}),n.formatArgs.call(t,e),(t.log||n.log).apply(t,e)}return l.namespace=e,l.useColors=n.useColors(),l.color=n.selectColor(e),l.extend=t,l.destroy=n.destroy,Object.defineProperty(l,"enabled",{enumerable:!0,configurable:!1,get:()=>null!==a?a:(s!==n.namespaces&&(s=n.namespaces,o=n.enabled(e)),o),set:e=>{a=e}}),"function"==typeof n.init&&n.init(l),l}function t(e,t){const r=n(this.namespace+(void 0===t?":":t)+e);return r.log=this.log,r}function r(e){return e.toString().substring(2,e.toString().length-2).replace(/\.\*\?$/,"*")}return n.debug=n,n.default=n,n.coerce=function(e){if(e instanceof Error)return e.stack||e.message;return e},n.disable=function(){const e=[...n.names.map(r),...n.skips.map(r).map(e=>"-"+e)].join(",");return n.enable(""),e},n.enable=function(e){let t;n.save(e),n.namespaces=e,n.names=[],n.skips=[];const r=("string"==typeof e?e:"").split(/[\s,]+/),s=r.length;for(t=0;t{n[t]=e[t]}),n.names=[],n.skips=[],n.formatters={},n.selectColor=function(e){let t=0;for(let n=0;n=31||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/))}function t(e){if(e[0]=(this.useColors?"%c":"")+this.namespace+(this.useColors?" %c":" ")+e[0]+(this.useColors?"%c ":" ")+"+"+module.exports.humanize(this.diff),!this.useColors)return;const o="color: "+this.color;e.splice(1,0,o,"color: inherit");let t=0,C=0;e[0].replace(/%[a-zA-Z%]/g,e=>{"%%"!==e&&(t++,"%c"===e&&(C=t))}),e.splice(C,0,o)}function C(e){try{e?exports.storage.setItem("debug",e):exports.storage.removeItem("debug")}catch(o){}}function r(){let o;try{o=exports.storage.getItem("debug")}catch(t){}return!o&&void 0!==e&&"env"in e&&(o=void 0),o}function n(){try{return localStorage}catch(e){}}exports.formatArgs=t,exports.save=C,exports.load=r,exports.useColors=o,exports.storage=n(),exports.destroy=(()=>{let e=!1;return()=>{e||(e=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."))}})(),exports.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"],exports.log=console.debug||console.log||(()=>{}),module.exports=require("./common")(exports);const{formatters:s}=module.exports;s.j=function(e){try{return JSON.stringify(e)}catch(o){return"[UnexpectedJSONParseError]: "+o.message}}; },{"./common":"cq18","process":"pBGv"}],"aoJx":[function(require,module,exports) { const e=require("engine.io-parser"),t=require("component-emitter"),s=require("debug")("engine.io-client:transport");class r extends t{constructor(e){super(),this.opts=e,this.query=e.query,this.readyState="",this.socket=e.socket}onError(e,t){const s=new Error(e);return s.type="TransportError",s.description=t,this.emit("error",s),this}open(){return"closed"!==this.readyState&&""!==this.readyState||(this.readyState="opening",this.doOpen()),this}close(){return"opening"!==this.readyState&&"open"!==this.readyState||(this.doClose(),this.onClose()),this}send(e){"open"===this.readyState?this.write(e):s("transport is not open, discarding packets")}onOpen(){this.readyState="open",this.writable=!0,this.emit("open")}onData(t){const s=e.decodePacket(t,this.socket.binaryType);this.onPacket(s)}onPacket(e){this.emit("packet",e)}onClose(){this.readyState="closed",this.emit("close")}}module.exports=r; },{"engine.io-parser":"c8NG","component-emitter":"G6pK","debug":"sXsT"}],"a1bU":[function(require,module,exports) { exports.encode=function(e){var n="";for(var o in e)e.hasOwnProperty(o)&&(n.length&&(n+="&"),n+=encodeURIComponent(o)+"="+encodeURIComponent(e[o]));return n},exports.decode=function(e){for(var n={},o=e.split("&"),t=0,r=o.length;t0);return n}function c(r){var e=0;for(u=0;u{o("paused"),this.readyState="paused",t()};if(this.polling||!this.writable){let t=0;this.polling&&(o("we are currently polling - waiting to pause"),t++,this.once("pollComplete",function(){o("pre-pause polling complete"),--t||e()})),this.writable||(o("we are currently writing - waiting to pause"),t++,this.once("drain",function(){o("pre-pause writing complete"),--t||e()}))}else e()}poll(){o("polling"),this.polling=!0,this.doPoll(),this.emit("poll")}onData(t){o("polling got data %s",t);s.decodePayload(t,this.socket.binaryType).forEach(t=>{if("opening"===this.readyState&&"open"===t.type&&this.onOpen(),"close"===t.type)return this.onClose(),!1;this.onPacket(t)}),"closed"!==this.readyState&&(this.polling=!1,this.emit("pollComplete"),"open"===this.readyState?this.poll():o('ignoring poll - transport state "%s"',this.readyState))}doClose(){const t=()=>{o("writing close packet"),this.write([{type:"close"}])};"open"===this.readyState?(o("transport open - closing"),t()):(o("transport not open - deferring close"),this.once("open",t))}write(t){this.writable=!1,s.encodePayload(t,t=>{this.doWrite(t,()=>{this.writable=!0,this.emit("drain")})})}uri(){let t=this.query||{};const s=this.opts.secure?"https":"http";let o="";return!1!==this.opts.timestampRequests&&(t[this.opts.timestampParam]=i()),this.supportsBinary||t.sid||(t.b64=1),t=e.encode(t),this.opts.port&&("https"===s&&443!==Number(this.opts.port)||"http"===s&&80!==Number(this.opts.port))&&(o=":"+this.opts.port),t.length&&(t="?"+t),s+"://"+(-1!==this.opts.hostname.indexOf(":")?"["+this.opts.hostname+"]":this.opts.hostname)+o+this.opts.path+t}}module.exports=p; },{"../transport":"aoJx","parseqs":"a1bU","engine.io-parser":"c8NG","yeast":"hQ4G","debug":"sXsT"}],"nxc0":[function(require,module,exports) { module.exports.pick=((e,...r)=>r.reduce((r,o)=>(e.hasOwnProperty(o)&&(r[o]=e[o]),r),{})); },{}],"uJlD":[function(require,module,exports) { const t=require("../../contrib/xmlhttprequest-ssl/XMLHttpRequest"),e=require("./polling"),s=require("component-emitter"),{pick:o}=require("../util"),r=require("../globalThis"),i=require("debug")("engine.io-client:polling-xhr");function n(){}const h=null!=new t({xdomain:!1}).responseType;class a extends e{constructor(t){if(super(t),"undefined"!=typeof location){const e="https:"===location.protocol;let s=location.port;s||(s=e?443:80),this.xd="undefined"!=typeof location&&t.hostname!==location.hostname||s!==t.port,this.xs=t.secure!==e}const e=t&&t.forceBase64;this.supportsBinary=h&&!e}request(t={}){return Object.assign(t,{xd:this.xd,xs:this.xs},this.opts),new u(this.uri(),t)}doWrite(t,e){const s=this.request({method:"POST",data:t});s.on("success",e),s.on("error",t=>{this.onError("xhr post error",t)})}doPoll(){i("xhr poll");const t=this.request();t.on("data",this.onData.bind(this)),t.on("error",t=>{this.onError("xhr poll error",t)}),this.pollXhr=t}}class u extends s{constructor(t,e){super(),this.opts=e,this.method=e.method||"GET",this.uri=t,this.async=!1!==e.async,this.data=void 0!==e.data?e.data:null,this.create()}create(){const e=o(this.opts,"agent","enablesXDR","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");e.xdomain=!!this.opts.xd,e.xscheme=!!this.opts.xs;const s=this.xhr=new t(e);try{i("xhr open %s: %s",this.method,this.uri),s.open(this.method,this.uri,this.async);try{if(this.opts.extraHeaders){s.setDisableHeaderCheck&&s.setDisableHeaderCheck(!0);for(let t in this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(t)&&s.setRequestHeader(t,this.opts.extraHeaders[t])}}catch(r){}if("POST"===this.method)try{s.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(r){}try{s.setRequestHeader("Accept","*/*")}catch(r){}"withCredentials"in s&&(s.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(s.timeout=this.opts.requestTimeout),this.hasXDR()?(s.onload=(()=>{this.onLoad()}),s.onerror=(()=>{this.onError(s.responseText)})):s.onreadystatechange=(()=>{4===s.readyState&&(200===s.status||1223===s.status?this.onLoad():setTimeout(()=>{this.onError("number"==typeof s.status?s.status:0)},0))}),i("xhr data %s",this.data),s.send(this.data)}catch(r){return void setTimeout(()=>{this.onError(r)},0)}"undefined"!=typeof document&&(this.index=u.requestsCount++,u.requests[this.index]=this)}onSuccess(){this.emit("success"),this.cleanup()}onData(t){this.emit("data",t),this.onSuccess()}onError(t){this.emit("error",t),this.cleanup(!0)}cleanup(t){if(void 0!==this.xhr&&null!==this.xhr){if(this.hasXDR()?this.xhr.onload=this.xhr.onerror=n:this.xhr.onreadystatechange=n,t)try{this.xhr.abort()}catch(e){}"undefined"!=typeof document&&delete u.requests[this.index],this.xhr=null}}onLoad(){const t=this.xhr.responseText;null!==t&&this.onData(t)}hasXDR(){return"undefined"!=typeof XDomainRequest&&!this.xs&&this.enablesXDR}abort(){this.cleanup()}}if(u.requestsCount=0,u.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",d);else if("function"==typeof addEventListener){addEventListener("onpagehide"in r?"pagehide":"unload",d,!1)}function d(){for(let t in u.requests)u.requests.hasOwnProperty(t)&&u.requests[t].abort()}module.exports=a,module.exports.Request=u; },{"../../contrib/xmlhttprequest-ssl/XMLHttpRequest":"jhGE","./polling":"BPT5","component-emitter":"G6pK","../util":"nxc0","../globalThis":"gHSz","debug":"sXsT"}],"dWDe":[function(require,module,exports) { const e=require("./polling"),t=require("../globalThis"),i=/\n/g,r=/\\n/g;let s;class o extends e{constructor(e){super(e),this.query=this.query||{},s||(s=t.___eio=t.___eio||[]),this.index=s.length,s.push(this.onData.bind(this)),this.query.j=this.index}get supportsBinary(){return!1}doClose(){this.script&&(this.script.onerror=(()=>{}),this.script.parentNode.removeChild(this.script),this.script=null),this.form&&(this.form.parentNode.removeChild(this.form),this.form=null,this.iframe=null),super.doClose()}doPoll(){const e=document.createElement("script");this.script&&(this.script.parentNode.removeChild(this.script),this.script=null),e.async=!0,e.src=this.uri(),e.onerror=(e=>{this.onError("jsonp poll error",e)});const t=document.getElementsByTagName("script")[0];t?t.parentNode.insertBefore(e,t):(document.head||document.body).appendChild(e),this.script=e,"undefined"!=typeof navigator&&/gecko/i.test(navigator.userAgent)&&setTimeout(function(){const e=document.createElement("iframe");document.body.appendChild(e),document.body.removeChild(e)},100)}doWrite(e,t){let s;if(!this.form){const e=document.createElement("form"),t=document.createElement("textarea"),i=this.iframeId="eio_iframe_"+this.index;e.className="socketio",e.style.position="absolute",e.style.top="-1000px",e.style.left="-1000px",e.target=i,e.method="POST",e.setAttribute("accept-charset","utf-8"),t.name="d",e.appendChild(t),document.body.appendChild(e),this.form=e,this.area=t}function o(){n(),t()}this.form.action=this.uri();const n=()=>{if(this.iframe)try{this.form.removeChild(this.iframe)}catch(e){this.onError("jsonp polling iframe removal error",e)}try{const t=' ``` #### Advanced Move Limits Passing a `minMoves` argument to `setActivePlayers` forces all the active players to make at least that number of moves before being able to end the stage, but sometimes you might want to set different move limits for different players. For cases like this, `setStage` and `setActivePlayers` support long-form arguments: ```js setStage({ stage: 'stage-name', minMoves: 3 }); ``` ```js setActivePlayers({ currentPlayer: { stage: 'stage-name', minMoves: 2 }, others: { stage: 'stage-name', minMoves: 1 }, value: { '0': { stage: 'stage-name', minMoves: 4 }, }, }); ``` Passing a `maxMoves` argument to `setActivePlayers` limits all the active players to making that number of moves, but sometimes you might want to set different move limits for different players. For cases like this, `setStage` and `setActivePlayers` support long-form arguments: ```js setStage({ stage: 'stage-name', maxMoves: 3 }); ``` ```js setActivePlayers({ currentPlayer: { stage: 'stage-name', maxMoves: 2 }, others: { stage: 'stage-name', maxMoves: 1 }, value: { '0': { stage: 'stage-name', maxMoves: 4 }, }, }); ``` ### Stage.NULL Sometimes you want to add a player to the set of active players but don't want them to be in a specific stage. You can use `Stage.NULL` for this: ```js import { Stage } from 'boardgame.io/core'; // This allows any player to make a move, but doesn't restrict them to // a particular stage. setActivePlayers({ all: Stage.NULL }); ``` There is also a convenient syntax to enumerate the players that you want in the set of active players: ```js // Players 0 and 3 are added to the set of active players, // and neither is placed in a stage. setActivePlayers(['0', '3']); ``` ### Configuring active players at the beginning of a turn. You can have `setActivePlayers` called automatically at the beginning of the turn by adding an `activePlayers` section to the `turn` config: ```js turn: { activePlayers: { all: Stage.NULL }, } ``` ### Presets A number of `activePlayers` configurations are available as presets that you can use directly: ```js import { ActivePlayers } from 'boardgame.io/core'; turn: { activePlayers: ActivePlayers.ALL; } ``` #### ALL Equivalent to `{ all: Stage.NULL }`. Any player can play, and they aren't restricted to any particular stage. #### ALL_ONCE Equivalent to `{ all: Stage.NULL, minMoves: 1, maxMoves: 1 }`. Any player can make exactly one move before they are removed from the set of active players. #### OTHERS Similar to `ALL`, but excludes the current player from the set of active players. #### OTHERS_ONCE Similar to `ALL_ONCE`, but excludes the current player from the set of active players. ================================================ FILE: docs/documentation/storage.md ================================================ # Storage **boardgame.io** is storage agnostic. Various adapters are available that allow you to persist your game state in different storage systems. You can even write your [own adapter](/storage?id=writing-a-custom-adapter) for a custom backend. ### Flatfile First, install the necessary packages: ``` npm install node-persist ``` Then modify your server spec to indicate that you want to connect to a flatfile database: ```js const { Server, FlatFile } = require('boardgame.io/server'); const { TicTacToe } = require('./game'); const server = Server({ games: [TicTacToe], db: new FlatFile({ dir: '/storage/directory', logging: (true/false), ttl: (optional, see node-persist docs), }), }); server.run(8000); ``` ### Other backends #### Firebase Instructions at https://github.com/delucis/bgio-firebase. #### Azure Storage Instructions at https://github.com/c-w/bgio-azure-storage. #### Postgres Instructions at https://github.com/janKir/bgio-postgres. #### MongoDB Coming soon (used to be supported but is not in sync with the latest release). ### Caching Depending on your set-up, you may want the server to cache some of the data, reducing the load on your database and speeding up server responses. [@boardgame.io/storage-cache](https://github.com/boardgameio/storage-cache) offers a basic caching model compatible with any boardgame.io database connector. ### Writing a Custom Adapter Create a class that implements the [StorageAPI.Async](https://github.com/boardgameio/boardgame.io/blob/main/src/server/db/base.ts) interface. ================================================ FILE: docs/documentation/testing.md ================================================ # Testing Strategies ### Unit Tests Moves are just functions, so they lend themselves to unit testing. A useful strategy is to implement each move as a standalone function before passing them to the game object: `Game.js` ```js export function clickCell({ G, playerID }, id) { G.cells[id] = playerID; } export const TicTacToe = { moves: { clickCell }, // ... } ``` `Game.test.js` ```js import { clickCell } from './Game'; it('should place the correct value in the cell', () => { // original state. const G = { cells: [null, null, null, null, null, null, null, null, null], }; // make move. clickCell({ G, playerID: '1' }, 3); // verify new state. expect(G).toEqual({ cells: [null, null, null, '1', null, null, null, null, null], }); }); ``` ### Scenario Tests Test your game logic in specific scenarios. ```js import { Client } from 'boardgame.io/client'; import { TicTacToe } from './Game'; it('should declare player 1 as the winner', () => { // set up a specific board scenario const TicTacToeCustomScenario = { ...TicTacToe, setup: () => ({ cells: ['0', '0', null, '1', '1', null, null, null, null], }), }; // initialize the client with your custom scenario const client = Client({ game: TicTacToeCustomScenario, }); // make some game moves client.moves.clickCell(8); client.moves.clickCell(5); // get the latest game state const { G, ctx } = client.getState(); // the board should look like this now expect(G.cells).toEqual(['0', '0', null, '1', '1', '1', null, null, '0']); // player '1' should be declared the winner expect(ctx.gameover).toEqual({ winner: '1' }); }); ``` ?> Note that we imported the vanilla JavaScript client, not the one from `boardgame.io/react`. ### Testing Randomness If you are testing a move that uses the [Random API](/random), by definition you can’t always expect the same result, making it harder to test. In this case, you can use one of the following strategies. #### Fixed PRNG seed You can set `seed` in your game object. This will be used to initialise the Random API’s internal state and you’ll see a predictable sequence of results from calls to random API methods: ```js import { Client } from 'boardgame.io/client'; const Game = { moves: { rollDice: ({ G, random }) => { G.roll = random.D6(); }, }, }; it('updates G.roll with a random number', () => { const client = Client({ // Set seed so PRNG always starts in same state game: { ...Game, seed: 'fixed-seed' }, }); client.moves.rollDice(); const { G } = client.getState(); expect(G.roll).toMatchInlineSnapshot(`4`); }); ``` #### Override Random API `since v0.49.10` If you need to test specific random outcomes, you can override the Random API entirely to allow complete control of the results of API methods. ```js import { Client } from 'boardgame.io/client'; import { MockRandom } from 'boardgame.io/testing'; // Create a mock of the random plugin, where the D6 method always returns 6. // Any methods you don’t provide an implementation for will behave as usual. const randomPlugin = MockRandom({ D6: () => 6, }); it ('rolls a six', () => { const client = Client({ game: { ...Game, // Add the random plugin mock to the game’s plugins. plugins: [...(Game.plugins || []), randomPlugin] }, }); client.moves.rollDice(); const { G } = client.getState(); expect(G.roll).toMatchInlineSnapshot(`6`); }); ``` ### Multiplayer Tests Use the local multiplayer mode to simulate multiplayer interactions in unit tests. ```js it('multiplayer test', () => { const spec = { game: MyGame, multiplayer: Local(), }; const p0 = Client({ ...spec, playerID: '0' }); const p1 = Client({ ...spec, playerID: '1' }); p0.start(); p1.start(); p0.moves.moveA(); p0.events.endTurn(); // Player 1's state reflects the moves made by Player 0. expect(p1.getState()).toEqual(...); p1.moves.moveA(); p1.events.endTurn(); ... }); ``` ### Integration Tests Test the application end-to-end from the UI layer's point of view. In this case we use [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) to mount our React component and look for the TicTacToe board inside of it. We then check the board is rendered and responds to user interaction as expected. ```js import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import App from './app'; describe('Tic-Tac-Toe', () => { const { container } = render(); const cells = container.querySelectorAll('td'); test('board is empty initially', () => { expect(cells).toHaveLength(9); for (const cell of cells) { expect(cell).toBeEmptyDOMElement(); } }); test('clicking a cell places player 0’s marker', () => { fireEvent.click(cells[5]); expect(cells[5]).toHaveTextContent('0'); }); }); ``` ================================================ FILE: docs/documentation/theme.css ================================================ :root { --theme-hue: 152; --theme-lightness : 26%; --mono-hue: calc(var(--theme-hue) + 400); --mono-saturation: 5%; --link-color: var(--theme-color); /* add rounded corners */ --border-radius-s: .25em; --border-radius-m: .5em; --border-radius-l: 1.5em; /* Sidebar Menu */ /* layout */ --sidebar-horizontal-padding: 1em; --sidebar-padding: 0 var(--sidebar-horizontal-padding); --sidebar-nav-indent: 0; --sidebar-nav-link-margin: 0 calc(-1 * var(--sidebar-horizontal-padding)) 0 0; --sidebar-nav-pagelink-padding: .25em 0 0 20px; /* active page link styling */ --sidebar-nav-link-font-weight--active: bold; --sidebar-nav-link-before-content-l3: ""; --sidebar-nav-link-before-content-l3--active: "→"; /* section headings */ --sidebar-nav-strong-font-size: var(--font-size-s); --sidebar-nav-strong-margin: 2em calc(-1 * var(--sidebar-horizontal-padding)) .25em 0; --sidebar-nav-strong-padding: .25em 0 0; /* syntax highlighting tweaks for contrast accessibility */ --code-theme-comment: hsl(0, 0%, 44%); --code-theme-function: hsl(347, 88%, 47%); --code-theme-punctuation: var(--mono-shade1); --code-theme-operator: hsl(29, 30%, 40%); --code-theme-selector: hsl(100, 100%, 25%); --code-theme-tag: hsl(310, 100%, 30%); } .markdown-section { padding: 2em; margin-bottom: 6em; } img[alt*="github stars" i] { vertical-align: middle; } .sidebar-nav li > strong { letter-spacing: .15ch; } .app-sub-sidebar { --sidebar-nav-indent: 1em; --sidebar-nav-pagelink-padding: 0; } @media screen and (max-width: 768px) { :root { --docsifytabs-content-padding: 1rem; --code-block-margin: 1em -1em; --code-block-padding: 1.75em 1em 1.25em; --code-block-border-radius: 0; } .markdown-section { padding: 2em 1em; } } @media screen and (max-width: 1250px) { /* prevent transparent part of GitHub Corner intercepting clicks */ .github-corner { pointer-events: none; } .github-corner path { pointer-events: all; } } ================================================ FILE: docs/documentation/turn-order.md ================================================ # Turn Order The framework's default behavior is to pass the turn around in a round-robin fashion. A player makes one or more moves before triggering an `endTurn` event, which passes the turn to the next player. Turn order state is maintained in the following fields: ```js ctx: { currentPlayer: '0', playOrder: ['0', '1', '2', ...], playOrderPos: 0, } ``` ##### `currentPlayer` This is the owner of the current turn and the only player that can normally make moves during the turn. You may also allow additional players to make moves during the turn using [Stages](stages.md). ##### `playOrder` The default value is `['0', '1', '2', ... ]`. You can think of this as the order in which players sit down at the table. A round robin turn order would move `currentPlayer` through this list in order. ##### `playOrderPos` An index into `playOrder`. It is the value that is updated by the turn order policy in order to compute `currentPlayer`. The default behavior is to just increment it in a round-robin fashion. `currentPlayer` is just `playOrder[playOrderPos]`. ### Changing the Turn Order Changing the game's turn order is accomplished by using the `order` option inside the `turn` section of the game config: ```js import { TurnOrder } from 'boardgame.io/core'; const game = { turn: { order: TurnOrder.ONCE, }, }; ``` You will typically use one of the presets below. You may also change the turn order at each phase of the game. See the guide on [Phases](phases.md) for more details. ### Presets #### DEFAULT This is the default round-robin. It is used if you don't specify any turn order. #### RESET This is similar to `DEFAULT`, but instead of incrementing the previous position at the beginning of a phase, it will always start from `0`. #### CONTINUE This is also similar to `DEFAULT`, but instead of incrementing the previous position at the beginning of a phase, it will start with the player who ended the previous phase. #### ONCE This is another round-robin, but it goes around only once. After this, the phase ends automatically. #### CUSTOM Round-robin like `DEFAULT`, but sets `playOrder` to the provided value. ```js turn: { order: TurnOrder.CUSTOM(['1', '3']), } ``` #### CUSTOM_FROM Round-robin like `DEFAULT`, but sets `playOrder` to the value in a specified field in `G`. ```js turn: { order: TurnOrder.CUSTOM_FROM('property_in_G'), } ``` ### Ad Hoc You can also specify the next player during the `endTurn` event. ```js endTurn({ next: playerID }); ``` This argument can also be the return value of `turn.endIf` and works the same way. Player `3` is made the new player in both examples below: ```js function Move({ events }) { events.endTurn({ next: '3' }); } ``` ```js const game = { turn: { endIf: () => ({ next: '3' }), }, }; ``` ### Creating a Custom Turn Order If the presets above aren't what you're looking for, you can create a custom turn order from scratch: ```js turn: { order: { // Get the initial value of playOrderPos. // This is called at the beginning of the phase. first: ({ G, ctx }) => 0, // Get the next value of playOrderPos. // This is called at the end of each turn. // The phase ends if this returns undefined. next: ({ G, ctx }) => (ctx.playOrderPos + 1) % ctx.numPlayers, // OPTIONAL: // Override the initial value of playOrder. // This is called at the beginning of the game / phase. playOrder: ({ G, ctx }) => [...], } } ``` ================================================ FILE: docs/documentation/tutorial.md ================================================ # Tutorial This tutorial walks through a simple game of Tic-Tac-Toe. ?> We’re going to be running commands from a terminal and using Node.js/npm. If you haven’t done that before, you might want to read [an introduction to the command line][cmd] and follow [the instructions on how to install Node][node]. You’ll also want a text editor to write code in like [VS Code][vsc] or [Atom][atom]. [node]: https://nodejs.dev/learn/how-to-install-nodejs [cmd]: https://tutorial.djangogirls.org/en/intro_to_command_line/ [vsc]: https://code.visualstudio.com/ [atom]: https://atom.io/ ## Setup We’re going to use ES2015 features like module [imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) and the [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator) syntax, so we’ll need to use some kind of build system to compile our code for the browser. This tutorial shows two different approaches: one using [React](https://reactjs.org/), the other using basic browser APIs and compiling our app with [Parcel](https://parceljs.org/). You can follow whichever you feel most comfortable with. ### **Plain JS** Let’s create a new Node project from the command line: ``` mkdir bgio-tutorial cd bgio-tutorial npm init --yes ``` ?> These commands will make a new directory called `bgio-tutorial`, change to that directory, and initialise a new Node package. [Read more in the Node Package Manager docs.][pkgjson] [pkgjson]: https://docs.npmjs.com/creating-a-package-json-file#creating-a-default-packagejson-file We’re going to add boardgame.io and also Parcel to help us build our app: ``` npm install boardgame.io npm install --save-dev parcel-bundler ``` Now, let’s create the basic structure our project needs: 1. A JavaScript file for our web app at `src/App.js`. 2. A JavaScript file for our game definition at `src/Game.js`. 3. A basic HTML page that will load our app at `index.html`: ```html boardgame.io Tutorial
``` Your project directory should now look like this: bgio-tutorial/ ├── index.html ├── node_modules/ ├── package-lock.json ├── package.json └── src/ ├── App.js └── Game.js Looking good? OK, let’s get started! 🚀 ?> You can check out the complete code for this tutorial and play around with it on CodeSandbox:

[![Edit bgio-plain-js-tutorial](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/bgio-plain-js-tutorial-ewyyt?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.js&theme=dark) ### **React** We’ll use the [create-react-app](https://create-react-app.dev/) command line tool to initialize our React app and then add boardgame.io to it. ``` npx create-react-app bgio-tutorial cd bgio-tutorial npm install boardgame.io ``` While we’re here, let’s also create an empty JavaScript file for our game code: ``` touch src/Game.js ``` ?> You can check out the complete code for this tutorial and play around with it on CodeSandbox:

[![Edit boardgame.io](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/boardgameio-wlvi2) ## Defining a Game We define a game by creating an object whose contents tell boardgame.io how your game works. More or less everything is optional, so we can start simple and gradually add complexity. To start, we’ll add a `setup` function, which will set the initial value of the game state `G`, and a `moves` object containing the moves that make up the game. A move is a function that updates `G` to the desired new state. It receives an object containing various fields as its first argument. This object includes the game state `G` and `ctx` — an object managed by boardgame.io that contains game metadata. It also includes `playerID`, which identifies the player making the move. After the object containing `G` and `ctx`, moves can receive arbitrary arguments that you pass in when making the move. In Tic-Tac-Toe, we only have one type of move and we will name it `clickCell`. It will take the ID of the cell that was clicked and update that cell with the ID of the player who clicked it. Let’s put this together in our `src/Game.js` file to start defining our game: ```js export const TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { clickCell: ({ G, playerID }, id) => { G.cells[id] = playerID; }, }, }; ``` ?> The `setup` function also receives an object as its first argument like moves. This is useful if you need to customize the initial state based on some field in `ctx` — the number of players, for example — but we don't need that for Tic-Tac-Toe. ## Creating a Client ### **Plain JS** We’ll start by creating a class to manage our web app’s logic in `src/App.js`. In the class’s constructor we’ll create a boardgame.io client and call its `start` method to run it. ```js import { Client } from 'boardgame.io/client'; import { TicTacToe } from './Game'; class TicTacToeClient { constructor() { this.client = Client({ game: TicTacToe }); this.client.start(); } } const app = new TicTacToeClient(); ``` Let’s also add a script to `package.json` to make serving the web app simpler and a [browserslist string](https://github.com/browserslist/browserslist) to indicate the browsers we want to support: ```json { "scripts": { "start": "parcel index.html --open" }, "browserslist": "defaults and supports async-functions" } ``` ?> By dropping support for browsers that don’t support async functions, we don’t need to worry about including the `regenerator-runtime` polyfill. If you need to support older browsers, you can skip adding `browserslist`, but may need to include the polyfill manually. You can now serve the app from the command line by running: ``` npm start ``` ### **React** Replace the contents of `src/App.js` with ```js import { Client } from 'boardgame.io/react'; import { TicTacToe } from './Game'; const App = Client({ game: TicTacToe }); export default App; ``` You can now serve the app from the command line by running: ``` npm start ``` Although we haven’t built any UI yet, boardgame.io renders a Debug Panel. This panel means we can already play our Tic-Tac-Toe game! You can make a move by clicking on `clickCell` on the Debug Panel, entering a number between `0` and `8`, and pressing **Enter**. The current player will make a move on the chosen cell. The number you enter is the `id` passed to the `clickCell` function as the first argument after `G` and `ctx`. Notice how the `cells` array on the Debug Panel updates as you make moves. You can end the turn by clicking `endTurn` and pressing **Enter**. The next call to `clickCell` will result in a “1” in the chosen cell instead of a “0”. ```react ``` ?> You can turn off the Debug Panel by passing `debug: false` in the `Client` config. ## Game Improvements ### Validating Moves So far, if a player calls `clickCell` for a cell that is already filled, it will be overwritten. Let’s prevent that by updating `clickCell` to let us know that a move is invalid if the selected cell isn’t `null`. Moves can let the framework know they are invalid by returning a special constant which we import into `src/Game.js`: ```js import { INVALID_MOVE } from 'boardgame.io/core'; ``` Now we can return `INVALID_MOVE` from `clickCell`: ```js clickCell: ({ G, playerID }, id) => { if (G.cells[id] !== null) { return INVALID_MOVE; } G.cells[id] = playerID; } ``` ### Managing Turns In the Debug Panel we clicked `endTurn` to pass the turn to the next player after making a move. We could do this from our client code too: make a move, then end the turn. This could be flexible because a player could choose when to end their turn, but in Tic-Tac-Toe we know that the turn should always end when a move is made. There are several different ways to manage turns in boardgame.io. We’ll use the `maxMoves` option in our game definition to tell the framework to automatically end a player’s turn after a single move has been made, as well as the `minMoves` option, so players *have* to make a move and can't just `endTurn`. ```js export const TicTacToe = { setup: () => { /* ... */ }, turn: { minMoves: 1, maxMoves: 1, }, moves: { /* ... */ }, } ``` ?> You can learn more in the [Turn Order](turn-order.md) and [Events](events.md) guides. ### Victory Condition The Tic-Tac-Toe game we have so far doesn't really ever end. Let's keep track of a winner in case one player wins the game. First, let’s declare two helper functions in `src/Game.js` to test the `cells` array with: ```js // Return true if `cells` is in a winning configuration. function IsVictory(cells) { const positions = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ]; const isRowComplete = row => { const symbols = row.map(i => cells[i]); return symbols.every(i => i !== null && i === symbols[0]); }; return positions.map(isRowComplete).some(i => i === true); } // Return true if all `cells` are occupied. function IsDraw(cells) { return cells.filter(c => c === null).length === 0; } ``` Now, we add an `endIf` method to our game. This method will be called each time our state updates to check if the game is over. ```js export const TicTacToe = { // setup, moves, etc. endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } if (IsDraw(G.cells)) { return { draw: true }; } }, }; ``` ?> `endIf` takes a function that determines if the game is over. If it returns anything at all, the game ends and the return value is available at `ctx.gameover`. ## Building a Board ### **Plain JS** You can build your game board with your preferred UI tools. This example will use basic JavaScript, but you should be able to adapt this approach to many other frameworks. To start with, let’s add a `createBoard` method to our `TicTacToeClient` and call it in the constructor. This will inject the required DOM structure for our board into the web page. To know where to insert our board UI, we’ll pass in an element when instantiating the class. We’ll also add an `attachListeners` method. This will set up our board cells so that they trigger the `clickCell` move when they are clicked. ```js class TicTacToeClient { constructor(rootElement) { this.client = Client({ game: TicTacToe }); this.client.start(); this.rootElement = rootElement; this.createBoard(); this.attachListeners(); } createBoard() { // Create cells in rows for the Tic-Tac-Toe board. const rows = []; for (let i = 0; i < 3; i++) { const cells = []; for (let j = 0; j < 3; j++) { const id = 3 * i + j; cells.push(``); } rows.push(`${cells.join('')}`); } // Add the HTML to our app
. // We’ll use the empty

to display the game winner later. this.rootElement.innerHTML = ` ${rows.join('')}

`; } attachListeners() { // This event handler will read the cell id from a cell’s // `data-id` attribute and make the `clickCell` move. const handleCellClick = event => { const id = parseInt(event.target.dataset.id); this.client.moves.clickCell(id); }; // Attach the event listener to each of the board cells. const cells = this.rootElement.querySelectorAll('.cell'); cells.forEach(cell => { cell.onclick = handleCellClick; }); } } const appElement = document.getElementById('app'); const app = new TicTacToeClient(appElement); ``` You probably won’t see anything just yet, because all the cells are empty. Let’s fix that by adding a style for the cells to `index.html`: ```html ``` Now you should see an empty Tic-Tac-Toe board! But there’s still one thing missing. If you click on the board cells, you should see `G.cells` update in the Debug Panel, but the board itself doesn’t change. We need to add a way to refresh the board every time boardgame.io’s state changes. Let’s do that by writing an `update` method for our `TicTacToeClient` class and subscribing to the boardgame.io state: ```js class TicTacToeClient { constructor() { // As before, but we also subscribe to the client: this.client.subscribe(state => this.update(state)); } createBoard() { /* ... */ } attachListeners() { /* ... */ } update(state) { // Get all the board cells. const cells = this.rootElement.querySelectorAll('.cell'); // Update cells to display the values in game state. cells.forEach(cell => { const cellId = parseInt(cell.dataset.id); const cellValue = state.G.cells[cellId]; cell.textContent = cellValue !== null ? cellValue : ''; }); // Get the gameover message element. const messageEl = this.rootElement.querySelector('.winner'); // Update the element to show a winner if any. if (state.ctx.gameover) { messageEl.textContent = state.ctx.gameover.winner !== undefined ? 'Winner: ' + state.ctx.gameover.winner : 'Draw!'; } else { messageEl.textContent = ''; } } } ``` Here are the key things to remember: - You can trigger the moves defined in your game definition by calling `client.moves['moveName']`. - You can register callbacks for every state change using `client.subscribe`. ### **React** React can be a good fit for board games because it provides a declarative API to translate objects to UI elements. To create a board we need to translate the game state `G` into actual cells that are clickable. Let’s create a new file at `src/Board.js`: ```js import React from 'react'; export function TicTacToeBoard({ ctx, G, moves }) { const onClick = (id) => moves.clickCell(id); let winner = ''; if (ctx.gameover) { winner = ctx.gameover.winner !== undefined ? (
Winner: {ctx.gameover.winner}
) : (
Draw!
); } const cellStyle = { border: '1px solid #555', width: '50px', height: '50px', lineHeight: '50px', textAlign: 'center', }; let tbody = []; for (let i = 0; i < 3; i++) { let cells = []; for (let j = 0; j < 3; j++) { const id = 3 * i + j; cells.push( {G.cells[id] ? (
{G.cells[id]}
) : (
); }; export default Chat; ================================================ FILE: examples/react-web/src/chess/checkerboard.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import { Grid } from './grid'; /** * Checkerboard * * Component that will show a configurable checker board for games like * chess, checkers and others. The vertical columns of squares are labeled * with letters from a to z, while the rows are labeled with numbers, starting * with 1. * * Props: * rows - How many rows to show up, 8 by default. * cols - How many columns to show up, 8 by default. Maximum is 26. * onClick - On Click Callback, (row, col) of the square passed as argument. * primaryColor - Primary color, #d18b47 by default. * secondaryColor - Secondary color, #ffce9e by default. * colorMap - Object of object having cell names as key and colors as values. * Ex: { 'c5': 'red' } colors cells c5 with red. * * Usage: * * * * * * */ export class Checkerboard extends React.Component { static propTypes = { rows: PropTypes.number, cols: PropTypes.number, onClick: PropTypes.func, primaryColor: PropTypes.string, secondaryColor: PropTypes.string, highlightedSquares: PropTypes.object, style: PropTypes.object, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.element), PropTypes.element, ]), }; static defaultProps = { rows: 8, cols: 8, onClick: () => {}, primaryColor: '#d18b47', secondaryColor: '#ffce9e', highlightedSquares: {}, style: {}, }; onClick = ({ x, y }) => { this.props.onClick({ square: cartesianToAlgebraic(x, y, this.props.rows) }); }; render() { // Convert the square="" prop to x and y. const tokens = React.Children.map(this.props.children, (child) => { const square = child.props.square; const { x, y } = algebraicToCartesian(square, this.props.rows); return React.cloneElement(child, { x, y }); }); // Build colorMap with checkerboard pattern. let colorMap = {}; for (let x = 0; x < this.props.cols; x++) { for (let y = 0; y < this.props.rows; y++) { const key = `${x},${y}`; let color = this.props.secondaryColor; if ((x + y) % 2 == 0) { color = this.props.primaryColor; } colorMap[key] = color; } } // Add highlighted squares. for (const square in this.props.highlightedSquares) { const { x, y } = algebraicToCartesian(square, this.props.rows); const key = `${x},${y}`; colorMap[key] = this.props.highlightedSquares[square]; } return ( {tokens} ); } } /** * Given an algebraic notation, returns x and y values. * Example: A1 returns { x: 0, y: 0 } */ export function algebraicToCartesian(square, rows = 8) { let regexp = /([A-Za-z])(\d+)/g; let match = regexp.exec(square); if (match == null) { throw 'Invalid square provided: ' + square; } let colSymbol = match[1].toLowerCase(); let col = colSymbol.charCodeAt(0) - 'a'.charCodeAt(0); let row = parseInt(match[2]); return { x: col, y: rows - row }; } /** * Given an x and y values, returns algebraic notation. * Example: 0, 0 returns A1 */ export function cartesianToAlgebraic(x, y, rows = 8) { let colSymbol = String.fromCharCode(x + 'a'.charCodeAt(0)); return colSymbol + (rows - y); } ================================================ FILE: examples/react-web/src/chess/checkerboard.test.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Checkerboard } from './checkerboard'; import { Token } from 'boardgame.io/ui'; import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; Enzyme.configure({ adapter: new Adapter() }); test('render squares correctly', () => { const grid = Enzyme.mount(); expect(grid.find('rect')).toHaveLength(64); }); test('position', () => { const grid = Enzyme.shallow( ); expect(grid.html()).toContain('translate(1, 3)'); }); test('click', () => { const onClick = jest.fn(); const grid = Enzyme.mount(); grid.find('rect').at(5).simulate('click'); expect(onClick).toHaveBeenCalledWith({ square: 'a3' }); }); test('invalid square', () => { let invalidSquare = () => { Enzyme.shallow() .instance() .algebraicCord({ square: '*1' }); }; expect(invalidSquare).toThrow(); }); test('colorMap', () => { const grid = Enzyme.mount( ); expect(grid.find('rect').at(3).html()).toContain('blue'); }); ================================================ FILE: examples/react-web/src/chess/game.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import Chess from 'chess.js'; // Helper to instantiate chess.js correctly on // both browser and Node. function Load(pgn) { let chess = null; if (Chess.Chess) { chess = new Chess.Chess(); } else { chess = new Chess(); } chess.load_pgn(pgn); return chess; } const ChessGame = { name: 'chess', setup: () => ({ pgn: '' }), moves: { move({ G, ctx }, san) { const chess = Load(G.pgn); if ( (chess.turn() == 'w' && ctx.currentPlayer == '1') || (chess.turn() == 'b' && ctx.currentPlayer == '0') ) { return { ...G }; } chess.move(san); return { pgn: chess.pgn() }; }, }, turn: { minMoves: 1, maxMoves: 1 }, endIf: ({ G }) => { const chess = Load(G.pgn); if (chess.game_over()) { if ( chess.in_draw() || chess.in_threefold_repetition() || chess.insufficient_material() || chess.in_stalemate() ) { return 'd'; } if (chess.in_checkmate()) { if (chess.turn() == 'w') { return 'b'; } else { return 'w'; } } } }, }; export default ChessGame; ================================================ FILE: examples/react-web/src/chess/grid.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; /** * Grid * * Component that will show children on a cartesian regular grid. * * Props: * rows - Number of rows (height) of the grid. * cols - Number of columns (width) of the grid. * style - CSS style of the Grid HTML element. * colorMap - A map from 'x,y' => color. * onClick - (x, y) => {} * Called when a square is clicked. * onMouseOver - (x, y) => {} * Called when a square is mouse over. * onMouseOut - (x, y) => {} * Called when a square is mouse out. * * Usage: * * * * */ export class Grid extends React.Component { static propTypes = { rows: PropTypes.number.isRequired, cols: PropTypes.number.isRequired, outline: PropTypes.bool, style: PropTypes.object, colorMap: PropTypes.object, cellSize: PropTypes.number, onClick: PropTypes.func, onMouseOver: PropTypes.func, onMouseOut: PropTypes.func, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.element), PropTypes.element, ]), }; static defaultProps = { colorMap: {}, outline: true, cellSize: 1, }; _svgRef = React.createRef(); _getCellColor(x, y) { const key = `${x},${y}`; let color = 'white'; if (key in this.props.colorMap) { color = this.props.colorMap[key]; } return color; } _getGrid() { if (!this.props.outline) { return null; } let squares = []; for (let x = 0; x < this.props.cols; x++) { for (let y = 0; y < this.props.rows; y++) { squares.push( ); } } return squares; } onClick = (args) => { if (this.props.onClick) { this.props.onClick(args); } }; onMouseOver = (args) => { if (this.props.onMouseOver) { this.props.onMouseOver(args); } }; onMouseOut = (args) => { if (this.props.onMouseOut) { this.props.onMouseOut(args); } }; render() { const tokens = React.Children.map(this.props.children, (child) => { return React.cloneElement(child, { template: Square, // Overwrites Token's onClick, onMouseOver, onMouseOut onClick: this.onClick, onMouseOver: this.onMouseOver, onMouseOut: this.onMouseOut, svgRef: this._svgRef, }); }); return ( {this._getGrid()} {tokens} ); } } /** * Square * * Component that renders a square inside a Grid. * * Props: * x - X coordinate on grid coordinates. * y - Y coordinate on grid coordinates. * size - Square size. * style - Custom styling. * onClick - Invoked when a Square is clicked. * onMouseOver - Invoked when a Square is mouse over. * onMouseOut - Invoked when a Square is mouse out. * eventListeners - Array of objects with name and callback * for DOM events. * * Not meant to be used by the end user directly (use Token). * Also not exposed in the NPM. */ export class Square extends React.Component { static propTypes = { x: PropTypes.number.isRequired, y: PropTypes.number.isRequired, size: PropTypes.number, style: PropTypes.any, onClick: PropTypes.func, onMouseOver: PropTypes.func, onMouseOut: PropTypes.func, eventListeners: PropTypes.array, children: PropTypes.element, svgRef: PropTypes.object, }; static defaultProps = { size: 1, x: 0, y: 0, style: { fill: '#fff' }, eventListeners: [], }; _gRef = React.createRef(); onClick = (e) => { this.props.onClick(this.getCoords(), e); }; onMouseOver = (e) => { this.props.onMouseOver(this.getCoords(), e); }; onMouseOut = (e) => { this.props.onMouseOut(this.getCoords(), e); }; getCoords() { return { x: this.props.x, y: this.props.y, }; } componentDidMount() { const element = this._gRef.current; for (let listener of this.props.eventListeners) { element.addEventListener(listener.name, listener.callback); } } componentWillUnmount() { const element = this._gRef.current; for (let listener of this.props.eventListeners) { element.removeEventListener(listener.name, listener.callback); } } render() { const tx = this.props.x * this.props.size; const ty = this.props.y * this.props.size; // If no child, render a square. let children = ( ); // If a child is passed, render child. if (this.props.children) { children = this.props.children; } if (this.props.svgRef) { return ( {children} ); } return ( {children} ); } } ================================================ FILE: examples/react-web/src/chess/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import Singleplayer from './singleplayer'; import Multiplayer from './multiplayer'; const routes = [ { path: '/chess/singleplayer', text: 'Singleplayer', component: Singleplayer, }, { path: '/chess/multiplayer0', text: 'Multiplayer (Player 0)', component: Multiplayer('0'), }, { path: '/chess/multiplayer1', text: 'Multiplayer (Player 1)', component: Multiplayer('1'), }, { path: '/chess/multiplayer', text: 'Multiplayer (Spectator)', component: Multiplayer(), }, ]; export default { routes }; ================================================ FILE: examples/react-web/src/chess/multiplayer.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { SocketIO } from 'boardgame.io/multiplayer'; import ChessGame from './game'; import ChessBoard from './board'; const hostname = window.location.hostname; const App = Client({ game: ChessGame, board: ChessBoard, multiplayer: SocketIO({ server: `${hostname}:8000` }), debug: true, }); const Multiplayer = (playerID) => () => (
PlayerID: {playerID}
); export default Multiplayer; ================================================ FILE: examples/react-web/src/chess/pieces/CREDITS ================================================ Chess piece artwork comes from https://en.wikipedia.org/wiki/Chess_piece and was made available by its authors under BSD license. ================================================ FILE: examples/react-web/src/chess/pieces/bishop.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; class Bishop extends React.Component { static propTypes = { color: PropTypes.string, }; render() { let primaryColor = this.props.color == 'b' ? '#000000' : '#FFFFFF'; let secondaryColor = this.props.color == 'b' ? '#FFFFFF' : '#000000'; return ( ); } } export default Bishop; ================================================ FILE: examples/react-web/src/chess/pieces/king.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; class King extends React.Component { static propTypes = { color: PropTypes.string, }; render() { let primaryColor = this.props.color == 'b' ? '#000000' : '#FFFFFF'; let secondaryColor = this.props.color == 'b' ? '#FFFFFF' : '#000000'; let extra = null; if (this.props.color == 'b') { extra = ( ); } return ( {extra} ); } } export default King; ================================================ FILE: examples/react-web/src/chess/pieces/knight.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; class Knight extends React.Component { static propTypes = { color: PropTypes.string, }; render() { let primaryColor = this.props.color == 'b' ? '#000000' : '#FFFFFF'; let secondaryColor = this.props.color == 'b' ? '#FFFFFF' : '#000000'; let extra = null; if (this.props.color == 'b') { extra = ( ); } return ( {extra} ); } } export default Knight; ================================================ FILE: examples/react-web/src/chess/pieces/pawn.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; class Pawn extends React.Component { static propTypes = { color: PropTypes.string, }; render() { let primaryColor = this.props.color == 'b' ? '#000000' : '#FFFFFF'; return ( ); } } export default Pawn; ================================================ FILE: examples/react-web/src/chess/pieces/queen.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; class Queen extends React.Component { static propTypes = { color: PropTypes.string, }; render() { let primaryColor = this.props.color == 'b' ? '#000000' : '#FFFFFF'; let secondaryColor = this.props.color == 'b' ? '#FFFFFF' : '#000000'; return ( ); } } export default Queen; ================================================ FILE: examples/react-web/src/chess/pieces/rook.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; class Rook extends React.Component { static propTypes = { color: PropTypes.string, }; render() { if (this.props.color == 'b') { return ( ); } else { return ( ); } } } export default Rook; ================================================ FILE: examples/react-web/src/chess/singleplayer.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import ChessGame from './game'; import ChessBoard from './board'; const App = Client({ game: ChessGame, board: ChessBoard, }); const Singleplayer = () => (
); export default Singleplayer; ================================================ FILE: examples/react-web/src/chess/token.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-syle * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import { Square } from './grid'; /** * Token * * Component that represents a board game piece (or token). * Can be used by itself or with one of the grid systems * provided (Grid or HexGrid). * * A token renders as a square inside a Grid and a * hexagon inside a HexGrid. Additionally, you can pass * it a child if you want any other custom rendering. * * Props: * x - X coordinate on grid / hex grid. * y - Y coordinate on grid / hex grid. * z - Z coordinate on hex grid. * animate - Changes in position are animated if true. * animationDuration - Length of animation. * onClick - Called when the token is clicked. * onMouseOver - Called when the token is mouse over. * onMouseOut - Called when the token is mouse out. * draggable - Whether a Token is draggable or not. * shouldDrag - Whether a draggable token should start drag. * onDrag - Called when a token was dragged (moved). * Parameter contain { x, y, originalX, originalY }. * onDrop - Called when the token was dropped after dragging. * Parameter contain { x, y, originalX, originalY }. * * Usage: * * * * * * * * * * * * * * */ export class Token extends React.Component { static propTypes = { x: PropTypes.number, y: PropTypes.number, z: PropTypes.number, template: PropTypes.any, style: PropTypes.any, animate: PropTypes.bool, onClick: PropTypes.func, onMouseOver: PropTypes.func, onMouseOut: PropTypes.func, children: PropTypes.element, animationDuration: PropTypes.number, draggable: PropTypes.bool, shouldDrag: PropTypes.func, onDrag: PropTypes.func, onDrop: PropTypes.func, svgRef: PropTypes.object, }; static defaultProps = { animationDuration: 750, template: Square, }; constructor(props) { super(props); this.state = { ...this.getCoords(), dragged: null, usingTouch: false, }; } _startDrag = (e) => { if (this.props.draggable && this.props.shouldDrag(this.getCoords())) { e.preventDefault(); // Required for Safari/iOs. e = e.touches ? e.touches[0] : e; this.setState({ ...this.state, dragged: { x: e.pageX, y: e.pageY }, }); this._addOrRemoveDragEventListeners(true); } }; _drag = (e) => { if (this.state.dragged) { e.preventDefault(); // Required for Safari/iOs. e = e.touches ? e.touches[0] : e; const ctm = this.props.svgRef.current.getScreenCTM().inverse(); const deltaPageX = e.pageX - this.state.dragged.x; const deltaPageY = e.pageY - this.state.dragged.y; const deltaSvgX = ctm.a * deltaPageX + ctm.b * deltaPageY; const deltaSvgY = ctm.c * deltaPageX + ctm.d * deltaPageY; const x = this.state.x + deltaSvgX; const y = this.state.y + deltaSvgY; if (this.props.onDrag) { this.props.onDrag({ x, y, originalX: this.props.x, originalY: this.props.y, }); } this.setState({ ...this.state, x, y, dragged: { x: e.pageX, y: e.pageY }, }); } }; _endDrag = (e) => { if (this.state.dragged) { e.preventDefault(); // Whether this is a drop or a click depends if the mouse moved after drag. // Android will issue very small drag events, so we need a distance. const dist = Math.sqrt( (this.state.x - this.props.x) ** 2 + (this.state.y - this.props.y) ** 2 ); if (dist > 0.2) { this.props.onDrop({ x: this.state.x, y: this.state.y, originalX: this.props.x, originalY: this.props.y, }); } else { this.props.onClick({ x: this.state.x, y: this.state.y }); } this.setState({ ...this.state, x: this.props.x, y: this.props.y, dragged: null, }); this._addOrRemoveDragEventListeners(false); } }; _onClick = (param) => { // Ignore onClick if the element is draggable, because desktops will // send both onClick and touch events, leading to duplication. // Whether this will be a click or a drop will be defined in _endDrag. if (!(this.props.draggable && this.props.shouldDrag(this.getCoords()))) { this.props.onClick(param); } }; componentWillUnmount() { if (this.state.dragged) { this._addOrRemoveDragEventListeners(false); } } /** * If there is a change in props, saves old x/y, * and current time. Starts animation. * @param {Object} nextProps Next props. */ // eslint-disable-next-line react/no-deprecated UNSAFE_componentWillReceiveProps(nextProps) { let oldCoord = this.getCoords(); let newCoord = this.getCoords(nextProps); // Debounce. if (oldCoord.x == newCoord.x && oldCoord.y == newCoord.y) { return; } this.setState({ ...this.state, originTime: Date.now(), originX: this.state.x, originY: this.state.y, originZ: this.state.z, }); requestAnimationFrame(this._animate(Date.now())); } /** * Add or remove event listeners. * @param {boolean} shouldAdd If it should add (or remove) listeners. */ _addOrRemoveDragEventListeners(shouldAdd) { const svgEl = this.props.svgRef.current; if (!svgEl) return; let addOrRemoveEventListener = svgEl.addEventListener; if (!shouldAdd) { addOrRemoveEventListener = svgEl.removeEventListener; } addOrRemoveEventListener('touchmove', this._drag, { passive: false }); addOrRemoveEventListener('mousemove', this._drag, { passive: false }); addOrRemoveEventListener('mouseup', this._endDrag, { passive: false }); addOrRemoveEventListener('mouseleave', this._endDrag, { passive: false }); addOrRemoveEventListener('touchcancel', this._endDrag, { passive: false }); addOrRemoveEventListener('touchleave', this._endDrag, { passive: false }); addOrRemoveEventListener('touchend', this._endDrag, { passive: false }); } /** * Recursively animates x and y. * @param {number} now Unix timestamp when this was called. */ _animate(now) { return (() => { let elapsed = now - this.state.originTime; let svgCoord = this.getCoords(); if (elapsed < this.props.animationDuration && this.props.animate) { const percentage = this._easeInOutCubic( elapsed, 0, 1, this.props.animationDuration ); this.setState({ ...this.state, x: (svgCoord.x - this.state.originX) * percentage + this.state.originX, y: (svgCoord.y - this.state.originY) * percentage + this.state.originY, z: (svgCoord.z - this.state.originZ) * percentage + this.state.originZ, }); requestAnimationFrame(this._animate(Date.now())); } else { this.setState({ ...this.state, x: svgCoord.x, y: svgCoord.y, z: svgCoord.z, }); } }).bind(this); } /** * Gets SVG x/y/z coordinates. * @param {Object} props Props object to get coordinates from. * @return {Object} Object with x, y and z parameters. */ getCoords(props = this.props) { return { x: props.x, y: props.y, z: props.z }; } /** * Returns animation easing value. See http://easings.net/#easeInOutCubic. * @param {number} t Current time. * @param {number} b Beginning value. * @param {number} c Final value. * @param {number} d Duration. */ _easeInOutCubic(t, b, c, d) { t /= d / 2; if (t < 1) return (c / 2) * t * t * t + b; t -= 2; return (c / 2) * (t * t * t + 2) + b; } /** * Gets event listeners needed for drag and drop. */ _eventListeners() { return [ { name: 'mousedown', callback: this._startDrag }, { name: 'touchstart', callback: this._startDrag }, ]; } render() { const Component = this.props.template; return ( {this.props.children} ); } } ================================================ FILE: examples/react-web/src/index.html ================================================
================================================ FILE: examples/react-web/src/index.js ================================================ import React from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app'; const container = document.getElementById('test') || document.createElement('div'); const root = createRoot(container); root.render( ); ================================================ FILE: examples/react-web/src/li-navlink.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import { NavLink, Route } from 'react-router-dom'; const LiNavLink = (props) => { const { to, exact, strict, activeClassName, className, activeStyle, style, isActive: getIsActive, ...rest } = props; return ( {({ location, match }) => { const isActive = !!(getIsActive ? getIsActive(match, location) : match); return (
  • ); }}
    ); }; LiNavLink.propTypes = { to: PropTypes.string, exact: PropTypes.bool, strict: PropTypes.bool, activeClassName: PropTypes.string, className: PropTypes.string, activeStyle: PropTypes.object, style: PropTypes.object, isActive: PropTypes.func, }; export default LiNavLink; ================================================ FILE: examples/react-web/src/lobby/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import routes from './routes'; // Any other additional setup for this module export default { routes, }; ================================================ FILE: examples/react-web/src/lobby/lobby.css ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ #lobby-view { display: flex; flex-direction: column; width: 100%; } .phase-title { margin-top: 40px; } .hidden { display: none; } .phase { border: none; outline: none; } #game-creation select { font-size: 14px; margin-right: 10px; } #game-creation span { margin-right: 10px; } #instances td { list-style: none; border: 0px solid #eee; border-top: none; height: 30px; line-height: 30px; text-align: left; font-size: 14px; padding-left: 10px; padding-right: 10px; } #instances table { height: 100%; width: 500px; white-space: nowrap; } #instances button { font-size: 12px; } .error-msg { font-size: 12px; color: red; margin-top: 10px; margin-bottom: 10px; display: block; } .buttons button { margin-left: 10px; margin-top: 10px; width: 70px; } ================================================ FILE: examples/react-web/src/lobby/lobby.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Lobby } from 'boardgame.io/react'; import { default as BoardTicTacToe } from '../tic-tac-toe/board'; import { default as BoardChess } from '../chess/board'; import { default as GameTicTacToe } from '../tic-tac-toe/game'; import { default as GameChess } from '../chess/game'; import './lobby.css'; GameTicTacToe.minPlayers = 1; GameTicTacToe.maxPlayers = 2; GameChess.minPlayers = GameChess.maxPlayers = 2; const hostname = window.location.hostname; const importedGames = [ { game: GameTicTacToe, board: BoardTicTacToe }, { game: GameChess, board: BoardChess }, ]; const LobbyView = () => (

    Lobby

    ); export default LobbyView; ================================================ FILE: examples/react-web/src/lobby/routes.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import LobbyView from './lobby'; const routes = [ { path: '/lobby/main', text: 'Example', component: LobbyView, }, ]; export default routes; ================================================ FILE: examples/react-web/src/random/board.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; const Board = ({ G, moves }) => (
    {JSON.stringify(G, null, 2)}
    ); Board.propTypes = { G: PropTypes.any.isRequired, moves: PropTypes.any.isRequired, }; export default Board; ================================================ FILE: examples/react-web/src/random/game.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ const RandomExample = { name: 'shuffle', setup: () => ({ deck: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], }), moves: { shuffle: ({ G, random }) => ({ ...G, deck: random.Shuffle(G.deck) }), rollDie: ({ G, random }, value) => ({ ...G, dice: random.Die(value) }), rollD6: ({ G, random }) => ({ ...G, dice: random.D6() }), }, }; export default RandomExample; ================================================ FILE: examples/react-web/src/random/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import Game from './game'; import Board from './board'; const App = Client({ game: Game, numPlayers: 1, board: Board, }); const SingleView = () => (
    ); const routes = [ { path: '/random/main', text: 'Examples', component: SingleView, }, ]; export default { routes }; ================================================ FILE: examples/react-web/src/redacted-move/board.css ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ .secret-state section { text-align: left; padding: 10px; margin-bottom: 20px; background: #eee; } ================================================ FILE: examples/react-web/src/redacted-move/board.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import './board.css'; const Board = ({ G, ctx, moves, playerID, log }) => (
    G
    {JSON.stringify(G, null, 2)}
    log
    {JSON.stringify(log, null, 2)}
    {playerID && ( )}
    ); Board.propTypes = { G: PropTypes.any.isRequired, ctx: PropTypes.any.isRequired, moves: PropTypes.any.isRequired, playerID: PropTypes.any, log: PropTypes.any, }; export default Board; ================================================ FILE: examples/react-web/src/redacted-move/game.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { PlayerView, ActivePlayers } from 'boardgame.io/core'; const RedactedMoves = { name: 'secret-state', setup: () => ({ other: {}, players: { 0: 'player 0 state', 1: 'player 1 state', }, }), moves: { clickCell: { /* eslint-disable no-unused-vars */ move: (_, secretstuff) => {}, /* eslint-enable no-unused-vars */ redact: true, }, }, turn: { activePlayers: ActivePlayers.ALL }, playerView: PlayerView.STRIP_SECRETS, }; export default RedactedMoves; ================================================ FILE: examples/react-web/src/redacted-move/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import Multiview from './multiview'; const routes = [ { path: '/redacted_move', text: 'Example', component: Multiview, }, ]; export default { routes }; ================================================ FILE: examples/react-web/src/redacted-move/multiview.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import Game from './game'; import Board from './board'; const App = Client({ game: Game, numPlayers: 2, board: Board, debug: false, multiplayer: Local(), }); const Multiview = () => (

    Redacted Moves

    This examples demonstrates the use of redacted moves. Using redacted moves allows for secret information to be stripped from the log for other players.

    Clicking the button on one of the players, you should see complete log event for that player but a redacted one for everyone else.

    <App playerID="0"/>
    <App playerID="1"/>
    <App/>
    ); export default Multiview; ================================================ FILE: examples/react-web/src/routes.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import tic_tac_toe from './tic-tac-toe'; import chess from './chess'; import secret_state from './secret-state'; import random from './random'; import threejs from './threejs'; import lobby from './lobby'; import simulator from './simulator'; import redacted_move from './redacted-move'; import undo from './undo'; const routes = [ { name: 'Tic-Tac-Toe', routes: tic_tac_toe.routes, }, { name: 'Chess', routes: chess.routes, }, { name: 'Turn Orders', routes: simulator.routes, }, { name: 'Random API', routes: random.routes, }, { name: 'Secret State', routes: secret_state.routes, }, { name: 'Redacted Move', routes: redacted_move.routes, }, { name: 'Undo', routes: undo.routes, }, { name: 'Other Frameworks', routes: threejs.routes, }, { name: 'Lobby', routes: lobby.routes, }, ]; export default routes; ================================================ FILE: examples/react-web/src/secret-state/board.css ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ .secret-state section { text-align: left; padding: 10px; margin-bottom: 20px; background: #eee; } ================================================ FILE: examples/react-web/src/secret-state/board.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import './board.css'; const Board = ({ G, ctx, moves, playerID }) => (
    {JSON.stringify(G, null, 2)}
    ); Board.propTypes = { G: PropTypes.any.isRequired, ctx: PropTypes.any.isRequired, moves: PropTypes.any.isRequired, playerID: PropTypes.any, }; export default Board; ================================================ FILE: examples/react-web/src/secret-state/game.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { PlayerView } from 'boardgame.io/core'; const SecretState = { name: 'secret-state', setup: () => ({ other: {}, players: { 0: 'player 0 state', 1: 'player 1 state', 2: 'player 2 state', }, }), playerView: PlayerView.STRIP_SECRETS, }; export default SecretState; ================================================ FILE: examples/react-web/src/secret-state/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import Multiview from './multiview'; const routes = [ { path: '/liars-dice', text: 'Examples', component: Multiview, }, ]; export default { routes }; ================================================ FILE: examples/react-web/src/secret-state/multiview.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import Game from './game'; import Board from './board'; const App = Client({ game: Game, numPlayers: 3, board: Board, debug: false, multiplayer: Local(), }); const Multiview = () => (

    Secret Info

    <App playerID="0"/>
    <App playerID="1"/>
    <App playerID="2"/>
    <App/>
    ); export default Multiview; ================================================ FILE: examples/react-web/src/simulator/example-all-once.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { ActivePlayers } from 'boardgame.io/core'; const code = `{ turn: { activePlayers: ActivePlayers.ALL_ONCE }, } `; const Description = () => (
    {code}
    ); export default { description: Description, game: { moves: { move: ({ G }) => G, }, turn: { activePlayers: ActivePlayers.ALL_ONCE }, events: { endPhase: false, }, }, }; ================================================ FILE: examples/react-web/src/simulator/example-all.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { ActivePlayers } from 'boardgame.io/core'; const code = `{ turn: { activePlayers: ActivePlayers.ALL }, } `; const Description = () => (
    {code}
    ); export default { description: Description, game: { moves: { move: ({ G }) => G, }, turn: { activePlayers: ActivePlayers.ALL }, events: { endPhase: false, }, }, }; ================================================ FILE: examples/react-web/src/simulator/example-others-once.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; const code = `{ moves: { play: ({ G, events }) => { events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1, }); return G; }, }, turn: { stages: { discard: { moves: { discard: ({ G }) => G, }, }, }, }, } `; const Description = () => (
    {code}
    ); export default { description: Description, game: { events: { endPhase: false, }, moves: { play: ({ G, events }) => { events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1, }); return G; }, }, turn: { stages: { discard: { moves: { discard: ({ G }) => G, }, }, }, }, }, }; ================================================ FILE: examples/react-web/src/simulator/example-others.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { ActivePlayers } from 'boardgame.io/core'; const code = `{ turn: { activePlayers: ActivePlayers.OTHERS }, } `; const Description = () => (
    {code}
    ); export default { description: Description, game: { moves: { move: ({ G }) => G, }, events: { endPhase: false, }, turn: { activePlayers: ActivePlayers.OTHERS }, }, }; ================================================ FILE: examples/react-web/src/simulator/index.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import App from './simulator'; const routes = [ { path: '/simulator', text: 'Simulator', component: App, }, ]; export default { routes }; ================================================ FILE: examples/react-web/src/simulator/simulator.css ================================================ #turnorder { display: flex; flex-direction: column; align-items: center; padding: 50px; } #turnorder pre { font-family: monospace; font-size: 12px; color: #999; } #turnorder .description { padding-top: 50px; padding-bottom: 30px; } #turnorder .table-interior { position: absolute; margin-left: -3.2em; margin-top: -0.85em; } #turnorder .player-container { position: relative; margin: 100px; width: 16em; height: 16em; border-radius: 50%; background: #eaeaea; border: 5px solid #aaa; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; } .turnorder-options { width: 500px; display: flex; flex-direction: row; justify-content: space-evenly; } .turnorder-options div { text-align: center; cursor: pointer; font-size: 12px; padding: 10px; border: 1px solid #aaa; width: 100px; } .turnorder-options div:hover { background: #efefef; } .turnorder-options div.active { background: #efefef; font-weight: bold; } #turnorder .stage-label { position: absolute; top: 2.25em; left: 50%; margin-left: -3.5em; width: 8em; text-align: center; } #turnorder .controls { position: absolute; margin-top: 60px; margin-left: -47px; } #turnorder .controls button { width: 80px; margin-bottom: 5px; } #turnorder .player { position: absolute; left: 50%; top: 50%; margin: -1.5em; width: 3em; height: 3em; line-height: 2.75em; border-radius: 50%; border-color: #aaa; border-style: solid; box-sizing: border-box; text-align: center; font-size: 24px; font-weight: bold; opacity: 0.3; } #turnorder .player.current { opacity: 1; background: #555; color: #eee; } #turnorder .player.active { opacity: 1; } #turnorder .phase, #turnorder .stage { display: inline-block; background: #555; color: #eee; padding: 3px 5px; margin-left: 5px; } #turnorder .bgio-client { padding: 0 !important; } .player-container span > .bgio-client:nth-child(1) .player-wrap { transform: rotate(0deg) translate(12em) rotate(-0deg); } .player-container span > .bgio-client:nth-child(2) .player-wrap { transform: rotate(60deg) translate(12em) rotate(-60deg); } .player-container span > .bgio-client:nth-child(3) .player-wrap { transform: rotate(120deg) translate(12em) rotate(-120deg); } .player-container span > .bgio-client:nth-child(4) .player-wrap { transform: rotate(180deg) translate(12em) rotate(-180deg); } .player-container span > .bgio-client:nth-child(5) .player-wrap { transform: rotate(240deg) translate(12em) rotate(-240deg); } .player-container span > .bgio-client:nth-child(6) .player-wrap { transform: rotate(300deg) translate(12em) rotate(-300deg); } ================================================ FILE: examples/react-web/src/simulator/simulator.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import All from './example-all'; import AllOnce from './example-all-once'; import Others from './example-others'; import OthersOnce from './example-others-once'; import './simulator.css'; function Board({ ctx, moves, events, playerID }) { if (playerID === null) { return
    ; } let className = 'player'; let active = false; let current = false; let stage; let onClick = () => {}; if (ctx.activePlayers) { if (playerID in ctx.activePlayers) { className += ' active'; active = true; stage = ctx.activePlayers[playerID]; } } else { if (playerID === ctx.currentPlayer) { className += ' active'; active = true; } } if (playerID == ctx.currentPlayer) { className += ' current'; current = true; } moves = Object.entries(moves) .filter((e) => !(e[0] === 'play' && stage === 'discard')) .filter((e) => !(e[0] === 'discard' && stage !== 'discard')) .map((e) => ( )); events = Object.entries(events) .filter(() => current && active) .filter((e) => e[0] != 'setActivePlayers') .filter((e) => e[0] != 'setStage') .filter((e) => e[0] != 'endStage') .map((e) => ( )); return (
    {playerID}
    {active && moves} {events}
    ); } const examples = { 'others-once': OthersOnce, all: All, 'all-once': AllOnce, others: Others, }; class App extends React.Component { constructor(props) { super(props); this.init('all'); } init(type) { let shouldUpdate = false; if (this.client !== undefined) { shouldUpdate = true; } this.type = type; this.description = examples[type].description; this.client = Client({ game: examples[type].game, numPlayers: 6, debug: false, board: Board, multiplayer: Local(), }); if (shouldUpdate) { this.forceUpdate(); } } render() { const Description = this.description; const App = this.client; let players = []; for (let i = 0; i < 6; i++) { players.push(); } return (
    this.init('all')} > ALL
    this.init('all-once')} > ALL_ONCE
    this.init('others')} > OTHERS
    this.init('others-once')} > OTHERS_ONCE
    {players}
    ); } } export default App; ================================================ FILE: examples/react-web/src/threejs/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import * as THREE from 'three'; import { Client } from 'boardgame.io/client'; import TicTacToe from '../tic-tac-toe/game'; import './main.css'; function Init(root) { const client = Client({ game: TicTacToe }); // Set up scene. const scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff); // Set up renderer. const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); // Add nine cubes. let cubes = []; for (let i = 0; i < 9; i++) { const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshLambertMaterial({ color: 0xcccccc }); const cube = new THREE.Mesh(geometry, material); const r = Math.floor(i / 3); const c = i % 3; cube.position.z = -c * 2; cube.position.x = r * 2; cube.userData.i = i; cubes.push(cube); scene.add(cube); } // Set up camera. const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.z = 5; camera.position.x = 12; camera.position.y = 15; camera.lookAt(cubes[4].position); scene.add(camera); // Set up lights. const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0x555555); scene.add(directionalLight); // Animation logic. let rotation = 0; function animate() { requestAnimationFrame(animate); const { G } = client.getState(); cubes .filter((c) => G.cells[c.userData.i] == '0') .forEach((c) => { c.material.color.setHex(0xff0000); c.rotation.x = rotation; }); cubes .filter((c) => G.cells[c.userData.i] == '1') .forEach((c) => { c.material.color.setHex(0x00ff00); c.rotation.y = -rotation; }); rotation += 0.03; renderer.render(scene, camera); } // Mouse handling. const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); function onMouseMove(e) { const { ctx } = client.getState(); if (ctx.gameover !== undefined) { root.style.cursor = ''; return; } const x = e.clientX - root.offsetParent.offsetLeft; const y = e.clientY; mouse.x = (x / window.innerWidth) * 2 - 1; mouse.y = -(y / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const highlightedCubes = raycaster.intersectObjects(cubes); cubes.forEach((c) => { c.material.color.setHex(0xcccccc); }); highlightedCubes.forEach((c) => { c.object.material.color.setHex(0xaaaaaa); }); if (highlightedCubes.length > 0) { root.style.cursor = 'pointer'; } else { root.style.cursor = ''; } } function onMouseDown() { raycaster.setFromCamera(mouse, camera); raycaster.intersectObjects(cubes).forEach((cube) => { client.moves.clickCell(cube.object.userData.i); }); } root.appendChild(renderer.domElement); root.addEventListener('mousemove', onMouseMove); root.addEventListener('mousedown', onMouseDown); animate(); } const routes = [ { path: '/threejs/main', text: 'threejs', component: class App extends React.Component { componentDidMount() { Init(this.ref); } render() { return (
    { this.ref = el; }} /> ); } }, }, ]; export default { routes, }; ================================================ FILE: examples/react-web/src/threejs/main.css ================================================ div.root { position: relative; } div.text { position: absolute; left: 10px; top: 10px; } ================================================ FILE: examples/react-web/src/tic-tac-toe/advanced-ai.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React, { useEffect, useState } from 'react'; import { Client as PlainJSClient } from 'boardgame.io/client'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import { MCTSBot, Step } from 'boardgame.io/ai'; import TicTacToe from './game'; import Board from './board'; const App = Client({ game: TicTacToe, board: Board, debug: false, // Use Local transport for communication with bots. multiplayer: Local(), }); /** * Component that controls and runs a custom bot instance. */ const BotControls = ({ playerID, matchID }) => { const difficulties = { easy: { iterations: 1, playoutDepth: 1, }, hard: { iterations: 1000, playoutDepth: 50, }, }; const [difficulty, setDifficulty] = useState('easy'); const [client, setClient] = useState(); // Create a plain Javascript boardgame.io client on mount. useEffect(() => { const newClient = PlainJSClient({ game: TicTacToe, debug: false, multiplayer: Local(), matchID, playerID, }); newClient.start(); setClient(newClient); // Clean up client on unmount. return () => newClient.stop(); }, []); // Update the client subscription when bot difficulty changes. useEffect(() => { if (!client) return; // Subscribe to the client with a function that will run AI on a bot // player’s turn. return client.subscribe((state) => { if (!state) return; if (state.ctx.currentPlayer === playerID) { const { iterations, playoutDepth } = difficulties[difficulty]; const bot = new MCTSBot({ game: TicTacToe, enumerate: TicTacToe.ai.enumerate, iterations, playoutDepth, }); // Delay AI stepping by a tick to allow React to render before the // main thread gets blocked by AI iterations. setTimeout(() => Step(client, bot), 0); } }); }, [client, difficulty]); // Render AI difficulty toggle buttons. return (

    AI Difficulty:{' '}

    ); }; const AdvancedAI = () => { return (

    Advanced AI

    This example shows how to use a custom bot instance to play against a local player.
    In the future, this will be made much simpler!

    ); }; export default AdvancedAI; ================================================ FILE: examples/react-web/src/tic-tac-toe/authenticated.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { SocketIO } from 'boardgame.io/multiplayer'; import TicTacToe from './game'; import Board from './board'; import PropTypes from 'prop-types'; import request from 'superagent'; const hostname = window.location.hostname; const App = Client({ game: TicTacToe, board: Board, debug: false, multiplayer: SocketIO({ server: `${hostname}:8000` }), }); class AuthenticatedClient extends React.Component { constructor(props) { super(props); this.state = { matchID: 'matchID', players: { 0: { credentials: 'credentials', }, 1: { credentials: 'credentials', }, }, }; } async componentDidMount() { const gameName = 'tic-tac-toe'; const PORT = 8000; const newGame = await request .post(`http://${hostname}:${PORT}/games/${gameName}/create`) .send({ numPlayers: 2 }); const matchID = newGame.body.matchID; let playerCredentials = []; for (let playerID of [0, 1]) { const player = await request .post(`http://${hostname}:${PORT}/games/${gameName}/${matchID}/join`) .send({ gameName, playerID, playerName: playerID.toString(), }); playerCredentials.push(player.body.playerCredentials); } this.setState({ matchID, players: { 0: { credentials: playerCredentials[0], }, 1: { credentials: playerCredentials[1], }, }, }); } onPlayerCredentialsChange(playerID, credentials) { this.setState({ matchID: this.state.matchID, players: { ...this.state.players, [playerID]: { credentials, }, }, }); } render() { return ( ); } } class AuthenticatedExample extends React.Component { static propTypes = { matchID: PropTypes.string, players: PropTypes.any, onPlayerCredentialsChange: PropTypes.func, }; render() { return (

    Authenticated

    Change the credentials of a player, and you will notice that the server no longer accepts moves from that client.

    this.props.onPlayerCredentialsChange('0', event.target.value) } />
    this.props.onPlayerCredentialsChange('1', event.target.value) } />
    ); } } export default AuthenticatedClient; ================================================ FILE: examples/react-web/src/tic-tac-toe/board.css ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ #board { border-collapse: collapse; } #winner { margin-top: 25px; width: 168px; text-align: center; } td { text-align: center; font-weight: bold; font-size: 25px; color: #555; width: 50px; height: 50px; line-height: 50px; border: 3px solid #aaa; background: #fff; } td.active { cursor: pointer; background: #eeffe9; } td.active:hover { background: #eeffff; } ================================================ FILE: examples/react-web/src/tic-tac-toe/board.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import './board.css'; class Board extends React.Component { static propTypes = { G: PropTypes.any.isRequired, ctx: PropTypes.any.isRequired, moves: PropTypes.any.isRequired, playerID: PropTypes.string, isActive: PropTypes.bool, isMultiplayer: PropTypes.bool, isConnected: PropTypes.bool, isPreview: PropTypes.bool, }; onClick = (id) => { if (this.isActive(id)) { this.props.moves.clickCell(id); } }; isActive(id) { return this.props.isActive && this.props.G.cells[id] === null; } render() { let tbody = []; for (let i = 0; i < 3; i++) { let cells = []; for (let j = 0; j < 3; j++) { const id = 3 * i + j; cells.push( this.onClick(id)} > {this.props.G.cells[id]} ); } tbody.push({cells}); } let disconnected = null; if (this.props.isMultiplayer && !this.props.isConnected) { disconnected =
    Disconnected!
    ; } let winner = null; if (this.props.ctx.gameover) { winner = this.props.ctx.gameover.winner !== undefined ? (
    Winner: {this.props.ctx.gameover.winner}
    ) : (
    Draw!
    ); } let player = null; if (this.props.playerID) { player =
    Player: {this.props.playerID}
    ; } if (this.props.isPreview) { disconnected = player = null; } return (
    {tbody}
    {player} {winner} {disconnected}
    ); } } export default Board; ================================================ FILE: examples/react-web/src/tic-tac-toe/bots.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import { MCTSBot } from 'boardgame.io/ai'; import TicTacToe from './game'; import Board from './board'; const App = Client({ game: TicTacToe, board: Board, debug: false, multiplayer: Local({ bots: { 1: MCTSBot, }, }), }); const Bots = () => (

    Singleplayer vs AI

    ); export default Bots; ================================================ FILE: examples/react-web/src/tic-tac-toe/game.js ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ function IsVictory(cells) { const positions = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; const isRowComplete = (row) => { const symbols = row.map((i) => cells[i]); return symbols.every((i) => i !== null && i === symbols[0]); }; return positions.map(isRowComplete).some((i) => i === true); } const TicTacToe = { name: 'tic-tac-toe', setup: () => ({ cells: new Array(9).fill(null), }), moves: { clickCell({ G, playerID }, id) { const cells = [...G.cells]; if (cells[id] === null) { cells[id] = playerID; return { ...G, cells }; } }, }, turn: { minMoves: 1, maxMoves: 1, }, endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } if (G.cells.filter((c) => c === null).length == 0) { return { draw: true }; } }, ai: { enumerate: (G) => { let r = []; for (let i = 0; i < 9; i++) { if (G.cells[i] === null) { r.push({ move: 'clickCell', args: [i] }); } } return r; }, }, }; export default TicTacToe; ================================================ FILE: examples/react-web/src/tic-tac-toe/index.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import Singleplayer from './singleplayer'; import Multiplayer from './multiplayer'; import Spectator from './spectator'; import Authenticated from './authenticated'; import Bots from './bots'; import AdvancedAI from './advanced-ai'; const routes = [ { path: '/', text: 'Singleplayer', component: Singleplayer, }, { path: '/multiplayer', text: 'Multiplayer', component: Multiplayer, }, { path: '/authenticated', text: 'Authenticated', component: Authenticated, }, { path: '/spectator', text: 'Spectator', component: Spectator, }, { path: '/bots', text: 'Singleplayer vs AI', component: Bots, }, { path: '/advanced-ai', text: 'Advanced AI', component: AdvancedAI, }, ]; export default { routes }; ================================================ FILE: examples/react-web/src/tic-tac-toe/multiplayer.js ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import TicTacToe from './game'; import Board from './board'; const App = Client({ game: TicTacToe, board: Board, multiplayer: Local(), }); const Multiplayer = () => (

    Multiplayer

    <App playerID="0"/>
    <App playerID="1"/>
    ); export default Multiplayer; ================================================ FILE: examples/react-web/src/tic-tac-toe/singleplayer.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Debug } from 'boardgame.io/debug'; import TicTacToe from './game'; import Board from './board'; const App = Client({ game: TicTacToe, board: Board, debug: { impl: Debug }, }); const Singleplayer = () => (

    Singleplayer

    ); export default Singleplayer; ================================================ FILE: examples/react-web/src/tic-tac-toe/spectator.js ================================================ /* * Copyright 2017 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { SocketIO } from 'boardgame.io/multiplayer'; import TicTacToe from './game'; import Board from './board'; const hostname = window.location.hostname; const App = Client({ game: TicTacToe, board: Board, debug: false, multiplayer: SocketIO({ server: `${hostname}:8000` }), }); const Spectator = () => (

    Spectator

    <App playerID="0"/>
    <App playerID="1"/>
    Spectator
    ); export default Spectator; ================================================ FILE: examples/react-web/src/undo/board.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; const Board = ({ playerID, G, ctx, moves, events, undo, redo }) => (

    Player {playerID}

    {JSON.stringify(G, null, 2)}
    ); Board.propTypes = { playerID: PropTypes.any.isRequired, G: PropTypes.any.isRequired, ctx: PropTypes.any.isRequired, moves: PropTypes.any.isRequired, undo: PropTypes.any.isRequired, redo: PropTypes.any.isRequired, events: PropTypes.any.isRequired, }; export default Board; ================================================ FILE: examples/react-web/src/undo/game.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ const UndoExample = { name: 'undo', setup: () => ({ moves: [] }), moves: { A: ({ G }) => { G.moves.push('A'); }, B: ({ G }) => { G.moves.push('B'); }, }, }; export default UndoExample; ================================================ FILE: examples/react-web/src/undo/index.js ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import { Client } from 'boardgame.io/react'; import { Local } from 'boardgame.io/multiplayer'; import Game from './game'; import Board from './board'; const App = Client({ game: Game, numPlayers: 2, board: Board, multiplayer: Local(), }); const View = () => (
    ); const routes = [ { path: '/undo/main', text: 'Examples', component: View, }, ]; export default { routes }; ================================================ FILE: examples/snippets/.gitignore ================================================ package-lock.json ================================================ FILE: examples/snippets/README.md ================================================ # snippets This directory contains the source code for the embedded demos found in boardgame.io’s documentation. To work with the snippets, `cd` to this directory and install the required dependencies: ```sh cd examples/snippets npm install ``` ## Available commands ### `npm start` Run a local development server with each snippet on its own page. Each snippet will be available at `https://localhost:1234//index.html`. ### `npm run update-docs` Builds the snippets and installs them at the appropriate places in the docs. Be careful not to let Prettier "un-minify" the bundled JS while committing the change. ================================================ FILE: examples/snippets/install.sh ================================================ #!/bin/bash # Builds the snippets and installs them at the # appropriate places in the docs. Be careful not # to let Prettier "un-minify" the bundled JS while # committing the change. rm -rf dist npm run build rm -rf ../../docs/documentation/snippets cp -r dist ../../docs/documentation/snippets ================================================ FILE: examples/snippets/package.json ================================================ { "name": "snippets", "scripts": { "start": "parcel src/*/index.html", "update-docs": "./install.sh", "build": "parcel build --no-source-maps --public-url /documentation/snippets src/*/index.html", "build:opt": "parcel build --experimental-scope-hoisting --no-source-maps --public-url /documentation/snippets src/*/index.html" }, "devDependencies": { "parcel-bundler": "^1.11.0" }, "alias": { "boardgame.io": "../../dist/esm" }, "browserslist": [ "last 1 Chrome versions" ], "dependencies": { "@babel/core": "^7.6.2", "@babel/preset-env": "^7.6.2", "@babel/preset-react": "^7.0.0", "parcel-plugin-svelte": "^4.0.4", "react": "^16.9.0", "react-dom": "^16.9.0", "svelte": "^3.0.0" } } ================================================ FILE: examples/snippets/src/example-1/index.html ================================================
    interactive (not an image)
    ================================================ FILE: examples/snippets/src/example-1/index.js ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import { Client } from 'boardgame.io/react'; import { Debug } from 'boardgame.io/debug'; var TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { clickCell({ G, playerID }, id) { G.cells[id] = playerID; }, }, }; var App = Client({ game: TicTacToe, debug: { impl: Debug } }); ReactDOM.render(, document.getElementById('app')); ================================================ FILE: examples/snippets/src/example-2/index.html ================================================
    interactive (not an image)
    ================================================ FILE: examples/snippets/src/example-2/index.js ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import { Client } from 'boardgame.io/react'; import { Debug } from 'boardgame.io/debug'; import { INVALID_MOVE } from 'boardgame.io/core'; function IsVictory(cells) { const positions = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; const isRowComplete = (row) => { const symbols = row.map((i) => cells[i]); return symbols.every((i) => i !== null && i === symbols[0]); }; return positions.map(isRowComplete).some((i) => i === true); } const TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { clickCell({ G, playerID }, id) { if (G.cells[id] !== null) { return INVALID_MOVE; } G.cells[id] = playerID; }, }, turn: { minMoves: 1, maxMoves: 1 }, endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } if (G.cells.filter((c) => c === null).length == 0) { return { draw: true }; } }, }; function TicTacToeBoard({ ctx, G, moves }) { const onClick = (id) => moves.clickCell(id); let winner = ''; if (ctx.gameover) { winner = ctx.gameover.winner !== undefined ? (
    Winner: {ctx.gameover.winner}
    ) : (
    Draw!
    ); } const cellStyle = { border: '1px solid #555', width: '50px', height: '50px', lineHeight: '50px', textAlign: 'center', fontFamily: 'monospace', fontSize: '20px', fontWeight: 'bold', padding: '0', boxSizing: 'border-box', }; let tbody = []; for (let i = 0; i < 3; i++) { let cells = []; for (let j = 0; j < 3; j++) { const id = 3 * i + j; cells.push( {G.cells[id] ? (
    {G.cells[id]}
    ) : (
  • {/if} ================================================ FILE: examples/snippets/src/phases-1/game.js ================================================ function DrawCard({ G, playerID }) { G.deck--; G.hand[playerID]++; } function PlayCard({ G, playerID }) { G.deck++; G.hand[playerID]--; } const game = { setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), moves: { DrawCard, PlayCard }, turn: { minMoves: 1, maxMoves: 1 }, }; export default game; ================================================ FILE: examples/snippets/src/phases-1/index.html ================================================
    ================================================ FILE: examples/snippets/src/phases-1/index.js ================================================ import App from './App.svelte'; const app = new App({ target: document.getElementById('app'), props: { name: 'world', }, }); export default app; ================================================ FILE: examples/snippets/src/phases-2/App.svelte ================================================
    ================================================ FILE: examples/snippets/src/phases-2/Player.svelte ================================================ {#if !playerID}
    {$client.G.deck}
    cards
    {$client.ctx.phase}
    {:else}
  • Player {playerID}
  • {$client.G.hand[playerID]} cards
  • {/if} ================================================ FILE: examples/snippets/src/phases-2/game.js ================================================ function DrawCard({ G, playerID }) { G.deck--; G.hand[playerID]++; } function PlayCard({ G, playerID }) { G.deck++; G.hand[playerID]--; } const game = { setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), phases: { draw: { moves: { DrawCard }, endIf: ({ G }) => G.deck <= 0, next: 'play', start: true, }, play: { moves: { PlayCard }, endIf: ({ G }) => G.deck >= 6, }, }, turn: { minMoves: 1, maxMoves: 1 }, }; export default game; ================================================ FILE: examples/snippets/src/phases-2/index.html ================================================
    ================================================ FILE: examples/snippets/src/phases-2/index.js ================================================ import App from './App.svelte'; const app = new App({ target: document.getElementById('app'), props: { name: 'world', }, }); export default app; ================================================ FILE: examples/snippets/src/stages-1/App.svelte ================================================
    ================================================ FILE: examples/snippets/src/stages-1/Player.svelte ================================================
  • Player {playerID}
  • {#if $client.isActive}
  • {#if discard} {:else} {/if}
  • {/if}
    ================================================ FILE: examples/snippets/src/stages-1/game.js ================================================ function militia({ G, events }) { events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 }); } function discard({ G, ctx }) {} const game = { moves: { militia }, turn: { stages: { discard: { moves: { discard }, }, }, }, }; export default game; ================================================ FILE: examples/snippets/src/stages-1/index.html ================================================
    ================================================ FILE: examples/snippets/src/stages-1/index.js ================================================ import App from './App.svelte'; const app = new App({ target: document.getElementById('app'), props: { name: 'world', }, }); export default app; ================================================ FILE: integration/.gitignore ================================================ build/ package-lock.json ================================================ FILE: integration/README.md ================================================ This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). Below you will find some information on how to perform common tasks.
    You can find the most recent version of this guide [here](https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md). ## Table of Contents * [Updating to New Releases](#updating-to-new-releases) * [Sending Feedback](#sending-feedback) * [Folder Structure](#folder-structure) * [Available Scripts](#available-scripts) * [npm start](#npm-start) * [npm test](#npm-test) * [npm run build](#npm-run-build) * [npm run eject](#npm-run-eject) * [Supported Browsers](#supported-browsers) * [Supported Language Features](#supported-language-features) * [Syntax Highlighting in the Editor](#syntax-highlighting-in-the-editor) * [Displaying Lint Output in the Editor](#displaying-lint-output-in-the-editor) * [Debugging in the Editor](#debugging-in-the-editor) * [Formatting Code Automatically](#formatting-code-automatically) * [Changing the Page ``](#changing-the-page-title) * [Installing a Dependency](#installing-a-dependency) * [Importing a Component](#importing-a-component) * [Code Splitting](#code-splitting) * [Adding a Stylesheet](#adding-a-stylesheet) * [Adding a CSS Modules Stylesheet](#adding-a-css-modules-stylesheet) * [Adding a Sass Stylesheet](#adding-a-sass-stylesheet) * [Post-Processing CSS](#post-processing-css) * [Adding Images, Fonts, and Files](#adding-images-fonts-and-files) * [Adding SVGs](#adding-svgs) * [Using the `public` Folder](#using-the-public-folder) * [Changing the HTML](#changing-the-html) * [Adding Assets Outside of the Module System](#adding-assets-outside-of-the-module-system) * [When to Use the `public` Folder](#when-to-use-the-public-folder) * [Using Global Variables](#using-global-variables) * [Adding Bootstrap](#adding-bootstrap) * [Using a Custom Theme](#using-a-custom-theme) * [Adding Flow](#adding-flow) * [Adding a Router](#adding-a-router) * [Adding Custom Environment Variables](#adding-custom-environment-variables) * [Referencing Environment Variables in the HTML](#referencing-environment-variables-in-the-html) * [Adding Temporary Environment Variables In Your Shell](#adding-temporary-environment-variables-in-your-shell) * [Adding Development Environment Variables In `.env`](#adding-development-environment-variables-in-env) * [Can I Use Decorators?](#can-i-use-decorators) * [Fetching Data with AJAX Requests](#fetching-data-with-ajax-requests) * [Integrating with an API Backend](#integrating-with-an-api-backend) * [Node](#node) * [Ruby on Rails](#ruby-on-rails) * [Proxying API Requests in Development](#proxying-api-requests-in-development) * ["Invalid Host Header" Errors After Configuring Proxy](#invalid-host-header-errors-after-configuring-proxy) * [Configuring the Proxy Manually](#configuring-the-proxy-manually) * [Using HTTPS in Development](#using-https-in-development) * [Generating Dynamic `<meta>` Tags on the Server](#generating-dynamic-meta-tags-on-the-server) * [Pre-Rendering into Static HTML Files](#pre-rendering-into-static-html-files) * [Injecting Data from the Server into the Page](#injecting-data-from-the-server-into-the-page) * [Running Tests](#running-tests) * [Filename Conventions](#filename-conventions) * [Command Line Interface](#command-line-interface) * [Version Control Integration](#version-control-integration) * [Writing Tests](#writing-tests) * [Testing Components](#testing-components) * [Using Third Party Assertion Libraries](#using-third-party-assertion-libraries) * [Initializing Test Environment](#initializing-test-environment) * [Focusing and Excluding Tests](#focusing-and-excluding-tests) * [Coverage Reporting](#coverage-reporting) * [Continuous Integration](#continuous-integration) * [Disabling jsdom](#disabling-jsdom) * [Snapshot Testing](#snapshot-testing) * [Editor Integration](#editor-integration) * [Debugging Tests](#debugging-tests) * [Debugging Tests in Chrome](#debugging-tests-in-chrome) * [Debugging Tests in Visual Studio Code](#debugging-tests-in-visual-studio-code) * [Developing Components in Isolation](#developing-components-in-isolation) * [Getting Started with Storybook](#getting-started-with-storybook) * [Getting Started with Styleguidist](#getting-started-with-styleguidist) * [Publishing Components to npm](#publishing-components-to-npm) * [Making a Progressive Web App](#making-a-progressive-web-app) * [Why Opt-in?](#why-opt-in) * [Offline-First Considerations](#offline-first-considerations) * [Progressive Web App Metadata](#progressive-web-app-metadata) * [Analyzing the Bundle Size](#analyzing-the-bundle-size) * [Deployment](#deployment) * [Static Server](#static-server) * [Other Solutions](#other-solutions) * [Serving Apps with Client-Side Routing](#serving-apps-with-client-side-routing) * [Building for Relative Paths](#building-for-relative-paths) * [Customizing Environment Variables for Arbitrary Build Environments](#customizing-environment-variables-for-arbitrary-build-environments) * [Azure](#azure) * [Firebase](#firebase) * [GitHub Pages](#github-pages) * [Heroku](#heroku) * [Netlify](#netlify) * [Now](#now) * [S3 and CloudFront](#s3-and-cloudfront) * [Surge](#surge) * [Advanced Configuration](#advanced-configuration) * [Troubleshooting](#troubleshooting) * [`npm start` doesn’t detect changes](#npm-start-doesnt-detect-changes) * [`npm test` hangs or crashes on macOS Sierra](#npm-test-hangs-or-crashes-on-macos-sierra) * [`npm run build` exits too early](#npm-run-build-exits-too-early) * [`npm run build` fails on Heroku](#npm-run-build-fails-on-heroku) * [`npm run build` fails to minify](#npm-run-build-fails-to-minify) * [Moment.js locales are missing](#momentjs-locales-are-missing) * [Alternatives to Ejecting](#alternatives-to-ejecting) * [Something Missing?](#something-missing) ## Updating to New Releases Create React App is divided into two packages: * `create-react-app` is a global command-line utility that you use to create new projects. * `react-scripts` is a development dependency in the generated projects (including this one). You almost never need to update `create-react-app` itself: it delegates all the setup to `react-scripts`. When you run `create-react-app`, it always creates the project with the latest version of `react-scripts` so you’ll get all the new features and improvements in newly created apps automatically. To update an existing project to a new version of `react-scripts`, [open the changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md), find the version you’re currently on (check `package.json` in this folder if you’re not sure), and apply the migration instructions for the newer versions. In most cases bumping the `react-scripts` version in `package.json` and running `npm install` (or `yarn install`) in this folder should be enough, but it’s good to consult the [changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md) for potential breaking changes. We commit to keeping the breaking changes minimal so you can upgrade `react-scripts` painlessly. ## Sending Feedback We are always open to [your feedback](https://github.com/facebook/create-react-app/issues). ## Folder Structure After creation, your project should look like this: ``` my-app/ README.md node_modules/ package.json public/ index.html favicon.ico src/ App.css App.js App.test.js index.css index.js logo.svg ``` For the project to build, **these files must exist with exact filenames**: * `public/index.html` is the page template; * `src/index.js` is the JavaScript entry point. You can delete or rename the other files. You may create subdirectories inside `src`. For faster rebuilds, only files inside `src` are processed by Webpack.<br> You need to **put any JS and CSS files inside `src`**, otherwise Webpack won’t see them. Only files inside `public` can be used from `public/index.html`.<br> Read instructions below for using assets from JavaScript and HTML. You can, however, create more top-level directories.<br> They will not be included in the production build so you can use them for things like documentation. ## Available Scripts In the project directory, you can run: ### `npm start` Runs the app in the development mode.<br> Open [http://localhost:3000](http://localhost:3000) to view it in the browser. The page will reload if you make edits.<br> You will also see any lint errors in the console. ### `npm test` Launches the test runner in the interactive watch mode.<br> See the section about [running tests](#running-tests) for more information. ### `npm run build` Builds the app for production to the `build` folder.<br> It correctly bundles React in production mode and optimizes the build for the best performance. The build is minified and the filenames include the hashes.<br> Your app is ready to be deployed! See the section about [deployment](#deployment) for more information. ### `npm run eject` **Note: this is a one-way operation. Once you `eject`, you can’t go back!** If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. ## Supported Browsers By default, the generated project supports all modern browsers.<br> Support for Internet Explorer 9, 10, and 11 requires [polyfills](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md). ### Supported Language Features This project supports a superset of the latest JavaScript standard.<br> In addition to [ES6](https://github.com/lukehoban/es6features) syntax features, it also supports: * [Exponentiation Operator](https://github.com/rwaldron/exponentiation-operator) (ES2016). * [Async/await](https://github.com/tc39/ecmascript-asyncawait) (ES2017). * [Object Rest/Spread Properties](https://github.com/tc39/proposal-object-rest-spread) (ES2018). * [Dynamic import()](https://github.com/tc39/proposal-dynamic-import) (stage 3 proposal) * [Class Fields and Static Properties](https://github.com/tc39/proposal-class-public-fields) (part of stage 3 proposal). * [JSX](https://facebook.github.io/react/docs/introducing-jsx.html) and [Flow](https://flow.org/) syntax. Learn more about [different proposal stages](https://babeljs.io/docs/plugins/#presets-stage-x-experimental-presets-). While we recommend using experimental proposals with some caution, Facebook heavily uses these features in the product code, so we intend to provide [codemods](https://medium.com/@cpojer/effective-javascript-codemods-5a6686bb46fb) if any of these proposals change in the future. Note that **this project includes no [polyfills](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md)** by default. If you use any other ES6+ features that need **runtime support** (such as `Array.from()` or `Symbol`), make sure you are [including the appropriate polyfills manually](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md), or that the browsers you are targeting already support them. ## Syntax Highlighting in the Editor To configure the syntax highlighting in your favorite text editor, head to the [relevant Babel documentation page](https://babeljs.io/docs/editors) and follow the instructions. Some of the most popular editors are covered. ## Displaying Lint Output in the Editor > Note: this feature is available with `react-scripts@0.2.0` and higher.<br> > It also only works with npm 3 or higher. Some editors, including Sublime Text, Atom, and Visual Studio Code, provide plugins for ESLint. They are not required for linting. You should see the linter output right in your terminal as well as the browser console. However, if you prefer the lint results to appear right in your editor, there are some extra steps you can do. You would need to install an ESLint plugin for your editor first. Then, add a file called `.eslintrc` to the project root: ```js { "extends": "react-app" } ``` Now your editor should report the linting warnings. Note that even if you edit your `.eslintrc` file further, these changes will **only affect the editor integration**. They won’t affect the terminal and in-browser lint output. This is because Create React App intentionally provides a minimal set of rules that find common mistakes. If you want to enforce a coding style for your project, consider using [Prettier](https://github.com/jlongster/prettier) instead of ESLint style rules. ## Debugging in the Editor **This feature is currently only supported by [Visual Studio Code](https://code.visualstudio.com) and [WebStorm](https://www.jetbrains.com/webstorm/).** Visual Studio Code and WebStorm support debugging out of the box with Create React App. This enables you as a developer to write and debug your React code without leaving the editor, and most importantly it enables you to have a continuous development workflow, where context switching is minimal, as you don’t have to switch between tools. ### Visual Studio Code You would need to have the latest version of [VS Code](https://code.visualstudio.com) and VS Code [Chrome Debugger Extension](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) installed. Then add the block below to your `launch.json` file and put it inside the `.vscode` folder in your app’s root directory. ```json { "version": "0.2.0", "configurations": [ { "name": "Chrome", "type": "chrome", "request": "launch", "url": "http://localhost:3000", "webRoot": "${workspaceRoot}/src", "sourceMapPathOverrides": { "webpack:///src/*": "${webRoot}/*" } } ] } ``` > Note: the URL may be different if you've made adjustments via the [HOST or PORT environment variables](#advanced-configuration). Start your app by running `npm start`, and start debugging in VS Code by pressing `F5` or by clicking the green debug icon. You can now write code, set breakpoints, make changes to the code, and debug your newly modified code—all from your editor. Having problems with VS Code Debugging? Please see their [troubleshooting guide](https://github.com/Microsoft/vscode-chrome-debug/blob/master/README.md#troubleshooting). ### WebStorm You would need to have [WebStorm](https://www.jetbrains.com/webstorm/) and [JetBrains IDE Support](https://chrome.google.com/webstore/detail/jetbrains-ide-support/hmhgeddbohgjknpmjagkdomcpobmllji) Chrome extension installed. In the WebStorm menu `Run` select `Edit Configurations...`. Then click `+` and select `JavaScript Debug`. Paste `http://localhost:3000` into the URL field and save the configuration. > Note: the URL may be different if you've made adjustments via the [HOST or PORT environment variables](#advanced-configuration). Start your app by running `npm start`, then press `^D` on macOS or `F9` on Windows and Linux or click the green debug icon to start debugging in WebStorm. The same way you can debug your application in IntelliJ IDEA Ultimate, PhpStorm, PyCharm Pro, and RubyMine. ## Formatting Code Automatically Prettier is an opinionated code formatter with support for JavaScript, CSS and JSON. With Prettier you can format the code you write automatically to ensure a code style within your project. See the [Prettier's GitHub page](https://github.com/prettier/prettier) for more information, and look at this [page to see it in action](https://prettier.github.io/prettier/). To format our code whenever we make a commit in git, we need to install the following dependencies: ```sh npm install --save husky lint-staged prettier ``` Alternatively you may use `yarn`: ```sh yarn add husky lint-staged prettier ``` * `husky` makes it easy to use githooks as if they are npm scripts. * `lint-staged` allows us to run scripts on staged files in git. See this [blog post about lint-staged to learn more about it](https://medium.com/@okonetchnikov/make-linting-great-again-f3890e1ad6b8). * `prettier` is the JavaScript formatter we will run before commits. Now we can make sure every file is formatted correctly by adding a few lines to the `package.json` in the project root. Add the following field to the `package.json` section: ```diff + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + } ``` Next we add a 'lint-staged' field to the `package.json`, for example: ```diff "dependencies": { // ... }, + "lint-staged": { + "src/**/*.{js,jsx,json,css}": [ + "prettier --single-quote --write", + "git add" + ] + }, "scripts": { ``` Now, whenever you make a commit, Prettier will format the changed files automatically. You can also run `./node_modules/.bin/prettier --single-quote --write "src/**/*.{js,jsx}"` to format your entire project for the first time. Next you might want to integrate Prettier in your favorite editor. Read the section on [Editor Integration](https://prettier.io/docs/en/editors.html) on the Prettier GitHub page. ## Changing the Page `<title>` You can find the source HTML file in the `public` folder of the generated project. You may edit the `<title>` tag in it to change the title from “React App” to anything else. Note that normally you wouldn’t edit files in the `public` folder very often. For example, [adding a stylesheet](#adding-a-stylesheet) is done without touching the HTML. If you need to dynamically update the page title based on the content, you can use the browser [`document.title`](https://developer.mozilla.org/en-US/docs/Web/API/Document/title) API. For more complex scenarios when you want to change the title from React components, you can use [React Helmet](https://github.com/nfl/react-helmet), a third party library. If you use a custom server for your app in production and want to modify the title before it gets sent to the browser, you can follow advice in [this section](#generating-dynamic-meta-tags-on-the-server). Alternatively, you can pre-build each page as a static HTML file which then loads the JavaScript bundle, which is covered [here](#pre-rendering-into-static-html-files). ## Installing a Dependency The generated project includes React and ReactDOM as dependencies. It also includes a set of scripts used by Create React App as a development dependency. You may install other dependencies (for example, React Router) with `npm`: ```sh npm install --save react-router-dom ``` Alternatively you may use `yarn`: ```sh yarn add react-router-dom ``` This works for any library, not just `react-router-dom`. ## Importing a Component This project setup supports ES6 modules thanks to Webpack.<br> While you can still use `require()` and `module.exports`, we encourage you to use [`import` and `export`](http://exploringjs.com/es6/ch_modules.html) instead. For example: ### `Button.js` ```js import React, { Component } from 'react'; class Button extends Component { render() { // ... } } export default Button; // Don’t forget to use export default! ``` ### `DangerButton.js` ```js import React, { Component } from 'react'; import Button from './Button'; // Import a component from another file class DangerButton extends Component { render() { return <Button color="red" />; } } export default DangerButton; ``` Be aware of the [difference between default and named exports](http://stackoverflow.com/questions/36795819/react-native-es-6-when-should-i-use-curly-braces-for-import/36796281#36796281). It is a common source of mistakes. We suggest that you stick to using default imports and exports when a module only exports a single thing (for example, a component). That’s what you get when you use `export default Button` and `import Button from './Button'`. Named exports are useful for utility modules that export several functions. A module may have at most one default export and as many named exports as you like. Learn more about ES6 modules: * [When to use the curly braces?](http://stackoverflow.com/questions/36795819/react-native-es-6-when-should-i-use-curly-braces-for-import/36796281#36796281) * [Exploring ES6: Modules](http://exploringjs.com/es6/ch_modules.html) * [Understanding ES6: Modules](https://leanpub.com/understandinges6/read#leanpub-auto-encapsulating-code-with-modules) ## Code Splitting Instead of downloading the entire app before users can use it, code splitting allows you to split your code into small chunks which you can then load on demand. This project setup supports code splitting via [dynamic `import()`](http://2ality.com/2017/01/import-operator.html#loading-code-on-demand). Its [proposal](https://github.com/tc39/proposal-dynamic-import) is in stage 3. The `import()` function-like form takes the module name as an argument and returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) which always resolves to the namespace object of the module. Here is an example: ### `moduleA.js` ```js const moduleA = 'Hello'; export { moduleA }; ``` ### `App.js` ```js import React, { Component } from 'react'; class App extends Component { handleClick = () => { import('./moduleA') .then(({ moduleA }) => { // Use moduleA }) .catch(err => { // Handle failure }); }; render() { return ( <div> <button onClick={this.handleClick}>Load</button> </div> ); } } export default App; ``` This will make `moduleA.js` and all its unique dependencies as a separate chunk that only loads after the user clicks the 'Load' button. You can also use it with `async` / `await` syntax if you prefer it. ### With React Router If you are using React Router check out [this tutorial](http://serverless-stack.com/chapters/code-splitting-in-create-react-app.html) on how to use code splitting with it. You can find the companion GitHub repository [here](https://github.com/AnomalyInnovations/serverless-stack-demo-client/tree/code-splitting-in-create-react-app). Also check out the [Code Splitting](https://reactjs.org/docs/code-splitting.html) section in React documentation. ## Adding a Stylesheet This project setup uses [Webpack](https://webpack.js.org/) for handling all assets. Webpack offers a custom way of “extending” the concept of `import` beyond JavaScript. To express that a JavaScript file depends on a CSS file, you need to **import the CSS from the JavaScript file**: ### `Button.css` ```css .Button { padding: 20px; } ``` ### `Button.js` ```js import React, { Component } from 'react'; import './Button.css'; // Tell Webpack that Button.js uses these styles class Button extends Component { render() { // You can use them as regular CSS styles return <div className="Button" />; } } ``` **This is not required for React** but many people find this feature convenient. You can read about the benefits of this approach [here](https://medium.com/seek-blog/block-element-modifying-your-javascript-components-d7f99fcab52b). However you should be aware that this makes your code less portable to other build tools and environments than Webpack. In development, expressing dependencies this way allows your styles to be reloaded on the fly as you edit them. In production, all CSS files will be concatenated into a single minified `.css` file in the build output. If you are concerned about using Webpack-specific semantics, you can put all your CSS right into `src/index.css`. It would still be imported from `src/index.js`, but you could always remove that import if you later migrate to a different build tool. ## Adding a CSS Modules Stylesheet > Note: this feature is available with `react-scripts@2.0.0` and higher. This project supports [CSS Modules](https://github.com/css-modules/css-modules) alongside regular stylesheets using the `[name].module.css` file naming convention. CSS Modules allows the scoping of CSS by automatically creating a unique classname of the format `[filename]\_[classname]\_\_[hash]`. > **Tip:** Should you want to preprocess a stylesheet with Sass then make sure to [follow the installation instructions](#adding-a-sass-stylesheet) and then change the stylesheet file extension as follows: `[name].module.scss` or `[name].module.sass`. CSS Modules let you use the same CSS class name in different files without worrying about naming clashes. Learn more about CSS Modules [here](https://css-tricks.com/css-modules-part-1-need/). ### `Button.module.css` ```css .error { background-color: red; } ``` ### `another-stylesheet.css` ```css .error { color: red; } ``` ### `Button.js` ```js import React, { Component } from 'react'; import styles from './Button.module.css'; // Import css modules stylesheet as styles import './another-stylesheet.css'; // Import regular stylesheet class Button extends Component { render() { // reference as a js object return <button className={styles.error}>Error Button</button>; } } ``` ### Result No clashes from other `.error` class names ```html <!-- This button has red background but not red text --> <button class="Button_error_ax7yz"></div> ``` **This is an optional feature.** Regular `<link>` stylesheets and CSS files are fully supported. CSS Modules are turned on for files ending with the `.module.css` extension. ## Adding a Sass Stylesheet > Note: this feature is available with `react-scripts@2.0.0` and higher. Generally, we recommend that you don’t reuse the same CSS classes across different components. For example, instead of using a `.Button` CSS class in `<AcceptButton>` and `<RejectButton>` components, we recommend creating a `<Button>` component with its own `.Button` styles, that both `<AcceptButton>` and `<RejectButton>` can render (but [not inherit](https://facebook.github.io/react/docs/composition-vs-inheritance.html)). Following this rule often makes CSS preprocessors less useful, as features like mixins and nesting are replaced by component composition. You can, however, integrate a CSS preprocessor if you find it valuable. To use Sass, first install `node-sass`: ```bash $ npm install node-sass --save $ # or $ yarn add node-sass ``` Now you can rename `src/App.css` to `src/App.scss` and update `src/App.js` to import `src/App.scss`. This file and any other file will be automatically compiled if imported with the extension `.scss` or `.sass`. To share variables between Sass files, you can use Sass imports. For example, `src/App.scss` and other component style files could include `@import "./shared.scss";` with variable definitions. This will allow you to do imports like ```scss @import 'styles/_colors.scss'; // assuming a styles directory under src/ @import '~nprogress/nprogress'; // importing a css file from the nprogress node module ``` > **Tip:** You can opt into using this feature with [CSS modules](#adding-a-css-modules-stylesheet) too! > **Note:** You must prefix imports from `node_modules` with `~` as displayed above. ## Post-Processing CSS This project setup minifies your CSS and adds vendor prefixes to it automatically through [Autoprefixer](https://github.com/postcss/autoprefixer) so you don’t need to worry about it. Support for new CSS features like the [`all` property](https://developer.mozilla.org/en-US/docs/Web/CSS/all), [`break` properties](https://www.w3.org/TR/css-break-3/#breaking-controls), [custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_variables), and [media query ranges](https://www.w3.org/TR/mediaqueries-4/#range-context) are automatically polyfilled to add support for older browsers. You can customize your target support browsers by adjusting the `browserslist` key in `package.json` accoring to the [Browserslist specification](https://github.com/browserslist/browserslist#readme). For example, this: ```css .App { display: flex; flex-direction: row; align-items: center; } ``` becomes this: ```css .App { display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; -ms-flex-direction: row; flex-direction: row; -webkit-box-align: center; -ms-flex-align: center; align-items: center; } ``` If you need to disable autoprefixing for some reason, [follow this section](https://github.com/postcss/autoprefixer#disabling). [CSS Grid Layout](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout) prefixing is disabled by default, but it will **not** strip manual prefixing. If you'd like to opt-in to CSS Grid prefixing, [first familiarize yourself about its limitations](https://github.com/postcss/autoprefixer#does-autoprefixer-polyfill-grid-layout-for-ie).<br> To enable CSS Grid prefixing, add `/* autoprefixer grid: on */` to the top of your CSS file. ## Adding Images, Fonts, and Files With Webpack, using static assets like images and fonts works similarly to CSS. You can **`import` a file right in a JavaScript module**. This tells Webpack to include that file in the bundle. Unlike CSS imports, importing a file gives you a string value. This value is the final path you can reference in your code, e.g. as the `src` attribute of an image or the `href` of a link to a PDF. To reduce the number of requests to the server, importing images that are less than 10,000 bytes returns a [data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) instead of a path. This applies to the following file extensions: bmp, gif, jpg, jpeg, and png. SVG files are excluded due to [#1153](https://github.com/facebook/create-react-app/issues/1153). Here is an example: ```js import React from 'react'; import logo from './logo.png'; // Tell Webpack this JS file uses this image console.log(logo); // /logo.84287d09.png function Header() { // Import result is the URL of your image return <img src={logo} alt="Logo" />; } export default Header; ``` This ensures that when the project is built, Webpack will correctly move the images into the build folder, and provide us with correct paths. This works in CSS too: ```css .Logo { background-image: url(./logo.png); } ``` Webpack finds all relative module references in CSS (they start with `./`) and replaces them with the final paths from the compiled bundle. If you make a typo or accidentally delete an important file, you will see a compilation error, just like when you import a non-existent JavaScript module. The final filenames in the compiled bundle are generated by Webpack from content hashes. If the file content changes in the future, Webpack will give it a different name in production so you don’t need to worry about long-term caching of assets. Please be advised that this is also a custom feature of Webpack. **It is not required for React** but many people enjoy it (and React Native uses a similar mechanism for images).<br> An alternative way of handling static assets is described in the next section. ### Adding SVGs > Note: this feature is available with `react-scripts@2.0.0` and higher. One way to add SVG files was described in the section above. You can also import SVGs directly as React components. You can use either of the two approaches. In your code it would look like this: ```js import { ReactComponent as Logo } from './logo.svg'; const App = () => ( <div> {/* Logo is an actual React component */} <Logo /> </div> ); ``` This is handy if you don't want to load SVG as a separate file. Don't forget the curly braces in the import! The `ReactComponent` import name is special and tells Create React App that you want a React component that renders an SVG, rather than its filename. ## Using the `public` Folder > Note: this feature is available with `react-scripts@0.5.0` and higher. ### Changing the HTML The `public` folder contains the HTML file so you can tweak it, for example, to [set the page title](#changing-the-page-title). The `<script>` tag with the compiled code will be added to it automatically during the build process. ### Adding Assets Outside of the Module System You can also add other assets to the `public` folder. Note that we normally encourage you to `import` assets in JavaScript files instead. For example, see the sections on [adding a stylesheet](#adding-a-stylesheet) and [adding images and fonts](#adding-images-fonts-and-files). This mechanism provides a number of benefits: * Scripts and stylesheets get minified and bundled together to avoid extra network requests. * Missing files cause compilation errors instead of 404 errors for your users. * Result filenames include content hashes so you don’t need to worry about browsers caching their old versions. However there is an **escape hatch** that you can use to add an asset outside of the module system. If you put a file into the `public` folder, it will **not** be processed by Webpack. Instead it will be copied into the build folder untouched. To reference assets in the `public` folder, you need to use a special variable called `PUBLIC_URL`. Inside `index.html`, you can use it like this: ```html <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> ``` Only files inside the `public` folder will be accessible by `%PUBLIC_URL%` prefix. If you need to use a file from `src` or `node_modules`, you’ll have to copy it there to explicitly specify your intention to make this file a part of the build. When you run `npm run build`, Create React App will substitute `%PUBLIC_URL%` with a correct absolute path so your project works even if you use client-side routing or host it at a non-root URL. In JavaScript code, you can use `process.env.PUBLIC_URL` for similar purposes: ```js render() { // Note: this is an escape hatch and should be used sparingly! // Normally we recommend using `import` for getting asset URLs // as described in “Adding Images and Fonts” above this section. return <img src={process.env.PUBLIC_URL + '/img/logo.png'} />; } ``` Keep in mind the downsides of this approach: * None of the files in `public` folder get post-processed or minified. * Missing files will not be called at compilation time, and will cause 404 errors for your users. * Result filenames won’t include content hashes so you’ll need to add query arguments or rename them every time they change. ### When to Use the `public` Folder Normally we recommend importing [stylesheets](#adding-a-stylesheet), [images, and fonts](#adding-images-fonts-and-files) from JavaScript. The `public` folder is useful as a workaround for a number of less common cases: * You need a file with a specific name in the build output, such as [`manifest.webmanifest`](https://developer.mozilla.org/en-US/docs/Web/Manifest). * You have thousands of images and need to dynamically reference their paths. * You want to include a small script like [`pace.js`](http://github.hubspot.com/pace/docs/welcome/) outside of the bundled code. * Some library may be incompatible with Webpack and you have no other option but to include it as a `<script>` tag. Note that if you add a `<script>` that declares global variables, you also need to read the next section on using them. ## Using Global Variables When you include a script in the HTML file that defines global variables and try to use one of these variables in the code, the linter will complain because it cannot see the definition of the variable. You can avoid this by reading the global variable explicitly from the `window` object, for example: ```js const $ = window.$; ``` This makes it obvious you are using a global variable intentionally rather than because of a typo. Alternatively, you can force the linter to ignore any line by adding `// eslint-disable-line` after it. ## Adding Bootstrap You don’t have to use [reactstrap](https://reactstrap.github.io/) together with React but it is a popular library for integrating Bootstrap with React apps. If you need it, you can integrate it with Create React App by following these steps: Install reactstrap and Bootstrap from npm. reactstrap does not include Bootstrap CSS so this needs to be installed as well: ```sh npm install --save reactstrap bootstrap@4 ``` Alternatively you may use `yarn`: ```sh yarn add bootstrap@4 reactstrap ``` Import Bootstrap CSS and optionally Bootstrap theme CSS in the beginning of your `src/index.js` file: ```js import 'bootstrap/dist/css/bootstrap.css'; // Put any other imports below so that CSS from your // components takes precedence over default styles. ``` Import required reactstrap components within `src/App.js` file or your custom component files: ```js import { Button } from 'reactstrap'; ``` Now you are ready to use the imported reactstrap components within your component hierarchy defined in the render method. Here is an example [`App.js`](https://gist.githubusercontent.com/zx6658/d9f128cd57ca69e583ea2b5fea074238/raw/a56701c142d0c622eb6c20a457fbc01d708cb485/App.js) redone using reactstrap. ### Using a Custom Theme > Note: this feature is available with `react-scripts@2.0.0` and higher. Sometimes you might need to tweak the visual styles of Bootstrap (or equivalent package).<br> As of `react-scripts@2.0.0` you can import `.scss` files. This makes it possible to use a package's built-in Sass variables for global style preferences. To customize Bootstrap, create a file called `src/custom.scss` (or similar) and import the Bootstrap source stylesheet. Add any overrides _before_ the imported file(s). You can reference [Bootstrap's documentation](http://getbootstrap.com/docs/4.1/getting-started/theming/#css-variables) for the names of the available variables. ```scss // Override default variables before the import $body-bg: #000; // Import Bootstrap and its default variables @import '~bootstrap/scss/bootstrap.scss'; ``` > **Note:** You must prefix imports from `node_modules` with `~` as displayed above. Finally, import the newly created `.scss` file instead of the default Bootstrap `.css` in the beginning of your `src/index.js` file, for example: ```javascript import './custom.scss'; ``` ## Adding Flow Flow is a static type checker that helps you write code with fewer bugs. Check out this [introduction to using static types in JavaScript](https://medium.com/@preethikasireddy/why-use-static-types-in-javascript-part-1-8382da1e0adb) if you are new to this concept. Recent versions of [Flow](https://flow.org/) work with Create React App projects out of the box. To add Flow to a Create React App project, follow these steps: 1. Run `npm install --save flow-bin` (or `yarn add flow-bin`). 2. Add `"flow": "flow"` to the `scripts` section of your `package.json`. 3. Run `npm run flow init` (or `yarn flow init`) to create a [`.flowconfig` file](https://flow.org/en/docs/config/) in the root directory. 4. Add `// @flow` to any files you want to type check (for example, to `src/App.js`). Now you can run `npm run flow` (or `yarn flow`) to check the files for type errors. You can optionally use an IDE like [Nuclide](https://nuclide.io/docs/languages/flow/) for a better integrated experience. In the future we plan to integrate it into Create React App even more closely. To learn more about Flow, check out [its documentation](https://flow.org/). ## Adding a Router Create React App doesn't prescribe a specific routing solution, but [React Router](https://reacttraining.com/react-router/web/) is the most popular one. To add it, run: ```sh npm install --save react-router-dom ``` Alternatively you may use `yarn`: ```sh yarn add react-router-dom ``` To try it, delete all the code in `src/App.js` and replace it with any of the examples on its website. The [Basic Example](https://reacttraining.com/react-router/web/example/basic) is a good place to get started. Note that [you may need to configure your production server to support client-side routing](#serving-apps-with-client-side-routing) before deploying your app. ## Adding Custom Environment Variables > Note: this feature is available with `react-scripts@0.2.3` and higher. Your project can consume variables declared in your environment as if they were declared locally in your JS files. By default you will have `NODE_ENV` defined for you, and any other environment variables starting with `REACT_APP_`. **The environment variables are embedded during the build time**. Since Create React App produces a static HTML/CSS/JS bundle, it can’t possibly read them at runtime. To read them at runtime, you would need to load HTML into memory on the server and replace placeholders in runtime, just like [described here](#injecting-data-from-the-server-into-the-page). Alternatively you can rebuild the app on the server anytime you change them. > Note: You must create custom environment variables beginning with `REACT_APP_`. Any other variables except `NODE_ENV` will be ignored to avoid accidentally [exposing a private key on the machine that could have the same name](https://github.com/facebook/create-react-app/issues/865#issuecomment-252199527). Changing any environment variables will require you to restart the development server if it is running. These environment variables will be defined for you on `process.env`. For example, having an environment variable named `REACT_APP_SECRET_CODE` will be exposed in your JS as `process.env.REACT_APP_SECRET_CODE`. There is also a special built-in environment variable called `NODE_ENV`. You can read it from `process.env.NODE_ENV`. When you run `npm start`, it is always equal to `'development'`, when you run `npm test` it is always equal to `'test'`, and when you run `npm run build` to make a production bundle, it is always equal to `'production'`. **You cannot override `NODE_ENV` manually.** This prevents developers from accidentally deploying a slow development build to production. These environment variables can be useful for displaying information conditionally based on where the project is deployed or consuming sensitive data that lives outside of version control. First, you need to have environment variables defined. For example, let’s say you wanted to consume a secret defined in the environment inside a `<form>`: ```jsx render() { return ( <div> <small>You are running this application in <b>{process.env.NODE_ENV}</b> mode.</small> <form> <input type="hidden" defaultValue={process.env.REACT_APP_SECRET_CODE} /> </form> </div> ); } ``` During the build, `process.env.REACT_APP_SECRET_CODE` will be replaced with the current value of the `REACT_APP_SECRET_CODE` environment variable. Remember that the `NODE_ENV` variable will be set for you automatically. When you load the app in the browser and inspect the `<input>`, you will see its value set to `abcdef`, and the bold text will show the environment provided when using `npm start`: ```html <div> <small>You are running this application in <b>development</b> mode.</small> <form> <input type="hidden" value="abcdef" /> </form> </div> ``` The above form is looking for a variable called `REACT_APP_SECRET_CODE` from the environment. In order to consume this value, we need to have it defined in the environment. This can be done using two ways: either in your shell or in a `.env` file. Both of these ways are described in the next few sections. Having access to the `NODE_ENV` is also useful for performing actions conditionally: ```js if (process.env.NODE_ENV !== 'production') { analytics.disable(); } ``` When you compile the app with `npm run build`, the minification step will strip out this condition, and the resulting bundle will be smaller. ### Referencing Environment Variables in the HTML > Note: this feature is available with `react-scripts@0.9.0` and higher. You can also access the environment variables starting with `REACT_APP_` in the `public/index.html`. For example: ```html <title>%REACT_APP_WEBSITE_NAME% ``` Note that the caveats from the above section apply: * Apart from a few built-in variables (`NODE_ENV` and `PUBLIC_URL`), variable names must start with `REACT_APP_` to work. * The environment variables are injected at build time. If you need to inject them at runtime, [follow this approach instead](#generating-dynamic-meta-tags-on-the-server). ### Adding Temporary Environment Variables In Your Shell Defining environment variables can vary between OSes. It’s also important to know that this manner is temporary for the life of the shell session. #### Windows (cmd.exe) ```cmd set "REACT_APP_SECRET_CODE=abcdef" && npm start ``` (Note: Quotes around the variable assignment are required to avoid a trailing whitespace.) #### Windows (Powershell) ```Powershell ($env:REACT_APP_SECRET_CODE = "abcdef") -and (npm start) ``` #### Linux, macOS (Bash) ```bash REACT_APP_SECRET_CODE=abcdef npm start ``` ### Adding Development Environment Variables In `.env` > Note: this feature is available with `react-scripts@0.5.0` and higher. To define permanent environment variables, create a file called `.env` in the root of your project: ``` REACT_APP_SECRET_CODE=abcdef ``` > Note: You must create custom environment variables beginning with `REACT_APP_`. Any other variables except `NODE_ENV` will be ignored to avoid [accidentally exposing a private key on the machine that could have the same name](https://github.com/facebook/create-react-app/issues/865#issuecomment-252199527). Changing any environment variables will require you to restart the development server if it is running. `.env` files **should be** checked into source control (with the exclusion of `.env*.local`). #### What other `.env` files can be used? > Note: this feature is **available with `react-scripts@1.0.0` and higher**. * `.env`: Default. * `.env.local`: Local overrides. **This file is loaded for all environments except test.** * `.env.development`, `.env.test`, `.env.production`: Environment-specific settings. * `.env.development.local`, `.env.test.local`, `.env.production.local`: Local overrides of environment-specific settings. Files on the left have more priority than files on the right: * `npm start`: `.env.development.local`, `.env.development`, `.env.local`, `.env` * `npm run build`: `.env.production.local`, `.env.production`, `.env.local`, `.env` * `npm test`: `.env.test.local`, `.env.test`, `.env` (note `.env.local` is missing) These variables will act as the defaults if the machine does not explicitly set them.
    Please refer to the [dotenv documentation](https://github.com/motdotla/dotenv) for more details. > Note: If you are defining environment variables for development, your CI and/or hosting platform will most likely need > these defined as well. Consult their documentation how to do this. For example, see the documentation for [Travis CI](https://docs.travis-ci.com/user/environment-variables/) or [Heroku](https://devcenter.heroku.com/articles/config-vars). #### Expanding Environment Variables In `.env` > Note: this feature is available with `react-scripts@1.1.0` and higher. Expand variables already on your machine for use in your `.env` file (using [dotenv-expand](https://github.com/motdotla/dotenv-expand)). For example, to get the environment variable `npm_package_version`: ``` REACT_APP_VERSION=$npm_package_version # also works: # REACT_APP_VERSION=${npm_package_version} ``` Or expand variables local to the current `.env` file: ``` DOMAIN=www.example.com REACT_APP_FOO=$DOMAIN/foo REACT_APP_BAR=$DOMAIN/bar ``` ## Can I Use Decorators? Some popular libraries use [decorators](https://medium.com/google-developers/exploring-es7-decorators-76ecb65fb841) in their documentation.
    Create React App intentionally doesn’t support decorator syntax at the moment because: * It is an experimental proposal and is subject to change (in fact, it has already changed once, and will change again). * Most libraries currently support only the old version of the proposal — which will never be a standard. However in many cases you can rewrite decorator-based code without decorators just as fine.
    Please refer to these two threads for reference: * [#214](https://github.com/facebook/create-react-app/issues/214) * [#411](https://github.com/facebook/create-react-app/issues/411) Create React App will add decorator support when the specification advances to a stable stage. ## Fetching Data with AJAX Requests React doesn't prescribe a specific approach to data fetching, but people commonly use either a library like [axios](https://github.com/axios/axios) or the [`fetch()` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) provided by the browser. The global `fetch` function allows you to easily make AJAX requests. It takes in a URL as an input and returns a `Promise` that resolves to a `Response` object. You can find more information about `fetch` [here](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). A Promise represents the eventual result of an asynchronous operation, you can find more information about Promises [here](https://www.promisejs.org/) and [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). Both axios and `fetch()` use Promises under the hood. You can also use the [`async / await`](https://davidwalsh.name/async-await) syntax to reduce the callback nesting. Make sure the [`fetch()` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) are available in your target audience's browsers. For example, support in Internet Explorer requires a [polyfill](https://github.com/facebook/create-react-app/blob/master/packages/react-app-polyfill/README.md). You can learn more about making AJAX requests from React components in [the FAQ entry on the React website](https://reactjs.org/docs/faq-ajax.html). ## Integrating with an API Backend These tutorials will help you to integrate your app with an API backend running on another port, using `fetch()` to access it. ### Node Check out [this tutorial](https://www.fullstackreact.com/articles/using-create-react-app-with-a-server/). You can find the companion GitHub repository [here](https://github.com/fullstackreact/food-lookup-demo). ### Ruby on Rails Check out [this tutorial](https://www.fullstackreact.com/articles/how-to-get-create-react-app-to-work-with-your-rails-api/). You can find the companion GitHub repository [here](https://github.com/fullstackreact/food-lookup-demo-rails). ### API Platform (PHP and Symfony) [API Platform](https://api-platform.com) is a framework designed to build API-driven projects. It allows to create hypermedia and GraphQL APIs in minutes. It is shipped with an official Progressive Web App generator as well as a dynamic administration interface, both built for Create React App. Check out [this tutorial](https://api-platform.com/docs/distribution). ## Proxying API Requests in Development > Note: this feature is available with `react-scripts@0.2.3` and higher. People often serve the front-end React app from the same host and port as their backend implementation.
    For example, a production setup might look like this after the app is deployed: ``` / - static server returns index.html with React app /todos - static server returns index.html with React app /api/todos - server handles any /api/* requests using the backend implementation ``` Such setup is **not** required. However, if you **do** have a setup like this, it is convenient to write requests like `fetch('/api/todos')` without worrying about redirecting them to another host or port during development. To tell the development server to proxy any unknown requests to your API server in development, add a `proxy` field to your `package.json`, for example: ```js "proxy": "http://localhost:4000", ``` This way, when you `fetch('/api/todos')` in development, the development server will recognize that it’s not a static asset, and will proxy your request to `http://localhost:4000/api/todos` as a fallback. The development server will **only** attempt to send requests without `text/html` in its `Accept` header to the proxy. Conveniently, this avoids [CORS issues](http://stackoverflow.com/questions/21854516/understanding-ajax-cors-and-security-considerations) and error messages like this in development: ``` Fetch API cannot load http://localhost:4000/api/todos. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:3000' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. ``` Keep in mind that `proxy` only has effect in development (with `npm start`), and it is up to you to ensure that URLs like `/api/todos` point to the right thing in production. You don’t have to use the `/api` prefix. Any unrecognized request without a `text/html` accept header will be redirected to the specified `proxy`. The `proxy` option supports HTTP, HTTPS and WebSocket connections.
    If the `proxy` option is **not** flexible enough for you, alternatively you can: * [Configure the proxy yourself](#configuring-the-proxy-manually) * Enable CORS on your server ([here’s how to do it for Express](http://enable-cors.org/server_expressjs.html)). * Use [environment variables](#adding-custom-environment-variables) to inject the right server host and port into your app. ### "Invalid Host Header" Errors After Configuring Proxy When you enable the `proxy` option, you opt into a more strict set of host checks. This is necessary because leaving the backend open to remote hosts makes your computer vulnerable to DNS rebinding attacks. The issue is explained in [this article](https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a) and [this issue](https://github.com/webpack/webpack-dev-server/issues/887). This shouldn’t affect you when developing on `localhost`, but if you develop remotely like [described here](https://github.com/facebook/create-react-app/issues/2271), you will see this error in the browser after enabling the `proxy` option: > Invalid Host header To work around it, you can specify your public development host in a file called `.env.development` in the root of your project: ``` HOST=mypublicdevhost.com ``` If you restart the development server now and load the app from the specified host, it should work. If you are still having issues or if you’re using a more exotic environment like a cloud editor, you can bypass the host check completely by adding a line to `.env.development.local`. **Note that this is dangerous and exposes your machine to remote code execution from malicious websites:** ``` # NOTE: THIS IS DANGEROUS! # It exposes your machine to attacks from the websites you visit. DANGEROUSLY_DISABLE_HOST_CHECK=true ``` We don’t recommend this approach. ### Configuring the Proxy Manually > Note: this feature is available with `react-scripts@2.0.0` and higher. If the `proxy` option is **not** flexible enough for you, you can get direct access to the Express app instance and hook up your own proxy middleware. You can use this feature in conjunction with the `proxy` property in `package.json`, but it is recommended you consolidate all of your logic into `src/setupProxy.js`. First, install `http-proxy-middleware` using npm or Yarn: ```bash $ npm install http-proxy-middleware --save $ # or $ yarn add http-proxy-middleware ``` Next, create `src/setupProxy.js` and place the following contents in it: ```js const proxy = require('http-proxy-middleware'); module.exports = function(app) { // ... }; ``` You can now register proxies as you wish! Here's an example using the above `http-proxy-middleware`: ```js const proxy = require('http-proxy-middleware'); module.exports = function(app) { app.use(proxy('/api', { target: 'http://localhost:5000/' })); }; ``` > **Note:** You do not need to import this file anywhere. It is automatically registered when you start the development server. > **Note:** This file only supports Node's JavaScript syntax. Be sure to only use supported language features (i.e. no support for Flow, ES Modules, etc). ## Using HTTPS in Development > Note: this feature is available with `react-scripts@0.4.0` and higher. You may require the dev server to serve pages over HTTPS. One particular case where this could be useful is when using [the "proxy" feature](#proxying-api-requests-in-development) to proxy requests to an API server when that API server is itself serving HTTPS. To do this, set the `HTTPS` environment variable to `true`, then start the dev server as usual with `npm start`: #### Windows (cmd.exe) ```cmd set HTTPS=true&&npm start ``` (Note: the lack of whitespace is intentional.) #### Windows (Powershell) ```Powershell ($env:HTTPS = $true) -and (npm start) ``` #### Linux, macOS (Bash) ```bash HTTPS=true npm start ``` Note that the server will use a self-signed certificate, so your web browser will almost definitely display a warning upon accessing the page. ## Generating Dynamic `` Tags on the Server Since Create React App doesn’t support server rendering, you might be wondering how to make `` tags dynamic and reflect the current URL. To solve this, we recommend to add placeholders into the HTML, like this: ```html ``` Then, on the server, regardless of the backend you use, you can read `index.html` into memory and replace `__OG_TITLE__`, `__OG_DESCRIPTION__`, and any other placeholders with values depending on the current URL. Just make sure to sanitize and escape the interpolated values so that they are safe to embed into HTML! If you use a Node server, you can even share the route matching logic between the client and the server. However duplicating it also works fine in simple cases. ## Pre-Rendering into Static HTML Files If you’re hosting your `build` with a static hosting provider you can use [react-snapshot](https://www.npmjs.com/package/react-snapshot) or [react-snap](https://github.com/stereobooster/react-snap) to generate HTML pages for each route, or relative link, in your application. These pages will then seamlessly become active, or “hydrated”, when the JavaScript bundle has loaded. There are also opportunities to use this outside of static hosting, to take the pressure off the server when generating and caching routes. The primary benefit of pre-rendering is that you get the core content of each page _with_ the HTML payload—regardless of whether or not your JavaScript bundle successfully downloads. It also increases the likelihood that each route of your application will be picked up by search engines. You can read more about [zero-configuration pre-rendering (also called snapshotting) here](https://medium.com/superhighfives/an-almost-static-stack-6df0a2791319). ## Injecting Data from the Server into the Page Similarly to the previous section, you can leave some placeholders in the HTML that inject global variables, for example: ```js ``` Then, on the server, you can replace `__SERVER_DATA__` with a JSON of real data right before sending the response. The client code can then read `window.SERVER_DATA` to use it. **Make sure to [sanitize the JSON before sending it to the client](https://medium.com/node-security/the-most-common-xss-vulnerability-in-react-js-applications-2bdffbcc1fa0) as it makes your app vulnerable to XSS attacks.** ## Running Tests > Note: this feature is available with `react-scripts@0.3.0` and higher.
    > [Read the migration guide to learn how to enable it in older projects!](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md#migrating-from-023-to-030) Create React App uses [Jest](https://facebook.github.io/jest/) as its test runner. To prepare for this integration, we did a [major revamp](https://facebook.github.io/jest/blog/2016/09/01/jest-15.html) of Jest so if you heard bad things about it years ago, give it another try. Jest is a Node-based runner. This means that the tests always run in a Node environment and not in a real browser. This lets us enable fast iteration speed and prevent flakiness. While Jest provides browser globals such as `window` thanks to [jsdom](https://github.com/tmpvar/jsdom), they are only approximations of the real browser behavior. Jest is intended to be used for unit tests of your logic and your components rather than the DOM quirks. We recommend that you use a separate tool for browser end-to-end tests if you need them. They are beyond the scope of Create React App. ### Filename Conventions Jest will look for test files with any of the following popular naming conventions: * Files with `.js` suffix in `__tests__` folders. * Files with `.test.js` suffix. * Files with `.spec.js` suffix. The `.test.js` / `.spec.js` files (or the `__tests__` folders) can be located at any depth under the `src` top level folder. We recommend to put the test files (or `__tests__` folders) next to the code they are testing so that relative imports appear shorter. For example, if `App.test.js` and `App.js` are in the same folder, the test just needs to `import App from './App'` instead of a long relative path. Colocation also helps find tests more quickly in larger projects. ### Command Line Interface When you run `npm test`, Jest will launch in the watch mode. Every time you save a file, it will re-run the tests, just like `npm start` recompiles the code. The watcher includes an interactive command-line interface with the ability to run all tests, or focus on a search pattern. It is designed this way so that you can keep it open and enjoy fast re-runs. You can learn the commands from the “Watch Usage” note that the watcher prints after every run: ![Jest watch mode](http://facebook.github.io/jest/img/blog/15-watch.gif) ### Version Control Integration By default, when you run `npm test`, Jest will only run the tests related to files changed since the last commit. This is an optimization designed to make your tests run fast regardless of how many tests you have. However it assumes that you don’t often commit the code that doesn’t pass the tests. Jest will always explicitly mention that it only ran tests related to the files changed since the last commit. You can also press `a` in the watch mode to force Jest to run all tests. Jest will always run all tests on a [continuous integration](#continuous-integration) server or if the project is not inside a Git or Mercurial repository. ### Writing Tests To create tests, add `it()` (or `test()`) blocks with the name of the test and its code. You may optionally wrap them in `describe()` blocks for logical grouping but this is neither required nor recommended. Jest provides a built-in `expect()` global function for making assertions. A basic test could look like this: ```js import sum from './sum'; it('sums numbers', () => { expect(sum(1, 2)).toEqual(3); expect(sum(2, 2)).toEqual(4); }); ``` All `expect()` matchers supported by Jest are [extensively documented here](https://facebook.github.io/jest/docs/en/expect.html#content).
    You can also use [`jest.fn()` and `expect(fn).toBeCalled()`](https://facebook.github.io/jest/docs/en/expect.html#tohavebeencalled) to create “spies” or mock functions. ### Testing Components There is a broad spectrum of component testing techniques. They range from a “smoke test” verifying that a component renders without throwing, to shallow rendering and testing some of the output, to full rendering and testing component lifecycle and state changes. Different projects choose different testing tradeoffs based on how often components change, and how much logic they contain. If you haven’t decided on a testing strategy yet, we recommend that you start with creating simple smoke tests for your components: ```js import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; it('renders without crashing', () => { const div = document.createElement('div'); ReactDOM.render(, div); }); ``` This test mounts a component and makes sure that it didn’t throw during rendering. Tests like this provide a lot of value with very little effort so they are great as a starting point, and this is the test you will find in `src/App.test.js`. When you encounter bugs caused by changing components, you will gain a deeper insight into which parts of them are worth testing in your application. This might be a good time to introduce more specific tests asserting specific expected output or behavior. If you’d like to test components in isolation from the child components they render, we recommend using [`shallow()` rendering API](http://airbnb.io/enzyme/docs/api/shallow.html) from [Enzyme](http://airbnb.io/enzyme/). To install it, run: ```sh npm install --save enzyme enzyme-adapter-react-16 react-test-renderer ``` Alternatively you may use `yarn`: ```sh yarn add enzyme enzyme-adapter-react-16 react-test-renderer ``` As of Enzyme 3, you will need to install Enzyme along with an Adapter corresponding to the version of React you are using. (The examples above use the adapter for React 16.) The adapter will also need to be configured in your [global setup file](#initializing-test-environment): #### `src/setupTests.js` ```js import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); ``` > Note: Keep in mind that if you decide to "eject" before creating `src/setupTests.js`, the resulting `package.json` file won't contain any reference to it. [Read here](#initializing-test-environment) to learn how to add this after ejecting. Now you can write a smoke test with it: ```js import React from 'react'; import { shallow } from 'enzyme'; import App from './App'; it('renders without crashing', () => { shallow(); }); ``` Unlike the previous smoke test using `ReactDOM.render()`, this test only renders `` and doesn’t go deeper. For example, even if `` itself renders a ` {/if} {:else}
    {#if showToggleButton} {/if}
    {#if $secondaryPane}
    {/if}
    {/if} ================================================ FILE: src/client/debug/Menu.svelte ================================================ ================================================ FILE: src/client/debug/ai/AI.svelte ================================================
    {#if client.game.ai && !client.multiplayer}

    Controls

    Bot

    {#if Object.keys(bot.opts()).length}

    Options

    {/if} {#if botAction || iterationCounter}

    Result

    {#if progress && progress < 1.0} {/if} {#if botAction}
    • Action: {botAction}
    • Args: {JSON.stringify(botActionArgs)}
    {/if}
    {/if} {:else} {#if client.multiplayer}

    The bot debugger is only available in singleplayer mode.

    {:else}

    No bots available.

    Follow the instructions here to set up bots.

    {/if} {/if}
    ================================================ FILE: src/client/debug/ai/Options.svelte ================================================ {#each Object.entries(bot.opts()) as [key, value]}
    {#if value.range} {values[key]} {:else if typeof value.value === 'boolean'} {/if}
    {/each} ================================================ FILE: src/client/debug/info/Info.svelte ================================================
    {#if client.multiplayer} {/if}
    ================================================ FILE: src/client/debug/info/Item.svelte ================================================
    {name}
    {JSON.stringify(value)}
    ================================================ FILE: src/client/debug/log/Log.svelte ================================================
    {#each renderedLogEntries as { turn }, i} {#if i in turnBoundaries} {/if} {/each} {#each renderedLogEntries as { action, metadata }, i} {/each} {#each renderedLogEntries as { phase }, i} {#if i in phaseBoundaries} {/if} {/each}
    ================================================ FILE: src/client/debug/log/LogEvent.svelte ================================================ ================================================ FILE: src/client/debug/log/LogMetadata.svelte ================================================
    {renderedMetadata}
    ================================================ FILE: src/client/debug/log/PhaseMarker.svelte ================================================
    {phase || ''}
    ================================================ FILE: src/client/debug/log/TurnMarker.svelte ================================================
    {turn}
    ================================================ FILE: src/client/debug/main/ClientSwitcher.svelte ================================================ {#if debuggableClients.length > 1}
    {/if} ================================================ FILE: src/client/debug/main/Controls.svelte ================================================
    ================================================ FILE: src/client/debug/main/Hotkey.svelte ================================================
    {#if label} {/if}
    ================================================ FILE: src/client/debug/main/InteractiveFunction.svelte ================================================
    {name} ( {}} on:keydown={OnKeyDown} contentEditable /> )
    ================================================ FILE: src/client/debug/main/Main.svelte ================================================

    Controls

    Players

    clientManager.switchPlayerID(e.detail.playerID)} ctx={ctx} playerID={playerID} />

    Moves

      {#each Object.entries(moves) as [name, fn]}
    • {/each}

    Events

      {#if ctx.activePlayers && events.endStage}
    • {/if} {#if events.endTurn}
    • {/if} {#if ctx.phase && events.endPhase}
    • {/if}

    G

    ctx

    ================================================ FILE: src/client/debug/main/Move.svelte ================================================
    {#if error} {error} {/if}
    ================================================ FILE: src/client/debug/main/PlayerInfo.svelte ================================================
    {#each players as player} {/each}
    ================================================ FILE: src/client/debug/mcts/Action.svelte ================================================
    {text}
    ================================================ FILE: src/client/debug/mcts/MCTS.svelte ================================================
    {#each nodes as { node, selectedIndex }, i} {#if i !== 0}
    {/if}
    {#if i === nodes.length - 1} SelectNode(e.detail, i)} on:preview={e => PreviewNode(e.detail, i)} root={node}/> {:else}
    SelectNode(e.detail, i)} root={node} selectedIndex={selectedIndex}/> {/if} {/each} {#if preview}
    {/if} ================================================ FILE: src/client/debug/mcts/Table.svelte ================================================
    {#each children as child, i} 0} class:selected={i === selectedIndex} on:click={() => Select(child, i)} on:mouseout={() => Preview(null, i)} on:mouseover={() => Preview(child, i)}> {/each}
    Value Visits Action
    {child.value} {child.visits}
    ================================================ FILE: src/client/debug/tests/JSONTree.mock.svelte ================================================ ================================================ FILE: src/client/debug/tests/debug.test.ts ================================================ /* * Copyright 2019 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import '@testing-library/jest-dom/extend-expect'; import { screen, fireEvent, waitFor } from '@testing-library/svelte'; import { Client } from '../../client'; import { Local } from '../../transport/local'; test('sanity', () => { const client = Client({ game: {} }); client.start(); expect(screen.getByText('Controls')).toBeInTheDocument(); expect(screen.getByText('Players')).toBeInTheDocument(); expect(screen.getByText('G')).toBeInTheDocument(); expect(screen.getByText('ctx')).toBeInTheDocument(); client.stop(); }); test('switching panels', async () => { const client = Client({ game: {} }); client.start(); // switch to info tab const InfoTab = screen.getByText('Info'); await fireEvent.click(InfoTab); expect(screen.getByText('matchID')).toBeInTheDocument(); expect(screen.getByText('playerID')).toBeInTheDocument(); expect(screen.getByText('isActive')).toBeInTheDocument(); // switch to AI tab const AITab = screen.getByText('AI'); await fireEvent.click(AITab); expect(screen.getByText('No bots available.')).toBeInTheDocument(); client.stop(); }); test('visibility toggle', async () => { const client = Client({ game: {} }); client.start(); // Visibility toggle button & debug panel are rendered const hideButton = screen.getByTitle('Hide Debug Panel'); expect(hideButton).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Controls' })).toBeInTheDocument(); // Hide debug panel await fireEvent.click(hideButton); await waitFor(() => expect(hideButton).not.toBeInTheDocument()); // Show button is rendered & debug panel is not. const showButton = screen.getByTitle('Show Debug Panel'); expect(showButton).toBeInTheDocument(); expect( screen.queryByRole('heading', { name: 'Controls' }) ).not.toBeInTheDocument(); // Show debug panel await fireEvent.click(showButton); await waitFor(() => expect(showButton).not.toBeInTheDocument()); // Hide button & debug panel are rendered. expect(screen.getByTitle('Hide Debug Panel')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Controls' })).toBeInTheDocument(); client.stop(); }); test('panel options', async () => { const client = Client({ game: {}, debug: { hideToggleButton: true, collapseOnLoad: true }, }); client.start(); const hideButton = screen.queryByTitle('Hide Debug Panel'); expect(hideButton).not.toBeInTheDocument(); expect( screen.queryByRole('heading', { name: 'Controls' }) ).not.toBeInTheDocument(); client.stop(); }); describe('multiple clients', () => { const client0 = Client({ game: { name: 'game1' }, playerID: '0', matchID: 'A', }); const multiplayer = Local(); const client1 = Client({ game: { name: 'game2' }, playerID: '0', matchID: 'B', multiplayer, }); const client2 = Client({ game: { name: 'game2' }, playerID: '1', matchID: 'B', multiplayer, }); beforeEach(() => { client0.start(); client1.start(); client2.start(); }); afterEach(() => { client0.stop(); client1.stop(); client2.stop(); }); test('switching clients', async () => { // Check if the client switcher is displayed. await waitFor(() => screen.getByText('Client')); // Get the client switcher select element. const select = screen.getByLabelText('Client'); // Check it is displaying details for the client that rendered first. expect( screen.getByDisplayValue('0 — playerID: "0", matchID: "A" (game1)') ).toBeInTheDocument(); // Switch to client1. await fireEvent.change(select, { target: { value: 1 } }); // Check the client switcher now shows details for client1. expect( screen.getByDisplayValue('1 — playerID: "0", matchID: "B" (game2)') ).toBeInTheDocument(); // Switch to the info tab and check if the matchID for client1 is displayed. const InfoTab = screen.getByText('Info'); await fireEvent.click(InfoTab); expect(screen.getByText('"B"')).toBeInTheDocument(); }); test('switching playerID for a solo client', async () => { expect(client0.playerID).toBe('0'); // Toggle to playerID 1 by clicking on the “1” button. await fireEvent.click(screen.getByRole('button', { name: 'Player 1' })); // Check client0’s playerID was updated. expect( screen.getByDisplayValue('0 — playerID: "1", matchID: "A" (game1)') ).toBeInTheDocument(); expect(client0.playerID).toBe('1'); }); test('switching playerID with multiplayer clients', async () => { expect(client1.playerID).toBe('0'); expect(client2.playerID).toBe('1'); // Switch to client1. const select = screen.getByLabelText('Client'); await fireEvent.change(select, { target: { value: 1 } }); // Check the client switcher now shows details for client1. expect( screen.getByDisplayValue('1 — playerID: "0", matchID: "B" (game2)') ).toBeInTheDocument(); // Toggle to playerID 1 by clicking on the “1” button. await fireEvent.click(screen.getByRole('button', { name: 'Player 1' })); // Check the client switcher now shows details for client2. expect( screen.getByDisplayValue('2 — playerID: "1", matchID: "B" (game2)') ).toBeInTheDocument(); // Client playerIDs have not changed. expect(client1.playerID).toBe('0'); expect(client2.playerID).toBe('1'); }); test('switching to current client', async () => { const select = screen.getByLabelText('Client'); await fireEvent.change(select, { target: { value: 0 } }); expect( screen.getByDisplayValue('0 — playerID: "1", matchID: "A" (game1)') ).toBeInTheDocument(); }); }); ================================================ FILE: src/client/debug/utils/shortcuts.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ export function AssignShortcuts(moveNames, blacklist) { let shortcuts = {}; const taken = {}; for (const c of blacklist) { taken[c] = true; } // Try assigning the first char of each move as the shortcut. let t = taken; let canUseFirstChar = true; for (const name in moveNames) { const shortcut = name[0]; if (t[shortcut]) { canUseFirstChar = false; break; } t[shortcut] = true; shortcuts[name] = shortcut; } if (canUseFirstChar) { return shortcuts; } // If those aren't unique, use a-z. t = taken; let next = 97; shortcuts = {}; for (const name in moveNames) { let shortcut = String.fromCharCode(next); while (t[shortcut]) { next++; shortcut = String.fromCharCode(next); } t[shortcut] = true; shortcuts[name] = shortcut; } return shortcuts; } ================================================ FILE: src/client/debug/utils/shortcuts.test.js ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { AssignShortcuts } from './shortcuts'; test('first char is used', () => { const moves = { clickCell: () => {}, playCard: () => {}, }; const shortcuts = AssignShortcuts(moves, ''); expect(shortcuts).toEqual({ clickCell: 'c', playCard: 'p', }); }); test('a-z if cannot use first char', () => { const moves = { takeCard: () => {}, takeToken: () => {}, }; const shortcuts = AssignShortcuts(moves, ''); expect(shortcuts).toEqual({ takeCard: 'a', takeToken: 'b', }); }); test('a-z if blacklist prevents using first char', () => { const moves = { clickCell: () => {}, }; const shortcuts = AssignShortcuts(moves, 'c'); expect(shortcuts).toEqual({ clickCell: 'a', }); }); ================================================ FILE: src/client/manager.ts ================================================ import Debug from './debug/Debug.svelte'; import type { _ClientImpl } from './client'; type SubscriptionState = { client: _ClientImpl; debuggableClients: _ClientImpl[]; }; type SubscribeCallback = (arg: SubscriptionState) => void; type UnsubscribeCallback = () => void; /** * Class to manage boardgame.io clients and limit debug panel rendering. */ export class ClientManager { private debugPanel: Debug | null; private currentClient: _ClientImpl | null; private clients: Map<_ClientImpl, _ClientImpl>; private subscribers: Map; constructor() { this.debugPanel = null; this.currentClient = null; this.clients = new Map(); this.subscribers = new Map(); } /** * Register a client with the client manager. */ register(client: _ClientImpl): void { // Add client to clients map. this.clients.set(client, client); // Mount debug for this client (no-op if another debug is already mounted). this.mountDebug(client); this.notifySubscribers(); } /** * Unregister a client from the client manager. */ unregister(client: _ClientImpl): void { // Remove client from clients map. this.clients.delete(client); if (this.currentClient === client) { // If the removed client owned the debug panel, unmount it. this.unmountDebug(); // Mount debug panel for next available client. for (const [client] of this.clients) { if (this.debugPanel) break; this.mountDebug(client); } } this.notifySubscribers(); } /** * Subscribe to the client manager state. * Calls the passed callback each time the current client changes or a client * registers/unregisters. * Returns a function to unsubscribe from the state updates. */ subscribe(callback: SubscribeCallback): UnsubscribeCallback { const id = Symbol(); this.subscribers.set(id, callback); callback(this.getState()); return () => { this.subscribers.delete(id); }; } /** * Switch to a client with a matching playerID. */ switchPlayerID(playerID: string): void { // For multiplayer clients, try switching control to a different client // that is using the same transport layer. if (this.currentClient.multiplayer) { for (const [client] of this.clients) { if ( client.playerID === playerID && client.debugOpt !== false && client.multiplayer === this.currentClient.multiplayer ) { this.switchToClient(client); return; } } } // If no client matches, update the playerID for the current client. this.currentClient.updatePlayerID(playerID); this.notifySubscribers(); } /** * Set the passed client as the active client for debugging. */ switchToClient(client: _ClientImpl): void { if (client === this.currentClient) return; this.unmountDebug(); this.mountDebug(client); this.notifySubscribers(); } /** * Notify all subscribers of changes to the client manager state. */ private notifySubscribers(): void { const arg = this.getState(); this.subscribers.forEach((cb) => { cb(arg); }); } /** * Get the client manager state. */ private getState(): SubscriptionState { return { client: this.currentClient, debuggableClients: this.getDebuggableClients(), }; } /** * Get an array of the registered clients that haven’t disabled the debug panel. */ private getDebuggableClients(): _ClientImpl[] { return [...this.clients.values()].filter( (client) => client.debugOpt !== false ); } /** * Mount the debug panel using the passed client. */ private mountDebug(client: _ClientImpl): void { if ( client.debugOpt === false || this.debugPanel !== null || typeof document === 'undefined' ) { return; } let DebugImpl: typeof Debug | undefined; let target = document.body; if (process.env.NODE_ENV !== 'production') { DebugImpl = Debug; } if (client.debugOpt && client.debugOpt !== true) { DebugImpl = client.debugOpt.impl || DebugImpl; target = client.debugOpt.target || target; } if (DebugImpl) { this.currentClient = client; this.debugPanel = new DebugImpl({ target, props: { clientManager: this }, }); } } /** * Unmount the debug panel. */ private unmountDebug(): void { this.debugPanel.$destroy(); this.debugPanel = null; this.currentClient = null; } } ================================================ FILE: src/client/react-native.js ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import { Client as RawClient } from './client'; /** * Client * * boardgame.io React Native client. * * @param {...object} game - The return value of `Game`. * @param {...object} numPlayers - The number of players. * @param {...object} board - The React component for the game. * @param {...object} loading - (optional) The React component for the loading state. * @param {...object} multiplayer - Set to a falsy value or a transportFactory, e.g., SocketIO() * @param {...object} enhancer - Optional enhancer to send to the Redux store * * Returns: * A React Native component that wraps board and provides an * API through props for it to interact with the framework * and dispatch actions such as MAKE_MOVE. */ export function Client(opts) { const { game, numPlayers, board, multiplayer, enhancer } = opts; let { loading } = opts; // Component that is displayed before the client has synced // with the game master. if (loading === undefined) { const Loading = () => <>; loading = Loading; } /* * WrappedBoard * * The main React component that wraps the passed in * board component and adds the API to its props. */ return class WrappedBoard extends React.Component { static propTypes = { // The ID of a game to connect to. // Only relevant in multiplayer. matchID: PropTypes.string, // The ID of the player associated with this client. // Only relevant in multiplayer. playerID: PropTypes.string, // This client's authentication credentials. // Only relevant in multiplayer. credentials: PropTypes.string, }; static defaultProps = { matchID: 'default', playerID: null, credentials: null, }; constructor(props) { super(props); this.client = RawClient({ game, numPlayers, multiplayer, matchID: props.matchID, playerID: props.playerID, credentials: props.credentials, debug: false, enhancer, }); } componentDidMount() { this.unsubscribe = this.client.subscribe(() => this.forceUpdate()); this.client.start(); } componentWillUnmount() { this.client.stop(); this.unsubscribe(); } componentDidUpdate(prevProps) { if (prevProps.matchID != this.props.matchID) { this.client.updateMatchID(this.props.matchID); } if (prevProps.playerID != this.props.playerID) { this.client.updatePlayerID(this.props.playerID); } if (prevProps.credentials != this.props.credentials) { this.client.updateCredentials(this.props.credentials); } } render() { let _board = null; const state = this.client.getState(); if (state === null) { return React.createElement(loading); } const { matchID, playerID, ...rest } = this.props; if (board) { _board = React.createElement(board, { ...state, ...rest, matchID, playerID, isMultiplayer: !!multiplayer, moves: this.client.moves, events: this.client.events, step: this.client.step, reset: this.client.reset, undo: this.client.undo, redo: this.client.redo, matchData: this.client.matchData, sendChatMessage: this.client.sendChatMessage, chatMessages: this.client.chatMessages, }); } return _board; } }; } ================================================ FILE: src/client/react-native.test.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ /* eslint-disable unicorn/no-array-callback-reference */ import React from 'react'; import { Client } from './react-native'; import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import { Local } from './transport/local'; import { Transport } from './transport/transport'; class NoConnectionTransport extends Transport { connect() {} disconnect() {} sendAction() {} sendChatMessage() {} requestSync() {} updateMatchID() {} updatePlayerID() {} updateCredentials() {} } Enzyme.configure({ adapter: new Adapter() }); class TestBoard extends React.Component { render() { return
    Board
    ; } } test('board is rendered', () => { const Board = Client({ game: {}, board: TestBoard, }); const game = Enzyme.mount(); const board = game.find(TestBoard); expect(board.props().isActive).toBe(true); expect(board.text()).toBe('Board'); game.unmount(); }); test('board is rendered with custom loading', () => { const Loading = () => <>connecting...; const Board = Client({ game: {}, board: TestBoard, loading: Loading, multiplayer: (opts) => new NoConnectionTransport(opts), }); const game = Enzyme.mount(); const loadingComponent = game.find(Loading); expect(loadingComponent.props()).toEqual({}); expect(loadingComponent.text()).toBe('connecting...'); game.unmount(); }); test('board props', () => { const Board = Client({ game: {}, board: TestBoard, }); const board = Enzyme.mount().find(TestBoard); expect(board.props().isMultiplayer).toEqual(false); expect(board.props().isActive).toBe(true); }); test('can pass extra props to Client', () => { const Board = Client({ game: {}, board: TestBoard, }); const board = Enzyme.mount( true} extraValue={55} /> ).find(TestBoard); expect(board.props().doStuff()).toBe(true); expect(board.props().extraValue).toBe(55); }); test('can pass empty board', () => { const Board = Client({ game: {}, }); const game = Enzyme.mount(); expect(game).not.toBe(undefined); }); test('move api', () => { const Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, }); const game = Enzyme.mount(); const board = game.find('TestBoard').instance(); expect(board.props.G).toEqual({}); board.props.moves.A(42); expect(board.props.G).toEqual({ arg: 42 }); }); test('update matchID / playerID', () => { let Board = null; let game = null; // No multiplayer. Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, }); game = Enzyme.mount(); game.setProps({ matchID: 'a' }); game.setProps({ playerID: '3' }); expect(game.instance().transport).toBe(undefined); // Multiplayer. Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, multiplayer: Local(), }); game = Enzyme.mount(); const m = game.instance().client.transport; const g = game.instance().client; const spy1 = jest.spyOn(m, 'updateMatchID'); const spy2 = jest.spyOn(m, 'updatePlayerID'); const spy3 = jest.spyOn(g, 'updateCredentials'); expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); game.setProps({ matchID: 'a' }); game.setProps({ playerID: '1' }); game.setProps({ credentials: 'foo' }); expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); expect(spy3).not.toHaveBeenCalled(); game.setProps({ matchID: 'next' }); game.setProps({ playerID: 'next' }); game.setProps({ credentials: 'bar' }); expect(m.matchID).toBe('next'); expect(m.playerID).toBe('next'); expect(spy1).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); expect(spy3).toHaveBeenCalled(); }); test('local playerView', () => { const Board = Client({ game: { setup: () => ({ secret: true }), playerView: ({ playerID }) => ({ stripped: playerID }), }, board: TestBoard, numPlayers: 2, }); const game = Enzyme.mount(); const board = game.find('TestBoard').instance(); expect(board.props.G).toEqual({ stripped: '1' }); }); test('reset Game', () => { const Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, }); const game = Enzyme.mount(); const board = game.find('TestBoard').instance(); const initial = { G: { ...board.props.G }, ctx: { ...board.props.ctx } }; expect(board.props.G).toEqual({}); board.props.moves.A(42); expect(board.props.G).toEqual({ arg: 42 }); board.props.reset(); expect(board.props.G).toEqual(initial.G); expect(board.props.ctx).toEqual(initial.ctx); }); test('can receive enhancer', () => { const enhancer = jest.fn().mockImplementation((next) => next); const Board = Client({ game: {}, board: TestBoard, enhancer, }); Enzyme.mount(); expect(enhancer).toBeCalled(); }); ================================================ FILE: src/client/react.ssr.test.tsx ================================================ /** * @jest-environment node */ import React from 'react'; import type { BoardProps } from './react'; import { Client } from './react'; import ReactDOMServer from 'react-dom/server'; class TestBoard extends React.Component { render() { return
    Board
    ; } } test('board is rendered - ssr', () => { const Board = Client({ game: {}, board: TestBoard, }); const ssrRender = ReactDOMServer.renderToString(); expect(ssrRender).toContain('bgio-client'); expect(ssrRender).toContain('my-board'); }); ================================================ FILE: src/client/react.test.tsx ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ /* eslint-disable unicorn/no-array-callback-reference */ import React from 'react'; import type { BoardProps } from './react'; import { Client } from './react'; import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import { Local } from './transport/local'; import { SocketIO } from './transport/socketio'; Enzyme.configure({ adapter: new Adapter() }); class TestBoard extends React.Component< BoardProps & { doStuff?; extraValue? } > { render() { return
    Board
    ; } } test('board is rendered', () => { const Board = Client({ game: {}, board: TestBoard, }); const game = Enzyme.mount(); const board = game.find(TestBoard); expect(board.props().isActive).toBe(true); expect(board.text()).toBe('Board'); game.unmount(); }); test('board props', () => { const Board = Client({ game: {}, board: TestBoard, }); const board = Enzyme.mount().find(TestBoard); expect(board.props().isMultiplayer).toEqual(false); expect(board.props().isActive).toBe(true); }); test('can pass extra props to Client', () => { const Board = Client({ game: {}, board: TestBoard, }); const board = Enzyme.mount( true} extraValue={55} /> ).find(TestBoard); expect(board.props().doStuff()).toBe(true); expect(board.props().extraValue).toBe(55); }); test('debug ui can be turned off', () => { const Board = Client({ game: {}, board: TestBoard, debug: false, }); const game = Enzyme.mount(); expect(game.find('.debug-ui')).toHaveLength(0); }); test('custom loading component', () => { const Loading = () =>
    custom
    ; const Board = Client({ game: {}, loading: Loading, board: TestBoard, multiplayer: SocketIO(), }); const board = Enzyme.mount(); expect(board.html()).toContain('custom'); }); test('can pass empty board', () => { const Board = Client({ game: {}, }); const game = Enzyme.mount(); expect(game).not.toBe(undefined); }); test('move api', () => { const Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, }); const game = Enzyme.mount(); const board = game.find('TestBoard').instance() as TestBoard; expect(board.props.G).toEqual({}); board.props.moves.A(42); expect(board.props.G).toEqual({ arg: 42 }); }); test('update matchID / playerID', () => { let Board = null; let game = null; // No multiplayer. Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, }); game = Enzyme.mount(); game.setProps({ matchID: 'a' }); game.setProps({ playerID: '3' }); expect(game.instance().transport).toBe(undefined); // Multiplayer. Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, multiplayer: Local(), }); game = Enzyme.mount(); const m = game.instance().client.transport; const g = game.instance().client; const spy1 = jest.spyOn(m, 'updateMatchID'); const spy2 = jest.spyOn(m, 'updatePlayerID'); const spy3 = jest.spyOn(g, 'updateCredentials'); expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); game.setProps({ matchID: 'a' }); game.setProps({ playerID: '1' }); game.setProps({ credentials: 'foo' }); expect(m.matchID).toBe('a'); expect(m.playerID).toBe('1'); expect(spy1).not.toHaveBeenCalled(); expect(spy2).not.toHaveBeenCalled(); expect(spy3).not.toHaveBeenCalled(); game.setProps({ matchID: 'next' }); game.setProps({ playerID: 'next' }); game.setProps({ credentials: 'bar' }); expect(m.matchID).toBe('next'); expect(m.playerID).toBe('next'); expect(spy1).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); expect(spy3).toHaveBeenCalled(); }); test('local playerView', () => { const Board = Client({ game: { setup: () => ({ secret: true }), playerView: ({ playerID }) => ({ stripped: playerID }), }, board: TestBoard, numPlayers: 2, }); const game = Enzyme.mount(); const board = game.find('TestBoard').instance() as TestBoard; expect(board.props.G).toEqual({ stripped: '1' }); }); test('reset Game', () => { const Board = Client({ game: { moves: { A: (_, arg) => ({ arg }), }, }, board: TestBoard, }); const game = Enzyme.mount(); const board = game.find('TestBoard').instance() as TestBoard; const initial = { G: { ...board.props.G }, ctx: { ...board.props.ctx } }; expect(board.props.G).toEqual({}); board.props.moves.A(42); expect(board.props.G).toEqual({ arg: 42 }); board.props.reset(); expect(board.props.G).toEqual(initial.G); expect(board.props.ctx).toEqual(initial.ctx); }); ================================================ FILE: src/client/react.tsx ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import PropTypes from 'prop-types'; import { Client as RawClient } from './client'; import type { ClientOpts, ClientState, _ClientImpl } from './client'; type WrappedBoardDelegates = 'matchID' | 'playerID' | 'credentials'; export type WrappedBoardProps = Pick< ClientOpts, WrappedBoardDelegates | 'debug' >; type ExposedClientProps = Pick< _ClientImpl, | 'log' | 'moves' | 'events' | 'reset' | 'undo' | 'redo' | 'playerID' | 'matchID' | 'matchData' | 'sendChatMessage' | 'chatMessages' >; export type BoardProps = ClientState & Omit> & ExposedClientProps & { isMultiplayer: boolean; }; type ReactClientOpts< G extends any = any, P extends BoardProps = BoardProps, PluginAPIs extends Record = Record > = Omit, WrappedBoardDelegates> & { board?: React.ComponentType

    ; loading?: React.ComponentType; }; /** * Client * * boardgame.io React client. * * @param {...object} game - The return value of `Game`. * @param {...object} numPlayers - The number of players. * @param {...object} board - The React component for the game. * @param {...object} loading - (optional) The React component for the loading state. * @param {...object} multiplayer - Set to a falsy value or a transportFactory, e.g., SocketIO() * @param {...object} debug - Enables the Debug UI. * @param {...object} enhancer - Optional enhancer to send to the Redux store * * Returns: * A React component that wraps board and provides an * API through props for it to interact with the framework * and dispatch actions such as MAKE_MOVE, GAME_EVENT, RESET, * UNDO and REDO. */ export function Client< G extends any = any, P extends BoardProps = BoardProps, PluginAPIs extends Record = Record >(opts: ReactClientOpts) { const { game, numPlayers, board, multiplayer, enhancer } = opts; let { loading, debug } = opts; // Component that is displayed before the client has synced // with the game master. if (loading === undefined) { const Loading = () =>

    connecting...
    ; loading = Loading; } type AdditionalProps = Omit>; /* * WrappedBoard * * The main React component that wraps the passed in * board component and adds the API to its props. */ return class WrappedBoard extends React.Component< WrappedBoardProps & AdditionalProps > { client: _ClientImpl; unsubscribe?: () => void; static propTypes = { // The ID of a game to connect to. // Only relevant in multiplayer. matchID: PropTypes.string, // The ID of the player associated with this client. // Only relevant in multiplayer. playerID: PropTypes.string, // This client's authentication credentials. // Only relevant in multiplayer. credentials: PropTypes.string, // Enable / disable the Debug UI. debug: PropTypes.any, }; static defaultProps = { matchID: 'default', playerID: null, credentials: null, debug: true, }; constructor(props: WrappedBoardProps & AdditionalProps) { super(props); if (debug === undefined) { debug = props.debug; } this.client = RawClient({ game, debug, numPlayers, multiplayer, matchID: props.matchID, playerID: props.playerID, credentials: props.credentials, enhancer, }); } componentDidMount() { this.unsubscribe = this.client.subscribe(() => this.forceUpdate()); this.client.start(); } componentWillUnmount() { this.client.stop(); this.unsubscribe(); } componentDidUpdate(prevProps: WrappedBoardProps & AdditionalProps) { if (this.props.matchID != prevProps.matchID) { this.client.updateMatchID(this.props.matchID); } if (this.props.playerID != prevProps.playerID) { this.client.updatePlayerID(this.props.playerID); } if (this.props.credentials != prevProps.credentials) { this.client.updateCredentials(this.props.credentials); } } render() { const state = this.client.getState(); if (state === null) { return React.createElement(loading); } let _board = null; if (board) { _board = React.createElement(board, { ...state, ...(this.props as P), isMultiplayer: !!multiplayer, moves: this.client.moves, events: this.client.events, matchID: this.client.matchID, playerID: this.client.playerID, reset: this.client.reset, undo: this.client.undo, redo: this.client.redo, log: this.client.log, matchData: this.client.matchData, sendChatMessage: this.client.sendChatMessage, chatMessages: this.client.chatMessages, }); } return
    {_board}
    ; } }; } ================================================ FILE: src/client/transport/dummy.ts ================================================ import { Transport } from './transport'; import type { TransportOpts } from './transport'; /** * This class doesn’t do anything, but simplifies the client class by providing * dummy functions to call, so we don’t need to mock them in the client. */ class DummyImpl extends Transport { connect() {} disconnect() {} sendAction() {} sendChatMessage() {} requestSync() {} updateCredentials() {} updateMatchID() {} updatePlayerID() {} } export const DummyTransport = (opts: TransportOpts) => new DummyImpl(opts); ================================================ FILE: src/client/transport/local.test.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { LocalTransport, LocalMaster, Local, GetBotPlayer } from './local'; import { gameEvent } from '../../core/action-creators'; import { ProcessGameConfig } from '../../core/game'; import { Client } from '../client'; import { RandomBot } from '../../ai/random-bot'; import { Stage } from '../../core/turn-order'; import type { Game, State } from '../../types'; const sleep = (ms = 500) => new Promise((resolve) => setTimeout(resolve, ms)); describe('bots', () => { const game: Game = { moves: { A: ({ events }) => { events.endTurn(); }, }, ai: { enumerate: () => [{ move: 'A' }], }, }; test('make bot move', async () => { const client = Client({ game: { ...game }, playerID: '0', multiplayer: Local({ bots: { '1': RandomBot } }), }); client.start(); expect(client.getState().ctx.turn).toBe(1); // Make it Player 1's turn and trigger the bot move. client.events.endTurn(); expect(client.getState().ctx.turn).toBe(2); // Wait until the bot has hopefully completed its move. await sleep(); expect(client.getState().ctx.turn).toBe(3); }); test('no bot move', async () => { const client = Client({ numPlayers: 3, game: { ...game }, playerID: '0', multiplayer: Local({ bots: { '2': RandomBot } }), }); client.start(); expect(client.getState().ctx.turn).toBe(1); // Make it Player 1's turn. No bot move. client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); // Wait until the bot has hopefully completed its move. await sleep(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.numMoves).toBe(0); }); }); describe('GetBotPlayer', () => { test('stages', () => { const result = GetBotPlayer( { ctx: { activePlayers: { '1': Stage.NULL, }, }, } as unknown as State, { '0': {}, '1': {}, } ); expect(result).toEqual('1'); }); test('no stages', () => { const result = GetBotPlayer( { ctx: { currentPlayer: '0', }, } as unknown as State, { '0': {} } ); expect(result).toEqual('0'); }); test('null', () => { const result = GetBotPlayer( { ctx: { currentPlayer: '1', }, } as unknown as State, { '0': {} } ); expect(result).toEqual(null); }); test('gameover', () => { const result = GetBotPlayer( { ctx: { currentPlayer: '0', gameover: true, }, } as unknown as State, { '0': {} } ); expect(result).toEqual(null); }); }); describe('Local', () => { test('transports for same game use shared master', () => { const gameKey = {}; const game = ProcessGameConfig(gameKey); const transport1 = Local()({ game, gameKey, transportDataCallback: () => {}, }); const transport2 = Local()({ game, gameKey, transportDataCallback: () => {}, }); expect(transport1.master).toBe(transport2.master); }); test('transports use shared master with bots', () => { const gameKey = {}; const game = ProcessGameConfig(gameKey); const bots = {}; const transport1 = Local({ bots })({ game, gameKey, transportDataCallback: () => {}, }); const transport2 = Local({ bots })({ game, gameKey, transportDataCallback: () => {}, }); expect(transport1.master).toBe(transport2.master); }); test('transports use different master for different bots', () => { const gameKey = {}; const game = ProcessGameConfig(gameKey); const transport1 = Local({ bots: {} })({ game, gameKey, transportDataCallback: () => {}, }); const transport2 = Local({ bots: {} })({ game, gameKey, transportDataCallback: () => {}, }); expect(transport1.master).not.toBe(transport2.master); }); describe('with localStorage persistence', () => { const game: Game = { setup: () => ({ count: 0 }), moves: { A: ({ G }) => { G.count++; }, }, }; afterEach(() => { localStorage.clear(); }); test('writes to localStorage', () => { const matchID = 'persists-to-ls'; const multiplayer = Local({ persist: true }); const client = Client({ playerID: '0', matchID, game, multiplayer }); client.start(); expect(client.getState().G).toEqual({ count: 0 }); client.moves.A(); expect(client.getState().G).toEqual({ count: 1 }); client.stop(); const stored = JSON.parse(localStorage.getItem('bgio_state')); const [id, state] = stored.find(([id]) => id === matchID); expect(id).toBe(matchID); expect(state.G).toEqual({ count: 1 }); }); test('reads from localStorage', () => { const matchID = 'reads-from-ls'; const storageKey = 'rfls'; const stateMap = { [matchID]: { G: { count: 'foo' }, ctx: {}, }, }; const entriesString = JSON.stringify(Object.entries(stateMap)); localStorage.setItem(`${storageKey}_state`, entriesString); const multiplayer = Local({ persist: true, storageKey }); const client = Client({ playerID: '0', matchID, game, multiplayer }); client.start(); expect(client.getState().G).toEqual({ count: 'foo' }); client.stop(); }); }); }); describe('LocalMaster', () => { let master: LocalMaster; let player0Callback: jest.Mock; let player1Callback: jest.Mock; beforeEach(() => { master = new LocalMaster({ game: {} }); player0Callback = jest.fn(); player1Callback = jest.fn(); master.connect('0', player0Callback); master.connect('1', player1Callback); master.onSync('matchID', '0'); master.onSync('matchID', '1'); }); test('sync', () => { expect(player0Callback).toHaveBeenCalledWith( expect.objectContaining({ type: 'sync' }) ); expect(player1Callback).toHaveBeenCalledWith( expect.objectContaining({ type: 'sync' }) ); }); test('update', () => { master.onUpdate(gameEvent('endTurn'), 0, 'matchID', '0'); expect(player0Callback).toBeCalledWith( expect.objectContaining({ type: 'update' }) ); expect(player1Callback).toBeCalledWith( expect.objectContaining({ type: 'update' }) ); }); test('connect without callback', () => { expect(() => { master.connect('0', undefined); master.onSync('matchID', '0'); }).not.toThrow(); }); }); describe('LocalTransport', () => { describe('update matchID / playerID', () => { const master = { connect: jest.fn(), onSync: jest.fn(), } as unknown as LocalMaster; class WrappedLocalTransport extends LocalTransport { getMatchID() { return this.matchID; } getPlayerID() { return this.playerID; } } const transport = new WrappedLocalTransport({ master, transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); jest.spyOn(transport, 'requestSync'); beforeEach(() => { jest.resetAllMocks(); }); test('matchID', () => { transport.updateMatchID('test'); expect(transport.getMatchID()).toBe('test'); expect(transport.requestSync).toBeCalled(); }); test('playerID', () => { transport.updatePlayerID('player'); expect(transport.getPlayerID()).toBe('player'); expect(master.connect).toBeCalled(); }); }); describe('multiplayer', () => { const game: Game = { setup: () => ({ initial: true }), moves: { A: () => ({ A: true }), }, }; const multiplayer = Local(); const matchID = 'local-multiplayer'; const client1 = Client({ game, multiplayer, matchID, playerID: '0' }); const client2 = Client({ game, multiplayer, matchID, playerID: '1' }); beforeAll(() => { client1.start(); client2.start(); }); afterAll(() => { client1.stop(); client2.stop(); }); test('send/receive update', () => { expect(client1.getState().G).toStrictEqual({ initial: true }); expect(client2.getState().G).toStrictEqual({ initial: true }); client1.moves.A(); expect(client1.getState().G).toStrictEqual({ A: true }); expect(client2.getState().G).toStrictEqual({ A: true }); }); test('receive sync', () => { const newClient = Client({ game, multiplayer, matchID }); newClient.start(); expect(newClient.getState().G).toStrictEqual({ A: true }); newClient.stop(); }); test('send chat-message', () => { const payload = { message: 'foo' }; client1.sendChatMessage(payload); expect(client2.chatMessages).toStrictEqual([ { id: expect.any(String), sender: '0', payload }, ]); }); }); }); ================================================ FILE: src/client/transport/local.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { InMemory } from '../../server/db/inmemory'; import { LocalStorage } from '../../server/db/localstorage'; import { Master } from '../../master/master'; import type { TransportAPI, TransportData } from '../../master/master'; import { Transport } from './transport'; import type { TransportOpts } from './transport'; import type { ChatMessage, CredentialedActionShape, Game, PlayerID, State, } from '../../types'; import { getFilterPlayerView } from '../../master/filter-player-view'; /** * Returns null if it is not a bot's turn. * Otherwise, returns a playerID of a bot that may play now. */ export function GetBotPlayer(state: State, bots: Record) { if (state.ctx.gameover !== undefined) { return null; } if (state.ctx.activePlayers) { for (const key of Object.keys(bots)) { if (key in state.ctx.activePlayers) { return key; } } } else if (state.ctx.currentPlayer in bots) { return state.ctx.currentPlayer; } return null; } interface LocalOpts { bots?: Record; persist?: boolean; storageKey?: string; } type LocalMasterOpts = LocalOpts & { game: Game; }; /** * Creates a local version of the master that the client * can interact with. */ export class LocalMaster extends Master { connect: ( playerID: PlayerID, callback: (data: TransportData) => void ) => void; constructor({ game, bots, storageKey, persist }: LocalMasterOpts) { const clientCallbacks: Record void> = {}; const initializedBots = {}; if (game && game.ai && bots) { for (const playerID in bots) { const bot = bots[playerID]; initializedBots[playerID] = new bot({ game, enumerate: game.ai.enumerate, seed: game.seed, }); } } const send: TransportAPI['send'] = ({ playerID, ...data }) => { const callback = clientCallbacks[playerID]; if (callback !== undefined) { callback(filterPlayerView(playerID, data)); } }; const filterPlayerView = getFilterPlayerView(game); const transportAPI: TransportAPI = { send, sendAll: (payload) => { for (const playerID in clientCallbacks) { send({ playerID, ...payload }); } }, }; const storage = persist ? new LocalStorage(storageKey) : new InMemory(); super(game, storage, transportAPI); this.connect = (playerID, callback) => { clientCallbacks[playerID] = callback; }; this.subscribe(({ state, matchID }) => { if (!bots) { return; } const botPlayer = GetBotPlayer(state, initializedBots); if (botPlayer !== null) { setTimeout(async () => { const botAction = await initializedBots[botPlayer].play( state, botPlayer ); await this.onUpdate( botAction.action, state._stateID, matchID, botAction.action.payload.playerID ); }, 100); } }); } } type LocalTransportOpts = TransportOpts & { master?: LocalMaster; }; /** * Local * * Transport interface that embeds a GameMaster within it * that you can connect multiple clients to. */ export class LocalTransport extends Transport { master: LocalMaster; /** * Creates a new Mutiplayer instance. * @param {string} matchID - The game ID to connect to. * @param {string} playerID - The player ID associated with this client. * @param {string} gameName - The game type (the `name` field in `Game`). * @param {string} numPlayers - The number of players. */ constructor({ master, ...opts }: LocalTransportOpts) { super(opts); this.master = master; } sendChatMessage(matchID: string, chatMessage: ChatMessage): void { const args: Parameters = [ matchID, chatMessage, this.credentials, ]; this.master.onChatMessage(...args); } sendAction(state: State, action: CredentialedActionShape.Any): void { this.master.onUpdate(action, state._stateID, this.matchID, this.playerID); } requestSync(): void { this.master.onSync( this.matchID, this.playerID, this.credentials, this.numPlayers ); } connect(): void { this.setConnectionStatus(true); this.master.connect(this.playerID, (data) => this.notifyClient(data)); this.requestSync(); } disconnect(): void { this.setConnectionStatus(false); } updateMatchID(id: string): void { this.matchID = id; this.connect(); } updatePlayerID(id: PlayerID): void { this.playerID = id; this.connect(); } updateCredentials(credentials?: string): void { this.credentials = credentials; this.connect(); } } /** * Global map storing local master instances. */ const localMasters: Map = new Map(); /** * Create a local transport. */ export function Local({ bots, persist, storageKey }: LocalOpts = {}) { return (transportOpts: TransportOpts) => { const { gameKey, game } = transportOpts; let master: LocalMaster; const instance = localMasters.get(gameKey); if ( instance && instance.bots === bots && instance.storageKey === storageKey && instance.persist === persist ) { master = instance.master; } if (!master) { master = new LocalMaster({ game, bots, persist, storageKey }); localMasters.set(gameKey, { master, bots, persist, storageKey }); } return new LocalTransport({ master, ...transportOpts }); }; } ================================================ FILE: src/client/transport/socketio.test.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { SocketIOTransport } from './socketio'; import type * as ioNamespace from 'socket.io-client'; import { makeMove } from '../../core/action-creators'; import type { Master } from '../../master/master'; import type { ChatMessage, State } from '../../types'; import { ProcessGameConfig } from '../../core/game'; jest.mock('../../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); type UpdateArgs = Parameters; type SyncArgs = Parameters; class MockSocket { callbacks: Record void>; emit: jest.Mock; constructor() { this.callbacks = {}; this.emit = jest.fn(); } receive(type: string, ...args) { this.callbacks[type](...args); } on(type: string, callback: (arg0?: any, arg1?: any) => void) { this.callbacks[type] = callback; } close() {} } test('defaults', () => { const m = new SocketIOTransport({ transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); expect(typeof (m as any).connectionStatusCallback).toBe('function'); (m as any).connectionStatusCallback(); }); class TransportAdapter extends SocketIOTransport { declare socket: ioNamespace.Socket & { io: { engine: any }; }; getMatchID() { return this.matchID; } getPlayerID() { return this.playerID; } getCredentials() { return this.credentials; } } describe('update matchID / playerID / credentials', () => { const socket = new MockSocket(); const m = new TransportAdapter({ socket, transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); beforeEach(() => (socket.emit = jest.fn())); test('matchID', () => { m.updateMatchID('test'); expect(m.getMatchID()).toBe('test'); const args: SyncArgs = ['test', null, undefined, 2]; expect(socket.emit).lastCalledWith('sync', ...args); }); test('playerID', () => { m.updatePlayerID('player'); expect(m.getPlayerID()).toBe('player'); const args: SyncArgs = ['test', 'player', undefined, 2]; expect(socket.emit).lastCalledWith('sync', ...args); }); test('credentials', () => { m.updateCredentials('1234'); expect(m.getCredentials()).toBe('1234'); const args: SyncArgs = ['test', 'player', '1234', 2]; expect(socket.emit).lastCalledWith('sync', ...args); }); }); describe('connection status', () => { let onChangeMock: jest.Mock; let mockSocket: MockSocket; let m: SocketIOTransport; beforeEach(() => { onChangeMock = jest.fn(); mockSocket = new MockSocket(); m = new SocketIOTransport({ socket: mockSocket, matchID: '0', playerID: '0', gameName: 'foo', game: ProcessGameConfig({}), gameKey: {}, numPlayers: 2, transportDataCallback: () => {}, }); m.subscribeToConnectionStatus(onChangeMock); m.connect(); }); test('connect', () => { mockSocket.callbacks['connect'](); expect(onChangeMock).toHaveBeenCalled(); expect(m.isConnected).toBe(true); }); test('disconnect', () => { mockSocket.callbacks['disconnect'](); expect(onChangeMock).toHaveBeenCalled(); expect(m.isConnected).toBe(false); }); test('close socket', () => { mockSocket.callbacks['connect'](); expect(m.isConnected).toBe(true); m.disconnect(); expect(m.isConnected).toBe(false); }); test('doesn’t crash if syncing before connecting', () => { const transportDataCallback = jest.fn(); const transport = new SocketIOTransport({ transportDataCallback, game: ProcessGameConfig({}), gameKey: {}, }); transport.requestSync(); expect(transportDataCallback).not.toHaveBeenCalled(); }); }); describe('multiplayer', () => { const mockSocket = new MockSocket(); const transportDataCallback = jest.fn(); const transport = new TransportAdapter({ socket: mockSocket, transportDataCallback, game: ProcessGameConfig({}), gameKey: {}, }); transport.connect(); beforeEach(jest.clearAllMocks); test('receive update', () => { const restored: { restore: boolean; _stateID?: number } = { restore: true }; mockSocket.receive('update', 'default', restored); expect(transportDataCallback).toHaveBeenCalledWith({ type: 'update', args: ['default', restored, undefined], }); }); test('receive sync', () => { const restored = { restore: true }; mockSocket.receive('sync', 'default', { state: restored }); expect(transportDataCallback).toHaveBeenCalledWith({ type: 'sync', args: ['default', { state: restored }], }); }); test('receive matchData', () => { const matchData = [{ id: '0', name: 'Alice' }]; mockSocket.receive('matchData', 'default', matchData); expect(transportDataCallback).toHaveBeenCalledWith({ type: 'matchData', args: ['default', matchData], }); }); test('send update', () => { const action = makeMove(undefined, undefined, undefined); const state = { _stateID: 0 } as State; transport.sendAction(state, action); const args: UpdateArgs = [action, state._stateID, 'default', null]; expect(mockSocket.emit).lastCalledWith('update', ...args); }); test('receive chat-message', () => { const chatData = { message: 'foo' }; mockSocket.receive('chat', 'default', chatData); expect(transportDataCallback).toHaveBeenCalledWith({ type: 'chat', args: ['default', chatData], }); }); test('send chat-message', () => { const message: ChatMessage = { id: '0', sender: '0', payload: { message: 'foo' }, }; transport.sendChatMessage('matchID', message); expect(mockSocket.emit).lastCalledWith( 'chat', 'matchID', message, transport.getCredentials() ); }); }); describe('multiplayer delta state', () => { const mockSocket = new MockSocket(); const transportDataCallback = jest.fn(); const transport = new TransportAdapter({ socket: mockSocket, transportDataCallback, game: ProcessGameConfig({}), gameKey: {}, }); transport.connect(); beforeEach(jest.clearAllMocks); test('receive patch', () => { const patch1 = [ 'default', 0, 1, [{ op: 'replace', path: '/_stateID', value: 1 }], [], ]; mockSocket.receive('patch', ...patch1); expect(transportDataCallback).toHaveBeenCalledWith({ type: 'patch', args: patch1, }); }); }); describe('server option', () => { const hostname = 'host'; const port = '1234'; test('without protocol', () => { const server = hostname + ':' + port; const m = new TransportAdapter({ server, transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); m.connect(); expect(m.socket.io.engine.hostname).toEqual(hostname); expect(m.socket.io.engine.port).toEqual(port); expect(m.socket.io.engine.secure).toEqual(false); }); test('without trailing slash', () => { const server = 'http://' + hostname + ':' + port; const m = new SocketIOTransport({ server, transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); m.connect(); expect((m.socket.io as any).uri).toEqual(server + '/default'); }); test('https', () => { const serverWithProtocol = 'https://' + hostname + ':' + port + '/'; const m = new TransportAdapter({ server: serverWithProtocol, transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); m.connect(); expect(m.socket.io.engine.hostname).toEqual(hostname); expect(m.socket.io.engine.port).toEqual(port); expect(m.socket.io.engine.secure).toEqual(true); }); test('http', () => { const serverWithProtocol = 'http://' + hostname + ':' + port + '/'; const m = new TransportAdapter({ server: serverWithProtocol, transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); m.connect(); expect(m.socket.io.engine.hostname).toEqual(hostname); expect(m.socket.io.engine.port).toEqual(port); expect(m.socket.io.engine.secure).toEqual(false); }); test('no server set', () => { const m = new TransportAdapter({ transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); m.connect(); expect(m.socket.io.engine.hostname).not.toEqual(hostname); expect(m.socket.io.engine.port).not.toEqual(port); }); }); ================================================ FILE: src/client/transport/socketio.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import * as ioNamespace from 'socket.io-client'; const io = ioNamespace.default; import type { Master } from '../../master/master'; import { Transport } from './transport'; import type { Operation } from 'rfc6902'; import type { TransportOpts } from './transport'; import type { CredentialedActionShape, FilteredMetadata, LogEntry, PlayerID, State, SyncInfo, ChatMessage, } from '../../types'; type SocketOpts = Partial< ioNamespace.SocketOptions & ioNamespace.ManagerOptions >; interface SocketIOOpts { server?: string; socketOpts?: SocketOpts; } type SocketIOTransportOpts = TransportOpts & SocketIOOpts & { socket?; }; /** * SocketIO * * Transport interface that interacts with the Master via socket.io. */ export class SocketIOTransport extends Transport { server: string; socket: ioNamespace.Socket; socketOpts: SocketOpts; /** * Creates a new Multiplayer instance. * @param {object} socket - Override for unit tests. * @param {object} socketOpts - Options to pass to socket.io. * @param {object} store - Redux store * @param {string} matchID - The game ID to connect to. * @param {string} playerID - The player ID associated with this client. * @param {string} credentials - Authentication credentials * @param {string} gameName - The game type (the `name` field in `Game`). * @param {string} numPlayers - The number of players. * @param {string} server - The game server in the form of 'hostname:port'. Defaults to the server serving the client if not provided. */ constructor({ socket, socketOpts, server, ...opts }: SocketIOTransportOpts) { super(opts); this.server = server; this.socket = socket; this.socketOpts = socketOpts; } sendAction(state: State, action: CredentialedActionShape.Any): void { const args: Parameters = [ action, state._stateID, this.matchID, this.playerID, ]; this.socket.emit('update', ...args); } sendChatMessage(matchID: string, chatMessage: ChatMessage): void { const args: Parameters = [ matchID, chatMessage, this.credentials, ]; this.socket.emit('chat', ...args); } connect(): void { if (!this.socket) { if (this.server) { let server = this.server; if (server.search(/^https?:\/\//) == -1) { server = 'http://' + this.server; } if (server.slice(-1) != '/') { // add trailing slash if not already present server = server + '/'; } this.socket = io(server + this.gameName, this.socketOpts); } else { this.socket = io('/' + this.gameName, this.socketOpts); } } // Called when another player makes a move and the // master broadcasts the update as a patch to other clients (including // this one). this.socket.on( 'patch', ( matchID: string, prevStateID: number, stateID: number, patch: Operation[], deltalog: LogEntry[] ) => { this.notifyClient({ type: 'patch', args: [matchID, prevStateID, stateID, patch, deltalog], }); } ); // Called when another player makes a move and the // master broadcasts the update to other clients (including // this one). this.socket.on( 'update', (matchID: string, state: State, deltalog: LogEntry[]) => { this.notifyClient({ type: 'update', args: [matchID, state, deltalog], }); } ); // Called when the client first connects to the master // and requests the current game state. this.socket.on('sync', (matchID: string, syncInfo: SyncInfo) => { this.notifyClient({ type: 'sync', args: [matchID, syncInfo] }); }); // Called when new player joins the match or changes // it's connection status this.socket.on( 'matchData', (matchID: string, matchData: FilteredMetadata) => { this.notifyClient({ type: 'matchData', args: [matchID, matchData] }); } ); this.socket.on('chat', (matchID: string, chatMessage: ChatMessage) => { this.notifyClient({ type: 'chat', args: [matchID, chatMessage] }); }); // Keep track of connection status. this.socket.on('connect', () => { // Initial sync to get game state. this.requestSync(); this.setConnectionStatus(true); }); this.socket.on('disconnect', () => { this.setConnectionStatus(false); }); } disconnect(): void { this.socket.close(); this.socket = null; this.setConnectionStatus(false); } requestSync(): void { if (this.socket) { const args: Parameters = [ this.matchID, this.playerID, this.credentials, this.numPlayers, ]; this.socket.emit('sync', ...args); } } updateMatchID(id: string): void { this.matchID = id; this.requestSync(); } updatePlayerID(id: PlayerID): void { this.playerID = id; this.requestSync(); } updateCredentials(credentials?: string): void { this.credentials = credentials; this.requestSync(); } } export function SocketIO({ server, socketOpts }: SocketIOOpts = {}) { return (transportOpts: SocketIOTransportOpts) => new SocketIOTransport({ server, socketOpts, ...transportOpts, }); } ================================================ FILE: src/client/transport/transport.test.ts ================================================ import { Transport } from './transport'; import { ProcessGameConfig } from '../../core/game'; describe('Transport', () => { class SimpleTransport extends Transport { connect() {} disconnect() {} sendAction() {} sendChatMessage() {} requestSync() {} updateMatchID() {} updatePlayerID() {} updateCredentials() {} get(key: 'connectionStatusCallback') { return this[key].bind(this); } } test('base class sets up callbacks', () => { const transport = new SimpleTransport({ transportDataCallback: () => {}, game: ProcessGameConfig({}), gameKey: {}, }); expect(transport.get('connectionStatusCallback')()).toBeUndefined(); }); }); ================================================ FILE: src/client/transport/transport.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import type { ProcessGameConfig } from '../../core/game'; import type { TransportData } from '../../master/master'; import type { Game, PlayerID, CredentialedActionShape, State, SyncInfo, ChatMessage, } from '../../types'; export type MetadataCallback = (metadata: SyncInfo['filteredMetadata']) => void; export type ChatCallback = (message: ChatMessage) => void; export interface TransportOpts { transportDataCallback: (data: TransportData) => void; gameName?: string; gameKey: Game; game: ReturnType; playerID?: PlayerID; matchID?: string; credentials?: string; numPlayers?: number; } export abstract class Transport { protected gameName: string; protected playerID: PlayerID | null; protected matchID: string; protected credentials?: string; protected numPlayers: number; /** Callback to pass transport data back to the client. */ private transportDataCallback: (data: TransportData) => void; /** Callback to let the client know when the connection status has changed. */ private connectionStatusCallback: () => void = () => {}; isConnected = false; constructor({ transportDataCallback, gameName, playerID, matchID, credentials, numPlayers, }: TransportOpts) { this.transportDataCallback = transportDataCallback; this.gameName = gameName || 'default'; this.playerID = playerID || null; this.matchID = matchID || 'default'; this.credentials = credentials; this.numPlayers = numPlayers || 2; } /** Subscribe to connection state changes. */ subscribeToConnectionStatus(fn: () => void): void { this.connectionStatusCallback = fn; } /** Transport implementations should call this when they connect/disconnect. */ protected setConnectionStatus(isConnected: boolean): void { this.isConnected = isConnected; this.connectionStatusCallback(); } /** Transport implementations should call this when they receive data from a master. */ protected notifyClient(data: TransportData): void { this.transportDataCallback(data); } /** Called by the client to connect the transport. */ abstract connect(): void; /** Called by the client to disconnect the transport. */ abstract disconnect(): void; /** Called by the client to dispatch an action via the transport. */ abstract sendAction(state: State, action: CredentialedActionShape.Any): void; /** Called by the client to dispatch a chat message via the transport. */ abstract sendChatMessage(matchID: string, chatMessage: ChatMessage): void; /** Called by the client to request a sync action from the transport. */ abstract requestSync(): void; /** Called by the client to update the matchID it wants to connect to. */ abstract updateMatchID(id: string): void; /** Called by the client to update the playerID it is playing as. */ abstract updatePlayerID(id: PlayerID): void; /** Called by the client to update the credentials it is using. */ abstract updateCredentials(credentials?: string): void; } ================================================ FILE: src/core/action-creators.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import * as Actions from './action-types'; import type { SyncInfo, State, LogEntry } from '../types'; import type { Operation } from 'rfc6902'; /** * Generate a move to be dispatched to the game move reducer. * * @param {string} type - The move type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ export const makeMove = ( type: string, args?: any, playerID?: string | null, credentials?: string ) => ({ type: Actions.MAKE_MOVE as typeof Actions.MAKE_MOVE, payload: { type, args, playerID, credentials }, }); /** * Generate a game event to be dispatched to the flow reducer. * * @param {string} type - The event type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ export const gameEvent = ( type: string, args?: any, playerID?: string | null, credentials?: string ) => ({ type: Actions.GAME_EVENT as typeof Actions.GAME_EVENT, payload: { type, args, playerID, credentials }, }); /** * Generate an automatic game event that is a side-effect of a move. * @param {string} type - The event type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ export const automaticGameEvent = ( type: string, args: any, playerID?: string | null, credentials?: string ) => ({ type: Actions.GAME_EVENT as typeof Actions.GAME_EVENT, payload: { type, args, playerID, credentials }, automatic: true, }); export const sync = (info: SyncInfo) => ({ type: Actions.SYNC as typeof Actions.SYNC, state: info.state, log: info.log, initialState: info.initialState, clientOnly: true as const, }); /** * Used to update the Redux store's state with patch in response to * an action coming from another player. * @param prevStateID previous stateID * @param stateID stateID after this patch * @param {Operation[]} patch - The patch to apply. * @param {LogEntry[]} deltalog - A log delta. */ export const patch = ( prevStateID: number, stateID: number, patch: Operation[], deltalog: LogEntry[] ) => ({ type: Actions.PATCH as typeof Actions.PATCH, prevStateID, stateID, patch, deltalog, clientOnly: true as const, }); /** * Used to update the Redux store's state in response to * an action coming from another player. * @param {object} state - The state to restore. * @param {Array} deltalog - A log delta. */ export const update = (state: State, deltalog: LogEntry[]) => ({ type: Actions.UPDATE as typeof Actions.UPDATE, state, deltalog, clientOnly: true as const, }); /** * Used to reset the game state. * @param {object} state - The initial state. */ export const reset = (state: State) => ({ type: Actions.RESET as typeof Actions.RESET, state, clientOnly: true as const, }); /** * Used to undo the last move. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ export const undo = (playerID?: string | null, credentials?: string) => ({ type: Actions.UNDO as typeof Actions.UNDO, payload: { type: null, args: null, playerID, credentials }, }); /** * Used to redo the last undone move. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ export const redo = (playerID?: string | null, credentials?: string) => ({ type: Actions.REDO as typeof Actions.REDO, payload: { type: null, args: null, playerID, credentials }, }); /** * Allows plugins to define their own actions and intercept them. */ export const plugin = ( type: string, args?: any, playerID?: string | null, credentials?: string ) => ({ type: Actions.PLUGIN as typeof Actions.PLUGIN, payload: { type, args, playerID, credentials }, }); /** * Private action used to strip transient metadata (e.g. errors) from the game * state. */ export const stripTransients = () => ({ type: Actions.STRIP_TRANSIENTS as typeof Actions.STRIP_TRANSIENTS, }); ================================================ FILE: src/core/action-types.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ export const MAKE_MOVE = 'MAKE_MOVE'; export const GAME_EVENT = 'GAME_EVENT'; export const REDO = 'REDO'; export const RESET = 'RESET'; export const SYNC = 'SYNC'; export const UNDO = 'UNDO'; export const UPDATE = 'UPDATE'; export const PATCH = 'PATCH'; export const PLUGIN = 'PLUGIN'; export const STRIP_TRANSIENTS = 'STRIP_TRANSIENTS'; ================================================ FILE: src/core/backwards-compatibility.ts ================================================ type MoveLimitOptions = { minMoves?: number; maxMoves?: number; moveLimit?: number; }; /** * Adjust the given options to use the new minMoves/maxMoves if a legacy moveLimit was given * @param options The options object to apply backwards compatibility to * @param enforceMinMoves Use moveLimit to set both minMoves and maxMoves */ export function supportDeprecatedMoveLimit( options: MoveLimitOptions, enforceMinMoves = false ) { if (options.moveLimit) { if (enforceMinMoves) { options.minMoves = options.moveLimit; } options.maxMoves = options.moveLimit; delete options.moveLimit; } } ================================================ FILE: src/core/constants.ts ================================================ /** * Moves can return this when they want to indicate * that the combination of arguments is illegal and * the move ought to be discarded. */ export const INVALID_MOVE = 'INVALID_MOVE'; ================================================ FILE: src/core/errors.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ export enum UpdateErrorType { // The action’s credentials were missing or invalid UnauthorizedAction = 'update/unauthorized_action', // The action’s matchID was not found MatchNotFound = 'update/match_not_found', // Could not apply Patch operation (rfc6902). PatchFailed = 'update/patch_failed', } export enum ActionErrorType { // The action contained a stale state ID StaleStateId = 'action/stale_state_id', // The requested move is unknown or not currently available UnavailableMove = 'action/unavailable_move', // The move declared it was invalid (INVALID_MOVE constant) InvalidMove = 'action/invalid_move', // The player making the action is not currently active InactivePlayer = 'action/inactive_player', // The game has finished GameOver = 'action/gameover', // The requested action is disabled (e.g. undo/redo, events) ActionDisabled = 'action/action_disabled', // The requested action is not currently possible ActionInvalid = 'action/action_invalid', // The requested action was declared invalid by a plugin PluginActionInvalid = 'action/plugin_invalid', } ================================================ FILE: src/core/flow.test.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { makeMove, gameEvent } from './action-creators'; import { Client } from '../client/client'; import { Flow } from './flow'; import { TurnOrder } from './turn-order'; import { error } from '../core/logger'; import type { Ctx, State, Game, PlayerID, MoveFn } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); afterEach(jest.clearAllMocks); describe('phases', () => { test('invalid phase name', () => { const flow = Flow({ phases: { '': {} }, }); flow.init({ ctx: flow.ctx(2) } as State); expect(error).toHaveBeenCalledWith('cannot specify phase with empty name'); }); test('onBegin / onEnd', () => { const flow = Flow({ phases: { A: { start: true, onBegin: ({ G }) => ({ ...G, setupA: true }), onEnd: ({ G }) => ({ ...G, cleanupA: true }), next: 'B', }, B: { onBegin: ({ G }) => ({ ...G, setupB: true }), onEnd: ({ G }) => ({ ...G, cleanupB: true }), next: 'A', }, }, turn: { order: { first: ({ G }) => { if (G.setupB && !G.cleanupB) return 1; return 0; }, next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.G).toMatchObject({ setupA: true }); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.G).toMatchObject({ setupA: true, cleanupA: true, setupB: true, }); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.G).toMatchObject({ setupA: true, cleanupA: true, setupB: true, cleanupB: true, }); expect(state.ctx.currentPlayer).toBe('0'); }); test('endIf', () => { const flow = Flow({ phases: { A: { start: true, endIf: () => true, next: 'B' }, B: {} }, }); const state = { ctx: flow.ctx(2) } as State; { const t = flow.processEvent(state, gameEvent('endPhase')); expect(t.ctx.phase).toBe('B'); } { const t = flow.processEvent(state, gameEvent('endTurn')); expect(t.ctx.phase).toBe('B'); } { const t = flow.processMove(state, makeMove('').payload); expect(t.ctx.phase).toBe('B'); } }); describe('onEnd', () => { let client: ReturnType; beforeAll(() => { const game: Game = { endIf: () => true, onEnd: ({ G }) => { G.onEnd = true; }, }; client = Client({ game }); }); test('works', () => { expect(client.getState().G).toEqual({ onEnd: true, }); }); }); test('end phase on move', () => { let endPhaseACount = 0; let endPhaseBCount = 0; const flow = Flow({ phases: { A: { start: true, endIf: () => true, onEnd: () => ++endPhaseACount, next: 'B', }, B: { endIf: () => false, onEnd: () => ++endPhaseBCount, }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; expect(state.ctx.phase).toBe('A'); state = flow.processMove(state, makeMove('').payload); expect(state.ctx.phase).toBe('B'); expect(endPhaseACount).toEqual(1); expect(endPhaseBCount).toEqual(0); }); test('endPhase returns to null phase', () => { const flow = Flow({ phases: { A: { start: true }, B: {}, C: {} }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.phase).toBe('A'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.phase).toBe(null); }); test('increment playOrderPos on phase end', () => { const flow = Flow({ phases: { A: { start: true, next: 'B' }, B: { next: 'A' } }, }); let state = { G: {}, ctx: flow.ctx(3) } as State; state = flow.init(state); expect(state.ctx.playOrderPos).toBe(0); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.playOrderPos).toBe(1); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.playOrderPos).toBe(2); }); describe('setPhase', () => { let flow: ReturnType; beforeEach(() => { flow = Flow({ phases: { A: { start: true }, B: {} }, }); }); test('basic', () => { let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.phase).toBe('A'); state = flow.processEvent(state, gameEvent('setPhase', 'B')); expect(state.ctx.phase).toBe('B'); }); test('invalid arg', () => { let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.phase).toBe('A'); state = flow.processEvent(state, gameEvent('setPhase', 'C')); expect(error).toBeCalledWith('invalid phase: C'); expect(state.ctx.phase).toBe(null); }); }); }); describe('turn', () => { test('onEnd', () => { const onEnd = jest.fn(({ G }) => G); const flow = Flow({ turn: { onEnd }, }); const state = { ctx: flow.ctx(2) } as State; flow.init(state); expect(onEnd).not.toHaveBeenCalled(); flow.processEvent(state, gameEvent('endTurn')); expect(onEnd).toHaveBeenCalled(); }); describe('onMove', () => { const onMove = () => ({ A: true }); test('top level callback', () => { const flow = Flow({ turn: { onMove } }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.processMove(state, makeMove('').payload); expect(state.G).toEqual({ A: true }); }); test('phase specific callback', () => { const flow = Flow({ turn: { onMove }, phases: { B: { turn: { onMove: () => ({ B: true }) } } }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.processMove(state, makeMove('').payload); expect(state.G).toEqual({ A: true }); state = flow.processEvent(state, gameEvent('setPhase', 'B')); state = flow.processMove(state, makeMove('').payload); expect(state.G).toEqual({ B: true }); }); test('ctx with playerID', () => { const playerID = 'playerID'; const flow = Flow({ turn: { onMove: ({ playerID }) => ({ playerID }) }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.processMove( state, makeMove('', undefined, 'playerID').payload ); expect(state.G.playerID).toEqual(playerID); }); }); describe('minMoves', () => { describe('without phases', () => { const flow = Flow({ turn: { minMoves: 2, }, }); test('player cannot endTurn if not enough moves were made', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); }); test('player can endTurn after enough moves were made', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); }); }); describe('with phases', () => { const flow = Flow({ turn: { minMoves: 2 }, phases: { B: { turn: { minMoves: 1, }, }, }, }); test('player cannot endTurn if not enough moves were made in default phase', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); }); test('player can endTurn after enough moves were made in default phase', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); }); test('player cannot endTurn if no move was made in explicit phase', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); state = flow.processMove(state, makeMove('move', null, '1').payload); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('setPhase', 'B')); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); }); test('player can endTurn after having made a move, fewer moves needed in explicit phase', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); state = flow.processMove(state, makeMove('move', null, '1').payload); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('setPhase', 'B')); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(4); expect(state.ctx.currentPlayer).toBe('1'); }); }); }); describe('maxMoves', () => { describe('without phases', () => { const flow = Flow({ turn: { maxMoves: 2, }, }); test('manual endTurn works, even if not enough moves were made', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); }); test('turn automatically ends after making enough moves', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processMove(state, makeMove('move', null, '0').payload); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); }); }); describe('with phases', () => { const flow = Flow({ turn: { maxMoves: 2 }, phases: { B: { turn: { maxMoves: 1 }, }, }, }); test('manual endTurn works in all phases, even if fewer than maxMoves have been made', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('setPhase', 'B')); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.turn).toBe(4); expect(state.ctx.currentPlayer).toBe('1'); }); test('automatic endTurn triggers after fewer moves in different phase', () => { let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processMove(state, makeMove('move', null, '0').payload); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('setPhase', 'B')); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('move', null, '0').payload); expect(state.ctx.turn).toBe(4); expect(state.ctx.currentPlayer).toBe('1'); }); }); test('with noLimit moves', () => { const flow = Flow({ turn: { maxMoves: 2, }, moves: { A: () => {}, B: { move: () => {}, noLimit: true, }, }, }); let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.numMoves).toBe(0); state = flow.processMove(state, makeMove('A', null, '0').payload); expect(state.ctx.turn).toBe(1); expect(state.ctx.numMoves).toBe(1); state = flow.processMove(state, makeMove('B', null, '0').payload); expect(state.ctx.turn).toBe(1); expect(state.ctx.numMoves).toBe(1); state = flow.processMove(state, makeMove('A', null, '0').payload); expect(state.ctx.turn).toBe(2); expect(state.ctx.numMoves).toBe(0); }); }); describe('endIf', () => { test('global', () => { const game: Game = { moves: { A: () => ({ endTurn: true }), B: ({ G }) => G, }, turn: { endIf: ({ G }) => G.endTurn }, }; const client = Client({ game }); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.B(); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.A(); expect(client.getState().ctx.currentPlayer).toBe('1'); }); test('phase specific', () => { const game: Game = { moves: { A: () => ({ endTurn: true }), B: ({ G }) => G, }, phases: { A: { start: true, turn: { endIf: ({ G }) => G.endTurn } }, }, }; const client = Client({ game }); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.B(); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.A(); expect(client.getState().ctx.currentPlayer).toBe('1'); }); test('return value', () => { const game: Game = { moves: { A: ({ G }) => G, }, turn: { endIf: () => ({ next: '2' }) }, }; const client = Client({ game, numPlayers: 3 }); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.A(); expect(client.getState().ctx.currentPlayer).toBe('2'); }); }); test('endTurn is not called twice in one move', () => { const flow = Flow({ turn: { endIf: () => true }, phases: { A: { start: true, endIf: ({ G }) => G.endPhase, next: 'B' }, B: {}, }, }); let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.turn).toBe(1); state = flow.processMove(state, makeMove('').payload); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.turn).toBe(2); state.G = { endPhase: true }; state = flow.processMove(state, makeMove('').payload); expect(state.ctx.phase).toBe('B'); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.turn).toBe(3); }); }); describe('stages', () => { let client: ReturnType; beforeAll(() => { const A = () => {}; const B = () => {}; const game: Game = { moves: { A }, turn: { stages: { B: { moves: { B } }, C: {}, }, }, }; client = Client({ game }); }); beforeEach(() => { jest.resetAllMocks(); }); describe('no stage', () => { test('A is allowed', () => { client.moves.A(); expect(error).not.toBeCalled(); }); test('B is not allowed', () => { client.moves.B(); expect(error).toBeCalledWith('disallowed move: B'); }); }); describe('stage B', () => { beforeAll(() => { client.events.setStage('B'); }); test('A is not allowed', () => { client.moves.A(); expect(error).toBeCalledWith('disallowed move: A'); }); test('B is allowed', () => { client.moves.B(); expect(error).not.toBeCalled(); }); }); describe('stage C', () => { beforeAll(() => { client.events.setStage('C'); }); test('A is allowed', () => { client.moves.A(); expect(error).not.toBeCalled(); }); test('B is not allowed', () => { client.moves.B(); expect(error).toBeCalledWith('disallowed move: B'); }); }); test('stage updates can be reacted to in turn.endIf', () => { const client = Client({ game: { turn: { activePlayers: { all: 'A', }, stages: { A: { moves: { leaveStage: ({ events }) => void events.endStage(), }, }, }, endIf: ({ ctx }) => ctx.activePlayers === null, }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); client.updatePlayerID('0'); client.moves.leaveStage(); state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.activePlayers).toEqual({ '1': 'A' }); client.updatePlayerID('1'); client.moves.leaveStage(); state = client.getState(); expect(state.ctx.turn).toBe(2); expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); }); test('stage changes due to move limits are seen by turn.endIf', () => { const client = Client({ game: { turn: { activePlayers: { currentPlayer: 'A', maxMoves: 1, }, endIf: ({ ctx }) => ctx.activePlayers === null, stages: { A: { moves: { A: () => ({ moved: true }), }, }, }, }, }, }); let state = client.getState(); expect(state.ctx.activePlayers).toEqual({ '0': 'A' }); client.moves.A(); state = client.getState(); expect(state.ctx.activePlayers).toEqual({ '1': 'A' }); }); }); describe('stage events', () => { describe('setStage', () => { test('basic', () => { const flow = Flow({}); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toBeNull(); state = flow.processEvent(state, gameEvent('setStage', 'A')); expect(state.ctx.activePlayers).toEqual({ '0': 'A' }); }); test('object syntax', () => { const flow = Flow({}); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toBeNull(); state = flow.processEvent(state, gameEvent('setStage', { stage: 'A' })); expect(state.ctx.activePlayers).toEqual({ '0': 'A' }); }); test('with multiple active players', () => { const flow = Flow({ turn: { activePlayers: { all: 'A', minMoves: 2, maxMoves: 5 }, }, }); let state = { G: {}, ctx: flow.ctx(3) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' }); state = flow.processEvent( state, gameEvent('setStage', { stage: 'B', minMoves: 1 }) ); expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'A', '2': 'A' }); state = flow.processEvent( state, gameEvent('setStage', { stage: 'B', maxMoves: 1 }, '1') ); expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'B', '2': 'A' }); }); test('resets move count', () => { const flow = Flow({ moves: { A: () => {} }, turn: { activePlayers: { currentPlayer: 'A' }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 }); state = flow.processMove(state, makeMove('A', null, '0').payload); expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 }); state = flow.processEvent(state, gameEvent('setStage', 'B')); expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 }); }); test('with min moves', () => { const flow = Flow({}); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toBeNull(); state = flow.processEvent( state, gameEvent('setStage', { stage: 'A', minMoves: 1 }) ); expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 1 }); expect(state.ctx._activePlayersMaxMoves).toBeNull(); }); test('with max moves', () => { const flow = Flow({}); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toBeNull(); state = flow.processEvent( state, gameEvent('setStage', { stage: 'A', maxMoves: 1 }) ); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1 }); }); test('empty argument ends stage', () => { const flow = Flow({ turn: { activePlayers: { currentPlayer: 'A' } } }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toEqual({ '0': 'A' }); state = flow.processEvent(state, gameEvent('setStage', {})); expect(state.ctx.activePlayers).toBeNull(); }); describe('disallowed in hooks', () => { const setStage: MoveFn = ({ events }) => { events.setStage('A'); }; test('phase.onBegin', () => { const game: Game = { phases: { A: { start: true, onBegin: setStage, }, }, }; Client({ game }); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/); }); test('phase.onEnd', () => { const game: Game = { phases: { A: { start: true, onEnd: setStage, }, }, }; const client = Client({ game }); expect(error).not.toHaveBeenCalled(); client.events.endPhase(); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/); }); test('turn.onBegin', () => { const game: Game = { turn: { onBegin: setStage, }, }; Client({ game }); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `turn.onBegin`/); }); test('turn.onEnd', () => { const game: Game = { turn: { onEnd: setStage, }, }; const client = Client({ game }); expect(error).not.toHaveBeenCalled(); client.events.endTurn(); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/); }); }); }); describe('endStage', () => { test('basic', () => { const flow = Flow({ turn: { activePlayers: { currentPlayer: 'A' }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toEqual({ '0': 'A' }); state = flow.processEvent(state, gameEvent('endStage')); expect(state.ctx.activePlayers).toBeNull(); }); test('with multiple active players', () => { const flow = Flow({ turn: { activePlayers: { all: 'A', maxMoves: 5 }, }, }); let state = { G: {}, ctx: flow.ctx(3) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' }); state = flow.processEvent(state, gameEvent('endStage')); expect(state.ctx.activePlayers).toEqual({ '1': 'A', '2': 'A' }); }); test('with min moves', () => { const flow = Flow({ turn: { activePlayers: { all: 'A', minMoves: 2 }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); state = flow.processEvent(state, gameEvent('endStage')); // player 0 is not allowed to end the stage, they haven't made any move yet expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endStage')); // player 0 is still not allowed to end the stage, they haven't made the minimum number of moves expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); state = flow.processMove(state, makeMove('move', null, '0').payload); state = flow.processEvent(state, gameEvent('endStage')); // having made 2 moves, player 0 was allowed to end the stage expect(state.ctx.activePlayers).toEqual({ '1': 'A' }); }); test('maintains move count', () => { const flow = Flow({ moves: { A: () => {} }, turn: { activePlayers: { currentPlayer: 'A' }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 }); state = flow.processMove(state, makeMove('A', null, '0').payload); expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 }); state = flow.processEvent(state, gameEvent('endStage')); expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 }); }); test('sets to next', () => { const flow = Flow({ turn: { activePlayers: { currentPlayer: 'A1', others: 'B1' }, stages: { A1: { next: 'A2' }, B1: { next: 'B2' }, }, }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.activePlayers).toMatchObject({ '0': 'A1', '1': 'B1', }); state = flow.processEvent(state, gameEvent('endStage', null, '0')); expect(state.ctx.activePlayers).toMatchObject({ '0': 'A2', '1': 'B1', }); state = flow.processEvent(state, gameEvent('endStage', null, '1')); expect(state.ctx.activePlayers).toMatchObject({ '0': 'A2', '1': 'B2', }); }); describe('disallowed in hooks', () => { const endStage: MoveFn = ({ events }) => { events.endStage(); }; test('phase.onBegin', () => { const game: Game = { phases: { A: { start: true, onBegin: endStage, }, }, }; Client({ game }); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/); }); test('phase.onEnd', () => { const game: Game = { phases: { A: { start: true, onEnd: endStage, }, }, }; const client = Client({ game }); expect(error).not.toHaveBeenCalled(); client.events.endPhase(); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/); }); test('turn.onBegin', () => { const game: Game = { turn: { onBegin: endStage, }, }; Client({ game }); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `turn.onBegin`/); }); test('turn.onEnd', () => { const game: Game = { turn: { onEnd: endStage, }, }; const client = Client({ game }); expect(error).not.toHaveBeenCalled(); client.events.endTurn(); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/); }); }); }); describe('setActivePlayers', () => { test('basic', () => { const client = Client({ numPlayers: 3, game: { turn: { onBegin: ({ events }) => { events.setActivePlayers({ currentPlayer: 'A' }); }, }, moves: { updateActivePlayers: ({ events }) => { events.setActivePlayers({ others: 'B' }); }, }, }, }); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A' }); client.moves.updateActivePlayers(); expect(client.getState().ctx.activePlayers).toEqual({ '1': 'B', '2': 'B', }); }); describe('in hooks', () => { const setActivePlayers: MoveFn = ({ events }) => { events.setActivePlayers({ currentPlayer: 'A' }); }; test('disallowed in phase.onBegin', () => { const game: Game = { phases: { A: { start: true, onBegin: setActivePlayers, }, }, }; Client({ game }); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/); }); test('disallowed in phase.onEnd', () => { const game: Game = { phases: { A: { start: true, onEnd: setActivePlayers, }, }, }; const client = Client({ game }); expect(error).not.toHaveBeenCalled(); client.events.endPhase(); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/); }); test('allowed in turn.onBegin', () => { const client = Client({ game: { turn: { onBegin: setActivePlayers }, }, }); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A' }); expect(error).not.toHaveBeenCalled(); }); test('disallowed in turn.onEnd', () => { const game: Game = { turn: { onEnd: setActivePlayers, }, }; const client = Client({ game }); expect(error).not.toHaveBeenCalled(); client.events.endTurn(); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/); }); }); }); }); test('init', () => { let flow = Flow({ phases: { A: { start: true, onEnd: () => ({ done: true }) } }, }); const orig = flow.ctx(2); let state = { G: {}, ctx: orig } as State; state = flow.processEvent(state, gameEvent('init')); expect(state).toEqual({ G: {}, ctx: orig }); flow = Flow({ phases: { A: { start: true, onBegin: () => ({ done: true }) } }, }); state = { ctx: orig } as State; state = flow.init(state); expect(state.G).toMatchObject({ done: true }); }); test('next', () => { const flow = Flow({ phases: { A: { start: true, next: () => 'C' }, B: {}, C: {}, }, }); let state = { ctx: flow.ctx(3) } as State; state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.phase).toEqual('C'); }); describe('endIf', () => { test('basic', () => { const flow = Flow({ endIf: ({ G }) => G.win }); let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.gameover).toBe(undefined); state.G = { win: 'A' }; { const t = flow.processEvent(state, gameEvent('endTurn')); expect(t.ctx.gameover).toBe('A'); } { const t = flow.processMove(state, makeMove('move').payload); expect(t.ctx.gameover).toBe('A'); } }); test('phase automatically ends', () => { const game: Game = { phases: { A: { start: true, moves: { A: () => ({ win: 'A' }), B: ({ G }) => G, }, }, }, endIf: ({ G }) => G.win, }; const client = Client({ game }); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.B(); expect(client.getState().ctx.gameover).toBe(undefined); expect(client.getState().ctx.currentPlayer).toBe('0'); client.moves.A(); expect(client.getState().ctx.gameover).toBe('A'); expect( client.getState().deltalog[client.getState().deltalog.length - 1].action .payload.type ).toBe('endPhase'); }); test('during game initialization with phases', () => { const flow = Flow({ phases: { A: { start: true, }, }, endIf: () => 'gameover', }); const state = flow.init({ G: {}, ctx: flow.ctx(2) } as State); expect(state.ctx.gameover).toBe('gameover'); }); }); test('isPlayerActive', () => { const playerID = '0'; const flow = Flow({}); expect(flow.isPlayerActive({}, {} as Ctx, playerID)).toBe(false); expect( flow.isPlayerActive( {}, { currentPlayer: '0', activePlayers: { '1': '' } } as unknown as Ctx, playerID ) ).toBe(false); expect(flow.isPlayerActive({}, { currentPlayer: '0' } as Ctx, playerID)).toBe( true ); }); describe('endGame', () => { let client: ReturnType; beforeEach(() => { const game: Game = { events: { endGame: true }, }; client = Client({ game }); }); test('without arguments', () => { client.events.endGame(); expect(client.getState().ctx.gameover).toBe(true); }); test('with arguments', () => { client.events.endGame(42); expect(client.getState().ctx.gameover).toBe(42); }); }); describe('endTurn args', () => { const flow = Flow({ phases: { A: { start: true, next: 'B' }, B: {}, C: {} }, }); const state = { ctx: flow.ctx(3) } as State; beforeEach(() => { jest.resetAllMocks(); }); test('no args', () => { let t = state; t = flow.processEvent(t, gameEvent('endPhase')); t = flow.processEvent(t, gameEvent('endTurn')); expect(t.ctx.playOrderPos).toBe(1); expect(t.ctx.currentPlayer).toBe('1'); expect(t.ctx.phase).toBe('B'); }); test('invalid arg to endTurn', () => { let t = state; t = flow.processEvent(t, gameEvent('endTurn', '2')); expect(error).toBeCalledWith(`invalid argument to endTurn: 2`); expect(t.ctx.currentPlayer).toBe('0'); }); test('valid args', () => { let t = state; t = flow.processEvent(t, gameEvent('endTurn', { next: '2' })); expect(t.ctx.playOrderPos).toBe(2); expect(t.ctx.currentPlayer).toBe('2'); }); }); describe('pass args', () => { const flow = Flow({ phases: { A: { start: true, next: 'B' }, B: {}, C: {} }, }); const state = { ctx: flow.ctx(3) } as State; beforeEach(() => { jest.resetAllMocks(); }); test('no args', () => { let t = state; t = flow.processEvent(t, gameEvent('pass')); expect(t.ctx.turn).toBe(1); expect(t.ctx.playOrderPos).toBe(1); expect(t.ctx.currentPlayer).toBe('1'); }); test('invalid arg to pass', () => { let t = state; t = flow.processEvent(t, gameEvent('pass', '2')); expect(error).toBeCalledWith(`invalid argument to endTurn: 2`); expect(t.ctx.currentPlayer).toBe('0'); }); test('valid args', () => { let t = state; t = flow.processEvent(t, gameEvent('pass', { remove: true })); expect(t.ctx.turn).toBe(1); expect(t.ctx.playOrderPos).toBe(0); expect(t.ctx.currentPlayer).toBe('1'); }); test('removing all players ends phase', () => { let t = state; t = flow.processEvent(t, gameEvent('pass', { remove: true })); t = flow.processEvent(t, gameEvent('pass', { remove: true })); t = flow.processEvent(t, gameEvent('pass', { remove: true })); expect(t.ctx.playOrderPos).toBe(0); expect(t.ctx.currentPlayer).toBe('0'); expect(t.ctx.phase).toBe('B'); }); test('playOrderPos does not go out of bounds when passing at the end of the list', () => { let t = state; t = flow.processEvent(t, gameEvent('pass')); t = flow.processEvent(t, gameEvent('pass')); t = flow.processEvent(t, gameEvent('pass', { remove: true })); expect(t.ctx.currentPlayer).toBe('0'); }); test('removing a player deeper into play order returns correct updated playOrder', () => { let t = state; t = flow.processEvent(t, gameEvent('pass')); t = flow.processEvent(t, gameEvent('pass', { remove: true })); expect(t.ctx.playOrderPos).toBe(1); expect(t.ctx.currentPlayer).toBe('2'); }); }); test('undoable moves', () => { const game: Game = { moves: { A: { move: () => ({ A: true }), undoable: ({ ctx }) => { return ctx.phase == 'A'; }, }, B: { move: () => ({ B: true }), undoable: false, }, C: () => ({ C: true }), }, phases: { A: { start: true }, B: {}, }, }; const client = Client({ game }); client.moves.A(); expect(client.getState().G).toEqual({ A: true }); client.undo(); expect(client.getState().G).toEqual({}); client.moves.B(); expect(client.getState().G).toEqual({ B: true }); client.undo(); expect(client.getState().G).toEqual({ B: true }); client.moves.C(); expect(client.getState().G).toEqual({ C: true }); client.undo(); expect(client.getState().G).toEqual({ B: true }); client.reset(); client.events.setPhase('B'); expect(client.getState().ctx.phase).toBe('B'); client.moves.A(); expect(client.getState().G).toEqual({ A: true }); client.undo(); expect(client.getState().G).toEqual({ A: true }); client.moves.B(); expect(client.getState().G).toEqual({ B: true }); client.undo(); expect(client.getState().G).toEqual({ B: true }); client.moves.C(); expect(client.getState().G).toEqual({ C: true }); client.undo(); expect(client.getState().G).toEqual({ B: true }); }); describe('moveMap', () => { const game: Game = { moves: { A: () => {} }, turn: { stages: { SA: { moves: { A: () => {}, }, }, }, }, phases: { PA: { moves: { A: () => {}, }, turn: { stages: { SB: { moves: { A: () => {}, }, }, }, }, }, }, }; test('basic', () => { const { moveMap } = Flow(game); expect(Object.keys(moveMap)).toEqual(['PA.A', 'PA.SB.A', '.SA.A']); }); }); describe('infinite loops', () => { test('infinite loop of self-ending phases via endIf', () => { const endIf = () => true; const game: Game = { phases: { A: { endIf, next: 'B', start: true }, B: { endIf, next: 'A' }, }, }; const client = Client({ game }); expect(client.getState().ctx.phase).toBe(null); }); test('infinite endPhase loop from phase.onBegin', () => { const onBegin = ({ events }) => void events.endPhase(); const game: Game = { phases: { A: { onBegin, next: 'B', start: true, moves: { a: ({ events }) => void events.endPhase(), }, }, B: { onBegin, next: 'C' }, C: { onBegin, next: 'A' }, }, }; // The onBegin fails to end the phase during initialisation. const client = Client({ game, numPlayers: 3 }); let state = client.getState(); expect(state.ctx.phase).toBe('A'); expect(state.ctx.turn).toBe(1); expect(error).toHaveBeenCalled(); { const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/); } jest.clearAllMocks(); // Moves also fail because of the infinite loop (the game is stuck). client.moves.a(); state = client.getState(); expect(error).toHaveBeenCalled(); { const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/); } expect(state.ctx.phase).toBe('A'); expect(state.ctx.turn).toBe(1); }); test('double phase ending from client event and turn.onEnd', () => { const game: Game = { turn: { onEnd: ({ events }) => void events.endPhase(), }, phases: { A: { next: 'B', start: true }, B: { next: 'C' }, C: { next: 'A' }, }, }; const client = Client({ game }); let state = client.getState(); expect(state.ctx.phase).toBe('A'); expect(state.ctx.turn).toBe(1); client.events.endPhase(); state = client.getState(); expect(state.ctx.phase).toBe('B'); expect(state.ctx.turn).toBe(2); }); test('infinite turn endings from turn.onBegin', () => { const game: Game = { moves: { endTurn: ({ events }) => { events.endTurn(); }, }, turn: { onBegin: ({ events }) => void events.endTurn(), }, }; const client = Client({ game }); const initialState = client.getState(); expect(client.getState().ctx.currentPlayer).toBe('0'); // Trigger infinite loop client.moves.endTurn(); // Expect state to be unchanged and error to be logged. expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState()).toEqual({ ...initialState, deltalog: [] }); }); test('double turn ending from event and endIf', () => { const game: Game = { moves: { endTurn: ({ events }) => { events.endTurn(); }, }, turn: { endIf: () => true, }, }; const client = Client({ game }); // turn.endIf is ignored during game setup. let state = client.getState(); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.turn).toBe(1); // turn.endIf is ignored when the turn was just ended. client.moves.endTurn(); state = client.getState(); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.turn).toBe(2); }); test('endIf that triggers endIf', () => { const game: Game = { phases: { A: { endIf: ({ events }) => { events.setActivePlayers({ currentPlayer: 'A' }); }, }, }, }; const client = Client({ game }); client.events.setPhase('A'); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch( /Events must be called from moves or the `.+` hooks./ ); }); }); describe('events in hooks', () => { const moves = { setAutoEnd: () => ({ shouldEnd: true }), }; describe('endTurn', () => { const conditionalEndTurn = ({ G, events }) => { if (!G.shouldEnd) return; G.shouldEnd = false; events.endTurn(); }; test('can end turn from turn.onBegin', () => { const client = Client({ game: { moves, turn: { onBegin: conditionalEndTurn } }, }); client.moves.setAutoEnd(); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); client.events.endTurn(); state = client.getState(); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); }); test('cannot end turn from phase.onBegin', () => { const client = Client({ game: { moves, phases: { A: { onBegin: conditionalEndTurn }, }, }, }); client.moves.setAutoEnd(); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBeNull(); client.events.setPhase('A'); state = client.getState(); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.phase).toBe('A'); }); test('can end turn from turn.onBegin at start of phase', () => { const client = Client({ game: { moves, phases: { A: { turn: { onBegin: conditionalEndTurn }, }, }, }, }); client.moves.setAutoEnd(); let state = client.getState(); expect(state.ctx.phase).toBeNull(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); client.events.setPhase('A'); state = client.getState(); expect(state.ctx.phase).toBe('A'); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); }); test('cannot end turn from turn.onEnd', () => { const client = Client({ game: { moves, turn: { onEnd: conditionalEndTurn }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); client.moves.setAutoEnd(); client.events.endTurn(); state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/`endTurn` is disallowed in `onEnd` hooks/); }); test('cannot end turn from phase.onEnd', () => { const client = Client({ game: { moves, phases: { A: { start: true, onEnd: conditionalEndTurn, }, }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('A'); client.moves.setAutoEnd(); client.events.endPhase(); state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('A'); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch(/`endTurn` is disallowed in `onEnd` hooks/); }); }); describe('endPhase', () => { const conditionalEndPhase = ({ G, events }) => { if (!G.shouldEnd) return; G.shouldEnd = false; events.endPhase(); }; test('can end phase from turn.onBegin', () => { const client = Client({ game: { moves, phases: { A: { start: true, turn: { onBegin: conditionalEndPhase }, }, }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('A'); client.moves.setAutoEnd(); client.events.endTurn(); state = client.getState(); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBeNull(); }); test('can end phase from phase.onBegin', () => { const client = Client({ game: { moves, phases: { A: { onBegin: conditionalEndPhase }, }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBeNull(); client.moves.setAutoEnd(); client.events.setPhase('A'); state = client.getState(); expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBeNull(); }); test('can end phase from turn.onEnd', () => { const client = Client({ game: { moves, phases: { A: { start: true, turn: { onEnd: conditionalEndPhase }, }, }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('A'); client.moves.setAutoEnd(); client.events.endTurn(); state = client.getState(); // TODO: This is likely not the desired behaviour. Turn 1 is ended, // then the phase is ended, automatically ending turn 2, ending up in turn 3. // Turn 2 is effectively skipped. Works better with TurnOrder.CONTINUE. expect(state.ctx.turn).toBe(3); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBeNull(); }); test('cannot end phase from phase.onEnd', () => { const client = Client({ game: { moves, phases: { A: { start: true, next: 'B', onEnd: conditionalEndPhase, }, B: {}, }, }, }); let state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('A'); client.moves.setAutoEnd(); client.events.endPhase(); state = client.getState(); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('A'); expect(error).toHaveBeenCalled(); const errorMessage = (error as jest.Mock).mock.calls[0][0]; expect(errorMessage).toMatch(/events plugin declared action invalid/); expect(errorMessage).toMatch( /`setPhase` & `endPhase` are disallowed in a phase’s `onEnd` hook/ ); }); }); }); describe('activePlayers', () => { test('sets activePlayers at each turn', () => { const game: Game = { turn: { stages: { A: {}, B: {} }, activePlayers: { currentPlayer: 'A', others: 'B', }, }, }; const client = Client({ game, numPlayers: 3 }); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A', '1': 'B', '2': 'B', }); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'B', '1': 'A', '2': 'B', }); }); }); test('events in hooks triggered by moves should be processed', () => { const game: Game = { turn: { onBegin: ({ events }) => { events.setActivePlayers({ currentPlayer: 'A' }); }, }, moves: { endTurn: ({ events }) => { events.endTurn(); }, }, }; const client = Client({ game, numPlayers: 3 }); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A', }); client.moves.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.activePlayers).toEqual({ '1': 'A', }); }); test('stage events should not be processed out of turn', () => { const game: Game = { phases: { A: { start: true, turn: { activePlayers: { all: 'A1', }, stages: { A1: { moves: { endStage: ({ G, events }) => { G.endStage = true; events.endStage(); }, }, }, }, }, endIf: ({ G }) => G.endStage, next: 'B', }, B: { turn: { activePlayers: { all: 'B1', }, stages: { B1: {}, }, }, }, }, }; const client = Client({ game, numPlayers: 3 }); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A1', '1': 'A1', '2': 'A1', }); client.moves.endStage(); expect(client.getState().ctx.activePlayers).toEqual({ '0': 'B1', '1': 'B1', '2': 'B1', }); }); describe('backwards compatibility for moveLimit', () => { test('turn config maps moveLimit to minMoves/maxMoves', () => { const flow = Flow({ moves: { pass: () => {}, }, turn: { moveLimit: 2, }, }); let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx.turn).toBe(1); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processMove(state, makeMove('pass', null, '0').payload); state = flow.processMove(state, makeMove('pass', null, '0').payload); expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processMove(state, makeMove('pass', null, '1').payload); state = flow.processEvent(state, gameEvent('endTurn', null, '1')); // player should not be able to endTurn because they haven't made minMoves yet expect(state.ctx.turn).toBe(2); expect(state.ctx.currentPlayer).toBe('1'); }); test('setActivePlayers maps moveLimit to maxMoves only', () => { const flow = Flow({}); let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toBeNull(); state = flow.processEvent( state, gameEvent('setActivePlayers', { all: 'A', moveLimit: 1 }) ); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1, '1': 1 }); }); test('setStage maps moveLimit to maxMoves only', () => { const flow = Flow({}); let state = flow.init({ ctx: flow.ctx(2) } as State); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toBeNull(); state = flow.processEvent( state, gameEvent('setStage', { stage: 'A', moveLimit: 2 }) ); expect(state.ctx._activePlayersMinMoves).toBeNull(); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2 }); }); }); // These tests serve to document the order in which the various game hooks // are executed and also to catch any potential breaking changes. describe('hook execution order', () => { const calls: string[] = []; afterEach(() => { calls.length = 0; }); const client = Client({ playerID: '0', game: { moves: { move: () => void calls.push('move'), setStage: ({ events }) => { events.setStage('A'); calls.push('moves.setStage'); }, endStage: ({ events }) => { events.endStage(); calls.push('moves.endStage'); }, setActivePlayers: ({ events }) => { events.setActivePlayers({ all: 'A', minMoves: 1, maxMoves: 1 }); calls.push('moves.setActivePlayers'); }, }, endIf: () => void calls.push('game.endIf'), onEnd: () => void calls.push('game.onEnd'), turn: { activePlayers: { all: 'A' }, endIf: () => void calls.push('turn.endIf'), onBegin: () => void calls.push('turn.onBegin'), onMove: () => void calls.push('turn.onMove'), onEnd: () => void calls.push('turn.onEnd'), order: { first: () => calls.push('turn.order.first') && 0, next: () => calls.push('turn.order.next') && 0, playOrder: () => calls.push('turn.order.playOrder') && ['0', '1'], }, }, phases: { A: { start: true, next: 'B', endIf: () => void calls.push('phaseA.endIf'), onBegin: () => void calls.push('phaseA.onBegin'), onEnd: () => void calls.push('phaseA.onEnd'), }, B: { next: 'A', endIf: () => void calls.push('phaseB.endIf'), onBegin: () => void calls.push('phaseB.onBegin'), onEnd: () => void calls.push('phaseB.onEnd'), }, }, }, }); test('hooks called during setup', () => { expect(calls).toEqual([ 'game.endIf', 'phaseA.endIf', 'phaseA.onBegin', 'game.endIf', 'phaseA.endIf', 'turn.order.playOrder', 'turn.order.first', 'turn.onBegin', 'game.endIf', 'phaseA.endIf', ]); }); test('hooks called on move', () => { client.moves.move(); expect(calls).toEqual([ 'move', 'turn.onMove', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on setStage', () => { client.events.setStage('B'); expect(calls).toEqual([ 'game.endIf', 'phaseA.endIf', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on endStage', () => { client.updatePlayerID('1'); client.events.endStage(); client.updatePlayerID('0'); expect(calls).toEqual([ 'game.endIf', 'phaseA.endIf', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on setActivePlayers', () => { client.events.setActivePlayers({}); expect(calls).toEqual(['game.endIf', 'phaseA.endIf', 'turn.endIf']); }); test('hooks called on setStage triggered by move', () => { client.moves.setStage(); expect(calls).toEqual([ 'moves.setStage', 'turn.onMove', 'game.endIf', 'phaseA.endIf', 'turn.endIf', 'game.endIf', 'phaseA.endIf', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on endStage triggered by move', () => { client.moves.endStage(); expect(calls).toEqual([ 'moves.endStage', 'turn.onMove', 'game.endIf', 'phaseA.endIf', 'turn.endIf', 'game.endIf', 'phaseA.endIf', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on setActivePlayers triggered by move', () => { client.moves.setActivePlayers(); expect(calls).toEqual([ 'moves.setActivePlayers', 'turn.onMove', 'game.endIf', 'phaseA.endIf', 'turn.endIf', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on stage end triggered by maxMoves', () => { client.updatePlayerID('1'); client.moves.move(); client.updatePlayerID('0'); expect(calls).toEqual([ 'move', 'turn.onMove', 'game.endIf', 'phaseA.endIf', 'turn.endIf', ]); }); test('hooks called on endTurn', () => { client.events.endTurn(); expect(calls).toEqual([ 'turn.onEnd', 'game.endIf', 'phaseA.endIf', 'turn.order.next', 'game.endIf', 'phaseA.endIf', 'turn.onBegin', 'game.endIf', 'phaseA.endIf', ]); }); test('hooks called on endPhase', () => { client.events.endPhase(); expect(calls).toEqual([ 'turn.onEnd', 'phaseA.onEnd', 'game.endIf', 'game.endIf', 'phaseB.endIf', 'phaseB.onBegin', 'game.endIf', 'phaseB.endIf', 'turn.order.playOrder', 'turn.order.first', 'turn.onBegin', 'game.endIf', 'phaseB.endIf', ]); }); test('hooks called on endGame', () => { client.events.endGame(5); expect(calls).toEqual(['phaseB.onEnd', 'game.onEnd']); }); }); describe('game function signatures', () => { const moveA = jest.fn(); let game: Game; let client: ReturnType; // Helpers to check the objects game functions are called with. const expectCtx = expect.objectContaining({ numPlayers: 2 }); const expectEvents = expect.objectContaining({ endTurn: expect.any(Function), }); const expectRandom = expect.objectContaining({ D6: expect.any(Function), }); const FnContext = ({ playerID, G = 'G', }: { playerID?: PlayerID; G?: any } = {}) => { const context: any = { G, ctx: expectCtx, events: expectEvents, random: expectRandom, testPluginAPI: { foo: 'bar' }, }; if (playerID !== undefined) context.playerID = playerID; return expect.objectContaining(context); }; beforeEach(() => { game = { setup: jest.fn(() => 'G'), plugins: [ { name: 'testPluginAPI', api: () => ({ foo: 'bar' }), }, ], onEnd: jest.fn(), endIf: jest.fn(({ G }) => G == 'gameover'), moves: { A: (...args) => moveA(...args), endGame: () => 'gameover', }, turn: { order: { playOrder: jest.fn(({ ctx }) => [...Array.from({ length: ctx.numPlayers })].map((_, i) => i + '') ), first: jest.fn(TurnOrder.DEFAULT.first), next: jest.fn(TurnOrder.DEFAULT.next), }, onBegin: jest.fn(), onMove: jest.fn(), onEnd: jest.fn(), endIf: jest.fn(), }, phases: { A: { onBegin: jest.fn(), onEnd: jest.fn(), endIf: jest.fn(), }, }, events: { endPhase: true, }, }; client = Client({ game, playerID: '0' }); }); afterEach(() => { jest.resetAllMocks(); }); test('game.setup', () => { expect(game.setup).lastCalledWith( // setup context object expect.objectContaining({ ctx: expectCtx, events: expectEvents, random: expectRandom, }), // setupData undefined ); }); test('game.onEnd', () => { client.events.endGame(); expect(game.onEnd).lastCalledWith(FnContext()); }); test('game.endIf', () => { client.moves.endGame(); expect(game.endIf).lastCalledWith(FnContext({ G: 'gameover' })); }); test('game.turn.order.playOrder', () => { expect(game.turn.order.playOrder).lastCalledWith(FnContext()); }); test('game.turn.order.first', () => { expect(game.turn.order.first).lastCalledWith(FnContext()); }); test('game.turn.order.next', () => { client.events.endTurn(); expect(game.turn.order.next).lastCalledWith(FnContext()); }); test('game.turn.onBegin', () => { expect(game.turn.onBegin).lastCalledWith(FnContext()); }); test('game.turn.onMove', () => { client.moves.A(); expect(game.turn.onMove).lastCalledWith(FnContext()); }); test('game.turn.onEnd', () => { client.events.endTurn(); expect(game.turn.onEnd).lastCalledWith(FnContext()); }); test('game.turn.endIf', () => { client.moves.A(); expect(game.turn.endIf).lastCalledWith(FnContext()); }); test('move', () => { client.moves.A('arg'); expect(moveA).lastCalledWith(FnContext({ playerID: '0' }), 'arg'); client.moves.A(2, 'args'); expect(moveA).lastCalledWith(FnContext({ playerID: '0' }), 2, 'args'); }); test('game.phases.phase.onBegin', () => { client.events.setPhase('A'); expect(game.phases.A.onBegin).lastCalledWith(FnContext()); }); test('game.phases.phase.onEnd', () => { client.events.setPhase('A'); client.updatePlayerID('1'); client.events.endPhase(); expect(game.phases.A.onEnd).lastCalledWith(FnContext()); }); test('game.phases.phase.endIf', () => { client.events.setPhase('A'); expect(game.phases.A.endIf).lastCalledWith(FnContext()); }); }); ================================================ FILE: src/core/flow.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { SetActivePlayers, UpdateActivePlayersOnceEmpty, InitTurnOrderState, UpdateTurnOrderState, Stage, TurnOrder, } from './turn-order'; import { gameEvent } from './action-creators'; import * as plugin from '../plugins/main'; import * as logging from './logger'; import type { ActionPayload, ActionShape, ActivePlayersArg, State, Ctx, FnContext, LogEntry, Game, PhaseConfig, PlayerID, Move, } from '../types'; import { GameMethod } from './game-methods'; import { supportDeprecatedMoveLimit } from './backwards-compatibility'; /** * Flow * * Creates a reducer that updates ctx (analogous to how moves update G). */ export function Flow({ moves, phases, endIf, onEnd, turn, events, plugins, }: Game) { // Attach defaults. if (moves === undefined) { moves = {}; } if (events === undefined) { events = {}; } if (plugins === undefined) { plugins = []; } if (phases === undefined) { phases = {}; } if (!endIf) endIf = () => undefined; if (!onEnd) onEnd = ({ G }) => G; if (!turn) turn = {}; const phaseMap = { ...phases }; if ('' in phaseMap) { logging.error('cannot specify phase with empty name'); } phaseMap[''] = {}; const moveMap = {}; const moveNames = new Set(); let startingPhase = null; Object.keys(moves).forEach((name) => moveNames.add(name)); const HookWrapper = ( hook: (context: FnContext) => any, hookType: GameMethod ) => { const withPlugins = plugin.FnWrap(hook, hookType, plugins); return (state: State & { playerID?: PlayerID }) => { const pluginAPIs = plugin.GetAPIs(state); return withPlugins({ ...pluginAPIs, G: state.G, ctx: state.ctx, playerID: state.playerID, }); }; }; const TriggerWrapper = (trigger: (context: FnContext) => any) => { return (state: State) => { const pluginAPIs = plugin.GetAPIs(state); return trigger({ ...pluginAPIs, G: state.G, ctx: state.ctx, }); }; }; const wrapped = { onEnd: HookWrapper(onEnd, GameMethod.GAME_ON_END), endIf: TriggerWrapper(endIf), }; for (const phase in phaseMap) { const phaseConfig = phaseMap[phase]; if (phaseConfig.start === true) { startingPhase = phase; } if (phaseConfig.moves !== undefined) { for (const move of Object.keys(phaseConfig.moves)) { moveMap[phase + '.' + move] = phaseConfig.moves[move]; moveNames.add(move); } } if (phaseConfig.endIf === undefined) { phaseConfig.endIf = () => undefined; } if (phaseConfig.onBegin === undefined) { phaseConfig.onBegin = ({ G }) => G; } if (phaseConfig.onEnd === undefined) { phaseConfig.onEnd = ({ G }) => G; } if (phaseConfig.turn === undefined) { phaseConfig.turn = turn; } if (phaseConfig.turn.order === undefined) { phaseConfig.turn.order = TurnOrder.DEFAULT; } if (phaseConfig.turn.onBegin === undefined) { phaseConfig.turn.onBegin = ({ G }) => G; } if (phaseConfig.turn.onEnd === undefined) { phaseConfig.turn.onEnd = ({ G }) => G; } if (phaseConfig.turn.endIf === undefined) { phaseConfig.turn.endIf = () => false; } if (phaseConfig.turn.onMove === undefined) { phaseConfig.turn.onMove = ({ G }) => G; } if (phaseConfig.turn.stages === undefined) { phaseConfig.turn.stages = {}; } // turns previously treated moveLimit as both minMoves and maxMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(phaseConfig.turn, true); for (const stage in phaseConfig.turn.stages) { const stageConfig = phaseConfig.turn.stages[stage]; const moves = stageConfig.moves || {}; for (const move of Object.keys(moves)) { const key = phase + '.' + stage + '.' + move; moveMap[key] = moves[move]; moveNames.add(move); } } phaseConfig.wrapped = { onBegin: HookWrapper(phaseConfig.onBegin, GameMethod.PHASE_ON_BEGIN), onEnd: HookWrapper(phaseConfig.onEnd, GameMethod.PHASE_ON_END), endIf: TriggerWrapper(phaseConfig.endIf), }; phaseConfig.turn.wrapped = { onMove: HookWrapper(phaseConfig.turn.onMove, GameMethod.TURN_ON_MOVE), onBegin: HookWrapper(phaseConfig.turn.onBegin, GameMethod.TURN_ON_BEGIN), onEnd: HookWrapper(phaseConfig.turn.onEnd, GameMethod.TURN_ON_END), endIf: TriggerWrapper(phaseConfig.turn.endIf), }; if (typeof phaseConfig.next !== 'function') { const { next } = phaseConfig; phaseConfig.next = () => next || null; } phaseConfig.wrapped.next = TriggerWrapper(phaseConfig.next); } function GetPhase(ctx: { phase: string }): PhaseConfig { return ctx.phase ? phaseMap[ctx.phase] : phaseMap['']; } function OnMove(state: State) { return state; } function Process( state: State, events: { fn: (state: State, opts: any) => State; arg?: any; turn?: Ctx['turn']; phase?: Ctx['phase']; automatic?: boolean; playerID?: PlayerID; force?: boolean; }[] ): State { const phasesEnded = new Set(); const turnsEnded = new Set(); for (let i = 0; i < events.length; i++) { const { fn, arg, ...rest } = events[i]; // Detect a loop of EndPhase calls. // This could potentially even be an infinite loop // if the endIf condition of each phase blindly // returns true. The moment we detect a single // loop, we just bail out of all phases. if (fn === EndPhase) { turnsEnded.clear(); const phase = state.ctx.phase; if (phasesEnded.has(phase)) { const ctx = { ...state.ctx, phase: null }; return { ...state, ctx }; } phasesEnded.add(phase); } // Process event. const next = []; state = fn(state, { ...rest, arg, next, }); if (fn === EndGame) { break; } // Check if we should end the game. const shouldEndGame = ShouldEndGame(state); if (shouldEndGame) { events.push({ fn: EndGame, arg: shouldEndGame, turn: state.ctx.turn, phase: state.ctx.phase, automatic: true, }); continue; } // Check if we should end the phase. const shouldEndPhase = ShouldEndPhase(state); if (shouldEndPhase) { events.push({ fn: EndPhase, arg: shouldEndPhase, turn: state.ctx.turn, phase: state.ctx.phase, automatic: true, }); continue; } // Check if we should end the turn. if ([OnMove, UpdateStage, UpdateActivePlayers].includes(fn)) { const shouldEndTurn = ShouldEndTurn(state); if (shouldEndTurn) { events.push({ fn: EndTurn, arg: shouldEndTurn, turn: state.ctx.turn, phase: state.ctx.phase, automatic: true, }); continue; } } events.push(...next); } return state; } /////////// // Start // /////////// function StartGame(state: State, { next }): State { next.push({ fn: StartPhase }); return state; } function StartPhase(state: State, { next }): State { let { G, ctx } = state; const phaseConfig = GetPhase(ctx); // Run any phase setup code provided by the user. G = phaseConfig.wrapped.onBegin(state); next.push({ fn: StartTurn }); return { ...state, G, ctx }; } function StartTurn(state: State, { currentPlayer }): State { let { ctx } = state; const phaseConfig = GetPhase(ctx); // Initialize the turn order state. if (currentPlayer) { ctx = { ...ctx, currentPlayer }; if (phaseConfig.turn.activePlayers) { ctx = SetActivePlayers(ctx, phaseConfig.turn.activePlayers); } } else { // This is only called at the beginning of the phase // when there is no currentPlayer yet. ctx = InitTurnOrderState(state, phaseConfig.turn); } const turn = ctx.turn + 1; ctx = { ...ctx, turn, numMoves: 0, _prevActivePlayers: [] }; const G = phaseConfig.turn.wrapped.onBegin({ ...state, ctx }); return { ...state, G, ctx, _undo: [], _redo: [] } as State; } //////////// // Update // //////////// function UpdatePhase(state: State, { arg, next, phase }): State { const phaseConfig = GetPhase({ phase }); let { ctx } = state; if (arg && arg.next) { if (arg.next in phaseMap) { ctx = { ...ctx, phase: arg.next }; } else { logging.error('invalid phase: ' + arg.next); return state; } } else { ctx = { ...ctx, phase: phaseConfig.wrapped.next(state) || null }; } state = { ...state, ctx }; // Start the new phase. next.push({ fn: StartPhase }); return state; } function UpdateTurn(state: State, { arg, currentPlayer, next }): State { let { G, ctx } = state; const phaseConfig = GetPhase(ctx); // Update turn order state. const { endPhase, ctx: newCtx } = UpdateTurnOrderState( state, currentPlayer, phaseConfig.turn, arg ); ctx = newCtx; state = { ...state, G, ctx }; if (endPhase) { next.push({ fn: EndPhase, turn: ctx.turn, phase: ctx.phase }); } else { next.push({ fn: StartTurn, currentPlayer: ctx.currentPlayer }); } return state; } function UpdateStage(state: State, { arg, playerID }): State { if (typeof arg === 'string' || arg === Stage.NULL) { arg = { stage: arg }; } if (typeof arg !== 'object') return state; // `arg` should be of type `StageArg`, loose typing as `any` here for historic reasons // stages previously did not enforce minMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(arg); let { ctx } = state; let { activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, } = ctx; // Checking if stage is valid, even Stage.NULL if (arg.stage !== undefined) { if (activePlayers === null) { activePlayers = {}; } activePlayers[playerID] = arg.stage; _activePlayersNumMoves[playerID] = 0; if (arg.minMoves) { if (_activePlayersMinMoves === null) { _activePlayersMinMoves = {}; } _activePlayersMinMoves[playerID] = arg.minMoves; } if (arg.maxMoves) { if (_activePlayersMaxMoves === null) { _activePlayersMaxMoves = {}; } _activePlayersMaxMoves[playerID] = arg.maxMoves; } } ctx = { ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, }; return { ...state, ctx }; } function UpdateActivePlayers(state: State, { arg }): State { return { ...state, ctx: SetActivePlayers(state.ctx, arg) }; } /////////////// // ShouldEnd // /////////////// function ShouldEndGame(state: State): boolean { return wrapped.endIf(state); } function ShouldEndPhase(state: State): boolean | void | { next: string } { const phaseConfig = GetPhase(state.ctx); return phaseConfig.wrapped.endIf(state); } function ShouldEndTurn(state: State): boolean | void | { next: PlayerID } { const phaseConfig = GetPhase(state.ctx); // End the turn if the required number of moves has been made. const currentPlayerMoves = state.ctx.numMoves || 0; if ( phaseConfig.turn.maxMoves && currentPlayerMoves >= phaseConfig.turn.maxMoves ) { return true; } return phaseConfig.turn.wrapped.endIf(state); } ///////// // End // ///////// function EndGame(state: State, { arg, phase }): State { state = EndPhase(state, { phase }); if (arg === undefined) { arg = true; } state = { ...state, ctx: { ...state.ctx, gameover: arg } }; // Run game end hook. const G = wrapped.onEnd(state); return { ...state, G }; } function EndPhase( state: State, { arg, next, turn: initialTurn, automatic }: any ): State { // End the turn first. state = EndTurn(state, { turn: initialTurn, force: true, automatic: true }); const { phase, turn } = state.ctx; if (next) { next.push({ fn: UpdatePhase, arg, phase }); } // If we aren't in a phase, there is nothing else to do. if (phase === null) { return state; } // Run any cleanup code for the phase that is about to end. const phaseConfig = GetPhase(state.ctx); const G = phaseConfig.wrapped.onEnd(state); // Reset the phase. const ctx = { ...state.ctx, phase: null }; // Add log entry. const action = gameEvent('endPhase', arg); const { _stateID } = state; const logEntry: LogEntry = { action, _stateID, turn, phase }; if (automatic) logEntry.automatic = true; const deltalog = [...(state.deltalog || []), logEntry]; return { ...state, G, ctx, deltalog }; } function EndTurn( state: State, { arg, next, turn: initialTurn, force, automatic, playerID }: any ): State { // This is not the turn that EndTurn was originally // called for. The turn was probably ended some other way. if (initialTurn !== state.ctx.turn) { return state; } const { currentPlayer, numMoves, phase, turn } = state.ctx; const phaseConfig = GetPhase(state.ctx); // Prevent ending the turn if minMoves haven't been reached. const currentPlayerMoves = numMoves || 0; if ( !force && phaseConfig.turn.minMoves && currentPlayerMoves < phaseConfig.turn.minMoves ) { logging.info( `cannot end turn before making ${phaseConfig.turn.minMoves} moves` ); return state; } // Run turn-end triggers. const G = phaseConfig.turn.wrapped.onEnd(state); if (next) { next.push({ fn: UpdateTurn, arg, currentPlayer }); } // Reset activePlayers. let ctx = { ...state.ctx, activePlayers: null }; // Remove player from playerOrder if (arg && arg.remove) { playerID = playerID || currentPlayer; const playOrder = ctx.playOrder.filter((i) => i != playerID); const playOrderPos = ctx.playOrderPos > playOrder.length - 1 ? 0 : ctx.playOrderPos; ctx = { ...ctx, playOrder, playOrderPos }; if (playOrder.length === 0) { next.push({ fn: EndPhase, turn, phase }); return state; } } // Create log entry. const action = gameEvent('endTurn', arg); const { _stateID } = state; const logEntry: LogEntry = { action, _stateID, turn, phase }; if (automatic) logEntry.automatic = true; const deltalog = [...(state.deltalog || []), logEntry]; return { ...state, G, ctx, deltalog, _undo: [], _redo: [] }; } function EndStage( state: State, { arg, next, automatic, playerID }: any ): State { playerID = playerID || state.ctx.currentPlayer; let { ctx, _stateID } = state; let { activePlayers, _activePlayersNumMoves, _activePlayersMinMoves, _activePlayersMaxMoves, phase, turn, } = ctx; const playerInStage = activePlayers !== null && playerID in activePlayers; const phaseConfig = GetPhase(ctx); if (!arg && playerInStage) { const stage = phaseConfig.turn.stages[activePlayers[playerID]]; if (stage && stage.next) { arg = stage.next; } } // Checking if arg is a valid stage, even Stage.NULL if (next) { next.push({ fn: UpdateStage, arg, playerID }); } // If player isn’t in a stage, there is nothing else to do. if (!playerInStage) return state; // Prevent ending the stage if minMoves haven't been reached. const currentPlayerMoves = _activePlayersNumMoves[playerID] || 0; if ( _activePlayersMinMoves && _activePlayersMinMoves[playerID] && currentPlayerMoves < _activePlayersMinMoves[playerID] ) { logging.info( `cannot end stage before making ${_activePlayersMinMoves[playerID]} moves` ); return state; } // Remove player from activePlayers. activePlayers = { ...activePlayers }; delete activePlayers[playerID]; if (_activePlayersMinMoves) { // Remove player from _activePlayersMinMoves. _activePlayersMinMoves = { ..._activePlayersMinMoves }; delete _activePlayersMinMoves[playerID]; } if (_activePlayersMaxMoves) { // Remove player from _activePlayersMaxMoves. _activePlayersMaxMoves = { ..._activePlayersMaxMoves }; delete _activePlayersMaxMoves[playerID]; } ctx = UpdateActivePlayersOnceEmpty({ ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, }); // Create log entry. const action = gameEvent('endStage', arg); const logEntry: LogEntry = { action, _stateID, turn, phase }; if (automatic) logEntry.automatic = true; const deltalog = [...(state.deltalog || []), logEntry]; return { ...state, ctx, deltalog }; } /** * Retrieves the relevant move that can be played by playerID. * * If ctx.activePlayers is set (i.e. one or more players are in some stage), * then it attempts to find the move inside the stages config for * that turn. If the stage for a player is '', then the player is * allowed to make a move (as determined by the phase config), but * isn't restricted to a particular set as defined in the stage config. * * If not, it then looks for the move inside the phase. * * If it doesn't find the move there, it looks at the global move definition. * * @param {object} ctx * @param {string} name * @param {string} playerID */ function GetMove(ctx: Ctx, name: string, playerID: PlayerID): null | Move { const phaseConfig = GetPhase(ctx); const stages = phaseConfig.turn.stages; const { activePlayers } = ctx; if ( activePlayers && activePlayers[playerID] !== undefined && activePlayers[playerID] !== Stage.NULL && stages[activePlayers[playerID]] !== undefined && stages[activePlayers[playerID]].moves !== undefined ) { // Check if moves are defined for the player's stage. const stage = stages[activePlayers[playerID]]; const moves = stage.moves; if (name in moves) { return moves[name]; } } else if (phaseConfig.moves) { // Check if moves are defined for the current phase. if (name in phaseConfig.moves) { return phaseConfig.moves[name]; } } else if (name in moves) { // Check for the move globally. return moves[name]; } return null; } function ProcessMove(state: State, action: ActionPayload.MakeMove): State { const { playerID, type } = action; const { currentPlayer, activePlayers, _activePlayersMaxMoves } = state.ctx; const move = GetMove(state.ctx, type, playerID); const shouldCount = !move || typeof move === 'function' || move.noLimit !== true; let { numMoves, _activePlayersNumMoves } = state.ctx; if (shouldCount) { if (playerID === currentPlayer) numMoves++; if (activePlayers) _activePlayersNumMoves[playerID]++; } state = { ...state, ctx: { ...state.ctx, numMoves, _activePlayersNumMoves, }, }; if ( _activePlayersMaxMoves && _activePlayersNumMoves[playerID] >= _activePlayersMaxMoves[playerID] ) { state = EndStage(state, { playerID, automatic: true }); } const phaseConfig = GetPhase(state.ctx); const G = phaseConfig.turn.wrapped.onMove({ ...state, playerID }); state = { ...state, G }; const events = [{ fn: OnMove }]; return Process(state, events); } function SetStageEvent(state: State, playerID: PlayerID, arg: any): State { return Process(state, [{ fn: EndStage, arg, playerID }]); } function EndStageEvent(state: State, playerID: PlayerID): State { return Process(state, [{ fn: EndStage, playerID }]); } function SetActivePlayersEvent( state: State, _playerID: PlayerID, arg: ActivePlayersArg ): State { return Process(state, [{ fn: UpdateActivePlayers, arg }]); } function SetPhaseEvent( state: State, _playerID: PlayerID, newPhase: string ): State { return Process(state, [ { fn: EndPhase, phase: state.ctx.phase, turn: state.ctx.turn, arg: { next: newPhase }, }, ]); } function EndPhaseEvent(state: State): State { return Process(state, [ { fn: EndPhase, phase: state.ctx.phase, turn: state.ctx.turn }, ]); } function EndTurnEvent(state: State, _playerID: PlayerID, arg: any): State { return Process(state, [ { fn: EndTurn, turn: state.ctx.turn, phase: state.ctx.phase, arg }, ]); } function PassEvent(state: State, _playerID: PlayerID, arg: any): State { return Process(state, [ { fn: EndTurn, turn: state.ctx.turn, phase: state.ctx.phase, force: true, arg, }, ]); } function EndGameEvent(state: State, _playerID: PlayerID, arg: any): State { return Process(state, [ { fn: EndGame, turn: state.ctx.turn, phase: state.ctx.phase, arg }, ]); } const eventHandlers = { endStage: EndStageEvent, setStage: SetStageEvent, endTurn: EndTurnEvent, pass: PassEvent, endPhase: EndPhaseEvent, setPhase: SetPhaseEvent, endGame: EndGameEvent, setActivePlayers: SetActivePlayersEvent, }; const enabledEventNames = []; if (events.endTurn !== false) { enabledEventNames.push('endTurn'); } if (events.pass !== false) { enabledEventNames.push('pass'); } if (events.endPhase !== false) { enabledEventNames.push('endPhase'); } if (events.setPhase !== false) { enabledEventNames.push('setPhase'); } if (events.endGame !== false) { enabledEventNames.push('endGame'); } if (events.setActivePlayers !== false) { enabledEventNames.push('setActivePlayers'); } if (events.endStage !== false) { enabledEventNames.push('endStage'); } if (events.setStage !== false) { enabledEventNames.push('setStage'); } function ProcessEvent(state: State, action: ActionShape.GameEvent): State { const { type, playerID, args } = action.payload; if (typeof eventHandlers[type] !== 'function') return state; return eventHandlers[type]( state, playerID, ...(Array.isArray(args) ? args : [args]) ); } function IsPlayerActive(_G: any, ctx: Ctx, playerID: PlayerID): boolean { if (ctx.activePlayers) { return playerID in ctx.activePlayers; } return ctx.currentPlayer === playerID; } return { ctx: (numPlayers: number): Ctx => ({ numPlayers, turn: 0, currentPlayer: '0', playOrder: [...Array.from({ length: numPlayers })].map((_, i) => i + ''), playOrderPos: 0, phase: startingPhase, activePlayers: null, }), init: (state: State): State => { return Process(state, [{ fn: StartGame }]); }, isPlayerActive: IsPlayerActive, eventHandlers, eventNames: Object.keys(eventHandlers), enabledEventNames, moveMap, moveNames: [...moveNames.values()], processMove: ProcessMove, processEvent: ProcessEvent, getMove: GetMove, }; } ================================================ FILE: src/core/game-methods.ts ================================================ export enum GameMethod { MOVE = 'MOVE', GAME_ON_END = 'GAME_ON_END', PHASE_ON_BEGIN = 'PHASE_ON_BEGIN', PHASE_ON_END = 'PHASE_ON_END', TURN_ON_BEGIN = 'TURN_ON_BEGIN', TURN_ON_MOVE = 'TURN_ON_MOVE', TURN_ON_END = 'TURN_ON_END', } ================================================ FILE: src/core/game.test.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { ProcessGameConfig } from './game'; import { Client } from '../client/client'; import { error } from '../core/logger'; import { InitializeGame } from './initialize'; import type { Game } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); describe('basic', () => { let game; beforeAll(() => { game = ProcessGameConfig({ moves: { A: ({ G }) => G, B: () => null, C: { move: () => 'C', }, }, phases: { PA: { moves: { A: () => 'PA.A', }, }, }, }); }); test('sanity', () => { expect(game.moveNames).toEqual(['A', 'B', 'C']); expect(typeof game.processMove).toEqual('function'); }); test('processMove', () => { const G = { test: true }; const ctx = { phase: '' }; const state = { G, ctx, plugins: {} }; expect(game.processMove(state, { type: 'A' })).toEqual(G); expect(game.processMove(state, { type: 'D' })).toEqual(G); expect(game.processMove(state, { type: 'B' })).toEqual(null); state.ctx.phase = 'PA'; expect(game.processMove(state, { type: 'A' })).toEqual('PA.A'); }); test('long-form move syntax', () => { expect( game.processMove({ ctx: { phase: '' }, plugins: {} }, { type: 'C' }) ).toEqual('C'); }); }); // Following turn order is often used in worker placement games like Agricola and Viticulture. test('rounds with starting player token', () => { const game: Game = { setup: () => ({ startingPlayerToken: 0 }), moves: { takeStartingPlayerToken: ({ G, ctx }) => { G.startingPlayerToken = ctx.currentPlayer; }, }, phases: { main: { start: true, turn: { order: { first: ({ G }) => G.startingPlayerToken, next: ({ ctx }) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, }, }, }; const client = Client({ game, numPlayers: 4 }); expect(client.getState().ctx.currentPlayer).toBe('0'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('2'); client.moves.takeStartingPlayerToken(); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('3'); client.events.endTurn(); client.events.setPhase('main'); expect(client.getState().ctx.currentPlayer).toBe('2'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('3'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('0'); }); // The following pattern is used in Catan, Twilight Imperium, and (sort of) Powergrid. test('serpentine setup phases', () => { const game: Game = { phases: { 'first setup round': { start: true, turn: { order: { first: () => 0, next: ({ ctx }) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, next: 'second setup round', }, 'second setup round': { turn: { order: { first: ({ ctx }) => ctx.playOrder.length - 1, next: ({ ctx }) => (+ctx.playOrderPos - 1) % ctx.playOrder.length, }, }, next: 'main phase', }, 'main phase': { turn: { order: { first: () => 0, next: ({ ctx }) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, }, }, }; const numPlayers = 4; const client = Client({ game, numPlayers }); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.phase).toBe('first setup round'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.phase).toBe('first setup round'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('2'); expect(client.getState().ctx.phase).toBe('first setup round'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('3'); expect(client.getState().ctx.phase).toBe('first setup round'); client.events.endTurn(); client.events.endPhase(); expect(client.getState().ctx.currentPlayer).toBe('3'); expect(client.getState().ctx.phase).toBe('second setup round'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('2'); expect(client.getState().ctx.phase).toBe('second setup round'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.phase).toBe('second setup round'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.phase).toBe('second setup round'); client.events.endTurn(); client.events.endPhase(); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('2'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('3'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('2'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('3'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('0'); expect(client.getState().ctx.phase).toBe('main phase'); client.events.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); expect(client.getState().ctx.phase).toBe('main phase'); }); describe('config errors', () => { test('game name with spaces', () => { const game = () => { ProcessGameConfig({ name: 'tic tac toe' }); }; expect(game).toThrow(); }); test('plugin name with spaces', () => { const plugins = [ { name: 'my cool plugin', api: () => {}, }, ]; const game = () => { ProcessGameConfig({ plugins }); }; expect(game).toThrow(); }); test('plugin name missing', () => { const plugins = [ { api: () => {}, }, ]; const game = () => { ProcessGameConfig({ plugins } as unknown as Game); }; expect(game).toThrow(); }); test('invalid move object', () => { const game = ProcessGameConfig({ moves: { A: 1 } } as unknown as Game); const state = InitializeGame({ game }); game.processMove(state, { type: 'A', args: null, playerID: '0' }); expect(error).toBeCalledWith( expect.stringContaining('invalid move object') ); }); }); describe('disableUndo', () => { test('set disableUndo to false by default', () => { const game = ProcessGameConfig({ moves: {}, }); expect(game.disableUndo).toBeFalsy(); }); test('set disableUndo to true', () => { const game = ProcessGameConfig({ moves: {}, disableUndo: true, }); expect(game.disableUndo).toBeTruthy(); }); }); ================================================ FILE: src/core/game.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import * as plugins from '../plugins/main'; import { Flow } from './flow'; import type { INVALID_MOVE } from './constants'; import type { ActionPayload, Game, Move, LongFormMove, State } from '../types'; import * as logging from './logger'; import { GameMethod } from './game-methods'; type ProcessedGame = Game & { flow: ReturnType; moveNames: string[]; pluginNames: string[]; processMove: ( state: State, action: ActionPayload.MakeMove ) => State | typeof INVALID_MOVE; }; function IsProcessed(game: Game | ProcessedGame): game is ProcessedGame { return game.processMove !== undefined; } /** * Helper to generate the game move reducer. The returned * reducer has the following signature: * * (G, action, ctx) => {} * * You can roll your own if you like, or use any Redux * addon to generate such a reducer. * * The convention used in this framework is to * have action.type contain the name of the move, and * action.args contain any additional arguments as an * Array. */ export function ProcessGameConfig(game: Game | ProcessedGame): ProcessedGame { // The Game() function has already been called on this // config object, so just pass it through. if (IsProcessed(game)) { return game; } if (game.name === undefined) game.name = 'default'; if (game.deltaState === undefined) game.deltaState = false; if (game.disableUndo === undefined) game.disableUndo = false; if (game.setup === undefined) game.setup = () => ({}); if (game.moves === undefined) game.moves = {}; if (game.playerView === undefined) game.playerView = ({ G }) => G; if (game.plugins === undefined) game.plugins = []; game.plugins.forEach((plugin) => { if (plugin.name === undefined) { throw new Error('Plugin missing name attribute'); } if (plugin.name.includes(' ')) { throw new Error(plugin.name + ': Plugin name must not include spaces'); } }); if (game.name.includes(' ')) { throw new Error(game.name + ': Game name must not include spaces'); } const flow = Flow(game); return { ...game, flow, moveNames: flow.moveNames as string[], pluginNames: game.plugins.map((p) => p.name) as string[], processMove: (state: State, action: ActionPayload.MakeMove) => { let moveFn = flow.getMove(state.ctx, action.type, action.playerID); if (IsLongFormMove(moveFn)) { moveFn = moveFn.move; } if (moveFn instanceof Function) { const fn = plugins.FnWrap(moveFn, GameMethod.MOVE, game.plugins); let args = []; if (action.args !== undefined) { args = Array.isArray(action.args) ? action.args : [action.args]; } const context = { ...plugins.GetAPIs(state), G: state.G, ctx: state.ctx, playerID: action.playerID, }; return fn(context, ...args); } logging.error(`invalid move object: ${action.type}`); return state.G; }, }; } export function IsLongFormMove(move: Move): move is LongFormMove { return move instanceof Object && (move as LongFormMove).move !== undefined; } ================================================ FILE: src/core/initialize.ts ================================================ /* * Copyright 2020 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { ProcessGameConfig } from './game'; import * as plugins from '../plugins/main'; import type { Ctx, Game, PartialGameState, State } from '../types'; /** * Creates the initial game state. */ export function InitializeGame({ game, numPlayers, setupData, }: { game: Game; numPlayers?: number; setupData?: any; }) { game = ProcessGameConfig(game); if (!numPlayers) { numPlayers = 2; } const ctx: Ctx = game.flow.ctx(numPlayers); let state: PartialGameState = { // User managed state. G: {}, // Framework managed state. ctx, // Plugin related state. plugins: {}, }; // Run plugins over initial state. state = plugins.Setup(state, { game }); state = plugins.Enhance(state, { game, playerID: undefined }); const pluginAPIs = plugins.GetAPIs(state); state.G = game.setup({ ...pluginAPIs, ctx: state.ctx }, setupData); let initial: State = { ...state, // List of {G, ctx} pairs that can be undone. _undo: [], // List of {G, ctx} pairs that can be redone. _redo: [], // A monotonically non-decreasing ID to ensure that // state updates are only allowed from clients that // are at the same version that the server. _stateID: 0, }; initial = game.flow.init(initial); [initial] = plugins.FlushAndValidate(initial, { game }); // Initialize undo stack. if (!game.disableUndo) { initial._undo = [ { G: initial.G, ctx: initial.ctx, plugins: initial.plugins, }, ]; } return initial; } ================================================ FILE: src/core/logger.test.js ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ describe('logging', () => { const oldConsoleLog = console.log; const oldConsoleError = console.error; const oldNodeEnv = process.env.NODE_ENV; beforeEach(() => { console.log.mockReset(); console.error.mockReset(); }); afterAll(() => { console.log = oldConsoleLog; console.error = oldConsoleError; process.env.NODE_ENV = oldNodeEnv; }); console.log = jest.fn(); console.error = jest.fn(); describe('dev', () => { let logging; beforeAll(() => { logging = require('./logger'); }); test('error', () => { logging.error('msg1'); expect(console.error).toHaveBeenCalledWith('ERROR:', 'msg1'); }); test('info', () => { logging.info('msg2'); expect(console.log).toHaveBeenCalledWith('INFO: msg2'); }); }); describe('production', () => { let logging; beforeAll(() => { process.env.NODE_ENV = 'production'; jest.resetModules(); logging = require('./logger'); }); afterAll(() => { process.env.NODE_ENV = oldNodeEnv; }); test('info stripped', () => { logging.info('msg2'); expect(console.log).not.toHaveBeenCalled(); }); test('error not stripped', () => { logging.error('msg1'); expect(console.error).toHaveBeenCalled(); }); }); describe('spying after load', () => { let logging; beforeAll(() => { jest.resetModules(); console.log = oldConsoleLog; console.error = oldConsoleError; logging = require('./logger'); console.log = jest.fn(); console.error = jest.fn(); }); test('should allow console log to be spied', () => { logging.info('test'); expect(console.log).toHaveBeenCalledWith('INFO: test'); }); test('should allow console error to be spied', () => { logging.error('test'); expect(console.error).toHaveBeenCalledWith('ERROR:', 'test'); }); }); }); ================================================ FILE: src/core/logger.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ const production = process.env.NODE_ENV === 'production'; const logfn = production ? () => {} : (...msg) => console.log(...msg); const errorfn = (...msg) => console.error(...msg); export function info(msg: string) { logfn(`INFO: ${msg}`); } export function error(error: string) { errorfn('ERROR:', error); } ================================================ FILE: src/core/player-view.test.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { PlayerView } from './player-view'; import type { Ctx } from '../types'; test('no change', () => { const G = { test: true }; const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '0' }); expect(newG).toEqual(G); }); test('secret', () => { const G = { secret: true }; const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '0' }); expect(newG).toEqual({}); }); describe('players', () => { const G = { players: { '0': {}, '1': {}, }, }; test('playerID: "0"', () => { const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '0' }); expect(newG.players).toEqual({ '0': {} }); }); test('playerID: "1"', () => { const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '1' }); expect(newG.players).toEqual({ '1': {} }); }); test('playerID: null', () => { const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: null, }); expect(newG.players).toEqual({}); }); }); ================================================ FILE: src/core/player-view.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import type { Game, PlayerID } from '../types'; /** * PlayerView reducers. */ export const PlayerView: { STRIP_SECRETS: Game['playerView'] } = { /** * STRIP_SECRETS * * Reducer which removes a key named `secret` and * removes all the keys in `players`, except for the one * corresponding to the current playerID. */ STRIP_SECRETS: ({ G, playerID }: { G: any; playerID: PlayerID | null }) => { const r = { ...G }; if (r.secret !== undefined) { delete r.secret; } if (r.players) { r.players = playerID ? { [playerID]: r.players[playerID], } : {}; } return r; }, }; ================================================ FILE: src/core/reducer.test.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { INVALID_MOVE } from './constants'; import { applyMiddleware, createStore } from 'redux'; import { CreateGameReducer, TransientHandlingMiddleware } from './reducer'; import { InitializeGame } from './initialize'; import { makeMove, gameEvent, sync, update, reset, undo, redo, patch, } from './action-creators'; import { error } from '../core/logger'; import type { Game, State, SyncInfo } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); const game: Game = { moves: { A: ({ G }) => G, B: () => ({ moved: true }), C: () => ({ victory: true }), Invalid: () => INVALID_MOVE, }, endIf: ({ G, ctx }) => (G.victory ? ctx.currentPlayer : undefined), }; const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); test('_stateID is incremented', () => { let state = initialState; state = reducer(state, makeMove('A')); expect(state._stateID).toBe(1); state = reducer(state, gameEvent('endTurn')); expect(state._stateID).toBe(2); }); test('move returns INVALID_MOVE', () => { const game: Game = { moves: { A: () => INVALID_MOVE, }, }; const reducer = CreateGameReducer({ game }); const state = reducer(initialState, makeMove('A')); expect(error).toBeCalledWith('invalid move: A args: undefined'); expect(state._stateID).toBe(0); }); test('makeMove', () => { let state = initialState; expect(state._stateID).toBe(0); state = reducer(state, makeMove('unknown')); expect(state._stateID).toBe(0); expect(state.G).not.toMatchObject({ moved: true }); expect(error).toBeCalledWith('disallowed move: unknown'); state = reducer(state, makeMove('A')); expect(state._stateID).toBe(1); expect(state.G).not.toMatchObject({ moved: true }); state = reducer(state, makeMove('B')); expect(state._stateID).toBe(2); expect(state.G).toMatchObject({ moved: true }); state.ctx.gameover = true; state = reducer(state, makeMove('B')); expect(state._stateID).toBe(2); expect(error).toBeCalledWith('cannot make move after game end'); state = reducer(state, gameEvent('endTurn')); expect(state._stateID).toBe(2); expect(error).toBeCalledWith('cannot call event after game end'); }); test('disable move by invalid playerIDs', () => { let state = initialState; expect(state._stateID).toBe(0); // playerID="1" cannot move right now. state = reducer(state, makeMove('A', null, '1')); expect(state._stateID).toBe(0); // playerID="1" cannot call events right now. state = reducer(state, gameEvent('endTurn', null, '1')); expect(state._stateID).toBe(0); // playerID="0" can move. state = reducer(state, makeMove('A', null, '0')); expect(state._stateID).toBe(1); // playerID=undefined can always move. state = reducer(state, makeMove('A')); expect(state._stateID).toBe(2); }); test('sync', () => { const state = reducer( undefined, sync({ state: { G: 'restored' } } as SyncInfo) ); expect(state).toEqual({ G: 'restored' }); }); test('update', () => { const state = reducer(undefined, update({ G: 'restored' } as State, [])); expect(state).toEqual({ G: 'restored' }); }); test('valid patch', () => { const originalState = { _stateID: 0, G: 'patch' } as State; const state = reducer( originalState, patch(0, 1, [{ op: 'replace', path: '/_stateID', value: 1 }], []) ); expect(state).toEqual({ _stateID: 1, G: 'patch' }); }); test('invalid patch', () => { const originalState = { _stateID: 0, G: 'patch' } as State; const { transients, ...state } = reducer( originalState, patch(0, 1, [{ op: 'replace', path: '/_stateIDD', value: 1 }], []) ); expect(state).toEqual(originalState); expect(transients.error.type).toEqual('update/patch_failed'); // It's an array. expect(transients.error.payload.length).toEqual(1); // It looks like the standard rfc6902 error language. expect(transients.error.payload[0].toString()).toContain('/_stateIDD'); }); test('reset', () => { let state = reducer(initialState, makeMove('A')); expect(state).not.toEqual(initialState); state = reducer(state, reset(initialState)); expect(state).toEqual(initialState); }); test('victory', () => { let state = reducer(initialState, makeMove('A')); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.gameover).toEqual(undefined); state = reducer(state, makeMove('B')); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.gameover).toEqual(undefined); state = reducer(state, makeMove('C')); expect(state.ctx.gameover).toEqual('0'); }); test('endTurn', () => { { const state = reducer(initialState, gameEvent('endTurn')); expect(state.ctx.turn).toBe(2); } { const reducer = CreateGameReducer({ game, isClient: true }); const state = reducer(initialState, gameEvent('endTurn')); expect(state.ctx.turn).toBe(1); } }); test('light client when multiplayer=true', () => { const game: Game = { moves: { A: () => ({ win: true }) }, endIf: ({ G }) => G.win, }; { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.ctx.gameover).toBe(undefined); state = reducer(state, makeMove('A')); expect(state.ctx.gameover).toBe(true); } { const reducer = CreateGameReducer({ game, isClient: true }); let state = InitializeGame({ game }); expect(state.ctx.gameover).toBe(undefined); state = reducer(state, makeMove('A')); expect(state.ctx.gameover).toBe(undefined); } }); test('disable optimistic updates', () => { const game: Game = { moves: { A: { move: () => ({ A: true }), client: false, }, }, }; { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.G).not.toMatchObject({ A: true }); state = reducer(state, makeMove('A')); expect(state.G).toMatchObject({ A: true }); } { const reducer = CreateGameReducer({ game, isClient: true }); let state = InitializeGame({ game }); expect(state.G).not.toMatchObject({ A: true }); state = reducer(state, makeMove('A')); expect(state.G).not.toMatchObject({ A: true }); } }); test('numPlayers', () => { const numPlayers = 4; const state = InitializeGame({ game, numPlayers }); expect(state.ctx.numPlayers).toBe(4); }); test('deltalog', () => { let state = initialState; const actionA = makeMove('A'); const actionB = makeMove('B'); const actionC = gameEvent('endTurn'); state = reducer(state, actionA); expect(state.deltalog).toEqual([ { action: actionA, _stateID: 0, phase: null, turn: 1, }, ]); state = reducer(state, actionB); expect(state.deltalog).toEqual([ { action: actionB, _stateID: 1, phase: null, turn: 1, }, ]); state = reducer(state, actionC); expect(state.deltalog).toEqual([ { action: actionC, _stateID: 2, phase: null, turn: 1, }, ]); }); describe('Events API', () => { const fn = ({ events }) => (events ? {} : { error: true }); const game: Game = { setup: () => ({}), phases: { A: {} }, turn: { onBegin: fn, onEnd: fn, onMove: fn, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('is attached at the beginning', () => { expect(state.G).not.toEqual({ error: true }); }); test('is attached at the end of turns', () => { state = reducer(state, gameEvent('endTurn')); expect(state.G).not.toEqual({ error: true }); }); test('is attached at the end of phases', () => { state = reducer(state, gameEvent('endPhase')); expect(state.G).not.toEqual({ error: true }); }); }); describe('Plugin Invalid Action API', () => { const pluginName = 'validator'; const message = 'G.value must divide by 5'; const game: Game<{ value: number }> = { setup: () => ({ value: 5 }), plugins: [ { name: pluginName, isInvalid: ({ G }) => { if (G.value % 5 !== 0) return message; return false; }, }, ], moves: { setValue: ({ G }, arg: number) => { G.value = arg; }, }, phases: { unenterable: { onBegin: () => ({ value: 13 }), }, enterable: { onBegin: () => ({ value: 25 }), }, }, }; let state: State; beforeEach(() => { state = InitializeGame({ game }); }); describe('multiplayer client', () => { const reducer = CreateGameReducer({ game }); test('move is cancelled if plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [6], '0')); expect(state.G).toMatchObject({ value: 5 }); expect(state['transients'].error).toEqual({ type: 'action/plugin_invalid', payload: { plugin: pluginName, message }, }); }); test('move is processed if no plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [15], '0')); expect(state.G).toMatchObject({ value: 15 }); expect(state['transients']).toBeUndefined(); }); test('event is cancelled if plugin declares it invalid', () => { state = reducer(state, gameEvent('setPhase', 'unenterable', '0')); expect(state.G).toMatchObject({ value: 5 }); expect(state.ctx.phase).toBe(null); expect(state['transients'].error).toEqual({ type: 'action/plugin_invalid', payload: { plugin: pluginName, message }, }); }); test('event is processed if no plugin declares it invalid', () => { state = reducer(state, gameEvent('setPhase', 'enterable', '0')); expect(state.G).toMatchObject({ value: 25 }); expect(state.ctx.phase).toBe('enterable'); expect(state['transients']).toBeUndefined(); }); }); describe('local client', () => { const reducer = CreateGameReducer({ game, isClient: true }); test('move is cancelled if plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [6], '0')); expect(state.G).toMatchObject({ value: 5 }); expect(state['transients'].error).toEqual({ type: 'action/plugin_invalid', payload: { plugin: pluginName, message }, }); }); test('move is processed if no plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [15], '0')); expect(state.G).toMatchObject({ value: 15 }); expect(state['transients']).toBeUndefined(); }); }); }); describe('Random inside setup()', () => { const game1: Game = { seed: 'seed1', setup: (ctx) => ({ n: ctx.random.D6() }), }; const game2: Game = { seed: 'seed2', setup: (ctx) => ({ n: ctx.random.D6() }), }; const game3: Game = { seed: 'seed2', setup: (ctx) => ({ n: ctx.random.D6() }), }; test('setting seed', () => { const state1 = InitializeGame({ game: game1 }); const state2 = InitializeGame({ game: game2 }); const state3 = InitializeGame({ game: game3 }); expect(state1.G.n).not.toBe(state2.G.n); expect(state2.G.n).toBe(state3.G.n); }); }); describe('redact', () => { const game: Game = { setup: () => ({ isASecret: false, }), moves: { A: { move: ({ G }) => G, redact: ({ G }) => G.isASecret, }, B: ({ G }) => { return { ...G, isASecret: true }; }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('move A is not secret and is not redact', () => { state = reducer(state, makeMove('A', ['not redact'], '0')); expect(state.G).toMatchObject({ isASecret: false, }); const [lastLogEntry] = state.deltalog.slice(-1); expect(lastLogEntry).toMatchObject({ action: { payload: { type: 'A', args: ['not redact'], }, }, redact: false, }); }); test('move A is secret and is redact', () => { state = reducer(state, makeMove('B', ['not redact'], '0')); state = reducer(state, makeMove('A', ['redact'], '0')); expect(state.G).toMatchObject({ isASecret: true, }); const [lastLogEntry] = state.deltalog.slice(-1); expect(lastLogEntry).toMatchObject({ action: { payload: { type: 'A', args: ['redact'], }, }, redact: true, }); }); }); describe('undo / redo', () => { const game: Game = { seed: 0, moves: { move: ({ G }, arg: string) => ({ ...G, [arg]: true }), roll: ({ G, random }) => { G.roll = random.D6(); }, }, turn: { stages: { special: {}, }, }, }; beforeEach(() => { jest.clearAllMocks(); }); const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); // TODO: Check if this test is still actually required after removal of APIs from ctx test('plugin APIs are not included in undo state', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, makeMove('move', 'B', '0')); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo[1].ctx).not.toHaveProperty('events'); expect(state._undo[1].ctx).not.toHaveProperty('random'); }); test('undo restores previous state after move', () => { const initial = reducer(initialState, makeMove('move', 'A', '0')); let newState = reducer(initial, makeMove('roll', null, '0')); newState = reducer(newState, undo()); expect(newState.G).toEqual(initial.G); expect(newState.ctx).toEqual(initial.ctx); expect(newState.plugins).toEqual(initial.plugins); }); test('undo restores previous state after event', () => { const initial = reducer( initialState, gameEvent('setStage', 'special', '0') ); let newState = reducer(initial, gameEvent('endStage', undefined, '0')); expect(error).not.toBeCalled(); // Make sure we actually modified the stage. expect(newState.ctx.activePlayers).not.toEqual(initial.ctx.activePlayers); newState = reducer(newState, undo()); expect(error).not.toBeCalled(); expect(newState.G).toEqual(initial.G); expect(newState.ctx).toEqual(initial.ctx); expect(newState.plugins).toEqual(initial.plugins); }); test('redo restores undone state', () => { let state = initialState; // Make two moves. const state1 = (state = reducer(state, makeMove('move', 'A', '0'))); const state2 = (state = reducer(state, makeMove('roll', null, '0'))); // Undo both of them. state = reducer(state, undo()); state = reducer(state, undo()); // Redo one of them. state = reducer(state, redo()); expect(state.G).toEqual(state1.G); expect(state.ctx).toEqual(state1.ctx); expect(state.plugins).toEqual(state1.plugins); // Redo a second time. state = reducer(state, redo()); expect(state.G).toEqual(state2.G); expect(state.ctx).toEqual(state2.ctx); expect(state.plugins).toEqual(state2.plugins); }); test('can undo redone state', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, undo()); state = reducer(state, redo()); state = reducer(state, undo()); expect(state.G).toMatchObject(initialState.G); expect(state.ctx).toMatchObject(initialState.ctx); expect(state.plugins).toMatchObject(initialState.plugins); }); test('undo has no effect if nothing to undo', () => { let state = reducer(initialState, undo()); state = reducer(state, undo()); state = reducer(state, undo()); expect(state.G).toMatchObject(initialState.G); expect(state.ctx).toMatchObject(initialState.ctx); expect(state.plugins).toMatchObject(initialState.plugins); }); test('redo works after multiple undos', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, undo()); state = reducer(state, undo()); state = reducer(state, undo()); state = reducer(state, redo()); state = reducer(state, makeMove('move', 'C', '0')); expect(state.G).toMatchObject({ A: true, C: true }); state = reducer(state, undo()); expect(state.G).toMatchObject({ A: true }); state = reducer(state, redo()); expect(state.G).toMatchObject({ A: true, C: true }); }); test('redo only resets deltalog if nothing to redo', () => { const state = reducer(initialState, makeMove('move', 'A', '0')); expect(reducer(state, redo())).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); }); test('disable undo / redo', () => { const game: Game = { seed: 0, disableUndo: true, moves: { move: ({ G }, arg: string) => ({ ...G, [arg]: true }), }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); state = reducer(state, makeMove('move', 'A', '0')); expect(state.G).toMatchObject({ A: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, makeMove('move', 'B', '0')); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, undo()); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, undo()); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, redo()); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); }); describe('undo stack', () => { const game: Game = { moves: { basic: () => {}, endTurn: ({ events }) => { events.endTurn(); }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('contains initial state at start of game', () => { expect(state._undo).toHaveLength(1); expect(state._undo[0].ctx).toEqual(state.ctx); expect(state._undo[0].plugins).toEqual(state.plugins); }); test('grows when a move is made', () => { state = reducer(state, makeMove('basic', null, '0')); expect(state._undo).toHaveLength(2); expect(state._undo[1].moveType).toBe('basic'); expect(state._undo[1].ctx).toEqual(state.ctx); expect(state._undo[1].plugins).toEqual(state.plugins); }); test('shrinks when a move is undone', () => { state = reducer(state, undo()); expect(state._undo).toHaveLength(1); expect(state._undo[0].ctx).toEqual(state.ctx); expect(state._undo[0].plugins).toEqual(state.plugins); }); test('grows when a move is redone', () => { state = reducer(state, redo()); expect(state._undo).toHaveLength(2); expect(state._undo[1].moveType).toBe('basic'); expect(state._undo[1].ctx).toEqual(state.ctx); expect(state._undo[1].plugins).toEqual(state.plugins); }); test('is reset when a turn ends', () => { state = reducer(state, makeMove('endTurn')); expect(state._undo).toHaveLength(1); expect(state._undo[0].ctx).toEqual(state.ctx); expect(state._undo[0].plugins).toEqual(state.plugins); expect(state._undo[0].moveType).toBe('endTurn'); }); test('can’t undo at the start of a turn', () => { const newState = reducer(state, undo()); expect(newState).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); test('can’t undo another player’s move', () => { state = reducer(state, makeMove('basic', null, '1')); const newState = reducer(state, undo('0')); expect(newState).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); }); describe('redo stack', () => { const game: Game = { moves: { basic: () => {}, endTurn: ({ events }) => { events.endTurn(); }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('is empty at start of game', () => { expect(state._redo).toHaveLength(0); }); test('grows when a move is undone', () => { state = reducer(state, makeMove('basic', null, '0')); state = reducer(state, undo()); expect(state._redo).toHaveLength(1); expect(state._redo[0].moveType).toBe('basic'); }); test('shrinks when a move is redone', () => { state = reducer(state, redo()); expect(state._redo).toHaveLength(0); }); test('is reset when a move is made', () => { state = reducer(state, makeMove('basic', null, '0')); state = reducer(state, undo()); state = reducer(state, undo()); expect(state._redo).toHaveLength(2); state = reducer(state, makeMove('basic', null, '0')); expect(state._redo).toHaveLength(0); }); test('is reset when a turn ends', () => { state = reducer(state, makeMove('basic', null, '0')); state = reducer(state, undo()); expect(state._redo).toHaveLength(1); state = reducer(state, makeMove('endTurn')); expect(state._redo).toHaveLength(0); }); test('can’t redo another player’s undo', () => { state = reducer(state, makeMove('basic', null, '1')); state = reducer(state, undo('1')); expect(state._redo).toHaveLength(1); const newState = reducer(state, redo('0')); expect(state._redo).toHaveLength(1); expect(newState).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); }); describe('undo / redo with stages', () => { const game: Game = { setup: () => ({ A: false, B: false, C: false }), turn: { activePlayers: { currentPlayer: 'start' }, stages: { start: { moves: { moveA: { move: ({ G, events }, moveAisReversible) => { events.setStage('A'); return { ...G, moveAisReversible, A: true }; }, undoable: ({ G }) => G.moveAisReversible > 0, }, }, }, A: { moves: { moveB: { move: ({ G, events }) => { events.setStage('B'); return { ...G, B: true }; }, undoable: false, }, }, }, B: { moves: { moveC: { move: ({ G, events }) => { events.setStage('C'); return { ...G, C: true }; }, undoable: true, }, }, }, C: { moves: {}, }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('moveA sets state & moves player to stage A (undoable)', () => { state = reducer(state, makeMove('moveA', true, '0')); expect(state.G).toMatchObject({ moveAisReversible: true, A: true, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('A'); }); test('undo undoes last move (moveA)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ A: false, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('start'); }); test('redo redoes moveA', () => { state = reducer(state, redo('0')); expect(state.G).toMatchObject({ moveAisReversible: true, A: true, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('A'); }); test('undo undoes last move after redo (moveA)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ A: false, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('start'); }); test('moveA sets state & moves player to stage A (not undoable)', () => { state = reducer(state, makeMove('moveA', false, '0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('A'); }); test('moveB sets state & moves player to stage B', () => { state = reducer(state, makeMove('moveB', [], '0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('undo doesn’t undo last move if not undoable (moveB)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('moveC sets state & moves player to stage C', () => { state = reducer(state, makeMove('moveC', [], '0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: true, }); expect(state.ctx.activePlayers['0']).toBe('C'); }); test('undo undoes last move (moveC)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('redo redoes moveC', () => { state = reducer(state, redo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: true, }); expect(state.ctx.activePlayers['0']).toBe('C'); }); test('undo undoes last move after redo (moveC)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('undo doesn’t undo last move if not undoable after undo/redo', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); }); describe('TransientHandlingMiddleware', () => { const middleware = applyMiddleware(TransientHandlingMiddleware); let store = null; beforeEach(() => { store = createStore(reducer, initialState, middleware); }); test('regular dispatch result has no transients', () => { const result = store.dispatch(makeMove('A')); expect(result).toEqual( expect.not.objectContaining({ transients: expect.anything() }) ); expect(result).toEqual( expect.not.objectContaining({ stripTransientsResult: expect.anything() }) ); }); test('failing dispatch result contains transients', () => { const result = store.dispatch(makeMove('Invalid')); expect(result).toMatchObject({ transients: { error: { type: 'action/invalid_move', }, }, }); }); }); ================================================ FILE: src/core/reducer.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import * as Actions from './action-types'; import * as plugins from '../plugins/main'; import { ProcessGameConfig } from './game'; import { error } from './logger'; import { INVALID_MOVE } from './constants'; import type { Dispatch } from 'redux'; import type { ActionShape, Ctx, ErrorType, Game, LogEntry, LongFormMove, Move, State, Store, TransientMetadata, TransientState, Undo, } from '../types'; import { stripTransients } from './action-creators'; import { ActionErrorType, UpdateErrorType } from './errors'; import { applyPatch } from 'rfc6902'; /** * Check if the payload for the passed action contains a playerID. */ const actionHasPlayerID = ( action: | ActionShape.MakeMove | ActionShape.GameEvent | ActionShape.Undo | ActionShape.Redo ) => action.payload.playerID !== null && action.payload.playerID !== undefined; /** * Returns true if a move can be undone. */ const CanUndoMove = (G: any, ctx: Ctx, move: Move): boolean => { function HasUndoable(move: Move): move is LongFormMove { return (move as LongFormMove).undoable !== undefined; } function IsFunction( undoable: boolean | ((...args: any[]) => any) ): undoable is (...args: any[]) => any { return undoable instanceof Function; } if (!HasUndoable(move)) { return true; } if (IsFunction(move.undoable)) { return move.undoable({ G, ctx }); } return move.undoable; }; /** * Update the undo and redo stacks for a move or event. */ function updateUndoRedoState( state: State, opts: { game: Game; action: ActionShape.GameEvent | ActionShape.MakeMove; } ): State { if (opts.game.disableUndo) return state; const undoEntry: Undo = { G: state.G, ctx: state.ctx, plugins: state.plugins, playerID: opts.action.payload.playerID || state.ctx.currentPlayer, }; if (opts.action.type === 'MAKE_MOVE') { undoEntry.moveType = opts.action.payload.type; } return { ...state, _undo: [...state._undo, undoEntry], // Always reset redo stack when making a move or event _redo: [], }; } /** * Process state, adding the initial deltalog for this action. */ function initializeDeltalog( state: State, action: ActionShape.MakeMove | ActionShape.Undo | ActionShape.Redo, move?: Move ): TransientState { // Create a log entry for this action. const logEntry: LogEntry = { action, _stateID: state._stateID, turn: state.ctx.turn, phase: state.ctx.phase, }; const pluginLogMetadata = state.plugins.log.data.metadata; if (pluginLogMetadata !== undefined) { logEntry.metadata = pluginLogMetadata; } if (typeof move === 'object' && move.redact === true) { logEntry.redact = true; } else if (typeof move === 'object' && move.redact instanceof Function) { logEntry.redact = move.redact({ G: state.G, ctx: state.ctx }); } return { ...state, deltalog: [logEntry], }; } /** * Update plugin state after move/event & check if plugins consider the action to be valid. * @param state Current version of state in the reducer. * @param oldState State to revert to in case of error. * @param pluginOpts Plugin configuration options. * @returns Tuple of the new state updated after flushing plugins and the old * state augmented with an error if a plugin declared the action invalid. */ function flushAndValidatePlugins( state: State, oldState: State, pluginOpts: { game: Game; isClient?: boolean } ): [State, TransientState?] { const [newState, isInvalid] = plugins.FlushAndValidate(state, pluginOpts); if (!isInvalid) return [newState]; return [ newState, WithError(oldState, ActionErrorType.PluginActionInvalid, isInvalid), ]; } /** * ExtractTransientsFromState * * Split out transients from the a TransientState */ function ExtractTransients( transientState: TransientState | null ): [State | null, TransientMetadata | undefined] { if (!transientState) { // We preserve null for the state for legacy callers, but the transient // field should be undefined if not present to be consistent with the // code path below. return [null, undefined]; } const { transients, ...state } = transientState; return [state as State, transients as TransientMetadata]; } /** * WithError * * Augment a State instance with transient error information. */ function WithError( state: State, errorType: ErrorType, payload?: PT ): TransientState { const error = { type: errorType, payload, }; return { ...state, transients: { error, }, }; } /** * Middleware for processing TransientState associated with the reducer * returned by CreateGameReducer. * This should pretty much be used everywhere you want realistic state * transitions and error handling. */ export const TransientHandlingMiddleware = (store: Store) => (next: Dispatch) => (action: ActionShape.Any) => { const result = next(action); switch (action.type) { case Actions.STRIP_TRANSIENTS: { return result; } default: { const [, transients] = ExtractTransients(store.getState()); if (typeof transients !== 'undefined') { store.dispatch(stripTransients()); // Dev Note: If parent middleware needs to correlate the spawned // StripTransients action to the triggering action, instrument here. // // This is a bit tricky; for more details, see: // https://github.com/boardgameio/boardgame.io/pull/940#discussion_r636200648 return { ...result, transients, }; } return result; } } }; /** * CreateGameReducer * * Creates the main game state reducer. */ export function CreateGameReducer({ game, isClient, }: { game: Game; isClient?: boolean; }) { game = ProcessGameConfig(game); /** * GameReducer * * Redux reducer that maintains the overall game state. * @param {object} state - The state before the action. * @param {object} action - A Redux action. */ return ( stateWithTransients: TransientState | null = null, action: ActionShape.Any ): TransientState => { let [state /*, transients */] = ExtractTransients(stateWithTransients); switch (action.type) { case Actions.STRIP_TRANSIENTS: { // This action indicates that transient metadata in the state has been // consumed and should now be stripped from the state.. return state; } case Actions.GAME_EVENT: { state = { ...state, deltalog: [] }; // Process game events only on the server. // These events like `endTurn` typically // contain code that may rely on secret state // and cannot be computed on the client. if (isClient) { return state; } // Disallow events once the game is over. if (state.ctx.gameover !== undefined) { error(`cannot call event after game end`); return WithError(state, ActionErrorType.GameOver); } // Ignore the event if the player isn't active. if ( actionHasPlayerID(action) && !game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID) ) { error(`disallowed event: ${action.payload.type}`); return WithError(state, ActionErrorType.InactivePlayer); } // Execute plugins. state = plugins.Enhance(state, { game, isClient: false, playerID: action.payload.playerID, }); // Process event. let newState = game.flow.processEvent(state, action); // Execute plugins. let stateWithError: TransientState | undefined; [newState, stateWithError] = flushAndValidatePlugins(newState, state, { game, isClient: false, }); if (stateWithError) return stateWithError; // Update undo / redo state. newState = updateUndoRedoState(newState, { game, action }); return { ...newState, _stateID: state._stateID + 1 }; } case Actions.MAKE_MOVE: { const oldState = (state = { ...state, deltalog: [] }); // Check whether the move is allowed at this time. const move: Move = game.flow.getMove( state.ctx, action.payload.type, action.payload.playerID || state.ctx.currentPlayer ); if (move === null) { error(`disallowed move: ${action.payload.type}`); return WithError(state, ActionErrorType.UnavailableMove); } // Don't run move on client if move says so. if (isClient && (move as LongFormMove).client === false) { return state; } // Disallow moves once the game is over. if (state.ctx.gameover !== undefined) { error(`cannot make move after game end`); return WithError(state, ActionErrorType.GameOver); } // Ignore the move if the player isn't active. if ( actionHasPlayerID(action) && !game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID) ) { error(`disallowed move: ${action.payload.type}`); return WithError(state, ActionErrorType.InactivePlayer); } // Execute plugins. state = plugins.Enhance(state, { game, isClient, playerID: action.payload.playerID, }); // Process the move. const G = game.processMove(state, action.payload); // The game declared the move as invalid. if (G === INVALID_MOVE) { error( `invalid move: ${action.payload.type} args: ${action.payload.args}` ); // TODO(#723): Marshal a nice error payload with the processed move. return WithError(state, ActionErrorType.InvalidMove); } const newState = { ...state, G }; // Some plugin indicated that it is not suitable to be // materialized on the client (and must wait for the server // response instead). if (isClient && plugins.NoClient(newState, { game })) { return state; } state = newState; // If we're on the client, just process the move // and no triggers in multiplayer mode. // These will be processed on the server, which // will send back a state update. if (isClient) { let stateWithError: TransientState | undefined; [state, stateWithError] = flushAndValidatePlugins(state, oldState, { game, isClient: true, }); if (stateWithError) return stateWithError; return { ...state, _stateID: state._stateID + 1, }; } // On the server, construct the deltalog. state = initializeDeltalog(state, action, move); // Allow the flow reducer to process any triggers that happen after moves. state = game.flow.processMove(state, action.payload); let stateWithError: TransientState | undefined; [state, stateWithError] = flushAndValidatePlugins(state, oldState, { game, }); if (stateWithError) return stateWithError; // Update undo / redo state. state = updateUndoRedoState(state, { game, action }); return { ...state, _stateID: state._stateID + 1, }; } case Actions.RESET: case Actions.UPDATE: case Actions.SYNC: { return action.state; } case Actions.UNDO: { state = { ...state, deltalog: [] }; if (game.disableUndo) { error('Undo is not enabled'); return WithError(state, ActionErrorType.ActionDisabled); } const { G, ctx, _undo, _redo, _stateID } = state; if (_undo.length < 2) { error(`No moves to undo`); return WithError(state, ActionErrorType.ActionInvalid); } const last = _undo[_undo.length - 1]; const restore = _undo[_undo.length - 2]; // Only allow players to undo their own moves. if ( actionHasPlayerID(action) && action.payload.playerID !== last.playerID ) { error(`Cannot undo other players' moves`); return WithError(state, ActionErrorType.ActionInvalid); } // If undoing a move, check it is undoable. if (last.moveType) { const lastMove: Move = game.flow.getMove( restore.ctx, last.moveType, last.playerID ); if (!CanUndoMove(G, ctx, lastMove)) { error(`Move cannot be undone`); return WithError(state, ActionErrorType.ActionInvalid); } } state = initializeDeltalog(state, action); return { ...state, G: restore.G, ctx: restore.ctx, plugins: restore.plugins, _stateID: _stateID + 1, _undo: _undo.slice(0, -1), _redo: [last, ..._redo], }; } case Actions.REDO: { state = { ...state, deltalog: [] }; if (game.disableUndo) { error('Redo is not enabled'); return WithError(state, ActionErrorType.ActionDisabled); } const { _undo, _redo, _stateID } = state; if (_redo.length === 0) { error(`No moves to redo`); return WithError(state, ActionErrorType.ActionInvalid); } const first = _redo[0]; // Only allow players to redo their own undos. if ( actionHasPlayerID(action) && action.payload.playerID !== first.playerID ) { error(`Cannot redo other players' moves`); return WithError(state, ActionErrorType.ActionInvalid); } state = initializeDeltalog(state, action); return { ...state, G: first.G, ctx: first.ctx, plugins: first.plugins, _stateID: _stateID + 1, _undo: [..._undo, first], _redo: _redo.slice(1), }; } case Actions.PLUGIN: { // TODO(#723): Expose error semantics to plugin processing. return plugins.ProcessAction(state, action, { game }); } case Actions.PATCH: { const oldState = state; const newState = JSON.parse(JSON.stringify(oldState)); const patchError = applyPatch(newState, action.patch); const hasError = patchError.some((entry) => entry !== null); if (hasError) { error(`Patch ${JSON.stringify(action.patch)} apply failed`); return WithError(oldState, UpdateErrorType.PatchFailed, patchError); } else { return newState; } } default: { return state; } } }; } ================================================ FILE: src/core/turn-order.test.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { Flow } from './flow'; import { Client } from '../client/client'; import { UpdateTurnOrderState, Stage, TurnOrder, ActivePlayers, } from './turn-order'; import { makeMove, gameEvent } from './action-creators'; import { CreateGameReducer } from './reducer'; import { InitializeGame } from './initialize'; import { error } from '../core/logger'; import type { Game, State } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); // Let the Typescript compiler know about our custom matcher. declare global { namespace jest { interface Matchers { toHaveUndefinedProperties(): R; } } } describe('turn orders', () => { // Defines a matcher for testing that ctx has no undefined properties. // Identifies which property is undefined. expect.extend({ toHaveUndefinedProperties(ctx) { const undefinedEntry = Object.entries(ctx).find((entry) => { const [, value] = entry; return value === undefined; }); if (undefinedEntry === undefined) { return { message: () => `expected some properties of ctx to be undefined`, pass: false, }; } else { const [k] = undefinedEntry; return { message: () => `expected ctx.${k} to be defined`, pass: true, }; } }, }); test('DEFAULT', () => { const flow = Flow({ phases: { A: { start: true, next: 'B' }, B: {} }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.phase).toBe('A'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('B'); }); test('CONTINUE', () => { const flow = Flow({ turn: { order: TurnOrder.CONTINUE }, phases: { A: { start: true, next: 'B' }, B: {} }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.phase).toBe('A'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.phase).toBe('B'); }); test('RESET', () => { const flow = Flow({ turn: { order: TurnOrder.RESET }, phases: { A: { start: true, next: 'B' }, B: {} }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.phase).toBe('A'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('B'); }); test('ONCE', () => { const flow = Flow({ turn: { order: TurnOrder.ONCE }, phases: { A: { start: true, next: 'B' }, B: {} }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.phase).toBe('B'); }); test('ALL', () => { const flow = Flow({ turn: { activePlayers: ActivePlayers.ALL }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); expect(state.ctx.activePlayers).toEqual({ '0': Stage.NULL, '1': Stage.NULL, }); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.activePlayers).toEqual({ '0': Stage.NULL, '1': Stage.NULL, }); }); test('ALL_ONCE', () => { const flow = Flow({ phases: { A: { start: true, turn: { activePlayers: ActivePlayers.ALL_ONCE } }, }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('0'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '1']); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('1'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '1']); state = flow.processMove(state, makeMove('', null, '0').payload); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('1'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['1']); state = flow.processMove(state, makeMove('', null, '1').payload); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.activePlayers).toBeNull(); state = flow.processMove(state, makeMove('', null, '1').payload); }); test('OTHERS', () => { const flow = Flow({ turn: { activePlayers: ActivePlayers.OTHERS }, }); let state = { ctx: flow.ctx(3) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['1', '2']); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '2']); }); test('OTHERS_ONCE', () => { const flow = Flow({ turn: { activePlayers: ActivePlayers.OTHERS_ONCE }, phases: { A: { start: true } }, }); let state = { ctx: flow.ctx(3) } as State; state = flow.init(state); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('0'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['1', '2']); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('1'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '2']); state = flow.processMove(state, makeMove('', null, '0').payload); expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('1'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['2']); state = flow.processMove(state, makeMove('', null, '2').payload); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx.activePlayers).toBeNull(); state = flow.processMove(state, makeMove('', null, '1').payload); }); test('CUSTOM', () => { const flow = Flow({ turn: { order: TurnOrder.CUSTOM(['1', '0']) }, }); let state = { ctx: flow.ctx(2) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('1'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); }); test('CUSTOM_FROM', () => { const flow = Flow({ turn: { order: TurnOrder.CUSTOM_FROM('order') }, }); let state = { G: { order: ['2', '1', '0'] }, ctx: flow.ctx(3) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('2'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); }); test('manual', () => { const flow = Flow({ phases: { A: { start: true, turn: { order: { first: () => 9, next: () => 3, }, }, }, }, }); let state = { ctx: flow.ctx(10) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('9'); expect(state.ctx).not.toHaveUndefinedProperties(); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('3'); }); }); test('override', () => { const even = { first: () => 0, next: ({ ctx }) => (+ctx.currentPlayer + 2) % ctx.numPlayers, }; const odd = { first: () => 1, next: ({ ctx }) => (+ctx.currentPlayer + 2) % ctx.numPlayers, }; const flow = Flow({ turn: { order: even }, phases: { A: { start: true, next: 'B' }, B: { turn: { order: odd } } }, }); let state = { ctx: flow.ctx(10) } as State; state = flow.init(state); expect(state.ctx.currentPlayer).toBe('0'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('2'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('4'); state = flow.processEvent(state, gameEvent('endPhase')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('3'); state = flow.processEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('5'); }); test('playOrder', () => { const game: Game = {}; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); state.ctx = { ...state.ctx, currentPlayer: '2', playOrder: ['2', '0', '1'], }; state = reducer(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('0'); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('1'); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('2'); }); describe('setActivePlayers', () => { const flow = Flow({}); const state = { ctx: flow.ctx(2) } as State; test('basic', () => { const newState = flow.processEvent( state, gameEvent('setActivePlayers', [{ value: { '1': Stage.NULL } }]) ); expect(newState.ctx.activePlayers).toMatchObject({ '1': Stage.NULL }); }); test('short form', () => { const newState = flow.processEvent( state, gameEvent('setActivePlayers', [['1', '2']]) ); expect(newState.ctx.activePlayers).toMatchObject({ '1': Stage.NULL, '2': Stage.NULL, }); }); test('undefined stage leaves player inactive', () => { const newState = flow.processEvent( state, gameEvent('setActivePlayers', [ { value: { '1': { minMoves: 2, maxMoves: 2, }, }, }, ]) ); expect(newState.ctx.activePlayers).toBeNull(); }); test('all', () => { const newState = flow.processEvent( state, gameEvent('setActivePlayers', [{ all: Stage.NULL }]) ); expect(newState.ctx.activePlayers).toMatchObject({ '0': Stage.NULL, '1': Stage.NULL, }); }); test('once', () => { const game: Game = { moves: { B: ({ G, events }) => { events.setActivePlayers({ value: { '0': Stage.NULL, '1': Stage.NULL }, minMoves: 1, maxMoves: 1, }); return G; }, A: ({ G }) => G, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); state = reducer(state, makeMove('B', null, '0')); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '1']); state = reducer(state, makeMove('A', null, '0')); expect(Object.keys(state.ctx.activePlayers)).toEqual(['1']); state = reducer(state, makeMove('A', null, '1')); expect(state.ctx.activePlayers).toBeNull(); }); test('others', () => { const game: Game = { moves: { B: ({ G, events }) => { events.setActivePlayers({ minMoves: 1, maxMoves: 1, others: Stage.NULL, }); return G; }, A: ({ G }) => G, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); // on move B, control switches from player 0 to players 1 and 2 state = reducer(state, makeMove('B', null, '0')); expect(Object.keys(state.ctx.activePlayers)).toEqual(['1', '2']); // player 1 makes move state = reducer(state, makeMove('A', null, '1')); expect(Object.keys(state.ctx.activePlayers)).toEqual(['2']); // player 2 makes move state = reducer(state, makeMove('A', null, '2')); expect(state.ctx.activePlayers).toBeNull(); }); test('set stages to Stage.NULL', () => { const game: Game = { moves: { A: ({ G }) => G, B: ({ G, events }) => { events.setActivePlayers({ minMoves: 1, maxMoves: 1, currentPlayer: 'start', }); return G; }, }, turn: { activePlayers: { currentPlayer: { stage: 'start', }, others: Stage.NULL, }, stages: { start: { moves: { S: ({ G, events }) => { events.setStage(Stage.NULL); return G; }, }, }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); expect(state.ctx.currentPlayer).toBe('0'); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '1', '2']); expect(state.ctx.activePlayers['0']).toEqual('start'); expect(state.ctx.activePlayers['1']).toEqual(Stage.NULL); expect(state.ctx.activePlayers['2']).toEqual(Stage.NULL); state = reducer(state, makeMove('S', null, '0')); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0', '1', '2']); expect(state.ctx.activePlayers['0']).toEqual(Stage.NULL); state = reducer(state, makeMove('B', null, '0')); expect(Object.keys(state.ctx.activePlayers)).toEqual(['0']); expect(state.ctx.activePlayers['0']).toEqual('start'); }); describe('reset behavior', () => { test('start of turn', () => { const game: Game = { moves: { A: () => {}, }, turn: { activePlayers: { currentPlayer: 'stage', minMoves: 1, maxMoves: 1 }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage' }, _prevActivePlayers: [], }); state = reducer(state, makeMove('A', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: null, _prevActivePlayers: [], }); }); describe('revert', () => { test('resets to previous', () => { const game: Game = { moves: { A: ({ events }) => { events.setActivePlayers({ currentPlayer: 'stage2', minMoves: 1, maxMoves: 1, revert: true, }); }, B: () => {}, }, turn: { activePlayers: { currentPlayer: 'stage1' }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], }); state = reducer(state, makeMove('A', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage2' }, _prevActivePlayers: [ { activePlayers: { '0': 'stage1' }, _activePlayersMinMoves: null, _activePlayersMaxMoves: null, _activePlayersNumMoves: { '0': 1 }, }, ], }); state = reducer(state, makeMove('B', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], }); }); test('restores move limits and counts', () => { const game: Game = { moves: { A: ({ events }) => { events.setActivePlayers({ currentPlayer: 'stage2', minMoves: 1, maxMoves: 1, revert: true, }); }, B: () => {}, }, turn: { activePlayers: { currentPlayer: 'stage1', minMoves: 2, maxMoves: 3, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], _activePlayersMinMoves: { '0': 2 }, _activePlayersMaxMoves: { '0': 3 }, _activePlayersNumMoves: { '0': 0, }, }); state = reducer(state, makeMove('B', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], _activePlayersMinMoves: { '0': 2 }, _activePlayersMaxMoves: { '0': 3 }, _activePlayersNumMoves: { '0': 1, }, }); state = reducer(state, makeMove('A', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage2' }, _prevActivePlayers: [ { activePlayers: { '0': 'stage1' }, _activePlayersNumMoves: { '0': 2 }, _activePlayersMinMoves: { '0': 2 }, _activePlayersMaxMoves: { '0': 3 }, }, ], _activePlayersMinMoves: { '0': 1 }, _activePlayersMaxMoves: { '0': 1 }, _activePlayersNumMoves: { '0': 0, }, }); state = reducer(state, makeMove('B', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], _activePlayersMinMoves: { '0': 2 }, _activePlayersMaxMoves: { '0': 3 }, _activePlayersNumMoves: { '0': 2, }, }); }); }); test('set to next', () => { const game: Game = { moves: { A: () => {}, }, turn: { activePlayers: { currentPlayer: 'stage1', minMoves: 1, maxMoves: 1, next: { currentPlayer: 'stage2', minMoves: 1, maxMoves: 1, next: { currentPlayer: 'stage3', }, }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], _nextActivePlayers: { currentPlayer: 'stage2', minMoves: 1, maxMoves: 1, next: { currentPlayer: 'stage3', }, }, }); state = reducer(state, makeMove('A', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage2' }, _prevActivePlayers: [], _nextActivePlayers: { currentPlayer: 'stage3', }, }); state = reducer(state, makeMove('A', null, '0')); expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage3' }, _prevActivePlayers: [], _nextActivePlayers: null, }); }); }); describe('move limits', () => { test('shorthand syntax', () => { const game: Game = { turn: { activePlayers: { all: 'play', minMoves: 1, maxMoves: 3, }, stages: { play: { moves: { A: () => {} } }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 1, '1': 1, '2': 1, }); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 3, '1': 3, '2': 3, }); expect(state.ctx._activePlayersNumMoves).toEqual({ '0': 0, '1': 0, '2': 0, }); state = reducer(state, makeMove('A', null, '0')); state = reducer(state, makeMove('A', null, '1')); state = reducer(state, makeMove('A', null, '1')); state = reducer(state, makeMove('A', null, '2')); expect(state.ctx._activePlayersNumMoves).toEqual({ '0': 1, '1': 2, '2': 1, }); state = reducer(state, makeMove('A', null, '1')); expect(state.ctx.activePlayers).toEqual({ '0': 'play', '2': 'play', }); }); test('long-form syntax', () => { const game: Game = { turn: { activePlayers: { currentPlayer: { stage: 'play', minMoves: 1, maxMoves: 2 }, others: { stage: 'play', maxMoves: 1 }, }, stages: { play: { moves: { A: () => {} } }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); expect(state.ctx._activePlayersMinMoves).toStrictEqual({ '0': 1 }); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2, '1': 1, '2': 1, }); expect(state.ctx._activePlayersNumMoves).toEqual({ '0': 0, '1': 0, '2': 0, }); state = reducer(state, makeMove('A', null, '0')); state = reducer(state, makeMove('A', null, '1')); state = reducer(state, makeMove('A', null, '2')); expect(state.ctx._activePlayersNumMoves).toEqual({ '0': 1, '1': 1, '2': 1, }); expect(state.ctx.activePlayers).toEqual({ '0': 'play' }); state = reducer(state, makeMove('A', null, '0')); expect(state.ctx.activePlayers).toBeNull(); }); test('player-specific limit overrides move limit args', () => { const game: Game = { turn: { activePlayers: { all: { stage: 'play', minMoves: 2, maxMoves: 2 }, minMoves: 1, maxMoves: 1, }, }, }; const state = InitializeGame({ game, numPlayers: 2 }); expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 2, '1': 2, }); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2, '1': 2, }); }); test('value syntax', () => { const game: Game = { turn: { activePlayers: { value: { '0': { stage: 'play', maxMoves: 1 }, '1': { stage: 'play', minMoves: 1, maxMoves: 2 }, '2': { stage: 'play', minMoves: 2, maxMoves: 3 }, }, }, stages: { play: { moves: { A: () => {} } }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); expect(state.ctx._activePlayersMinMoves).toStrictEqual({ '1': 1, '2': 2, }); expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1, '1': 2, '2': 3, }); state = reducer(state, makeMove('A', null, '0')); state = reducer(state, makeMove('A', null, '1')); state = reducer(state, makeMove('A', null, '2')); expect(state.ctx.activePlayers).toEqual({ '1': 'play', '2': 'play' }); state = reducer(state, makeMove('A', null, '1')); state = reducer(state, makeMove('A', null, '2')); expect(state.ctx.activePlayers).toEqual({ '2': 'play' }); state = reducer(state, makeMove('A', null, '2')); expect(state.ctx.activePlayers).toBeNull(); }); test('move counts reset on turn end', () => { const game: Game = { turn: { activePlayers: { all: 'play', }, stages: { play: { moves: { A: () => {} } }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); state = reducer(state, makeMove('A', null, '0')); state = reducer(state, makeMove('A', null, '1')); expect(state.ctx._activePlayersNumMoves).toEqual({ '0': 1, '1': 1, '2': 0, }); state = reducer(state, gameEvent('endTurn')); expect(state.ctx._activePlayersNumMoves).toEqual({ '0': 0, '1': 0, '2': 0, }); }); }); describe('militia', () => { let state; let reducer; beforeAll(() => { const game: Game = { moves: { militia: ({ events }) => { events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1, revert: true, }); }, }, turn: { stages: { discard: { moves: { discard: ({ G }) => G, }, }, }, }, }; reducer = CreateGameReducer({ game }); state = InitializeGame({ game, numPlayers: 3 }); }); beforeEach(() => { (error as jest.Mock).mockClear(); }); test('sanity', () => { expect(state.ctx.activePlayers).toEqual(null); }); test('player 1 cannot play the militia card', () => { state = reducer(state, makeMove('militia', undefined, '1')); expect(error).toHaveBeenCalledWith('disallowed move: militia'); }); test('player 2 cannot play the militia card', () => { state = reducer(state, makeMove('militia', undefined, '2')); expect(error).toHaveBeenCalledWith('disallowed move: militia'); }); test('player 0 cannot discard', () => { state = reducer(state, makeMove('discard', undefined, '0')); expect(error).toHaveBeenCalledWith('disallowed move: discard'); }); test('player 1 cannot discard', () => { state = reducer(state, makeMove('discard', undefined, '1')); expect(error).toHaveBeenCalledWith('disallowed move: discard'); }); test('player 2 cannot discard', () => { state = reducer(state, makeMove('discard', undefined, '2')); expect(error).toHaveBeenCalledWith('disallowed move: discard'); }); test('player 0 plays militia', () => { state = reducer(state, makeMove('militia', undefined, '0')); expect(state.ctx.activePlayers).toEqual({ '1': 'discard', '2': 'discard', }); }); test('player 0 cannot play militia again', () => { state = reducer(state, makeMove('militia', undefined, '0')); expect(error).toHaveBeenCalledWith('disallowed move: militia'); }); test('player 0 still cannot discard', () => { state = reducer(state, makeMove('discard', undefined, '0')); expect(error).toHaveBeenCalledWith('disallowed move: discard'); }); test('everyone else discards', () => { state = reducer(state, makeMove('discard', undefined, '1')); expect(state.ctx.activePlayers).toEqual({ '2': 'discard' }); state = reducer(state, makeMove('discard', undefined, '2')); }); test('activePlayers is restored to previous state', () => { expect(state.ctx.activePlayers).toEqual(null); }); }); }); describe('UpdateTurnOrderState', () => { const G = {}; const ctx = { currentPlayer: '0', playOrder: ['0', '1', '2'], playOrderPos: 0, }; const turn = { order: TurnOrder.DEFAULT }; test('without next player', () => { const { ctx: t } = UpdateTurnOrderState( { G, ctx } as State, ctx.currentPlayer, turn ); expect(t).toMatchObject({ currentPlayer: '1' }); }); test('with next player', () => { const { ctx: t } = UpdateTurnOrderState( { G, ctx } as State, ctx.currentPlayer, turn, { next: '2', } ); expect(t).toMatchObject({ currentPlayer: '2' }); }); test('errors if turn.order.next doesn’t return a number', () => { UpdateTurnOrderState({ G, ctx } as State, ctx.currentPlayer, { order: { first: () => 0, next: () => '2' as unknown as number, }, }); expect(error).toHaveBeenCalledWith( `invalid value returned by turn.order.next — expected number or undefined got string “2”.` ); }); }); describe('Random API is available', () => { let first; let next; const turn = { order: { first: ({ random }) => { if (random !== undefined) { first = true; } return 0; }, next: ({ random }) => { if (random !== undefined) { next = true; } return 0; }, }, }; const game: Game = { turn }; beforeEach(() => { first = next = false; }); test('init', () => { Client({ game }); expect(first).toBe(true); }); test('end turn', () => { const client = Client({ game }); expect(next).toBe(false); client.events.endTurn(); expect(next).toBe(true); }); }); ================================================ FILE: src/core/turn-order.ts ================================================ /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import * as logging from './logger'; import * as plugin from '../plugins/main'; import type { Ctx, StageArg, ActivePlayersArg, PlayerID, State, TurnConfig, FnContext, } from '../types'; import { supportDeprecatedMoveLimit } from './backwards-compatibility'; export function SetActivePlayers(ctx: Ctx, arg: ActivePlayersArg): Ctx { let activePlayers: typeof ctx.activePlayers = {}; let _prevActivePlayers: typeof ctx._prevActivePlayers = []; let _nextActivePlayers: ActivePlayersArg | null = null; let _activePlayersMinMoves = {}; let _activePlayersMaxMoves = {}; if (Array.isArray(arg)) { // support a simple array of player IDs as active players const value = {}; arg.forEach((v) => (value[v] = Stage.NULL)); activePlayers = value; } else { // process active players argument object // stages previously did not enforce minMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(arg); if (arg.next) { _nextActivePlayers = arg.next; } if (arg.revert) { _prevActivePlayers = [ ...ctx._prevActivePlayers, { activePlayers: ctx.activePlayers, _activePlayersMinMoves: ctx._activePlayersMinMoves, _activePlayersMaxMoves: ctx._activePlayersMaxMoves, _activePlayersNumMoves: ctx._activePlayersNumMoves, }, ]; } if (arg.currentPlayer !== undefined) { ApplyActivePlayerArgument( activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, ctx.currentPlayer, arg.currentPlayer ); } if (arg.others !== undefined) { for (let i = 0; i < ctx.playOrder.length; i++) { const id = ctx.playOrder[i]; if (id !== ctx.currentPlayer) { ApplyActivePlayerArgument( activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.others ); } } } if (arg.all !== undefined) { for (let i = 0; i < ctx.playOrder.length; i++) { const id = ctx.playOrder[i]; ApplyActivePlayerArgument( activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.all ); } } if (arg.value) { for (const id in arg.value) { ApplyActivePlayerArgument( activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.value[id] ); } } if (arg.minMoves) { for (const id in activePlayers) { if (_activePlayersMinMoves[id] === undefined) { _activePlayersMinMoves[id] = arg.minMoves; } } } if (arg.maxMoves) { for (const id in activePlayers) { if (_activePlayersMaxMoves[id] === undefined) { _activePlayersMaxMoves[id] = arg.maxMoves; } } } } if (Object.keys(activePlayers).length === 0) { activePlayers = null; } if (Object.keys(_activePlayersMinMoves).length === 0) { _activePlayersMinMoves = null; } if (Object.keys(_activePlayersMaxMoves).length === 0) { _activePlayersMaxMoves = null; } const _activePlayersNumMoves = {}; for (const id in activePlayers) { _activePlayersNumMoves[id] = 0; } return { ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, }; } /** * Update activePlayers, setting it to previous, next or null values * when it becomes empty. * @param ctx */ export function UpdateActivePlayersOnceEmpty(ctx: Ctx) { let { activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, } = ctx; if (activePlayers && Object.keys(activePlayers).length === 0) { if (_nextActivePlayers) { ctx = SetActivePlayers(ctx, _nextActivePlayers); ({ activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, } = ctx); } else if (_prevActivePlayers.length > 0) { const lastIndex = _prevActivePlayers.length - 1; ({ activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, } = _prevActivePlayers[lastIndex]); _prevActivePlayers = _prevActivePlayers.slice(0, lastIndex); } else { activePlayers = null; _activePlayersMinMoves = null; _activePlayersMaxMoves = null; } } return { ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, }; } /** * Apply an active player argument to the given player ID * @param {Object} activePlayers * @param {Object} _activePlayersMinMoves * @param {Object} _activePlayersMaxMoves * @param {String} playerID The player to apply the parameter to * @param {(String|Object)} arg An active player argument */ function ApplyActivePlayerArgument( activePlayers: Ctx['activePlayers'], _activePlayersMinMoves: Ctx['_activePlayersMinMoves'], _activePlayersMaxMoves: Ctx['_activePlayersMaxMoves'], playerID: PlayerID, arg: StageArg ) { if (typeof arg !== 'object' || arg === Stage.NULL) { arg = { stage: arg as string | null }; } if (arg.stage !== undefined) { // stages previously did not enforce minMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(arg); activePlayers[playerID] = arg.stage; if (arg.minMoves) _activePlayersMinMoves[playerID] = arg.minMoves; if (arg.maxMoves) _activePlayersMaxMoves[playerID] = arg.maxMoves; } } /** * Converts a playOrderPos index into its value in playOrder. * @param {Array} playOrder - An array of player ID's. * @param {number} playOrderPos - An index into the above. */ function getCurrentPlayer( playOrder: Ctx['playOrder'], playOrderPos: Ctx['playOrderPos'] ) { // convert to string in case playOrder is set to number[] return playOrder[playOrderPos] + ''; } /** * Called at the start of a turn to initialize turn order state. * * TODO: This is called inside StartTurn, which is called from * both UpdateTurn and StartPhase (so it's called at the beginning * of a new phase as well as between turns). We should probably * split it into two. */ export function InitTurnOrderState(state: State, turn: TurnConfig) { let { G, ctx } = state; const { numPlayers } = ctx; const pluginAPIs = plugin.GetAPIs(state); const context = { ...pluginAPIs, G, ctx }; const order = turn.order; let playOrder = [...Array.from({ length: numPlayers })].map((_, i) => i + ''); if (order.playOrder !== undefined) { playOrder = order.playOrder(context); } const playOrderPos = order.first(context); const posType = typeof playOrderPos; if (posType !== 'number') { logging.error( `invalid value returned by turn.order.first — expected number got ${posType} “${playOrderPos}”.` ); } const currentPlayer = getCurrentPlayer(playOrder, playOrderPos); ctx = { ...ctx, currentPlayer, playOrderPos, playOrder }; ctx = SetActivePlayers(ctx, turn.activePlayers || {}); return ctx; } /** * Called at the end of each turn to update the turn order state. * @param {object} G - The game object G. * @param {object} ctx - The game object ctx. * @param {object} turn - A turn object for this phase. * @param {string} endTurnArg - An optional argument to endTurn that may specify the next player. */ export function UpdateTurnOrderState( state: State, currentPlayer: PlayerID, turn: TurnConfig, endTurnArg?: true | { remove?: any; next?: string } ) { const order = turn.order; let { G, ctx } = state; let playOrderPos = ctx.playOrderPos; let endPhase = false; if (endTurnArg && endTurnArg !== true) { if (typeof endTurnArg !== 'object') { logging.error(`invalid argument to endTurn: ${endTurnArg}`); } Object.keys(endTurnArg).forEach((arg) => { switch (arg) { case 'remove': currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos); break; case 'next': playOrderPos = ctx.playOrder.indexOf(endTurnArg.next); currentPlayer = endTurnArg.next; break; default: logging.error(`invalid argument to endTurn: ${arg}`); } }); } else { const pluginAPIs = plugin.GetAPIs(state); const context = { ...pluginAPIs, G, ctx }; const t = order.next(context); const type = typeof t; if (t !== undefined && type !== 'number') { logging.error( `invalid value returned by turn.order.next — expected number or undefined got ${type} “${t}”.` ); } if (t === undefined) { endPhase = true; } else { playOrderPos = t; currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos); } } ctx = { ...ctx, playOrderPos, currentPlayer, }; return { endPhase, ctx }; } /** * Set of different turn orders possible in a phase. * These are meant to be passed to the `turn` setting * in the flow objects. * * Each object defines the first player when the phase / game * begins, and also a function `next` to determine who the * next player is when the turn ends. * * The phase ends if next() returns undefined. */ export const TurnOrder = { /** * DEFAULT * * The default round-robin turn order. */ DEFAULT: { first: ({ ctx }: FnContext) => ctx.turn === 0 ? ctx.playOrderPos : (ctx.playOrderPos + 1) % ctx.playOrder.length, next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * RESET * * Similar to DEFAULT, but starts from 0 each time. */ RESET: { first: () => 0, next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * CONTINUE * * Similar to DEFAULT, but starts with the player who ended the last phase. */ CONTINUE: { first: ({ ctx }: FnContext) => ctx.playOrderPos, next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * ONCE * * Another round-robin turn order, but goes around just once. * The phase ends after all players have played. */ ONCE: { first: () => 0, next: ({ ctx }: FnContext) => { if (ctx.playOrderPos < ctx.playOrder.length - 1) { return ctx.playOrderPos + 1; } }, }, /** * CUSTOM * * Identical to DEFAULT, but also sets playOrder at the * beginning of the phase. * * @param {Array} playOrder - The play order. */ CUSTOM: (playOrder: string[]) => ({ playOrder: () => playOrder, first: () => 0, next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), /** * CUSTOM_FROM * * Identical to DEFAULT, but also sets playOrder at the * beginning of the phase to a value specified by a field * in G. * * @param {string} playOrderField - Field in G. */ CUSTOM_FROM: (playOrderField: string) => ({ playOrder: ({ G }: FnContext) => G[playOrderField], first: () => 0, next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), }; export const Stage = { NULL: null, }; export const ActivePlayers = { /** * ALL * * The turn stays with one player, but any player can play (in any order) * until the phase ends. */ ALL: { all: Stage.NULL }, /** * ALL_ONCE * * The turn stays with one player, but any player can play (once, and in any order). * This is typically used in a phase where you want to elicit a response * from every player in the game. */ ALL_ONCE: { all: Stage.NULL, minMoves: 1, maxMoves: 1 }, /** * OTHERS * * The turn stays with one player, and every *other* player can play (in any order) * until the phase ends. */ OTHERS: { others: Stage.NULL }, /** * OTHERS_ONCE * * The turn stays with one player, and every *other* player can play (once, and in any order). * This is typically used in a phase where you want to elicit a response * from every *other* player in the game. */ OTHERS_ONCE: { others: Stage.NULL, minMoves: 1, maxMoves: 1 }, }; ================================================ FILE: src/lobby/client.test.ts ================================================ // Tell ESLint about additional assertion methods. /* eslint jest/expect-expect: [ "warn", { "assertFunctionNames": ["expect", "throwsWith*", "testBasicBody"] } ] */ import { LobbyClient } from './client'; const throwsWithoutBody = (fn: (...args: any) => Promise) => async () => { await expect(fn('tic-tac-toe')).rejects.toThrow( `Expected body, got “undefined”.` ); }; const testStringValidation = (fn: (arg: any) => Promise, label: string) => async () => { await expect(fn(undefined)).rejects.toThrow( `Expected ${label} string, got "undefined".` ); await expect(fn(2)).rejects.toThrow(`Expected ${label} string, got "2".`); await expect(fn('')).rejects.toThrow(`Expected ${label} string, got "".`); }; const throwsWithInvalidGameName = (fn: (...args: any) => Promise) => testStringValidation(fn, 'game name'); const throwsWithInvalidMatchID = (fn: (...args: any) => Promise) => testStringValidation((matchID: string) => fn('chess', matchID), 'match ID'); const testBasicBody = (fn: (...args: any) => Promise) => async () => { await expect( fn('chess', '1', { playerID: undefined, credentials: 'pwd' }) ).rejects.toThrow( 'Expected body.playerID to be of type string, got “undefined”.' ); await expect( fn('chess', '2', { playerID: '0', credentials: 0 as unknown as string }) ).rejects.toThrow('Expected body.credentials to be of type string, got “0”.'); }; describe('LobbyClient', () => { let client = new LobbyClient(); beforeEach(async () => { (global as any).fetch = jest.fn(async () => ({ ok: true, status: 200, json: async () => {}, })); }); describe('construction', () => { test('basic', async () => { await client.listGames(); expect(fetch).toBeCalledWith(`/games`, undefined); }); test('with server address', async () => { const client = new LobbyClient({ server: 'http://api.io' }); await client.listGames(); expect(fetch).toBeCalledWith(`http://api.io/games`, undefined); }); test('with server address with trailing slash', async () => { const client = new LobbyClient({ server: 'http://api.io/' }); await client.listGames(); expect(fetch).toBeCalledWith(`http://api.io/games`, undefined); }); }); describe('status errors', () => { beforeEach(async () => { client = new LobbyClient(); }); test('404 throws an error', async () => { (global as any).fetch = jest.fn(async () => ({ ok: false, status: 404, json: async () => {}, })); await expect(client.listGames()).rejects.toThrow('HTTP status 404'); }); test('404 throws an error with json details', async () => { (global as any).fetch = jest.fn(async () => ({ ok: false, status: 404, clone: () => ({ json: async () => ({ moreInformation: 'some helpful details' }), }), })); await expect(client.listGames()).rejects.toThrow( expect.objectContaining({ message: 'HTTP status 404', details: { moreInformation: 'some helpful details', }, }) ); }); test('404 throws an error with text details', async () => { (global as any).fetch = jest.fn(async () => ({ ok: false, status: 404, json: async () => { throw new Error('impossible to parse json'); }, text: async () => 'some helpful details', })); await expect(client.listGames()).rejects.toThrow( expect.objectContaining({ message: 'HTTP status 404', details: 'some helpful details', }) ); }); test('404 throws an error without details', async () => { (global as any).fetch = jest.fn(async () => ({ ok: false, status: 404, json: async () => { throw new Error('impossible to parse json'); }, text: async () => { throw new Error('something went wrong in the connection'); }, })); await expect(client.listGames()).rejects.toThrow( expect.objectContaining({ message: 'HTTP status 404', details: 'something went wrong in the connection', }) ); }); }); describe('listGames', () => { test('calls `/games`', async () => { await client.listGames(); expect(fetch).toBeCalledWith('/games', undefined); }); test('init can be customized', async () => { await client.listGames({ headers: { Authorization: 'pwd' } }); expect(fetch).toBeCalledWith('/games', { headers: { Authorization: 'pwd' }, }); }); }); describe('listMatches', () => { test('calls `/games/:name`', async () => { await client.listMatches('tic-tac-toe'); expect(fetch).toBeCalledWith(`/games/tic-tac-toe`, undefined); }); test('validates gameName', throwsWithInvalidGameName(client.listMatches)); describe('builds filter queries', () => { test('kitchen sink', async () => { await client.listMatches('chess', { isGameover: false, updatedBefore: 3000, updatedAfter: 1000, }); expect(fetch).toBeCalledWith( '/games/chess?isGameover=false&updatedBefore=3000&updatedAfter=1000', undefined ); }); test('isGameover', async () => { await client.listMatches('chess', { isGameover: undefined }); expect(fetch).toBeCalledWith('/games/chess', undefined); await client.listMatches('chess', { isGameover: false }); expect(fetch).toBeCalledWith( '/games/chess?isGameover=false', undefined ); await client.listMatches('chess', { isGameover: true }); expect(fetch).toBeCalledWith('/games/chess?isGameover=true', undefined); }); test('updatedBefore', async () => { const updatedBefore = 1989; await client.listMatches('chess', { updatedBefore }); expect(fetch).toBeCalledWith( '/games/chess?updatedBefore=1989', undefined ); }); test('updatedAfter', async () => { const updatedAfter = 1970; await client.listMatches('chess', { updatedAfter }); expect(fetch).toBeCalledWith( '/games/chess?updatedAfter=1970', undefined ); }); }); }); describe('getMatch', () => { test('calls `/games/:name/:id`', async () => { await client.getMatch('tic-tac-toe', 'xyz'); expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz`, undefined); }); test('validates gameName', throwsWithInvalidGameName(client.getMatch)); test('validates matchID', throwsWithInvalidMatchID(client.getMatch)); }); describe('createMatch', () => { test('calls `/games/:name/create`', async () => { await client.createMatch('tic-tac-toe', { numPlayers: 2 }); expect(fetch).toBeCalledWith(`/games/tic-tac-toe/create`, { method: 'post', body: '{"numPlayers":2}', headers: { 'Content-Type': 'application/json' }, }); }); test('validates gameName', throwsWithInvalidGameName(client.createMatch)); test('throws without body', throwsWithoutBody(client.createMatch)); test('validates body', async () => { await expect( client.createMatch('tic-tac-toe', { numPlayers: '12' as unknown as number, }) ).rejects.toThrow( 'Expected body.numPlayers to be of type number, got “12”.' ); }); test('init can be customized', async () => { await client.createMatch( 'chess', { numPlayers: 2 }, { headers: { Authorization: 'pwd' } } ); expect(fetch).toBeCalledWith(`/games/chess/create`, { method: 'post', body: '{"numPlayers":2}', headers: { 'Content-Type': 'application/json', Authorization: 'pwd', }, }); }); }); describe('joinMatch', () => { test('calls `/games/:name/:id/join`', async () => { await client.joinMatch('tic-tac-toe', 'xyz', { playerID: '0', playerName: 'Alice', }); expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/join`, { method: 'post', body: '{"playerID":"0","playerName":"Alice"}', headers: { 'Content-Type': 'application/json' }, }); }); test('validates gameName', throwsWithInvalidGameName(client.joinMatch)); test('validates matchID', throwsWithInvalidMatchID(client.joinMatch)); test( 'throws without body', throwsWithoutBody(() => client.joinMatch('chess', 'id', undefined)) ); test('validates body', async () => { await expect( client.joinMatch('tic-tac-toe', 'xyz', { playerID: 0 as unknown as string, playerName: 'Bob', }) ).rejects.toThrow( 'Expected body.playerID to be of type string|undefined, got “0”.' ); await expect( client.joinMatch('tic-tac-toe', 'xyz', { playerID: '0', playerName: undefined, }) ).rejects.toThrow( 'Expected body.playerName to be of type string, got “undefined”.' ); // Allows requests that don’t specify `playerID`. await expect( client.joinMatch('tic-tac-toe', 'xyz', { playerName: 'Bob' }) ).resolves.not.toThrow(); }); }); describe('leaveMatch', () => { test('calls `/games/:name/:id/leave`', async () => { await client.leaveMatch('tic-tac-toe', 'xyz', { playerID: '0', credentials: 'pwd', }); expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/leave`, { method: 'post', body: '{"playerID":"0","credentials":"pwd"}', headers: { 'Content-Type': 'application/json' }, }); }); test('validates gameName', throwsWithInvalidGameName(client.leaveMatch)); test('validates matchID', throwsWithInvalidMatchID(client.leaveMatch)); test( 'throws without body', throwsWithoutBody(() => client.leaveMatch('chess', 'id', undefined)) ); test('validates body', testBasicBody(client.leaveMatch)); }); describe('updatePlayer', () => { test('calls `/games/:name/:id/update`', async () => { await client.updatePlayer('tic-tac-toe', 'xyz', { playerID: '0', credentials: 'pwd', newName: 'Al', }); expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/update`, { method: 'post', body: '{"playerID":"0","credentials":"pwd","newName":"Al"}', headers: { 'Content-Type': 'application/json' }, }); }); test('validates gameName', throwsWithInvalidGameName(client.updatePlayer)); test('validates matchID', throwsWithInvalidMatchID(client.updatePlayer)); test( 'throws without body', throwsWithoutBody(() => client.updatePlayer('chess', 'id', undefined)) ); test('validates body', testBasicBody(client.updatePlayer)); }); describe('playAgain', () => { test('calls `/games/:name/:id/playAgain`', async () => { await client.playAgain('tic-tac-toe', 'xyz', { playerID: '0', credentials: 'pwd', }); expect(fetch).toBeCalledWith(`/games/tic-tac-toe/xyz/playAgain`, { method: 'post', body: '{"playerID":"0","credentials":"pwd"}', headers: { 'Content-Type': 'application/json' }, }); }); test('validates gameName', throwsWithInvalidGameName(client.playAgain)); test('validates matchID', throwsWithInvalidMatchID(client.playAgain)); test( 'throws without body', throwsWithoutBody(() => client.playAgain('chess', 'id', undefined)) ); test('validates body', testBasicBody(client.playAgain)); }); }); ================================================ FILE: src/lobby/client.ts ================================================ import type { LobbyAPI } from '../types'; const assertString = (str: unknown, label: string) => { if (!str || typeof str !== 'string') { throw new Error(`Expected ${label} string, got "${str}".`); } }; const assertGameName = (name?: string) => assertString(name, 'game name'); const assertMatchID = (id?: string) => assertString(id, 'match ID'); type JSType = | 'string' | 'number' | 'bigint' | 'object' | 'boolean' | 'symbol' | 'function' | 'undefined'; const validateBody = ( body: { [key: string]: any } | undefined, schema: { [key: string]: JSType | JSType[] } ) => { if (!body) throw new Error(`Expected body, got “${body}”.`); for (const key in schema) { const propSchema = schema[key]; const types = Array.isArray(propSchema) ? propSchema : [propSchema]; const received = body[key]; if (!types.includes(typeof received)) { const union = types.join('|'); throw new TypeError( `Expected body.${key} to be of type ${union}, got “${received}”.` ); } } }; export class LobbyClientError extends Error { readonly details: any; constructor(message: string, details: any) { super(message); this.details = details; } } /** * Create a boardgame.io Lobby API client. * @param server The API’s base URL, e.g. `http://localhost:8000`. */ export class LobbyClient { private server: string; constructor({ server = '' }: { server?: string } = {}) { // strip trailing slash if passed this.server = server.replace(/\/$/, ''); } private async request(route: string, init?: RequestInit) { const response = await fetch(this.server + route, init); if (!response.ok) { let details: any; try { details = await response.clone().json(); } catch { try { details = await response.text(); } catch (error) { details = error.message; } } throw new LobbyClientError(`HTTP status ${response.status}`, details); } return response.json(); } private async post(route: string, opts: { body?: any; init?: RequestInit }) { let init: RequestInit = { method: 'post', body: JSON.stringify(opts.body), headers: { 'Content-Type': 'application/json' }, }; if (opts.init) init = { ...init, ...opts.init, headers: { ...init.headers, ...opts.init.headers }, }; return this.request(route, init); } /** * Get a list of the game names available on this server. * @param init Optional RequestInit interface to override defaults. * @return Array of game names. * * @example * lobbyClient.listGames() * .then(console.log); // => ['chess', 'tic-tac-toe'] */ async listGames(init?: RequestInit): Promise { return this.request('/games', init); } /** * Get a list of the matches for a specific game type on the server. * @param gameName The game to list for, e.g. 'tic-tac-toe'. * @param where Options to filter matches by update time or gameover state * @param init Optional RequestInit interface to override defaults. * @return Array of match metadata objects. * * @example * lobbyClient.listMatches('tic-tac-toe', where: { isGameover: false }) * .then(data => console.log(data.matches)); * // => [ * // { * // matchID: 'xyz', * // gameName: 'tic-tac-toe', * // players: [{ id: 0, name: 'Alice' }, { id: 1 }] * // }, * // ... * // ] */ async listMatches( gameName: string, where?: { /** * If true, only games that have ended will be returned. * If false, only games that have not yet ended will be returned. * Leave undefined to receive both finished and unfinished games. */ isGameover?: boolean; /** * List matches last updated before a specific time. * Value should be a timestamp in milliseconds after January 1, 1970. */ updatedBefore?: number; /** * List matches last updated after a specific time. * Value should be a timestamp in milliseconds after January 1, 1970. */ updatedAfter?: number; }, init?: RequestInit ): Promise { assertGameName(gameName); let query = ''; if (where) { const queries = []; const { isGameover, updatedBefore, updatedAfter } = where; if (isGameover !== undefined) queries.push(`isGameover=${isGameover}`); if (updatedBefore) queries.push(`updatedBefore=${updatedBefore}`); if (updatedAfter) queries.push(`updatedAfter=${updatedAfter}`); if (queries.length > 0) query = '?' + queries.join('&'); } return this.request(`/games/${gameName}${query}`, init); } /** * Get metadata for a specific match. * @param gameName The match’s game type, e.g. 'tic-tac-toe'. * @param matchID Match ID for the match to fetch. * @param init Optional RequestInit interface to override defaults. * @return A match metadata object. * * @example * lobbyClient.getMatch('tic-tac-toe', 'xyz').then(console.log); * // => { * // matchID: 'xyz', * // gameName: 'tic-tac-toe', * // players: [{ id: 0, name: 'Alice' }, { id: 1 }] * // } */ async getMatch( gameName: string, matchID: string, init?: RequestInit ): Promise { assertGameName(gameName); assertMatchID(matchID); return this.request(`/games/${gameName}/${matchID}`, init); } /** * Create a new match for a specific game type. * @param gameName The game to create a match for, e.g. 'tic-tac-toe'. * @param body Options required to configure match creation. * @param init Optional RequestInit interface to override defaults. * @return An object containing the created `matchID`. * * @example * lobbyClient.createMatch('tic-tac-toe', { numPlayers: 2 }) * .then(console.log); * // => { matchID: 'xyz' } */ async createMatch( gameName: string, body: { numPlayers: number; setupData?: any; unlisted?: boolean; [key: string]: any; }, init?: RequestInit ): Promise { assertGameName(gameName); validateBody(body, { numPlayers: 'number' }); return this.post(`/games/${gameName}/create`, { body, init }); } /** * Join a match using its matchID. * @param gameName The match’s game type, e.g. 'tic-tac-toe'. * @param matchID Match ID for the match to join. * @param body Options required to join match. * @param init Optional RequestInit interface to override defaults. * @return Object containing `playerCredentials` for the player who joined. * * @example * lobbyClient.joinMatch('tic-tac-toe', 'xyz', { * playerID: '1', * playerName: 'Bob', * }).then(console.log); * // => { playerID: '1', playerCredentials: 'random-string' } */ async joinMatch( gameName: string, matchID: string, body: { playerID?: string; playerName: string; data?: any; [key: string]: any; }, init?: RequestInit ): Promise { assertGameName(gameName); assertMatchID(matchID); validateBody(body, { playerID: ['string', 'undefined'], playerName: 'string', }); return this.post(`/games/${gameName}/${matchID}/join`, { body, init }); } /** * Leave a previously joined match. * @param gameName The match’s game type, e.g. 'tic-tac-toe'. * @param matchID Match ID for the match to leave. * @param body Options required to leave match. * @param init Optional RequestInit interface to override defaults. * @return Promise resolves if successful. * * @example * lobbyClient.leaveMatch('tic-tac-toe', 'xyz', { * playerID: '1', * credentials: 'credentials-returned-when-joining', * }) * .then(() => console.log('Left match.')) * .catch(error => console.error('Error leaving match', error)); */ async leaveMatch( gameName: string, matchID: string, body: { playerID: string; credentials: string; [key: string]: any; }, init?: RequestInit ): Promise { assertGameName(gameName); assertMatchID(matchID); validateBody(body, { playerID: 'string', credentials: 'string' }); await this.post(`/games/${gameName}/${matchID}/leave`, { body, init }); } /** * Update a player’s name or custom metadata. * @param gameName The match’s game type, e.g. 'tic-tac-toe'. * @param matchID Match ID for the match to update. * @param body Options required to update player. * @param init Optional RequestInit interface to override defaults. * @return Promise resolves if successful. * * @example * lobbyClient.updatePlayer('tic-tac-toe', 'xyz', { * playerID: '0', * credentials: 'credentials-returned-when-joining', * newName: 'Al', * }) * .then(() => console.log('Updated player data.')) * .catch(error => console.error('Error updating data', error)); */ async updatePlayer( gameName: string, matchID: string, body: { playerID: string; credentials: string; newName?: string; data?: any; [key: string]: any; }, init?: RequestInit ): Promise { assertGameName(gameName); assertMatchID(matchID); validateBody(body, { playerID: 'string', credentials: 'string' }); await this.post(`/games/${gameName}/${matchID}/update`, { body, init }); } /** * Create a new match based on the configuration of the current match. * @param gameName The match’s game type, e.g. 'tic-tac-toe'. * @param matchID Match ID for the match to play again. * @param body Options required to configure match. * @param init Optional RequestInit interface to override defaults. * @return Object containing `nextMatchID`. * * @example * lobbyClient.playAgain('tic-tac-toe', 'xyz', { * playerID: '0', * credentials: 'credentials-returned-when-joining', * }) * .then(({ nextMatchID }) => { * return lobbyClient.joinMatch('tic-tac-toe', nextMatchID, { * playerID: '0', * playerName: 'Al', * }) * }) * .then({ playerCredentials } => { * console.log(playerCredentials); * }) * .catch(console.error); */ async playAgain( gameName: string, matchID: string, body: { playerID: string; credentials: string; unlisted?: boolean; [key: string]: any; }, init?: RequestInit ): Promise { assertGameName(gameName); assertMatchID(matchID); validateBody(body, { playerID: 'string', credentials: 'string' }); return this.post(`/games/${gameName}/${matchID}/playAgain`, { body, init }); } } ================================================ FILE: src/lobby/connection.test.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { LobbyConnection } from './connection'; import type { LobbyAPI } from '../types'; describe('lobby', () => { let lobby: ReturnType; let match1: LobbyAPI.Match, match2: LobbyAPI.Match; let jsonResult = []; let nextStatus = 200; beforeEach(async () => { match1 = { gameName: 'game1', matchID: 'matchID_1', players: [{ id: 0 }], createdAt: 1, updatedAt: 4, }; match2 = { gameName: 'game2', matchID: 'matchID_2', players: [{ id: 1 }], createdAt: 2, updatedAt: 3, }; // result of connection requests jsonResult = [ () => ['game1', 'game2'], () => { return { matches: [match1] }; }, () => { return { matches: [match2] }; }, ]; const nextResult = jsonResult.shift.bind(jsonResult); nextStatus = 200; (global as any).fetch = jest.fn(async () => ({ ok: nextStatus === 200, status: nextStatus, json: nextResult(), })); }); describe('handling all games', () => { beforeEach(async () => { lobby = LobbyConnection({ server: 'localhost', gameComponents: [ { board: () => null, game: { name: 'game1', minPlayers: 2, maxPlayers: 4 }, }, { board: () => null, game: { name: 'game2' }, }, ], playerName: 'Bob', }); await lobby.refresh(); }); describe('get list of matches', () => { test('when the server requests succeed', async () => { expect(fetch).toHaveBeenCalledTimes(3); expect(lobby.matches).toEqual([match1, match2]); }); test('when the server request fails', async () => { nextStatus = 404; await expect(lobby.refresh()).rejects.toThrow(); expect(lobby.matches).toEqual([]); }); }); describe('join a match', () => { beforeEach(async () => { // result of request 'join' jsonResult.push(() => { return { playerCredentials: 'SECRET' }; }); }); test('when the match exists', async () => { await lobby.join('game1', 'matchID_1', '0'); expect(fetch).toHaveBeenCalledTimes(4); expect(lobby.matches[0].players[0]).toEqual({ id: 0, name: 'Bob', }); expect(lobby.playerCredentials).toEqual('SECRET'); }); test('when the match does not exist', async () => { await expect(lobby.join('game1', 'matchID_3', '0')).rejects.toThrow(); expect(lobby.matches).toEqual([match1, match2]); }); test('when the seat is not available', async () => { match1.players[0].name = 'Bob'; await expect(lobby.join('game1', 'matchID_3', '0')).rejects.toThrow(); }); test('when the server request fails', async () => { nextStatus = 404; await expect(lobby.join('game1', 'matchID_1', '0')).rejects.toThrow(); }); test('when the player has already joined another match', async () => { match2.players[0].name = 'Bob'; await expect(lobby.join('game1', 'matchID_1', '0')).rejects.toThrow(); }); }); describe('leave a match', () => { beforeEach(async () => { // result of request 'join' jsonResult.push(() => { return { playerCredentials: 'SECRET' }; }); await lobby.join('game1', 'matchID_1', '0'); // result of request 'leave' jsonResult.push(() => { return {}; }); }); test('when the match exists', async () => { await lobby.leave('game1', 'matchID_1'); expect(fetch).toHaveBeenCalledTimes(5); expect(lobby.matches).toEqual([match1, match2]); }); test('when the match does not exist', async () => { await expect(lobby.leave('game1', 'matchID_3')).rejects.toThrow(); expect(fetch).toHaveBeenCalledTimes(4); expect(lobby.matches).toEqual([match1, match2]); }); test('when the player is not in the match', async () => { await lobby.leave('game1', 'matchID_1'); expect(fetch).toHaveBeenCalledTimes(5); await expect(lobby.leave('game1', 'matchID_1')).rejects.toThrow(); }); test('when the server request fails', async () => { nextStatus = 404; await expect(lobby.leave('game1', 'matchID_1')).rejects.toThrow(); }); }); describe('disconnect', () => { beforeEach(async () => {}); test('when the player leaves the lobby', async () => { await lobby.disconnect(); expect(lobby.matches).toEqual([]); }); test('when the player had joined a match', async () => { // result of request 'join' jsonResult.push(() => { return { playerCredentials: 'SECRET' }; }); await lobby.join('game1', 'matchID_1', '0'); // result of request 'leave' jsonResult.push(() => { return {}; }); await lobby.disconnect(); expect(lobby.matches).toEqual([]); }); }); describe('create a match', () => { test('when the server request succeeds', async () => { jsonResult.push(() => ({ matchID: 'abc' })); await lobby.create('game1', 2); expect(fetch).toHaveBeenCalledTimes(4); }); test('when the number of players is off boundaries', async () => { await expect(lobby.create('game1', 1)).rejects.toThrow(); }); test('when the number of players has no boundaries', async () => { jsonResult.push(() => ({ matchID: 'def' })); await expect(lobby.create('game2', 1)).resolves.toBeUndefined(); }); test('when the game is unknown', async () => { await expect(lobby.create('game3', 2)).rejects.toThrow(); }); test('when the server request fails', async () => { nextStatus = 404; await expect(lobby.create('game1', 2)).rejects.toThrow(); }); }); }); describe('handling some games', () => { beforeEach(async () => { lobby = LobbyConnection({ server: 'localhost', gameComponents: [{ board: () => null, game: { name: 'game1' } }], }); await lobby.refresh(); }); test('get list of matches for supported games', async () => { expect(fetch).toHaveBeenCalledTimes(2); expect(lobby.matches).toEqual([match1]); }); }); }); ================================================ FILE: src/lobby/connection.ts ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import type { ComponentType } from 'react'; import { LobbyClient } from './client'; import type { Game, LobbyAPI } from '../types'; export interface GameComponent { game: Game; board: ComponentType; } interface LobbyConnectionOpts { server: string; playerName?: string; playerCredentials?: string; gameComponents: GameComponent[]; } class _LobbyConnectionImpl { client: LobbyClient; gameComponents: GameComponent[]; playerName: string; playerCredentials?: string; matches: LobbyAPI.MatchList['matches']; constructor({ server, gameComponents, playerName, playerCredentials, }: LobbyConnectionOpts) { this.client = new LobbyClient({ server }); this.gameComponents = gameComponents; this.playerName = playerName || 'Visitor'; this.playerCredentials = playerCredentials; this.matches = []; } async refresh() { try { this.matches = []; const games = await this.client.listGames(); for (const game of games) { if (!this._getGameComponents(game)) continue; const { matches } = await this.client.listMatches(game); this.matches.push(...matches); } } catch (error) { throw new Error('failed to retrieve list of matches (' + error + ')'); } } _getMatchInstance(matchID: string) { for (const inst of this.matches) { if (inst['matchID'] === matchID) return inst; } } _getGameComponents(gameName: string) { for (const comp of this.gameComponents) { if (comp.game.name === gameName) return comp; } } _findPlayer(playerName: string) { for (const inst of this.matches) { if (inst.players.some((player) => player.name === playerName)) return inst; } } async join(gameName: string, matchID: string, playerID: string) { try { let inst = this._findPlayer(this.playerName); if (inst) { throw new Error('player has already joined ' + inst.matchID); } inst = this._getMatchInstance(matchID); if (!inst) { throw new Error('game instance ' + matchID + ' not found'); } const json = await this.client.joinMatch(gameName, matchID, { playerID, playerName: this.playerName, }); inst.players[Number.parseInt(playerID)].name = this.playerName; this.playerCredentials = json.playerCredentials; } catch (error) { throw new Error('failed to join match ' + matchID + ' (' + error + ')'); } } async leave(gameName: string, matchID: string) { try { const inst = this._getMatchInstance(matchID); if (!inst) throw new Error('match instance not found'); for (const player of inst.players) { if (player.name === this.playerName) { await this.client.leaveMatch(gameName, matchID, { playerID: player.id.toString(), credentials: this.playerCredentials, }); delete player.name; delete this.playerCredentials; return; } } throw new Error('player not found in match'); } catch (error) { throw new Error('failed to leave match ' + matchID + ' (' + error + ')'); } } async disconnect() { const inst = this._findPlayer(this.playerName); if (inst) { await this.leave(inst.gameName, inst.matchID); } this.matches = []; this.playerName = 'Visitor'; } async create(gameName: string, numPlayers: number) { try { const comp = this._getGameComponents(gameName); if (!comp) throw new Error('game not found'); if ( numPlayers < comp.game.minPlayers || numPlayers > comp.game.maxPlayers ) throw new Error('invalid number of players ' + numPlayers); await this.client.createMatch(gameName, { numPlayers }); } catch (error) { throw new Error( 'failed to create match for ' + gameName + ' (' + error + ')' ); } } } /** * LobbyConnection * * Lobby model. * * @param {string} server - ':' of the server. * @param {Array} gameComponents - A map of Board and Game objects for the supported games. * @param {string} playerName - The name of the player. * @param {string} playerCredentials - The credentials currently used by the player, if any. * * Returns: * A JS object that synchronizes the list of running game instances with the server and provides an API to create/join/start instances. */ export function LobbyConnection(opts: LobbyConnectionOpts) { return new _LobbyConnectionImpl(opts); } ================================================ FILE: src/lobby/create-match-form.tsx ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import type { Game } from '../types'; import type { GameComponent } from './connection'; type CreateMatchProps = { games: GameComponent[]; createMatch: (gameName: string, numPlayers: number) => Promise; }; type CreateMatchState = { selectedGame: number; numPlayers: number; }; class LobbyCreateMatchForm extends React.Component< CreateMatchProps, CreateMatchState > { state = { selectedGame: 0, numPlayers: 2, }; constructor(props: CreateMatchProps) { super(props); /* fix min and max number of players */ for (const game of props.games) { const matchDetails = game.game; if (!matchDetails.minPlayers) { matchDetails.minPlayers = 1; } if (!matchDetails.maxPlayers) { matchDetails.maxPlayers = 4; } console.assert(matchDetails.maxPlayers >= matchDetails.minPlayers); } this.state = { selectedGame: 0, numPlayers: props.games[0].game.minPlayers, }; } _createGameNameOption = (game: GameComponent, idx: number) => { return ( ); }; _createNumPlayersOption = (idx: number) => { return ( ); }; _createNumPlayersRange = (game: Game) => { return Array.from({ length: game.maxPlayers + 1 }) .map((_, i) => i) .slice(game.minPlayers); }; render() { return (
    Players:
    ); } onChangeNumPlayers = (event: React.ChangeEvent) => { this.setState({ numPlayers: Number.parseInt(event.target.value), }); }; onChangeSelectedGame = (event: React.ChangeEvent) => { const idx = Number.parseInt(event.target.value); this.setState({ selectedGame: idx, numPlayers: this.props.games[idx].game.minPlayers, }); }; onClickCreate = () => { this.props.createMatch( this.props.games[this.state.selectedGame].game.name, this.state.numPlayers ); }; } export default LobbyCreateMatchForm; ================================================ FILE: src/lobby/login-form.tsx ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; type LoginFormProps = { playerName?: string; onEnter: (playerName: string) => void; }; type LoginFormState = { playerName?: string; nameErrorMsg: string; }; class LobbyLoginForm extends React.Component { static defaultProps = { playerName: '', }; state = { playerName: this.props.playerName, nameErrorMsg: '', }; render() { return (

    Choose a player name:


    {this.state.nameErrorMsg}
    ); } onClickEnter = () => { if (this.state.playerName === '') return; this.props.onEnter(this.state.playerName); }; onKeyPress = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { this.onClickEnter(); } }; onChangePlayerName = (event: React.ChangeEvent) => { const name = event.target.value.trim(); this.setState({ playerName: name, nameErrorMsg: name.length > 0 ? '' : 'empty player name', }); }; } export default LobbyLoginForm; ================================================ FILE: src/lobby/match-instance.tsx ================================================ /* * Copyright 2018 The boardgame.io Authors. * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import type { LobbyAPI } from '../types'; export type MatchOpts = { numPlayers: number; matchID: string; playerID?: string; }; type Match = { gameName: string; matchID: string; players: LobbyAPI.Match['players']; }; type MatchInstanceProps = { match: Match; playerName: string; onClickJoin: (gameName: string, matchID: string, playerID: string) => void; onClickLeave: (gameName: string, matchID: string) => void; onClickPlay: (gameName: string, matchOpts: MatchOpts) => void; }; class LobbyMatchInstance extends React.Component { _createSeat = (player: { name?: string }) => { return player.name || '[free]'; }; _createButtonJoin = (inst: Match, seatId: number) => ( ); _createButtonLeave = (inst: Match) => ( ); _createButtonPlay = (inst: Match, seatId: number) => ( ); _createButtonSpectate = (inst: Match) => ( ); _createInstanceButtons = (inst: Match) => { const playerSeat = inst.players.find( (player) => player.name === this.props.playerName ); const freeSeat = inst.players.find((player) => !player.name); if (playerSeat && freeSeat) { // already seated: waiting for match to start return this._createButtonLeave(inst); } if (freeSeat) { // at least 1 seat is available return this._createButtonJoin(inst, freeSeat.id); } // match is full if (playerSeat) { return (
    {[ this._createButtonPlay(inst, playerSeat.id), this._createButtonLeave(inst), ]}
    ); } // allow spectating return this._createButtonSpectate(inst); }; render() { const match = this.props.match; let status = 'OPEN'; if (!match.players.some((player) => !player.name)) { status = 'RUNNING'; } return ( {match.gameName} {status} {match.players.map((player) => this._createSeat(player)).join(', ')} {this._createInstanceButtons(match)} ); } } export default LobbyMatchInstance; ================================================ FILE: src/lobby/react.ssr.test.tsx ================================================ /** * @jest-environment node */ import React from 'react'; import Lobby from './react'; import ReactDOMServer from 'react-dom/server'; /* mock server requests */ global.fetch = jest .fn() .mockReturnValue({ ok: true, status: 200, json: () => [] }); describe('lobby', () => { test('is rendered', () => { const components: any[] = [{ board: 'Board', game: { name: 'GameName' } }]; const ssrRender = ReactDOMServer.renderToString( ); expect(ssrRender).toContain('lobby-view'); }); }); ================================================ FILE: src/lobby/react.test.tsx ================================================ /* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import React from 'react'; import Cookies from 'react-cookies'; import Lobby from './react'; import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; /* mock server requests */ global.fetch = jest .fn() .mockReturnValue({ ok: true, status: 200, json: () => [] }); /* mock 'Client' component */ function NullComponent() { return '