diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de82437 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Images for apps/backend and apps/frontend are built with the repo root as context. +**/node_modules +**/dist +**/target +**/.parcel-cache +.git +apps/mobile +apps/keycloak diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1792ff0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. Keep it current: when you learn something that would have +saved time, add it here. + +## Project + +Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app and a Java (Quarkus) backend. + +| Path | What | +|---|---| +| `packages/mancala.js` | Game engine used by the apps; rules are plug-ins in `src/common/game_rules`. The backend has a Java port | +| `packages/core` | Code shared by the apps: models, WebSocket client (`RTMTWS`), channel names, history helpers, themes, `PitAnimator`, translations (en, tr) | +| `apps/backend` | Java 25 + Quarkus: matchmaking, games, auth, saved games, HTTP API, the Java port of the engine | +| `apps/frontend` | React web app, built with Parcel | +| `apps/mobile` | React Native app | +| `apps/keycloak` | Keycloak image with the realm, the login theme and a Java extension for guest login | +| `apps/postgres` | Init script creating the `keycloak` and `mancala` databases | +| `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend | +| `config/nginx.conf` | The production server's nginx config (Certbot managed) | +| `docs/specs/` | Specs: `requirements.md`, `design.md`, `tasks.md` | +| `TODO.md` | Open work, known bugs and deployment steps; read it before starting | + +Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay), +`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board, +pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus), `game-bot` (playing against a bot +at four levels), `tutorial` (rules page, lessons, hints in bot games) and `rematch` (rematches, alternating first +moves, the series strip). + +## Running locally + +```sh +yarn build:packages # repo root; builds mancala.js and core +cd apps && docker compose up -d --build postgres valkey keycloak # needs apps/.env, see README.md +cd apps/backend && ./mvnw quarkus:dev # http://localhost:5005, needs a JDK 25 +cd apps/frontend && yarn dev # http://localhost:1234 (must be 1234, Keycloak only allows that) +``` + +Postgres is on `localhost:5433` (5432 is often taken), Valkey on `6379`, Keycloak on `8080` +(admin console `/admin`). The backend's and frontend's defaults point at these. The mobile app points at production +unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`. + +## Checks + +| What | Command | Notes | +|---|---|---| +| Engine | `cd packages/mancala.js && npx jest` | After changing the rules, change the Java port too and record the parity games again (`apps/backend/README.md`) | +| Core | `cd packages/core && npx jest` | | +| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services. The bot's calibration tournament is tagged `calibration` and skipped; see `apps/backend/README.md` | +| Keycloak extension | `cd apps/keycloak/extension && mvn test` | Needs Docker; starts Keycloak with the real realm file | +| Web app | `cd apps/frontend && yarn build` | There is no `tsconfig.json`, so nothing type-checks it; use a temporary one when needed (with `"types": ["styled-jsx", "react", "react-dom"]`). 1 type error (`react-dom/client`) is known | +| Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work | + +There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with +`playwright-core` (installed outside the repo, e.g. `npm install playwright-core@1.48.2` in a scratch folder; +`executablePath: /usr/bin/google-chrome`) against the local stack. A live game is `GET /game/:id` on the backend, +which helps to check what the server holds. + +## How we work + +- The user writes in Turkish; code, comments, commit messages and specs are in English. +- Larger features start with a spec in `docs/specs//` (requirements with acceptance criteria, design with + decisions and reasons, tasks as a checklist). Implement the tasks in order, one commit each, and tick them. Small + fixes need no spec. When resuming a feature, read its spec and `tasks.md` first. +- Work on feature branches; commit messages follow `type(scope): summary` with a short body. +- Never add AI attribution (Co-Authored-By lines, "Generated with …") to commits or PRs. +- Do not commit files the user changed but did not ask to commit; stage only your own changes (hunks if needed). +- New dependencies get exact versions. + +## Things that are easy to get wrong + +**Packages and builds** +- The web and mobile apps use `packages/core` and `packages/mancala.js` through `link:` dependencies, i.e. their built + `dist`. After changing a package, run `yarn build` in it (or `yarn dev` to rebuild on change), or the apps use stale + code. + Running dev servers do not notice a rebuilt package: restart Parcel after deleting `apps/frontend/.parcel-cache`. + `yarn build` in `apps/frontend` writes to the `dist` that `yarn dev` serves; running it while the dev server runs + can leave stale CSS or JS being served, so restart the dev server afterwards. + The backend does not use the packages. +- Backend and frontend Docker images are built from the repo root: `docker build -f apps/backend/Dockerfile .`. +- The mobile app uses `RTMTWS` from core (its own `src/rtmt` copy is unused), so changing the core WebSocket client + changes mobile too. Metro needs `watchFolders` and `nodeModulesPaths` (already in `metro.config.js`). + +**Backend** +- The game rules exist twice: `packages/mancala.js` for the clients and `app.playmancala.engine` in the backend. + Change both; `EngineParityTest` replays games recorded with the TypeScript engine and fails if they differ. +- All live state (games, matchmaking, sockets, previews) belongs to the game loop thread (`GameLoop`). Touch it only + in tasks running there; never block the loop on Postgres, Valkey or HTTP. Other threads use `GameLoop.call`. +- The JSON must stay what the Node backend sent and stored: TypeScript leaves out `undefined` but keeps `null`, so only + optional fields get `@JsonInclude(NON_NULL)` (e.g. `result: null` stays). Valkey snapshots and keys, the tables and + `schema_migrations` are shared with the Node backend's format; tests check data the Node backend wrote. +- User ids: Keycloak users are `kc:`; legacy mobile users are their `userKey`, which is a secret. Anything + saved or listed publicly goes through `PublicIds.publicUserId`; clients get `toClientGame()`, never spectator ids. +- Live games: memory is the source of truth, Valkey is a write-through copy restored on startup; the backend + assumes a single instance. Finished games are written to Postgres once, with retry when it is down. +- Postgres or Valkey being down must not stop games; reads fall back, writes are retried. +- The game clock is stored as each player's time at the start of the turn plus `turnStartedAt`; compute the time + left with `GameClocks.getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for + manual tests. +- `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed). +- The bot plays as `bot:` (`BotLevel.isBotId`) in many games at once: `GameStore` does not map it to a game, + nothing is sent to it, it is always online and `PublicIds` keeps its id. The bot's search must never run on the + game loop; `BotPlayer` runs it on its own pool and plays the move back on the loop. +- Rematch offers (`RematchOffers`) hook into `GameManager` like `BotPlayer` (`onGameEnded`, `onPlayerSearching`) and + live in memory only. A game's series travels with the live game (`BackendGame.series`, snapshots); the seat that + moved first is derived from the history (`firstSeat()`), not stored. +- `-Dtest=GameManagerTest` skips its `@Nested` classes; use `-Dtest='GameManagerTest*'`. +- `-Dgroups=...` runs only the tests with that tag; run the untagged tests separately too. +- Unit tests build the logic with fakes (`FakeRtmt`, in-memory stores, `ManualScheduler` instead of timers and + direct executors); only storage, HTTP and WebSocket tests are `@QuarkusTest`s. `FakeTokenVerifier` replaces + Keycloak in all `@QuarkusTest`s. +- Quarkus dev mode's debugger is moved to port 5006 (`pom.xml`), because 5005 is the backend's port. +- The Maven wrapper checks the `.zip` it downloads; without `unzip` it fetches a `.tar.gz` and fails the check (the + Dockerfile installs `unzip`). + +**Web app** +- styled-jsx only scopes JSX written directly in a component's render; elements built in helper functions or + `map` callbacks need `.parent :global(.child)` rules. +- Pages are full-height flex columns: a block with `overflow: hidden` needs `flex-shrink: 0` or it gets squeezed. +- Colours come from CSS variables of the theme class on `` (`styles/app.css`); the web app's themes + (`theme/themes.ts`) are passed to core's `ThemeManager`, which the mobile app uses with its own default themes. +- Styles that need container queries (`@container`, `cqw`), such as the board's, are plain `.css` files imported by the + component; styled-jsx 5.0 does not handle them. `BoardView` fills its container and stands upright below 600 px. +- `PitAnimator` sends the same `BoardViewModel` object, changed, at every animation step. Put a new object (or a new + id in its own state, as `GamePage` does) into React state, or React skips the update and only the first step shows. +- The rules, lessons and move explanations live in `packages/core/src/tutorial`; their boards after a move come from + the engine (`getMoveOutcome`). After a rule change, run core's tests: they fail if a lesson no longer works, and + the rule texts in `en.ts`/`tr.ts` may need updating. +- The game route renders `GamePage` with `key={gameId}`, so following a rematch to the new game's id remounts it. +- Short class names collide across the global CSS files (e.g. `.st` is a stone on the board); check before adding one. +- When a script drives the app in Chrome, wait for the header (`.hdr`) and a few seconds after the first load before + navigating; navigating during the guest login leaves an invalid token. Open the lobby with the "New Game" button, + not by URL, or the matchmaking message goes out before the WebSocket is open. +- The WebSocket client emits `open` after authentication; anything the server must know about a connection + (spectating a game, live game previews) is sent on `open`, because the server forgets it when a user goes offline. + +**Keycloak** +- The realm file is imported only when the realm does not exist; changes to it must also be made in a running + Keycloak (or recreate the database). +- Listing some `requiredActions` in the realm file replaces Keycloak's whole default list, so the file contains all + of them. +- The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in + sync and run `mvn test` when upgrading. +- Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not. +- The login theme (`apps/keycloak/themes/mancala`) is only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches + themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for + reloading it in a running container. + +**Mobile** +- `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba64777 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +The instructions for this repository are in AGENTS.md, shared by all coding agents: + +@AGENTS.md diff --git a/README.md b/README.md index 0cb83ec..4716a9a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,43 @@ ### Mancala Mancala is an open source application for playing mancala game online. + +## Development + +The web and mobile apps use the local `packages/mancala.js` and `packages/core` through `link:` dependencies, so build +those first: + +```sh +yarn build:packages # from the repo root +``` + +After changing a package, rebuild it (or run `yarn dev` in it to rebuild on change); the apps pick up the new `dist`. + +Postgres (games and Keycloak), Valkey (live games and caches) and Keycloak run with Docker Compose. Create +`apps/.env` once, then start them: + +```sh +cd apps +cat > .env <" } } ``` -### Development Dependencies +The server answers on `on_auth` with the user (`{ id, name, isGuest }`) or on `on_auth_error` and closes the socket +with code 4001. Other messages are ignored until the socket is authenticated, and connections that do not +authenticate within 5 seconds are closed. Send `auth` again with the new token after refreshing it. +User ids of Keycloak users are the token subject prefixed with `kc:`. -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. \ No newline at end of file +| Variable | Default | Purpose | +|---|---|---| +| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/mancala` | Token issuer; must match the `iss` claim exactly | +| `KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | Where to fetch signing keys, if the backend reaches Keycloak at a different address | +| `KEYCLOAK_CLIENT_IDS` | `mancala-web` | Comma-separated clients whose tokens are accepted | +| `LEGACY_USERKEY_AUTH` | `true` | Also accept old clients that connect with `?userKey=` and the `/register` endpoint. Turn off once no released client uses it, because a userKey is not a secret the server can check | +| `PORT` | `5005` (`6000` in the image) | HTTP and WebSocket port | + +## Saved games and live games + +Finished games are saved in Postgres; live games are copied to Valkey after every change and restored when the +backend starts, so a restart or deploy does not end them. The design is in `docs/specs/game-history`. + +| Variable | Default | Purpose | +|---|---|---| +| `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5433/mancala` | Saved games; migrations run on startup | +| `DATABASE_USERNAME`, `DATABASE_PASSWORD` | `mancala`, `mancala` | | +| `VALKEY_URL` | `redis://localhost:6379` | Live game state and short-lived caches | + +The backend keeps running when either is down: games are still played, finished games are saved once Postgres is +back, and lists are served without the missing data. The tables, the `schema_migrations` table and the Valkey keys +are the Node backend's, so the two can replace each other in either direction. + +## Game clock + +Each player has a clock for the whole game; only the clock of the player whose turn it is runs, also while they are +offline. A move that arrives after the mover's time has run out is refused, and `GameClockScheduler` ends games whose +running clock reaches zero once a second, so the opponent wins on time even if nobody is connected. Time while the +backend is down is not charged. The design is in `docs/specs/game-clock`. + +| Variable | Default | Purpose | +|---|---|---| +| `GAME_CLOCK_MS` | `300000` | Time each player has for a game; a short value such as `40000` helps when testing by hand | + +## Bot + +Players can play against a bot at four levels (`new_bot_game` with `{ level }`); the lobby offers it after 10 seconds +without an opponent. Each level is a range of strengths, and the bot draws one per game, so games at the same level +differ. It plays as `bot:` through the same game logic as players, with the clock, saving and spectators, and +waits for the previous move's animation and a thinking time before moving. Its moves are worked out with alpha-beta +search on `Position` on a thread pool of their own, never on the game loop. The design is in `docs/specs/game-bot`. + +| Variable | Default | Purpose | +|---|---|---| +| `BOT_SEARCH_THREADS` | half the processors | Threads working out the bot's moves | + +After changing the search or the strength table, check that every level still beats the one below: + +```sh +./mvnw test -Dtest=BotTournamentTest -Dgroups=calibration -DexcludedGroups=none +``` + +| Route | Auth | Response | +|---|---|---| +| `GET /game/:id` | – | A live game, or `{ status: "finished", record }` | +| `GET /games/popular?limit=5` | – | `{ kind: "live" \| "finished", games }` | +| `GET /games/me/active` | Bearer token | `{ game }` or `{ game: null }` | +| `GET /games/me?limit=3` | Bearer token | `{ games }`, newest first | + +Requests are logged to `access.log`, and a move that fails writes the game to `crashes/`, both in the working +directory. diff --git a/apps/backend/build_and_deploy.sh b/apps/backend/build_and_deploy.sh deleted file mode 100644 index 8db7f0b..0000000 --- a/apps/backend/build_and_deploy.sh +++ /dev/null @@ -1,4 +0,0 @@ -rm -rf node_modules -yarn -yarn build -yarn start \ No newline at end of file diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js deleted file mode 100644 index 85c664c..0000000 --- a/apps/backend/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - transform: {'^.+\\.ts?$': 'ts-jest'}, - testEnvironment: 'node', - testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] - }; \ No newline at end of file diff --git a/apps/backend/mvnw b/apps/backend/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/apps/backend/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/apps/backend/mvnw.cmd b/apps/backend/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/apps/backend/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/apps/backend/package.json b/apps/backend/package.json deleted file mode 100644 index ac5e34b..0000000 --- a/apps/backend/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@mancala/backend", - "version": "0.4.7", - "description": "", - "main": "index.js", - "scripts": { - "start": "node dist/index.js", - "dev": "nodemon src/index.ts", - "build": "tsc", - "test": "jest", - "coverage": "jest --coverage" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@mancala/core": "0.0.2", - "@types/cors": "^2.8.10", - "@types/morgan": "^1.9.3", - "@types/uuid": "^8.3.0", - "@types/ws": "^7.4.5", - "cors": "^2.8.5", - "express": "^4.17.1", - "mancala.js": "^0.0.2-beta.3", - "morgan": "^1.10.0", - "uuid": "^8.3.2", - "ws": "^7.5.0" - }, - "devDependencies": { - "@types/express": "^4.17.12", - "@types/jest": "^26.0.23", - "@types/node": "^15.12.4", - "jest": "^27.0.5", - "nodemon": "^2.0.7", - "ts-jest": "^27.0.3", - "ts-node": "^10.0.0", - "typescript": "^4.7.4" - } -} diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml new file mode 100644 index 0000000..7293f4c --- /dev/null +++ b/apps/backend/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + app.playmancala + mancala-backend + 0.5.0 + quarkus + + + 3.15.0 + 25 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.40.1 + 3.5.6 + + 5006 + + calibration + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-jackson + + + io.quarkus + quarkus-websockets-next + + + io.quarkus + quarkus-agroal + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-redis-client + + + org.bitbucket.b_c + jose4j + + + io.quarkus + quarkus-junit + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + @{argLine} + + org.jboss.logmanager.LogManager + ${maven.home} + ${project.build.directory}/crashes + + + + + + diff --git a/apps/backend/scripts/record-engine-games.js b/apps/backend/scripts/record-engine-games.js new file mode 100644 index 0000000..e9ec499 --- /dev/null +++ b/apps/backend/scripts/record-engine-games.js @@ -0,0 +1,61 @@ +// Plays seeded random games with the TypeScript engine (packages/mancala.js) and writes every move and its outcome, +// so EngineParityTest can check that the Java engine plays exactly the same. Run it again after changing the rules: +// +// yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js +// +// Moves include illegal ones (the wrong player, an empty pit, a store, out of range) and moves after the end. +const fs = require("fs"); +const path = require("path"); +const { CommonMancalaGame } = require("../../../packages/mancala.js"); + +const GAME_COUNT = 15; +const MAX_MOVES = 400; +const OUTPUT = path.join(__dirname, "../src/test/resources/engine/recorded-games.json"); + +// mulberry32 +function random(seed) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function recordGame(number) { + const next = random(number + 1); + const pick = (items) => items[Math.floor(next() * items.length)]; + const game = new CommonMancalaGame(`game-${number}`, `kc:player1-${number}`, `kc:player2-${number}`); + game.turnPlayerId = next() < 0.5 ? game.player1Id : game.player2Id; + const recorded = { + id: game.id, player1Id: game.player1Id, player2Id: game.player2Id, turnPlayerId: game.turnPlayerId, moves: [], + }; + let movesAfterEnd = 0; + for (let i = 0; i < MAX_MOVES && movesAfterEnd < 2; i++) { + const roll = next(); + const opponent = game.turnPlayerId === game.player1Id ? game.player2Id : game.player1Id; + const playerId = roll < 0.93 ? game.turnPlayerId : opponent; + const pitsWithStones = [0, 1, 2, 3, 4, 5].filter((pit) => + game.board.pits[game.getBoardIndexByPlayerId(playerId, pit)].stoneCount > 0); + const pitIndex = roll < 0.85 && pitsWithStones.length > 0 ? pick(pitsWithStones) + : roll < 0.97 ? Math.floor(next() * 6) : pick([-1, 6, 7, 13, 14]); + const move = { playerId, pitIndex }; + try { + game.moveByPlayerPit(playerId, pitIndex); + } catch (err) { + move.error = err.message; + } + Object.assign(move, { board: game.board.getStoneArray(), turnPlayerId: game.turnPlayerId, state: game.state }); + recorded.moves.push(move); + if (game.state === "ended") movesAfterEnd++; + } + recorded.wonPlayerId = game.getWonPlayerId() ?? null; + recorded.game = JSON.parse(JSON.stringify(game)); + return recorded; +} + +const games = Array.from({ length: GAME_COUNT }, (_, number) => recordGame(number)); +fs.writeFileSync(OUTPUT, JSON.stringify({ games }) + "\n"); +const moves = games.reduce((sum, game) => sum + game.moves.length, 0); +console.log(`Recorded ${games.length} games, ${moves} moves, to ${path.relative(process.cwd(), OUTPUT)}`); diff --git a/apps/backend/src/consts/config.ts b/apps/backend/src/consts/config.ts deleted file mode 100644 index 7bb3c13..0000000 --- a/apps/backend/src/consts/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const RTMT_WS_PING_INTERVAL = 2000; \ No newline at end of file diff --git a/apps/backend/src/game/GameCrashManager.ts b/apps/backend/src/game/GameCrashManager.ts deleted file mode 100644 index 1f76fe8..0000000 --- a/apps/backend/src/game/GameCrashManager.ts +++ /dev/null @@ -1,38 +0,0 @@ -import fs from "fs"; -import { Game } from "@mancala/core"; -import { BackendGame } from "../models/Game"; -const crashFolder = "./crashes"; -export class GameCrashManager { - constructor() {} - - static createCrashFolderIfNotExist(): void { - if (!fs.existsSync(crashFolder)) { - fs.mkdirSync(crashFolder); - } - } - - static getTime(): string { - let date_ob = new Date(); - let date = ("0" + date_ob.getDate()).slice(-2); - let month = ("0" + (date_ob.getMonth() + 1)).slice(-2); - let year = date_ob.getFullYear(); - let hours = date_ob.getHours(); - let minutes = date_ob.getMinutes(); - let seconds = date_ob.getSeconds(); - let miliSeconds = date_ob.getMilliseconds(); - return `${year}-${month}-${date}-${hours}:${minutes}:${seconds}:${miliSeconds}`; - } - - public static logGameCrash(error: Error, game: BackendGame): string { - this.createCrashFolderIfNotExist(); - const crash = { - message: error.message, - stack: error.stack, - game, - }; - const crashStr = JSON.stringify(crash); - const crashFileName = `${game.id}::${this.getTime()}`; - fs.writeFile(`${crashFolder}/${crashFileName}`, crashStr, () => {}); - return crashFileName; - } -} diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts deleted file mode 100644 index 2350240..0000000 --- a/apps/backend/src/game/GameManager.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { CommonMancalaGame, MancalaGame } from "mancala.js"; -import { RTMT } from "../rtmt/rtmt"; -import { GameStore } from "./gamestore/GameStore"; -import { generateKey } from "../util/key_factory"; -import { - channel_cancel_new_game, - channel_game_move, - channel_leave_game, - channel_listen_game_events, - channel_new_game, - channel_on_game_crashed, - channel_on_game_start, - channel_on_game_update, - channel_on_game_user_leave, - channel_on_user_connection_change, - channel_unlisten_game_events -} from "@mancala/core"; -import { GameMove } from "@mancala/core"; -import { GameCrashManager } from "./GameCrashManager"; -import { MatchMaker } from "../matchmaker/MatchMaker"; -import { GameUsersConnectionInfo } from "@mancala/core"; -import { BackendGame } from "../models/Game"; - -export class GameManager { - gameStore: GameStore; - rtmt: RTMT; - matchMaker: MatchMaker; - - constructor(params: { gameStore: GameStore, rtmt: RTMT, matchMaker: MatchMaker }) { - this.gameStore = params.gameStore; - this.rtmt = params.rtmt; - this.matchMaker = params.matchMaker; - this.initialize(); - } - - private initialize() { - this.listenOnPlayersPaired(); - this.listenRtmtMessages(); - this.listenUserConnectionChange(); - } - - private listenRtmtMessages() { - this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => { - this.matchMaker.join(userKey); - }); - - this.rtmt.listenMessage(channel_cancel_new_game, (userKey: string, message: Object) => { - this.matchMaker.cancel(userKey); - }); - - this.rtmt.listenMessage(channel_game_move, (userKey: string, message: Object) => { - this.onGameMove(userKey, message as GameMove); - }); - - this.rtmt.listenMessage(channel_leave_game, (userKey: string, message: Object) => { - this.onPlayerLeave(userKey) - }); - - this.rtmt.listenMessage(channel_listen_game_events, (userKey: string, message: Object) => { - this.addSpectator(userKey, message as string); - }); - - this.rtmt.listenMessage(channel_unlisten_game_events, (userKey: string, message: Object) => { - this.removeSpectator(userKey, message as string); - }); - } - - private onGameMove(userKey: string, gameMove: GameMove) { - const game = this.gameStore.getGameByUser(userKey); - if (game) { - try { - game.mancalaGame.moveByPlayerPit(userKey, gameMove.index); - game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); - this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, game); - if (game.mancalaGame.state == "ended") { - this.onGameEnd(game); - } - } catch (err: any) { - this.onGameError(game, err) - } - } else { - console.log("Game not found!"); - } - } - - private onGameError(game: BackendGame, error: any) { - console.error(error); - const crashFileName = GameCrashManager.logGameCrash(error, game); - console.info(`Game crash saved to file : ${crashFileName}`); - this.sendMessageToPlayersAndSpectators(game, channel_on_game_crashed, error); - } - - private onPlayerLeave(userKey: string) { - const game = this.gameStore.getGameByUser(userKey); - if (game) { - this.deleteGame(game); - this.sendMessageToPlayersAndSpectators(game, channel_on_game_user_leave, userKey) - } - } - - private addSpectator(userId: string, gameId: string) { - const game = this.gameStore.get(gameId); - if (game) { - const isUserPlayer = this.checkUserIdisPlayer(game, userId); - const isAlreadySpectator = game.spectatorIds.find((value) => value === userId); - if (!isAlreadySpectator && !isUserPlayer) { - game.spectatorIds.push(userId); - } - } - } - - private removeSpectator(userId: string, gameId: string) { - const game = this.gameStore.get(gameId); - if (game) { - const userIdIndex = game.spectatorIds.findIndex((value) => value === userId); - if (userIdIndex >= 0) { - game.spectatorIds.splice(userIdIndex, 1); - } - } - } - - private checkUserIdisPlayer(game: BackendGame, userId: string): boolean { - return game.mancalaGame.player1Id === userId || game.mancalaGame.player2Id === userId; - } - - private sendMessageToPlayersAndSpectators(game: BackendGame, channel: string, message: Object) { - const sendMessage = (userId: string) => this.rtmt.sendMessage(userId, channel, message); - sendMessage(game.mancalaGame.player1Id); - sendMessage(game.mancalaGame.player2Id); - game.spectatorIds.forEach((spectatorId) => sendMessage(spectatorId)); - } - - private onGameEnd(game: BackendGame) { - this.deleteGame(game); - game.spectatorIds = []; - } - - private listenOnPlayersPaired() { - this.matchMaker.listenOnPlayersPaired((player1Id: string, player2Id: string) => { - const game = this.createMancalaGame(player1Id, player2Id); - this.startGame(game); - }); - } - - public fireOnPlayerConnected(playerId: string) { } - - public createMancalaGame(userKey1: string, userKey2: string): BackendGame { - const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2); - const random = Math.random(); - mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2; - - const game: BackendGame = { - id: mancalaGame.id, - mancalaGame, - gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromUsers(mancalaGame.player1Id, mancalaGame.player2Id), - spectatorIds: [] - }; - - this.gameStore.set(game.id, game); - this.gameStore.setGameByUser(userKey1, game); - this.gameStore.setGameByUser(userKey2, game); - return game; - } - - public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { - return this.getGameUsersConnectionInfoFromUsers(game.mancalaGame.player1Id, game.mancalaGame.player2Id); - } - - public getGameUsersConnectionInfoFromUsers(user1Id: string, user2Id: string): GameUsersConnectionInfo { - const isPlayer1Online = this.rtmt.isClientOnline(user1Id); - const isPlayer2Online = this.rtmt.isClientOnline(user2Id); - return { - user1ConnectionInfo: { userId: user1Id, isOnline: isPlayer1Online }, - user2ConnectionInfo: { userId: user2Id, isOnline: isPlayer2Online } - }; - } - - public startGame(game: BackendGame) { - this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, game); - this.sendUserConnectionInfo(game); - this.sendUserConnectionInfo(game); - } - - public sendUserConnectionInfo(game: BackendGame) { - const gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); - this.sendMessageToPlayersAndSpectators(game, channel_on_user_connection_change, gameUsersConnectionInfo); - } - - public deleteGame(game: BackendGame) { - if (game) { - this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id); - this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id); - } - } - - private listenUserConnectionChange() { - this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => { - const game = this.gameStore.getGameByUser(clientId); - if (game) this.sendUserConnectionInfo(game); - if (!isOnline) { - const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId); - speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId)); - } else { - game && this.rtmt.sendMessage(clientId, channel_on_game_update, game); - } - }); - } -} \ No newline at end of file diff --git a/apps/backend/src/game/gamestore/GameStore.ts b/apps/backend/src/game/gamestore/GameStore.ts deleted file mode 100644 index 3265e3c..0000000 --- a/apps/backend/src/game/gamestore/GameStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Game } from "@mancala/core"; -import { BackendGame } from "../../models/Game"; - -export interface GameStore { - get(id: string): BackendGame | undefined; - set(id: string, game: BackendGame): void; - remove(id: string): void; - - setGameIdByUser(userId: string, gameId: string): void; - getGameIdByUser(userId: string): string | undefined; - removeGameIdByPlayer(userId: string): void; - - setGameByUser(userId: string, game: BackendGame): void; - getGameByUser(userId: string): BackendGame | undefined; - removeGameByPlayer(userId: string): void; - - getSpeactatingGameIdsByUser(userId: string): string[] -} \ No newline at end of file diff --git a/apps/backend/src/game/gamestore/GameStoreImpl.ts b/apps/backend/src/game/gamestore/GameStoreImpl.ts deleted file mode 100644 index d1153b9..0000000 --- a/apps/backend/src/game/gamestore/GameStoreImpl.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { GameStore } from "./GameStore"; -import { BackendGame } from "../../models/Game"; - -export class GameStoreImpl implements GameStore { - gameStore: Map = new Map() - userGameMap: Map = new Map() - - get(id: string): BackendGame | undefined { - return this.gameStore.get(id); - } - set(id: string, game: BackendGame): void { - this.gameStore.set(id, game); - } - remove(id: string): void { - this.gameStore.delete(id); - } - - setGameIdByUser(userId: string, gameId: string): void { - this.userGameMap.set(userId, gameId) - } - getGameIdByUser(userId: string): string | undefined { - return this.userGameMap.get(userId); - } - removeGameIdByPlayer(userId: string): void { - this.userGameMap.delete(userId); - } - - setGameByUser(userId: string, game: BackendGame): void { - this.setGameIdByUser(userId, game.id); - } - getGameByUser(userId: string): BackendGame | undefined { - const gameId = this.getGameIdByUser(userId); - return gameId && this.gameStore.get(gameId) || undefined; - } - removeGameByPlayer(userId: string): void { - this.removeGameIdByPlayer(userId); - } - - getSpeactatingGameIdsByUser(userId: string): string[] { - const speactatingGameIds = []; - for (const gameId in this.gameStore.keys()) { - const game = this.gameStore.get(gameId) as BackendGame; - const isSpectator = game.spectatorIds.find((value) => value === userId); - isSpectator && speactatingGameIds.push(game.id); - } - return speactatingGameIds; - } -} \ No newline at end of file diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts deleted file mode 100644 index e5d51e7..0000000 --- a/apps/backend/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ - -import { RTMTWS } from "./rtmt/rtmt_websocket"; -import { GameManager } from "./game/GameManager"; -import { GameStoreImpl } from "./game/gamestore/GameStoreImpl"; -import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl"; -import { ExpressApp } from "./server/ExpressApp"; -import { WebServer } from "./server/WebServer"; - -const rtmt = new RTMTWS(); -const gameStore = new GameStoreImpl(); -const matchMaker = new MatchMakerImpl({ rtmt }); -const gameManager = new GameManager({ rtmt, gameStore, matchMaker }) - -const expressApp = new ExpressApp({ gameStore }); -const server = new WebServer({ expressApp: expressApp.app }); - -rtmt.initWebSocket(server.server, (userKey: string) => gameManager.fireOnPlayerConnected(userKey)); diff --git a/apps/backend/src/main/java/app/playmancala/Backend.java b/apps/backend/src/main/java/app/playmancala/Backend.java new file mode 100644 index 0000000..ab6b2c0 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/Backend.java @@ -0,0 +1,202 @@ +package app.playmancala; + +import java.sql.SQLException; +import java.util.SplittableRandom; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import javax.sql.DataSource; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import app.playmancala.auth.TokenVerifier; +import app.playmancala.bot.BotPlayer; +import app.playmancala.game.GameClockScheduler; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameRecorder; +import app.playmancala.game.GameStore; +import app.playmancala.game.LiveGames; +import app.playmancala.game.MatchMaker; +import app.playmancala.game.RematchOffers; +import app.playmancala.history.GameHistoryService; +import app.playmancala.history.Migrations; +import app.playmancala.history.PostgresGameHistoryRepository; +import app.playmancala.live.ValkeyCache; +import app.playmancala.live.ValkeyLiveGameStore; +import app.playmancala.loop.GameLoop; +import app.playmancala.rtmt.RtmtServer; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.runtime.Startup; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +// Builds the backend from the infrastructure Quarkus provides and starts it (docs/specs/quarkus-backend, Startup). +@Startup +@ApplicationScoped +public class Backend { + private static final Logger LOG = Logger.getLogger(Backend.class); + private static final long MIGRATION_RETRY_MS = 30_000; + private static final long VALKEY_STARTUP_WAIT_MS = 5_000; + + @Inject + DataSource dataSource; + + @Inject + RedisDataSource redis; + + @Inject + TokenVerifier tokenVerifier; + + @ConfigProperty(name = "mancala.auth.legacy-userkey-auth") + String legacyUserKeyAuth; + + @ConfigProperty(name = "mancala.game-clock-ms") + long gameClockMs; + + // 0: half the processors. + @ConfigProperty(name = "mancala.bot-search-threads") + int botSearchThreads; + + private GameLoop loop; + // Valkey writes, in order. + private ExecutorService storeWriter; + // Migrations and saving finished games. + private ScheduledExecutorService recorderIo; + // Token checks. + private ExecutorService verifier; + // Working out the bot's moves. + private ExecutorService botSearch; + + private RtmtServer rtmt; + private GameStore gameStore; + private GameManager gameManager; + private LiveGames liveGames; + private GameHistoryService history; + private GameClockScheduler clockScheduler; + + @PostConstruct + void start() { + loop = new GameLoop(); + storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory()); + recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory()); + verifier = Executors.newVirtualThreadPerTaskExecutor(); + int searchThreads = botSearchThreads > 0 ? botSearchThreads + : Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + botSearch = Executors.newFixedThreadPool(searchThreads, + Thread.ofPlatform().name("bot-search-", 0).daemon().factory()); + + ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis); + PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource); + GameRecorder recorder = new GameRecorder(repository, liveGameStore, recorderIo); + history = new GameHistoryService(repository, new ValkeyCache(redis), recorder::getUnsaved); + recorder.onSaved(history::onGameSaved); + + // Exactly "true", like the Node backend. + rtmt = new RtmtServer(tokenVerifier, "true".equals(legacyUserKeyAuth), loop, verifier, + RtmtServer.DEFAULT_AUTH_TIMEOUT_MS); + gameStore = new GameStore(); + gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter, + loop::now, gameClockMs); + liveGames = new LiveGames(rtmt, gameStore, gameManager, loop); + new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom()); + new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom()); + clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop, + GameClockScheduler.CLOCK_CHECK_INTERVAL_MS); + + // The game runs without the database; saved games wait in the recorder until migrations have run. + recorderIo.execute(this::migrate); + recorderIo.scheduleWithFixedDelay(() -> guarded(recorder::retryPending), 0, GameRecorder.RETRY_INTERVAL_MS, + TimeUnit.MILLISECONDS); + // The first task on the loop, so socket events queue behind it and reconnecting players find their games. + loop.execute(() -> { + waitForValkey(); + gameManager.restoreLiveGames(); + clockScheduler.start(); + rtmt.start(); + }); + } + + @PreDestroy + void stop() { + loop.close(); + storeWriter.shutdown(); + recorderIo.shutdownNow(); + verifier.shutdownNow(); + botSearch.shutdownNow(); + } + + private void migrate() { + try { + Migrations.run(dataSource); + } catch (SQLException | RuntimeException e) { + LOG.errorf("(DB) Migrations failed, retrying in %ds: %s", MIGRATION_RETRY_MS / 1000, e.getMessage()); + recorderIo.schedule(this::migrate, MIGRATION_RETRY_MS, TimeUnit.MILLISECONDS); + } + } + + private void waitForValkey() { + long deadline = System.currentTimeMillis() + VALKEY_STARTUP_WAIT_MS; + while (true) { + try { + redis.execute("PING"); + return; + } catch (RuntimeException e) { + if (System.currentTimeMillis() >= deadline) { + LOG.error("(Valkey) Not reachable yet; starting without restoring live games"); + return; + } + } + try { + Thread.sleep(250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private static void guarded(Runnable task) { + try { + task.run(); + } catch (RuntimeException e) { + LOG.error("(Backend) Task failed", e); + } + } + + public GameLoop loop() { + return loop; + } + + public RtmtServer rtmt() { + return rtmt; + } + + public GameStore gameStore() { + return gameStore; + } + + public GameManager gameManager() { + return gameManager; + } + + public LiveGames liveGames() { + return liveGames; + } + + public GameHistoryService history() { + return history; + } + + public TokenVerifier tokenVerifier() { + return tokenVerifier; + } + + public boolean legacyUserKeyAuth() { + return "true".equals(legacyUserKeyAuth); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/auth/InvalidTokenException.java b/apps/backend/src/main/java/app/playmancala/auth/InvalidTokenException.java new file mode 100644 index 0000000..a4915f0 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/auth/InvalidTokenException.java @@ -0,0 +1,7 @@ +package app.playmancala.auth; + +public class InvalidTokenException extends Exception { + public InvalidTokenException(String message) { + super(message); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java b/apps/backend/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java new file mode 100644 index 0000000..825a8f6 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java @@ -0,0 +1,87 @@ +package app.playmancala.auth; + +import java.util.List; +import java.util.Map; + +import org.jose4j.http.Get; +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jwk.HttpsJwks; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; +import org.jose4j.keys.resolvers.VerificationKeyResolver; + +import app.playmancala.model.AuthUser; + +// Verifies Keycloak access tokens offline against the realm's published signing keys. Player ids are the token +// subject prefixed with "kc:", so they can never collide with ids that legacy userKey clients choose for themselves. +public class KeycloakTokenVerifier implements TokenVerifier { + public static final String KEYCLOAK_USER_ID_PREFIX = "kc:"; + public static final String GUEST_ROLE = "guest"; + + private static final int JWKS_TIMEOUT_MS = 5_000; + // Keys are cached this long, and kept this long while Keycloak cannot be reached. + private static final long JWKS_CACHE_SECONDS = 600; + // A token with an unknown key id refetches the keys at most this often. + private static final long JWKS_REFRESH_COOLDOWN_MS = 30_000; + + private final List allowedClientIds; + private final JwtConsumer consumer; + + // `jwksUrl` defaults to the issuer's certs endpoint. + public static KeycloakTokenVerifier create(String issuer, List allowedClientIds, String jwksUrl) { + HttpsJwks jwks = new HttpsJwks(jwksUrl != null ? jwksUrl : issuer + "/protocol/openid-connect/certs"); + Get get = new Get(); + get.setConnectTimeout(JWKS_TIMEOUT_MS); + get.setReadTimeout(JWKS_TIMEOUT_MS); + jwks.setSimpleHttpGet(get); + jwks.setDefaultCacheDuration(JWKS_CACHE_SECONDS); + jwks.setRetainCacheOnErrorDuration(JWKS_CACHE_SECONDS * 1000); + jwks.setRefreshReprieveThreshold(JWKS_REFRESH_COOLDOWN_MS); + return new KeycloakTokenVerifier(issuer, allowedClientIds, new HttpsJwksVerificationKeyResolver(jwks)); + } + + public KeycloakTokenVerifier(String issuer, List allowedClientIds, VerificationKeyResolver keys) { + this.allowedClientIds = List.copyOf(allowedClientIds); + this.consumer = new JwtConsumerBuilder() + .setExpectedIssuer(issuer) + .setRequireSubject() + .setRequireExpirationTime() + // Keycloak access tokens are checked by azp below, not by audience. + .setSkipDefaultAudienceValidation() + .setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256) + .setVerificationKeyResolver(keys) + .build(); + } + + @Override + public AuthUser verify(String token) throws InvalidTokenException { + JwtClaims claims; + try { + claims = consumer.processToClaims(token); + } catch (InvalidJwtException e) { + throw new InvalidTokenException(e.getMessage()); + } + + // Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens. + if (!"Bearer".equals(claims.getClaimValue("typ"))) { + throw new InvalidTokenException("not an access token"); + } + Object azp = claims.getClaimValue("azp"); + if (!(azp instanceof String clientId) || !allowedClientIds.contains(clientId)) { + throw new InvalidTokenException("token issued to an unknown client"); + } + + Object username = claims.getClaimValue("preferred_username"); + return new AuthUser(KEYCLOAK_USER_ID_PREFIX + claims.getClaimValue("sub"), + username instanceof String name ? name : null, hasGuestRole(claims.getClaimValue("realm_access"))); + } + + private static boolean hasGuestRole(Object realmAccess) { + return realmAccess instanceof Map access && access.get("roles") instanceof List roles + && roles.contains(GUEST_ROLE); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/auth/TokenVerifier.java b/apps/backend/src/main/java/app/playmancala/auth/TokenVerifier.java new file mode 100644 index 0000000..26be99c --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/auth/TokenVerifier.java @@ -0,0 +1,8 @@ +package app.playmancala.auth; + +import app.playmancala.model.AuthUser; + +public interface TokenVerifier { + // The token's user. May block (fetching signing keys); call it off the game loop. + AuthUser verify(String token) throws InvalidTokenException; +} diff --git a/apps/backend/src/main/java/app/playmancala/auth/TokenVerifierProducer.java b/apps/backend/src/main/java/app/playmancala/auth/TokenVerifierProducer.java new file mode 100644 index 0000000..da25e80 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/auth/TokenVerifierProducer.java @@ -0,0 +1,23 @@ +package app.playmancala.auth; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +public class TokenVerifierProducer { + @Produces + @Singleton + TokenVerifier tokenVerifier( + @ConfigProperty(name = "mancala.auth.issuer") String issuer, + @ConfigProperty(name = "mancala.auth.jwks-url") Optional jwksUrl, + @ConfigProperty(name = "mancala.auth.client-ids") String clientIds) { + // Comma-separated; spaces and empty entries are ignored, as in the Node backend. + List allowedClientIds = Arrays.stream(clientIds.split(",")).map(String::trim).filter(id -> !id.isEmpty()).toList(); + return KeycloakTokenVerifier.create(issuer, allowedClientIds, jwksUrl.orElse(null)); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java new file mode 100644 index 0000000..574b293 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java @@ -0,0 +1,80 @@ +package app.playmancala.bot; + +import java.util.random.RandomGenerator; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +// The levels players choose from. Each is a range of strengths (0-100); the bot draws its strength for a game from +// it. The ranges leave gaps so neighbouring levels stay clearly apart. +public enum BotLevel { + EASY("easy", 5, 25, 800, 1800), + MEDIUM("medium", 30, 55, 1000, 2200), + HARD("hard", 60, 80, 1200, 2600), + MASTER("master", 85, 100, 1400, 3000); + + private static final String BOT_ID_PREFIX = "bot:"; + + private final String wireName; + private final int minStrength; + private final int maxStrength; + // How long the bot seems to think before a move, on top of the previous move's animation. + private final int minThinkMs; + private final int maxThinkMs; + + BotLevel(String wireName, int minStrength, int maxStrength, int minThinkMs, int maxThinkMs) { + this.wireName = wireName; + this.minStrength = minStrength; + this.maxStrength = maxStrength; + this.minThinkMs = minThinkMs; + this.maxThinkMs = maxThinkMs; + } + + @JsonValue + public String wireName() { + return wireName; + } + + public int minStrength() { + return minStrength; + } + + public int maxStrength() { + return maxStrength; + } + + public int middleStrength() { + return (minStrength + maxStrength) / 2; + } + + public int drawStrength(RandomGenerator random) { + return random.nextInt(minStrength, maxStrength + 1); + } + + public long drawThinkMs(RandomGenerator random) { + return random.nextLong(minThinkMs, maxThinkMs + 1); + } + + // The user id the bot plays with at this level, e.g. bot:medium. + public String botId() { + return BOT_ID_PREFIX + wireName; + } + + // The level from its wire name, or null. + @JsonCreator + public static BotLevel fromWireName(String wireName) { + for (BotLevel level : values()) { + if (level.wireName.equals(wireName)) return level; + } + return null; + } + + public static boolean isBotId(String userId) { + return userId != null && userId.startsWith(BOT_ID_PREFIX); + } + + // The level of a bot id, or null if it is not one. + public static BotLevel fromBotId(String userId) { + return isBotId(userId) ? fromWireName(userId.substring(BOT_ID_PREFIX.length())) : null; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java new file mode 100644 index 0000000..5203605 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java @@ -0,0 +1,106 @@ +package app.playmancala.bot; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.random.RandomGenerator; + +import org.jboss.logging.Logger; + +import app.playmancala.engine.GameState; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; +import app.playmancala.engine.Position; +import app.playmancala.game.BackendGame; +import app.playmancala.game.GameClocks; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameStore; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.Seat; + +// Plays the bot's turns in live games. The move is worked out on `searchExecutor`, never on the game loop; it is +// played on the loop once the previous move's animation and the bot's thinking time are over, if the game is still +// waiting for it. Everything except the search runs on the loop. +public class BotPlayer { + private static final Logger LOG = Logger.getLogger(BotPlayer.class); + // PitAnimator's DEFAULT_ANIMATION_INTERVAL in packages/core: clients animate a move one step at a time. + static final long ANIMATION_STEP_MS = 300; + // The game page needs a moment to open before the bot's first move. + static final long GAME_START_DELAY_MS = 1_500; + // With less time than this on its clock, the bot stops pretending to think. + static final long LOW_TIME_MS = 30_000; + static final long LOW_TIME_THINK_MS = 300; + + private final GameManager gameManager; + private final GameStore gameStore; + private final Scheduler loop; + private final Executor searchExecutor; + private final RandomGenerator random; + // Games whose bot move is being worked out; by identity, since a restored game is a new object with the same id. + private final Set pending = Collections.newSetFromMap(new IdentityHashMap<>()); + + public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor, + RandomGenerator random) { + this.gameManager = gameManager; + this.gameStore = gameStore; + this.loop = loop; + this.searchExecutor = searchExecutor; + this.random = random; + gameManager.onTurnStarted(this::onTurnStarted); + } + + private void onTurnStarted(BackendGame game) { + if (!game.isBotTurn() || game.mancalaGame.getState() == GameState.ENDED || !pending.add(game)) return; + Position position = Position.of(game.mancalaGame); + int moveNumber = game.mancalaGame.getHistory().size(); + BotSettings bot = game.bot; + long readyAt = loop.now() + delayMs(game); + searchExecutor.execute(() -> { + int pit = choose(position, bot, moveNumber); + loop.execute(() -> loop.schedule(() -> play(game, moveNumber, pit), Math.max(0, readyAt - loop.now()))); + }); + } + + private static int choose(Position position, BotSettings bot, int moveNumber) { + try { + return BotPolicy.chooseMove(position, bot, moveNumber); + } catch (RuntimeException e) { + // A failed search must not leave the game waiting: play any move. + LOG.errorf(e, "(BotPlayer) Search failed in %s", position); + int[] pits = new int[Position.PIT_COUNT]; + position.legalPits(pits); + return pits[0]; + } + } + + private void play(BackendGame game, int moveNumber, int pit) { + pending.remove(game); + // The game may have ended (the player left or ran out of time) or been replaced by a restore meanwhile. + if (gameStore.get(game.id) != game || game.mancalaGame.getHistory().size() != moveNumber || !game.isBotTurn()) { + return; + } + gameManager.playMove(game, game.bot.botId(), pit); + } + + // The previous move's animation plus the bot's thinking time, shorter when its clock runs low. + long delayMs(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + Seat seat = GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), game.bot.botId()); + if (game.clock.turnStartedAt() != null + && GameClocks.getRemainingMs(game.clock, seat, seat, loop.now()) < LOW_TIME_MS) { + return LOW_TIME_THINK_MS; + } + List history = mancalaGame.getHistory(); + long waitBefore = history.isEmpty() ? GAME_START_DELAY_MS : animationMs(history.getLast()); + return waitBefore + game.bot.level().drawThinkMs(random); + } + + static long animationMs(MoveHistoryItem move) { + long steps = move.getGameSteps().stream().map(GameStep::type) + .filter(MancalaGame.GAME_STEP_GAME_MOVE::equals).count(); + return (steps + 1) * ANIMATION_STEP_MS; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java new file mode 100644 index 0000000..c6a8992 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java @@ -0,0 +1,58 @@ +package app.playmancala.bot; + +import java.util.SplittableRandom; + +import app.playmancala.engine.Position; + +// Chooses the bot's move. Everything random comes from the game's seed and the move number, so a move can be +// reproduced from the saved game. +public final class BotPolicy { + // How far the strength of a single move may stray from the game's strength. + static final int MOVE_STRENGTH_SPREAD = 5; + private static final long MOVE_NUMBER_MIX = 0x9E3779B97F4A7C15L; + + private BotPolicy() { + } + + // The pit (0-5) of the side to move to play. `moveNumber` is the number of moves played in the game so far. + public static int chooseMove(Position position, BotSettings bot, int moveNumber) { + return chooseMove(position, bot, moveNumber, BotSearch.DEFAULT_NODE_BUDGET); + } + + static int chooseMove(Position position, BotSettings bot, int moveNumber, long nodeBudget) { + SplittableRandom random = new SplittableRandom(bot.seed() + MOVE_NUMBER_MIX * (moveNumber + 1)); + int strength = Math.clamp(bot.strength() + random.nextInt(-MOVE_STRENGTH_SPREAD, MOVE_STRENGTH_SPREAD + 1), 0, 100); + BotStrength.Params params = BotStrength.at(strength); + int[] pits = new int[Position.PIT_COUNT]; + int count = position.legalPits(pits); + if (count == 0) throw new IllegalArgumentException("No moves in " + position); + // Drawn every time, so the draws that follow do not depend on how many moves there are. + double randomMoveDraw = random.nextDouble(); + double choiceDraw = random.nextDouble(); + if (count == 1) return pits[0]; + if (randomMoveDraw < params.randomMoveChance()) return pits[(int) (choiceDraw * count)]; + + BotSearch.Result result = BotSearch.scoreMoves(position, params.depth(), nodeBudget); + return pick(result, params.temperature(), choiceDraw); + } + + // A move with probability proportional to exp(score / temperature); temperature in stones. + static int pick(BotSearch.Result result, double temperature, double draw) { + int[] scores = result.scores(); + int best = Integer.MIN_VALUE; + for (int score : scores) best = Math.max(best, score); + double scale = Math.max(temperature, 0.001) * 100; + double[] weights = new double[scores.length]; + double total = 0; + for (int i = 0; i < scores.length; i++) { + weights[i] = Math.exp((scores[i] - best) / scale); + total += weights[i]; + } + double target = draw * total; + for (int i = 0; i < scores.length; i++) { + target -= weights[i]; + if (target < 0) return result.pits()[i]; + } + return result.pits()[scores.length - 1]; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java new file mode 100644 index 0000000..e844587 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java @@ -0,0 +1,140 @@ +package app.playmancala.bot; + +import java.util.Arrays; + +import app.playmancala.engine.Position; + +// Scores every move of a position with negamax and alpha-beta pruning. Scores are from the mover's view in +// hundredths of a stone; a move that gives an extra turn keeps the side to move, so its score is not negated. +// Deepens one ply at a time up to the depth; when the node budget runs out, the scores of the last completed depth +// are returned. Nothing depends on time, so the same position, depth and budget always give the same scores. +public final class BotSearch { + public static final long DEFAULT_NODE_BUDGET = 3_000_000; + // An ended (or decided) game scores beyond any bank difference. + public static final int WIN = 100_000; + // A bank with more than half of the 48 stones has won. + private static final int DECIDING_BANK = 24; + private static final int STONE = 100; + // Leaf score for each stone more in the mover's pits than in the opponent's. + static final int PIT_STONE_WEIGHT = 0; + + public record Result(int[] pits, int[] scores, int depth, long nodes) { + public int count() { + return pits.length; + } + } + + private static final class OutOfBudget extends RuntimeException { + OutOfBudget() { + super(null, null, false, false); + } + } + + private final long nodeBudget; + private Position[][] children; + private int[][] order; + private boolean[][] extraTurns; + private int[][] orderKeys; + private long nodes; + private boolean budgetApplies; + + private BotSearch(long nodeBudget) { + this.nodeBudget = nodeBudget; + } + + public static Result scoreMoves(Position root, int maxDepth) { + return scoreMoves(root, maxDepth, DEFAULT_NODE_BUDGET); + } + + public static Result scoreMoves(Position root, int maxDepth, long nodeBudget) { + return new BotSearch(nodeBudget).run(root, Math.max(1, maxDepth)); + } + + private Result run(Position root, int maxDepth) { + children = new Position[maxDepth + 1][Position.PIT_COUNT]; + order = new int[maxDepth + 1][Position.PIT_COUNT]; + extraTurns = new boolean[maxDepth + 1][Position.PIT_COUNT]; + orderKeys = new int[maxDepth + 1][Position.PIT_COUNT]; + for (Position[] ply : children) { + for (int i = 0; i < ply.length; i++) ply[i] = root.copy(); + } + int[] pits = new int[Position.PIT_COUNT]; + int count = root.legalPits(pits); + if (count == 0) throw new IllegalArgumentException("No moves in " + root); + pits = Arrays.copyOf(pits, count); + Position[] rootChildren = new Position[count]; + boolean[] rootExtraTurns = new boolean[count]; + for (int i = 0; i < count; i++) { + rootChildren[i] = root.copy(); + rootExtraTurns[i] = root.play(pits[i], rootChildren[i]); + } + + int[] scores = null; + int completedDepth = 0; + for (int depth = 1; depth <= maxDepth; depth++) { + budgetApplies = depth > 1; + int[] depthScores = new int[count]; + try { + for (int i = 0; i < count; i++) { + nodes++; + int value = negamax(rootChildren[i], depth - 1, -Integer.MAX_VALUE, Integer.MAX_VALUE, 1); + depthScores[i] = rootExtraTurns[i] ? value : -value; + } + } catch (OutOfBudget e) { + break; + } + scores = depthScores; + completedDepth = depth; + } + return new Result(pits, scores, completedDepth, nodes); + } + + private int negamax(Position position, int depth, int alpha, int beta, int ply) { + if (budgetApplies && nodes > nodeBudget) throw new OutOfBudget(); + if (position.isOver()) return endScore(position.bankDifference()); + int ownBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK); + int opponentBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER2_BANK : Position.PLAYER1_BANK); + if (ownBank > DECIDING_BANK || opponentBank > DECIDING_BANK) return endScore(ownBank - opponentBank); + if (depth == 0) return STONE * (ownBank - opponentBank) + PIT_STONE_WEIGHT * position.pitStoneDifference(); + + // Play every move once to order them: extra turns first, then by the stones they bring to the bank. + Position[] next = children[ply]; + int[] moveOrder = order[ply]; + boolean[] extra = extraTurns[ply]; + int[] keys = orderKeys[ply]; + int count = 0; + for (int pit = 0; pit < Position.PIT_COUNT; pit++) { + if (position.stonesAt(position.boardIndex(pit)) == 0) continue; + boolean extraTurn = position.play(pit, next[pit]); + extra[pit] = extraTurn; + int gain = next[pit].stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK) - ownBank; + int key = (extraTurn ? 1000 : 0) + gain; + int at = count++; + while (at > 0 && keys[at - 1] < key) { + keys[at] = keys[at - 1]; + moveOrder[at] = moveOrder[at - 1]; + at--; + } + keys[at] = key; + moveOrder[at] = pit; + } + + int best = -Integer.MAX_VALUE; + for (int i = 0; i < count; i++) { + int pit = moveOrder[i]; + nodes++; + int value = extra[pit] + ? negamax(next[pit], depth - 1, alpha, beta, ply + 1) + : -negamax(next[pit], depth - 1, -beta, -alpha, ply + 1); + if (value > best) best = value; + if (best > alpha) alpha = best; + if (alpha >= beta) break; + } + return best; + } + + private static int endScore(int bankDifference) { + int sign = Integer.signum(bankDifference); + return sign * WIN + STONE * bankDifference; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java new file mode 100644 index 0000000..e91b567 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java @@ -0,0 +1,16 @@ +package app.playmancala.bot; + +import java.util.random.RandomGenerator; + +// How the bot plays one game: its level, the strength drawn for the game and the seed its choices come from. Kept +// with the live game and the saved game, so a restart does not change how it plays and its moves can be reproduced. +public record BotSettings(BotLevel level, int strength, long seed) { + + public static BotSettings draw(BotLevel level, RandomGenerator random) { + return new BotSettings(level, level.drawStrength(random), random.nextLong()); + } + + public String botId() { + return level.botId(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java new file mode 100644 index 0000000..20d5207 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java @@ -0,0 +1,34 @@ +package app.playmancala.bot; + +// What a strength (0-100) means for one move: how deep the bot searches, how strongly it prefers the best move +// (softmax temperature in stones; lower is more exact) and how often it plays a random move instead. Values between +// the rows of the table are interpolated linearly. +public final class BotStrength { + + public record Params(int depth, double temperature, double randomMoveChance) { + } + + private static final double[] STRENGTHS = { 0, 25, 50, 75, 100 }; + private static final double[] DEPTHS = { 1, 2, 4, 7, 16 }; + private static final double[] TEMPERATURES = { 4, 2, 1, 0.4, 0.05 }; + private static final double[] RANDOM_MOVE_CHANCES = { 0.35, 0.15, 0.05, 0.01, 0 }; + + private BotStrength() { + } + + public static Params at(double strength) { + double s = Math.clamp(strength, 0, 100); + return new Params((int) Math.round(interpolate(DEPTHS, s)), interpolate(TEMPERATURES, s), + interpolate(RANDOM_MOVE_CHANCES, s)); + } + + private static double interpolate(double[] values, double strength) { + for (int row = 1; row < STRENGTHS.length; row++) { + if (strength <= STRENGTHS[row]) { + double t = (strength - STRENGTHS[row - 1]) / (STRENGTHS[row] - STRENGTHS[row - 1]); + return values[row - 1] + t * (values[row] - values[row - 1]); + } + } + return values[values.length - 1]; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/Bank.java b/apps/backend/src/main/java/app/playmancala/engine/Bank.java new file mode 100644 index 0000000..29d9c72 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Bank.java @@ -0,0 +1,15 @@ +package app.playmancala.engine; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class Bank extends Pit { + public Bank(int index, int stoneCount) { + super(index, stoneCount); + } + + @Override + @JsonIgnore + public boolean isBank() { + return true; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/Board.java b/apps/backend/src/main/java/app/playmancala/engine/Board.java new file mode 100644 index 0000000..f53986c --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Board.java @@ -0,0 +1,221 @@ +package app.playmancala.engine; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntConsumer; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// Port of packages/mancala.js/src/core/Board.ts. Pits are indexed counterclockwise: player 1's pits, player 1's bank, +// player 2's pits, player 2's bank. +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonPropertyOrder({ "pits", "playerPitCount", "initialStoneCountInPits" }) +public class Board { + private static final int BANK_COUNT = 2; + + private final List pits; + private final int playerPitCount; + private final int initialStoneCountInPits; + + @JsonIgnore + IntConsumer onGameMoveStart = index -> { }; + @JsonIgnore + IntConsumer onGameMove = index -> { }; + @JsonIgnore + IntConsumer onGameMoveEnd = index -> { }; + + public Board(int playerPitCount, int initialStoneCountInPits) { + this(playerPitCount, initialStoneCountInPits, null); + } + + public Board(int playerPitCount, int initialStoneCountInPits, List pits) { + this.playerPitCount = playerPitCount; + this.initialStoneCountInPits = initialStoneCountInPits; + this.pits = pits != null ? pits : createPits(initialStoneCountInPits); + } + + private List createPits(int initialStoneCountInPits) { + List pitList = new ArrayList<>(playerPitCount * 2 + BANK_COUNT); + for (int index = 0; index < totalPitCount(); index++) { + PitType pitType = getPitTypeByIndex(index); + pitList.add(pitType.isBank() ? new Bank(index, 0) : new Pit(index, initialStoneCountInPits)); + } + return pitList; + } + + public List getPits() { + return pits; + } + + public int getPlayerPitCount() { + return playerPitCount; + } + + public int getInitialStoneCountInPits() { + return initialStoneCountInPits; + } + + public int player1PitStartIndex() { + return 0; + } + + public int player1PitStopIndex() { + return player1PitStartIndex() + playerPitCount - 1; + } + + public int player1BankIndex() { + return player1PitStopIndex() + 1; + } + + public int player2PitStartIndex() { + return player1BankIndex() + 1; + } + + public int player2PitStopIndex() { + return player2PitStartIndex() + playerPitCount - 1; + } + + public int player2BankIndex() { + return player2PitStopIndex() + 1; + } + + public int totalPitCount() { + return player2BankIndex() + 1; + } + + public boolean checkIndex(int index) { + return index >= player1PitStartIndex() && index <= player2BankIndex(); + } + + void checkIndexAndMaybeThrowError(int index) { + if (!checkIndex(index)) { + throw new IllegalStateException("IndexOutOfRange => index : " + index + " [start, stop] = [" + + player1PitStartIndex() + ", " + player2BankIndex() + "]"); + } + } + + public PitType getPitTypeByIndex(int index) { + checkIndexAndMaybeThrowError(index); + if (index >= player1PitStartIndex() && index <= player1PitStopIndex()) { + return PitType.PLAYER1_PIT; + } else if (index == player1BankIndex()) { + return PitType.PLAYER1_BANK; + } else if (index <= player2PitStopIndex()) { + return PitType.PLAYER2_PIT; + } else { + return PitType.PLAYER2_BANK; + } + } + + public boolean checkPitTypeIsNormalPitByIndex(int index) { + return getPitTypeByIndex(index).isNormalPit(); + } + + public boolean checkPitTypeIsBankByIndex(int index) { + return getPitTypeByIndex(index).isBank(); + } + + public void move(int index) { + checkIndexAndMaybeThrowError(index); + PitType pitType = getPitTypeByIndex(index); + if (pitType.isBank()) { + throw new IllegalStateException("InCorrectPit => index : " + index + " pitType = " + pitTypeName(pitType)); + } + Pit pit = pits.get(index); + if (pit.getStoneCount() <= 0) { + throw new IllegalStateException("StoneNotFound => index : " + index + " stoneCount = " + pit.getStoneCount()); + } + List path = getSowingPath(index); + pit.setStoneCount(0); + onGameMoveStart.accept(path.getFirst()); + path.forEach(this::onGameMoveStep); + onGameMoveEnd.accept(path.getLast()); + } + + public List getSowingPath(int index) { + return getSowingPath(index, pits.get(index).getStoneCount()); + } + + // Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the + // first stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped. + // The last index is where the last stone lands. Empty for a bank or an empty pit. + public List getSowingPath(int index, int stoneCount) { + checkIndexAndMaybeThrowError(index); + if (stoneCount <= 0 || checkPitTypeIsBankByIndex(index)) return List.of(); + int opponentBankIndex = getPitTypeByIndex(index) == PitType.PLAYER1_PIT ? player2BankIndex() : player1BankIndex(); + List path = new ArrayList<>(stoneCount); + int current = stoneCount == 1 ? getNextPitIndexCircularly(index) : index; + while (path.size() < stoneCount) { + if (current != opponentBankIndex) path.add(current); + current = getNextPitIndexCircularly(current); + } + return path; + } + + public void onGameMoveStep(int index) { + pits.get(getPitIndexCircularly(index)).increaseStone(); + onGameMove.accept(index); + } + + public int getNextPitIndexCircularly(int currentPitIndex) { + checkIndexAndMaybeThrowError(currentPitIndex); + return getPitIndexCircularly(currentPitIndex + 1); + } + + public Pit getPitCircularly(int currentPitIndex) { + return pits.get(getPitIndexCircularly(currentPitIndex)); + } + + public int getPitIndexCircularly(int currentPitIndex) { + return currentPitIndex % totalPitCount(); + } + + public int[] getStoneArray() { + return pits.stream().mapToInt(Pit::getStoneCount).toArray(); + } + + public List player1Pits() { + return pits.subList(player1PitStartIndex(), player1BankIndex()); + } + + public List player2Pits() { + return pits.subList(player2PitStartIndex(), player2BankIndex()); + } + + public Pit player1Bank() { + return pits.get(player1BankIndex()); + } + + public Pit player2Bank() { + return pits.get(player2BankIndex()); + } + + public int getOppositePitIndex(int index) { + if (index > player1BankIndex()) { + return player2PitStopIndex() - index; + } else { + return player1BankIndex() + playerPitCount - index; + } + } + + public void clearPlayer1Pits() { + player1Pits().forEach(pit -> pit.setStoneCount(0)); + } + + public void clearPlayer2Pits() { + player2Pits().forEach(pit -> pit.setStoneCount(0)); + } + + // The TypeScript name of a pit type, for error messages. + static String pitTypeName(PitType pitType) { + return switch (pitType) { + case PLAYER1_PIT -> "player1Pit"; + case PLAYER1_BANK -> "player1Bank"; + case PLAYER2_PIT -> "player2Pit"; + case PLAYER2_BANK -> "player2Bank"; + }; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/CommonBoard.java b/apps/backend/src/main/java/app/playmancala/engine/CommonBoard.java new file mode 100644 index 0000000..c04efc0 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/CommonBoard.java @@ -0,0 +1,10 @@ +package app.playmancala.engine; + +public class CommonBoard extends Board { + private static final int PIT_COUNT_BY_USER = 6; + private static final int INITIAL_STONE_COUNT_IN_PIT = 4; + + public CommonBoard() { + super(PIT_COUNT_BY_USER, INITIAL_STONE_COUNT_IN_PIT); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/CommonMancalaGame.java b/apps/backend/src/main/java/app/playmancala/engine/CommonMancalaGame.java new file mode 100644 index 0000000..bb57c10 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/CommonMancalaGame.java @@ -0,0 +1,18 @@ +package app.playmancala.engine; + +import java.util.List; + +import app.playmancala.engine.rules.GRClearBoardAtEnd; +import app.playmancala.engine.rules.GRDoubleStoneInPit; +import app.playmancala.engine.rules.GRLastStoneInBank; +import app.playmancala.engine.rules.GRLastStoneInEmptyPit; + +// The Turkish Mangala rules. The order of the rules matters: each sees the board as the previous one left it. +public class CommonMancalaGame extends MancalaGame { + public CommonMancalaGame(String id, String player1Id, String player2Id) { + super(id, new CommonBoard(), player1Id, player2Id, player1Id, + List.of(new GRLastStoneInEmptyPit(), new GRDoubleStoneInPit(), new GRLastStoneInBank(), + new GRClearBoardAtEnd()), + List.of()); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/GameRule.java b/apps/backend/src/main/java/app/playmancala/engine/GameRule.java new file mode 100644 index 0000000..9780c85 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/GameRule.java @@ -0,0 +1,12 @@ +package app.playmancala.engine; + +public interface GameRule { + default void onGameMoveStart(MancalaGame game, int index) { + } + + default void onGameMove(MancalaGame game, int index) { + } + + default void onGameMoveEnd(MancalaGame game, int index) { + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/GameState.java b/apps/backend/src/main/java/app/playmancala/engine/GameState.java new file mode 100644 index 0000000..a20c354 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/GameState.java @@ -0,0 +1,9 @@ +package app.playmancala.engine; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum GameState { + @JsonProperty("initial") INITIAL, + @JsonProperty("playing") PLAYING, + @JsonProperty("ended") ENDED +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/GameStep.java b/apps/backend/src/main/java/app/playmancala/engine/GameStep.java new file mode 100644 index 0000000..342d870 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/GameStep.java @@ -0,0 +1,13 @@ +package app.playmancala.engine; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// One thing that happened during a move, for clients to animate. `data` depends on the type and is null for most. +@JsonPropertyOrder({ "index", "type", "data" }) +@JsonInclude(JsonInclude.Include.ALWAYS) +public record GameStep(int index, String type, Object data) { + public GameStep(int index, String type) { + this(index, type, null); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/MancalaGame.java b/apps/backend/src/main/java/app/playmancala/engine/MancalaGame.java new file mode 100644 index 0000000..32dafe6 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/MancalaGame.java @@ -0,0 +1,213 @@ +package app.playmancala.engine; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// Port of packages/mancala.js/src/core/MancalaGame.ts. Its JSON is what JSON.stringify makes of the TypeScript object, +// because clients rebuild the game from it with MancalaGame.createFromMancalaGame. +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonPropertyOrder({ "id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history", + "currentHistoryItem" }) +@JsonInclude(JsonInclude.Include.ALWAYS) +public class MancalaGame { + public static final String GAME_STEP_GAME_MOVE = "GAME_STEP_GAME_MOVE"; + + private final String id; + private final Board board; + private final String player1Id; + private final String player2Id; + private String turnPlayerId; + private GameState state; + @JsonIgnore + private final List rules; + private final List history; + private MoveHistoryItem currentHistoryItem; + + public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId, + List gameRules, List history) { + this(id, board, player1Id, player2Id, turnPlayerId, gameRules, history, GameState.INITIAL); + } + + public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId, + List gameRules, List history, GameState state) { + this.id = id; + this.board = board; + this.player1Id = player1Id; + this.player2Id = player2Id; + this.turnPlayerId = turnPlayerId; + this.state = state; + this.rules = List.copyOf(gameRules); + this.history = new ArrayList<>(history); + listenBoardMoveEvents(); + } + + private void listenBoardMoveEvents() { + board.onGameMoveStart = index -> rules.forEach(gameRule -> gameRule.onGameMoveStart(this, index)); + board.onGameMove = index -> { + addGameStep(new GameStep(index, GAME_STEP_GAME_MOVE)); + rules.forEach(gameRule -> gameRule.onGameMove(this, index)); + }; + board.onGameMoveEnd = index -> rules.forEach(gameRule -> gameRule.onGameMoveEnd(this, index)); + } + + public String getId() { + return id; + } + + public Board getBoard() { + return board; + } + + public String getPlayer1Id() { + return player1Id; + } + + public String getPlayer2Id() { + return player2Id; + } + + public String getTurnPlayerId() { + return turnPlayerId; + } + + public void setTurnPlayerId(String turnPlayerId) { + this.turnPlayerId = turnPlayerId; + } + + public GameState getState() { + return state; + } + + public void setState(GameState state) { + this.state = state; + } + + public List getHistory() { + return history; + } + + public MoveHistoryItem getCurrentHistoryItem() { + return currentHistoryItem; + } + + // Rules have no state; the TypeScript objects serialise as {}. + @JsonProperty("gameRules") + private List> gameRulesJson() { + return rules.stream().map(rule -> Map.of()).toList(); + } + + public void changePlayerTurn() { + turnPlayerId = turnPlayerId.equals(player1Id) ? player2Id : player1Id; + } + + public boolean isTurnPlayer1() { + return player1Id.equals(turnPlayerId); + } + + public boolean isTurnPlayer2() { + return player2Id.equals(turnPlayerId); + } + + public String getPlayerIdByPitType(PitType pitType) { + return switch (pitType) { + case PLAYER1_PIT, PLAYER1_BANK -> player1Id; + case PLAYER2_PIT, PLAYER2_BANK -> player2Id; + }; + } + + public String getPlayerIdByIndex(int index) { + return getPlayerIdByPitType(board.getPitTypeByIndex(index)); + } + + public boolean checkIsPlayerTurnByIndex(int index) { + return checkIsPlayerTurn(getPlayerIdByIndex(index)); + } + + public int getBoardIndexByPlayerId(String playerId, int pitIndex) { + if (player1Id.equals(playerId)) { + return board.player1PitStartIndex() + pitIndex; + } else if (player2Id.equals(playerId)) { + return board.player2PitStartIndex() + pitIndex; + } else { + return -1; + } + } + + public boolean checkIsPlayerTurn(String playerId) { + return playerId.equals(turnPlayerId); + } + + public boolean checkPitIndexForPlayerId(String playerId, int pitIndex) { + return playerId.equals(getPlayerIdByIndex(getBoardIndexByPlayerId(playerId, pitIndex))); + } + + public boolean checkIsPitIndexBank(String playerId, int pitIndex) { + return board.getPitTypeByIndex(getBoardIndexByPlayerId(playerId, pitIndex)).isBank(); + } + + public boolean canPlayerMove(String playerId, int pitIndex) { + boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex); + boolean isPitIndexBank = checkIsPitIndexBank(playerId, pitIndex); + boolean isPlayerTurn = checkIsPlayerTurn(playerId); + return isPitIndexCorrect && !isPitIndexBank && isPlayerTurn; + } + + public void moveByPlayerPit(String playerId, int pitIndex) { + if (state == GameState.ENDED) return; + if (state == GameState.INITIAL) { + state = GameState.PLAYING; + } + if (canPlayerMove(playerId, pitIndex)) { + int moveIndex = getBoardIndexByPlayerId(playerId, pitIndex); + currentHistoryItem = new MoveHistoryItem(playerId, moveIndex, new int[0], List.of()); + board.move(moveIndex); + currentHistoryItem.setBoardSnapshot(board.getStoneArray()); + history.add(currentHistoryItem); + if (checkGameIsEnded()) { + state = GameState.ENDED; + } + } else { + boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex); + boolean isPlayerTurn = checkIsPlayerTurn(playerId); + throw new IllegalStateException("Player cannot move reason : isPitIndexCorrect = " + isPitIndexCorrect + + " isPlayerTurn = " + isPlayerTurn); + } + } + + public int getPlayer1StoneCountInPits() { + return getPlayerStoneCountInPits(board.player1PitStartIndex(), board.player1BankIndex()); + } + + public int getPlayer2StoneCountInPits() { + return getPlayerStoneCountInPits(board.player2PitStartIndex(), board.player2BankIndex()); + } + + private int getPlayerStoneCountInPits(int playerPitStartIndex, int playerBankIndex) { + return board.getPits().subList(playerPitStartIndex, playerBankIndex).stream() + .mapToInt(Pit::getStoneCount).sum(); + } + + public boolean checkGameIsEnded() { + return getPlayer1StoneCountInPits() == 0 || getPlayer2StoneCountInPits() == 0; + } + + // The winner once the game has ended; null for a draw or a game still being played. + public String getWonPlayerId() { + int player1StoneCount = board.player1Bank().getStoneCount(); + int player2StoneCount = board.player2Bank().getStoneCount(); + if (!checkGameIsEnded() || player1StoneCount == player2StoneCount) return null; + return player1StoneCount > player2StoneCount ? player1Id : player2Id; + } + + public void addGameStep(GameStep gameStep) { + if (currentHistoryItem != null) currentHistoryItem.getGameSteps().add(gameStep); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/MoveHistoryItem.java b/apps/backend/src/main/java/app/playmancala/engine/MoveHistoryItem.java new file mode 100644 index 0000000..f43f859 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/MoveHistoryItem.java @@ -0,0 +1,49 @@ +package app.playmancala.engine; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// A played move. The property order is that of the TypeScript MoveHistoryItem, whose base class sets the board first. +@JsonPropertyOrder({ "boardSnapshot", "gameSteps", "playerId", "moveIndex" }) +public class MoveHistoryItem { + private int[] boardSnapshot; + private final List gameSteps; + private final String playerId; + private final int moveIndex; + + @JsonCreator + public MoveHistoryItem( + @JsonProperty("playerId") String playerId, + @JsonProperty("moveIndex") int moveIndex, + @JsonProperty("boardSnapshot") int[] boardSnapshot, + @JsonProperty("gameSteps") List gameSteps) { + this.playerId = playerId; + this.moveIndex = moveIndex; + this.boardSnapshot = boardSnapshot == null ? new int[0] : boardSnapshot; + this.gameSteps = gameSteps == null ? new ArrayList<>() : new ArrayList<>(gameSteps); + } + + public int[] getBoardSnapshot() { + return boardSnapshot; + } + + void setBoardSnapshot(int[] boardSnapshot) { + this.boardSnapshot = boardSnapshot; + } + + public List getGameSteps() { + return gameSteps; + } + + public String getPlayerId() { + return playerId; + } + + public int getMoveIndex() { + return moveIndex; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/Pit.java b/apps/backend/src/main/java/app/playmancala/engine/Pit.java new file mode 100644 index 0000000..cd38b62 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Pit.java @@ -0,0 +1,40 @@ +package app.playmancala.engine; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +@JsonPropertyOrder({ "index", "stoneCount" }) +public class Pit { + private final int index; + private int stoneCount; + + public Pit(int index, int stoneCount) { + this.index = index; + this.stoneCount = stoneCount; + } + + public int getIndex() { + return index; + } + + public int getStoneCount() { + return stoneCount; + } + + public void setStoneCount(int stoneCount) { + this.stoneCount = stoneCount; + } + + @JsonIgnore + public boolean isBank() { + return false; + } + + public void increaseStone() { + stoneCount++; + } + + public void addStones(int count) { + stoneCount += count; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/PitType.java b/apps/backend/src/main/java/app/playmancala/engine/PitType.java new file mode 100644 index 0000000..8a468b3 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/PitType.java @@ -0,0 +1,16 @@ +package app.playmancala.engine; + +public enum PitType { + PLAYER1_PIT, + PLAYER1_BANK, + PLAYER2_PIT, + PLAYER2_BANK; + + public boolean isBank() { + return this == PLAYER1_BANK || this == PLAYER2_BANK; + } + + public boolean isNormalPit() { + return this == PLAYER1_PIT || this == PLAYER2_PIT; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/Position.java b/apps/backend/src/main/java/app/playmancala/engine/Position.java new file mode 100644 index 0000000..454ecac --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Position.java @@ -0,0 +1,176 @@ +package app.playmancala.engine; + +import java.util.Arrays; + +// A board as 14 stone counts and whose turn it is, with the rules of CommonMancalaGame played on it directly. Fast +// enough for searching millions of moves: playing a move allocates nothing. Kept equal to MancalaGame by +// PositionTest; MancalaGame stays the engine for live games, since it also records the steps clients animate. +public final class Position { + public static final int SIZE = 14; + public static final int PIT_COUNT = 6; + public static final int PLAYER1_BANK = 6; + public static final int PLAYER2_BANK = 13; + private static final int INITIAL_STONES_IN_PIT = 4; + + // Player 1's pits 0-5 and bank 6; player 2's pits 7-12 and bank 13, as in CommonBoard. + private final int[] stones; + private boolean player1ToMove; + + private Position(int[] stones, boolean player1ToMove) { + this.stones = stones; + this.player1ToMove = player1ToMove; + } + + public static Position initial(boolean player1ToMove) { + int[] stones = new int[SIZE]; + for (int pit = 0; pit < PIT_COUNT; pit++) { + stones[pit] = INITIAL_STONES_IN_PIT; + stones[PLAYER1_BANK + 1 + pit] = INITIAL_STONES_IN_PIT; + } + return new Position(stones, player1ToMove); + } + + public static Position of(int[] stones, boolean player1ToMove) { + if (stones.length != SIZE) throw new IllegalArgumentException("A board has " + SIZE + " pits: " + stones.length); + return new Position(stones.clone(), player1ToMove); + } + + public static Position of(MancalaGame game) { + return of(game.getBoard().getStoneArray(), game.isTurnPlayer1()); + } + + public Position copy() { + return new Position(stones.clone(), player1ToMove); + } + + public int[] stones() { + return stones.clone(); + } + + public int stonesAt(int index) { + return stones[index]; + } + + public boolean isPlayer1ToMove() { + return player1ToMove; + } + + // Writes the mover's playable pits (0-5) into `into` and returns how many there are. + public int legalPits(int[] into) { + int first = firstPitIndex(player1ToMove); + int count = 0; + for (int pit = 0; pit < PIT_COUNT; pit++) { + if (stones[first + pit] > 0) into[count++] = pit; + } + return count; + } + + // Plays the mover's pit (0-5) into `into`, which may be this position. Returns true if the mover plays again. + public boolean play(int pit, Position into) { + if (pit < 0 || pit >= PIT_COUNT) throw new IllegalArgumentException("No such pit: " + pit); + int index = firstPitIndex(player1ToMove) + pit; + int stoneCount = stones[index]; + if (stoneCount <= 0) throw new IllegalArgumentException("Pit " + pit + " is empty"); + int[] board = into.stones; + if (into != this) System.arraycopy(stones, 0, board, 0, SIZE); + boolean player1 = player1ToMove; + int ownBank = player1 ? PLAYER1_BANK : PLAYER2_BANK; + int opponentBank = player1 ? PLAYER2_BANK : PLAYER1_BANK; + + // Sowing: a single stone goes to the next pit; otherwise the first stone goes back into the played pit. + board[index] = 0; + int current = stoneCount == 1 ? next(index) : index; + int last = current; + for (int dropped = 0; dropped < stoneCount; current = next(current)) { + if (current == opponentBank) continue; + board[current]++; + last = current; + dropped++; + } + + // The last stone in an own empty pit takes the stones opposite, if there are any. + if (board[last] == 1 && isOwnPit(last, player1)) { + int opposite = 12 - last; + if (board[opposite] > 0) { + board[ownBank] += 1 + board[opposite]; + board[opposite] = 0; + board[last] = 0; + } + } + // The last stone making an opponent's pit even takes them. + if (board[last] % 2 == 0 && isOwnPit(last, !player1)) { + board[ownBank] += board[last]; + board[last] = 0; + } + boolean extraTurn = last == ownBank; + into.player1ToMove = extraTurn == player1; + + // When a side is empty, its player gets the stones left on the other side. + int player1Stones = sumPits(board, 0); + int player2Stones = sumPits(board, PLAYER1_BANK + 1); + if (player1Stones == 0) { + board[PLAYER1_BANK] += player2Stones; + Arrays.fill(board, PLAYER1_BANK + 1, PLAYER2_BANK, 0); + } else if (player2Stones == 0) { + board[PLAYER2_BANK] += player1Stones; + Arrays.fill(board, 0, PLAYER1_BANK, 0); + } + return extraTurn; + } + + // One side is empty, so the game has ended. + public boolean isOver() { + return sumPits(stones, 0) == 0 || sumPits(stones, PLAYER1_BANK + 1) == 0; + } + + // The mover's bank minus the opponent's. + public int bankDifference() { + int difference = stones[PLAYER1_BANK] - stones[PLAYER2_BANK]; + return player1ToMove ? difference : -difference; + } + + // The mover's stones in pits minus the opponent's. + public int pitStoneDifference() { + int difference = sumPits(stones, 0) - sumPits(stones, PLAYER1_BANK + 1); + return player1ToMove ? difference : -difference; + } + + // Board index of the mover's pit (0-5). + public int boardIndex(int pit) { + return firstPitIndex(player1ToMove) + pit; + } + + private static int firstPitIndex(boolean player1) { + return player1 ? 0 : PLAYER1_BANK + 1; + } + + private static boolean isOwnPit(int index, boolean player1) { + return player1 ? index < PLAYER1_BANK : index > PLAYER1_BANK && index < PLAYER2_BANK; + } + + private static int next(int index) { + return index == SIZE - 1 ? 0 : index + 1; + } + + private static int sumPits(int[] board, int first) { + int sum = 0; + for (int index = first; index < first + PIT_COUNT; index++) sum += board[index]; + return sum; + } + + @Override + public boolean equals(Object other) { + return other instanceof Position position && player1ToMove == position.player1ToMove + && Arrays.equals(stones, position.stones); + } + + @Override + public int hashCode() { + return 31 * Arrays.hashCode(stones) + Boolean.hashCode(player1ToMove); + } + + @Override + public String toString() { + return Arrays.toString(stones) + (player1ToMove ? " player 1 to move" : " player 2 to move"); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java new file mode 100644 index 0000000..a331de8 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java @@ -0,0 +1,44 @@ +package app.playmancala.engine.rules; + +import java.util.ArrayList; +import java.util.List; + +import app.playmancala.engine.Board; +import app.playmancala.engine.GameRule; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; + +// When a move empties one side, the player whose side is empty gets the stones left on the other side. +public class GRClearBoardAtEnd implements GameRule { + public static final String GAME_STEP_BOARD_CLEARED = "GAME_STEP_BOARD_CLEARED"; + + public record ClearBoardAtEndData(List pitIndexesThatHasStone) { + } + + @Override + public void onGameMoveEnd(MancalaGame game, int index) { + Board board = game.getBoard(); + if (game.getPlayer1StoneCountInPits() == 0) { + ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board)); + board.player1Bank().addStones(game.getPlayer2StoneCountInPits()); + board.clearPlayer2Pits(); + game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data)); + } else if (game.getPlayer2StoneCountInPits() == 0) { + ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board)); + board.player2Bank().addStones(game.getPlayer1StoneCountInPits()); + board.clearPlayer1Pits(); + game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data)); + } + } + + private List getPitIndexesThatHasStone(Board board) { + List indexList = new ArrayList<>(); + int[] stones = board.getStoneArray(); + for (int index = 0; index < stones.length; index++) { + if (stones[index] > 0 && board.checkPitTypeIsNormalPitByIndex(index)) { + indexList.add(index); + } + } + return indexList; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java new file mode 100644 index 0000000..107ed8d --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java @@ -0,0 +1,37 @@ +package app.playmancala.engine.rules; + +import app.playmancala.engine.Board; +import app.playmancala.engine.GameRule; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.Pit; +import app.playmancala.engine.PitType; + +// When the last stone makes the number of stones in an opponent's pit even, the mover takes them. +public class GRDoubleStoneInPit implements GameRule { + public static final String GAME_STEP_DOUBLE_STONE_IN_PIT = "GAME_STEP_DOUBLE_STONE_IN_PIT"; + + public record DoubleStoneInPitData(int pitIndex, int bankIndex) { + } + + @Override + public void onGameMoveEnd(MancalaGame game, int index) { + Board board = game.getBoard(); + index = board.getPitIndexCircularly(index); + Pit pit = board.getPits().get(index); + PitType pitType = board.getPitTypeByIndex(index); + if (pit.getStoneCount() % 2 != 0) return; + if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer2()) { + capture(game, index, board.player2Bank(), board.player2BankIndex()); + } else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer1()) { + capture(game, index, board.player1Bank(), board.player1BankIndex()); + } + } + + private void capture(MancalaGame game, int pitIndex, Pit bank, int bankIndex) { + Pit pit = game.getBoard().getPitCircularly(pitIndex); + bank.addStones(pit.getStoneCount()); + pit.setStoneCount(0); + game.addGameStep(new GameStep(pitIndex, GAME_STEP_DOUBLE_STONE_IN_PIT, new DoubleStoneInPitData(pitIndex, bankIndex))); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java new file mode 100644 index 0000000..85c3b7f --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java @@ -0,0 +1,23 @@ +package app.playmancala.engine.rules; + +import app.playmancala.engine.GameRule; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.PitType; + +// When the last stone lands in the mover's bank, the mover plays again; otherwise the turn passes. +public class GRLastStoneInBank implements GameRule { + public static final String GAME_STEP_LAST_STONE_IN_BANK = "GAME_STEP_LAST_STONE_IN_BANK"; + + @Override + public void onGameMoveEnd(MancalaGame game, int index) { + index = game.getBoard().getPitIndexCircularly(index); + PitType pitType = game.getBoard().getPitTypeByIndex(index); + if ((pitType == PitType.PLAYER1_BANK && game.isTurnPlayer1()) + || (pitType == PitType.PLAYER2_BANK && game.isTurnPlayer2())) { + game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_BANK)); + } else { + game.changePlayerTurn(); + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java new file mode 100644 index 0000000..5dd4183 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java @@ -0,0 +1,40 @@ +package app.playmancala.engine.rules; + +import app.playmancala.engine.Board; +import app.playmancala.engine.GameRule; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.Pit; +import app.playmancala.engine.PitType; + +// When the last stone lands in an empty pit of the mover's own, the mover takes it and the stones opposite. +public class GRLastStoneInEmptyPit implements GameRule { + public static final String GAME_STEP_LAST_STONE_IN_EMPTY_PIT = "GAME_STEP_LAST_STONE_IN_EMPTY_PIT"; + + public record LastStoneInEmptyPitData(int oppositeIndex) { + } + + @Override + public void onGameMoveEnd(MancalaGame game, int index) { + Board board = game.getBoard(); + index = board.getPitIndexCircularly(index); + Pit pit = board.getPits().get(index); + PitType pitType = board.getPitTypeByIndex(index); + if (pit.getStoneCount() != 1) return; + if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer1()) { + capture(game, index, pit, board.player1Bank()); + } else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer2()) { + capture(game, index, pit, board.player2Bank()); + } + } + + private void capture(MancalaGame game, int index, Pit pit, Pit bank) { + int oppositeIndex = game.getBoard().getOppositePitIndex(index); + Pit oppositePit = game.getBoard().getPits().get(oppositeIndex); + if (oppositePit.getStoneCount() <= 0) return; + bank.addStones(1 + oppositePit.getStoneCount()); + oppositePit.setStoneCount(0); + pit.setStoneCount(0); + game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_EMPTY_PIT, new LastStoneInEmptyPitData(oppositeIndex))); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java new file mode 100644 index 0000000..993c106 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java @@ -0,0 +1,79 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import app.playmancala.bot.BotSettings; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.PlayerSummary; +import app.playmancala.model.Seat; +import app.playmancala.model.Series; + +// A live game and the server-side state around it. Only touched on the game loop. +public class BackendGame { + public final String id; + public final MancalaGame mancalaGame; + public GameUsersConnectionInfo gameUsersConnectionInfo; + // Users watching right now. + public final List spectatorIds = new ArrayList<>(); + // Everyone who watched at some point, and the most at once. + public final List allSpectatorIds; + public int peakSpectators; + // Keyed by user id, captured when the game is created. + public final Map players; + public final long startedAt; + // Time of each move, aligned with mancalaGame.history. + public final List moveTimes; + public GameClock clock; + // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. + public final List moveRemainingMs; + // How the bot plays in a game against the bot; null in games between people. + public final BotSettings bot; + // The games the same players played before this one (docs/specs/rematch). + public final Series series; + + public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, + List allSpectatorIds, int peakSpectators, Map players, long startedAt, + List moveTimes, GameClock clock, List moveRemainingMs, BotSettings bot, Series series) { + this.id = mancalaGame.getId(); + this.mancalaGame = mancalaGame; + this.gameUsersConnectionInfo = gameUsersConnectionInfo; + this.allSpectatorIds = new ArrayList<>(allSpectatorIds); + this.peakSpectators = peakSpectators; + this.players = Map.copyOf(players); + this.startedAt = startedAt; + this.moveTimes = new ArrayList<>(moveTimes); + this.clock = clock; + this.moveRemainingMs = new ArrayList<>(moveRemainingMs); + this.bot = bot; + this.series = series; + } + + // The seat that moved first: the first move's player, or the player to move before any move. + public Seat firstSeat() { + List history = mancalaGame.getHistory(); + String firstPlayerId = history.isEmpty() ? mancalaGame.getTurnPlayerId() : history.getFirst().getPlayerId(); + return mancalaGame.getPlayer1Id().equals(firstPlayerId) ? Seat.PLAYER1 : Seat.PLAYER2; + } + + public boolean isBotTurn() { + return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId()); + } + + public PlayerSummary player(String userId) { + PlayerSummary player = players.get(userId); + return player != null ? player : PlayerSummary.unknown(userId); + } + + public boolean isPlayer(String userId) { + return mancalaGame.getPlayer1Id().equals(userId) || mancalaGame.getPlayer2Id().equals(userId); + } + + public String opponentOf(String userId) { + return userId.equals(mancalaGame.getPlayer1Id()) ? mancalaGame.getPlayer2Id() : mancalaGame.getPlayer1Id(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/CrashLog.java b/apps/backend/src/main/java/app/playmancala/game/CrashLog.java new file mode 100644 index 0000000..6c2c1c0 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/CrashLog.java @@ -0,0 +1,50 @@ +package app.playmancala.game; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jboss.logging.Logger; + +import app.playmancala.model.Json; + +// Writes a file to ./crashes (or the folder in the mancala.crash-dir system property) for each game that failed while a move was played, with the error and the game. +public final class CrashLog { + private static final Logger LOG = Logger.getLogger(CrashLog.class); + private static final Path CRASH_FOLDER = Path.of(System.getProperty("mancala.crash-dir", "crashes")); + + private CrashLog() { + } + + // The JSON to write; call it on the game loop, while the game cannot change. + public static String describe(Throwable error, BackendGame game) { + StringWriter stack = new StringWriter(); + error.printStackTrace(new PrintWriter(stack)); + Map crash = new LinkedHashMap<>(); + crash.put("message", error.getMessage()); + crash.put("stack", stack.toString()); + crash.put("game", game); + return Json.write(crash); + } + + // Returns the file name, e.g. "::2026-10-16-12:5:3:42" as the Node backend named them. + public static String fileName(String gameId) { + LocalDateTime now = LocalDateTime.now(); + return String.format("%s::%d-%02d-%02d-%d:%d:%d:%d", gameId, now.getYear(), now.getMonthValue(), + now.getDayOfMonth(), now.getHour(), now.getMinute(), now.getSecond(), now.getNano() / 1_000_000); + } + + public static void write(String fileName, String crash) { + try { + Files.createDirectories(CRASH_FOLDER); + Files.writeString(CRASH_FOLDER.resolve(fileName), crash); + } catch (IOException e) { + LOG.errorf("(CrashLog) Could not write %s: %s", fileName, e.getMessage()); + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/GameClockScheduler.java b/apps/backend/src/main/java/app/playmancala/game/GameClockScheduler.java new file mode 100644 index 0000000..6747859 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/GameClockScheduler.java @@ -0,0 +1,32 @@ +package app.playmancala.game; + +import app.playmancala.loop.Cancellable; +import app.playmancala.loop.Scheduler; + +// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected +// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs +// only decides how soon the end is announced, never who wins. +public class GameClockScheduler { + public static final long CLOCK_CHECK_INTERVAL_MS = 1_000; + + private final Runnable endGamesOutOfTime; + private final Scheduler scheduler; + private final long intervalMs; + private Cancellable timer; + + public GameClockScheduler(Runnable endGamesOutOfTime, Scheduler scheduler, long intervalMs) { + this.endGamesOutOfTime = endGamesOutOfTime; + this.scheduler = scheduler; + this.intervalMs = intervalMs; + } + + public void start() { + if (timer != null) return; + timer = scheduler.scheduleAtFixedRate(endGamesOutOfTime, intervalMs); + } + + public void stop() { + if (timer != null) timer.cancel(); + timer = null; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/GameClocks.java b/apps/backend/src/main/java/app/playmancala/game/GameClocks.java new file mode 100644 index 0000000..b617989 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/GameClocks.java @@ -0,0 +1,61 @@ +package app.playmancala.game; + +import app.playmancala.model.GameClock; +import app.playmancala.model.Seat; + +// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn +// plus when that turn started, so it only changes on moves; the time left at any moment is computed. +public final class GameClocks { + private GameClocks() { + } + + public record ClockTimes(long player1Ms, long player2Ms) { + } + + public record ChargedMove(GameClock clock, long remainingMs) { + } + + // The seat of the player, or null if they do not play in the game. + public static Seat getSeat(String player1Id, String player2Id, String playerId) { + if (playerId.equals(player1Id)) return Seat.PLAYER1; + if (playerId.equals(player2Id)) return Seat.PLAYER2; + return null; + } + + public static GameClock start(long initialMs, long now) { + return new GameClock(initialMs, initialMs, initialMs, now); + } + + // Both players' time at `now`; never below zero. Port of getClockTimes in packages/core. + public static ClockTimes getClockTimes(GameClock clock, Seat runningSeat, long now) { + long elapsed = runningSeat != null && clock.turnStartedAt() != null ? Math.max(0, now - clock.turnStartedAt()) : 0; + return new ClockTimes( + Math.max(0, clock.player1Ms() - (runningSeat == Seat.PLAYER1 ? elapsed : 0)), + Math.max(0, clock.player2Ms() - (runningSeat == Seat.PLAYER2 ? elapsed : 0))); + } + + public static long getRemainingMs(GameClock clock, Seat seat, Seat runningSeat, long now) { + ClockTimes times = getClockTimes(clock, runningSeat, now); + return seat == Seat.PLAYER1 ? times.player1Ms() : times.player2Ms(); + } + + // Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn. + public static ChargedMove chargeMove(GameClock clock, Seat moverSeat, long now) { + long remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now); + GameClock charged = moverSeat == Seat.PLAYER1 + ? new GameClock(clock.initialMs(), remainingMs, clock.player2Ms(), now) + : new GameClock(clock.initialMs(), clock.player1Ms(), remainingMs, now); + return new ChargedMove(charged, remainingMs); + } + + // Deducts the running seat's time, if any, and stops the clock. + public static GameClock stop(GameClock clock, Seat runningSeat, long now) { + ClockTimes times = getClockTimes(clock, runningSeat, now); + return new GameClock(clock.initialMs(), times.player1Ms(), times.player2Ms(), null); + } + + // Starts the running clock again from the time it had at the start of the turn, e.g. after a restart. + public static GameClock resume(GameClock clock, long now) { + return new GameClock(clock.initialMs(), clock.player1Ms(), clock.player2Ms(), now); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java new file mode 100644 index 0000000..1c2211a --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/GameManager.java @@ -0,0 +1,450 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.IntSupplier; +import java.util.function.LongSupplier; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; +import app.playmancala.engine.Board; +import app.playmancala.engine.CommonMancalaGame; +import app.playmancala.engine.GameState; +import app.playmancala.engine.MancalaGame; +import app.playmancala.game.GameSerialization.GameEnding; +import app.playmancala.game.GameSerialization.LiveGameSnapshot; +import app.playmancala.live.LiveGameStore; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.ClientGame; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameEndMessage; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.PlayerSummary; +import app.playmancala.model.Seat; +import app.playmancala.model.Series; +import app.playmancala.model.SeriesGame; +import app.playmancala.model.UserConnectionInfo; +import app.playmancala.rtmt.Rtmt; + +// Runs live games: matchmaking, moves, the clock, spectators and every way a game ends. Only touched on the game loop. +// Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop. +public class GameManager { + private static final Logger LOG = Logger.getLogger(GameManager.class); + // Clients show the bot by its id ("Bot · Medium"); the name is for anything that only shows names. + static final String BOT_NAME = "Bot"; + + private final GameStore gameStore; + private final Rtmt rtmt; + private final MatchMaker matchMaker; + private final LiveGameStore liveGameStore; + private final GameRecorder recorder; + private final Executor storeExecutor; + private final LongSupplier clock; + // Time each player has for a new game. + private final long gameClockMs; + private final List liveGamesListeners = new ArrayList<>(); + private final List> turnListeners = new ArrayList<>(); + private final List> gameEndedListeners = new ArrayList<>(); + private final List> searchingListeners = new ArrayList<>(); + + public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, + GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { + this.gameStore = gameStore; + this.rtmt = rtmt; + this.matchMaker = matchMaker; + this.liveGameStore = liveGameStore; + this.recorder = recorder; + this.storeExecutor = storeExecutor; + this.clock = clock; + this.gameClockMs = gameClockMs; + matchMaker.listenOnPlayersPaired((player1Id, player2Id) -> startGame(createMancalaGame(player1Id, player2Id))); + listenRtmtMessages(); + listenUserConnectionChange(); + } + + // Called whenever a live game starts, changes or ends. + public void onLiveGamesChanged(Runnable listener) { + liveGamesListeners.add(listener); + } + + // Called whenever a player is to move: when a game starts or is restored, and after a move that did not end it, + // including one that gives an extra turn. + public void onTurnStarted(Consumer listener) { + turnListeners.add(listener); + } + + // Called when a game has ended, for any reason but a crash, with what the players were sent. + public void onGameEnded(BiConsumer listener) { + gameEndedListeners.add(listener); + } + + // Called with a user who asks for a new game or a bot game, before it is handled. + public void onPlayerSearching(Consumer listener) { + searchingListeners.add(listener); + } + + private void listenRtmtMessages() { + rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { + searchingListeners.forEach(listener -> listener.accept(userId)); + BackendGame activeGame = gameStore.getGameByUser(userId); + if (activeGame != null) { + rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id)); + return; + } + matchMaker.join(userId); + }); + rtmt.listenMessage(Channels.NEW_BOT_GAME, this::onNewBotGame); + rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId)); + rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove); + rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId)); + rtmt.listenMessage(Channels.LISTEN_GAME_EVENTS, (userId, message) -> { + if (message.isTextual()) addSpectator(userId, message.asText()); + }); + rtmt.listenMessage(Channels.UNLISTEN_GAME_EVENTS, (userId, message) -> { + if (message.isTextual()) removeSpectator(userId, message.asText()); + }); + } + + private void onNewBotGame(String userId, JsonNode message) { + BotLevel level = BotLevel.fromWireName(message.path("level").asText(null)); + if (level == null) { + LOG.debugf("(GameManager) Unknown bot level: %s", message); + return; + } + searchingListeners.forEach(listener -> listener.accept(userId)); + BackendGame activeGame = gameStore.getGameByUser(userId); + if (activeGame != null) { + rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id)); + return; + } + matchMaker.cancel(userId); + BotSettings bot = BotSettings.draw(level, ThreadLocalRandom.current()); + startGame(createMancalaGame(userId, bot.botId(), bot)); + } + + private void onGameMove(String userId, JsonNode gameMove) { + BackendGame game = gameStore.getGameByUser(userId); + if (game == null) { + LOG.debug("Game not found!"); + return; + } + playMove(game, userId, () -> { + JsonNode index = gameMove.path("index"); + if (!index.canConvertToInt() || !index.isIntegralNumber()) { + throw new IllegalStateException("Invalid move: " + gameMove); + } + return index.asInt(); + }); + } + + // Plays a player's pit (0-5) in a live game; the bot's moves come here too. + public void playMove(BackendGame game, String userId, int pitIndex) { + playMove(game, userId, () -> pitIndex); + } + + // The pit is read after the clock check, so an invalid move after the time has run out still ends the game on time. + private void playMove(BackendGame game, String userId, IntSupplier pitIndex) { + MancalaGame mancalaGame = game.mancalaGame; + try { + long now = clock.getAsLong(); + // A move that arrives after the mover's time has run out is not played. + if (userId.equals(mancalaGame.getTurnPlayerId()) && isOutOfTime(game, now)) { + endGameOnTime(game); + return; + } + int index = pitIndex.getAsInt(); + int movesBefore = mancalaGame.getHistory().size(); + Seat moverSeat = seatOf(mancalaGame, userId); + mancalaGame.moveByPlayerPit(userId, index); + if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { + game.moveTimes.add(now); + GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); + game.clock = charged.clock(); + game.moveRemainingMs.add(charged.remainingMs()); + } + game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game); + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_UPDATE, toClientGame(game)); + if (mancalaGame.getState() == GameState.ENDED) { + endGame(game, new GameEnding(EndReason.COMPLETED, mancalaGame.getWonPlayerId(), null)); + } else { + saveLiveGame(game); + notifyLiveGamesChanged(); + notifyTurnStarted(game); + } + } catch (RuntimeException e) { + onGameError(game, e); + } + } + + private void onGameError(BackendGame game, RuntimeException error) { + LOG.error("(GameManager) Game " + game.id + " failed", error); + String crash = CrashLog.describe(error, game); + String fileName = CrashLog.fileName(game.id); + storeExecutor.execute(() -> CrashLog.write(fileName, crash)); + LOG.infof("Game crash saved to file : %s", fileName); + // The Node backend sent the Error object, which serialises to {}; clients only use the channel. + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_CRASHED, Map.of()); + } + + private void onPlayerLeave(String userId) { + BackendGame game = gameStore.getGameByUser(userId); + if (game != null) { + endGame(game, new GameEnding(EndReason.LEFT, game.opponentOf(userId), userId)); + } + } + + // Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it. + private void endGame(BackendGame game, GameEnding ending) { + if (gameStore.get(game.id) != game) return; + deleteGame(game); + game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong()); + GameResult result = GameSerialization.getResult(game, ending); + Board board = game.mancalaGame.getBoard(); + int[] stones = board.getStoneArray(); + Series series = game.series.next(new SeriesGame(game.id, game.firstSeat(), result, ending.endReason(), + stones[board.player1BankIndex()], stones[board.player2BankIndex()])); + GameEndMessage endMessage = new GameEndMessage(game.id, result, ending.endReason(), game.clock, + ending.winnerId(), ending.leftUserId(), series, + ending.leftUserId() == null ? GameEndMessage.REMATCH_AVAILABLE : null); + if (ending.leftUserId() != null) { + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId()); + } + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_END, endMessage); + game.spectatorIds.clear(); + + GameRecord record = GameSerialization.toGameRecord(game, ending, clock.getAsLong()); + inStore(() -> liveGameStore.removeGame(game.id)); + recorder.save(record); + notifyLiveGamesChanged(); + gameEndedListeners.forEach(listener -> listener.accept(game, endMessage)); + } + + // The player whose turn it is ran out of time: the opponent wins. + private void endGameOnTime(BackendGame game) { + endGame(game, new GameEnding(EndReason.TIMEOUT, game.opponentOf(game.mancalaGame.getTurnPlayerId()), null)); + } + + private boolean isOutOfTime(BackendGame game, long now) { + Seat runningSeat = getRunningSeat(game); + return runningSeat != null && GameClocks.getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0; + } + + // The seat whose clock runs; null once the game has ended. + private Seat getRunningSeat(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + if (mancalaGame.getState() == GameState.ENDED || game.clock.turnStartedAt() == null) return null; + return seatOf(mancalaGame, mancalaGame.getTurnPlayerId()); + } + + private static Seat seatOf(MancalaGame mancalaGame, String playerId) { + return GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), playerId); + } + + // Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or + // not the players are online, so this also ends games that both players have left. + public void endGamesOutOfTime() { + long now = clock.getAsLong(); + for (BackendGame game : gameStore.getAll()) { + try { + if (isOutOfTime(game, now)) endGameOnTime(game); + } catch (RuntimeException e) { + LOG.error("(GameManager) Could not end game " + game.id + " on time", e); + } + } + } + + // Puts the live games saved in Valkey back into memory, e.g. after a restart or deploy. Blocks on Valkey and the + // database; runs once at startup. + public int restoreLiveGames() { + List stored; + try { + stored = liveGameStore.loadGames(); + } catch (RuntimeException e) { + LOG.errorf("(GameManager) Could not load live games: %s", e.getMessage()); + return 0; + } + int restored = 0; + for (JsonNode json : stored) { + LiveGameSnapshot snapshot = GameSerialization.parseSnapshot(json); + if (snapshot == null || gameStore.get(snapshot.id()) != null) continue; + // A game that ended but whose snapshot could not be removed must not come back. + if (isRecorded(snapshot.id())) { + inStore(() -> liveGameStore.removeGame(snapshot.id())); + continue; + } + BackendGame game = GameSerialization.fromSnapshot(snapshot, clock.getAsLong(), gameClockMs); + gameStore.set(game.id, game); + gameStore.setGameByUser(game.mancalaGame.getPlayer1Id(), game); + gameStore.setGameByUser(game.mancalaGame.getPlayer2Id(), game); + game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game); + restored++; + } + if (restored > 0) { + LOG.infof("(GameManager) Restored %d live game(s)", restored); + notifyLiveGamesChanged(); + gameStore.getAll().forEach(this::notifyTurnStarted); + } + return restored; + } + + private boolean isRecorded(String gameId) { + try { + return recorder.isRecorded(gameId); + } catch (RuntimeException e) { + return false; + } + } + + private void addSpectator(String userId, String gameId) { + BackendGame game = gameStore.get(gameId); + if (game == null || game.isPlayer(userId) || game.spectatorIds.contains(userId)) return; + game.spectatorIds.add(userId); + if (!game.allSpectatorIds.contains(userId)) game.allSpectatorIds.add(userId); + game.peakSpectators = Math.max(game.peakSpectators, game.spectatorIds.size()); + saveLiveGame(game); + notifyLiveGamesChanged(); + } + + private void removeSpectator(String userId, String gameId) { + BackendGame game = gameStore.get(gameId); + if (game != null && game.spectatorIds.remove(userId)) { + notifyLiveGamesChanged(); + } + } + + private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { + sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message); + List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message)); + } + + // The bot has no connection to send to. + private void sendMessageToPlayer(String playerId, String channel, Object message) { + if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, channel, message); + } + + public ClientGame toClientGame(BackendGame game) { + return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, + List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong(), game.series, + game.firstSeat()); + } + + public BackendGame createMancalaGame(String userId1, String userId2) { + return createMancalaGame(userId1, userId2, null); + } + + // `bot` is set for a game against the bot, which plays as one of the two users. + public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) { + return createMancalaGame(userId1, userId2, bot, Series.start(), null); + } + + // A game in `series`; `firstSeat` moves first, or a player drawn at random when it is null. + public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot, Series series, Seat firstSeat) { + MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); + boolean player1First = firstSeat != null ? firstSeat == Seat.PLAYER1 : ThreadLocalRandom.current().nextBoolean(); + mancalaGame.setTurnPlayerId(player1First ? userId1 : userId2); + long now = clock.getAsLong(); + PlayerSummary player1 = getPlayerInfo(userId1); + PlayerSummary player2 = getPlayerInfo(userId2); + Map players = new LinkedHashMap<>(); + players.put(userId1, player1); + players.put(userId2, player2); + BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0, + players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot, series); + gameStore.set(game.id, game); + gameStore.setGameByUser(userId1, game); + gameStore.setGameByUser(userId2, game); + return game; + } + + private PlayerSummary getPlayerInfo(String userId) { + if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false); + AuthUser user = rtmt.getUser(userId); + return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest()); + } + + public GameUsersConnectionInfo getGameUsersConnectionInfo(BackendGame game) { + return getGameUsersConnectionInfo(game.player(game.mancalaGame.getPlayer1Id()), + game.player(game.mancalaGame.getPlayer2Id())); + } + + private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) { + return new GameUsersConnectionInfo( + UserConnectionInfo.of(player1, isOnline(player1.userId())), + UserConnectionInfo.of(player2, isOnline(player2.userId()))); + } + + // The bot is always there. + private boolean isOnline(String userId) { + return BotLevel.isBotId(userId) || rtmt.isClientOnline(userId); + } + + public void startGame(BackendGame game) { + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_START, toClientGame(game)); + sendUserConnectionInfo(game); + saveLiveGame(game); + notifyLiveGamesChanged(); + notifyTurnStarted(game); + } + + public void sendUserConnectionInfo(BackendGame game) { + game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game); + sendMessageToPlayersAndSpectators(game, Channels.ON_USER_CONNECTION_CHANGE, game.gameUsersConnectionInfo); + } + + public void deleteGame(BackendGame game) { + gameStore.removeGameByPlayer(game.mancalaGame.getPlayer1Id()); + gameStore.removeGameByPlayer(game.mancalaGame.getPlayer2Id()); + gameStore.remove(game.id); + } + + private void saveLiveGame(BackendGame game) { + LiveGameSnapshot snapshot = GameSerialization.toSnapshot(game); + inStore(() -> liveGameStore.saveGame(game.id, snapshot)); + } + + // Runs a Valkey write off the loop; a failure is only logged, the game goes on from memory. + private void inStore(Runnable write) { + storeExecutor.execute(() -> { + try { + write.run(); + } catch (RuntimeException e) { + LOG.errorf("(GameManager) Live game store: %s", e.getMessage()); + } + }); + } + + private void notifyLiveGamesChanged() { + liveGamesListeners.forEach(Runnable::run); + } + + private void notifyTurnStarted(BackendGame game) { + turnListeners.forEach(listener -> listener.accept(game)); + } + + private void listenUserConnectionChange() { + rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { + BackendGame game = gameStore.getGameByUser(clientId); + if (game != null) sendUserConnectionInfo(game); + if (!isOnline) { + gameStore.getSpectatingGameIdsByUser(clientId).forEach(gameId -> removeSpectator(clientId, gameId)); + } else if (game != null) { + rtmt.sendMessage(clientId, Channels.ON_GAME_UPDATE, toClientGame(game)); + } + }); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/GameRecorder.java b/apps/backend/src/main/java/app/playmancala/game/GameRecorder.java new file mode 100644 index 0000000..4a9f3b1 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/GameRecorder.java @@ -0,0 +1,109 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import org.jboss.logging.Logger; + +import app.playmancala.history.GameHistoryRepository; +import app.playmancala.live.LiveGameStore; +import app.playmancala.model.GameRecord; + +// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is +// down too) and saved later, so a game is never lost because a dependency was briefly unavailable. Saves run on the +// given executor; the lists of unsaved games are read from any thread. +public class GameRecorder { + public static final long RETRY_INTERVAL_MS = 30_000; + private static final Logger LOG = Logger.getLogger(GameRecorder.class); + + private final GameHistoryRepository repository; + private final LiveGameStore liveGameStore; + private final Executor io; + // Being saved right now. + private final Map saving = new ConcurrentHashMap<>(); + // Failed to save; retried later. + private final Map pending = new ConcurrentHashMap<>(); + private final List> savedListeners = new CopyOnWriteArrayList<>(); + private final AtomicBoolean retrying = new AtomicBoolean(); + + public GameRecorder(GameHistoryRepository repository, LiveGameStore liveGameStore, Executor io) { + this.repository = repository; + this.liveGameStore = liveGameStore; + this.io = io; + } + + public void onSaved(Consumer listener) { + savedListeners.add(listener); + } + + public void save(GameRecord record) { + saving.put(record.id(), record); + io.execute(() -> { + try { + repository.save(record); + saving.remove(record.id()); + notifySaved(record); + } catch (RuntimeException e) { + LOG.errorf("(GameRecorder) Could not save game %s, will retry: %s", record.id(), e.getMessage()); + pending.put(record.id(), record); + saving.remove(record.id()); + try { + liveGameStore.savePendingRecord(record); + } catch (RuntimeException ignored) { + // Kept in memory. + } + } + }); + } + + // Finished games that are not in the database yet, so readers can include them. + public List getUnsaved() { + List unsaved = new ArrayList<>(saving.values()); + unsaved.addAll(pending.values()); + return unsaved; + } + + // True if the game is saved or waiting to be saved. Throws if the database cannot be asked. + public boolean isRecorded(String gameId) { + return pending.containsKey(gameId) || repository.get(gameId) != null; + } + + // Saves the records that failed before, including those a previous run left in Valkey. Blocks. + public void retryPending() { + if (!retrying.compareAndSet(false, true)) return; + try { + try { + liveGameStore.loadPendingRecords().forEach(record -> pending.put(record.id(), record)); + } catch (RuntimeException ignored) { + // Valkey is down; retry what is in memory. + } + for (GameRecord record : List.copyOf(pending.values())) { + try { + repository.save(record); + } catch (RuntimeException e) { + return; // still unreachable; try again later + } + pending.remove(record.id()); + try { + liveGameStore.removePendingRecord(record.id()); + } catch (RuntimeException ignored) { + // Saving it again later is a no-op. + } + LOG.infof("(GameRecorder) Saved game %s after retrying", record.id()); + notifySaved(record); + } + } finally { + retrying.set(false); + } + } + + private void notifySaved(GameRecord record) { + savedListeners.forEach(listener -> listener.accept(record)); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java new file mode 100644 index 0000000..1b1f13a --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -0,0 +1,176 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; +import app.playmancala.engine.CommonMancalaGame; +import app.playmancala.engine.GameState; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; +import app.playmancala.engine.Pit; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.Json; +import app.playmancala.model.MoveRecord; +import app.playmancala.model.PlayerSummary; +import app.playmancala.model.Series; +import app.playmancala.model.UserConnectionInfo; + +// Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend +// can restore what the other saved. +public final class GameSerialization { + private static final Logger LOG = Logger.getLogger(GameSerialization.class); + private static final int BOARD_SIZE = 14; + + private GameSerialization() { + } + + // What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock. Built on + // the game loop from copies, so it can be serialised on another thread. + @JsonInclude(JsonInclude.Include.NON_NULL) + public record LiveGameSnapshot( + int version, + String id, + String player1Id, + String player2Id, + String turnPlayerId, + GameState state, + List board, + List history, + Map players, + long startedAt, + List moveTimes, + int peakSpectators, + List allSpectatorIds, + GameClock clock, + @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs, + // Games against the bot only. + BotSettings bot, + // Missing in snapshots from before rematches. + Series series) { + } + + public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { + // Without a winner. + public static GameEnding of(EndReason endReason) { + return new GameEnding(endReason, null, null); + } + } + + public static LiveGameSnapshot toSnapshot(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + return new LiveGameSnapshot(2, game.id, mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), + mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()), + List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt, + List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock, + Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot, game.series); + } + + // The snapshot in the JSON, or null if it is not one this backend can restore. + public static LiveGameSnapshot parseSnapshot(JsonNode json) { + if (!isLiveGameSnapshot(json)) return null; + try { + return Json.MAPPER.treeToValue(json, LiveGameSnapshot.class); + } catch (Exception e) { + LOG.errorf("(GameSerialization) Ignoring unreadable snapshot %s: %s", json.path("id").asText(), e.getMessage()); + return null; + } + } + + private static boolean isLiveGameSnapshot(JsonNode json) { + if (json == null || !json.isObject()) return false; + boolean valid = json.path("id").isTextual() && json.path("board").isArray() && json.path("board").size() == BOARD_SIZE + && json.path("history").isArray() && json.path("players").isObject(); + int version = json.path("version").asInt(0); + if (version == 1) return valid; + return version == 2 && valid && json.path("clock").path("initialMs").isNumber() + && json.path("moveRemainingMs").isArray(); + } + + // Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on + // from the start of the turn, so the time the backend was down is not charged; games from before the game clock + // get a new clock. + public static BackendGame fromSnapshot(LiveGameSnapshot snapshot, long now, long gameClockMs) { + MancalaGame mancalaGame = new CommonMancalaGame(snapshot.id(), snapshot.player1Id(), snapshot.player2Id()); + List pits = mancalaGame.getBoard().getPits(); + for (int index = 0; index < pits.size(); index++) pits.get(index).setStoneCount(snapshot.board().get(index)); + mancalaGame.setTurnPlayerId(snapshot.turnPlayerId()); + mancalaGame.setState(snapshot.state()); + mancalaGame.getHistory().addAll(snapshot.history()); + Map players = snapshot.players(); + PlayerSummary player1 = players.getOrDefault(snapshot.player1Id(), PlayerSummary.unknown(snapshot.player1Id())); + PlayerSummary player2 = players.getOrDefault(snapshot.player2Id(), PlayerSummary.unknown(snapshot.player2Id())); + GameClock clock = snapshot.clock() != null ? GameClocks.resume(snapshot.clock(), now) : GameClocks.start(gameClockMs, now); + List moveRemainingMs = snapshot.moveRemainingMs() != null ? snapshot.moveRemainingMs() + : Collections.nCopies(snapshot.history().size(), null); + return new BackendGame(mancalaGame, + new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)), + orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(), + orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot), + snapshot.series() != null ? snapshot.series() : Series.start()); + } + + // The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at + // the middle of its level. + private static BotSettings restoreBot(LiveGameSnapshot snapshot) { + if (snapshot.bot() != null) return snapshot.bot(); + BotLevel level = BotLevel.fromBotId(snapshot.player2Id()); + if (level == null) level = BotLevel.fromBotId(snapshot.player1Id()); + return level == null ? null : new BotSettings(level, level.middleStrength(), ThreadLocalRandom.current().nextLong()); + } + + // Null when the game ended without a winner and was not completed, e.g. both players left. + public static GameResult getResult(BackendGame game, GameEnding ending) { + if (game.mancalaGame.getPlayer1Id().equals(ending.winnerId())) return GameResult.PLAYER1; + if (game.mancalaGame.getPlayer2Id().equals(ending.winnerId())) return GameResult.PLAYER2; + return ending.endReason() == EndReason.COMPLETED ? GameResult.DRAW : null; + } + + // The record saved for a finished game; every id in it is a public id. + public static GameRecord toGameRecord(BackendGame game, GameEnding ending, long endedAt) { + MancalaGame mancalaGame = game.mancalaGame; + List history = mancalaGame.getHistory(); + List moves = new ArrayList<>(history.size()); + for (int i = 0; i < history.size(); i++) { + MoveHistoryItem entry = history.get(i); + moves.add(new MoveRecord(PublicIds.publicUserId(entry.getPlayerId()), entry.getMoveIndex(), + boxed(entry.getBoardSnapshot()), List.copyOf(entry.getGameSteps()), + i < game.moveTimes.size() ? game.moveTimes.get(i) : endedAt, + i < game.moveRemainingMs.size() ? game.moveRemainingMs.get(i) : null)); + } + return new GameRecord(game.id, publicSummary(game, mancalaGame.getPlayer1Id()), + publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()), + getResult(game, ending), ending.endReason(), + ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt, + moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size(), game.bot, + game.series.id()); + } + + // The player with a public id, for anything saved or listed. + static PlayerSummary publicSummary(BackendGame game, String userId) { + PlayerSummary player = game.player(userId); + return new PlayerSummary(PublicIds.publicUserId(userId), player.name(), player.isGuest()); + } + + private static List orEmpty(List list) { + return list != null ? list : List.of(); + } + + static List boxed(int[] values) { + return Arrays.stream(values).boxed().toList(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/GameStore.java b/apps/backend/src/main/java/app/playmancala/game/GameStore.java new file mode 100644 index 0000000..e957e50 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/GameStore.java @@ -0,0 +1,52 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import app.playmancala.bot.BotLevel; + +// Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop. +public class GameStore { + // In the order games started. + private final Map games = new LinkedHashMap<>(); + private final Map gameIdsByUser = new HashMap<>(); + + public BackendGame get(String id) { + return games.get(id); + } + + public List getAll() { + return new ArrayList<>(games.values()); + } + + public void set(String id, BackendGame game) { + games.put(id, game); + } + + public void remove(String id) { + games.remove(id); + } + + // Bots play many games at once, so they are not mapped to one. + public void setGameByUser(String userId, BackendGame game) { + if (BotLevel.isBotId(userId)) return; + gameIdsByUser.put(userId, game.id); + } + + // The game the user plays in, or null. + public BackendGame getGameByUser(String userId) { + String gameId = gameIdsByUser.get(userId); + return gameId == null ? null : games.get(gameId); + } + + public void removeGameByPlayer(String userId) { + gameIdsByUser.remove(userId); + } + + public List getSpectatingGameIdsByUser(String userId) { + return games.values().stream().filter(game -> game.spectatorIds.contains(userId)).map(game -> game.id).toList(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/LiveGames.java b/apps/backend/src/main/java/app/playmancala/game/LiveGames.java new file mode 100644 index 0000000..440f886 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/LiveGames.java @@ -0,0 +1,93 @@ +package app.playmancala.game; + +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import app.playmancala.loop.Cancellable; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.Channels; +import app.playmancala.model.Json; +import app.playmancala.model.LiveGameSummary; +import app.playmancala.rtmt.Rtmt; + +// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games +// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a +// spectator. Only touched on the game loop. +public class LiveGames { + public static final int LIVE_GAMES_PUSH_COUNT = 5; + private static final long PUSH_INTERVAL_MS = 1_000; + + private final Rtmt rtmt; + private final GameStore gameStore; + private final Scheduler scheduler; + private final Set subscribers = new LinkedHashSet<>(); + private String lastPushed = ""; + private long lastPushAt; + private Cancellable pushTimer; + + public LiveGames(Rtmt rtmt, GameStore gameStore, GameManager gameManager, Scheduler scheduler) { + this.rtmt = rtmt; + this.gameStore = gameStore; + this.scheduler = scheduler; + rtmt.listenMessage(Channels.SUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribe(userId)); + rtmt.listenMessage(Channels.UNSUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribers.remove(userId)); + rtmt.listenOnClientConnectionChange((userId, isOnline) -> { + if (!isOnline) subscribers.remove(userId); + }); + gameManager.onLiveGamesChanged(this::schedulePush); + } + + public static LiveGameSummary toLiveGameSummary(BackendGame game) { + var mancalaGame = game.mancalaGame; + return new LiveGameSummary(game.id, + GameSerialization.publicSummary(game, mancalaGame.getPlayer1Id()), + GameSerialization.publicSummary(game, mancalaGame.getPlayer2Id()), + GameSerialization.boxed(mancalaGame.getBoard().getStoneArray()), + PublicIds.publicUserId(mancalaGame.getTurnPlayerId()), game.spectatorIds.size(), + mancalaGame.getHistory().size(), game.startedAt); + } + + private static long lastActivity(BackendGame game) { + return game.moveTimes.isEmpty() ? game.startedAt : game.moveTimes.getLast(); + } + + public List getTop(int limit) { + return gameStore.getAll().stream() + .sorted(Comparator.comparingInt(game -> game.spectatorIds.size()) + .thenComparingLong(LiveGames::lastActivity).reversed()) + .limit(limit) + .map(LiveGames::toLiveGameSummary) + .toList(); + } + + public void dispose() { + if (pushTimer != null) pushTimer.cancel(); + } + + private void subscribe(String userId) { + subscribers.add(userId); + rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, Map.of("games", getTop(LIVE_GAMES_PUSH_COUNT))); + } + + private void schedulePush() { + if (pushTimer != null) return; + long wait = Math.max(0, lastPushAt + PUSH_INTERVAL_MS - scheduler.now()); + pushTimer = scheduler.schedule(() -> { + pushTimer = null; + push(); + }, wait); + } + + private void push() { + List games = getTop(LIVE_GAMES_PUSH_COUNT); + String serialized = Json.write(games); + if (serialized.equals(lastPushed)) return; + lastPushed = serialized; + lastPushAt = scheduler.now(); + Map message = Map.of("games", games); + List.copyOf(subscribers).forEach(userId -> rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, message)); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/MatchMaker.java b/apps/backend/src/main/java/app/playmancala/game/MatchMaker.java new file mode 100644 index 0000000..e9c87fd --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/MatchMaker.java @@ -0,0 +1,39 @@ +package app.playmancala.game; + +import java.util.function.BiConsumer; + +import app.playmancala.rtmt.Rtmt; + +// Pairs the next two players who ask for a game. Only touched on the game loop. +public class MatchMaker { + private final Rtmt rtmt; + private String waitingPlayerId; + private BiConsumer onPlayersPaired = (player1Id, player2Id) -> { }; + + public MatchMaker(Rtmt rtmt) { + this.rtmt = rtmt; + } + + public void join(String newcomerPlayerId) { + if (newcomerPlayerId.equals(waitingPlayerId)) return; + if (waitingPlayerId != null && rtmt.isClientOnline(waitingPlayerId)) { + String waiting = waitingPlayerId; + waitingPlayerId = null; + onPlayersPaired.accept(waiting, newcomerPlayerId); + } else { + waitingPlayerId = newcomerPlayerId; + } + } + + public boolean cancel(String playerId) { + if (playerId.equals(waitingPlayerId)) { + waitingPlayerId = null; + return true; + } + return false; + } + + public void listenOnPlayersPaired(BiConsumer onPlayersPaired) { + this.onPlayersPaired = onPlayersPaired; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java new file mode 100644 index 0000000..498e91b --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java @@ -0,0 +1,31 @@ +package app.playmancala.game; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import app.playmancala.auth.KeycloakTokenVerifier; +import app.playmancala.bot.BotLevel; + +// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash +// instead. Keycloak ids and bot ids are not secret and stay as they are. +public final class PublicIds { + private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; + + private PublicIds() { + } + + public static String publicUserId(String userId) { + if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX) + || BotLevel.isBotId(userId)) { + return userId; + } + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(userId.getBytes(StandardCharsets.UTF_8)); + return LEGACY_PUBLIC_ID_PREFIX + HexFormat.of().formatHex(hash).substring(0, 20); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/RematchOffers.java b/apps/backend/src/main/java/app/playmancala/game/RematchOffers.java new file mode 100644 index 0000000..b3d5436 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/RematchOffers.java @@ -0,0 +1,197 @@ +package app.playmancala.game; + +import java.util.HashMap; +import java.util.Map; +import java.util.random.RandomGenerator; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; +import app.playmancala.loop.Cancellable; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.Channels; +import app.playmancala.model.GameEndMessage; +import app.playmancala.model.Seat; +import app.playmancala.model.Series; +import app.playmancala.rtmt.Rtmt; + +// Rematches between the players of a game that just ended (docs/specs/rematch). An offer is open for a while after +// the game; one player asks, the other accepts and the next game of the series starts. Kept in memory only, and only +// touched on the game loop. +public class RematchOffers { + // How long the opponent has to answer a request. + static final long ANSWER_MS = 60_000; + // How long after a game a rematch can be asked for. + static final long OPEN_MS = 10 * 60_000; + + // Why a request, or the offer, ended; sent in on_rematch_ended. + enum Reason { + DECLINED, WITHDRAWN, EXPIRED, OFFLINE, LEFT, CLOSED; + + String wireName() { + return name().toLowerCase(); + } + } + + // What the next game needs, and the request in progress. + private static final class Offer { + final String gameId; + final String player1Id; + final String player2Id; + // Games against the bot only. + final BotLevel botLevel; + final Series series; + final Seat firstSeat; + // The player who asked; null while nobody has. + String askedBy; + Cancellable answerTimer; + Cancellable closeTimer; + + Offer(String gameId, String player1Id, String player2Id, BotLevel botLevel, Series series, Seat firstSeat) { + this.gameId = gameId; + this.player1Id = player1Id; + this.player2Id = player2Id; + this.botLevel = botLevel; + this.series = series; + this.firstSeat = firstSeat; + } + + boolean isPlayer(String userId) { + return player1Id.equals(userId) || player2Id.equals(userId); + } + + String opponentOf(String userId) { + return player1Id.equals(userId) ? player2Id : player1Id; + } + } + + private final GameManager gameManager; + private final GameStore gameStore; + private final Rtmt rtmt; + private final Scheduler loop; + private final RandomGenerator random; + private final Map offersByGame = new HashMap<>(); + // A player has at most one offer, the one of their last game. The bot has none. + private final Map offersByUser = new HashMap<>(); + + public RematchOffers(GameManager gameManager, GameStore gameStore, Rtmt rtmt, Scheduler loop, RandomGenerator random) { + this.gameManager = gameManager; + this.gameStore = gameStore; + this.rtmt = rtmt; + this.loop = loop; + this.random = random; + gameManager.onGameEnded(this::onGameEnded); + gameManager.onPlayerSearching(userId -> close(offersByUser.get(userId), Reason.LEFT)); + rtmt.listenMessage(Channels.REMATCH, (userId, message) -> onRematch(userId, gameIdOf(message))); + rtmt.listenMessage(Channels.DECLINE_REMATCH, (userId, message) -> onDecline(userId, gameIdOf(message))); + rtmt.listenOnClientConnectionChange((userId, isOnline) -> { + Offer offer = offersByUser.get(userId); + if (!isOnline && offer != null && offer.askedBy != null) endRequest(offer, Reason.OFFLINE); + }); + } + + private static String gameIdOf(JsonNode message) { + JsonNode gameId = message.path("gameId"); + return gameId.isTextual() ? gameId.asText() : null; + } + + private void onGameEnded(BackendGame game, GameEndMessage endMessage) { + if (!endMessage.isRematchAvailable()) return; + String player1Id = game.mancalaGame.getPlayer1Id(), player2Id = game.mancalaGame.getPlayer2Id(); + close(offersByUser.get(player1Id), Reason.CLOSED); + close(offersByUser.get(player2Id), Reason.CLOSED); + // The other player moves first next (R2.2). + Seat nextFirstSeat = game.firstSeat() == Seat.PLAYER1 ? Seat.PLAYER2 : Seat.PLAYER1; + Offer offer = new Offer(game.id, player1Id, player2Id, game.bot != null ? game.bot.level() : null, + endMessage.series(), nextFirstSeat); + offer.closeTimer = loop.schedule(() -> close(offer, Reason.CLOSED), OPEN_MS); + offersByGame.put(offer.gameId, offer); + for (String playerId : new String[] { player1Id, player2Id }) { + if (!BotLevel.isBotId(playerId)) offersByUser.put(playerId, offer); + } + } + + private void onRematch(String userId, String gameId) { + if (gameId == null) return; + Offer offer = offersByGame.get(gameId); + if (offer == null || !offer.isPlayer(userId)) { + rtmt.sendMessage(userId, Channels.ON_REMATCH_ENDED, ended(gameId, Reason.CLOSED)); + return; + } + String opponentId = offer.opponentOf(userId); + // The bot always accepts (R4.2). + if (BotLevel.isBotId(opponentId) || opponentId.equals(offer.askedBy)) { + startNextGame(offer); + return; + } + if (userId.equals(offer.askedBy)) return; + if (!rtmt.isClientOnline(opponentId)) { + rtmt.sendMessage(userId, Channels.ON_REMATCH_ENDED, ended(gameId, Reason.OFFLINE)); + return; + } + offer.askedBy = userId; + rtmt.sendMessage(opponentId, Channels.ON_REMATCH_REQUEST, Map.of("gameId", gameId)); + offer.answerTimer = loop.schedule(() -> endRequest(offer, Reason.EXPIRED), ANSWER_MS); + } + + // Withdraws the user's own request, or says no to the opponent's, which ends the series (R1.6). + private void onDecline(String userId, String gameId) { + Offer offer = gameId == null ? null : offersByGame.get(gameId); + if (offer == null || !offer.isPlayer(userId)) return; + if (userId.equals(offer.askedBy)) endRequest(offer, Reason.WITHDRAWN); + else close(offer, Reason.DECLINED); + } + + // Ends the request in progress; the offer stays open, so either player can ask again. + private void endRequest(Offer offer, Reason reason) { + cancel(offer.answerTimer); + offer.answerTimer = null; + offer.askedBy = null; + notifyPlayers(offer, reason); + } + + // Ends the offer: no rematch of this game any more. + private void close(Offer offer, Reason reason) { + if (offer == null || !remove(offer)) return; + notifyPlayers(offer, reason); + } + + private boolean remove(Offer offer) { + if (offersByGame.get(offer.gameId) != offer) return false; + offersByGame.remove(offer.gameId); + offersByUser.remove(offer.player1Id, offer); + offersByUser.remove(offer.player2Id, offer); + cancel(offer.answerTimer); + cancel(offer.closeTimer); + return true; + } + + private void startNextGame(Offer offer) { + remove(offer); + // Matchmaking ends the offer, so neither player can be in a game; a restored game must not make it two. + if (gameStore.getGameByUser(offer.player1Id) != null || gameStore.getGameByUser(offer.player2Id) != null) { + notifyPlayers(offer, Reason.CLOSED); + return; + } + // The bot keeps its level and draws a new strength, as in any new game. + BotSettings bot = offer.botLevel != null ? BotSettings.draw(offer.botLevel, random) : null; + gameManager.startGame(gameManager.createMancalaGame(offer.player1Id, offer.player2Id, bot, offer.series, + offer.firstSeat)); + } + + private void notifyPlayers(Offer offer, Reason reason) { + Map message = ended(offer.gameId, reason); + for (String playerId : new String[] { offer.player1Id, offer.player2Id }) { + if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, Channels.ON_REMATCH_ENDED, message); + } + } + + private static Map ended(String gameId, Reason reason) { + return Map.of("gameId", gameId, "reason", reason.wireName()); + } + + private static void cancel(Cancellable timer) { + if (timer != null) timer.cancel(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/history/GameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/GameHistoryRepository.java new file mode 100644 index 0000000..c9ae70f --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/history/GameHistoryRepository.java @@ -0,0 +1,22 @@ +package app.playmancala.history; + +import java.util.List; + +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; + +// Saved, finished games. Ids in records are public ids (see PublicIds). Methods block and throw when the database +// is unreachable. +public interface GameHistoryRepository { + // Saving the same game twice is a no-op, so failed saves can be retried. + void save(GameRecord record); + + // Null if there is no such game. + GameRecord get(String id); + + // The user's finished games, most recent first. + List getRecentForUser(String userId, int limit); + + // Finished games with the most spectators, most recent first among equals. + List getPopular(int limit); +} diff --git a/apps/backend/src/main/java/app/playmancala/history/GameHistoryService.java b/apps/backend/src/main/java/app/playmancala/history/GameHistoryService.java new file mode 100644 index 0000000..645d0e6 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/history/GameHistoryService.java @@ -0,0 +1,91 @@ +package app.playmancala.history; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.core.type.TypeReference; + +import app.playmancala.live.Cache; +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; + +// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the +// saved games while the database is unreachable, so the home page still loads. Blocks; call it off the game loop. +public class GameHistoryService { + public static final int MAX_LIST_LIMIT = 20; + private static final Logger LOG = Logger.getLogger(GameHistoryService.class); + private static final long CACHE_TTL_SECONDS = 10; + private static final String POPULAR_KEY = "popular-finished"; + private static final TypeReference> SUMMARIES = new TypeReference<>() { }; + + private static final Comparator BY_NEWEST = + Comparator.comparingLong(FinishedGameSummary::endedAt).reversed(); + private static final Comparator BY_POPULARITY = + Comparator.comparingInt(FinishedGameSummary::peakSpectators).reversed().thenComparing(BY_NEWEST); + + private final GameHistoryRepository repository; + private final Cache cache; + // Games that ended but are not in the database yet (being saved, or waiting for a retry). + private final Supplier> unsaved; + + public GameHistoryService(GameHistoryRepository repository, Cache cache, Supplier> unsaved) { + this.repository = repository; + this.cache = cache; + this.unsaved = unsaved; + } + + private static String recentKey(String userId) { + return "recent:" + userId; + } + + // Null if there is no such game. Throws if the database cannot be asked. + public GameRecord get(String id) { + return unsaved.get().stream().filter(record -> record.id().equals(id)).findFirst() + .orElseGet(() -> repository.get(id)); + } + + public List getRecentForUser(String userId, int limit) { + List saved = cached(recentKey(userId), () -> repository.getRecentForUser(userId, MAX_LIST_LIMIT)); + List unsavedGames = unsaved.get().stream().filter(record -> record.hasPlayer(userId)).toList(); + return merge(saved, unsavedGames, BY_NEWEST, limit); + } + + public List getPopular(int limit) { + List saved = cached(POPULAR_KEY, () -> repository.getPopular(MAX_LIST_LIMIT)); + return merge(saved, unsaved.get(), BY_POPULARITY, limit); + } + + // Call when a game has been saved, so lists show it right away. + public void onGameSaved(GameRecord record) { + cache.delete(POPULAR_KEY, recentKey(record.player1().userId()), recentKey(record.player2().userId())); + } + + private List cached(String key, Supplier> load) { + Optional> hit = cache.get(key, SUMMARIES); + if (hit.isPresent()) return hit.get(); + try { + List games = load.get(); + cache.set(key, games, CACHE_TTL_SECONDS); + return games; + } catch (RuntimeException e) { + LOG.errorf("(History) Could not load games: %s", e.getMessage()); + return List.of(); + } + } + + private static List merge(List saved, List unsaved, + Comparator order, int limit) { + Set savedIds = saved.stream().map(FinishedGameSummary::id).collect(Collectors.toSet()); + List all = new ArrayList<>(saved); + unsaved.stream().filter(record -> !savedIds.contains(record.id())).map(GameRecord::toSummary).forEach(all::add); + all.sort(order); + return all.stream().limit(limit).toList(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/history/Migrations.java b/apps/backend/src/main/java/app/playmancala/history/Migrations.java new file mode 100644 index 0000000..3fd67fd --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/history/Migrations.java @@ -0,0 +1,100 @@ +package app.playmancala.history; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.sql.DataSource; + +import org.jboss.logging.Logger; + +// Applies the migrations in src/main/resources/db/migrations that have not run yet, each in its own transaction. +// Same table and lock as the Node backend's runner, so databases it migrated are recognised. +public final class Migrations { + private static final Logger LOG = Logger.getLogger(Migrations.class); + private static final long MIGRATION_LOCK_ID = 727274; + + // Applied in this order; never change a migration that has been released, add a new one instead. + public static final List VERSIONS = List.of("001_games", "002_clock", "003_bot", "004_series"); + + private Migrations() { + } + + public static void run(DataSource dataSource) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(true); + advisoryLock(connection, "pg_advisory_lock"); + try { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )"""); + } + Set applied = appliedVersions(connection); + for (String version : VERSIONS) { + if (applied.contains(version)) continue; + apply(connection, version); + LOG.infof("(DB) Applied migration %s", version); + } + } finally { + try { + advisoryLock(connection, "pg_advisory_unlock"); + } catch (SQLException ignored) { + // Released with the connection. + } + } + } + } + + private static void advisoryLock(Connection connection, String function) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement("SELECT " + function + "(?)")) { + statement.setLong(1, MIGRATION_LOCK_ID); + statement.execute(); + } + } + + private static Set appliedVersions(Connection connection) throws SQLException { + Set applied = new HashSet<>(); + try (Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations")) { + while (rows.next()) applied.add(rows.getString(1)); + } + return applied; + } + + private static void apply(Connection connection, String version) throws SQLException { + connection.setAutoCommit(false); + try (Statement statement = connection.createStatement(); + PreparedStatement insert = connection.prepareStatement("INSERT INTO schema_migrations (version) VALUES (?)")) { + statement.execute(sql(version)); + insert.setString(1, version); + insert.executeUpdate(); + connection.commit(); + } catch (SQLException e) { + connection.rollback(); + throw e; + } finally { + connection.setAutoCommit(true); + } + } + + private static String sql(String version) { + String path = "/db/migrations/" + version + ".sql"; + try (InputStream in = Migrations.class.getResourceAsStream(path)) { + if (in == null) throw new IllegalStateException("Missing migration " + path); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java new file mode 100644 index 0000000..36a5de3 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java @@ -0,0 +1,205 @@ +package app.playmancala.history; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import javax.sql.DataSource; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; + +import app.playmancala.bot.BotSettings; +import app.playmancala.model.EndReason; +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.Json; +import app.playmancala.model.MoveRecord; +import app.playmancala.model.PlayerSummary; + +// Throws IllegalStateException when the database cannot be reached or a query fails. +public class PostgresGameHistoryRepository implements GameHistoryRepository { + private static final Pattern UUID_PATTERN = + Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE); + + private static final String SUMMARY_COLUMNS = """ + g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count, + g.final_board, g.peak_spectators, g.clock_initial_ms, + p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest, + p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest"""; + + private static final String FROM_GAMES = """ + FROM games g + JOIN game_players p1 ON p1.game_id = g.id AND p1.seat = 1 + JOIN game_players p2 ON p2.game_id = g.id AND p2.seat = 2"""; + + private static final TypeReference> BOARD = new TypeReference<>() { }; + private static final TypeReference> MOVES = new TypeReference<>() { }; + + private final DataSource dataSource; + + public PostgresGameHistoryRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + @FunctionalInterface + private interface Work { + T run(Connection connection) throws SQLException, JsonProcessingException; + } + + @Override + public void save(GameRecord record) { + inTransaction(connection -> { + int inserted; + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at, + move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot, series_id) + VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0), + ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?::uuid) + ON CONFLICT (id) DO NOTHING""")) { + statement.setString(1, record.id()); + statement.setString(2, record.player1().userId()); + statement.setString(3, record.player2().userId()); + statement.setString(4, record.result() == null ? null : record.result().wireName()); + statement.setString(5, record.endReason().wireName()); + statement.setString(6, record.leftUserId()); + statement.setLong(7, record.startedAt()); + statement.setLong(8, record.endedAt()); + statement.setInt(9, record.moveCount()); + statement.setString(10, Json.write(record.board())); + statement.setString(11, Json.write(record.moves())); + statement.setInt(12, record.peakSpectators()); + statement.setInt(13, record.totalSpectators()); + if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER); + else statement.setInt(14, Math.toIntExact(record.clockInitialMs())); + statement.setString(15, record.bot() == null ? null : Json.write(record.bot())); + statement.setString(16, record.seriesId()); + inserted = statement.executeUpdate(); + } + if (inserted == 0) return null; + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO game_players (game_id, seat, user_id, name, is_guest) VALUES (?::uuid, ?, ?, ?, ?)")) { + PlayerSummary[] players = { record.player1(), record.player2() }; + for (int seat = 1; seat <= 2; seat++) { + PlayerSummary player = players[seat - 1]; + statement.setString(1, record.id()); + statement.setInt(2, seat); + statement.setString(3, player.userId()); + statement.setString(4, player.name()); + statement.setBoolean(5, player.isGuest()); + statement.addBatch(); + } + statement.executeBatch(); + } + return null; + }); + } + + @Override + public GameRecord get(String id) { + if (!UUID_PATTERN.matcher(id).matches()) return null; + return query(connection -> { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot, g.series_id " + FROM_GAMES + " WHERE g.id = ?::uuid")) { + statement.setString(1, id); + try (ResultSet row = statement.executeQuery()) { + if (!row.next()) return null; + FinishedGameSummary summary = toSummary(row); + return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(), + summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(), + summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(), + Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"), + row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class), + row.getString("series_id")); + } + } + }); + } + + @Override + public List getRecentForUser(String userId, int limit) { + return query(connection -> { + try (PreparedStatement statement = connection.prepareStatement("SELECT " + SUMMARY_COLUMNS + " " + FROM_GAMES + + " WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?) ORDER BY g.ended_at DESC LIMIT ?")) { + statement.setString(1, userId); + statement.setInt(2, limit); + return summaries(statement); + } + }); + } + + @Override + public List getPopular(int limit) { + return query(connection -> { + try (PreparedStatement statement = connection.prepareStatement("SELECT " + SUMMARY_COLUMNS + " " + FROM_GAMES + + " ORDER BY g.peak_spectators DESC, g.ended_at DESC LIMIT ?")) { + statement.setInt(1, limit); + return summaries(statement); + } + }); + } + + private static List summaries(PreparedStatement statement) throws SQLException, JsonProcessingException { + List summaries = new ArrayList<>(); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) summaries.add(toSummary(rows)); + } + return summaries; + } + + private static FinishedGameSummary toSummary(ResultSet row) throws SQLException, JsonProcessingException { + Object clockInitialMs = row.getObject("clock_initial_ms"); + return new FinishedGameSummary( + row.getString("id"), + player(row, "p1_"), + player(row, "p2_"), + Json.MAPPER.readValue(row.getString("final_board"), BOARD), + GameResult.fromWireName(row.getString("result")), + EndReason.fromWireName(row.getString("end_reason")), + row.getString("left_user_id"), + row.getTimestamp("started_at").getTime(), + row.getTimestamp("ended_at").getTime(), + row.getInt("move_count"), + row.getInt("peak_spectators"), + clockInitialMs == null ? null : ((Number) clockInitialMs).longValue()); + } + + private static PlayerSummary player(ResultSet row, String prefix) throws SQLException { + return new PlayerSummary(row.getString(prefix + "user_id"), row.getString(prefix + "name"), + row.getBoolean(prefix + "is_guest")); + } + + private T query(Work work) { + try (Connection connection = dataSource.getConnection()) { + return work.run(connection); + } catch (SQLException | JsonProcessingException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + private void inTransaction(Work work) { + query(connection -> { + connection.setAutoCommit(false); + try { + work.run(connection); + connection.commit(); + } catch (SQLException | JsonProcessingException | RuntimeException e) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // The connection is broken; the transaction ends with it. + } + throw e; + } finally { + connection.setAutoCommit(true); + } + return null; + }); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/http/BearerAuth.java b/apps/backend/src/main/java/app/playmancala/http/BearerAuth.java new file mode 100644 index 0000000..cc69264 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/http/BearerAuth.java @@ -0,0 +1,35 @@ +package app.playmancala.http; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jboss.logging.Logger; + +import app.playmancala.auth.InvalidTokenException; +import app.playmancala.auth.TokenVerifier; +import app.playmancala.model.AuthUser; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +// Requires an "Authorization: Bearer " header with the same Keycloak token the WebSocket uses. +final class BearerAuth { + private static final Logger LOG = Logger.getLogger(BearerAuth.class); + private static final Pattern BEARER = Pattern.compile("^Bearer (.+)$"); + + private BearerAuth() { + } + + // The user, or a 401 response. + static AuthUser requireUser(TokenVerifier tokenVerifier, String authorization) { + Matcher match = BEARER.matcher(authorization == null ? "" : authorization); + if (!match.matches()) throw new WebApplicationException(Response.Status.UNAUTHORIZED); + try { + return tokenVerifier.verify(match.group(1)); + } catch (InvalidTokenException e) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } catch (RuntimeException e) { + LOG.error("(Auth) Token verification failed", e); + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/http/ErrorMapper.java b/apps/backend/src/main/java/app/playmancala/http/ErrorMapper.java new file mode 100644 index 0000000..7b01516 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/http/ErrorMapper.java @@ -0,0 +1,20 @@ +package app.playmancala.http; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.server.ServerExceptionMapper; + +import io.vertx.core.http.HttpServerRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +// A failing dependency (the database, mostly) answers 503, as the Express error handler did. +public class ErrorMapper { + private static final Logger LOG = Logger.getLogger(ErrorMapper.class); + + @ServerExceptionMapper + public Response map(RuntimeException error, HttpServerRequest request) { + if (error instanceof WebApplicationException webError) return webError.getResponse(); + LOG.errorf("(HTTP) %s %s failed: %s", request.method(), request.path(), error.getMessage()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE).build(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/http/GameResource.java b/apps/backend/src/main/java/app/playmancala/http/GameResource.java new file mode 100644 index 0000000..9addfc3 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/http/GameResource.java @@ -0,0 +1,125 @@ +package app.playmancala.http; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.playmancala.Backend; +import app.playmancala.game.BackendGame; +import app.playmancala.game.LiveGames; +import app.playmancala.history.GameHistoryService; +import app.playmancala.model.AuthUser; +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; +import app.playmancala.model.LiveGameSummary; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +// The HTTP API. Blocking: runs on worker threads, reads live games on the game loop and saved games itself. +// Bodies are written with the shared mapper, so they are the same JSON as the WebSocket messages. +@Path("/") +public class GameResource { + private static final Pattern LEADING_INTEGER = Pattern.compile("^\\s*([+-]?\\d+)"); + + @Inject + Backend backend; + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String root() { + return "Server up and running! ci test1"; + } + + // Issues userKeys for legacy clients; token clients get their identity from Keycloak. + @GET + @Path("register{slash: /?}") + @Produces(MediaType.TEXT_PLAIN) + public String register() { + if (!backend.legacyUserKeyAuth()) throw new NotFoundException(); + return UUID.randomUUID().toString(); + } + + // A live game as players receive it, or the saved record of a finished game. + @GET + @Path("game/{id}") + public Response game(@PathParam("id") String id) { + // Serialised on the loop, because the client game refers to the live engine objects. + String live = backend.loop().call(() -> { + BackendGame game = backend.gameStore().get(id); + return game == null ? null : Json.write(backend.gameManager().toClientGame(game)); + }); + if (live != null) return json(live); + GameRecord record = backend.history().get(id); + if (record == null) throw new NotFoundException(); + Map response = new LinkedHashMap<>(); + response.put("status", "finished"); + response.put("record", record); + return json(response); + } + + // Live games if any are being played, otherwise the most spectated finished games. With kind=finished always the + // finished games, for clients that get live games over the WebSocket. + @GET + @Path("games/popular") + public Response popular(@QueryParam("limit") String limitParam, @QueryParam("kind") String kind) { + int limit = parseLimit(limitParam, 5); + List live = "finished".equals(kind) ? List.of() + : backend.loop().call(() -> backend.liveGames().getTop(limit)); + Map response = new LinkedHashMap<>(); + if (!live.isEmpty()) { + response.put("kind", "live"); + response.put("games", live); + } else { + response.put("kind", "finished"); + response.put("games", backend.history().getPopular(limit)); + } + return json(response); + } + + @GET + @Path("games/me/active") + public Response activeGame(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization); + LiveGameSummary game = backend.loop().call(() -> { + BackendGame active = backend.gameStore().getGameByUser(user.id()); + return active == null ? null : LiveGames.toLiveGameSummary(active); + }); + Map response = new HashMap<>(); + response.put("game", game); + return json(response); + } + + @GET + @Path("games/me") + public Response myGames(@HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @QueryParam("limit") String limit) { + AuthUser user = BearerAuth.requireUser(backend.tokenVerifier(), authorization); + return json(Map.of("games", backend.history().getRecentForUser(user.id(), parseLimit(limit, 3)))); + } + + private static Response json(Object body) { + return Response.ok(body instanceof String text ? text : Json.write(body), MediaType.APPLICATION_JSON).build(); + } + + // Like parseInt in the Node backend: the leading integer, clamped to 1..MAX_LIST_LIMIT. + static int parseLimit(String value, int fallback) { + Matcher match = LEADING_INTEGER.matcher(value == null ? "" : value); + if (!match.find()) return fallback; + BigInteger limit = new BigInteger(match.group(1)); + return limit.max(BigInteger.ONE).min(BigInteger.valueOf(GameHistoryService.MAX_LIST_LIMIT)).intValue(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/live/Cache.java b/apps/backend/src/main/java/app/playmancala/live/Cache.java new file mode 100644 index 0000000..d41dbf1 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/live/Cache.java @@ -0,0 +1,14 @@ +package app.playmancala.live; + +import java.util.Optional; + +import com.fasterxml.jackson.core.type.TypeReference; + +// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value. +public interface Cache { + Optional get(String key, TypeReference type); + + void set(String key, Object value, long ttlSeconds); + + void delete(String... keys); +} diff --git a/apps/backend/src/main/java/app/playmancala/live/LiveGameStore.java b/apps/backend/src/main/java/app/playmancala/live/LiveGameStore.java new file mode 100644 index 0000000..a843497 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/live/LiveGameStore.java @@ -0,0 +1,24 @@ +package app.playmancala.live; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.GameRecord; + +// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished +// games that could not be saved to the database yet. Methods block and throw when Valkey is unreachable. +public interface LiveGameStore { + void saveGame(String gameId, Object snapshot); + + void removeGame(String gameId); + + // As stored; the caller checks what it can use. + List loadGames(); + + void savePendingRecord(GameRecord record); + + void removePendingRecord(String gameId); + + List loadPendingRecords(); +} diff --git a/apps/backend/src/main/java/app/playmancala/live/ValkeyCache.java b/apps/backend/src/main/java/app/playmancala/live/ValkeyCache.java new file mode 100644 index 0000000..eced22f --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/live/ValkeyCache.java @@ -0,0 +1,52 @@ +package app.playmancala.live; + +import java.util.Arrays; +import java.util.Optional; + +import com.fasterxml.jackson.core.type.TypeReference; + +import app.playmancala.model.Json; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.value.SetArgs; +import io.quarkus.redis.datasource.value.ValueCommands; + +public class ValkeyCache implements Cache { + private static final String PREFIX = "mancala:cache:"; + + private final RedisDataSource redis; + private final ValueCommands values; + + public ValkeyCache(RedisDataSource redis) { + this.redis = redis; + this.values = redis.value(String.class); + } + + @Override + public Optional get(String key, TypeReference type) { + try { + String value = values.get(PREFIX + key); + return value == null ? Optional.empty() : Optional.of(Json.MAPPER.readValue(value, type)); + } catch (Exception e) { + return Optional.empty(); + } + } + + @Override + public void set(String key, Object value, long ttlSeconds) { + try { + values.set(PREFIX + key, Json.write(value), new SetArgs().ex(ttlSeconds)); + } catch (RuntimeException e) { + // A missing cache entry only costs a database query. + } + } + + @Override + public void delete(String... keys) { + if (keys.length == 0) return; + try { + redis.key().del(Arrays.stream(keys).map(key -> PREFIX + key).toArray(String[]::new)); + } catch (RuntimeException e) { + // Entries expire on their own. + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java b/apps/backend/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java new file mode 100644 index 0000000..34a4455 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java @@ -0,0 +1,87 @@ +package app.playmancala.live; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.keys.KeyScanArgs; +import io.quarkus.redis.datasource.value.ValueCommands; + +// Keys and values are the Node backend's, so either backend reads what the other wrote. +public class ValkeyLiveGameStore implements LiveGameStore { + private static final Logger LOG = Logger.getLogger(ValkeyLiveGameStore.class); + private static final String LIVE_PREFIX = "mancala:live:"; + private static final String PENDING_SAVE_PREFIX = "mancala:pending-save:"; + + private final RedisDataSource redis; + private final ValueCommands values; + + public ValkeyLiveGameStore(RedisDataSource redis) { + this.redis = redis; + this.values = redis.value(String.class); + } + + @Override + public void saveGame(String gameId, Object snapshot) { + values.set(LIVE_PREFIX + gameId, Json.write(snapshot)); + } + + @Override + public void removeGame(String gameId) { + redis.key().del(LIVE_PREFIX + gameId); + } + + @Override + public List loadGames() { + return loadAll(LIVE_PREFIX); + } + + @Override + public void savePendingRecord(GameRecord record) { + values.set(PENDING_SAVE_PREFIX + record.id(), Json.write(record)); + } + + @Override + public void removePendingRecord(String gameId) { + redis.key().del(PENDING_SAVE_PREFIX + gameId); + } + + @Override + public List loadPendingRecords() { + List records = new ArrayList<>(); + for (JsonNode json : loadAll(PENDING_SAVE_PREFIX)) { + try { + records.add(Json.MAPPER.treeToValue(json, GameRecord.class)); + } catch (Exception e) { + LOG.errorf("(Valkey) Ignoring unreadable pending record %s", json.path("id").asText()); + } + } + return records; + } + + private List loadAll(String prefix) { + // SCAN rather than KEYS, which would block Valkey. + List keys = new ArrayList<>(); + redis.key().scan(new KeyScanArgs().match(prefix + "*").count(100)).toIterable().forEach(keys::add); + if (keys.isEmpty()) return List.of(); + Map stored = values.mget(keys.toArray(String[]::new)); + List parsed = new ArrayList<>(); + for (String key : keys) { + String value = stored.get(key); + if (value == null) continue; + try { + parsed.add(Json.parse(value)); + } catch (Exception e) { + LOG.errorf("(Valkey) Ignoring unreadable value in %s", key); + } + } + return parsed; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/loop/Cancellable.java b/apps/backend/src/main/java/app/playmancala/loop/Cancellable.java new file mode 100644 index 0000000..2e5344e --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/loop/Cancellable.java @@ -0,0 +1,6 @@ +package app.playmancala.loop; + +@FunctionalInterface +public interface Cancellable { + void cancel(); +} diff --git a/apps/backend/src/main/java/app/playmancala/loop/GameLoop.java b/apps/backend/src/main/java/app/playmancala/loop/GameLoop.java new file mode 100644 index 0000000..72a02f3 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/loop/GameLoop.java @@ -0,0 +1,88 @@ +package app.playmancala.loop; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.jboss.logging.Logger; + +// The one thread that owns all live game state: games, matchmaking, previews and the WebSocket registry. Like the +// Node backend's event loop, it lets that state go without locks. Never block it on I/O. +public final class GameLoop implements Scheduler, AutoCloseable { + private static final Logger LOG = Logger.getLogger(GameLoop.class); + + private final ScheduledExecutorService executor; + private volatile Thread thread; + + public GameLoop() { + executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread loopThread = new Thread(runnable, "game-loop"); + loopThread.setDaemon(true); + thread = loopThread; + return loopThread; + }); + } + + @Override + public long now() { + return System.currentTimeMillis(); + } + + @Override + public void execute(Runnable task) { + executor.execute(guarded(task)); + } + + @Override + public Cancellable schedule(Runnable task, long delayMs) { + Future future = executor.schedule(guarded(task), delayMs, TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + + @Override + public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) { + Future future = executor.scheduleAtFixedRate(guarded(task), periodMs, periodMs, TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + + // Runs the task on the loop and waits for its result; for threads that need to read live state. + public T call(Callable task) { + if (Thread.currentThread() == thread) { + try { + return task.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + try { + return executor.submit(task).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the game loop", e); + } catch (ExecutionException e) { + if (e.getCause() instanceof RuntimeException runtime) throw runtime; + throw new IllegalStateException(e.getCause()); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } + + // An exception must not stop a repeating task or reach the executor, which would swallow it. + private static Runnable guarded(Runnable task) { + return () -> { + try { + task.run(); + } catch (Throwable e) { + LOG.error("(GameLoop) Task failed", e); + } + }; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/loop/Scheduler.java b/apps/backend/src/main/java/app/playmancala/loop/Scheduler.java new file mode 100644 index 0000000..c20381e --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/loop/Scheduler.java @@ -0,0 +1,15 @@ +package app.playmancala.loop; + +// What the game logic needs from the game loop: running tasks and timers on the thread that owns the game state, and +// the time. Tests use ManualScheduler, whose clock only moves when the test advances it. +public interface Scheduler { + // Epoch milliseconds. + long now(); + + void execute(Runnable task); + + Cancellable schedule(Runnable task, long delayMs); + + // First run after one period. + Cancellable scheduleAtFixedRate(Runnable task, long periodMs); +} diff --git a/apps/backend/src/main/java/app/playmancala/model/AuthUser.java b/apps/backend/src/main/java/app/playmancala/model/AuthUser.java new file mode 100644 index 0000000..2b7dd37 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/AuthUser.java @@ -0,0 +1,11 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +// A logged in user. `id` is also the player id in games; `name` is null for legacy userKey clients. +public record AuthUser( + String id, + @JsonInclude(JsonInclude.Include.NON_NULL) String name, + @JsonProperty("isGuest") boolean isGuest) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/Channels.java b/apps/backend/src/main/java/app/playmancala/model/Channels.java new file mode 100644 index 0000000..03a30c3 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/Channels.java @@ -0,0 +1,46 @@ +package app.playmancala.model; + +// WebSocket channel names; must match packages/core/src/rtmt/channel_names.ts. +public final class Channels { + public static final String NEW_GAME = "new_game"; + public static final String CANCEL_NEW_GAME = "cancel_new_game"; + // client -> server: { level }, starts a game against the bot at that level (docs/specs/game-bot). + public static final String NEW_BOT_GAME = "new_bot_game"; + public static final String ON_GAME_START = "on_game_start"; + public static final String GAME_MOVE = "game_move"; + public static final String ON_GAME_UPDATE = "on_game_update"; + public static final String LEAVE_GAME = "leave_game"; + public static final String ON_GAME_END = "on_game_end"; + public static final String ON_GAME_CRASHED = "on_game_crashed"; + public static final String ON_GAME_USER_LEAVE = "on_game_user_leave"; + public static final String PING = "ping"; + public static final String PONG = "pong"; + public static final String ON_USER_CONNECTION_CHANGE = "channel_on_user_connection_change"; + public static final String LISTEN_GAME_EVENTS = "channel_listen_game_events"; + public static final String UNLISTEN_GAME_EVENTS = "channel_unlisten_game_events"; + // Auth handshake. client -> server: { token } right after connecting and again after a token refresh; + // other messages are ignored until the server answers on_auth with { user }. + public static final String AUTH = "auth"; + public static final String ON_AUTH = "on_auth"; + // server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED. + public static final String ON_AUTH_ERROR = "on_auth_error"; + public static final int CLOSE_AUTH_FAILED = 4001; + // Home page previews. client -> server: subscribe_live_games / unsubscribe_live_games; + // server -> client: on_live_games with { games: LiveGameSummary[] }. + public static final String SUBSCRIBE_LIVE_GAMES = "subscribe_live_games"; + public static final String UNSUBSCRIBE_LIVE_GAMES = "unsubscribe_live_games"; + public static final String ON_LIVE_GAMES = "on_live_games"; + // server -> client: { gameId }, when new_game is sent while the user has a game in progress. + public static final String ON_NEW_GAME_REJECTED = "on_new_game_rejected"; + // Rematches (docs/specs/rematch). client -> server: rematch { gameId } asks for a rematch of an ended game or + // accepts the opponent's request; decline_rematch { gameId } says no or withdraws one's own request. + // server -> client: on_rematch_request { gameId } when the opponent asks; on_rematch_ended { gameId, reason } to + // both players when a request or the chance of a rematch ends. + public static final String REMATCH = "rematch"; + public static final String DECLINE_REMATCH = "decline_rematch"; + public static final String ON_REMATCH_REQUEST = "on_rematch_request"; + public static final String ON_REMATCH_ENDED = "on_rematch_ended"; + + private Channels() { + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/ClientGame.java b/apps/backend/src/main/java/app/playmancala/model/ClientGame.java new file mode 100644 index 0000000..fa4432d --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/ClientGame.java @@ -0,0 +1,22 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import app.playmancala.engine.MancalaGame; + +// A live game as players and spectators receive it; leaves out spectator ids and other server-side state. +// It refers to the live MancalaGame, so serialise it on the game loop. `series` holds the games before this one. +public record ClientGame( + String id, + MancalaGame mancalaGame, + GameUsersConnectionInfo gameUsersConnectionInfo, + long startedAt, + List moveTimes, + int spectatorCount, + GameClock clock, + long serverTime, + @JsonInclude(JsonInclude.Include.NON_NULL) Series series, + Seat firstSeat) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/EndReason.java b/apps/backend/src/main/java/app/playmancala/model/EndReason.java new file mode 100644 index 0000000..e419d04 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/EndReason.java @@ -0,0 +1,20 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum EndReason { + @JsonProperty("completed") COMPLETED, + @JsonProperty("left") LEFT, + @JsonProperty("timeout") TIMEOUT, + // Only in games saved before the game clock. + @JsonProperty("inactive") INACTIVE; + + // The value stored in the games.end_reason column. + public String wireName() { + return name().toLowerCase(); + } + + public static EndReason fromWireName(String value) { + return valueOf(value.toUpperCase()); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/FinishedGameSummary.java b/apps/backend/src/main/java/app/playmancala/model/FinishedGameSummary.java new file mode 100644 index 0000000..afd6ead --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/FinishedGameSummary.java @@ -0,0 +1,24 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// A finished game in lists. `board` is the final board; `result` is null (and sent as null) without a winner. +@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", + "moveCount", "peakSpectators", "clockInitialMs" }) +public record FinishedGameSummary( + String id, + PlayerSummary player1, + PlayerSummary player2, + List board, + GameResult result, + EndReason endReason, + @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId, + long startedAt, + long endedAt, + int moveCount, + int peakSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/GameClock.java b/apps/backend/src/main/java/app/playmancala/model/GameClock.java new file mode 100644 index 0000000..301b923 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/GameClock.java @@ -0,0 +1,12 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +// The players' clocks (docs/specs/game-clock): each player's time left at the start of the current turn, and when +// that turn started. `turnStartedAt` is null when no clock runs, i.e. the game has ended. +public record GameClock( + long initialMs, + long player1Ms, + long player2Ms, + @JsonInclude(JsonInclude.Include.NON_NULL) Long turnStartedAt) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java new file mode 100644 index 0000000..9aee0de --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java @@ -0,0 +1,26 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and +// sent as null) when the game ended without a winner. `series` includes the game that ended; `rematch` is "available" +// when the players can ask for a rematch, i.e. unless a player left. +@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series", "rematch" }) +public record GameEndMessage( + String gameId, + GameResult result, + EndReason endReason, + GameClock clock, + @JsonInclude(JsonInclude.Include.NON_NULL) String winnerId, + @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId, + @JsonInclude(JsonInclude.Include.NON_NULL) Series series, + @JsonInclude(JsonInclude.Include.NON_NULL) String rematch) { + public static final String REMATCH_AVAILABLE = "available"; + + @JsonIgnore + public boolean isRematchAvailable() { + return REMATCH_AVAILABLE.equals(rematch); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java new file mode 100644 index 0000000..1c028a6 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java @@ -0,0 +1,49 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import app.playmancala.bot.BotSettings; + +// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it +// is a public id. `bot` is how the bot played, in games against the bot; `seriesId` is missing in games saved before +// rematches. +@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot", "seriesId" }) +public record GameRecord( + String id, + PlayerSummary player1, + PlayerSummary player2, + List board, + GameResult result, + EndReason endReason, + @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId, + long startedAt, + long endedAt, + int moveCount, + int peakSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, + List moves, + int totalSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot, + @JsonInclude(JsonInclude.Include.NON_NULL) String seriesId) { + + // A game between people. + public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List board, GameResult result, + EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators, + Long clockInitialMs, List moves, int totalSpectators) { + this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators, + clockInitialMs, moves, totalSpectators, null, null); + } + + public FinishedGameSummary toSummary() { + return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, + moveCount, peakSpectators, clockInitialMs); + } + + public boolean hasPlayer(String userId) { + return player1.userId().equals(userId) || player2.userId().equals(userId); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/GameResult.java b/apps/backend/src/main/java/app/playmancala/model/GameResult.java new file mode 100644 index 0000000..43a3488 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/GameResult.java @@ -0,0 +1,18 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum GameResult { + @JsonProperty("player1") PLAYER1, + @JsonProperty("player2") PLAYER2, + @JsonProperty("draw") DRAW; + + // The value stored in the games.result column. + public String wireName() { + return name().toLowerCase(); + } + + public static GameResult fromWireName(String value) { + return value == null ? null : valueOf(value.toUpperCase()); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java b/apps/backend/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java new file mode 100644 index 0000000..78976af --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java @@ -0,0 +1,4 @@ +package app.playmancala.model; + +public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/Json.java b/apps/backend/src/main/java/app/playmancala/model/Json.java new file mode 100644 index 0000000..58b0686 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/Json.java @@ -0,0 +1,33 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +// The JSON mapper for messages, snapshots, records and the cache. Unknown properties are ignored, so data written by +// a newer or older backend can still be read. +public final class Json { + public static final ObjectMapper MAPPER = new ObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + private Json() { + } + + public static String write(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot serialise " + value.getClass().getSimpleName(), e); + } + } + + public static JsonNode tree(Object value) { + return MAPPER.valueToTree(value); + } + + // Throws JsonProcessingException for text that is not JSON. + public static JsonNode parse(String text) throws JsonProcessingException { + return MAPPER.readTree(text); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/LiveGameSummary.java b/apps/backend/src/main/java/app/playmancala/model/LiveGameSummary.java new file mode 100644 index 0000000..85088d1 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/LiveGameSummary.java @@ -0,0 +1,15 @@ +package app.playmancala.model; + +import java.util.List; + +// A live game for previews. Ids are public ids. +public record LiveGameSummary( + String id, + PlayerSummary player1, + PlayerSummary player2, + List board, + String turnPlayerId, + int spectatorCount, + int moveCount, + long startedAt) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/MoveRecord.java b/apps/backend/src/main/java/app/playmancala/model/MoveRecord.java new file mode 100644 index 0000000..5685248 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/MoveRecord.java @@ -0,0 +1,17 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import app.playmancala.engine.GameStep; + +// A move of a saved game. `remainingMs` is the mover's clock after the move; null for games before the game clock. +public record MoveRecord( + String playerId, + int moveIndex, + List boardSnapshot, + List gameSteps, + long at, + @JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/PlayerSummary.java b/apps/backend/src/main/java/app/playmancala/model/PlayerSummary.java new file mode 100644 index 0000000..e9ee937 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/PlayerSummary.java @@ -0,0 +1,15 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +// A player as captured when a game is created. In saved games and lists, `userId` is a public id (PublicIds). +public record PlayerSummary( + String userId, + @JsonInclude(JsonInclude.Include.NON_NULL) String name, + @JsonProperty("isGuest") boolean isGuest) { + + public static PlayerSummary unknown(String userId) { + return new PlayerSummary(userId, null, true); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/Seat.java b/apps/backend/src/main/java/app/playmancala/model/Seat.java new file mode 100644 index 0000000..651f4d3 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/Seat.java @@ -0,0 +1,8 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum Seat { + @JsonProperty("player1") PLAYER1, + @JsonProperty("player2") PLAYER2 +} diff --git a/apps/backend/src/main/java/app/playmancala/model/Series.java b/apps/backend/src/main/java/app/playmancala/model/Series.java new file mode 100644 index 0000000..8a0a18f --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/Series.java @@ -0,0 +1,24 @@ +package app.playmancala.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +// The series a live game belongs to: its id and the games played before this one, oldest first. A game that is not a +// rematch starts a series of its own (docs/specs/rematch). +public record Series(String id, List games) { + public Series { + games = List.copyOf(games); + } + + public static Series start() { + return new Series(UUID.randomUUID().toString(), List.of()); + } + + // The same series with `finished` played. + public Series next(SeriesGame finished) { + List played = new ArrayList<>(games); + played.add(finished); + return new Series(id, played); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/SeriesGame.java b/apps/backend/src/main/java/app/playmancala/model/SeriesGame.java new file mode 100644 index 0000000..e92b6d1 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/SeriesGame.java @@ -0,0 +1,12 @@ +package app.playmancala.model; + +// One finished game of a series, as the series strip shows it (docs/specs/rematch). Seats rather than user ids: the +// players keep their seats through a series, and no id can leak. `result` is null when the game ended without a winner. +public record SeriesGame( + String gameId, + Seat firstSeat, + GameResult result, + EndReason endReason, + int player1Store, + int player2Store) { +} diff --git a/apps/backend/src/main/java/app/playmancala/model/UserConnectionInfo.java b/apps/backend/src/main/java/app/playmancala/model/UserConnectionInfo.java new file mode 100644 index 0000000..9916780 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/UserConnectionInfo.java @@ -0,0 +1,15 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UserConnectionInfo( + String userId, + @JsonInclude(JsonInclude.Include.NON_NULL) String name, + @JsonProperty("isGuest") boolean isGuest, + @JsonProperty("isOnline") boolean isOnline) { + + public static UserConnectionInfo of(PlayerSummary player, boolean isOnline) { + return new UserConnectionInfo(player.userId(), player.name(), player.isGuest(), isOnline); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java b/apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java new file mode 100644 index 0000000..6a57eb2 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java @@ -0,0 +1,24 @@ +package app.playmancala.rtmt; + +import java.util.function.BiConsumer; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.AuthUser; + +// Real-time messaging with connected users, as the game logic sees it. Call it on the game loop only. +public interface Rtmt { + // Sends to every socket of the user; nothing happens if the user is offline. + void sendMessage(String clientId, String channel, Object message); + + // One listener per channel; it gets the sender's user id and the message (JSON null if none was sent). + void listenMessage(String channel, BiConsumer callback); + + boolean isClientOnline(String clientId); + + // Null if the user is not connected. + AuthUser getUser(String clientId); + + // Adds a listener; every listener is called, also for each further socket of a user who is already online. + void listenOnClientConnectionChange(BiConsumer listener); +} diff --git a/apps/backend/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java new file mode 100644 index 0000000..de16978 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java @@ -0,0 +1,93 @@ +package app.playmancala.rtmt; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import org.jboss.logging.Logger; + +import app.playmancala.Backend; +import io.quarkus.websockets.next.CloseReason; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnError; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import io.smallrye.common.annotation.NonBlocking; +import jakarta.inject.Inject; + +// Clients connect to the root path, as they did to the Node backend. Events run on the connection's event loop, in +// order, and are only handed to the game loop, where RtmtServer handles them. +@WebSocket(path = "/") +public class RtmtEndpoint { + private static final Logger LOG = Logger.getLogger(RtmtEndpoint.class); + + @Inject + Backend backend; + + @OnOpen + @NonBlocking + public void onOpen(WebSocketConnection connection) { + RtmtSocket socket = new ConnectionSocket(connection); + backend.loop().execute(() -> backend.rtmt().onOpen(socket)); + } + + @OnTextMessage + @NonBlocking + public void onTextMessage(WebSocketConnection connection, String text) { + RtmtSocket socket = new ConnectionSocket(connection); + backend.loop().execute(() -> backend.rtmt().onText(socket, text)); + } + + @OnClose + @NonBlocking + public void onClose(WebSocketConnection connection) { + RtmtSocket socket = new ConnectionSocket(connection); + backend.loop().execute(() -> backend.rtmt().onClose(socket)); + } + + @OnError + @NonBlocking + public void onError(WebSocketConnection connection, Throwable error) { + // A close event follows, which cleans up. + LOG.debugf("(RTMT) WebSocket error: %s", error.getMessage()); + } + + private record ConnectionSocket(WebSocketConnection connection) implements RtmtSocket { + @Override + public String id() { + return connection.id(); + } + + @Override + public String queryParam(String name) { + String query = connection.handshakeRequest().query(); + if (query == null || query.isEmpty()) return null; + for (String pair : query.split("&")) { + int equals = pair.indexOf('='); + String key = equals < 0 ? pair : pair.substring(0, equals); + if (URLDecoder.decode(key, StandardCharsets.UTF_8).equals(name)) { + return equals < 0 ? "" : URLDecoder.decode(pair.substring(equals + 1), StandardCharsets.UTF_8); + } + } + return null; + } + + @Override + public boolean isOpen() { + return connection.isOpen(); + } + + @Override + public void send(String text) { + connection.sendText(text).subscribe().with(ok -> { }, + error -> LOG.debugf("(RTMT) Could not send: %s", error.getMessage())); + } + + @Override + public void close(int code, String reason) { + connection.close(new CloseReason(code, reason)).subscribe().with(ok -> { }, + error -> LOG.debugf("(RTMT) Could not close: %s", error.getMessage())); + } + } +} diff --git a/apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java new file mode 100644 index 0000000..50eaa73 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java @@ -0,0 +1,256 @@ +package app.playmancala.rtmt; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.function.BiConsumer; +import java.util.regex.Pattern; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.NullNode; + +import app.playmancala.auth.InvalidTokenException; +import app.playmancala.auth.TokenVerifier; +import app.playmancala.loop.Cancellable; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.Json; + +// The WebSocket protocol: authentication, routing messages by channel, users with several sockets and the ping +// keep-alive. Messages are JSON objects { channel, message }. Everything here runs on the game loop; the endpoint +// forwards socket events to it, and tokens are checked on `verifier` and the outcome handled back on the loop. +public class RtmtServer implements Rtmt { + public static final long PING_INTERVAL_MS = 2_000; + public static final long DEFAULT_AUTH_TIMEOUT_MS = 5_000; + private static final Logger LOG = Logger.getLogger(RtmtServer.class); + private static final Pattern LEGACY_USER_KEY_PATTERN = + Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE); + + private record Envelope(String channel, Object message) { + } + + private static final class Client { + final RtmtSocket socket; + boolean alive = true; + // Set once the socket is authenticated. + String userId; + Cancellable authTimeout; + + Client(RtmtSocket socket) { + this.socket = socket; + } + } + + private final TokenVerifier tokenVerifier; + private final boolean legacyUserKeyAuth; + private final Scheduler loop; + private final Executor verifier; + private final long authTimeoutMs; + + private final Map> messageChannels = new HashMap<>(); + // By socket id. + private final Map clients = new HashMap<>(); + // A user can be connected from several devices or tabs at once. + private final Map> sockets = new HashMap<>(); + private final Map users = new HashMap<>(); + private final List> connectionChangeListeners = new ArrayList<>(); + private Cancellable pingTimer; + + public RtmtServer(TokenVerifier tokenVerifier, boolean legacyUserKeyAuth, Scheduler loop, Executor verifier, + long authTimeoutMs) { + this.tokenVerifier = tokenVerifier; + this.legacyUserKeyAuth = legacyUserKeyAuth; + this.loop = loop; + this.verifier = verifier; + this.authTimeoutMs = authTimeoutMs; + } + + public void start() { + stop(); + pingTimer = loop.scheduleAtFixedRate(this::ping, PING_INTERVAL_MS); + } + + public void stop() { + if (pingTimer != null) pingTimer.cancel(); + pingTimer = null; + } + + public void onOpen(RtmtSocket socket) { + LOG.debug("(RTMT) Client connected"); + Client client = new Client(socket); + clients.put(socket.id(), client); + + String legacyUserKey = socket.queryParam("userKey"); + if (legacyUserKey != null) { + if (legacyUserKeyAuth && LEGACY_USER_KEY_PATTERN.matcher(legacyUserKey).matches()) { + bindSocket(client, new AuthUser(legacyUserKey, null, true)); + } else { + rejectSocket(client, "userKey login is not accepted"); + } + return; + } + + client.authTimeout = loop.schedule(() -> { + if (client.userId == null) rejectSocket(client, "authentication timeout"); + }, authTimeoutMs); + } + + public void onClose(RtmtSocket socket) { + Client client = clients.remove(socket.id()); + if (client == null) return; + LOG.debug("(RTMT) Client disconnected"); + if (client.authTimeout != null) client.authTimeout.cancel(); + unbindSocket(client); + } + + public void onText(RtmtSocket socket, String text) { + Client client = clients.get(socket.id()); + if (client == null) return; + JsonNode envelope; + try { + envelope = Json.parse(text); + } catch (Exception e) { + LOG.debug("(RTMT) Ignoring malformed message"); + return; + } + if (envelope == null || !envelope.path("channel").isTextual()) return; + String channel = envelope.get("channel").asText(); + JsonNode message = envelope.has("message") ? envelope.get("message") : NullNode.getInstance(); + + if (channel.equals(Channels.PONG)) { + client.alive = true; + return; + } + if (channel.equals(Channels.AUTH)) { + authenticate(client, message); + return; + } + if (client.userId == null) { + LOG.debugf("(RTMT) Ignoring message from unauthenticated client on channel %s", channel); + return; + } + BiConsumer callback = messageChannels.get(channel); + if (callback != null) { + callback.accept(client.userId, message); + } else { + LOG.debugf("(RTMT) Channel callback not found! %s", channel); + } + } + + private void authenticate(Client client, JsonNode message) { + JsonNode token = message.path("token"); + if (!token.isTextual()) { + rejectSocket(client, "missing token"); + return; + } + verifier.execute(() -> { + try { + AuthUser user = tokenVerifier.verify(token.asText()); + loop.execute(() -> onAuthenticated(client, user)); + } catch (InvalidTokenException e) { + loop.execute(() -> rejectSocket(client, "invalid token")); + } catch (RuntimeException e) { + LOG.error("(RTMT) Token verification failed", e); + loop.execute(() -> rejectSocket(client, "authentication failed")); + } + }); + } + + private void onAuthenticated(Client client, AuthUser user) { + if (!client.socket.isOpen() || clients.get(client.socket.id()) != client) return; + if (client.userId != null && !client.userId.equals(user.id())) { + rejectSocket(client, "token belongs to another user"); + return; + } + if (client.authTimeout != null) client.authTimeout.cancel(); + if (client.userId != null) { + // Token refresh on an already authenticated socket. + users.put(user.id(), user); + } else { + bindSocket(client, user); + } + send(client, Channels.ON_AUTH, Map.of("user", user)); + } + + private void rejectSocket(Client client, String reason) { + if (!client.socket.isOpen()) return; + send(client, Channels.ON_AUTH_ERROR, Map.of("reason", reason)); + client.socket.close(Channels.CLOSE_AUTH_FAILED, reason); + } + + private void bindSocket(Client client, AuthUser user) { + client.userId = user.id(); + users.put(user.id(), user); + sockets.computeIfAbsent(user.id(), id -> new LinkedHashSet<>()).add(client); + // Fired for every new socket so each device gets the current game state. + List.copyOf(connectionChangeListeners).forEach(listener -> listener.accept(user.id(), true)); + } + + private void unbindSocket(Client client) { + String userId = client.userId; + if (userId == null) return; + Set userSockets = sockets.get(userId); + if (userSockets == null || !userSockets.remove(client)) return; + if (userSockets.isEmpty()) { + sockets.remove(userId); + users.remove(userId); + List.copyOf(connectionChangeListeners).forEach(listener -> listener.accept(userId, false)); + } + } + + private void ping() { + for (Client client : List.copyOf(clients.values())) { + if (!client.alive) { + LOG.debug("(RTMT) Closing a socket that did not answer the ping"); + client.socket.close(1001, "ping timeout"); + continue; + } + client.alive = false; + send(client, Channels.PING, Map.of()); + } + } + + @Override + public void sendMessage(String clientId, String channel, Object message) { + Set userSockets = sockets.get(clientId); + if (userSockets == null) { + LOG.debugf("(RTMT) Client not connected: %s", clientId); + return; + } + String text = Json.write(new Envelope(channel, message)); + userSockets.forEach(client -> { + if (client.socket.isOpen()) client.socket.send(text); + }); + } + + private void send(Client client, String channel, Object message) { + if (client.socket.isOpen()) client.socket.send(Json.write(new Envelope(channel, message))); + } + + @Override + public void listenMessage(String channel, BiConsumer callback) { + messageChannels.put(channel, callback); + } + + @Override + public boolean isClientOnline(String clientId) { + return sockets.containsKey(clientId); + } + + @Override + public AuthUser getUser(String clientId) { + return users.get(clientId); + } + + @Override + public void listenOnClientConnectionChange(BiConsumer listener) { + connectionChangeListeners.add(listener); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/rtmt/RtmtSocket.java b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtSocket.java new file mode 100644 index 0000000..f8af2a2 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtSocket.java @@ -0,0 +1,15 @@ +package app.playmancala.rtmt; + +// A client's WebSocket as RtmtServer sees it. Sends and closes do not block. +public interface RtmtSocket { + String id(); + + // A query parameter of the URL the client connected to, or null. + String queryParam(String name); + + boolean isOpen(); + + void send(String text); + + void close(int code, String reason); +} diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties new file mode 100644 index 0000000..a6e3fe4 --- /dev/null +++ b/apps/backend/src/main/resources/application.properties @@ -0,0 +1,44 @@ +# Settings come from environment variables with the Node backend's names; see README.md. + +# HTTP and WebSocket +quarkus.http.port=${PORT:5005} +quarkus.http.test-port=0 +quarkus.http.cors.enabled=true +quarkus.http.cors.origins=* +quarkus.http.access-log.enabled=true +quarkus.http.access-log.pattern=common +quarkus.http.access-log.log-to-file=true +quarkus.http.access-log.base-file-name=access +quarkus.http.access-log.log-directory=. +quarkus.http.access-log.rotate=false +%test.quarkus.http.access-log.enabled=false + +# Keycloak +mancala.auth.issuer=${KEYCLOAK_ISSUER:http://localhost:8080/realms/mancala} +# Empty: the issuer's certs endpoint. +mancala.auth.jwks-url=${KEYCLOAK_JWKS_URL:} +mancala.auth.client-ids=${KEYCLOAK_CLIENT_IDS:mancala-web} +mancala.auth.legacy-userkey-auth=${LEGACY_USERKEY_AUTH:true} + +# Time each player has for a whole game (docs/specs/game-clock). +mancala.game-clock-ms=${GAME_CLOCK_MS:300000} + +# Threads working out the bot's moves (docs/specs/game-bot); 0 is half the processors. +mancala.bot-search-threads=${BOT_SEARCH_THREADS:0} + +# Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried. +quarkus.datasource.db-kind=postgresql +%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala} +%dev,prod.quarkus.datasource.username=${DATABASE_USERNAME:mancala} +%dev,prod.quarkus.datasource.password=${DATABASE_PASSWORD:mancala} +quarkus.datasource.jdbc.max-size=5 +quarkus.datasource.jdbc.acquisition-timeout=5S +quarkus.datasource.devservices.image-name=postgres:17.6 + +# Valkey. Tests start one with Dev Services. Short timeouts, so callers fall back quickly while it is down. +%dev,prod.quarkus.redis.hosts=${VALKEY_URL:redis://localhost:6379} +quarkus.redis.timeout=2S +quarkus.redis.tcp.connection-timeout=1S +quarkus.redis.devservices.image-name=valkey/valkey:9.1.2 +# Nothing listens on port 1; for the tests of an unreachable Valkey. +%test.quarkus.redis.offline.hosts=redis://127.0.0.1:1 diff --git a/apps/backend/src/main/resources/db/migrations/001_games.sql b/apps/backend/src/main/resources/db/migrations/001_games.sql new file mode 100644 index 0000000..f5eb0fc --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/001_games.sql @@ -0,0 +1,27 @@ +CREATE TABLE games ( + id uuid PRIMARY KEY, + player1_id text NOT NULL, + player2_id text NOT NULL, + result text CHECK (result IN ('player1', 'player2', 'draw')), + end_reason text NOT NULL CHECK (end_reason IN ('completed', 'left', 'timeout', 'inactive')), + left_user_id text, + started_at timestamptz NOT NULL, + ended_at timestamptz NOT NULL, + move_count integer NOT NULL, + final_board jsonb NOT NULL, + moves jsonb NOT NULL, + peak_spectators integer NOT NULL DEFAULT 0, + total_spectators integer NOT NULL DEFAULT 0 +); + +CREATE TABLE game_players ( + game_id uuid NOT NULL REFERENCES games (id) ON DELETE CASCADE, + seat smallint NOT NULL CHECK (seat IN (1, 2)), + user_id text NOT NULL, + name text, + is_guest boolean NOT NULL, + PRIMARY KEY (game_id, seat) +); + +CREATE INDEX game_players_user_id ON game_players (user_id); +CREATE INDEX games_popular ON games (peak_spectators DESC, ended_at DESC); diff --git a/apps/backend/src/main/resources/db/migrations/002_clock.sql b/apps/backend/src/main/resources/db/migrations/002_clock.sql new file mode 100644 index 0000000..10c4a38 --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/002_clock.sql @@ -0,0 +1,2 @@ +-- Time each player started with; NULL for games saved before the game clock. Each move's clock is in `moves`. +ALTER TABLE games ADD COLUMN clock_initial_ms integer; diff --git a/apps/backend/src/main/resources/db/migrations/003_bot.sql b/apps/backend/src/main/resources/db/migrations/003_bot.sql new file mode 100644 index 0000000..0119f3d --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/003_bot.sql @@ -0,0 +1,2 @@ +-- How the bot played in a game against the bot: { level, strength, seed }; NULL for games between people. +ALTER TABLE games ADD COLUMN bot jsonb; diff --git a/apps/backend/src/main/resources/db/migrations/004_series.sql b/apps/backend/src/main/resources/db/migrations/004_series.sql new file mode 100644 index 0000000..0abf21f --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/004_series.sql @@ -0,0 +1,3 @@ +-- The series a game belongs to: a game and the rematches after it (docs/specs/rematch). NULL for games saved before. +ALTER TABLE games ADD COLUMN series_id uuid; +CREATE INDEX games_series_id ON games (series_id); diff --git a/apps/backend/src/matchmaker/MatchMaker.ts b/apps/backend/src/matchmaker/MatchMaker.ts deleted file mode 100644 index 737bf37..0000000 --- a/apps/backend/src/matchmaker/MatchMaker.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type OnPlayersPaired = (player1Id: string, player2Id: string)=> void; - -export interface MatchMaker { - join(playerId : string): void; - cancel(playerId : string): boolean; - listenOnPlayersPaired(onPlayersPaired: OnPlayersPaired ) : void; -} \ No newline at end of file diff --git a/apps/backend/src/matchmaker/MatchMakerImpl.ts b/apps/backend/src/matchmaker/MatchMakerImpl.ts deleted file mode 100644 index ec4faf9..0000000 --- a/apps/backend/src/matchmaker/MatchMakerImpl.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { RTMT } from "../rtmt/rtmt"; -import { MatchMaker, OnPlayersPaired } from "./MatchMaker"; - -export class MatchMakerImpl implements MatchMaker { - private waitingPlayerId?: string; - - private onPlayersPaired?: OnPlayersPaired; - - private rtmt: RTMT; - - constructor(params: { rtmt: RTMT }) { - this.rtmt = params.rtmt; - } - - private fireOnPlayerPaired(player1Id: string, player2Id: string): void { - this.onPlayersPaired?.(player1Id, player2Id) - } - - private setWaitingPlayerId(playerKey?: string) { - this.waitingPlayerId = playerKey; - } - - public join(newcomerPlayerId: string): void { - if (this.waitingPlayerId === newcomerPlayerId) return; - if (this.waitingPlayerId && this.rtmt.isClientOnline(this.waitingPlayerId)) { - this.fireOnPlayerPaired(this.waitingPlayerId, newcomerPlayerId) - this.setWaitingPlayerId(undefined); - } else { - this.waitingPlayerId = newcomerPlayerId - } - } - - public cancel(playerId: string): boolean { - if(playerId === this.waitingPlayerId) { - this.waitingPlayerId = undefined; - return true; - } - - return false; - } - - public listenOnPlayersPaired(onPlayersPaired: OnPlayersPaired): void { - this.onPlayersPaired = onPlayersPaired; - } -} \ No newline at end of file diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts deleted file mode 100644 index 01854f5..0000000 --- a/apps/backend/src/models/Game.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Game } from "@mancala/core"; - -export interface BackendGame extends Game { - spectatorIds: string[]; -} \ No newline at end of file diff --git a/apps/backend/src/rtmt/encode_decode_message.ts b/apps/backend/src/rtmt/encode_decode_message.ts deleted file mode 100644 index 11908db..0000000 --- a/apps/backend/src/rtmt/encode_decode_message.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type Message = { - channel: string, - message: Object, -} - -export function encode(message: Message) { - return JSON.stringify({ - channel : message.channel, - message : message.message - }); -} - -export function decode(bytes: string): Message { - return JSON.parse(bytes); -} diff --git a/apps/backend/src/rtmt/rtmt.ts b/apps/backend/src/rtmt/rtmt.ts deleted file mode 100644 index fe735d7..0000000 --- a/apps/backend/src/rtmt/rtmt.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type OnMessage = (clientID: string, message: Object) => any -export type OnClientConnectionChange = (clientID: string, isOnline : boolean) => any - -export interface RTMT { - sendMessage: (clientID: string, channel: string, message: Object) => any; - listenMessage: (channel: string, callback: OnMessage) => any; - isClientOnline(clientID: string): boolean; - listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void; -} \ No newline at end of file diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts deleted file mode 100644 index fd3078d..0000000 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { OnClientConnectionChange, OnMessage, RTMT } from "./rtmt" - -import WebSocket from "ws" -import * as http from 'http'; -import { channel_ping, channel_pong } from "@mancala/core"; -import { RTMT_WS_PING_INTERVAL } from "../consts/config"; -import { decode, encode } from "./encode_decode_message"; - -export class RTMTWS implements RTMT { - - private messageChannels: Map - - private wsServer: WebSocket.Server | null - - public clients = new Map() - - private pingInterval?: NodeJS.Timeout = undefined; - - onUserConnectionChange?: OnClientConnectionChange; - - constructor() { - this.messageChannels = new Map() - this.wsServer = null - } - - initWebSocket(server: http.Server, onopen: (userKey: string) => any) { - const wsServer = new WebSocket.Server({ server }) - this.wsServer = wsServer - this.clients = new Map() - - wsServer.on("connection", (ws: WebSocket, req: Request) => { - console.log("(RTMT) Client Connected"); - const regexResult = req.url.split(RegExp("\/\?userKey=")); - const clientID = regexResult[1] - this.clients.set(clientID, ws) - this.onUserConnectionChange?.(clientID, true); - ws.on("message", (messageBytes: string) => { - this.onWebSocketMessage(clientID, messageBytes) - }) - - ws.on("close", (code: number, reason: string) => { - console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason); - this.clients.delete(clientID) - this.onUserConnectionChange?.(clientID, false); - }) - - ws.on("error", (err: Error) => { - console.log("(RTMT) WS Closed with error! error : " + err.message); - this.clients.delete(clientID) - }) - - this.setWebSocketAsAlive(ws); - onopen(clientID) - }) - this.startPingInterval(wsServer); - } - - dispose() { - this.stopPingInterval(); - } - - sendMessage(clientID: string, channel: string, message: Object) { - if (this.wsServer) { - const client = this.clients.get(clientID) - if (client) { - this.sendMessageInternal(client, channel, message); - } else { - console.log("(RTMT) Client not connected!"); - } - } else { - console.log('(RTMT) ws is undefined') - } - } - - sendMessageInternal(ws: WebSocket, channel: string, message: Object) { - const data = encode({ channel: channel, message: message }) - ws.send(data) - } - - listenMessage(channel: string, callback: OnMessage) { - this.messageChannels.set(channel, callback) - } - - onWebSocketMessage(clientID: string, messageBytes: string) { - const message = decode(messageBytes) - this.onMessage(clientID, message.channel, message.message) - - } - - onMessage(clientID: string, channel: string, message: Object) { - if (channel === channel_pong) { - const ws = this.clients.get(clientID); - if (ws) { - this.heartbeat(ws); - } - return; - } - const callback = this.messageChannels.get(channel) - if (callback) { - callback(clientID, message) - } else { - console.log("(RTMT) Channel callback not found! " + channel); - } - } - - startPingInterval(wsServer: WebSocket.Server) { - this.stopPingInterval(); - this.pingInterval = setInterval(() => { - const sendMessageInternal = this.sendMessageInternal; - wsServer.clients.forEach(function each(ws) { - //@ts-ignore - if (ws.isAlive === false) { - console.log("(RTMT) Terminated WebSocket Connection"); - return ws.terminate(); - } - //@ts-ignore - ws.isAlive = false; - sendMessageInternal(ws, channel_ping, {}); - }); - }, RTMT_WS_PING_INTERVAL); - } - - stopPingInterval() { - if (this.pingInterval) clearInterval(this.pingInterval); - } - - heartbeat(ws: WebSocket) { - this.setWebSocketAsAlive(ws); - } - - setWebSocketAsAlive(ws: WebSocket) { - //@ts-ignore - ws.isAlive = true; - } - - isClientOnline(clientID: string): boolean { - return this.clients.has(clientID); - } - - listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) { - this.onUserConnectionChange = onUserConnectionChange; - } -} \ No newline at end of file diff --git a/apps/backend/src/server/ExpressApp.ts b/apps/backend/src/server/ExpressApp.ts deleted file mode 100644 index e8a0d72..0000000 --- a/apps/backend/src/server/ExpressApp.ts +++ /dev/null @@ -1,45 +0,0 @@ -import cors from "cors"; -import express from "express"; -import { Request, Response } from "express"; -import morgan from "morgan"; -import fs from "fs"; -import { generateKey } from "../util/key_factory"; -import { GameStore } from "../game/gamestore/GameStore"; - -export class ExpressApp { - app: Express.Application; - gameStore: GameStore; - constructor(params: { gameStore: GameStore }) { - this.gameStore = params.gameStore; - this.app = this.createExpressApp(); - } - private createAccessLogMiddleware() { - return morgan("common", { - stream: fs.createWriteStream("./access.log", { flags: "a" }), - }); - } - public createExpressApp(): Express.Application { - const app = express(); - app.use(cors()); - app.use(this.createAccessLogMiddleware()); - app.use(morgan("dev")); - app.get("/", (req: Request, res: Response) => { - res.send("Server up and running! ci test1"); - }); - app.get("/register/", (req: Request, res: Response) => { - res.send(generateKey()); - }); - - app.get("/game/:id", (req: Request, res: Response) => { - const gameId = req.params.id; - const game = this.gameStore.get(gameId); - if(game) { - res.send(game) - } else { - res.sendStatus(404) - } - }); - - return app; - } -} diff --git a/apps/backend/src/server/WebServer.ts b/apps/backend/src/server/WebServer.ts deleted file mode 100644 index fa82ad6..0000000 --- a/apps/backend/src/server/WebServer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import express, { Request, Response } from "express"; -import * as http from "http"; - -export class WebServer { - server: http.Server; - constructor(props: { expressApp: Express.Application }) { - this.server = this.createWebServer(props.expressApp); - } - public createWebServer(expressApp: Express.Application): http.Server { - const server = http.createServer(expressApp); - const port = process.env.PORT || 5005; - server.listen(port, () => { - console.log(`Server started on port ${port}`); - }); - return server; - } -} - - - - diff --git a/apps/backend/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java b/apps/backend/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java new file mode 100644 index 0000000..22d2eb6 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java @@ -0,0 +1,112 @@ +package app.playmancala.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.RsaJsonWebKey; +import org.jose4j.jwk.RsaJwkGenerator; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.keys.resolvers.JwksVerificationKeyResolver; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import app.playmancala.model.AuthUser; + +class KeycloakTokenVerifierTest { + private static final String ISSUER = "http://keycloak.test/realms/mancala"; + + private static RsaJsonWebKey key; + private static RsaJsonWebKey otherKey; + private static KeycloakTokenVerifier verifier; + + @BeforeAll + static void createKeys() throws Exception { + key = RsaJwkGenerator.generateJwk(2048); + key.setKeyId("test-key"); + otherKey = RsaJwkGenerator.generateJwk(2048); + otherKey.setKeyId("test-key"); + JsonWebKey publicKey = JsonWebKey.Factory.newJwk(key.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY)); + verifier = new KeycloakTokenVerifier(ISSUER, List.of("mancala-web"), + new JwksVerificationKeyResolver(List.of(publicKey))); + } + + private static String sign(Consumer customize) { + return sign(customize, key); + } + + private static String sign(Consumer customize, RsaJsonWebKey signingKey) { + try { + JwtClaims claims = new JwtClaims(); + claims.setIssuer(ISSUER); + claims.setSubject("user-1"); + claims.setIssuedAtToNow(); + claims.setExpirationTimeMinutesInTheFuture(5); + claims.setClaim("typ", "Bearer"); + claims.setClaim("azp", "mancala-web"); + claims.setClaim("preferred_username", "alice"); + customize.accept(claims); + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(signingKey.getPrivateKey()); + jws.setKeyIdHeaderValue(signingKey.getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + return jws.getCompactSerialization(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Test + void returnsTheUserOfAValidAccessToken() throws Exception { + AuthUser user = verifier.verify(sign(claims -> claims.setClaim("realm_access", Map.of("roles", List.of("offline_access"))))); + assertEquals(new AuthUser("kc:user-1", "alice", false), user); + } + + @Test + void marksUsersWithTheGuestRoleAsGuests() throws Exception { + AuthUser user = verifier.verify(sign(claims -> { + claims.setClaim("preferred_username", "guest-1a2b3c4d"); + claims.setClaim("realm_access", Map.of("roles", List.of("guest"))); + })); + assertEquals(new AuthUser("kc:user-1", "guest-1a2b3c4d", true), user); + } + + static Stream invalidTokens() { + return Stream.of( + Arguments.of("signed with another key", sign(claims -> { }, otherKey)), + Arguments.of("from another issuer", sign(claims -> claims.setIssuer("http://evil.test/realms/mancala"))), + Arguments.of("expired", sign(claims -> claims.setExpirationTimeMinutesInTheFuture(-1))), + Arguments.of("an ID token", sign(claims -> claims.setClaim("typ", "ID"))), + Arguments.of("a refresh token", sign(claims -> claims.setClaim("typ", "Refresh"))), + Arguments.of("issued to another client", sign(claims -> claims.setClaim("azp", "some-other-app"))), + Arguments.of("without a subject", sign(claims -> claims.unsetClaim("sub"))), + Arguments.of("not a JWT", "not-a-token")); + } + + @ParameterizedTest(name = "rejects a token {0}") + @MethodSource("invalidTokens") + void rejectsInvalidTokens(String description, String token) { + assertThrows(InvalidTokenException.class, () -> verifier.verify(token)); + } + + @Test + void rejectsUnsignedTokens() { + Base64.Encoder base64 = Base64.getUrlEncoder().withoutPadding(); + String header = base64.encodeToString("{\"alg\":\"none\"}".getBytes()); + String payload = base64.encodeToString(("{\"iss\":\"" + ISSUER + "\",\"sub\":\"user-1\",\"typ\":\"Bearer\"," + + "\"azp\":\"mancala-web\",\"exp\":" + (System.currentTimeMillis() / 1000 + 300) + "}").getBytes()); + assertThrows(InvalidTokenException.class, () -> verifier.verify(header + "." + payload + ".")); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java new file mode 100644 index 0000000..a6ae07b --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -0,0 +1,250 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import app.playmancala.engine.GameState; +import app.playmancala.engine.Pit; +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +import app.playmancala.game.BackendGame; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameRecorder; +import app.playmancala.game.GameStore; +import app.playmancala.game.MatchMaker; +import app.playmancala.game.PublicIds; +import app.playmancala.loop.ManualScheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; +import app.playmancala.model.PlayerSummary; + +// Games against the bot, with the real game logic, a manual clock and searches that run at once. +class BotPlayerTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false); + static final long CLOCK_MS = 5 * 60_000; + // Longer than any wait: the start delay or a long animation plus the longest thinking time. + static final long LONGEST_WAIT_MS = 20_000; + + ManualScheduler loop; + FakeRtmt rtmt; + GameStore gameStore; + InMemoryLiveGameStore liveGameStore; + InMemoryGameHistoryRepository repository; + GameManager gameManager; + BotPlayer botPlayer; + + void createBackend() { + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + GameRecorder recorder = new GameRecorder(repository, liveGameStore, Runnable::run); + gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run, + loop::now, CLOCK_MS); + botPlayer = new BotPlayer(gameManager, gameStore, loop, Runnable::run, new SplittableRandom(1)); + rtmt.connect(ALICE); + rtmt.connect(CAROL); + } + + @BeforeEach + void setUp() { + loop = new ManualScheduler(Instant.parse("2026-10-16T12:00:00Z").toEpochMilli()); + liveGameStore = new InMemoryLiveGameStore(); + repository = new InMemoryGameHistoryRepository(); + createBackend(); + } + + // A game between Alice and the bot, with the given player to move first. + BackendGame startBotGame(BotSettings bot, String firstPlayerId, int... stones) { + BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot); + game.mancalaGame.setTurnPlayerId(firstPlayerId); + setBoard(game, stones); + gameManager.startGame(game); + return game; + } + + static void setBoard(BackendGame game, int... stones) { + List pits = game.mancalaGame.getBoard().getPits(); + for (int i = 0; i < stones.length; i++) pits.get(i).setStoneCount(stones[i]); + } + + void playAnyMove(BackendGame game, String playerId) { + List pits = game.mancalaGame.getBoard().getPits(); + for (int pit = 0; pit < 6; pit++) { + if (pits.get(game.mancalaGame.getBoardIndexByPlayerId(playerId, pit)).getStoneCount() > 0) { + rtmt.receive(playerId, Channels.GAME_MOVE, Map.of("index", pit)); + return; + } + } + throw new IllegalStateException("no move"); + } + + @Test + void startsAGameAgainstTheBotAtTheChosenLevel() { + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "medium")); + BackendGame game = gameStore.getGameByUser(ALICE.id()); + assertNotNull(game); + assertEquals("bot:medium", game.mancalaGame.getPlayer2Id()); + assertEquals(BotLevel.MEDIUM, game.bot.level()); + assertTrue(game.bot.strength() >= 30 && game.bot.strength() <= 55); + assertEquals(new PlayerSummary("bot:medium", "Bot", false), game.player("bot:medium")); + + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(game.id, start.get("id").asText()); + JsonNode bot = start.at("/gameUsersConnectionInfo/user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", "bot:medium", "name", "Bot", "isGuest", false, "isOnline", true)), bot); + assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); + // The bot is not mapped to the game; it plays many at once. + assertNull(gameStore.getGameByUser("bot:medium")); + } + + @Test + void endsTheSearchForAnOpponent() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "easy")); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + assertEquals("bot:easy", gameStore.getGameByUser(ALICE.id()).mancalaGame.getPlayer2Id()); + assertNull(gameStore.getGameByUser(CAROL.id())); + } + + @Test + void refusesABotGameDuringAnotherGame() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + BackendGame game = gameStore.getGameByUser(ALICE.id()); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "hard")); + assertSame(game, gameStore.getGameByUser(ALICE.id())); + assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), + rtmt.messagesTo(ALICE.id(), Channels.ON_NEW_GAME_REJECTED)); + } + + @Test + void ignoresUnknownLevels() { + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "impossible")); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, "master"); + assertNull(gameStore.getGameByUser(ALICE.id())); + } + + @Test + void movesAfterTheStartDelayAndThinkingTime() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), "bot:easy"); + loop.advance(BotPlayer.GAME_START_DELAY_MS + 800 - 1); + assertEquals(0, game.mancalaGame.getHistory().size()); + loop.advance(1000 + 1); + assertEquals(1, game.mancalaGame.getHistory().size()); + assertEquals("bot:easy", game.mancalaGame.getHistory().getFirst().getPlayerId()); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_UPDATE).size()); + } + + @Test + void waitsForTheAnimationOfThePlayersMove() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), ALICE.id()); + // Pit 2 with 4 stones passes the turn: four steps to animate. + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2)); + assertTrue(game.isBotTurn()); + long animation = 5 * BotPlayer.ANIMATION_STEP_MS; + loop.advance(animation + 800 - 1); + assertEquals(1, game.mancalaGame.getHistory().size()); + loop.advance(1000 + 1); + assertEquals(2, game.mancalaGame.getHistory().size()); + } + + @Test + void playsAgainAfterAnExtraTurn() { + // The only move, pit 11 with 3 stones, ends in the bank and leaves stones in pits 11 and 12. + BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 100, 3), "bot:master", + 3, 3, 3, 3, 3, 3, 10, 0, 0, 0, 0, 3, 0, 10); + loop.advance(LONGEST_WAIT_MS); + List movers = game.mancalaGame.getHistory().stream().map(move -> move.getPlayerId()).toList(); + assertEquals(List.of("bot:master", "bot:master"), movers.subList(0, 2)); + assertEquals(11, game.mancalaGame.getHistory().getFirst().getMoveIndex()); + } + + @Test + void dropsTheMoveWhenTheGameEndedMeanwhile() { + BackendGame game = startBotGame(new BotSettings(BotLevel.HARD, 70, 1), "bot:hard"); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + loop.advance(LONGEST_WAIT_MS); + assertEquals(0, game.mancalaGame.getHistory().size()); + assertEquals(0, loop.pendingTimers()); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.LEFT, record.endReason()); + } + + @Test + void hurriesWhenItsClockRunsLow() { + BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 90, 1), ALICE.id()); + game.clock = new GameClock(CLOCK_MS, CLOCK_MS, 20_000, loop.now()); + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2)); + loop.advance(BotPlayer.LOW_TIME_THINK_MS); + assertEquals(2, game.mancalaGame.getHistory().size()); + } + + @Test + void playsAWholeGameThatIsSavedWithTheBot() { + BackendGame game = startBotGame(new BotSettings(BotLevel.MEDIUM, 40, 11), ALICE.id()); + for (int turn = 0; turn < 200 && game.mancalaGame.getState() != GameState.ENDED; turn++) { + if (game.isBotTurn()) loop.advance(LONGEST_WAIT_MS); + else playAnyMove(game, ALICE.id()); + } + GameRecord record = repository.records.get(game.id); + assertNotNull(record, "the game did not end"); + assertEquals(EndReason.COMPLETED, record.endReason()); + assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2()); + assertEquals(new BotSettings(BotLevel.MEDIUM, 40, 11), record.bot()); + assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium"))); + assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); + } + + @Test + void goesOnAfterARestartAtTheSameStrength() { + BotSettings bot = new BotSettings(BotLevel.HARD, 66, 99); + BackendGame game = startBotGame(bot, "bot:hard"); + assertEquals(Json.tree(Map.of("level", "hard", "strength", 66, "seed", 99L)), + liveGameStore.games.get(game.id).get("bot")); + + // A new process: the old loop and its timers are gone. + loop = new ManualScheduler(loop.now()); + createBackend(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.get(game.id); + assertEquals(bot, restored.bot); + loop.advance(LONGEST_WAIT_MS); + assertEquals(1, restored.mancalaGame.getHistory().size()); + } + + @Test + void restoresABotGameWithoutSettingsAtTheMiddleOfItsLevel() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 6, 5), ALICE.id()); + ((ObjectNode) liveGameStore.games.get(game.id)).remove("bot"); + + loop = new ManualScheduler(loop.now()); + createBackend(); + gameManager.restoreLiveGames(); + assertEquals(BotLevel.EASY, gameStore.get(game.id).bot.level()); + assertEquals(BotLevel.EASY.middleStrength(), gameStore.get(game.id).bot.strength()); + } + + @Test + void botIdsArePublic() { + assertEquals("bot:easy", PublicIds.publicUserId("bot:easy")); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java new file mode 100644 index 0000000..68bf92e --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java @@ -0,0 +1,126 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +class BotPolicyTest { + + @Test + void levelsHaveWireNamesAndBotIds() { + assertEquals(BotLevel.MEDIUM, BotLevel.fromWireName("medium")); + assertNull(BotLevel.fromWireName("impossible")); + assertEquals("bot:master", BotLevel.MASTER.botId()); + assertEquals(BotLevel.EASY, BotLevel.fromBotId("bot:easy")); + assertTrue(BotLevel.isBotId("bot:hard")); + assertNull(BotLevel.fromBotId("kc:someone")); + } + + @Test + void strengthsAreDrawnWithinTheLevel() { + SplittableRandom random = new SplittableRandom(1); + for (BotLevel level : BotLevel.values()) { + for (int i = 0; i < 500; i++) { + int strength = BotSettings.draw(level, random).strength(); + assertTrue(strength >= level.minStrength() && strength <= level.maxStrength(), level + " " + strength); + } + } + } + + @Test + void strengthIsInterpolatedFromTheTable() { + assertParams(1, 4, 0.35, BotStrength.at(0)); + assertParams(4, 1, 0.05, BotStrength.at(50)); + assertParams(16, 0.05, 0, BotStrength.at(100)); + BotStrength.Params between = BotStrength.at(37.5); + assertEquals(3, between.depth()); + assertEquals(1.5, between.temperature(), 1e-9); + assertEquals(BotStrength.at(100), BotStrength.at(130)); + } + + private static void assertParams(int depth, double temperature, double randomMoveChance, BotStrength.Params params) { + assertEquals(depth, params.depth()); + assertEquals(temperature, params.temperature(), 1e-9); + assertEquals(randomMoveChance, params.randomMoveChance(), 1e-9); + } + + @Test + void sameSeedAndMoveNumberGiveTheSameMove() { + Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, true); + for (BotLevel level : BotLevel.values()) { + BotSettings bot = new BotSettings(level, level.middleStrength(), 42); + int first = BotPolicy.chooseMove(position, bot, 10); + for (int i = 0; i < 5; i++) assertEquals(first, BotPolicy.chooseMove(position, bot, 10)); + } + } + + @Test + void playsTheOnlyMove() { + Position position = Position.of(new int[] { 0, 0, 0, 0, 0, 3, 20, 1, 1, 1, 1, 1, 1, 19 }, true); + assertEquals(5, BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, 1), 0)); + } + + @Test + void easyMissesAClearBestMoveMuchMoreOftenThanMaster() { + // Pit 0 captures 10 stones; no other move comes close. + Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true); + int[] master = new int[Position.PIT_COUNT]; + int[] easy = new int[Position.PIT_COUNT]; + for (int seed = 0; seed < 200; seed++) { + master[BotPolicy.chooseMove(position, new BotSettings(BotLevel.MASTER, 100, seed), 0)]++; + easy[BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, seed), 0)]++; + } + assertTrue(master[0] >= 195, "Master: " + Arrays.toString(master)); + assertTrue(easy[0] <= 180, "Easy: " + Arrays.toString(easy)); + } + + @Test + void gamesAtTheSameLevelDiffer() { + // R2.5: 20 games against an opponent that always plays its first pit; the bot moves first. + Set> openings = new HashSet<>(); + SplittableRandom random = new SplittableRandom(3); + int[] pits = new int[Position.PIT_COUNT]; + for (int game = 0; game < 20; game++) { + BotSettings bot = BotSettings.draw(BotLevel.MEDIUM, random); + Position position = Position.initial(true); + List botMoves = new ArrayList<>(); + int moveNumber = 0; + while (!position.isOver() && botMoves.size() < 5) { + int pit; + if (position.isPlayer1ToMove()) { + pit = BotPolicy.chooseMove(position, bot, moveNumber); + botMoves.add(pit); + } else { + position.legalPits(pits); + pit = pits[0]; + } + position.play(pit, position); + moveNumber++; + } + openings.add(botMoves); + } + assertTrue(openings.size() > 1, "every game opened with " + openings); + } + + @Test + void picksMovesByTheirScores() { + BotSearch.Result result = new BotSearch.Result(new int[] { 0, 2, 4 }, new int[] { 0, 500, 100 }, 1, 0); + // At a low temperature the best move takes almost all the weight. + assertEquals(2, BotPolicy.pick(result, 0.05, 0.01)); + assertEquals(2, BotPolicy.pick(result, 0.05, 0.999)); + // At a high temperature every move can be drawn. + assertEquals(0, BotPolicy.pick(result, 100, 0.0)); + assertEquals(4, BotPolicy.pick(result, 100, 0.999)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java new file mode 100644 index 0000000..d8fc568 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java @@ -0,0 +1,82 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +class BotSearchTest { + + private static int bestPit(BotSearch.Result result) { + int best = 0; + for (int i = 1; i < result.count(); i++) { + if (result.scores()[i] > result.scores()[best]) best = i; + } + return result.pits()[best]; + } + + @Test + void scoresEveryLegalMove() { + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 3); + assertArrayEquals(new int[] { 0, 1, 2, 3, 4, 5 }, result.pits()); + assertEquals(6, result.scores().length); + assertEquals(3, result.depth()); + } + + @Test + void takesTheExtraTurn() { + // Pit 3 ends in the bank and gives another move; pits 4 and 5 gain a stone too but pass the turn. + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 2); + assertEquals(3, bestPit(result)); + } + + @Test + void capturesTheStonesOpposite() { + // Pit 0's single stone lands in the empty pit 1, opposite pit 11 with 9 stones. + Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true); + assertEquals(0, bestPit(BotSearch.scoreMoves(position, 1))); + } + + @Test + void takesAnEvenPitOfTheOpponent() { + // Pit 5 with 3 stones: 5, the bank and pit 7, which then holds 6. + Position position = Position.of(new int[] { 1, 1, 1, 1, 1, 3, 10, 5, 1, 1, 1, 1, 1, 20 }, true); + BotSearch.Result result = BotSearch.scoreMoves(position, 1); + assertEquals(5, bestPit(result)); + } + + @Test + void seesAWinTwoMovesAhead() { + // Pit 5's stone goes to the bank; with the extra turn, pit 4's stone captures pit 7, the side is empty and + // takes the opponent's stones. Playing pit 4 first passes the turn. + Position position = Position.of(new int[] { 0, 0, 0, 0, 1, 1, 21, 1, 1, 1, 1, 1, 1, 19 }, true); + BotSearch.Result result = BotSearch.scoreMoves(position, 2); + int best = 0; + for (int i = 1; i < result.count(); i++) { + if (result.scores()[i] > result.scores()[best]) best = i; + } + assertEquals(5, result.pits()[best]); + assertTrue(result.scores()[best] > BotSearch.WIN, "a won game: " + result.scores()[best]); + } + + @Test + void sameInputGivesTheSameScores() { + Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, false); + BotSearch.Result first = BotSearch.scoreMoves(position, 8); + BotSearch.Result second = BotSearch.scoreMoves(position, 8); + assertArrayEquals(first.scores(), second.scores()); + assertEquals(first.nodes(), second.nodes()); + } + + @Test + void stopsAtTheNodeBudgetWithTheLastCompletedDepth() { + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 30, 20_000); + assertTrue(result.depth() >= 1 && result.depth() < 30, "depth " + result.depth()); + assertEquals(6, result.scores().length); + // Stops within the depth that ran out of budget. + assertTrue(result.nodes() < 20_000 + 20_000, "nodes " + result.nodes()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java new file mode 100644 index 0000000..bcbad53 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java @@ -0,0 +1,88 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +// Plays the levels against each other (R2.4). The full tournament is slow and tagged `calibration`, so it is left +// out of the normal tests; run it with ./mvnw test -Dgroups=calibration -DexcludedGroups=none after changing the +// search or the strength table. +class BotTournamentTest { + + record Score(int wins, int losses, int draws, long moves, long nanos) { + int games() { + return wins + losses + draws; + } + + double winRate() { + return (wins + draws / 2.0) / games(); + } + } + + @Test + void masterBeatsEasy() { + Score score = play(BotLevel.MASTER, BotLevel.EASY, 20, 1); + assertTrue(score.wins() >= 19, () -> "Master against Easy: " + score); + } + + @Test + @Tag("calibration") + void strongerLevelsWin() { + int games = Integer.getInteger("bot.tournament.games", 200); + BotLevel[] levels = BotLevel.values(); + StringBuilder report = new StringBuilder(); + boolean ok = true; + for (int i = 1; i < levels.length; i++) { + Score score = play(levels[i], levels[i - 1], games, 100 + i); + report.append(line(levels[i], levels[i - 1], score)); + ok &= score.winRate() >= 0.65; + } + Score masterEasy = play(BotLevel.MASTER, BotLevel.EASY, games, 200); + report.append(line(BotLevel.MASTER, BotLevel.EASY, masterEasy)); + ok &= masterEasy.winRate() >= 0.95; + System.out.println(report); + assertTrue(ok, report::toString); + } + + private static String line(BotLevel stronger, BotLevel weaker, Score score) { + return String.format("%-6s vs %-6s won %3d lost %3d drawn %3d rate %.2f %.1f ms/move%n", stronger, weaker, + score.wins(), score.losses(), score.draws(), score.winRate(), + score.nanos() / 1e6 / Math.max(1, score.moves())); + } + + // Plays `games` games with strengths drawn per game, alternating who moves first. + static Score play(BotLevel first, BotLevel second, int games, long seed) { + SplittableRandom random = new SplittableRandom(seed); + int wins = 0, losses = 0, draws = 0; + long moves = 0, nanos = 0; + for (int game = 0; game < games; game++) { + BotSettings a = BotSettings.draw(first, random); + BotSettings b = BotSettings.draw(second, random); + boolean aIsPlayer1 = game % 2 == 0; + Position position = Position.initial(random.nextBoolean()); + int moveNumber = 0; + while (!position.isOver()) { + boolean aToMove = position.isPlayer1ToMove() == aIsPlayer1; + long start = System.nanoTime(); + int pit = BotPolicy.chooseMove(position, aToMove ? a : b, moveNumber); + if (aToMove) { + nanos += System.nanoTime() - start; + moves++; + } + position.play(pit, position); + moveNumber++; + } + int player1Lead = position.stonesAt(Position.PLAYER1_BANK) - position.stonesAt(Position.PLAYER2_BANK); + int aLead = aIsPlayer1 ? player1Lead : -player1Lead; + if (aLead > 0) wins++; + else if (aLead < 0) losses++; + else draws++; + } + return new Score(wins, losses, draws, moves, nanos); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/engine/EngineParityTest.java b/apps/backend/src/test/java/app/playmancala/engine/EngineParityTest.java new file mode 100644 index 0000000..fb8e484 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/engine/EngineParityTest.java @@ -0,0 +1,58 @@ +package app.playmancala.engine; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.Json; + +// Replays games recorded with the TypeScript engine (scripts/record-engine-games.js) and checks that every move +// has the same outcome, and that the finished game serialises to the same JSON clients received from the Node backend. +class EngineParityTest { + + @TestFactory + Stream playsLikeTheTypeScriptEngine() throws IOException { + JsonNode games; + try (InputStream in = getClass().getResourceAsStream("/engine/recorded-games.json")) { + games = Json.MAPPER.readTree(in).get("games"); + } + List tests = new ArrayList<>(); + games.forEach(recorded -> tests.add(DynamicTest.dynamicTest(recorded.get("id").asText(), () -> replay(recorded)))); + return tests.stream(); + } + + private void replay(JsonNode recorded) { + MancalaGame game = new CommonMancalaGame(recorded.get("id").asText(), recorded.get("player1Id").asText(), + recorded.get("player2Id").asText()); + game.setTurnPlayerId(recorded.get("turnPlayerId").asText()); + int number = 0; + for (JsonNode move : recorded.get("moves")) { + String where = "move " + ++number; + String error = null; + try { + game.moveByPlayerPit(move.get("playerId").asText(), move.get("pitIndex").asInt()); + } catch (IllegalStateException e) { + error = e.getMessage(); + } + assertEquals(move.has("error") ? move.get("error").asText() : null, error, where); + assertArrayEquals(Json.MAPPER.convertValue(move.get("board"), int[].class), game.getBoard().getStoneArray(), where); + assertEquals(move.get("turnPlayerId").asText(), game.getTurnPlayerId(), where); + assertEquals(move.get("state"), Json.tree(game.getState()), where); + } + JsonNode wonPlayerId = recorded.get("wonPlayerId"); + if (wonPlayerId.isNull()) assertNull(game.getWonPlayerId()); + else assertEquals(wonPlayerId.asText(), game.getWonPlayerId()); + assertEquals(recorded.get("game"), Json.tree(game)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/engine/MancalaGameTest.java b/apps/backend/src/test/java/app/playmancala/engine/MancalaGameTest.java new file mode 100644 index 0000000..c0de01d --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/engine/MancalaGameTest.java @@ -0,0 +1,184 @@ +package app.playmancala.engine; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.rules.GRClearBoardAtEnd; +import app.playmancala.engine.rules.GRDoubleStoneInPit; +import app.playmancala.engine.rules.GRLastStoneInBank; +import app.playmancala.engine.rules.GRLastStoneInEmptyPit; +import app.playmancala.model.Json; + +// Ported from packages/mancala.js/tests; EngineParityTest covers whole games. +class MancalaGameTest { + private static final String P1 = "0"; + private static final String P2 = "1"; + + private static MancalaGame createGame() { + return new CommonMancalaGame("0", P1, P2); + } + + private static void setStones(MancalaGame game, int... stones) { + for (int i = 0; i < stones.length; i++) game.getBoard().getPits().get(i).setStoneCount(stones[i]); + } + + private static GameStep move(int index) { + return new GameStep(index, MancalaGame.GAME_STEP_GAME_MOVE); + } + + @Test + void pitTypesAndIndexes() { + Board board = new Board(6, 4); + assertEquals(PitType.PLAYER1_PIT, board.getPitTypeByIndex(5)); + assertEquals(PitType.PLAYER1_BANK, board.getPitTypeByIndex(6)); + assertEquals(PitType.PLAYER2_PIT, board.getPitTypeByIndex(7)); + assertEquals(PitType.PLAYER2_BANK, board.getPitTypeByIndex(13)); + assertThrows(IllegalStateException.class, () -> board.getPitTypeByIndex(-1)); + assertThrows(IllegalStateException.class, () -> board.getPitTypeByIndex(14)); + assertEquals(1, board.getPitIndexCircularly(15)); + assertEquals(12, board.getOppositePitIndex(0)); + assertEquals(5, board.getOppositePitIndex(7)); + assertEquals(0, board.getOppositePitIndex(12)); + assertTrue(board.getPits().get(6).isBank()); + assertFalse(board.getPits().get(0).isBank()); + } + + @Test + void sowingPathSkipsTheOpponentsStore() { + Board board = new Board(6, 4); + assertEquals(List.of(1), board.getSowingPath(0, 1)); + assertEquals(List.of(5, 6), board.getSowingPath(5, 2)); + assertEquals(List.of(5, 6, 7, 8, 9, 10, 11, 12, 0), board.getSowingPath(5, 9)); + assertEquals(List.of(12, 13, 0), board.getSowingPath(12, 3)); + assertEquals(List.of(11, 12, 13, 0, 1, 2, 3, 4, 5, 7), board.getSowingPath(11, 10)); + assertEquals(List.of(), board.getSowingPath(0, 0)); + assertEquals(List.of(), board.getSowingPath(6)); + assertEquals(List.of(), board.getSowingPath(13)); + } + + @Test + void boardMove() { + Board board = new Board(6, 4); + board.move(0); + assertArrayEquals(new int[] { 1, 5, 5, 5, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0 }, board.getStoneArray()); + board.move(0); + assertArrayEquals(new int[] { 0, 6, 5, 5, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0 }, board.getStoneArray()); + board.move(1); + assertArrayEquals(new int[] { 0, 1, 6, 6, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0 }, board.getStoneArray()); + assertEquals("StoneNotFound => index : 0 stoneCount = 0", + assertThrows(IllegalStateException.class, () -> board.move(0)).getMessage()); + assertEquals("InCorrectPit => index : 6 pitType = player1Bank", + assertThrows(IllegalStateException.class, () -> board.move(6)).getMessage()); + } + + @Test + void canPlayerMove() { + MancalaGame game = createGame(); + for (int pit = 0; pit < 6; pit++) assertTrue(game.canPlayerMove(P1, pit)); + assertFalse(game.canPlayerMove(P1, 6)); + assertFalse(game.canPlayerMove(P2, 0)); + assertEquals(P1, game.getPlayerIdByIndex(6)); + assertEquals(P2, game.getPlayerIdByIndex(7)); + } + + @Test + void turnsAndState() { + MancalaGame game = createGame(); + assertEquals(GameState.INITIAL, game.getState()); + game.moveByPlayerPit(P1, 0); + assertEquals(GameState.PLAYING, game.getState()); + assertEquals(P2, game.getTurnPlayerId()); + game.moveByPlayerPit(P2, 0); + assertEquals(P1, game.getTurnPlayerId()); + + IllegalStateException error = assertThrows(IllegalStateException.class, () -> game.moveByPlayerPit(P2, 1)); + assertEquals("Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false", error.getMessage()); + } + + @Test + void lastStoneInBankGivesAnotherTurn() { + MancalaGame game = createGame(); + game.moveByPlayerPit(P1, 3); + assertEquals(P1, game.getTurnPlayerId()); + assertEquals(new GameStep(6, GRLastStoneInBank.GAME_STEP_LAST_STONE_IN_BANK), + game.getHistory().getFirst().getGameSteps().getLast()); + } + + @Test + void lastStoneInEmptyPitCapturesTheOppositePit() { + MancalaGame game = createGame(); + game.getBoard().getPits().get(0).setStoneCount(1); + game.getBoard().getPits().get(1).setStoneCount(0); + game.moveByPlayerPit(P1, 0); + assertEquals(P2, game.getTurnPlayerId()); + assertArrayEquals(new int[] { 0, 0, 4, 4, 4, 4, 5, 4, 4, 4, 4, 0, 4, 0 }, game.getBoard().getStoneArray()); + assertEquals(List.of(move(1), new GameStep(1, GRLastStoneInEmptyPit.GAME_STEP_LAST_STONE_IN_EMPTY_PIT, + new GRLastStoneInEmptyPit.LastStoneInEmptyPitData(11))), game.getHistory().getFirst().getGameSteps()); + } + + @Test + void doubleStoneInPitCapturesAnEvenOpponentPit() { + MancalaGame game = createGame(); + setStones(game, 3, 4, 4, 4, 4, 3, 0, 3, 4, 4, 4, 4, 3, 0); + game.moveByPlayerPit(P1, 5); + assertArrayEquals(new int[] { 3, 4, 4, 4, 4, 1, 5, 0, 4, 4, 4, 4, 3, 0 }, game.getBoard().getStoneArray()); + assertEquals(List.of(move(5), move(6), move(7), new GameStep(7, GRDoubleStoneInPit.GAME_STEP_DOUBLE_STONE_IN_PIT, + new GRDoubleStoneInPit.DoubleStoneInPitData(7, 6))), game.getHistory().getFirst().getGameSteps()); + + game.moveByPlayerPit(P2, 5); + assertArrayEquals(new int[] { 0, 4, 4, 4, 4, 1, 5, 0, 4, 4, 4, 4, 1, 5 }, game.getBoard().getStoneArray()); + assertEquals(List.of(move(12), move(13), move(0), new GameStep(0, GRDoubleStoneInPit.GAME_STEP_DOUBLE_STONE_IN_PIT, + new GRDoubleStoneInPit.DoubleStoneInPitData(0, 13))), game.getHistory().getLast().getGameSteps()); + } + + @Test + void clearBoardAtEndGivesTheRemainingStonesToThePlayerWhoseSideIsEmpty() { + MancalaGame game = createGame(); + setStones(game, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0); + game.moveByPlayerPit(P1, 5); + assertArrayEquals(new int[] { 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0 }, game.getBoard().getStoneArray()); + assertEquals(GameState.ENDED, game.getState()); + assertEquals(List.of(move(6), new GameStep(6, GRLastStoneInBank.GAME_STEP_LAST_STONE_IN_BANK), + new GameStep(6, GRClearBoardAtEnd.GAME_STEP_BOARD_CLEARED, + new GRClearBoardAtEnd.ClearBoardAtEndData(List.of(12)))), + game.getHistory().getFirst().getGameSteps()); + assertEquals(P1, game.getWonPlayerId()); + + // Moves after the end are ignored. + game.moveByPlayerPit(P2, 0); + assertEquals(1, game.getHistory().size()); + } + + @Test + void wonPlayerId() { + MancalaGame game = createGame(); + assertNull(game.getWonPlayerId()); + setStones(game, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0); + assertEquals(P1, game.getWonPlayerId()); + setStones(game, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1); + assertEquals(P2, game.getWonPlayerId()); + setStones(game, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + assertNull(game.getWonPlayerId()); + } + + @Test + // Same properties, in the same order, as JSON.stringify of a TypeScript MoveHistoryItem; null data is kept. + void historyJson() { + MancalaGame game = createGame(); + game.moveByPlayerPit(P1, 0); + String json = Json.write(game.getHistory().getFirst()); + assertTrue(json.startsWith("{\"boardSnapshot\":[1,5,5,5,4,4,0,4,4,4,4,4,4,0],\"gameSteps\":[{\"index\":0,\"type\":\"GAME_STEP_GAME_MOVE\",\"data\":null}"), json); + assertTrue(json.endsWith("\"playerId\":\"0\",\"moveIndex\":0}"), json); + + MoveHistoryItem read = Json.MAPPER.convertValue(Json.tree(game.getHistory().getFirst()), MoveHistoryItem.class); + assertEquals(Json.tree(game.getHistory().getFirst()), Json.tree(read)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java new file mode 100644 index 0000000..566d3ae --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java @@ -0,0 +1,106 @@ +package app.playmancala.engine; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.Json; + +// Position must play exactly like MancalaGame: the recorded games from the TypeScript engine and random games are +// played on both, move by move. +class PositionTest { + + @Test + void startsWithFourStonesInEveryPit() { + Position position = Position.initial(true); + assertArrayEquals(new CommonMancalaGame("g", "a", "b").getBoard().getStoneArray(), position.stones()); + assertTrue(position.isPlayer1ToMove()); + int[] pits = new int[Position.PIT_COUNT]; + assertEquals(6, position.legalPits(pits)); + } + + @Test + void lastStoneInOwnBankGivesAnExtraTurn() { + Position position = Position.initial(true); + // Pit 3 with 4 stones: 3, 4, 5 and the bank. + assertTrue(position.play(3, position)); + assertArrayEquals(new int[] { 4, 4, 4, 1, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0 }, position.stones()); + assertTrue(position.isPlayer1ToMove()); + } + + @Test + void playingIntoAnotherPositionLeavesThisOneAlone() { + Position position = Position.initial(false); + Position next = Position.initial(true); + assertFalse(position.play(0, next)); + assertEquals(Position.initial(false), position); + assertTrue(next.isPlayer1ToMove()); + } + + @Test + void refusesEmptyPitsAndBanks() { + Position position = Position.of(new int[] { 0, 1, 0, 0, 0, 0, 20, 1, 0, 0, 0, 0, 0, 26 }, true); + assertThrows(IllegalArgumentException.class, () -> position.play(0, position)); + assertThrows(IllegalArgumentException.class, () -> position.play(6, position)); + } + + @Test + void playsTheRecordedGamesLikeTheEngine() throws IOException { + JsonNode games; + try (InputStream in = getClass().getResourceAsStream("/engine/recorded-games.json")) { + games = Json.MAPPER.readTree(in).get("games"); + } + for (JsonNode recorded : games) { + String player1 = recorded.get("player1Id").asText(); + String player2 = recorded.get("player2Id").asText(); + MancalaGame game = new CommonMancalaGame("g", player1, player2); + game.setTurnPlayerId(recorded.get("turnPlayerId").asText()); + int number = 0; + for (JsonNode move : recorded.get("moves")) { + number++; + // Refused moves, and moves after the end, which the engine ignores. + if (move.has("error") || game.getState() == GameState.ENDED) continue; + String player = move.get("playerId").asText(); + playOnBoth(game, player, move.get("pitIndex").asInt(), recorded.get("id").asText() + " move " + number); + } + } + } + + @Test + void playsRandomGamesLikeTheEngine() { + SplittableRandom random = new SplittableRandom(7); + int[] pits = new int[Position.PIT_COUNT]; + for (int gameNumber = 0; gameNumber < 3000; gameNumber++) { + MancalaGame game = new CommonMancalaGame("g", "p1", "p2"); + game.setTurnPlayerId(random.nextBoolean() ? "p1" : "p2"); + int moves = 0; + while (game.getState() != GameState.ENDED) { + Position position = Position.of(game); + int count = position.legalPits(pits); + int pit = pits[random.nextInt(count)]; + playOnBoth(game, game.getTurnPlayerId(), pit, "game " + gameNumber + " move " + ++moves); + } + } + } + + private static void playOnBoth(MancalaGame game, String player, int pit, String where) { + Position position = Position.of(game); + assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where); + boolean extraTurn = position.play(pit, position); + game.moveByPlayerPit(player, pit); + assertArrayEquals(game.getBoard().getStoneArray(), position.stones(), where); + assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where); + assertEquals(player.equals(game.getTurnPlayerId()), extraTurn, where); + assertEquals(game.checkGameIsEnded(), position.isOver(), where); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/fakes/FakeRtmt.java b/apps/backend/src/test/java/app/playmancala/fakes/FakeRtmt.java new file mode 100644 index 0000000..111f98a --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/fakes/FakeRtmt.java @@ -0,0 +1,82 @@ +package app.playmancala.fakes; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.NullNode; + +import app.playmancala.model.AuthUser; +import app.playmancala.model.Json; +import app.playmancala.rtmt.Rtmt; + +// In-memory Rtmt: tests connect users, send messages as them and inspect what the server sent back. +public class FakeRtmt implements Rtmt { + public record SentMessage(String clientId, String channel, JsonNode message) { + } + + public final List sent = new ArrayList<>(); + private final Map> listeners = new HashMap<>(); + private final Map online = new LinkedHashMap<>(); + private final List> connectionListeners = new ArrayList<>(); + + public void connect(AuthUser user) { + online.put(user.id(), user); + connectionListeners.forEach(listener -> listener.accept(user.id(), true)); + } + + public void disconnect(String userId) { + online.remove(userId); + connectionListeners.forEach(listener -> listener.accept(userId, false)); + } + + public void receive(String userId, String channel) { + receive(userId, channel, Map.of()); + } + + public void receive(String userId, String channel, Object message) { + BiConsumer listener = listeners.get(channel); + if (listener == null) throw new IllegalStateException("No listener for " + channel); + listener.accept(userId, message == null ? NullNode.getInstance() : Json.tree(message)); + } + + public List messagesTo(String userId, String channel) { + return sent.stream().filter(m -> m.clientId().equals(userId) && m.channel().equals(channel)) + .map(SentMessage::message).toList(); + } + + public JsonNode lastMessageTo(String userId, String channel) { + List messages = messagesTo(userId, channel); + return messages.isEmpty() ? null : messages.getLast(); + } + + @Override + public void sendMessage(String clientId, String channel, Object message) { + // Serialised at once, like sending over the wire. + sent.add(new SentMessage(clientId, channel, Json.tree(message))); + } + + @Override + public void listenMessage(String channel, BiConsumer callback) { + listeners.put(channel, callback); + } + + @Override + public boolean isClientOnline(String clientId) { + return online.containsKey(clientId); + } + + @Override + public AuthUser getUser(String clientId) { + return online.get(clientId); + } + + @Override + public void listenOnClientConnectionChange(BiConsumer listener) { + connectionListeners.add(listener); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/fakes/InMemoryCache.java b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryCache.java new file mode 100644 index 0000000..f5a8148 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryCache.java @@ -0,0 +1,35 @@ +package app.playmancala.fakes; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.core.type.TypeReference; + +import app.playmancala.live.Cache; +import app.playmancala.model.Json; + +public class InMemoryCache implements Cache { + public final Map values = new HashMap<>(); + + @Override + public Optional get(String key, TypeReference type) { + String value = values.get(key); + if (value == null) return Optional.empty(); + try { + return Optional.of(Json.MAPPER.readValue(value, type)); + } catch (Exception e) { + return Optional.empty(); + } + } + + @Override + public void set(String key, Object value, long ttlSeconds) { + values.put(key, Json.write(value)); + } + + @Override + public void delete(String... keys) { + for (String key : keys) values.remove(key); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java new file mode 100644 index 0000000..5d3bf27 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java @@ -0,0 +1,48 @@ +package app.playmancala.fakes; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import app.playmancala.history.GameHistoryRepository; +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; + +// Lists leave out the moves, like the Postgres repository. +public class InMemoryGameHistoryRepository implements GameHistoryRepository { + public final Map records = new LinkedHashMap<>(); + public boolean failing; + + private void check() { + if (failing) throw new IllegalStateException("database is down"); + } + + @Override + public void save(GameRecord record) { + check(); + records.putIfAbsent(record.id(), record); + } + + @Override + public GameRecord get(String id) { + check(); + return records.get(id); + } + + @Override + public List getRecentForUser(String userId, int limit) { + check(); + return records.values().stream().filter(record -> record.hasPlayer(userId)) + .sorted(Comparator.comparingLong(GameRecord::endedAt).reversed()) + .limit(limit).map(GameRecord::toSummary).toList(); + } + + @Override + public List getPopular(int limit) { + check(); + return records.values().stream() + .sorted(Comparator.comparingInt(GameRecord::peakSpectators).thenComparingLong(GameRecord::endedAt).reversed()) + .limit(limit).map(GameRecord::toSummary).toList(); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java new file mode 100644 index 0000000..2b3f788 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java @@ -0,0 +1,58 @@ +package app.playmancala.fakes; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.live.LiveGameStore; +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; + +public class InMemoryLiveGameStore implements LiveGameStore { + public final Map games = new LinkedHashMap<>(); + public final Map pending = new LinkedHashMap<>(); + public boolean failing; + + private void check() { + if (failing) throw new IllegalStateException("Valkey is down"); + } + + @Override + public void saveGame(String gameId, Object snapshot) { + check(); + games.put(gameId, Json.tree(snapshot)); + } + + @Override + public void removeGame(String gameId) { + check(); + games.remove(gameId); + } + + @Override + public List loadGames() { + check(); + return new ArrayList<>(games.values()); + } + + @Override + public void savePendingRecord(GameRecord record) { + check(); + pending.put(record.id(), record); + } + + @Override + public void removePendingRecord(String gameId) { + check(); + pending.remove(gameId); + } + + @Override + public List loadPendingRecords() { + check(); + return new ArrayList<>(pending.values()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/game/GameClockSchedulerTest.java b/apps/backend/src/test/java/app/playmancala/game/GameClockSchedulerTest.java new file mode 100644 index 0000000..6730c82 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/game/GameClockSchedulerTest.java @@ -0,0 +1,29 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import app.playmancala.loop.ManualScheduler; + +class GameClockSchedulerTest { + @Test + void endsGamesOutOfTimeOnEveryIntervalUntilStopped() { + ManualScheduler scheduler = new ManualScheduler(0); + AtomicInteger calls = new AtomicInteger(); + GameClockScheduler clockScheduler = new GameClockScheduler(calls::incrementAndGet, scheduler, 1_000); + clockScheduler.start(); + clockScheduler.start(); // starting twice does not run it twice + + scheduler.advance(999); + assertEquals(0, calls.get()); + scheduler.advance(2_001); + assertEquals(3, calls.get()); + + clockScheduler.stop(); + scheduler.advance(10_000); + assertEquals(3, calls.get()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java new file mode 100644 index 0000000..624e5a3 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java @@ -0,0 +1,711 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import app.playmancala.engine.Pit; +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.Json; +import app.playmancala.model.MoveRecord; +import app.playmancala.model.Seat; +import app.playmancala.model.Series; +import app.playmancala.model.SeriesGame; + +// Ported from the Node backend's tests/game/GameManager.test.ts. +class GameManagerTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser BOB = new AuthUser("kc:bob", "guest-1a2b3c4d", true); + static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false); + static final AuthUser DAVE = new AuthUser("kc:dave", "dave", false); + static final AuthUser LEGACY = new AuthUser("0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", null, true); + static final long CLOCK_MS = 5 * 60_000; + + long now; + FakeRtmt rtmt; + GameStore gameStore; + InMemoryLiveGameStore liveGameStore; + InMemoryGameHistoryRepository repository; + GameManager gameManager; + + // Saves run at once, so the tests need no waiting. + GameRecorder createManager() { + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + GameRecorder recorder = new GameRecorder(repository, liveGameStore, Runnable::run); + gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run, + () -> now, CLOCK_MS); + return recorder; + } + + @BeforeEach + void setUp() { + now = Instant.parse("2026-10-16T12:00:00Z").toEpochMilli(); + liveGameStore = new InMemoryLiveGameStore(); + repository = new InMemoryGameHistoryRepository(); + createManager(); + List.of(ALICE, BOB, CAROL, DAVE).forEach(rtmt::connect); + } + + BackendGame startGame() { + return startGame(ALICE, BOB); + } + + BackendGame startGame(AuthUser first, AuthUser second) { + rtmt.receive(first.id(), Channels.NEW_GAME); + rtmt.receive(second.id(), Channels.NEW_GAME); + BackendGame game = gameStore.getGameByUser(first.id()); + assertNotNull(game, "game did not start"); + return game; + } + + // Plays the current player's first non-empty pit. + void playAnyMove(BackendGame game) { + String turnPlayerId = game.mancalaGame.getTurnPlayerId(); + List pits = game.mancalaGame.getBoard().getPits(); + for (int pit = 0; pit < 6; pit++) { + if (pits.get(game.mancalaGame.getBoardIndexByPlayerId(turnPlayerId, pit)).getStoneCount() > 0) { + rtmt.receive(turnPlayerId, Channels.GAME_MOVE, Map.of("index", pit)); + return; + } + } + throw new IllegalStateException("no move"); + } + + // Leaves the given player a single stone next to their store; playing it empties their side and ends the game. + static void setUpLastMove(BackendGame game, String playerId) { + boolean isPlayer1 = game.mancalaGame.getPlayer1Id().equals(playerId); + List pits = game.mancalaGame.getBoard().getPits(); + pits.forEach(pit -> pit.setStoneCount(0)); + pits.get(isPlayer1 ? 5 : 12).setStoneCount(1); + pits.get(isPlayer1 ? 7 : 0).setStoneCount(1); + game.mancalaGame.setTurnPlayerId(playerId); + } + + static String opponentOf(String playerId) { + return playerId.equals(ALICE.id()) ? BOB.id() : ALICE.id(); + } + + static boolean isPlayer1(BackendGame game, String playerId) { + return game.mancalaGame.getPlayer1Id().equals(playerId); + } + + static long timeOf(GameClock clock, BackendGame game, String playerId) { + return isPlayer1(game, playerId) ? clock.player1Ms() : clock.player2Ms(); + } + + // The clock with the given times for the two players, in their seats. + static GameClock clockOf(BackendGame game, String playerId, long playerMs, long opponentMs, Long turnStartedAt) { + return isPlayer1(game, playerId) + ? new GameClock(CLOCK_MS, playerMs, opponentMs, turnStartedAt) + : new GameClock(CLOCK_MS, opponentMs, playerMs, turnStartedAt); + } + + static GameResult seatOf(BackendGame game, String playerId) { + return isPlayer1(game, playerId) ? GameResult.PLAYER1 : GameResult.PLAYER2; + } + + @Nested + class WhileAGameIsPlayed { + @Test + void sendsTheConnectionInfoOnceWhenAGameStarts() { + startGame(); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_START).size()); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_USER_CONNECTION_CHANGE).size()); + } + + @Test + void doesNotSendSpectatorIdsToClients() { + BackendGame game = startGame(); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + playAnyMove(game); + JsonNode update = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_UPDATE); + assertFalse(update.has("spectatorIds")); + assertFalse(update.has("allSpectatorIds")); + assertEquals(1, update.get("spectatorCount").asInt()); + } + + @Test + void recordsTheTimeOfEachMove() { + BackendGame game = startGame(); + now += 1000; + playAnyMove(game); + now += 2000; + playAnyMove(game); + assertEquals(List.of(game.startedAt + 1000, game.startedAt + 3000), game.moveTimes); + assertEquals(2, rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_UPDATE).get("moveTimes").size()); + } + + @Test + void rejectsANewGameForAPlayerWhoHasOneInProgress() { + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), + rtmt.messagesTo(ALICE.id(), Channels.ON_NEW_GAME_REJECTED)); + assertSame(game, gameStore.getGameByUser(ALICE.id())); + assertNull(gameStore.getGameByUser(CAROL.id())); + } + + @Test + void countsSpectatorsRemovedOnDisconnectPeakAndDistinctTotalKept() { + BackendGame game = startGame(); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.receive(DAVE.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.disconnect(CAROL.id()); + rtmt.connect(CAROL); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.disconnect(DAVE.id()); + assertEquals(List.of(CAROL.id()), game.spectatorIds); + assertEquals(2, game.peakSpectators); + + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + GameRecord record = repository.records.get(game.id); + assertEquals(2, record.peakSpectators()); + assertEquals(2, record.totalSpectators()); + } + + @Test + void keepsASnapshotOfTheLiveGameUntilItEnds() { + BackendGame game = startGame(); + playAnyMove(game); + JsonNode snapshot = liveGameStore.games.get(game.id); + assertEquals(game.id, snapshot.get("id").asText()); + assertEquals(Json.tree(List.of(now)), snapshot.get("moveTimes")); + + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertFalse(liveGameStore.games.containsKey(game.id)); + } + + @Test + void sendsTheGameAsTheNodeBackendDidPlusTheSeries() { + BackendGame game = startGame(); + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes", + "spectatorCount", "clock", "serverTime", "series", "firstSeat"), fieldNames(start)); + assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history", + "currentHistoryItem"), fieldNames(start.get("mancalaGame"))); + assertEquals("initial", start.at("/mancalaGame/state").asText()); + assertTrue(start.at("/mancalaGame/currentHistoryItem").isNull()); + JsonNode alice = isPlayer1(game, ALICE.id()) ? start.at("/gameUsersConnectionInfo/user1ConnectionInfo") + : start.at("/gameUsersConnectionInfo/user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", ALICE.id(), "name", "alice", "isGuest", false, "isOnline", true)), alice); + } + + @Test + void anInvalidMoveIsReportedAsACrashAndChangesNothing() { + BackendGame game = startGame(); + String waiting = opponentOf(game.mancalaGame.getTurnPlayerId()); + rtmt.receive(waiting, Channels.GAME_MOVE, Map.of("index", 0)); + assertEquals(List.of(Json.tree(Map.of())), rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_CRASHED)); + assertEquals(0, game.mancalaGame.getHistory().size()); + assertSame(game, gameStore.get(game.id)); + } + } + + @Nested + class Clock { + @Test + void startsWithTheFullTimeForBothPlayersRunningForThePlayerWhoMovesFirst() { + BackendGame game = startGame(); + assertEquals(new GameClock(CLOCK_MS, CLOCK_MS, CLOCK_MS, now), game.clock); + JsonNode start = rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_START).getFirst(); + assertEquals(Json.tree(game.clock), start.get("clock")); + assertEquals(now, start.get("serverTime").asLong()); + } + + @Test + void aMoveDeductsTheTurnsTimeFromTheMoverOnlyAndStartsTheOpponentsClock() { + BackendGame game = startGame(); + String first = game.mancalaGame.getTurnPlayerId(); + now += 7_000; + playAnyMove(game); + assertEquals(opponentOf(first), game.mancalaGame.getTurnPlayerId()); + assertEquals(CLOCK_MS - 7_000, timeOf(game.clock, game, first)); + assertEquals(CLOCK_MS, timeOf(game.clock, game, opponentOf(first))); + assertEquals(now, game.clock.turnStartedAt()); + JsonNode update = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_UPDATE); + assertEquals(Json.tree(game.clock), update.get("clock")); + assertEquals(now, update.get("serverTime").asLong()); + + now += 3_000; + playAnyMove(game); + assertEquals(CLOCK_MS - 3_000, timeOf(game.clock, game, opponentOf(first))); + assertEquals(List.of(CLOCK_MS - 7_000, CLOCK_MS - 3_000), game.moveRemainingMs); + } + + @Test + void anExtraTurnKeepsTheMoversClockRunning() { + BackendGame game = startGame(); + String mover = game.mancalaGame.getTurnPlayerId(); + now += 2_000; + // Four stones from the fourth pit: the last one lands in the mover's store. + rtmt.receive(mover, Channels.GAME_MOVE, Map.of("index", 3)); + assertEquals(mover, game.mancalaGame.getTurnPlayerId()); + now += 1_000; + playAnyMove(game); + assertEquals(List.of(CLOCK_MS - 2_000, CLOCK_MS - 3_000), game.moveRemainingMs); + } + + @Test + void aMoveWithTimeLeftIsPlayed() { + BackendGame game = startGame(); + now += CLOCK_MS - 1; + playAnyMove(game); + assertEquals(1, game.mancalaGame.getHistory().size()); + assertEquals(List.of(1L), game.moveRemainingMs); + } + + @Test + void aMoveThatArrivesAfterTheTimeHasRunOutIsNotPlayedAndTheOpponentWins() { + BackendGame game = startGame(); + String mover = game.mancalaGame.getTurnPlayerId(); + now += CLOCK_MS; + playAnyMove(game); + + assertEquals(0, game.mancalaGame.getHistory().size()); + assertNull(gameStore.get(game.id)); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.TIMEOUT, record.endReason()); + assertEquals(seatOf(game, opponentOf(mover)), record.result()); + JsonNode end = rtmt.messagesTo(mover, Channels.ON_GAME_END).getFirst(); + assertEquals("timeout", end.get("endReason").asText()); + assertEquals(opponentOf(mover), end.get("winnerId").asText()); + assertEquals(Json.tree(clockOf(game, mover, 0, CLOCK_MS, null)), end.get("clock")); + } + + @Test + void stopsWhenTheGameEndsAfterDeductingTheRunningPlayersTime() { + BackendGame game = startGame(); + String mover = game.mancalaGame.getTurnPlayerId(); + now += 4_000; + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertEquals(clockOf(game, mover, CLOCK_MS - 4_000, CLOCK_MS, null), game.clock); + assertEquals(Json.tree(game.clock), rtmt.messagesTo(BOB.id(), Channels.ON_GAME_END).getFirst().get("clock")); + } + + @Test + void stopsWhenTheGameIsCompletedByTheRules() { + BackendGame game = startGame(); + playAnyMove(game); + setUpLastMove(game, ALICE.id()); + now += 1_000; + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5)); + assertNull(game.clock.turnStartedAt()); + assertEquals(Json.tree(game.clock), rtmt.messagesTo(BOB.id(), Channels.ON_GAME_END).getFirst().get("clock")); + } + } + + @Nested + class EndingGamesOnTime { + @Test + void endsOnlyTheGamesWhoseRunningClockHasReachedZeroTellingPlayersAndSpectators() { + BackendGame late = startGame(ALICE, BOB); + rtmt.receive(DAVE.id(), Channels.LISTEN_GAME_EVENTS, late.id); + now += 60_000; + AuthUser erin = new AuthUser("kc:erin", "erin", false); + rtmt.connect(erin); + BackendGame early = startGame(CAROL, erin); + String latePlayer = late.mancalaGame.getTurnPlayerId(); + + now += CLOCK_MS - 60_000 - 1; + gameManager.endGamesOutOfTime(); + assertSame(late, gameStore.get(late.id)); + + now += 1; + gameManager.endGamesOutOfTime(); + assertNull(gameStore.get(late.id)); + assertSame(early, gameStore.get(early.id)); + GameResult winnerSeat = seatOf(late, opponentOf(latePlayer)); + GameRecord record = repository.records.get(late.id); + assertEquals(EndReason.TIMEOUT, record.endReason()); + assertEquals(winnerSeat, record.result()); + for (AuthUser user : List.of(ALICE, BOB, DAVE)) { + List ends = rtmt.messagesTo(user.id(), Channels.ON_GAME_END); + assertEquals(1, ends.size()); + assertEquals(late.id, ends.getFirst().get("gameId").asText()); + assertEquals("timeout", ends.getFirst().get("endReason").asText()); + assertEquals(Json.tree(winnerSeat), ends.getFirst().get("result")); + } + } + + @Test + void keepsTheClockRunningWhilePlayersAreOfflineSoGamesNobodyComesBackToEndToo() { + BackendGame game = startGame(); + rtmt.disconnect(ALICE.id()); + rtmt.disconnect(BOB.id()); + now += CLOCK_MS; + gameManager.endGamesOutOfTime(); + assertEquals(EndReason.TIMEOUT, repository.records.get(game.id).endReason()); + } + + @Test + void tellsThePlayersWhenAPlayerWentOffline() { + startGame(); + rtmt.disconnect(BOB.id()); + JsonNode info = rtmt.lastMessageTo(ALICE.id(), Channels.ON_USER_CONNECTION_CHANGE); + JsonNode bob = info.get("user1ConnectionInfo").get("userId").asText().equals(BOB.id()) + ? info.get("user1ConnectionInfo") : info.get("user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", BOB.id(), "name", BOB.name(), "isGuest", true, "isOnline", false)), bob); + } + } + + @Nested + class HowGamesEnd { + @Test + void completedTheWinnerByTheRulesWithEveryMoveSaved() { + BackendGame game = startGame(); + playAnyMove(game); + setUpLastMove(game, ALICE.id()); + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5)); + + // Alice's stone goes into her store and empties her side, so she also collects Bob's last stone: 2-0. + GameResult aliceSeat = seatOf(game, ALICE.id()); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.COMPLETED, record.endReason()); + assertEquals(aliceSeat, record.result()); + assertEquals(2, record.moves().size()); + assertEquals(game.moveTimes, record.moves().stream().map(MoveRecord::at).toList()); + assertNull(gameStore.get(game.id)); + List ends = rtmt.messagesTo(BOB.id(), Channels.ON_GAME_END); + assertEquals(1, ends.size()); + ObjectNode end = ends.getFirst().deepCopy(); + assertTrue(end.remove("clock").isObject()); + assertTrue(end.remove("series").isObject()); + assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed", + "winnerId", ALICE.id(), "rematch", "available")), end); + } + + @Test + void leftThePlayerWhoLeavesLoses() { + BackendGame game = startGame(); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + + GameRecord record = repository.records.get(game.id); + GameResult bobSeat = seatOf(game, BOB.id()); + assertEquals(EndReason.LEFT, record.endReason()); + assertEquals(bobSeat, record.result()); + assertEquals(ALICE.id(), record.leftUserId()); + for (AuthUser user : List.of(ALICE, BOB, CAROL)) { + assertEquals(List.of(Json.tree(ALICE.id())), rtmt.messagesTo(user.id(), Channels.ON_GAME_USER_LEAVE)); + List ends = rtmt.messagesTo(user.id(), Channels.ON_GAME_END); + assertEquals(1, ends.size()); + ObjectNode end = ends.getFirst().deepCopy(); + assertTrue(end.remove("clock").isObject()); + assertTrue(end.remove("series").isObject()); + assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left", + "winnerId", BOB.id(), "leftUserId", ALICE.id())), end); + } + } + } + + @Nested + class Saving { + @Test + void savesTheInitialTimeAndEachMovesTimeLeft() { + BackendGame game = startGame(); + now += 4_000; + playAnyMove(game); + now += 6_000; + playAnyMove(game); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + + GameRecord record = repository.records.get(game.id); + assertEquals(CLOCK_MS, record.clockInitialMs()); + assertEquals(List.of(CLOCK_MS - 4_000, CLOCK_MS - 6_000), record.moves().stream().map(MoveRecord::remainingMs).toList()); + } + + @Test + void replacesLegacyIdsWithPublicIdsInSavedGames() { + rtmt.connect(LEGACY); + BackendGame game = startGame(ALICE, LEGACY); + playAnyMove(game); + rtmt.receive(LEGACY.id(), Channels.LEAVE_GAME); + + String saved = Json.write(repository.records.get(game.id)); + assertFalse(saved.contains(LEGACY.id())); + assertTrue(saved.contains(PublicIds.publicUserId(LEGACY.id()))); + assertTrue(saved.contains(ALICE.id())); + } + + @Test + void keepsAGameThatCouldNotBeSavedAndSavesItOnceTheDatabaseIsBack() { + repository.failing = true; + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertTrue(liveGameStore.pending.containsKey(game.id)); + + // A restarted backend picks the pending record up from Valkey. + repository.failing = false; + GameRecorder recorder = createManager(); + recorder.retryPending(); + assertTrue(repository.records.containsKey(game.id)); + assertFalse(liveGameStore.pending.containsKey(game.id)); + } + + @Test + void keepsGamesPlayableWhileValkeyAndTheDatabaseAreDown() { + liveGameStore.failing = true; + repository.failing = true; + BackendGame game = startGame(); + playAnyMove(game); + playAnyMove(game); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertEquals(2, game.mancalaGame.getHistory().size()); + assertNull(gameStore.get(game.id)); + } + } + + @Nested + class Restart { + @Test + void restoresLiveGamesWhichCanThenBePlayedOn() { + BackendGame game = startGame(); + playAnyMove(game); + playAnyMove(game); + int[] board = game.mancalaGame.getBoard().getStoneArray(); + String turnPlayer = game.mancalaGame.getTurnPlayerId(); + + createManager(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(game.id, restored.id); + assertArrayEquals(board, restored.mancalaGame.getBoard().getStoneArray()); + assertEquals(turnPlayer, restored.mancalaGame.getTurnPlayerId()); + assertEquals(2, restored.mancalaGame.getHistory().size()); + assertSame(restored, gameStore.getGameByUser(BOB.id())); + assertEquals(Json.tree(game.mancalaGame.getHistory()), Json.tree(restored.mancalaGame.getHistory())); + + rtmt.connect(ALICE); + rtmt.connect(BOB); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_UPDATE).size()); + playAnyMove(restored); + assertEquals(3, restored.mancalaGame.getHistory().size()); + } + + @Test + void restoresTheClockWithoutChargingTheTimeTheBackendWasDown() { + BackendGame game = startGame(); + String first = game.mancalaGame.getTurnPlayerId(); + now += 10_000; + playAnyMove(game); + now += 5_000; // the second player thinks, then the backend goes down for a minute + now += 60_000; + + createManager(); + gameManager.restoreLiveGames(); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(clockOf(game, first, CLOCK_MS - 10_000, CLOCK_MS, now), restored.clock); + assertEquals(List.of(CLOCK_MS - 10_000), restored.moveRemainingMs); + + now += 1_000; + playAnyMove(restored); + assertEquals(CLOCK_MS - 1_000, timeOf(restored.clock, game, opponentOf(first))); + } + + @Test + void givesGamesFromBeforeTheGameClockANewClock() { + BackendGame game = startGame(); + playAnyMove(game); + playAnyMove(game); + ObjectNode snapshot = liveGameStore.games.get(game.id).deepCopy(); + snapshot.remove(List.of("clock", "moveRemainingMs")); + snapshot.put("version", 1); + liveGameStore.games.put(game.id, snapshot); + + now += 60_000; + createManager(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(new GameClock(CLOCK_MS, CLOCK_MS, CLOCK_MS, now), restored.clock); + assertEquals(Arrays.asList(null, null), restored.moveRemainingMs); + + now += 2_000; + playAnyMove(restored); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + GameRecord record = repository.records.get(game.id); + assertEquals(CLOCK_MS, record.clockInitialMs()); + assertEquals(Arrays.asList(null, null, CLOCK_MS - 2_000), + record.moves().stream().map(MoveRecord::remainingMs).toList()); + } + + @Test + void doesNotBringBackAGameThatHasAlreadyBeenSaved() { + BackendGame game = startGame(); + JsonNode snapshot = liveGameStore.games.get(game.id); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + liveGameStore.games.put(game.id, snapshot); // as if removing it had failed + + createManager(); + assertEquals(0, gameManager.restoreLiveGames()); + assertFalse(liveGameStore.games.containsKey(game.id)); + } + + @Test + void restoresASnapshotWrittenByTheNodeBackend() { + // Written by the Node backend after one move; ids and times as JSON.stringify left them. + String snapshot = """ + {"version":2,"id":"5c0b7d0e-8a2f-4c8e-9a51-3f0c2b6d8e11","player1Id":"kc:alice","player2Id":"kc:bob", + "turnPlayerId":"kc:bob","state":"playing","board":[0,5,5,5,5,4,0,4,4,4,4,4,4,0], + "history":[{"boardSnapshot":[0,5,5,5,5,4,0,4,4,4,4,4,4,0],"gameSteps":[ + {"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}, + {"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}], + "playerId":"kc:alice","moveIndex":0}], + "players":{"kc:alice":{"userId":"kc:alice","name":"alice","isGuest":false}, + "kc:bob":{"userId":"kc:bob","name":"guest-1a2b3c4d","isGuest":true}}, + "startedAt":1792152000000,"moveTimes":[1792152003000],"peakSpectators":1,"allSpectatorIds":["kc:carol"], + "clock":{"initialMs":300000,"player1Ms":297000,"player2Ms":300000,"turnStartedAt":1792152003000}, + "moveRemainingMs":[297000]} + """; + try { + liveGameStore.games.put("5c0b7d0e-8a2f-4c8e-9a51-3f0c2b6d8e11", Json.parse(snapshot)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + createManager(); + List.of(ALICE, BOB).forEach(rtmt::connect); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.getGameByUser(BOB.id()); + assertEquals(new GameClock(300_000, 297_000, 300_000, now), restored.clock); + assertEquals(List.of("kc:carol"), restored.allSpectatorIds); + + rtmt.receive(BOB.id(), Channels.GAME_MOVE, Map.of("index", 0)); + assertEquals(2, restored.mancalaGame.getHistory().size()); + // What is written back is what the Node backend wrote, plus the new move. + JsonNode written = liveGameStore.games.get(restored.id); + assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history")); + assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history", + "players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs", + "series"), fieldNames(written)); + } + } + + @Nested + class InASeries { + @Test + void everyNewGameStartsASeriesOfItsOwn() { + BackendGame first = startGame(ALICE, BOB); + BackendGame second = startGame(CAROL, DAVE); + assertTrue(first.series.games().isEmpty()); + assertNotNull(first.series.id()); + assertFalse(first.series.id().equals(second.series.id())); + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(Json.tree(Map.of("id", first.series.id(), "games", List.of())), start.get("series")); + } + + @Test + void theFirstSeatIsTheSeatThatMovedFirst() { + BackendGame game = startGame(); + Seat firstSeat = isPlayer1(game, game.mancalaGame.getTurnPlayerId()) ? Seat.PLAYER1 : Seat.PLAYER2; + assertEquals(firstSeat, game.firstSeat()); + assertEquals(Json.tree(firstSeat), rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_START).get("firstSeat")); + playAnyMove(game); + playAnyMove(game); + assertEquals(firstSeat, game.firstSeat()); + } + + @Test + void aGameCanBeCreatedInASeriesWithTheSeatToMoveFirst() { + Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.PLAYER1, + EndReason.COMPLETED, 30, 18))); + for (Seat seat : Seat.values()) { + BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, seat); + assertEquals(ALICE.id(), game.mancalaGame.getPlayer1Id()); + assertEquals(seat == Seat.PLAYER1 ? ALICE.id() : BOB.id(), game.mancalaGame.getTurnPlayerId()); + assertSame(series, game.series); + assertEquals(Json.tree(series), Json.tree(gameManager.toClientGame(game)).get("series")); + gameManager.deleteGame(game); + } + } + + @Test + void theEndMessageHasTheSeriesWithTheGameThatEnded() { + Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.DRAW, + EndReason.COMPLETED, 24, 24))); + BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER1); + gameManager.startGame(game); + playAnyMove(game); + setUpLastMove(game, ALICE.id()); + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5)); + + JsonNode end = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END); + assertEquals(Json.tree(series.next(new SeriesGame(game.id, Seat.PLAYER1, GameResult.PLAYER1, + EndReason.COMPLETED, 2, 0))), end.get("series")); + } + + @Test + void aGameThatEndsWithoutAWinnerIsInTheSeriesToo() { + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + JsonNode games = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).at("/series/games"); + assertEquals(1, games.size()); + assertEquals("left", games.get(0).get("endReason").asText()); + assertEquals(game.id, games.get(0).get("gameId").asText()); + } + + @Test + void savesTheSeriesWithTheGame() { + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertEquals(game.series.id(), repository.records.get(game.id).seriesId()); + } + + @Test + void restoresTheSeriesOfALiveGame() { + Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, null, + EndReason.TIMEOUT, 10, 12))); + BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER2); + gameManager.startGame(game); + playAnyMove(game); + + createManager(); + gameManager.restoreLiveGames(); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(series, restored.series); + assertEquals(Seat.PLAYER2, restored.firstSeat()); + } + + @Test + void aSnapshotFromBeforeRematchesStartsANewSeries() { + BackendGame game = startGame(); + ObjectNode snapshot = liveGameStore.games.get(game.id).deepCopy(); + snapshot.remove("series"); + liveGameStore.games.put(game.id, snapshot); + + createManager(); + gameManager.restoreLiveGames(); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertNotNull(restored.series.id()); + assertTrue(restored.series.games().isEmpty()); + } + } + + static List fieldNames(JsonNode node) { + return node.propertyStream().map(Map.Entry::getKey).toList(); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/game/LiveGamesTest.java b/apps/backend/src/test/java/app/playmancala/game/LiveGamesTest.java new file mode 100644 index 0000000..037ade6 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/game/LiveGamesTest.java @@ -0,0 +1,126 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +import app.playmancala.loop.ManualScheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; + +// Ported from the Node backend's tests/game/LiveGames.test.ts. +class LiveGamesTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser BOB = new AuthUser("kc:bob", "bob", false); + static final AuthUser VIEWER = new AuthUser("kc:viewer", "viewer", false); + static final AuthUser SPECTATOR = new AuthUser("kc:spectator", "spectator", false); + + ManualScheduler scheduler; + FakeRtmt rtmt; + GameStore gameStore; + LiveGames liveGames; + + @BeforeEach + void setUp() { + scheduler = new ManualScheduler(1_000_000); + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + InMemoryLiveGameStore liveGameStore = new InMemoryLiveGameStore(); + GameRecorder recorder = new GameRecorder(new InMemoryGameHistoryRepository(), liveGameStore, Runnable::run); + GameManager gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, + Runnable::run, scheduler::now, 300_000); + liveGames = new LiveGames(rtmt, gameStore, gameManager, scheduler); + List.of(ALICE, BOB, VIEWER, SPECTATOR).forEach(rtmt::connect); + } + + @AfterEach + void tearDown() { + liveGames.dispose(); + } + + List pushes() { + return rtmt.messagesTo(VIEWER.id(), Channels.ON_LIVE_GAMES); + } + + BackendGame startGame() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(BOB.id(), Channels.NEW_GAME); + return gameStore.getGameByUser(ALICE.id()); + } + + @Test + void sendsTheCurrentGamesOnSubscribe() { + BackendGame game = startGame(); + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + assertEquals(1, pushes().size()); + assertEquals(game.id, pushes().getFirst().at("/games/0/id").asText()); + assertEquals(1, pushes().getFirst().get("games").size()); + } + + @Test + void pushesChangesAtMostOncePerSecond() { + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + BackendGame game = startGame(); + scheduler.advance(0); + assertEquals(2, pushes().size()); + + // Two changes right after a push are sent together, a second later. + rtmt.receive(game.mancalaGame.getTurnPlayerId(), Channels.GAME_MOVE, Map.of("index", 0)); + rtmt.receive(SPECTATOR.id(), Channels.LISTEN_GAME_EVENTS, game.id); + scheduler.advance(999); + assertEquals(2, pushes().size()); + scheduler.advance(1); + assertEquals(3, pushes().size()); + JsonNode pushed = pushes().get(2).at("/games/0"); + assertEquals(1, pushed.get("moveCount").asInt()); + assertEquals(1, pushed.get("spectatorCount").asInt()); + } + + @Test + void doesNotCountSubscribersAsSpectators() { + BackendGame game = startGame(); + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + assertEquals(List.of(), game.spectatorIds); + assertEquals(0, pushes().getFirst().at("/games/0/spectatorCount").asInt()); + } + + @Test + void stopsPushingToUsersWhoUnsubscribeOrGoOffline() { + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + rtmt.receive(SPECTATOR.id(), Channels.SUBSCRIBE_LIVE_GAMES); + rtmt.receive(VIEWER.id(), Channels.UNSUBSCRIBE_LIVE_GAMES); + rtmt.disconnect(SPECTATOR.id()); + int before = rtmt.sent.size(); + startGame(); + scheduler.advance(1000); + assertEquals(List.of(), rtmt.sent.subList(before, rtmt.sent.size()).stream() + .filter(message -> message.channel().equals(Channels.ON_LIVE_GAMES)).toList()); + } + + @Test + void ordersByMostSpectatedThenMostRecentlyActive() { + AuthUser carol = new AuthUser("kc:carol", "carol", false); + AuthUser dave = new AuthUser("kc:dave", "dave", false); + List.of(carol, dave).forEach(rtmt::connect); + BackendGame older = startGame(); + scheduler.advance(1000); + rtmt.receive(carol.id(), Channels.NEW_GAME); + rtmt.receive(dave.id(), Channels.NEW_GAME); + BackendGame newer = gameStore.getGameByUser(carol.id()); + assertEquals(List.of(newer.id, older.id), liveGames.getTop(5).stream().map(summary -> summary.id()).toList()); + + rtmt.receive(SPECTATOR.id(), Channels.LISTEN_GAME_EVENTS, older.id); + assertEquals(List.of(older.id, newer.id), liveGames.getTop(5).stream().map(summary -> summary.id()).toList()); + assertEquals(1, liveGames.getTop(1).size()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/game/RematchOffersTest.java b/apps/backend/src/test/java/app/playmancala/game/RematchOffersTest.java new file mode 100644 index 0000000..d63360d --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/game/RematchOffersTest.java @@ -0,0 +1,268 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; +import app.playmancala.engine.Pit; +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +import app.playmancala.loop.ManualScheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.Json; +import app.playmancala.model.Seat; + +// Rematch requests with the real game logic and a manual clock (docs/specs/rematch). +class RematchOffersTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser BOB = new AuthUser("kc:bob", "bob", false); + static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false); + static final long CLOCK_MS = 5 * 60_000; + + ManualScheduler loop; + FakeRtmt rtmt; + GameStore gameStore; + GameManager gameManager; + + @BeforeEach + void setUp() { + loop = new ManualScheduler(Instant.parse("2026-10-17T12:00:00Z").toEpochMilli()); + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + InMemoryLiveGameStore liveGameStore = new InMemoryLiveGameStore(); + GameRecorder recorder = new GameRecorder(new InMemoryGameHistoryRepository(), liveGameStore, Runnable::run); + gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run, + loop::now, CLOCK_MS); + new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom(1)); + List.of(ALICE, BOB, CAROL).forEach(rtmt::connect); + } + + BackendGame startGame() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(BOB.id(), Channels.NEW_GAME); + return gameStore.getGameByUser(ALICE.id()); + } + + // The player to move plays a last stone that empties their side, which ends the game by the rules. + void finish(BackendGame game) { + String moverId = game.mancalaGame.getTurnPlayerId(); + boolean isPlayer1 = game.mancalaGame.getPlayer1Id().equals(moverId); + List pits = game.mancalaGame.getBoard().getPits(); + pits.forEach(pit -> pit.setStoneCount(0)); + pits.get(isPlayer1 ? 5 : 12).setStoneCount(1); + pits.get(isPlayer1 ? 7 : 0).setStoneCount(1); + gameManager.playMove(game, moverId, 5); + assertNull(gameStore.get(game.id), "game did not end"); + } + + void ask(AuthUser user, BackendGame game) { + rtmt.receive(user.id(), Channels.REMATCH, Map.of("gameId", game.id)); + } + + void decline(AuthUser user, BackendGame game) { + rtmt.receive(user.id(), Channels.DECLINE_REMATCH, Map.of("gameId", game.id)); + } + + List requestsTo(AuthUser user) { + return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_REQUEST); + } + + List endedReasonsTo(AuthUser user) { + return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_ENDED).stream().map(m -> m.get("reason").asText()).toList(); + } + + static Seat other(Seat seat) { + return seat == Seat.PLAYER1 ? Seat.PLAYER2 : Seat.PLAYER1; + } + + @Test + void whenBothAskTheNextGameStartsWithTheSameSeatsTheOtherFirstSeatAndTheSeries() { + BackendGame game = startGame(); + Seat firstSeat = game.firstSeat(); + finish(game); + + ask(ALICE, game); + assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), requestsTo(BOB)); + assertNull(gameStore.getGameByUser(ALICE.id())); + ask(BOB, game); + + BackendGame next = gameStore.getGameByUser(ALICE.id()); + assertNotNull(next); + assertEquals(game.mancalaGame.getPlayer1Id(), next.mancalaGame.getPlayer1Id()); + assertEquals(game.mancalaGame.getPlayer2Id(), next.mancalaGame.getPlayer2Id()); + assertEquals(other(firstSeat), next.firstSeat()); + assertEquals(game.series.id(), next.series.id()); + assertEquals(List.of(game.id), next.series.games().stream().map(g -> g.gameId()).toList()); + for (AuthUser user : List.of(ALICE, BOB)) { + assertEquals(next.id, rtmt.lastMessageTo(user.id(), Channels.ON_GAME_START).get("id").asText()); + } + assertEquals(0, loop.pendingTimers()); + } + + @Test + void theFirstSeatAlternatesThroughTheSeries() { + BackendGame game = startGame(); + Seat firstSeat = game.firstSeat(); + for (int number = 2; number <= 4; number++) { + finish(game); + ask(BOB, game); + ask(ALICE, game); + game = gameStore.getGameByUser(ALICE.id()); + assertEquals(number % 2 == 0 ? other(firstSeat) : firstSeat, game.firstSeat()); + assertEquals(number - 1, game.series.games().size()); + } + } + + @Test + void thereIsNoRematchAfterAPlayerLeft() { + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertFalse(rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).has("rematch")); + + ask(BOB, game); + assertEquals(List.of("closed"), endedReasonsTo(BOB)); + assertTrue(requestsTo(ALICE).isEmpty()); + } + + @Test + void sayingNoEndsTheSeries() { + BackendGame game = startGame(); + finish(game); + ask(ALICE, game); + decline(BOB, game); + assertEquals(List.of("declined"), endedReasonsTo(ALICE)); + assertEquals(List.of("declined"), endedReasonsTo(BOB)); + + ask(ALICE, game); + assertEquals(List.of("declined", "closed"), endedReasonsTo(ALICE)); + assertEquals(1, requestsTo(BOB).size()); + assertEquals(0, loop.pendingTimers()); + } + + @Test + void aWithdrawnRequestLeavesTheOfferOpen() { + BackendGame game = startGame(); + finish(game); + ask(ALICE, game); + decline(ALICE, game); + assertEquals(List.of("withdrawn"), endedReasonsTo(BOB)); + + ask(BOB, game); + assertEquals(1, requestsTo(ALICE).size()); + ask(ALICE, game); + assertNotNull(gameStore.getGameByUser(BOB.id())); + } + + @Test + void aRequestNotAnsweredWithinAMinuteExpiresAndCanBeMadeAgain() { + BackendGame game = startGame(); + finish(game); + ask(ALICE, game); + loop.advance(RematchOffers.ANSWER_MS - 1); + assertTrue(endedReasonsTo(ALICE).isEmpty()); + loop.advance(1); + assertEquals(List.of("expired"), endedReasonsTo(ALICE)); + assertEquals(List.of("expired"), endedReasonsTo(BOB)); + + ask(ALICE, game); + assertEquals(2, requestsTo(BOB).size()); + } + + @Test + void aPlayerGoingOfflineEndsTheRequest() { + BackendGame game = startGame(); + finish(game); + ask(ALICE, game); + rtmt.disconnect(BOB.id()); + assertEquals(List.of("offline"), endedReasonsTo(ALICE)); + } + + @Test + void aRequestToAnOpponentWhoIsOfflineEndsAtOnce() { + BackendGame game = startGame(); + finish(game); + rtmt.disconnect(BOB.id()); + ask(ALICE, game); + assertEquals(List.of("offline"), endedReasonsTo(ALICE)); + assertTrue(requestsTo(BOB).isEmpty()); + + rtmt.connect(BOB); + ask(ALICE, game); + assertEquals(1, requestsTo(BOB).size()); + } + + @ParameterizedTest + @ValueSource(strings = { Channels.NEW_GAME, Channels.NEW_BOT_GAME }) + void lookingForAnotherGameEndsTheSeries(String channel) { + BackendGame game = startGame(); + finish(game); + ask(ALICE, game); + rtmt.receive(BOB.id(), channel, Map.of("level", "easy")); + assertEquals(List.of("left"), endedReasonsTo(ALICE)); + + ask(ALICE, game); + assertEquals(List.of("left", "closed"), endedReasonsTo(ALICE)); + } + + @Test + void aRematchCanBeAskedForUpToTenMinutesAfterTheGame() { + BackendGame game = startGame(); + finish(game); + loop.advance(RematchOffers.OPEN_MS); + assertEquals(List.of("closed"), endedReasonsTo(ALICE)); + assertEquals(List.of("closed"), endedReasonsTo(BOB)); + + ask(ALICE, game); + assertTrue(requestsTo(BOB).isEmpty()); + } + + @Test + void theBotAcceptsAtOnceAtTheSameLevel() { + BotSettings bot = BotSettings.draw(BotLevel.HARD, new SplittableRandom(2)); + BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot); + gameManager.startGame(game); + Seat firstSeat = game.firstSeat(); + finish(game); + + ask(ALICE, game); + BackendGame next = gameStore.getGameByUser(ALICE.id()); + assertNotNull(next); + assertEquals(bot.botId(), next.mancalaGame.getPlayer2Id()); + assertEquals(BotLevel.HARD, next.bot.level()); + assertEquals(other(firstSeat), next.firstSeat()); + assertEquals(game.series.id(), next.series.id()); + assertEquals(1, next.series.games().size()); + } + + @Test + void aPlayerAlreadyInAnotherGameIsNotPulledIntoARematch() { + BackendGame game = startGame(); + finish(game); + ask(ALICE, game); + // As after a restore: Bob has a game without having looked for one. + BackendGame other = gameManager.createMancalaGame(BOB.id(), CAROL.id()); + ask(BOB, game); + + assertNull(gameStore.getGameByUser(ALICE.id())); + assertEquals(other, gameStore.getGameByUser(BOB.id())); + assertEquals(List.of("closed"), endedReasonsTo(ALICE)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/history/GameHistoryServiceTest.java b/apps/backend/src/test/java/app/playmancala/history/GameHistoryServiceTest.java new file mode 100644 index 0000000..cbc75f7 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/history/GameHistoryServiceTest.java @@ -0,0 +1,102 @@ +package app.playmancala.history; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import app.playmancala.fakes.InMemoryCache; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.model.EndReason; +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.PlayerSummary; + +// The list behaviour the Node backend's HTTP API tests checked: the cache, unsaved games and a database that is down. +class GameHistoryServiceTest { + static final PlayerSummary ALICE = new PlayerSummary("kc:alice", "alice", false); + static final PlayerSummary BOB = new PlayerSummary("kc:bob", "bob", false); + static final PlayerSummary CAROL = new PlayerSummary("kc:carol", "carol", false); + + InMemoryGameHistoryRepository repository; + InMemoryCache cache; + List unsaved; + GameHistoryService history; + long endedAt = 1_000_000; + + @BeforeEach + void setUp() { + repository = new InMemoryGameHistoryRepository(); + cache = new InMemoryCache(); + unsaved = new ArrayList<>(); + history = new GameHistoryService(repository, cache, () -> List.copyOf(unsaved)); + } + + GameRecord record(PlayerSummary player1, PlayerSummary player2, int peakSpectators) { + endedAt += 60_000; + return new GameRecord(UUID.randomUUID().toString(), player1, player2, List.of(), GameResult.PLAYER1, + EndReason.LEFT, null, endedAt - 1000, endedAt, 0, peakSpectators, 300_000L, List.of(), peakSpectators); + } + + static List ids(List games) { + return games.stream().map(FinishedGameSummary::id).toList(); + } + + @Test + void returnsTheUsersLatestGamesAndShowsANewOneRightAwayDespiteTheCache() { + GameRecord first = record(ALICE, BOB, 0); + repository.save(first); + repository.save(record(CAROL, BOB, 0)); + assertEquals(List.of(first.id()), ids(history.getRecentForUser(ALICE.userId(), 3))); + + GameRecord second = record(BOB, ALICE, 0); + repository.save(second); + // Still cached. + assertEquals(List.of(first.id()), ids(history.getRecentForUser(ALICE.userId(), 3))); + history.onGameSaved(second); + assertEquals(List.of(second.id()), ids(history.getRecentForUser(ALICE.userId(), 1))); + } + + @Test + void returnsAnEmptyListWhileTheDatabaseIsDown() { + repository.failing = true; + assertEquals(List.of(), history.getRecentForUser(ALICE.userId(), 3)); + assertEquals(List.of(), history.getPopular(5)); + } + + @Test + void includesGamesThatEndedButAreNotSavedYet() { + repository.failing = true; + GameRecord game = record(ALICE, BOB, 0); + unsaved.add(game); + assertEquals(List.of(game.id()), ids(history.getRecentForUser(ALICE.userId(), 3))); + assertEquals(List.of(), ids(history.getRecentForUser(CAROL.userId(), 3))); + assertEquals(List.of(game.id()), ids(history.getPopular(5))); + assertSame(game, history.get(game.id())); + } + + @Test + void listsPopularGamesBySpectatorsThenNewestWithoutDuplicates() { + GameRecord quiet = record(ALICE, BOB, 0); + GameRecord watched = record(ALICE, BOB, 3); + GameRecord newerQuiet = record(ALICE, BOB, 0); + List.of(quiet, watched).forEach(repository::save); + unsaved.add(newerQuiet); + unsaved.add(watched); // saved meanwhile + assertEquals(List.of(watched.id(), newerQuiet.id(), quiet.id()), ids(history.getPopular(5))); + assertEquals(List.of(watched.id()), ids(history.getPopular(1))); + } + + @Test + void readingASavedGameFailsWhileTheDatabaseIsDown() { + repository.failing = true; + assertThrows(IllegalStateException.class, () -> history.get(UUID.randomUUID().toString())); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java new file mode 100644 index 0000000..ab41959 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java @@ -0,0 +1,218 @@ +package app.playmancala.history; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; +import app.playmancala.engine.GameStep; +import app.playmancala.model.EndReason; +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.Json; +import app.playmancala.model.MoveRecord; +import app.playmancala.model.PlayerSummary; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; + +// Ported from the Node backend's tests/history/PostgresGameHistoryRepository.test.ts. Postgres runs in Docker. +@QuarkusTest +class PostgresGameHistoryRepositoryTest { + static final PlayerSummary ALICE = new PlayerSummary("kc:alice", "alice", false); + static final PlayerSummary BOB = new PlayerSummary("kc:bob", "bob", false); + static final PlayerSummary GUEST = new PlayerSummary("kc:guest", "guest-1a2b3c4d", true); + static final PlayerSummary LEGACY = new PlayerSummary("legacy:0123456789abcdef0123", null, true); + + static long endedAt = Instant.parse("2026-10-01T00:00:00Z").toEpochMilli(); + + @Inject + DataSource dataSource; + + PostgresGameHistoryRepository repository; + + @BeforeEach + void setUp() throws Exception { + Migrations.run(dataSource); + execute("TRUNCATE games CASCADE"); + repository = new PostgresGameHistoryRepository(dataSource); + } + + void execute(String sql) throws Exception { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + static GameRecord record(PlayerSummary player1, PlayerSummary player2, GameResult result, EndReason endReason, + String leftUserId, int peakSpectators, int totalSpectators, Long clockInitialMs, Long remainingMs) { + endedAt += 60_000; + MoveRecord move = new MoveRecord(player1.userId(), 3, List.of(4, 4, 4, 0, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0), + List.of(new GameStep(6, "GAME_STEP_LAST_STONE_IN_BANK")), endedAt - 599_000, remainingMs); + return new GameRecord(UUID.randomUUID().toString(), player1, player2, + List.of(0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18), result, endReason, leftUserId, endedAt - 600_000, + endedAt, 1, peakSpectators, clockInitialMs, List.of(move), totalSpectators); + } + + static GameRecord record() { + return record(ALICE, BOB, GameResult.PLAYER1, EndReason.COMPLETED, null, 0, 0, null, null); + } + + static GameRecord record(PlayerSummary player1, PlayerSummary player2) { + return record(player1, player2, GameResult.PLAYER1, EndReason.COMPLETED, null, 0, 0, null, null); + } + + static GameRecord withPeak(int peakSpectators) { + return record(ALICE, BOB, GameResult.PLAYER1, EndReason.COMPLETED, null, peakSpectators, 0, null, null); + } + + @Test + void runsMigrationsOnlyOnce() throws Exception { + Migrations.run(dataSource); + List versions = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) { + while (rows.next()) versions.add(rows.getString(1)); + } + assertEquals(List.of("001_games", "002_clock", "003_bot", "004_series"), versions); + } + + @Test + void savesAGameAndReadsItBackUnchanged() { + GameRecord game = record(ALICE, LEGACY, null, EndReason.INACTIVE, null, 3, 5, null, null); + repository.save(game); + assertEquals(Json.tree(game), Json.tree(repository.get(game.id()))); + } + + @Test + void keepsTheClockTheInitialTimeAndEachMovesTimeLeft() { + GameRecord game = record(ALICE, BOB, GameResult.PLAYER1, EndReason.COMPLETED, null, 0, 0, 300_000L, 291_500L); + repository.save(game); + assertEquals(Json.tree(game), Json.tree(repository.get(game.id()))); + assertEquals(300_000L, repository.getRecentForUser(ALICE.userId(), 1).getFirst().clockInitialMs()); + } + + @Test + void readsGamesSavedBeforeTheGameClockWithoutClockData() { + GameRecord game = record(); + repository.save(game); + JsonNode saved = Json.tree(repository.get(game.id())); + assertFalse(saved.has("clockInitialMs")); + assertFalse(saved.at("/moves/0").has("remainingMs")); + } + + @Test + void keepsHowTheBotPlayed() { + GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false)); + GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), + game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, + game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L), null); + repository.save(botGame); + assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id()))); + assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId()); + GameRecord betweenPeople = record(); + repository.save(betweenPeople); + assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot")); + } + + @Test + void keepsTheSeriesAGameBelongsTo() { + GameRecord game = record(); + GameRecord inSeries = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), + game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, + game.moves(), game.totalSpectators(), null, UUID.randomUUID().toString()); + repository.save(inSeries); + assertEquals(Json.tree(inSeries), Json.tree(repository.get(game.id()))); + GameRecord before = record(); + repository.save(before); + assertFalse(Json.tree(repository.get(before.id())).has("seriesId")); + } + + @Test + void keepsLeftUserIdForGamesAPlayerLeft() { + GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); + repository.save(game); + assertEquals(ALICE.userId(), repository.get(game.id()).leftUserId()); + } + + @Test + void ignoresSavingTheSameGameAgain() { + GameRecord game = record(); + repository.save(game); + repository.save(new GameRecord(game.id(), game.player1(), game.player2(), game.board(), GameResult.DRAW, + game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, + game.moves(), game.totalSpectators())); + assertEquals(GameResult.PLAYER1, repository.get(game.id()).result()); + } + + @Test + void returnsNullForUnknownAndMalformedIds() { + assertNull(repository.get(UUID.randomUUID().toString())); + assertNull(repository.get("not-a-uuid")); + } + + @Test + void listsAUsersGamesNewestFirstWithoutTheMoves() { + GameRecord first = record(); + GameRecord second = record(GUEST, ALICE); + GameRecord otherPlayers = record(BOB, GUEST); + List.of(first, second, otherPlayers).forEach(repository::save); + + List recent = repository.getRecentForUser(ALICE.userId(), 3); + assertEquals(List.of(second.id(), first.id()), recent.stream().map(FinishedGameSummary::id).toList()); + assertFalse(Json.tree(recent.getFirst()).has("moves")); + assertEquals(1, repository.getRecentForUser(ALICE.userId(), 1).size()); + } + + @Test + void listsPopularGamesByPeakSpectatorsThenNewest() { + GameRecord quiet = withPeak(0); + GameRecord popular = withPeak(9); + GameRecord alsoPopularButNewer = withPeak(9); + List.of(quiet, popular, alsoPopularButNewer).forEach(repository::save); + + assertEquals(List.of(alsoPopularButNewer.id(), popular.id(), quiet.id()), + repository.getPopular(5).stream().map(FinishedGameSummary::id).toList()); + } + + @Test + void readsAGameSavedByTheNodeBackend() throws Exception { + // As the Node backend's repository inserted it. + String id = UUID.randomUUID().toString(); + execute(""" + INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at, + move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms) + VALUES ('%s', 'kc:alice', 'legacy:0123456789abcdef0123', 'player2', 'left', 'kc:alice', + to_timestamp(1792152000000 / 1000.0), to_timestamp(1792152061234 / 1000.0), 1, + '[4,4,4,0,5,5,1,4,4,4,4,4,4,0]', + '[{"playerId":"kc:alice","moveIndex":3,"boardSnapshot":[4,4,4,0,5,5,1,4,4,4,4,4,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[12]}}],"at":1792152001000,"remainingMs":299000}]', + 2, 4, 300000); + INSERT INTO game_players (game_id, seat, user_id, name, is_guest) + VALUES ('%s', 1, 'kc:alice', 'alice', false), ('%s', 2, 'legacy:0123456789abcdef0123', NULL, true); + """.formatted(id, id, id)); + GameRecord game = repository.get(id); + assertEquals(1792152061234L, game.endedAt()); + assertEquals(GameResult.PLAYER2, game.result()); + assertEquals(new PlayerSummary("legacy:0123456789abcdef0123", null, true), game.player2()); + assertEquals("{\"playerId\":\"kc:alice\",\"moveIndex\":3,\"boardSnapshot\":[4,4,4,0,5,5,1,4,4,4,4,4,4,0]," + + "\"gameSteps\":[{\"index\":3,\"type\":\"GAME_STEP_GAME_MOVE\",\"data\":null},{\"index\":6," + + "\"type\":\"GAME_STEP_BOARD_CLEARED\",\"data\":{\"pitIndexesThatHasStone\":[12]}}]," + + "\"at\":1792152001000,\"remainingMs\":299000}", Json.write(game.moves().getFirst())); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/http/ApiTest.java b/apps/backend/src/test/java/app/playmancala/http/ApiTest.java new file mode 100644 index 0000000..8bb82bf --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/http/ApiTest.java @@ -0,0 +1,221 @@ +package app.playmancala.http; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.matchesPattern; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.game.PublicIds; +import app.playmancala.model.Channels; +import app.playmancala.model.Json; +import app.playmancala.support.TestClient; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; + +// The WebSocket and the HTTP API end to end, with Postgres and Valkey in Docker and a fake token verifier. +// Ported from the Node backend's tests/server/ExpressApp.test.ts and tests/rtmt/rtmt_websocket.test.ts. +@QuarkusTest +class ApiTest { + @TestHTTPResource("/") + URI base; + + URI webSocketUri(String query) { + return URI.create(base.toString().replaceFirst("^http", "ws") + query); + } + + static String unique(String name) { + return "kc:" + name + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + static JsonNode get(String path, String userId) { + var request = given(); + if (userId != null) request = request.header("Authorization", "Bearer valid:" + userId); + String body = request.when().get(path).then().statusCode(200).extract().asString(); + try { + return Json.parse(body); + } catch (Exception e) { + throw new IllegalStateException(body, e); + } + } + + static List ids(JsonNode games) { + return games.valueStream().map(game -> game.get("id").asText()).toList(); + } + + // Both ask for a game and get paired; returns the on_game_start message. + static JsonNode startGame(TestClient first, TestClient second) { + first.send(Channels.NEW_GAME, Map.of()); + second.send(Channels.NEW_GAME, Map.of()); + JsonNode start = first.waitFor(Channels.ON_GAME_START); + assertEquals(start.get("id"), second.waitFor(Channels.ON_GAME_START).get("id")); + return start; + } + + @Test + void answersOnTheRootPathAndIssuesLegacyUserKeys() { + given().when().get("/").then().statusCode(200); + String uuid = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + given().when().get("/register/").then().statusCode(200).body(matchesPattern(uuid)); + given().when().get("/register").then().statusCode(200).body(matchesPattern(uuid)); + } + + @Test + void allowsEveryOrigin() { + given().header("Origin", "https://playmancala.app").when().get("/games/popular").then().statusCode(200) + .header("Access-Control-Allow-Origin", matchesPattern("\\*|https://playmancala\\.app")); + } + + @Test + void aGameFromStartToTheSavedRecord() { + String alice = unique("alice"); + String bob = unique("bob"); + String carol = unique("carol"); + try (TestClient aliceClient = TestClient.connectAs(webSocketUri(""), alice); + TestClient bobClient = TestClient.connectAs(webSocketUri(""), bob); + TestClient carolClient = TestClient.connectAs(webSocketUri(""), carol)) { + JsonNode start = startGame(aliceClient, bobClient); + String gameId = start.get("id").asText(); + assertEquals("initial", start.at("/mancalaGame/state").asText()); + assertEquals(300_000, start.at("/clock/initialMs").asLong()); + + // A spectator, seen by the API but without spectator ids. + carolClient.send(Channels.LISTEN_GAME_EVENTS, gameId); + await().atMost(Duration.ofSeconds(5)).until(() -> get("/game/" + gameId, null).get("spectatorCount").asInt() == 1); + JsonNode live = get("/game/" + gameId, null); + assertEquals(gameId, live.get("id").asText()); + assertFalse(live.has("spectatorIds")); + assertTrue(ids(get("/games/popular?limit=20", null).get("games")).contains(gameId)); + assertEquals(gameId, get("/games/me/active", alice).at("/game/id").asText()); + assertTrue(get("/games/me/active", carol).get("game").isNull()); + + // A move reaches the players and the spectator. + String turnPlayerId = start.at("/mancalaGame/turnPlayerId").asText(); + (turnPlayerId.equals(alice) ? aliceClient : bobClient).send(Channels.GAME_MOVE, Map.of("index", 0)); + for (TestClient client : List.of(aliceClient, bobClient, carolClient)) { + assertEquals(1, client.waitFor(Channels.ON_GAME_UPDATE).at("/mancalaGame/history").size()); + } + assertEquals(1, get("/game/" + gameId, null).at("/mancalaGame/history").size()); + + // Alice leaves: Bob wins, and the game is saved. + aliceClient.send(Channels.LEAVE_GAME, Map.of()); + JsonNode end = carolClient.waitFor(Channels.ON_GAME_END); + assertEquals("left", end.get("endReason").asText()); + assertEquals(bob, end.get("winnerId").asText()); + assertEquals(alice, bobClient.waitFor(Channels.ON_GAME_USER_LEAVE).asText()); + + JsonNode finished = get("/game/" + gameId, null); + assertEquals("finished", finished.get("status").asText()); + assertEquals(1, finished.at("/record/moves").size()); + assertEquals(alice, finished.at("/record/leftUserId").asText()); + assertTrue(get("/games/me/active", alice).get("game").isNull()); + await().atMost(Duration.ofSeconds(10)).until(() -> ids(get("/games/me", alice).get("games")).contains(gameId)); + assertEquals(List.of(gameId), ids(get("/games/me?limit=1", bob).get("games"))); + assertFalse(get("/games/me", bob).get("games").get(0).has("moves")); + assertTrue(ids(get("/games/popular?kind=finished&limit=20", null).get("games")).contains(gameId)); + } + } + + @Test + void rejectsANewGameWhileOneIsInProgress() { + try (TestClient alice = TestClient.connectAs(webSocketUri(""), unique("alice")); + TestClient bob = TestClient.connectAs(webSocketUri(""), unique("bob"))) { + String gameId = startGame(alice, bob).get("id").asText(); + alice.send(Channels.NEW_GAME, Map.of()); + assertEquals(gameId, alice.waitFor(Channels.ON_NEW_GAME_REJECTED).get("gameId").asText()); + alice.send(Channels.LEAVE_GAME, Map.of()); + bob.waitFor(Channels.ON_GAME_END); + } + } + + @Test + void aReconnectingPlayerGetsTheGameAgain() { + String alice = unique("alice"); + try (TestClient aliceClient = TestClient.connectAs(webSocketUri(""), alice); + TestClient bob = TestClient.connectAs(webSocketUri(""), unique("bob"))) { + String gameId = startGame(aliceClient, bob).get("id").asText(); + aliceClient.close(); + JsonNode offline = bob.waitFor(Channels.ON_USER_CONNECTION_CHANGE, 2).getLast(); + assertTrue(offline.toString().contains("\"isOnline\":false"), offline.toString()); + + try (TestClient again = TestClient.connectAs(webSocketUri(""), alice)) { + assertEquals(gameId, again.waitFor(Channels.ON_GAME_UPDATE).get("id").asText()); + again.send(Channels.LEAVE_GAME, Map.of()); + bob.waitFor(Channels.ON_GAME_END); + } + } + } + + @Test + void sendsLiveGamePreviewsToSubscribers() { + try (TestClient viewer = TestClient.connectAs(webSocketUri(""), unique("viewer")); + TestClient alice = TestClient.connectAs(webSocketUri(""), unique("alice")); + TestClient bob = TestClient.connectAs(webSocketUri(""), unique("bob"))) { + viewer.send(Channels.SUBSCRIBE_LIVE_GAMES, Map.of()); + viewer.waitFor(Channels.ON_LIVE_GAMES); + String gameId = startGame(alice, bob).get("id").asText(); + await().atMost(Duration.ofSeconds(5)).until(() -> viewer.messages(Channels.ON_LIVE_GAMES).stream() + .anyMatch(push -> ids(push.get("games")).contains(gameId))); + alice.send(Channels.LEAVE_GAME, Map.of()); + bob.waitFor(Channels.ON_GAME_END); + } + } + + @Test + void requiresAValidTokenForTheUsersGames() { + given().when().get("/games/me/active").then().statusCode(401); + given().header("Authorization", "Bearer forged").when().get("/games/me/active").then().statusCode(401); + given().when().get("/games/me").then().statusCode(401); + } + + @Test + void answers404ForUnknownGames() { + given().when().get("/game/unknown").then().statusCode(404); + given().when().get("/game/" + UUID.randomUUID()).then().statusCode(404); + } + + @Test + void closesASocketWithAnInvalidToken() throws Exception { + TestClient client = new TestClient(webSocketUri("")); + client.send(Channels.AUTH, Map.of("token", "forged")); + assertEquals(Json.tree(Map.of("reason", "invalid token")), client.waitFor(Channels.ON_AUTH_ERROR)); + assertEquals(Channels.CLOSE_AUTH_FAILED, client.closed.get(5, TimeUnit.SECONDS)); + } + + @Test + void playsWithLegacyUserKeyClients() { + String key1 = UUID.randomUUID().toString(); + String key2 = UUID.randomUUID().toString(); + try (TestClient player1 = new TestClient(webSocketUri("?userKey=" + key1)); + TestClient player2 = new TestClient(webSocketUri("?userKey=" + key2))) { + String gameId = startGame(player1, player2).get("id").asText(); + JsonNode preview = get("/games/popular?limit=20", null).get("games").valueStream() + .filter(game -> game.get("id").asText().equals(gameId)).findFirst().orElseThrow(); + assertFalse(preview.toString().contains(key1)); + assertTrue(preview.toString().contains(PublicIds.publicUserId(key1))); + player1.send(Channels.LEAVE_GAME, Map.of()); + player2.waitFor(Channels.ON_GAME_END); + } + } + + @Test + void parsesLimitsLikeParseInt() { + assertEquals(3, GameResource.parseLimit(null, 3)); + assertEquals(3, GameResource.parseLimit("abc", 3)); + assertEquals(7, GameResource.parseLimit("7abc", 3)); + assertEquals(1, GameResource.parseLimit("-4", 3)); + assertEquals(20, GameResource.parseLimit("99999999999999999999", 3)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/live/ValkeyStoreTest.java b/apps/backend/src/test/java/app/playmancala/live/ValkeyStoreTest.java new file mode 100644 index 0000000..f53217e --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/live/ValkeyStoreTest.java @@ -0,0 +1,131 @@ +package app.playmancala.live; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.EndReason; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.Json; +import app.playmancala.model.PlayerSummary; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; + +// Ported from the Node backend's tests/live/ValkeyStore.test.ts. Valkey runs in Docker. +@QuarkusTest +class ValkeyStoreTest { + static final TypeReference> NUMBERS = new TypeReference<>() { }; + + @Inject + RedisDataSource redis; + + // Points at a port nothing listens on. + @Inject + @RedisClientName("offline") + RedisDataSource offline; + + @BeforeEach + void flush() { + redis.flushall(); + } + + static GameRecord record(String id) { + return new GameRecord(id, new PlayerSummary("kc:alice", "alice", false), new PlayerSummary("kc:bob", "bob", false), + List.of(0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18), GameResult.PLAYER1, EndReason.COMPLETED, null, 1, 2, 0, + 0, null, List.of(), 0); + } + + static List sortById(List games) { + return games.stream().sorted(Comparator.comparing(game -> game.get("id").asText())).toList(); + } + + @Test + void savesLoadsAndRemovesLiveGameSnapshots() { + ValkeyLiveGameStore store = new ValkeyLiveGameStore(redis); + store.saveGame("g1", Map.of("id", "g1", "moves", 1)); + store.saveGame("g2", Map.of("id", "g2", "moves", 2)); + store.saveGame("g1", Map.of("id", "g1", "moves", 3)); + assertEquals(List.of(Json.tree(Map.of("id", "g1", "moves", 3)), Json.tree(Map.of("id", "g2", "moves", 2))), + sortById(store.loadGames())); + + store.removeGame("g1"); + assertEquals(List.of(Json.tree(Map.of("id", "g2", "moves", 2))), store.loadGames()); + } + + @Test + void keepsPendingRecordsSeparateFromLiveGames() { + ValkeyLiveGameStore store = new ValkeyLiveGameStore(redis); + store.saveGame("g1", Map.of("id", "g1")); + store.savePendingRecord(record("r1")); + assertEquals(List.of(record("r1")), store.loadPendingRecords()); + assertEquals(List.of(Json.tree(Map.of("id", "g1"))), store.loadGames()); + + store.removePendingRecord("r1"); + assertEquals(List.of(), store.loadPendingRecords()); + } + + @Test + void skipsValuesItCannotRead() { + ValkeyLiveGameStore store = new ValkeyLiveGameStore(redis); + redis.value(String.class).set("mancala:live:broken", "{not json"); + store.saveGame("g1", Map.of("id", "g1")); + assertEquals(List.of(Json.tree(Map.of("id", "g1"))), store.loadGames()); + } + + @Test + void storesValuesAsTheNodeBackendDid() { + new ValkeyLiveGameStore(redis).saveGame("g1", Map.of("id", "g1")); + assertEquals("{\"id\":\"g1\"}", redis.value(String.class).get("mancala:live:g1")); + new ValkeyCache(redis).set("popular", List.of(1, 2), 60); + assertEquals("[1,2]", redis.value(String.class).get("mancala:cache:popular")); + } + + @Test + void cacheReturnsValuesUntilTheyExpire() throws InterruptedException { + ValkeyCache cache = new ValkeyCache(redis); + cache.set("popular", List.of(1, 2, 3), 1); + assertEquals(Optional.of(List.of(1, 2, 3)), cache.get("popular", NUMBERS)); + Thread.sleep(1500); + assertEquals(Optional.empty(), cache.get("popular", NUMBERS)); + } + + @Test + void cacheDeletesEntries() { + ValkeyCache cache = new ValkeyCache(redis); + cache.set("a", 1, 60); + cache.set("b", 2, 60); + cache.delete("a", "b"); + assertEquals(Optional.empty(), cache.get("a", NUMBERS)); + assertEquals(Optional.empty(), cache.get("b", NUMBERS)); + } + + @Test + void whenValkeyIsUnreachableTheCacheAnswersWithMisses() { + ValkeyCache cache = new ValkeyCache(offline); + cache.set("a", 1, 60); + assertEquals(Optional.empty(), cache.get("a", NUMBERS)); + cache.delete("a"); + } + + @Test + void whenValkeyIsUnreachableTheStoreFailsAtOnce() { + ValkeyLiveGameStore store = new ValkeyLiveGameStore(offline); + long started = System.currentTimeMillis(); + assertThrows(RuntimeException.class, () -> store.saveGame("g1", Map.of())); + assertTrue(System.currentTimeMillis() - started < 1000, "took " + (System.currentTimeMillis() - started) + " ms"); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/loop/ManualScheduler.java b/apps/backend/src/test/java/app/playmancala/loop/ManualScheduler.java new file mode 100644 index 0000000..8ee6b63 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/loop/ManualScheduler.java @@ -0,0 +1,85 @@ +package app.playmancala.loop; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +// A Scheduler for tests, in place of Jest's fake timers: tasks run at once, timers when advance() reaches them. +public final class ManualScheduler implements Scheduler { + private long now; + private long sequence; + private final List timers = new ArrayList<>(); + + private final class Timer implements Cancellable { + final Runnable task; + final long periodMs; + final long order = sequence++; + long dueAt; + boolean cancelled; + + Timer(Runnable task, long dueAt, long periodMs) { + this.task = task; + this.dueAt = dueAt; + this.periodMs = periodMs; + } + + @Override + public void cancel() { + cancelled = true; + timers.remove(this); + } + } + + public ManualScheduler(long now) { + this.now = now; + } + + @Override + public long now() { + return now; + } + + public void setNow(long now) { + this.now = now; + } + + @Override + public void execute(Runnable task) { + task.run(); + } + + @Override + public Cancellable schedule(Runnable task, long delayMs) { + Timer timer = new Timer(task, now + Math.max(0, delayMs), 0); + timers.add(timer); + return timer; + } + + @Override + public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) { + Timer timer = new Timer(task, now + periodMs, periodMs); + timers.add(timer); + return timer; + } + + // Moves the clock forward, running the timers that come due in order, each at its own time. + public void advance(long ms) { + long target = now + ms; + while (true) { + Timer next = timers.stream() + .filter(timer -> timer.dueAt <= target) + .min(Comparator.comparingLong(timer -> timer.dueAt).thenComparingLong(timer -> timer.order)) + .orElse(null); + if (next == null) break; + now = next.dueAt; + if (next.periodMs > 0) next.dueAt += next.periodMs; + else timers.remove(next); + next.task.run(); + } + now = target; + } + + public int pendingTimers() { + return timers.size(); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/loop/ManualSchedulerTest.java b/apps/backend/src/test/java/app/playmancala/loop/ManualSchedulerTest.java new file mode 100644 index 0000000..aac52b8 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/loop/ManualSchedulerTest.java @@ -0,0 +1,26 @@ +package app.playmancala.loop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ManualSchedulerTest { + @Test + void runsTimersInOrderAtTheirOwnTime() { + ManualScheduler scheduler = new ManualScheduler(1_000); + List runs = new ArrayList<>(); + scheduler.scheduleAtFixedRate(() -> runs.add("tick@" + scheduler.now()), 100); + scheduler.schedule(() -> runs.add("once@" + scheduler.now()), 150); + Cancellable cancelled = scheduler.schedule(() -> runs.add("cancelled"), 50); + cancelled.cancel(); + + scheduler.advance(99); + assertEquals(List.of(), runs); + scheduler.advance(201); + assertEquals(List.of("tick@1100", "once@1150", "tick@1200", "tick@1300"), runs); + assertEquals(1_300, scheduler.now()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/rtmt/RtmtServerTest.java b/apps/backend/src/test/java/app/playmancala/rtmt/RtmtServerTest.java new file mode 100644 index 0000000..0d3d29f --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/rtmt/RtmtServerTest.java @@ -0,0 +1,324 @@ +package app.playmancala.rtmt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.auth.InvalidTokenException; +import app.playmancala.auth.TokenVerifier; +import app.playmancala.loop.ManualScheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.Json; + +// Ported from the Node backend's tests/rtmt/rtmt_websocket.test.ts, with fake sockets instead of a server. +class RtmtServerTest { + static final String LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11"; + static final long AUTH_TIMEOUT_MS = 300; + + // Accepts tokens of the form "valid:". + static final TokenVerifier FAKE_VERIFIER = token -> { + if (!token.startsWith("valid:")) throw new InvalidTokenException("bad token"); + String id = token.substring("valid:".length()); + return new AuthUser(id, "name-of-" + id, false); + }; + + final ManualScheduler loop = new ManualScheduler(0); + // Token checks wait here until the test runs them, as they would on a worker thread. + final List pendingChecks = new ArrayList<>(); + final List connectionChanges = new ArrayList<>(); + RtmtServer server; + int socketCount; + + class FakeSocket implements RtmtSocket { + final String id = "socket-" + ++socketCount; + final String query; + final List received = new ArrayList<>(); + boolean open = true; + Integer closeCode; + + FakeSocket(String query) { + this.query = query; + } + + @Override + public String id() { + return id; + } + + @Override + public String queryParam(String name) { + return query != null && query.startsWith(name + "=") ? query.substring(name.length() + 1) : null; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void send(String text) { + try { + received.add(Json.parse(text)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + // Closing also delivers the close event, as the endpoint would. + @Override + public void close(int code, String reason) { + if (!open) return; + open = false; + closeCode = code; + server.onClose(this); + } + + void sendText(String text) { + server.onText(this, text); + runChecks(); + } + + void send(String channel, Object message) { + sendText(Json.write(Map.of("channel", channel, "message", message))); + } + + List messages(String channel) { + return received.stream().filter(m -> m.get("channel").asText().equals(channel)).map(m -> m.get("message")).toList(); + } + } + + void start(boolean legacyUserKeyAuth) { + server = new RtmtServer(FAKE_VERIFIER, legacyUserKeyAuth, loop, pendingChecks::add, AUTH_TIMEOUT_MS); + server.listenOnClientConnectionChange((id, isOnline) -> connectionChanges.add(id + (isOnline ? " online" : " offline"))); + server.start(); + } + + void runChecks() { + List checks = List.copyOf(pendingChecks); + pendingChecks.clear(); + checks.forEach(Runnable::run); + } + + FakeSocket connect(String query) { + FakeSocket socket = new FakeSocket(query); + server.onOpen(socket); + return socket; + } + + FakeSocket connectAs(String id) { + FakeSocket socket = connect(null); + socket.send(Channels.AUTH, Map.of("token", "valid:" + id)); + assertEquals(1, socket.messages(Channels.ON_AUTH).size()); + return socket; + } + + void disconnect(FakeSocket socket) { + socket.open = false; + server.onClose(socket); + } + + @Test + void authenticatesASocketAndRoutesItsMessagesAsThatUser() { + start(false); + List received = new ArrayList<>(); + server.listenMessage("hello", (id, message) -> received.add(id + " " + message)); + + FakeSocket socket = connect(null); + socket.send(Channels.AUTH, Map.of("token", "valid:alice")); + assertEquals(List.of(Json.tree(Map.of("user", Map.of("id", "alice", "name", "name-of-alice", "isGuest", false)))), + socket.messages(Channels.ON_AUTH)); + + socket.send("hello", Map.of("n", 1)); + assertEquals(List.of("alice {\"n\":1}"), received); + assertTrue(server.isClientOnline("alice")); + assertEquals("name-of-alice", server.getUser("alice").name()); + assertEquals(List.of("alice online"), connectionChanges); + } + + @Test + void sendsMessagesAsChannelAndMessage() { + start(false); + FakeSocket socket = connectAs("alice"); + server.sendMessage("alice", "news", "text"); + assertEquals(Json.tree(Map.of("channel", "news", "message", "text")), socket.received.getLast()); + assertEquals(List.of("channel", "message"), socket.received.getLast().propertyStream().map(Map.Entry::getKey).toList()); + } + + @Test + void ignoresMessagesBeforeAuthentication() { + start(false); + List received = new ArrayList<>(); + server.listenMessage("hello", (id, message) -> received.add(id)); + connect(null).send("hello", Map.of()); + assertEquals(List.of(), received); + } + + @Test + void closesTheSocketOnAnInvalidToken() { + start(false); + FakeSocket socket = connect(null); + socket.send(Channels.AUTH, Map.of("token", "forged")); + assertEquals(List.of(Json.tree(Map.of("reason", "invalid token"))), socket.messages(Channels.ON_AUTH_ERROR)); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + } + + @Test + void closesTheSocketWhenATokenIsMissing() { + start(false); + FakeSocket socket = connect(null); + socket.send(Channels.AUTH, Map.of()); + assertEquals(List.of(Json.tree(Map.of("reason", "missing token"))), socket.messages(Channels.ON_AUTH_ERROR)); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + } + + @Test + void closesTheSocketWhenNoTokenArrivesInTime() { + start(false); + FakeSocket socket = connect(null); + loop.advance(AUTH_TIMEOUT_MS - 1); + assertTrue(socket.open); + loop.advance(1); + assertEquals(List.of(Json.tree(Map.of("reason", "authentication timeout"))), socket.messages(Channels.ON_AUTH_ERROR)); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + } + + @Test + void keepsAnAuthenticatedSocketPastTheTimeout() { + start(false); + FakeSocket socket = connectAs("alice"); + loop.advance(AUTH_TIMEOUT_MS * 2); + assertNull(socket.closeCode); + } + + @Test + void acceptsARefreshedTokenForTheSameUserButNotForAnotherUser() { + start(false); + FakeSocket socket = connectAs("alice"); + socket.send(Channels.AUTH, Map.of("token", "valid:alice")); + assertEquals(2, socket.messages(Channels.ON_AUTH).size()); + assertEquals(List.of("alice online"), connectionChanges); + + socket.send(Channels.AUTH, Map.of("token", "valid:mallory")); + assertEquals(List.of(Json.tree(Map.of("reason", "token belongs to another user"))), + socket.messages(Channels.ON_AUTH_ERROR)); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + assertFalse(server.isClientOnline("alice")); + } + + @Test + void ignoresATokenCheckThatEndsAfterTheSocketClosed() { + start(false); + FakeSocket socket = connect(null); + server.onText(socket, Json.write(Map.of("channel", Channels.AUTH, "message", Map.of("token", "valid:alice")))); + disconnect(socket); + runChecks(); + assertFalse(server.isClientOnline("alice")); + assertEquals(List.of(), connectionChanges); + } + + @Test + void rejectsUserKeyConnectionsWhenLegacyAuthIsOff() { + start(false); + FakeSocket socket = connect("userKey=" + LEGACY_KEY); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + } + + @Test + void survivesMalformedMessages() { + start(false); + FakeSocket socket = connectAs("alice"); + socket.sendText("{not json"); + socket.sendText("null"); + socket.sendText("[1]"); + socket.sendText("{\"channel\":5}"); + socket.send(Channels.AUTH, Map.of("token", "valid:alice")); + assertEquals(2, socket.messages(Channels.ON_AUTH).size()); + } + + @Test + void deliversMessagesToEverySocketOfTheUser() { + start(false); + FakeSocket phone = connectAs("alice"); + FakeSocket laptop = connectAs("alice"); + server.sendMessage("alice", "news", Map.of("n", 1)); + assertEquals(List.of(Json.tree(Map.of("n", 1))), phone.messages("news")); + assertEquals(List.of(Json.tree(Map.of("n", 1))), laptop.messages("news")); + assertEquals(List.of("alice online", "alice online"), connectionChanges); + } + + @Test + void keepsTheUserOnlineUntilTheLastSocketCloses() { + start(false); + FakeSocket phone = connectAs("alice"); + FakeSocket laptop = connectAs("alice"); + + disconnect(phone); + assertTrue(server.isClientOnline("alice")); + assertFalse(connectionChanges.contains("alice offline")); + + disconnect(laptop); + assertFalse(server.isClientOnline("alice")); + assertNull(server.getUser("alice")); + assertTrue(connectionChanges.contains("alice offline")); + } + + @Test + void doesNotDropAReconnectedSocketWhenTheOldOneClosesLate() { + start(false); + FakeSocket oldSocket = connectAs("alice"); + FakeSocket newSocket = connectAs("alice"); + disconnect(oldSocket); + server.sendMessage("alice", "news", Map.of()); + assertEquals(List.of(Json.tree(Map.of())), newSocket.messages("news")); + } + + @Test + void pingsEverySocketAndClosesThoseThatDoNotAnswer() { + start(false); + FakeSocket answering = connectAs("alice"); + FakeSocket silent = connectAs("bob"); + loop.advance(RtmtServer.PING_INTERVAL_MS); + assertEquals(1, answering.messages(Channels.PING).size()); + assertEquals(Json.tree(Map.of()), silent.messages(Channels.PING).getFirst()); + + answering.send(Channels.PONG, Map.of()); + loop.advance(RtmtServer.PING_INTERVAL_MS); + assertNull(answering.closeCode); + assertEquals(1001, silent.closeCode); + assertFalse(server.isClientOnline("bob")); + } + + @Test + void acceptsAUuidUserKey() { + start(true); + connect("userKey=" + LEGACY_KEY); + assertTrue(server.isClientOnline(LEGACY_KEY)); + assertEquals(new AuthUser(LEGACY_KEY, null, true), server.getUser(LEGACY_KEY)); + } + + @Test + void aUserKeyCannotClaimAKeycloakUsersId() { + start(true); + FakeSocket socket = connect("userKey=kc:" + LEGACY_KEY); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + } + + @Test + void aLegacySocketCannotSwitchToATokenIdentity() { + start(true); + FakeSocket socket = connect("userKey=" + LEGACY_KEY); + socket.send(Channels.AUTH, Map.of("token", "valid:kc:alice")); + assertEquals(Channels.CLOSE_AUTH_FAILED, socket.closeCode); + assertFalse(server.isClientOnline(LEGACY_KEY)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/support/FakeTokenVerifier.java b/apps/backend/src/test/java/app/playmancala/support/FakeTokenVerifier.java new file mode 100644 index 0000000..797682c --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/support/FakeTokenVerifier.java @@ -0,0 +1,21 @@ +package app.playmancala.support; + +import app.playmancala.auth.InvalidTokenException; +import app.playmancala.auth.TokenVerifier; +import app.playmancala.model.AuthUser; +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.Alternative; +import jakarta.inject.Singleton; + +// Replaces Keycloak in @QuarkusTests: accepts tokens of the form "valid:". +@Alternative +@Priority(1) +@Singleton +public class FakeTokenVerifier implements TokenVerifier { + @Override + public AuthUser verify(String token) throws InvalidTokenException { + if (!token.startsWith("valid:")) throw new InvalidTokenException("bad token"); + String id = token.substring("valid:".length()); + return new AuthUser(id, "name-of-" + id, false); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/support/TestClient.java b/apps/backend/src/test/java/app/playmancala/support/TestClient.java new file mode 100644 index 0000000..89c362a --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/support/TestClient.java @@ -0,0 +1,96 @@ +package app.playmancala.support; + +import static org.awaitility.Awaitility.await; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.Channels; +import app.playmancala.model.Json; + +// A WebSocket client speaking the game protocol; answers pings like the real clients. +public class TestClient implements WebSocket.Listener, AutoCloseable { + public final List messages = new CopyOnWriteArrayList<>(); + public final CompletableFuture closed = new CompletableFuture<>(); + private final StringBuilder partial = new StringBuilder(); + private final WebSocket webSocket; + + public TestClient(URI uri) { + webSocket = HttpClient.newHttpClient().newWebSocketBuilder().buildAsync(uri, this).join(); + } + + // Connects and logs in as the user, with a token the FakeTokenVerifier accepts. + public static TestClient connectAs(URI uri, String userId) { + TestClient client = new TestClient(uri); + client.send(Channels.AUTH, Map.of("token", "valid:" + userId)); + client.waitFor(Channels.ON_AUTH); + return client; + } + + public void send(String channel, Object message) { + webSocket.sendText(Json.write(Map.of("channel", channel, "message", message)), true).join(); + } + + public List messages(String channel) { + return messages.stream().filter(m -> m.get("channel").asText().equals(channel)).map(m -> m.get("message")).toList(); + } + + // The first message on the channel, waiting up to 10 seconds for it. + public JsonNode waitFor(String channel) { + return waitFor(channel, 1).getFirst(); + } + + public List waitFor(String channel, int count) { + await().atMost(Duration.ofSeconds(10)).until(() -> messages(channel).size() >= count); + return messages(channel); + } + + @Override + public void onOpen(WebSocket webSocket) { + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + partial.append(data); + if (last) { + try { + JsonNode message = Json.parse(partial.toString()); + if (message.get("channel").asText().equals(Channels.PING)) { + webSocket.sendText(Json.write(Map.of("channel", Channels.PONG, "message", Map.of())), true); + } + messages.add(message); + } catch (Exception e) { + throw new IllegalStateException(e); + } + partial.setLength(0); + } + webSocket.request(1); + return null; + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + closed.complete(statusCode); + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + closed.completeExceptionally(error); + } + + @Override + public void close() { + if (!webSocket.isOutputClosed()) webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "").join(); + } +} diff --git a/apps/backend/src/test/resources/engine/recorded-games.json b/apps/backend/src/test/resources/engine/recorded-games.json new file mode 100644 index 0000000..cb6c3fc --- /dev/null +++ b/apps/backend/src/test/resources/engine/recorded-games.json @@ -0,0 +1 @@ +{"games":[{"id":"game-0","player1Id":"kc:player1-0","player2Id":"kc:player2-0","turnPlayerId":"kc:player2-0","moves":[{"playerId":"kc:player2-0","pitIndex":3,"board":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":14,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":3,"board":[4,4,4,4,4,4,0,4,4,4,0,6,5,1],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":2,"board":[4,4,1,5,5,5,0,4,4,4,0,6,5,1],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":7,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[4,4,1,5,5,5,0,4,4,4,0,6,5,1],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":0,"board":[4,4,0,5,5,5,0,1,5,5,0,6,5,3],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":1,"board":[4,1,1,6,6,5,0,1,5,5,0,6,5,3],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":2,"board":[4,1,1,6,6,5,0,1,5,1,1,7,6,4],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":2,"board":[4,1,1,6,6,5,0,1,5,0,2,7,6,4],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":1,"board":[4,0,2,6,6,5,0,1,5,0,2,7,6,4],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":0,"board":[4,0,2,6,6,5,0,0,6,0,2,7,6,4],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":3,"board":[4,0,2,1,7,6,1,1,7,0,2,7,6,4],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":1,"board":[5,0,2,1,7,6,1,1,1,1,3,8,7,5],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":4,"board":[5,0,2,1,1,7,6,2,2,2,0,8,7,5],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":4,"board":[6,1,3,2,0,7,6,2,2,2,0,1,8,8],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":3,"board":[6,1,3,1,0,7,9,2,0,2,0,1,8,8],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":5,"board":[7,2,4,2,1,0,9,2,0,2,0,1,1,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[7,2,4,2,1,0,9,2,0,2,0,1,1,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":0,"board":[1,3,5,3,2,1,10,2,0,2,0,1,1,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":2,"board":[1,3,1,4,3,2,11,2,0,2,0,1,1,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":0,"board":[0,4,1,4,3,2,11,2,0,2,0,1,1,17],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":4,"board":[0,4,1,4,3,2,11,2,0,2,0,0,2,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":2,"board":[0,4,0,5,3,2,11,2,0,2,0,0,2,17],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":2,"board":[0,4,0,5,3,2,11,2,0,1,1,0,2,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":1,"board":[0,1,1,6,4,2,11,2,0,1,1,0,2,17],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":13,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[0,1,1,6,4,2,11,2,0,1,1,0,2,17],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":2,"board":[0,1,1,6,4,2,11,2,0,0,2,0,2,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":5,"board":[0,1,1,6,4,1,12,2,0,0,2,0,2,17],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":4,"board":[0,1,1,6,1,2,13,3,0,0,2,0,2,17],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":5,"board":[0,1,1,6,1,2,13,3,0,0,2,0,1,18],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":0,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,1,6,1,2,13,3,0,0,2,0,1,18],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":5,"board":[0,1,1,6,1,2,13,3,0,0,2,0,0,19],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":3,"board":[0,0,1,6,1,2,13,3,0,0,1,0,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":5,"board":[0,0,1,6,1,1,14,3,0,0,1,0,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":2,"board":[0,0,0,7,1,1,14,3,0,0,1,0,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":3,"board":[0,0,0,7,1,1,14,3,0,0,0,1,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":5,"board":[0,0,0,7,1,0,15,3,0,0,0,1,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":3,"board":[0,0,0,1,2,1,16,4,1,1,0,1,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":1,"board":[0,0,0,1,2,1,16,4,0,2,0,1,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":4,"board":[0,0,0,1,1,2,16,4,0,2,0,1,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":2,"board":[0,0,0,1,1,2,16,4,0,1,1,1,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":5,"board":[0,0,0,1,1,1,17,4,0,1,1,1,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":1,"error":"StoneNotFound => index : 1 stoneCount = 0","board":[0,0,0,1,1,1,17,4,0,1,1,1,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":3,"board":[0,0,0,0,2,1,17,4,0,1,1,1,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":1,"error":"StoneNotFound => index : 8 stoneCount = 0","board":[0,0,0,0,2,1,17,4,0,1,1,1,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":3,"board":[0,0,0,0,2,1,17,4,0,1,0,2,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":4,"board":[0,0,0,0,1,2,17,4,0,1,0,2,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":0,"board":[0,0,0,0,1,2,17,1,1,2,1,2,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":4,"board":[0,0,0,0,0,3,17,1,1,2,1,2,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":3,"board":[0,0,0,0,0,3,17,1,1,2,0,3,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":5,"board":[0,0,0,0,0,1,20,0,1,2,0,3,0,21],"turnPlayerId":"kc:player2-0","state":"playing"},{"playerId":"kc:player2-0","pitIndex":1,"board":[0,0,0,0,0,1,20,0,0,3,0,3,0,21],"turnPlayerId":"kc:player1-0","state":"playing"},{"playerId":"kc:player1-0","pitIndex":5,"board":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"turnPlayerId":"kc:player1-0","state":"ended"},{"playerId":"kc:player2-0","pitIndex":14,"board":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"turnPlayerId":"kc:player1-0","state":"ended"}],"wonPlayerId":"kc:player1-0","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11]}}],"playerId":"kc:player1-0","moveIndex":5},"id":"game-0","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":27},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":21}]},"player1Id":"kc:player1-0","player2Id":"kc:player2-0","turnPlayerId":"kc:player1-0","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-0","moveIndex":10},{"boardSnapshot":[4,4,4,4,4,4,0,4,4,4,0,6,5,1],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":10},{"boardSnapshot":[4,4,1,5,5,5,0,4,4,4,0,6,5,1],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":2},{"boardSnapshot":[4,4,0,5,5,5,0,1,5,5,0,6,5,3],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-0","moveIndex":7},{"boardSnapshot":[4,1,1,6,6,5,0,1,5,5,0,6,5,3],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":1},{"boardSnapshot":[4,1,1,6,6,5,0,1,5,1,1,7,6,4],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-0","moveIndex":9},{"boardSnapshot":[4,1,1,6,6,5,0,1,5,0,2,7,6,4],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":9},{"boardSnapshot":[4,0,2,6,6,5,0,1,5,0,2,7,6,4],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":1},{"boardSnapshot":[4,0,2,6,6,5,0,0,6,0,2,7,6,4],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":7},{"boardSnapshot":[4,0,2,1,7,6,1,1,7,0,2,7,6,4],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":3},{"boardSnapshot":[5,0,2,1,7,6,1,1,1,1,3,8,7,5],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":8},{"boardSnapshot":[5,0,2,1,1,7,6,2,2,2,0,8,7,5],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":10,"bankIndex":6}}],"playerId":"kc:player1-0","moveIndex":4},{"boardSnapshot":[6,1,3,2,0,7,6,2,2,2,0,1,8,8],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":4,"bankIndex":13}}],"playerId":"kc:player2-0","moveIndex":11},{"boardSnapshot":[6,1,3,1,0,7,9,2,0,2,0,1,8,8],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":8}}],"playerId":"kc:player1-0","moveIndex":3},{"boardSnapshot":[7,2,4,2,1,0,9,2,0,2,0,1,1,17],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":5,"bankIndex":13}}],"playerId":"kc:player2-0","moveIndex":12},{"boardSnapshot":[1,3,5,3,2,1,10,2,0,2,0,1,1,17],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-0","moveIndex":0},{"boardSnapshot":[1,3,1,4,3,2,11,2,0,2,0,1,1,17],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-0","moveIndex":2},{"boardSnapshot":[0,4,1,4,3,2,11,2,0,2,0,1,1,17],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":0},{"boardSnapshot":[0,4,1,4,3,2,11,2,0,2,0,0,2,17],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":11},{"boardSnapshot":[0,4,0,5,3,2,11,2,0,2,0,0,2,17],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":2},{"boardSnapshot":[0,4,0,5,3,2,11,2,0,1,1,0,2,17],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":9},{"boardSnapshot":[0,1,1,6,4,2,11,2,0,1,1,0,2,17],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":1},{"boardSnapshot":[0,1,1,6,4,2,11,2,0,0,2,0,2,17],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":9},{"boardSnapshot":[0,1,1,6,4,1,12,2,0,0,2,0,2,17],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-0","moveIndex":5},{"boardSnapshot":[0,1,1,6,1,2,13,3,0,0,2,0,2,17],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":4},{"boardSnapshot":[0,1,1,6,1,2,13,3,0,0,2,0,1,18],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-0","moveIndex":12},{"boardSnapshot":[0,1,1,6,1,2,13,3,0,0,2,0,0,19],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-0","moveIndex":12},{"boardSnapshot":[0,0,1,6,1,2,13,3,0,0,1,0,0,21],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-0","moveIndex":10},{"boardSnapshot":[0,0,1,6,1,1,14,3,0,0,1,0,0,21],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-0","moveIndex":5},{"boardSnapshot":[0,0,0,7,1,1,14,3,0,0,1,0,0,21],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":2},{"boardSnapshot":[0,0,0,7,1,1,14,3,0,0,0,1,0,21],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":10},{"boardSnapshot":[0,0,0,7,1,0,15,3,0,0,0,1,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-0","moveIndex":5},{"boardSnapshot":[0,0,0,1,2,1,16,4,1,1,0,1,0,21],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":3},{"boardSnapshot":[0,0,0,1,2,1,16,4,0,2,0,1,0,21],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":8},{"boardSnapshot":[0,0,0,1,1,2,16,4,0,2,0,1,0,21],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":4},{"boardSnapshot":[0,0,0,1,1,2,16,4,0,1,1,1,0,21],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":9},{"boardSnapshot":[0,0,0,1,1,1,17,4,0,1,1,1,0,21],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-0","moveIndex":5},{"boardSnapshot":[0,0,0,0,2,1,17,4,0,1,1,1,0,21],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":3},{"boardSnapshot":[0,0,0,0,2,1,17,4,0,1,0,2,0,21],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":10},{"boardSnapshot":[0,0,0,0,1,2,17,4,0,1,0,2,0,21],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":4},{"boardSnapshot":[0,0,0,0,1,2,17,1,1,2,1,2,0,21],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":7},{"boardSnapshot":[0,0,0,0,0,3,17,1,1,2,1,2,0,21],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-0","moveIndex":4},{"boardSnapshot":[0,0,0,0,0,3,17,1,1,2,0,3,0,21],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":10},{"boardSnapshot":[0,0,0,0,0,1,20,0,1,2,0,3,0,21],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-0","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,1,20,0,0,3,0,3,0,21],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-0","moveIndex":8},{"boardSnapshot":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11]}}],"playerId":"kc:player1-0","moveIndex":5}]}},{"id":"game-1","player1Id":"kc:player1-1","player2Id":"kc:player2-1","turnPlayerId":"kc:player2-1","moves":[{"playerId":"kc:player2-1","pitIndex":1,"board":[4,4,4,4,4,4,0,4,1,5,5,5,4,0],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[4,4,4,4,4,1,3,5,0,5,5,5,4,0],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":3,"board":[5,4,4,4,4,1,3,5,0,5,1,6,5,1],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[5,4,4,4,1,2,10,0,0,5,1,6,5,1],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[6,5,5,4,1,2,10,0,0,5,1,6,1,2],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":2,"board":[6,5,1,5,2,3,11,0,0,5,1,6,1,2],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":3,"board":[6,5,1,1,3,4,12,1,0,5,1,6,1,2],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":4,"board":[7,6,0,1,3,4,12,1,0,5,1,1,2,5],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[7,6,0,1,3,1,13,2,1,5,1,1,2,5],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[7,6,0,1,3,1,13,2,1,5,1,1,1,6],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":0,"board":[7,6,0,1,3,1,13,1,2,5,1,1,1,6],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":0,"board":[1,7,1,2,4,2,14,1,2,5,1,1,1,6],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":2,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,7,1,2,4,2,14,1,2,5,1,1,1,6],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[1,7,1,2,4,1,15,1,2,5,1,1,1,6],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":2,"board":[1,7,0,3,4,1,15,1,2,5,1,1,1,6],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":0,"board":[1,7,0,3,4,1,15,0,3,5,1,1,1,6],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":0,"board":[0,8,0,3,4,1,15,0,3,5,1,1,1,6],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[0,8,0,3,4,1,15,0,3,5,1,1,0,7],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":1,"board":[0,8,0,3,4,1,15,0,1,6,2,1,0,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":-1,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[0,8,0,3,4,1,15,0,1,6,2,1,0,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":7,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[0,8,0,3,4,1,15,0,1,6,2,1,0,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[0,8,0,3,1,2,16,1,1,6,2,1,0,7],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":3,"board":[0,8,0,3,1,2,16,1,1,6,1,2,0,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[0,8,0,3,0,3,16,1,1,6,1,2,0,7],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":4,"board":[0,8,0,3,0,3,16,1,1,6,1,1,1,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[0,8,0,3,0,1,19,0,1,6,1,1,1,7],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":14,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[0,8,0,3,0,1,19,0,1,6,1,1,1,7],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":3,"board":[0,8,0,3,0,1,19,0,1,6,0,2,1,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[0,8,0,3,0,0,20,0,1,6,0,2,1,7],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":3,"board":[0,8,0,1,1,1,20,0,1,6,0,2,1,7],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[0,8,0,1,1,1,20,0,1,6,0,2,0,8],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":2,"board":[1,8,0,1,1,1,20,0,1,1,1,3,1,9],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":0,"board":[0,9,0,1,1,1,20,0,1,1,1,3,1,9],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":1,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,9,0,1,1,1,20,0,1,1,1,3,1,9],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":4,"board":[0,9,0,1,1,1,20,0,1,1,1,1,2,10],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[0,9,0,1,1,1,20,0,1,1,1,1,1,11],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":2,"board":[0,9,0,1,1,1,20,0,1,0,2,1,1,11],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[0,9,0,1,0,2,20,0,1,0,2,1,1,11],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[0,9,0,1,0,2,20,0,1,0,2,1,0,12],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":1,"board":[0,9,0,0,0,2,20,0,0,0,2,1,0,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[0,9,0,0,0,1,21,0,0,0,2,1,0,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":1,"board":[0,1,1,1,1,2,22,1,1,1,2,1,0,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":0,"board":[0,1,1,1,1,2,22,0,2,1,2,1,0,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":2,"board":[0,1,0,2,1,2,22,0,2,1,2,1,0,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":2,"board":[0,1,0,2,1,2,22,0,2,0,3,1,0,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":3,"board":[0,1,0,1,2,2,22,0,2,0,3,1,0,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,0,1,2,2,22,0,2,0,3,1,0,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":4,"board":[0,1,0,1,2,2,22,0,2,0,3,0,1,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[0,1,0,1,1,3,22,0,2,0,3,0,1,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":3,"board":[0,1,0,1,1,3,22,0,2,0,1,1,2,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":3,"board":[0,1,0,0,2,3,22,0,2,0,1,1,2,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":1,"board":[0,1,0,0,2,3,22,0,1,1,1,1,2,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[0,1,0,0,2,1,23,1,1,1,1,1,2,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":13,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[0,1,0,0,2,1,23,1,1,1,1,1,2,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":3,"board":[0,1,0,0,2,1,23,1,1,1,0,2,2,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":0,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,0,0,2,1,23,1,1,1,0,2,2,14],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[0,1,0,0,1,2,23,1,1,1,0,2,2,14],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":5,"board":[0,1,0,0,1,2,23,1,1,1,0,2,1,15],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":2,"board":[0,1,0,0,1,2,23,1,1,0,1,2,1,15],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[0,1,0,0,1,1,24,1,1,0,1,2,1,15],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":1,"board":[0,0,0,0,1,1,26,1,1,0,0,2,1,15],"turnPlayerId":"kc:player2-1","state":"playing"},{"playerId":"kc:player2-1","pitIndex":1,"board":[0,0,0,0,1,1,26,1,0,1,0,2,1,15],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":5,"board":[0,0,0,0,1,0,27,1,0,1,0,2,1,15],"turnPlayerId":"kc:player1-1","state":"playing"},{"playerId":"kc:player1-1","pitIndex":4,"board":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"turnPlayerId":"kc:player2-1","state":"ended"},{"playerId":"kc:player2-1","pitIndex":3,"board":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"turnPlayerId":"kc:player2-1","state":"ended"}],"wonPlayerId":"kc:player1-1","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":7}},{"index":5,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11,12]}}],"playerId":"kc:player1-1","moveIndex":4},"id":"game-1","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":33},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":15}]},"player1Id":"kc:player1-1","player2Id":"kc:player2-1","turnPlayerId":"kc:player2-1","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,1,5,5,5,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":8},{"boardSnapshot":[4,4,4,4,4,1,3,5,0,5,5,5,4,0],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":8,"bankIndex":6}}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[5,4,4,4,4,1,3,5,0,5,1,6,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":10},{"boardSnapshot":[5,4,4,4,1,2,10,0,0,5,1,6,5,1],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-1","moveIndex":4},{"boardSnapshot":[6,5,5,4,1,2,10,0,0,5,1,6,1,2],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[6,5,1,5,2,3,11,0,0,5,1,6,1,2],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":2},{"boardSnapshot":[6,5,1,1,3,4,12,1,0,5,1,6,1,2],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":3},{"boardSnapshot":[7,6,0,1,3,4,12,1,0,5,1,1,2,5],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":2,"bankIndex":13}}],"playerId":"kc:player2-1","moveIndex":11},{"boardSnapshot":[7,6,0,1,3,1,13,2,1,5,1,1,2,5],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[7,6,0,1,3,1,13,2,1,5,1,1,1,6],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[7,6,0,1,3,1,13,1,2,5,1,1,1,6],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":7},{"boardSnapshot":[1,7,1,2,4,2,14,1,2,5,1,1,1,6],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":0},{"boardSnapshot":[1,7,1,2,4,1,15,1,2,5,1,1,1,6],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[1,7,0,3,4,1,15,1,2,5,1,1,1,6],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":2},{"boardSnapshot":[1,7,0,3,4,1,15,0,3,5,1,1,1,6],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":7},{"boardSnapshot":[0,8,0,3,4,1,15,0,3,5,1,1,1,6],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":0},{"boardSnapshot":[0,8,0,3,4,1,15,0,3,5,1,1,0,7],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[0,8,0,3,4,1,15,0,1,6,2,1,0,7],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":8},{"boardSnapshot":[0,8,0,3,1,2,16,1,1,6,2,1,0,7],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":4},{"boardSnapshot":[0,8,0,3,1,2,16,1,1,6,1,2,0,7],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":10},{"boardSnapshot":[0,8,0,3,0,3,16,1,1,6,1,2,0,7],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":4},{"boardSnapshot":[0,8,0,3,0,3,16,1,1,6,1,1,1,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":11},{"boardSnapshot":[0,8,0,3,0,1,19,0,1,6,1,1,1,7],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[0,8,0,3,0,1,19,0,1,6,0,2,1,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":10},{"boardSnapshot":[0,8,0,3,0,0,20,0,1,6,0,2,1,7],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[0,8,0,1,1,1,20,0,1,6,0,2,1,7],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":3},{"boardSnapshot":[0,8,0,1,1,1,20,0,1,6,0,2,0,8],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[1,8,0,1,1,1,20,0,1,1,1,3,1,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":9},{"boardSnapshot":[0,9,0,1,1,1,20,0,1,1,1,3,1,9],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":0},{"boardSnapshot":[0,9,0,1,1,1,20,0,1,1,1,1,2,10],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":11},{"boardSnapshot":[0,9,0,1,1,1,20,0,1,1,1,1,1,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[0,9,0,1,1,1,20,0,1,0,2,1,1,11],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":9},{"boardSnapshot":[0,9,0,1,0,2,20,0,1,0,2,1,1,11],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":4},{"boardSnapshot":[0,9,0,1,0,2,20,0,1,0,2,1,0,12],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[0,9,0,0,0,2,20,0,0,0,2,1,0,14],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":3}}],"playerId":"kc:player2-1","moveIndex":8},{"boardSnapshot":[0,9,0,0,0,1,21,0,0,0,2,1,0,14],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[0,1,1,1,1,2,22,1,1,1,2,1,0,14],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":1},{"boardSnapshot":[0,1,1,1,1,2,22,0,2,1,2,1,0,14],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":7},{"boardSnapshot":[0,1,0,2,1,2,22,0,2,1,2,1,0,14],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":2},{"boardSnapshot":[0,1,0,2,1,2,22,0,2,0,3,1,0,14],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":9},{"boardSnapshot":[0,1,0,1,2,2,22,0,2,0,3,1,0,14],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":3},{"boardSnapshot":[0,1,0,1,2,2,22,0,2,0,3,0,1,14],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":11},{"boardSnapshot":[0,1,0,1,1,3,22,0,2,0,3,0,1,14],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":4},{"boardSnapshot":[0,1,0,1,1,3,22,0,2,0,1,1,2,14],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":10},{"boardSnapshot":[0,1,0,0,2,3,22,0,2,0,1,1,2,14],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":3},{"boardSnapshot":[0,1,0,0,2,3,22,0,1,1,1,1,2,14],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":8},{"boardSnapshot":[0,1,0,0,2,1,23,1,1,1,1,1,2,14],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[0,1,0,0,2,1,23,1,1,1,0,2,2,14],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":10},{"boardSnapshot":[0,1,0,0,1,2,23,1,1,1,0,2,2,14],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-1","moveIndex":4},{"boardSnapshot":[0,1,0,0,1,2,23,1,1,1,0,2,1,15],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-1","moveIndex":12},{"boardSnapshot":[0,1,0,0,1,2,23,1,1,0,1,2,1,15],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":9},{"boardSnapshot":[0,1,0,0,1,1,24,1,1,0,1,2,1,15],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[0,0,0,0,1,1,26,1,1,0,0,2,1,15],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-1","moveIndex":1},{"boardSnapshot":[0,0,0,0,1,1,26,1,0,1,0,2,1,15],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-1","moveIndex":8},{"boardSnapshot":[0,0,0,0,1,0,27,1,0,1,0,2,1,15],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-1","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":7}},{"index":5,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11,12]}}],"playerId":"kc:player1-1","moveIndex":4}]}},{"id":"game-2","player1Id":"kc:player1-2","player2Id":"kc:player2-2","turnPlayerId":"kc:player2-2","moves":[{"playerId":"kc:player2-2","pitIndex":2,"board":[4,4,4,4,4,4,0,4,4,1,5,5,5,0],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":4,"board":[4,4,4,4,1,5,1,5,4,1,5,5,5,0],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":2,"board":[4,4,4,4,1,5,1,5,4,0,6,5,5,0],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":1,"board":[4,1,5,5,2,5,1,5,4,0,6,5,5,0],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":0,"board":[4,1,5,5,2,5,1,1,5,1,7,6,5,0],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":4,"board":[4,1,5,5,1,6,1,1,5,1,7,6,5,0],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":4,"board":[5,2,0,5,1,6,1,1,5,1,7,1,6,7],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":1,"board":[5,1,0,5,1,6,9,1,5,1,0,1,6,7],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":1,"board":[5,1,0,5,1,6,9,1,1,2,1,2,7,7],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":3,"board":[5,1,0,1,2,7,12,0,1,2,1,2,7,7],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":3,"board":[5,1,0,1,2,7,12,0,1,2,0,3,7,7],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":5,"board":[5,1,0,1,2,1,17,1,2,3,1,0,7,7],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":5,"board":[6,2,1,2,3,1,17,1,2,3,1,0,1,8],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":2,"board":[6,2,0,3,3,1,17,1,2,3,1,0,1,8],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":0,"board":[6,2,0,3,3,1,17,0,3,3,1,0,1,8],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":4,"board":[6,2,0,3,1,2,18,0,3,3,1,0,1,8],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":0,"board":[1,3,1,4,2,3,18,0,3,3,1,0,1,8],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":5,"board":[1,3,1,4,2,3,18,0,3,3,1,0,0,9],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":3,"board":[1,0,1,4,2,3,18,0,3,3,0,0,0,13],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":3,"board":[1,0,1,1,3,4,19,0,3,3,0,0,0,13],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":0,"board":[0,1,1,1,3,4,19,0,3,3,0,0,0,13],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":2,"board":[0,0,1,1,3,4,19,0,3,1,1,0,0,15],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":3,"board":[0,0,1,0,4,4,19,0,3,1,1,0,0,15],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":2,"board":[0,0,1,0,4,4,19,0,3,0,2,0,0,15],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":5,"board":[0,0,1,0,4,1,24,1,0,0,2,0,0,15],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":3,"board":[0,0,1,0,4,1,24,1,0,0,1,1,0,15],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":5,"board":[0,0,1,0,4,0,25,1,0,0,1,1,0,15],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":2,"board":[0,0,0,1,4,0,25,1,0,0,1,1,0,15],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":14,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[0,0,0,1,4,0,25,1,0,0,1,1,0,15],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":4,"board":[0,0,0,1,4,0,25,1,0,0,1,0,1,15],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":4,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,0,1,4,0,25,1,0,0,1,0,1,15],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":3,"board":[0,0,0,0,5,0,25,1,0,0,1,0,1,15],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":5,"board":[0,0,0,0,5,0,25,1,0,0,1,0,0,16],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":3,"board":[0,0,0,0,5,0,25,1,0,0,0,1,0,16],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":4,"board":[0,0,0,0,1,1,26,2,1,0,0,1,0,16],"turnPlayerId":"kc:player2-2","state":"playing"},{"playerId":"kc:player2-2","pitIndex":1,"board":[0,0,0,0,1,1,26,2,0,1,0,1,0,16],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":5,"board":[0,0,0,0,1,0,27,2,0,1,0,1,0,16],"turnPlayerId":"kc:player1-2","state":"playing"},{"playerId":"kc:player1-2","pitIndex":4,"board":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"turnPlayerId":"kc:player2-2","state":"ended"},{"playerId":"kc:player2-2","pitIndex":4,"board":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"turnPlayerId":"kc:player2-2","state":"ended"}],"wonPlayerId":"kc:player1-2","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":7}},{"index":5,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11]}}],"playerId":"kc:player1-2","moveIndex":4},"id":"game-2","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":32},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":16}]},"player1Id":"kc:player1-2","player2Id":"kc:player2-2","turnPlayerId":"kc:player2-2","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,4,1,5,5,5,0],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":9},{"boardSnapshot":[4,4,4,4,1,5,1,5,4,1,5,5,5,0],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":4},{"boardSnapshot":[4,4,4,4,1,5,1,5,4,0,6,5,5,0],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":9},{"boardSnapshot":[4,1,5,5,2,5,1,5,4,0,6,5,5,0],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":1},{"boardSnapshot":[4,1,5,5,2,5,1,1,5,1,7,6,5,0],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":7},{"boardSnapshot":[4,1,5,5,1,6,1,1,5,1,7,6,5,0],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":4},{"boardSnapshot":[5,2,0,5,1,6,1,1,5,1,7,1,6,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":2,"bankIndex":13}}],"playerId":"kc:player2-2","moveIndex":11},{"boardSnapshot":[5,1,0,5,1,6,9,1,5,1,0,1,6,7],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-2","moveIndex":1},{"boardSnapshot":[5,1,0,5,1,6,9,1,1,2,1,2,7,7],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":8},{"boardSnapshot":[5,1,0,1,2,7,12,0,1,2,1,2,7,7],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-2","moveIndex":3},{"boardSnapshot":[5,1,0,1,2,7,12,0,1,2,0,3,7,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":10},{"boardSnapshot":[5,1,0,1,2,1,17,1,2,3,1,0,7,7],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":11,"bankIndex":6}}],"playerId":"kc:player1-2","moveIndex":5},{"boardSnapshot":[6,2,1,2,3,1,17,1,2,3,1,0,1,8],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":12},{"boardSnapshot":[6,2,0,3,3,1,17,1,2,3,1,0,1,8],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":2},{"boardSnapshot":[6,2,0,3,3,1,17,0,3,3,1,0,1,8],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":7},{"boardSnapshot":[6,2,0,3,1,2,18,0,3,3,1,0,1,8],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-2","moveIndex":4},{"boardSnapshot":[1,3,1,4,2,3,18,0,3,3,1,0,1,8],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":0},{"boardSnapshot":[1,3,1,4,2,3,18,0,3,3,1,0,0,9],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-2","moveIndex":12},{"boardSnapshot":[1,0,1,4,2,3,18,0,3,3,0,0,0,13],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-2","moveIndex":10},{"boardSnapshot":[1,0,1,1,3,4,19,0,3,3,0,0,0,13],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-2","moveIndex":3},{"boardSnapshot":[0,1,1,1,3,4,19,0,3,3,0,0,0,13],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":0},{"boardSnapshot":[0,0,1,1,3,4,19,0,3,1,1,0,0,15],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-2","moveIndex":9},{"boardSnapshot":[0,0,1,0,4,4,19,0,3,1,1,0,0,15],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":3},{"boardSnapshot":[0,0,1,0,4,4,19,0,3,0,2,0,0,15],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":9},{"boardSnapshot":[0,0,1,0,4,1,24,1,0,0,2,0,0,15],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":8,"bankIndex":6}}],"playerId":"kc:player1-2","moveIndex":5},{"boardSnapshot":[0,0,1,0,4,1,24,1,0,0,1,1,0,15],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":10},{"boardSnapshot":[0,0,1,0,4,0,25,1,0,0,1,1,0,15],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-2","moveIndex":5},{"boardSnapshot":[0,0,0,1,4,0,25,1,0,0,1,1,0,15],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":2},{"boardSnapshot":[0,0,0,1,4,0,25,1,0,0,1,0,1,15],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":11},{"boardSnapshot":[0,0,0,0,5,0,25,1,0,0,1,0,1,15],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":3},{"boardSnapshot":[0,0,0,0,5,0,25,1,0,0,1,0,0,16],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-2","moveIndex":12},{"boardSnapshot":[0,0,0,0,5,0,25,1,0,0,0,1,0,16],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":10},{"boardSnapshot":[0,0,0,0,1,1,26,2,1,0,0,1,0,16],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-2","moveIndex":4},{"boardSnapshot":[0,0,0,0,1,1,26,2,0,1,0,1,0,16],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-2","moveIndex":8},{"boardSnapshot":[0,0,0,0,1,0,27,2,0,1,0,1,0,16],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-2","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":7}},{"index":5,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11]}}],"playerId":"kc:player1-2","moveIndex":4}]}},{"id":"game-3","player1Id":"kc:player1-3","player2Id":"kc:player2-3","turnPlayerId":"kc:player2-3","moves":[{"playerId":"kc:player2-3","pitIndex":1,"board":[4,4,4,4,4,4,0,4,1,5,5,5,4,0],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":1,"board":[4,1,5,5,5,4,0,4,1,5,5,5,4,0],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":0,"board":[4,1,5,5,5,4,0,1,2,6,6,5,4,0],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":13,"error":"IndexOutOfRange => index : 20 [start, stop] = [0, 13]","board":[4,1,5,5,5,4,0,1,2,6,6,5,4,0],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":4,"board":[4,1,5,5,1,5,1,2,3,6,6,5,4,0],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":4,"board":[5,0,5,5,1,5,1,2,3,6,6,1,5,3],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":3,"board":[5,0,5,1,2,6,2,3,3,6,6,1,5,3],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":2,"board":[0,0,5,1,2,6,2,3,3,1,7,2,6,10],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":4,"board":[0,0,5,1,1,7,2,3,3,1,7,2,6,10],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,5,1,1,7,2,3,3,1,7,2,6,10],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":2,"board":[0,0,5,1,1,7,2,3,3,0,8,2,6,10],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":3,"board":[0,0,5,0,2,7,2,3,3,0,8,2,6,10],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":3,"board":[1,1,6,1,2,7,2,3,3,0,1,3,7,11],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":2,"board":[1,1,1,2,3,8,7,0,3,0,1,3,7,11],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":3,"board":[1,1,1,2,3,8,7,0,3,0,0,4,7,11],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":4,"board":[1,1,1,2,1,9,8,0,3,0,0,4,7,11],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":4,"board":[1,1,1,2,0,10,8,0,3,0,0,4,7,11],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":1,"board":[1,1,0,2,0,10,8,0,1,1,0,4,7,13],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":0,"board":[0,2,0,2,0,10,8,0,1,1,0,4,7,13],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":2,"board":[0,2,0,2,0,10,8,0,1,0,1,4,7,13],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":1,"board":[0,1,0,2,0,10,10,0,1,0,0,4,7,13],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,0,2,0,10,10,0,1,0,0,4,7,13],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":2,"error":"StoneNotFound => index : 9 stoneCount = 0","board":[0,1,0,2,0,10,10,0,1,0,0,4,7,13],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":4,"board":[1,1,0,2,0,10,10,0,1,0,0,1,8,14],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":0,"board":[0,2,0,2,0,10,10,0,1,0,0,1,8,14],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":5,"board":[1,3,1,3,1,11,10,0,1,0,0,1,1,15],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":2,"board":[1,3,0,4,1,11,10,0,1,0,0,1,1,15],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":4,"board":[1,3,0,4,1,11,10,0,1,0,0,0,2,15],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"board":[2,4,0,4,1,1,13,1,2,1,0,1,3,15],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":4,"board":[2,4,0,4,1,1,13,1,2,1,0,0,4,15],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":3,"board":[2,4,0,1,2,2,14,1,2,1,0,0,4,15],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":0,"board":[1,5,0,1,2,2,14,1,2,1,0,0,4,15],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":2,"board":[1,5,0,1,2,2,14,1,2,0,1,0,4,15],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"board":[1,5,0,1,2,1,15,1,2,0,1,0,4,15],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":3,"board":[1,5,0,0,3,1,15,1,2,0,1,0,4,15],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":5,"board":[2,0,0,0,3,1,15,1,2,0,1,0,1,22],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":0,"board":[1,1,0,0,3,1,15,1,2,0,1,0,1,22],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":5,"board":[1,1,0,0,3,1,15,1,2,0,1,0,0,23],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":3,"board":[1,0,0,0,3,1,15,1,2,0,0,0,0,25],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":0,"board":[0,1,0,0,3,1,15,1,2,0,0,0,0,25],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,0,0,3,1,15,1,2,0,0,0,0,25],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":14,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[0,1,0,0,3,1,15,1,2,0,0,0,0,25],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":0,"board":[0,1,0,0,3,1,15,0,3,0,0,0,0,25],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":1,"board":[0,0,1,0,3,1,15,0,3,0,0,0,0,25],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":1,"board":[0,0,0,0,3,1,15,0,1,1,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"board":[0,0,0,0,3,0,16,0,1,1,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":4,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,0,0,3,0,16,0,1,1,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":4,"board":[0,0,0,0,1,1,17,0,1,1,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"board":[0,0,0,0,1,0,18,0,1,1,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":4,"board":[0,0,0,0,0,1,18,0,1,1,0,0,0,27],"turnPlayerId":"kc:player2-3","state":"playing"},{"playerId":"kc:player2-3","pitIndex":2,"board":[0,0,0,0,0,1,18,0,1,0,1,0,0,27],"turnPlayerId":"kc:player1-3","state":"playing"},{"playerId":"kc:player1-3","pitIndex":5,"board":[0,0,0,0,0,0,21,0,0,0,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"ended"},{"playerId":"kc:player1-3","pitIndex":0,"board":[0,0,0,0,0,0,21,0,0,0,0,0,0,27],"turnPlayerId":"kc:player1-3","state":"ended"}],"wonPlayerId":"kc:player2-3","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,21,0,0,0,0,0,0,27],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[8,10]}}],"playerId":"kc:player1-3","moveIndex":5},"id":"game-3","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":21},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":27}]},"player1Id":"kc:player1-3","player2Id":"kc:player2-3","turnPlayerId":"kc:player1-3","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,1,5,5,5,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":8},{"boardSnapshot":[4,1,5,5,5,4,0,4,1,5,5,5,4,0],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":1},{"boardSnapshot":[4,1,5,5,5,4,0,1,2,6,6,5,4,0],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":7},{"boardSnapshot":[4,1,5,5,1,5,1,2,3,6,6,5,4,0],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":4},{"boardSnapshot":[5,0,5,5,1,5,1,2,3,6,6,1,5,3],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-3","moveIndex":11},{"boardSnapshot":[5,0,5,1,2,6,2,3,3,6,6,1,5,3],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":3},{"boardSnapshot":[0,0,5,1,2,6,2,3,3,1,7,2,6,10],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-3","moveIndex":9},{"boardSnapshot":[0,0,5,1,1,7,2,3,3,1,7,2,6,10],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":4},{"boardSnapshot":[0,0,5,1,1,7,2,3,3,0,8,2,6,10],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":9},{"boardSnapshot":[0,0,5,0,2,7,2,3,3,0,8,2,6,10],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":3},{"boardSnapshot":[1,1,6,1,2,7,2,3,3,0,1,3,7,11],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":10},{"boardSnapshot":[1,1,1,2,3,8,7,0,3,0,1,3,7,11],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-3","moveIndex":2},{"boardSnapshot":[1,1,1,2,3,8,7,0,3,0,0,4,7,11],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":10},{"boardSnapshot":[1,1,1,2,1,9,8,0,3,0,0,4,7,11],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-3","moveIndex":4},{"boardSnapshot":[1,1,1,2,0,10,8,0,3,0,0,4,7,11],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":4},{"boardSnapshot":[1,1,0,2,0,10,8,0,1,1,0,4,7,13],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-3","moveIndex":8},{"boardSnapshot":[0,2,0,2,0,10,8,0,1,1,0,4,7,13],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":0},{"boardSnapshot":[0,2,0,2,0,10,8,0,1,0,1,4,7,13],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":9},{"boardSnapshot":[0,1,0,2,0,10,10,0,1,0,0,4,7,13],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-3","moveIndex":1},{"boardSnapshot":[1,1,0,2,0,10,10,0,1,0,0,1,8,14],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":11},{"boardSnapshot":[0,2,0,2,0,10,10,0,1,0,0,1,8,14],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":0},{"boardSnapshot":[1,3,1,3,1,11,10,0,1,0,0,1,1,15],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":12},{"boardSnapshot":[1,3,0,4,1,11,10,0,1,0,0,1,1,15],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":2},{"boardSnapshot":[1,3,0,4,1,11,10,0,1,0,0,0,2,15],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":11},{"boardSnapshot":[2,4,0,4,1,1,13,1,2,1,0,1,3,15],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-3","moveIndex":5},{"boardSnapshot":[2,4,0,4,1,1,13,1,2,1,0,0,4,15],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":11},{"boardSnapshot":[2,4,0,1,2,2,14,1,2,1,0,0,4,15],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-3","moveIndex":3},{"boardSnapshot":[1,5,0,1,2,2,14,1,2,1,0,0,4,15],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":0},{"boardSnapshot":[1,5,0,1,2,2,14,1,2,0,1,0,4,15],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":9},{"boardSnapshot":[1,5,0,1,2,1,15,1,2,0,1,0,4,15],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-3","moveIndex":5},{"boardSnapshot":[1,5,0,0,3,1,15,1,2,0,1,0,4,15],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":3},{"boardSnapshot":[2,0,0,0,3,1,15,1,2,0,1,0,1,22],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-3","moveIndex":12},{"boardSnapshot":[1,1,0,0,3,1,15,1,2,0,1,0,1,22],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":0},{"boardSnapshot":[1,1,0,0,3,1,15,1,2,0,1,0,0,23],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-3","moveIndex":12},{"boardSnapshot":[1,0,0,0,3,1,15,1,2,0,0,0,0,25],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-3","moveIndex":10},{"boardSnapshot":[0,1,0,0,3,1,15,1,2,0,0,0,0,25],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":0},{"boardSnapshot":[0,1,0,0,3,1,15,0,3,0,0,0,0,25],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":7},{"boardSnapshot":[0,0,1,0,3,1,15,0,3,0,0,0,0,25],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":1},{"boardSnapshot":[0,0,0,0,3,1,15,0,1,1,0,0,0,27],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-3","moveIndex":8},{"boardSnapshot":[0,0,0,0,3,0,16,0,1,1,0,0,0,27],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-3","moveIndex":5},{"boardSnapshot":[0,0,0,0,1,1,17,0,1,1,0,0,0,27],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-3","moveIndex":4},{"boardSnapshot":[0,0,0,0,1,0,18,0,1,1,0,0,0,27],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-3","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,1,18,0,1,1,0,0,0,27],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-3","moveIndex":4},{"boardSnapshot":[0,0,0,0,0,1,18,0,1,0,1,0,0,27],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-3","moveIndex":9},{"boardSnapshot":[0,0,0,0,0,0,21,0,0,0,0,0,0,27],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[8,10]}}],"playerId":"kc:player1-3","moveIndex":5}]}},{"id":"game-4","player1Id":"kc:player1-4","player2Id":"kc:player2-4","turnPlayerId":"kc:player2-4","moves":[{"playerId":"kc:player2-4","pitIndex":1,"board":[4,4,4,4,4,4,0,4,1,5,5,5,4,0],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":0,"board":[1,5,5,5,4,4,0,4,1,5,5,5,4,0],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":4,"board":[2,0,5,5,4,4,0,4,1,5,5,1,5,7],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":5,"board":[2,0,5,5,4,1,3,5,0,5,5,1,5,7],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":4,"board":[2,0,5,5,4,1,3,5,0,5,5,0,6,7],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":4,"board":[2,0,5,5,1,2,10,0,0,5,5,0,6,7],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":3,"board":[3,0,5,5,1,2,10,0,0,5,1,1,7,8],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":5,"board":[3,0,5,5,1,1,11,0,0,5,1,1,7,8],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":4,"board":[3,0,5,5,0,2,11,0,0,5,1,1,7,8],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":3,"board":[3,0,5,5,0,2,11,0,0,5,0,2,7,8],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":5,"board":[3,0,5,5,0,1,12,0,0,5,0,2,7,8],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":2,"board":[3,0,1,6,1,2,13,0,0,5,0,2,7,8],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":3,"board":[3,0,1,1,2,3,14,1,1,5,0,2,7,8],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,0,1,1,2,3,14,1,1,5,0,2,7,8],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":5,"board":[4,1,2,2,3,3,14,1,1,5,0,2,1,9],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":2,"board":[4,1,1,3,3,3,14,1,1,5,0,2,1,9],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[4,1,1,3,3,3,14,1,1,5,0,2,1,9],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":1,"board":[4,1,1,3,3,3,14,1,0,6,0,2,1,9],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":4,"board":[4,1,1,3,1,4,15,1,0,6,0,2,1,9],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":3,"board":[4,1,1,1,2,5,15,1,0,6,0,2,1,9],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":4,"board":[4,1,1,1,2,5,15,1,0,6,0,1,2,9],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":3,"board":[4,1,1,0,3,5,15,1,0,6,0,1,2,9],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":2,"board":[5,1,1,0,3,5,15,1,0,1,1,2,3,10],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":4,"board":[5,1,1,0,1,6,16,1,0,1,1,2,3,10],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":5,"board":[5,1,1,0,1,1,19,2,1,2,0,2,3,10],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":1,"board":[5,1,1,0,1,1,19,2,0,3,0,2,3,10],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":1,"board":[5,0,2,0,1,1,19,2,0,3,0,2,3,10],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":5,"board":[0,0,2,0,1,1,19,2,0,3,0,2,1,17],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":5,"board":[0,0,2,0,1,0,20,2,0,3,0,2,1,17],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":2,"board":[0,0,1,0,1,0,24,2,0,0,0,2,1,17],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":0,"board":[0,0,1,0,0,0,24,1,0,0,0,2,1,19],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":2,"board":[0,0,0,1,0,0,24,1,0,0,0,2,1,19],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":5,"board":[0,0,0,1,0,0,24,1,0,0,0,2,0,20],"turnPlayerId":"kc:player2-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":0,"board":[0,0,0,1,0,0,24,0,1,0,0,2,0,20],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player2-4","pitIndex":0,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,0,1,0,0,24,0,1,0,0,2,0,20],"turnPlayerId":"kc:player1-4","state":"playing"},{"playerId":"kc:player1-4","pitIndex":3,"board":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"turnPlayerId":"kc:player2-4","state":"ended"},{"playerId":"kc:player2-4","pitIndex":3,"board":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"turnPlayerId":"kc:player2-4","state":"ended"}],"wonPlayerId":"kc:player1-4","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":8}},{"index":4,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[11]}}],"playerId":"kc:player1-4","moveIndex":3},"id":"game-4","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":28},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":20}]},"player1Id":"kc:player1-4","player2Id":"kc:player2-4","turnPlayerId":"kc:player2-4","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,1,5,5,5,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":8},{"boardSnapshot":[1,5,5,5,4,4,0,4,1,5,5,5,4,0],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":0},{"boardSnapshot":[2,0,5,5,4,4,0,4,1,5,5,1,5,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-4","moveIndex":11},{"boardSnapshot":[2,0,5,5,4,1,3,5,0,5,5,1,5,7],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":8,"bankIndex":6}}],"playerId":"kc:player1-4","moveIndex":5},{"boardSnapshot":[2,0,5,5,4,1,3,5,0,5,5,0,6,7],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":11},{"boardSnapshot":[2,0,5,5,1,2,10,0,0,5,5,0,6,7],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-4","moveIndex":4},{"boardSnapshot":[3,0,5,5,1,2,10,0,0,5,1,1,7,8],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":10},{"boardSnapshot":[3,0,5,5,1,1,11,0,0,5,1,1,7,8],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-4","moveIndex":5},{"boardSnapshot":[3,0,5,5,0,2,11,0,0,5,1,1,7,8],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":4},{"boardSnapshot":[3,0,5,5,0,2,11,0,0,5,0,2,7,8],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":10},{"boardSnapshot":[3,0,5,5,0,1,12,0,0,5,0,2,7,8],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-4","moveIndex":5},{"boardSnapshot":[3,0,1,6,1,2,13,0,0,5,0,2,7,8],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-4","moveIndex":2},{"boardSnapshot":[3,0,1,1,2,3,14,1,1,5,0,2,7,8],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":3},{"boardSnapshot":[4,1,2,2,3,3,14,1,1,5,0,2,1,9],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":12},{"boardSnapshot":[4,1,1,3,3,3,14,1,1,5,0,2,1,9],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":2},{"boardSnapshot":[4,1,1,3,3,3,14,1,0,6,0,2,1,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":8},{"boardSnapshot":[4,1,1,3,1,4,15,1,0,6,0,2,1,9],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-4","moveIndex":4},{"boardSnapshot":[4,1,1,1,2,5,15,1,0,6,0,2,1,9],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":3},{"boardSnapshot":[4,1,1,1,2,5,15,1,0,6,0,1,2,9],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":11},{"boardSnapshot":[4,1,1,0,3,5,15,1,0,6,0,1,2,9],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":3},{"boardSnapshot":[5,1,1,0,3,5,15,1,0,1,1,2,3,10],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":9},{"boardSnapshot":[5,1,1,0,1,6,16,1,0,1,1,2,3,10],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-4","moveIndex":4},{"boardSnapshot":[5,1,1,0,1,1,19,2,1,2,0,2,3,10],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":10,"bankIndex":6}}],"playerId":"kc:player1-4","moveIndex":5},{"boardSnapshot":[5,1,1,0,1,1,19,2,0,3,0,2,3,10],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":8},{"boardSnapshot":[5,0,2,0,1,1,19,2,0,3,0,2,3,10],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":1},{"boardSnapshot":[0,0,2,0,1,1,19,2,0,3,0,2,1,17],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-4","moveIndex":12},{"boardSnapshot":[0,0,2,0,1,0,20,2,0,3,0,2,1,17],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-4","moveIndex":5},{"boardSnapshot":[0,0,1,0,1,0,24,2,0,0,0,2,1,17],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-4","moveIndex":2},{"boardSnapshot":[0,0,1,0,0,0,24,1,0,0,0,2,1,19],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-4","moveIndex":7},{"boardSnapshot":[0,0,0,1,0,0,24,1,0,0,0,2,1,19],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-4","moveIndex":2},{"boardSnapshot":[0,0,0,1,0,0,24,1,0,0,0,2,0,20],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-4","moveIndex":12},{"boardSnapshot":[0,0,0,1,0,0,24,0,1,0,0,2,0,20],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-4","moveIndex":7},{"boardSnapshot":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":8}},{"index":4,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[11]}}],"playerId":"kc:player1-4","moveIndex":3}]}},{"id":"game-5","player1Id":"kc:player1-5","player2Id":"kc:player2-5","turnPlayerId":"kc:player2-5","moves":[{"playerId":"kc:player2-5","pitIndex":3,"board":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":3,"board":[4,4,4,4,4,4,0,4,4,4,0,6,5,1],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":0,"board":[1,5,5,5,4,4,0,4,4,4,0,6,5,1],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":5,"board":[2,6,0,5,4,4,0,4,4,4,0,6,1,8],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":5,"board":[2,6,0,5,4,1,1,5,5,4,0,6,1,8],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":0,"board":[2,6,0,5,4,1,1,1,6,5,1,7,1,8],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":3,"board":[2,6,0,1,5,2,4,0,6,5,1,7,1,8],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":4,"board":[3,7,1,0,5,2,4,0,6,5,1,1,2,11],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":2,"board":[3,7,0,0,5,2,10,0,6,0,1,1,2,11],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":5,"board":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":1,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":13,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":1,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":1,"board":[3,7,0,0,5,2,10,0,1,1,2,2,2,13],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":2,"board":[3,7,0,0,5,2,10,0,1,0,3,2,2,13],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":1,"board":[3,1,1,1,6,3,11,1,1,0,3,2,2,13],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":3,"board":[3,1,1,1,6,3,11,1,1,0,1,3,3,13],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":3,"board":[3,1,1,0,7,3,11,1,1,0,1,3,3,13],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":4,"board":[3,1,1,0,7,3,11,1,1,0,1,1,4,14],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":1,"board":[3,1,1,0,7,3,11,1,0,1,1,1,4,14],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":4,"board":[3,1,1,0,1,4,14,2,1,2,0,1,4,14],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":2,"board":[3,1,0,0,1,4,14,2,1,1,0,1,4,16],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":4,"board":[3,1,0,0,0,5,14,2,1,1,0,1,4,16],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":1,"board":[3,1,0,0,0,5,14,2,0,2,0,1,4,16],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":0,"board":[1,2,1,0,0,5,14,2,0,2,0,1,4,16],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":2,"board":[1,2,0,0,0,5,14,2,0,1,0,1,4,18],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":0,"board":[0,3,0,0,0,5,14,2,0,1,0,1,4,18],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":0,"board":[0,3,0,0,0,5,14,1,1,1,0,1,4,18],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":3,"error":"StoneNotFound => index : 3 stoneCount = 0","board":[0,3,0,0,0,5,14,1,1,1,0,1,4,18],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":1,"board":[0,1,1,0,0,5,16,1,1,0,0,1,4,18],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":0,"board":[0,1,1,0,0,5,16,0,2,0,0,1,4,18],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":5,"board":[0,1,1,0,0,1,17,1,3,1,0,1,4,18],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":1,"board":[0,1,0,0,0,1,17,1,1,2,0,1,4,20],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":0,"error":"StoneNotFound => index : 0 stoneCount = 0","board":[0,1,0,0,0,1,17,1,1,2,0,1,4,20],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":5,"board":[0,1,0,0,0,0,18,1,1,2,0,1,4,20],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":1,"board":[0,0,1,0,0,0,18,1,1,2,0,1,4,20],"turnPlayerId":"kc:player2-5","state":"playing"},{"playerId":"kc:player2-5","pitIndex":0,"board":[0,0,1,0,0,0,18,0,2,2,0,1,4,20],"turnPlayerId":"kc:player1-5","state":"playing"},{"playerId":"kc:player1-5","pitIndex":2,"board":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"turnPlayerId":"kc:player2-5","state":"ended"},{"playerId":"kc:player2-5","pitIndex":0,"board":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"turnPlayerId":"kc:player2-5","state":"ended"}],"wonPlayerId":"kc:player1-5","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}},{"index":3,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[8,11,12]}}],"playerId":"kc:player1-5","moveIndex":2},"id":"game-5","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":28},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":20}]},"player1Id":"kc:player1-5","player2Id":"kc:player2-5","turnPlayerId":"kc:player2-5","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-5","moveIndex":10},{"boardSnapshot":[4,4,4,4,4,4,0,4,4,4,0,6,5,1],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":10},{"boardSnapshot":[1,5,5,5,4,4,0,4,4,4,0,6,5,1],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":0},{"boardSnapshot":[2,6,0,5,4,4,0,4,4,4,0,6,1,8],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":2,"bankIndex":13}}],"playerId":"kc:player2-5","moveIndex":12},{"boardSnapshot":[2,6,0,5,4,1,1,5,5,4,0,6,1,8],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":5},{"boardSnapshot":[2,6,0,5,4,1,1,1,6,5,1,7,1,8],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":7},{"boardSnapshot":[2,6,0,1,5,2,4,0,6,5,1,7,1,8],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-5","moveIndex":3},{"boardSnapshot":[3,7,1,0,5,2,4,0,6,5,1,1,2,11],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":3,"bankIndex":13}}],"playerId":"kc:player2-5","moveIndex":11},{"boardSnapshot":[3,7,0,0,5,2,10,0,6,0,1,1,2,11],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-5","moveIndex":2},{"boardSnapshot":[3,7,0,0,5,2,10,0,6,0,1,1,1,12],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-5","moveIndex":12},{"boardSnapshot":[3,7,0,0,5,2,10,0,1,1,2,2,2,13],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-5","moveIndex":8},{"boardSnapshot":[3,7,0,0,5,2,10,0,1,0,3,2,2,13],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":9},{"boardSnapshot":[3,1,1,1,6,3,11,1,1,0,3,2,2,13],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":1},{"boardSnapshot":[3,1,1,1,6,3,11,1,1,0,1,3,3,13],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":10},{"boardSnapshot":[3,1,1,0,7,3,11,1,1,0,1,3,3,13],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":3},{"boardSnapshot":[3,1,1,0,7,3,11,1,1,0,1,1,4,14],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-5","moveIndex":11},{"boardSnapshot":[3,1,1,0,7,3,11,1,0,1,1,1,4,14],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":8},{"boardSnapshot":[3,1,1,0,1,4,14,2,1,2,0,1,4,14],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":10,"bankIndex":6}}],"playerId":"kc:player1-5","moveIndex":4},{"boardSnapshot":[3,1,0,0,1,4,14,2,1,1,0,1,4,16],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-5","moveIndex":9},{"boardSnapshot":[3,1,0,0,0,5,14,2,1,1,0,1,4,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":4},{"boardSnapshot":[3,1,0,0,0,5,14,2,0,2,0,1,4,16],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":8},{"boardSnapshot":[1,2,1,0,0,5,14,2,0,2,0,1,4,16],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":0},{"boardSnapshot":[1,2,0,0,0,5,14,2,0,1,0,1,4,18],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-5","moveIndex":9},{"boardSnapshot":[0,3,0,0,0,5,14,2,0,1,0,1,4,18],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":0},{"boardSnapshot":[0,3,0,0,0,5,14,1,1,1,0,1,4,18],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":7},{"boardSnapshot":[0,1,1,0,0,5,16,1,1,0,0,1,4,18],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-5","moveIndex":1},{"boardSnapshot":[0,1,1,0,0,5,16,0,2,0,0,1,4,18],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":7},{"boardSnapshot":[0,1,1,0,0,1,17,1,3,1,0,1,4,18],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":5},{"boardSnapshot":[0,1,0,0,0,1,17,1,1,2,0,1,4,20],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-5","moveIndex":8},{"boardSnapshot":[0,1,0,0,0,0,18,1,1,2,0,1,4,20],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-5","moveIndex":5},{"boardSnapshot":[0,0,1,0,0,0,18,1,1,2,0,1,4,20],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-5","moveIndex":1},{"boardSnapshot":[0,0,1,0,0,0,18,0,2,2,0,1,4,20],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-5","moveIndex":7},{"boardSnapshot":[0,0,0,0,0,0,28,0,0,0,0,0,0,20],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}},{"index":3,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[8,11,12]}}],"playerId":"kc:player1-5","moveIndex":2}]}},{"id":"game-6","player1Id":"kc:player1-6","player2Id":"kc:player2-6","turnPlayerId":"kc:player1-6","moves":[{"playerId":"kc:player1-6","pitIndex":5,"board":[4,4,4,4,4,1,1,5,5,4,4,4,4,0],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":3,"board":[4,4,4,4,4,1,1,5,5,4,1,5,5,1],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":2,"board":[4,4,4,4,4,1,1,5,5,1,2,6,6,1],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"board":[4,4,4,1,5,2,2,5,5,1,2,6,6,1],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":1,"board":[4,1,5,2,6,2,2,5,5,1,2,6,6,1],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"board":[5,2,0,2,6,2,2,5,5,1,2,1,7,8],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":0,"board":[1,3,1,3,7,2,2,5,5,1,2,1,7,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":1,"board":[1,3,1,3,7,2,2,5,1,2,3,2,8,8],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":1,"board":[1,1,2,4,7,2,2,5,1,2,3,2,8,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,1,2,4,7,2,2,5,1,2,3,2,8,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":1,"board":[1,1,2,4,7,2,2,5,0,3,3,2,8,8],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":0,"board":[0,2,2,4,7,2,2,5,0,3,3,2,8,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":3,"board":[0,2,2,4,7,2,2,5,0,3,1,3,9,8],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":1,"board":[0,1,3,4,7,2,2,5,0,3,1,3,9,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":3,"board":[0,1,3,4,7,2,2,5,0,3,0,4,9,8],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":4,"board":[0,1,3,4,1,3,3,6,1,4,1,4,9,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,3,4,1,3,3,6,1,4,1,4,9,8],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"board":[1,1,3,4,1,3,3,6,1,4,1,1,10,9],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":1,"board":[1,0,4,4,1,3,3,6,1,4,1,1,10,9],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":2,"board":[1,0,4,4,1,3,3,6,1,1,2,2,11,9],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":0,"board":[0,0,4,4,1,3,6,6,1,1,2,0,11,9],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":1,"board":[0,0,4,4,1,3,6,6,0,2,2,0,11,9],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"board":[0,0,4,1,2,4,7,6,0,2,2,0,11,9],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,4,1,2,4,7,6,0,2,2,0,11,9],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"board":[0,0,4,0,3,4,7,6,0,2,2,0,11,9],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":0,"board":[0,0,4,0,3,4,7,1,1,3,3,1,12,9],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":5,"board":[0,0,4,0,3,1,10,2,0,3,3,1,12,9],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":0,"board":[0,0,4,0,0,1,10,1,0,3,3,1,12,13],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":2,"board":[0,0,1,1,1,2,10,1,0,3,3,1,12,13],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"board":[0,0,1,1,1,2,10,1,0,3,3,0,13,13],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"board":[0,0,1,0,2,2,10,1,0,3,3,0,13,13],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":3,"board":[0,0,1,0,2,2,10,1,0,3,1,1,14,13],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":0,"error":"StoneNotFound => index : 0 stoneCount = 0","board":[0,0,1,0,2,2,10,1,0,3,1,1,14,13],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":4,"board":[0,0,1,0,1,3,10,1,0,3,1,1,14,13],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":5,"board":[1,1,2,1,2,4,10,2,1,4,2,2,2,14],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"board":[1,1,2,0,3,4,10,2,1,4,2,2,2,14],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,1,2,0,3,4,10,2,1,4,2,2,2,14],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"board":[1,1,2,0,3,4,10,2,1,4,2,1,3,14],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":2,"board":[1,1,1,0,3,4,15,2,1,0,2,1,3,14],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"board":[1,1,1,0,3,4,15,2,1,0,2,0,4,14],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":4,"board":[1,1,1,0,1,5,16,2,1,0,2,0,4,14],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":7,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[1,1,1,0,1,5,16,2,1,0,2,0,4,14],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":2,"board":[1,1,0,1,1,5,16,2,1,0,2,0,4,14],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":3,"board":[1,0,0,1,1,5,16,2,1,0,1,0,4,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":3,"board":[1,0,0,0,2,5,16,2,1,0,1,0,4,16],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":0,"board":[1,0,0,0,2,5,16,1,2,0,1,0,4,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":5,"board":[1,0,0,0,2,1,17,2,3,1,1,0,4,16],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":3,"board":[1,0,0,0,2,1,17,2,3,1,0,1,4,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":4,"board":[1,0,0,0,1,2,17,2,3,1,0,1,4,16],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":4,"board":[1,0,0,0,1,2,17,2,3,1,0,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,0,0,0,1,2,17,2,3,1,0,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":13,"error":"IndexOutOfRange => index : 20 [start, stop] = [0, 13]","board":[1,0,0,0,1,2,17,2,3,1,0,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":4,"board":[1,0,0,0,0,3,17,2,3,1,0,0,5,16],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":0,"board":[1,0,0,0,0,3,17,1,4,1,0,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":7,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[1,0,0,0,0,3,17,1,4,1,0,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":0,"board":[0,1,0,0,0,3,17,1,4,1,0,0,5,16],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":0,"board":[0,1,0,0,0,3,17,0,5,1,0,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":5,"board":[0,1,0,0,0,1,18,1,5,1,0,0,5,16],"turnPlayerId":"kc:player2-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":2,"board":[0,1,0,0,0,1,18,1,5,0,1,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player2-6","pitIndex":13,"error":"IndexOutOfRange => index : 20 [start, stop] = [0, 13]","board":[0,1,0,0,0,1,18,1,5,0,1,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":5,"board":[0,1,0,0,0,0,19,1,5,0,1,0,5,16],"turnPlayerId":"kc:player1-6","state":"playing"},{"playerId":"kc:player1-6","pitIndex":1,"board":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"turnPlayerId":"kc:player2-6","state":"ended"},{"playerId":"kc:player2-6","pitIndex":4,"board":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"turnPlayerId":"kc:player2-6","state":"ended"}],"wonPlayerId":"kc:player1-6","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}},{"index":2,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,8,12]}}],"playerId":"kc:player1-6","moveIndex":1},"id":"game-6","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":32},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":16}]},"player1Id":"kc:player1-6","player2Id":"kc:player2-6","turnPlayerId":"kc:player2-6","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,1,1,5,5,4,4,4,4,0],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":5},{"boardSnapshot":[4,4,4,4,4,1,1,5,5,4,1,5,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-6","moveIndex":10},{"boardSnapshot":[4,4,4,4,4,1,1,5,5,1,2,6,6,1],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":9},{"boardSnapshot":[4,4,4,1,5,2,2,5,5,1,2,6,6,1],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-6","moveIndex":3},{"boardSnapshot":[4,1,5,2,6,2,2,5,5,1,2,6,6,1],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":1},{"boardSnapshot":[5,2,0,2,6,2,2,5,5,1,2,1,7,8],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":2,"bankIndex":13}}],"playerId":"kc:player2-6","moveIndex":11},{"boardSnapshot":[1,3,1,3,7,2,2,5,5,1,2,1,7,8],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":0},{"boardSnapshot":[1,3,1,3,7,2,2,5,1,2,3,2,8,8],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":8},{"boardSnapshot":[1,1,2,4,7,2,2,5,1,2,3,2,8,8],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":1},{"boardSnapshot":[1,1,2,4,7,2,2,5,0,3,3,2,8,8],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":8},{"boardSnapshot":[0,2,2,4,7,2,2,5,0,3,3,2,8,8],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":0},{"boardSnapshot":[0,2,2,4,7,2,2,5,0,3,1,3,9,8],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":10},{"boardSnapshot":[0,1,3,4,7,2,2,5,0,3,1,3,9,8],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":1},{"boardSnapshot":[0,1,3,4,7,2,2,5,0,3,0,4,9,8],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":10},{"boardSnapshot":[0,1,3,4,1,3,3,6,1,4,1,4,9,8],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":4},{"boardSnapshot":[1,1,3,4,1,3,3,6,1,4,1,1,10,9],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":11},{"boardSnapshot":[1,0,4,4,1,3,3,6,1,4,1,1,10,9],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":1},{"boardSnapshot":[1,0,4,4,1,3,3,6,1,1,2,2,11,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":9},{"boardSnapshot":[0,0,4,4,1,3,6,6,1,1,2,0,11,9],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}}],"playerId":"kc:player1-6","moveIndex":0},{"boardSnapshot":[0,0,4,4,1,3,6,6,0,2,2,0,11,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":8},{"boardSnapshot":[0,0,4,1,2,4,7,6,0,2,2,0,11,9],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-6","moveIndex":3},{"boardSnapshot":[0,0,4,0,3,4,7,6,0,2,2,0,11,9],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":3},{"boardSnapshot":[0,0,4,0,3,4,7,1,1,3,3,1,12,9],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":7},{"boardSnapshot":[0,0,4,0,3,1,10,2,0,3,3,1,12,9],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":8,"bankIndex":6}}],"playerId":"kc:player1-6","moveIndex":5},{"boardSnapshot":[0,0,4,0,0,1,10,1,0,3,3,1,12,13],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-6","moveIndex":7},{"boardSnapshot":[0,0,1,1,1,2,10,1,0,3,3,1,12,13],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":2},{"boardSnapshot":[0,0,1,1,1,2,10,1,0,3,3,0,13,13],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":11},{"boardSnapshot":[0,0,1,0,2,2,10,1,0,3,3,0,13,13],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":3},{"boardSnapshot":[0,0,1,0,2,2,10,1,0,3,1,1,14,13],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":10},{"boardSnapshot":[0,0,1,0,1,3,10,1,0,3,1,1,14,13],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":4},{"boardSnapshot":[1,1,2,1,2,4,10,2,1,4,2,2,2,14],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":12},{"boardSnapshot":[1,1,2,0,3,4,10,2,1,4,2,2,2,14],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":3},{"boardSnapshot":[1,1,2,0,3,4,10,2,1,4,2,1,3,14],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":11},{"boardSnapshot":[1,1,1,0,3,4,15,2,1,0,2,1,3,14],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-6","moveIndex":2},{"boardSnapshot":[1,1,1,0,3,4,15,2,1,0,2,0,4,14],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":11},{"boardSnapshot":[1,1,1,0,1,5,16,2,1,0,2,0,4,14],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-6","moveIndex":4},{"boardSnapshot":[1,1,0,1,1,5,16,2,1,0,2,0,4,14],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":2},{"boardSnapshot":[1,0,0,1,1,5,16,2,1,0,1,0,4,16],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-6","moveIndex":10},{"boardSnapshot":[1,0,0,0,2,5,16,2,1,0,1,0,4,16],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":3},{"boardSnapshot":[1,0,0,0,2,5,16,1,2,0,1,0,4,16],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":7},{"boardSnapshot":[1,0,0,0,2,1,17,2,3,1,1,0,4,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":5},{"boardSnapshot":[1,0,0,0,2,1,17,2,3,1,0,1,4,16],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":10},{"boardSnapshot":[1,0,0,0,1,2,17,2,3,1,0,1,4,16],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":4},{"boardSnapshot":[1,0,0,0,1,2,17,2,3,1,0,0,5,16],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":11},{"boardSnapshot":[1,0,0,0,0,3,17,2,3,1,0,0,5,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":4},{"boardSnapshot":[1,0,0,0,0,3,17,1,4,1,0,0,5,16],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":7},{"boardSnapshot":[0,1,0,0,0,3,17,1,4,1,0,0,5,16],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":0},{"boardSnapshot":[0,1,0,0,0,3,17,0,5,1,0,0,5,16],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":7},{"boardSnapshot":[0,1,0,0,0,1,18,1,5,1,0,0,5,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-6","moveIndex":5},{"boardSnapshot":[0,1,0,0,0,1,18,1,5,0,1,0,5,16],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-6","moveIndex":9},{"boardSnapshot":[0,1,0,0,0,0,19,1,5,0,1,0,5,16],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-6","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,0,32,0,0,0,0,0,0,16],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}},{"index":2,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,8,12]}}],"playerId":"kc:player1-6","moveIndex":1}]}},{"id":"game-7","player1Id":"kc:player1-7","player2Id":"kc:player2-7","turnPlayerId":"kc:player1-7","moves":[{"playerId":"kc:player1-7","pitIndex":1,"board":[4,1,5,5,5,4,0,4,4,4,4,4,4,0],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"board":[4,1,5,5,5,4,0,4,4,4,1,5,5,1],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":4,"board":[5,0,5,5,5,4,0,4,4,4,1,1,6,4],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":3,"board":[5,0,5,1,6,5,1,5,4,4,1,1,6,4],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"board":[5,0,5,1,6,5,1,5,4,4,0,2,6,4],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":3,"board":[5,0,5,0,7,5,1,5,4,4,0,2,6,4],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":0,"board":[5,0,5,0,7,5,1,1,5,5,1,3,6,4],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":4,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[5,0,5,0,7,5,1,1,5,5,1,3,6,4],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":0,"board":[1,1,6,1,8,5,1,1,5,5,1,3,6,4],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"board":[1,1,6,1,8,5,1,1,5,5,0,4,6,4],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":4,"board":[1,1,6,1,1,6,2,2,6,6,1,5,6,4],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":0,"board":[1,1,6,1,1,6,2,1,7,6,1,5,6,4],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":2,"board":[1,1,1,2,2,7,5,0,7,6,1,5,6,4],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":2,"board":[0,1,1,2,2,7,5,0,7,1,2,6,7,7],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":1,"board":[0,0,2,2,2,7,5,0,7,1,2,6,7,7],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"board":[1,0,2,2,2,7,5,0,1,2,3,7,8,8],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":5,"board":[1,0,2,2,2,1,14,1,2,3,4,0,8,8],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"board":[1,0,2,2,2,1,14,1,2,3,1,1,9,9],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"board":[1,0,2,2,2,1,14,1,2,3,0,2,9,9],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":2,"board":[1,0,1,3,2,1,14,1,2,3,0,2,9,9],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"board":[1,0,1,3,2,1,14,1,1,4,0,2,9,9],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":7,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[1,0,1,3,2,1,14,1,1,4,0,2,9,9],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":0,"board":[0,0,1,3,2,1,17,1,1,4,0,0,9,9],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":5,"board":[1,1,2,4,3,2,17,2,1,4,0,0,1,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,1,2,4,3,2,17,2,1,4,0,0,1,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":0,"board":[0,2,2,4,3,2,17,2,1,4,0,0,1,10],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":2,"board":[0,2,2,4,3,2,17,2,1,1,1,1,2,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":3,"board":[0,2,2,1,4,3,18,2,1,1,1,1,2,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":1,"board":[0,1,3,1,4,3,18,2,1,1,1,1,2,10],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":14,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[0,1,3,1,4,3,18,2,1,1,1,1,2,10],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":0,"board":[0,1,3,1,4,3,18,1,2,1,1,1,2,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,3,1,4,3,18,1,2,1,1,1,2,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":3,"board":[0,1,3,0,5,3,18,1,2,1,1,1,2,10],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":0,"board":[0,1,3,0,5,3,18,0,3,1,1,1,2,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":2,"board":[0,1,1,1,6,3,18,0,3,1,1,1,2,10],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"board":[0,1,1,1,6,3,18,0,1,2,2,1,2,10],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":2,"board":[0,1,0,2,6,3,18,0,1,2,2,1,2,10],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":5,"board":[0,1,0,2,6,3,18,0,1,2,2,1,1,11],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":5,"board":[0,1,0,2,6,3,18,0,1,2,2,1,0,12],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":3,"board":[0,1,0,2,6,3,18,0,1,2,1,2,0,12],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,1,0,2,6,3,18,0,1,2,1,2,0,12],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":4,"board":[0,1,0,2,1,4,19,1,2,3,1,2,0,12],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"board":[0,1,0,2,1,4,19,1,1,4,1,2,0,12],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":1,"board":[0,0,0,2,1,4,21,1,1,4,0,2,0,12],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"board":[0,0,0,2,1,4,21,1,0,5,0,2,0,12],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":4,"board":[0,0,0,2,0,5,21,1,0,5,0,2,0,12],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":4,"board":[0,0,0,2,0,5,21,1,0,5,0,1,1,12],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":5,"board":[0,0,0,2,0,1,28,2,1,0,0,1,1,12],"turnPlayerId":"kc:player2-7","state":"playing"},{"playerId":"kc:player2-7","pitIndex":1,"board":[0,0,0,0,0,1,28,2,0,0,0,1,1,15],"turnPlayerId":"kc:player1-7","state":"playing"},{"playerId":"kc:player1-7","pitIndex":5,"board":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"turnPlayerId":"kc:player1-7","state":"ended"},{"playerId":"kc:player1-7","pitIndex":0,"board":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"turnPlayerId":"kc:player1-7","state":"ended"}],"wonPlayerId":"kc:player1-7","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,11,12]}}],"playerId":"kc:player1-7","moveIndex":5},"id":"game-7","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":33},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":15}]},"player1Id":"kc:player1-7","player2Id":"kc:player2-7","turnPlayerId":"kc:player1-7","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,1,5,5,5,4,0,4,4,4,4,4,4,0],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":1},{"boardSnapshot":[4,1,5,5,5,4,0,4,4,4,1,5,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-7","moveIndex":10},{"boardSnapshot":[5,0,5,5,5,4,0,4,4,4,1,1,6,4],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-7","moveIndex":11},{"boardSnapshot":[5,0,5,1,6,5,1,5,4,4,1,1,6,4],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":3},{"boardSnapshot":[5,0,5,1,6,5,1,5,4,4,0,2,6,4],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":10},{"boardSnapshot":[5,0,5,0,7,5,1,5,4,4,0,2,6,4],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":3},{"boardSnapshot":[5,0,5,0,7,5,1,1,5,5,1,3,6,4],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":7},{"boardSnapshot":[1,1,6,1,8,5,1,1,5,5,1,3,6,4],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":0},{"boardSnapshot":[1,1,6,1,8,5,1,1,5,5,0,4,6,4],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":10},{"boardSnapshot":[1,1,6,1,1,6,2,2,6,6,1,5,6,4],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":4},{"boardSnapshot":[1,1,6,1,1,6,2,1,7,6,1,5,6,4],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":7},{"boardSnapshot":[1,1,1,2,2,7,5,0,7,6,1,5,6,4],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-7","moveIndex":2},{"boardSnapshot":[0,1,1,2,2,7,5,0,7,1,2,6,7,7],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-7","moveIndex":9},{"boardSnapshot":[0,0,2,2,2,7,5,0,7,1,2,6,7,7],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":1},{"boardSnapshot":[1,0,2,2,2,7,5,0,1,2,3,7,8,8],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":8},{"boardSnapshot":[1,0,2,2,2,1,14,1,2,3,4,0,8,8],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":11,"bankIndex":6}}],"playerId":"kc:player1-7","moveIndex":5},{"boardSnapshot":[1,0,2,2,2,1,14,1,2,3,1,1,9,9],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-7","moveIndex":10},{"boardSnapshot":[1,0,2,2,2,1,14,1,2,3,0,2,9,9],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":10},{"boardSnapshot":[1,0,1,3,2,1,14,1,2,3,0,2,9,9],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":2},{"boardSnapshot":[1,0,1,3,2,1,14,1,1,4,0,2,9,9],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":8},{"boardSnapshot":[0,0,1,3,2,1,17,1,1,4,0,0,9,9],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}}],"playerId":"kc:player1-7","moveIndex":0},{"boardSnapshot":[1,1,2,4,3,2,17,2,1,4,0,0,1,10],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":12},{"boardSnapshot":[0,2,2,4,3,2,17,2,1,4,0,0,1,10],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":0},{"boardSnapshot":[0,2,2,4,3,2,17,2,1,1,1,1,2,10],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":9},{"boardSnapshot":[0,2,2,1,4,3,18,2,1,1,1,1,2,10],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-7","moveIndex":3},{"boardSnapshot":[0,1,3,1,4,3,18,2,1,1,1,1,2,10],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":1},{"boardSnapshot":[0,1,3,1,4,3,18,1,2,1,1,1,2,10],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":7},{"boardSnapshot":[0,1,3,0,5,3,18,1,2,1,1,1,2,10],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":3},{"boardSnapshot":[0,1,3,0,5,3,18,0,3,1,1,1,2,10],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":7},{"boardSnapshot":[0,1,1,1,6,3,18,0,3,1,1,1,2,10],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":2},{"boardSnapshot":[0,1,1,1,6,3,18,0,1,2,2,1,2,10],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":8},{"boardSnapshot":[0,1,0,2,6,3,18,0,1,2,2,1,2,10],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":2},{"boardSnapshot":[0,1,0,2,6,3,18,0,1,2,2,1,1,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-7","moveIndex":12},{"boardSnapshot":[0,1,0,2,6,3,18,0,1,2,2,1,0,12],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-7","moveIndex":12},{"boardSnapshot":[0,1,0,2,6,3,18,0,1,2,1,2,0,12],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":10},{"boardSnapshot":[0,1,0,2,1,4,19,1,2,3,1,2,0,12],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":4},{"boardSnapshot":[0,1,0,2,1,4,19,1,1,4,1,2,0,12],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":8},{"boardSnapshot":[0,0,0,2,1,4,21,1,1,4,0,2,0,12],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-7","moveIndex":1},{"boardSnapshot":[0,0,0,2,1,4,21,1,0,5,0,2,0,12],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":8},{"boardSnapshot":[0,0,0,2,0,5,21,1,0,5,0,2,0,12],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-7","moveIndex":4},{"boardSnapshot":[0,0,0,2,0,5,21,1,0,5,0,1,1,12],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-7","moveIndex":11},{"boardSnapshot":[0,0,0,2,0,1,28,2,1,0,0,1,1,12],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":9,"bankIndex":6}}],"playerId":"kc:player1-7","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,1,28,2,0,0,0,1,1,15],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":3}}],"playerId":"kc:player2-7","moveIndex":8},{"boardSnapshot":[0,0,0,0,0,0,33,0,0,0,0,0,0,15],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,11,12]}}],"playerId":"kc:player1-7","moveIndex":5}]}},{"id":"game-8","player1Id":"kc:player1-8","player2Id":"kc:player2-8","turnPlayerId":"kc:player1-8","moves":[{"playerId":"kc:player1-8","pitIndex":0,"board":[1,5,5,5,4,4,0,4,4,4,4,4,4,0],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":4,"board":[0,5,5,5,4,4,0,4,4,4,4,1,5,3],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":2,"board":[0,5,1,6,5,5,1,4,4,4,4,1,5,3],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":0,"error":"StoneNotFound => index : 0 stoneCount = 0","board":[0,5,1,6,5,5,1,4,4,4,4,1,5,3],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[0,5,1,6,1,6,2,5,5,4,4,1,5,3],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":1,"board":[0,5,1,6,1,6,2,5,1,5,5,2,6,3],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":1,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,5,1,6,1,6,2,5,1,5,5,2,6,3],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[0,5,1,6,0,7,2,5,1,5,5,2,6,3],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[1,5,1,6,0,7,2,5,1,5,1,3,7,4],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":5,"board":[1,5,1,6,0,1,7,6,2,6,2,0,7,4],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":5,"board":[2,6,2,7,1,1,7,6,2,6,2,0,1,5],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[2,6,2,7,0,2,7,6,2,6,2,0,1,5],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":0,"board":[2,6,2,7,0,2,7,1,3,7,3,1,2,5],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":1,"board":[2,1,3,8,1,3,8,1,3,7,3,1,2,5],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":2,"board":[2,1,1,9,2,3,8,1,3,7,3,1,2,5],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":5,"board":[2,1,1,9,2,3,8,1,3,7,3,1,1,6],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":1,"board":[2,1,1,9,2,3,8,1,1,8,4,1,1,6],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":2,"board":[2,1,0,10,2,3,8,1,1,8,4,1,1,6],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[2,1,0,10,2,3,8,1,1,8,1,2,2,7],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[2,1,0,10,2,3,8,1,1,8,0,3,2,7],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[2,1,0,10,2,3,8,1,1,8,0,3,2,7],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":3,"board":[2,1,0,1,3,4,9,2,2,9,1,4,3,7],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[2,1,0,1,3,4,9,2,2,9,0,5,3,7],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":3,"board":[2,1,0,0,4,4,9,2,2,9,0,5,3,7],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":2,"board":[3,2,1,1,4,4,9,2,2,1,1,6,4,8],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":2,"board":[3,2,0,2,4,4,9,2,2,1,1,6,4,8],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":5,"board":[4,3,0,2,4,4,9,2,2,1,1,6,1,9],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[4,3,0,2,1,5,10,3,2,1,1,6,1,9],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[4,3,0,2,1,5,10,3,2,1,0,7,1,9],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":14,"error":"IndexOutOfRange => index : 21 [start, stop] = [0, 13]","board":[4,3,0,2,1,5,10,3,2,1,0,7,1,9],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[4,3,0,2,0,6,10,3,2,1,0,7,1,9],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":4,"board":[5,4,1,3,0,6,10,3,2,1,0,1,2,10],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":5,"board":[5,4,1,3,0,1,11,4,3,2,1,1,2,10],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":5,"board":[5,4,1,3,0,1,11,4,3,2,1,1,1,11],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":1,"board":[5,4,1,3,0,1,11,4,1,3,2,1,1,11],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":5,"board":[5,4,1,3,0,0,12,4,1,3,2,1,1,11],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":3,"board":[5,4,1,1,1,0,17,0,1,3,2,1,1,11],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":4,"board":[5,4,1,1,1,0,17,0,1,3,2,0,2,11],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":3,"board":[5,4,1,0,2,0,17,0,1,3,2,0,2,11],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[5,0,1,0,2,0,17,0,1,3,1,0,2,16],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":0,"board":[1,1,2,1,3,0,17,0,1,3,1,0,2,16],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[1,0,2,1,3,0,17,0,1,3,0,0,2,18],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":3,"board":[1,0,2,0,4,0,17,0,1,3,0,0,2,18],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":2,"board":[1,0,2,0,4,0,17,0,1,1,1,1,2,18],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[1,0,2,0,1,1,18,1,1,1,1,1,2,18],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"board":[1,0,2,0,1,1,18,1,1,1,0,2,2,18],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,0,2,0,1,1,18,1,1,1,0,2,2,18],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":0,"board":[0,0,2,0,1,1,21,1,1,1,0,0,2,18],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":2,"board":[0,0,0,0,1,1,21,1,1,0,0,0,2,21],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":4,"board":[0,0,0,0,0,2,21,1,1,0,0,0,2,21],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":3,"error":"StoneNotFound => index : 10 stoneCount = 0","board":[0,0,0,0,0,2,21,1,1,0,0,0,2,21],"turnPlayerId":"kc:player2-8","state":"playing"},{"playerId":"kc:player2-8","pitIndex":1,"board":[0,0,0,0,0,2,21,1,0,1,0,0,2,21],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":5,"board":[0,0,0,0,0,1,22,1,0,1,0,0,2,21],"turnPlayerId":"kc:player1-8","state":"playing"},{"playerId":"kc:player1-8","pitIndex":5,"board":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"turnPlayerId":"kc:player1-8","state":"ended"},{"playerId":"kc:player1-8","pitIndex":1,"board":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"turnPlayerId":"kc:player1-8","state":"ended"}],"wonPlayerId":"kc:player1-8","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,9,12]}}],"playerId":"kc:player1-8","moveIndex":5},"id":"game-8","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":27},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":21}]},"player1Id":"kc:player1-8","player2Id":"kc:player2-8","turnPlayerId":"kc:player1-8","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[1,5,5,5,4,4,0,4,4,4,4,4,4,0],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":0},{"boardSnapshot":[0,5,5,5,4,4,0,4,4,4,4,1,5,3],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-8","moveIndex":11},{"boardSnapshot":[0,5,1,6,5,5,1,4,4,4,4,1,5,3],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-8","moveIndex":2},{"boardSnapshot":[0,5,1,6,1,6,2,5,5,4,4,1,5,3],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[0,5,1,6,1,6,2,5,1,5,5,2,6,3],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":8},{"boardSnapshot":[0,5,1,6,0,7,2,5,1,5,5,2,6,3],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[1,5,1,6,0,7,2,5,1,5,1,3,7,4],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[1,5,1,6,0,1,7,6,2,6,2,0,7,4],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":11,"bankIndex":6}}],"playerId":"kc:player1-8","moveIndex":5},{"boardSnapshot":[2,6,2,7,1,1,7,6,2,6,2,0,1,5],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":12},{"boardSnapshot":[2,6,2,7,0,2,7,6,2,6,2,0,1,5],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[2,6,2,7,0,2,7,1,3,7,3,1,2,5],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":7},{"boardSnapshot":[2,1,3,8,1,3,8,1,3,7,3,1,2,5],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-8","moveIndex":1},{"boardSnapshot":[2,1,1,9,2,3,8,1,3,7,3,1,2,5],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":2},{"boardSnapshot":[2,1,1,9,2,3,8,1,3,7,3,1,1,6],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-8","moveIndex":12},{"boardSnapshot":[2,1,1,9,2,3,8,1,1,8,4,1,1,6],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":8},{"boardSnapshot":[2,1,0,10,2,3,8,1,1,8,4,1,1,6],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":2},{"boardSnapshot":[2,1,0,10,2,3,8,1,1,8,1,2,2,7],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[2,1,0,10,2,3,8,1,1,8,0,3,2,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[2,1,0,1,3,4,9,2,2,9,1,4,3,7],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":3},{"boardSnapshot":[2,1,0,1,3,4,9,2,2,9,0,5,3,7],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[2,1,0,0,4,4,9,2,2,9,0,5,3,7],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":3},{"boardSnapshot":[3,2,1,1,4,4,9,2,2,1,1,6,4,8],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":9},{"boardSnapshot":[3,2,0,2,4,4,9,2,2,1,1,6,4,8],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":2},{"boardSnapshot":[4,3,0,2,4,4,9,2,2,1,1,6,1,9],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":12},{"boardSnapshot":[4,3,0,2,1,5,10,3,2,1,1,6,1,9],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[4,3,0,2,1,5,10,3,2,1,0,7,1,9],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[4,3,0,2,0,6,10,3,2,1,0,7,1,9],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[5,4,1,3,0,6,10,3,2,1,0,1,2,10],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":11},{"boardSnapshot":[5,4,1,3,0,1,11,4,3,2,1,1,2,10],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":5},{"boardSnapshot":[5,4,1,3,0,1,11,4,3,2,1,1,1,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-8","moveIndex":12},{"boardSnapshot":[5,4,1,3,0,1,11,4,1,3,2,1,1,11],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":8},{"boardSnapshot":[5,4,1,3,0,0,12,4,1,3,2,1,1,11],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-8","moveIndex":5},{"boardSnapshot":[5,4,1,1,1,0,17,0,1,3,2,1,1,11],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":7}}],"playerId":"kc:player1-8","moveIndex":3},{"boardSnapshot":[5,4,1,1,1,0,17,0,1,3,2,0,2,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":11},{"boardSnapshot":[5,4,1,0,2,0,17,0,1,3,2,0,2,11],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":3},{"boardSnapshot":[5,0,1,0,2,0,17,0,1,3,1,0,2,16],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[1,1,2,1,3,0,17,0,1,3,1,0,2,16],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":0},{"boardSnapshot":[1,0,2,1,3,0,17,0,1,3,0,0,2,18],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":1}}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[1,0,2,0,4,0,17,0,1,3,0,0,2,18],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":3},{"boardSnapshot":[1,0,2,0,4,0,17,0,1,1,1,1,2,18],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":9},{"boardSnapshot":[1,0,2,0,1,1,18,1,1,1,1,1,2,18],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[1,0,2,0,1,1,18,1,1,1,0,2,2,18],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":10},{"boardSnapshot":[0,0,2,0,1,1,21,1,1,1,0,0,2,18],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}}],"playerId":"kc:player1-8","moveIndex":0},{"boardSnapshot":[0,0,0,0,1,1,21,1,1,0,0,0,2,21],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-8","moveIndex":9},{"boardSnapshot":[0,0,0,0,0,2,21,1,1,0,0,0,2,21],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-8","moveIndex":4},{"boardSnapshot":[0,0,0,0,0,2,21,1,0,1,0,0,2,21],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-8","moveIndex":8},{"boardSnapshot":[0,0,0,0,0,1,22,1,0,1,0,0,2,21],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-8","moveIndex":5},{"boardSnapshot":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,9,12]}}],"playerId":"kc:player1-8","moveIndex":5}]}},{"id":"game-9","player1Id":"kc:player1-9","player2Id":"kc:player2-9","turnPlayerId":"kc:player2-9","moves":[{"playerId":"kc:player2-9","pitIndex":4,"board":[5,4,4,4,4,4,0,4,4,4,4,1,5,1],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":2,"board":[5,4,1,5,5,5,0,4,4,4,4,1,5,1],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":1,"board":[5,4,1,5,5,5,0,4,1,5,5,2,5,1],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":0,"board":[1,5,2,6,6,5,0,4,1,5,5,2,5,1],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":14,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[1,5,2,6,6,5,0,4,1,5,5,2,5,1],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":2,"board":[1,5,2,6,6,5,0,4,1,1,6,3,6,2],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":2,"board":[1,5,2,6,6,5,0,4,1,0,7,3,6,2],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":1,"board":[1,1,3,7,7,6,0,4,1,0,7,3,6,2],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":5,"board":[2,2,4,0,7,6,0,4,1,0,7,3,1,11],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":1,"board":[2,1,5,0,7,6,0,4,1,0,7,3,1,11],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":3,"board":[3,2,0,0,7,6,0,4,1,0,1,4,2,18],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":1,"board":[3,1,0,0,7,6,2,4,1,0,0,4,2,18],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":1,"board":[3,1,0,0,7,6,2,4,0,1,0,4,2,18],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":0,"board":[1,2,1,0,7,6,2,4,0,1,0,4,2,18],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":0,"board":[1,2,0,0,7,6,2,1,1,2,0,4,2,20],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":0,"board":[0,3,0,0,7,6,2,1,1,2,0,4,2,20],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":0,"board":[0,3,0,0,7,6,2,0,2,2,0,4,2,20],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":5,"board":[0,3,0,0,7,1,3,1,3,3,1,4,2,20],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":2,"board":[0,3,0,0,7,1,3,1,3,1,2,5,2,20],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":5,"board":[0,3,0,0,7,0,4,1,3,1,2,5,2,20],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":1,"board":[0,1,1,0,7,0,6,1,3,0,2,5,2,20],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":1,"board":[0,1,1,0,7,0,6,1,1,1,3,5,2,20],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":4,"board":[0,1,1,0,1,1,11,2,2,2,0,5,2,20],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":2,"board":[0,1,0,0,1,1,11,2,2,1,0,5,2,22],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":3,"error":"StoneNotFound => index : 3 stoneCount = 0","board":[0,1,0,0,1,1,11,2,2,1,0,5,2,22],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":5,"board":[0,1,0,0,1,0,12,2,2,1,0,5,2,22],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":4,"board":[0,1,0,0,0,0,15,0,2,1,0,5,2,22],"turnPlayerId":"kc:player2-9","state":"playing"},{"playerId":"kc:player2-9","pitIndex":4,"board":[1,0,0,0,0,0,15,0,2,1,0,1,3,25],"turnPlayerId":"kc:player1-9","state":"playing"},{"playerId":"kc:player1-9","pitIndex":0,"board":[0,0,0,0,0,0,23,0,0,0,0,0,0,25],"turnPlayerId":"kc:player2-9","state":"ended"},{"playerId":"kc:player2-9","pitIndex":0,"board":[0,0,0,0,0,0,23,0,0,0,0,0,0,25],"turnPlayerId":"kc:player2-9","state":"ended"}],"wonPlayerId":"kc:player2-9","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,23,0,0,0,0,0,0,25],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}},{"index":1,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[8,9,12]}}],"playerId":"kc:player1-9","moveIndex":0},"id":"game-9","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":23},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":25}]},"player1Id":"kc:player1-9","player2Id":"kc:player2-9","turnPlayerId":"kc:player2-9","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[5,4,4,4,4,4,0,4,4,4,4,1,5,1],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":11},{"boardSnapshot":[5,4,1,5,5,5,0,4,4,4,4,1,5,1],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":2},{"boardSnapshot":[5,4,1,5,5,5,0,4,1,5,5,2,5,1],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":8},{"boardSnapshot":[1,5,2,6,6,5,0,4,1,5,5,2,5,1],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":0},{"boardSnapshot":[1,5,2,6,6,5,0,4,1,1,6,3,6,2],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-9","moveIndex":9},{"boardSnapshot":[1,5,2,6,6,5,0,4,1,0,7,3,6,2],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":9},{"boardSnapshot":[1,1,3,7,7,6,0,4,1,0,7,3,6,2],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":1},{"boardSnapshot":[2,2,4,0,7,6,0,4,1,0,7,3,1,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":3,"bankIndex":13}}],"playerId":"kc:player2-9","moveIndex":12},{"boardSnapshot":[2,1,5,0,7,6,0,4,1,0,7,3,1,11],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":1},{"boardSnapshot":[3,2,0,0,7,6,0,4,1,0,1,4,2,18],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":2,"bankIndex":13}}],"playerId":"kc:player2-9","moveIndex":10},{"boardSnapshot":[3,1,0,0,7,6,2,4,1,0,0,4,2,18],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-9","moveIndex":1},{"boardSnapshot":[3,1,0,0,7,6,2,4,0,1,0,4,2,18],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":8},{"boardSnapshot":[1,2,1,0,7,6,2,4,0,1,0,4,2,18],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":0},{"boardSnapshot":[1,2,0,0,7,6,2,1,1,2,0,4,2,20],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-9","moveIndex":7},{"boardSnapshot":[0,3,0,0,7,6,2,1,1,2,0,4,2,20],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":0},{"boardSnapshot":[0,3,0,0,7,6,2,0,2,2,0,4,2,20],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":7},{"boardSnapshot":[0,3,0,0,7,1,3,1,3,3,1,4,2,20],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-9","moveIndex":5},{"boardSnapshot":[0,3,0,0,7,1,3,1,3,1,2,5,2,20],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":9},{"boardSnapshot":[0,3,0,0,7,0,4,1,3,1,2,5,2,20],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-9","moveIndex":5},{"boardSnapshot":[0,1,1,0,7,0,6,1,3,0,2,5,2,20],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-9","moveIndex":1},{"boardSnapshot":[0,1,1,0,7,0,6,1,1,1,3,5,2,20],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-9","moveIndex":8},{"boardSnapshot":[0,1,1,0,1,1,11,2,2,2,0,5,2,20],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":10,"bankIndex":6}}],"playerId":"kc:player1-9","moveIndex":4},{"boardSnapshot":[0,1,0,0,1,1,11,2,2,1,0,5,2,22],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-9","moveIndex":9},{"boardSnapshot":[0,1,0,0,1,0,12,2,2,1,0,5,2,22],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-9","moveIndex":5},{"boardSnapshot":[0,1,0,0,0,0,15,0,2,1,0,5,2,22],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":7}}],"playerId":"kc:player1-9","moveIndex":4},{"boardSnapshot":[1,0,0,0,0,0,15,0,2,1,0,1,3,25],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-9","moveIndex":11},{"boardSnapshot":[0,0,0,0,0,0,23,0,0,0,0,0,0,25],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}},{"index":1,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[8,9,12]}}],"playerId":"kc:player1-9","moveIndex":0}]}},{"id":"game-10","player1Id":"kc:player1-10","player2Id":"kc:player2-10","turnPlayerId":"kc:player2-10","moves":[{"playerId":"kc:player2-10","pitIndex":3,"board":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":5,"board":[5,5,5,4,4,4,0,4,4,4,1,5,1,2],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":0,"board":[1,6,6,5,5,4,0,4,4,4,1,5,1,2],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[1,6,6,5,5,4,0,4,4,4,0,6,1,2],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":2,"board":[1,6,1,6,6,5,1,5,4,4,0,6,1,2],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":2,"board":[1,6,1,6,6,5,1,5,4,1,1,7,2,2],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":4,"board":[1,6,1,6,1,6,4,6,5,0,1,7,2,2],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":0,"board":[1,6,1,6,1,6,4,1,6,1,2,8,3,2],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":2,"board":[1,6,0,7,1,6,4,1,6,1,2,8,3,2],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[1,6,0,7,1,6,4,1,6,1,1,9,3,2],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"board":[1,6,0,1,2,7,7,2,7,0,1,9,3,2],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[1,6,0,1,2,7,7,2,7,0,0,10,3,2],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":5,"board":[1,6,0,1,2,1,8,3,8,1,1,11,3,2],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":1,"board":[2,7,0,1,2,1,8,3,1,2,2,12,4,3],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":1,"board":[2,1,1,2,3,2,13,0,1,2,2,12,4,3],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":4,"board":[3,2,2,3,4,3,13,1,2,3,2,1,5,4],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,2,2,3,4,3,13,1,2,3,2,1,5,4],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":4,"board":[3,2,2,3,1,4,16,0,2,3,2,1,5,4],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[3,2,2,3,1,4,16,0,2,3,1,2,5,4],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":5,"board":[3,2,2,3,1,1,17,1,3,3,1,2,5,4],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":5,"board":[4,3,3,3,1,1,17,1,3,3,1,2,1,5],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":2,"board":[4,3,1,4,2,1,17,1,3,3,1,2,1,5],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":0,"board":[4,3,1,4,2,1,17,0,4,3,1,2,1,5],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":2,"board":[4,3,0,5,2,1,17,0,4,3,1,2,1,5],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":2,"board":[4,3,0,5,2,1,17,0,4,1,2,3,1,5],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":4,"board":[4,3,0,5,1,2,17,0,4,1,2,3,1,5],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":4,"board":[4,3,0,5,1,2,17,0,4,1,2,1,2,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[4,3,0,5,1,2,17,0,4,1,1,2,2,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":5,"board":[4,3,0,5,1,1,18,0,4,1,1,2,2,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"board":[4,3,0,1,2,2,19,1,4,1,1,2,2,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":0,"board":[4,3,0,1,2,2,19,0,5,1,1,2,2,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"board":[4,3,0,0,3,2,19,0,5,1,1,2,2,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":2,"board":[4,3,0,0,3,2,19,0,5,0,2,2,2,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":1,"board":[4,1,1,1,3,2,19,0,5,0,2,2,2,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":1,"board":[4,1,1,1,3,2,19,0,1,1,3,3,3,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"board":[4,1,1,0,4,2,19,0,1,1,3,3,3,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":1,"board":[4,1,1,0,4,2,19,0,0,2,3,3,3,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":1,"board":[4,0,2,0,4,2,19,0,0,2,3,3,3,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[4,0,2,0,4,2,19,0,0,2,3,3,3,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[4,0,2,0,4,2,19,0,0,2,1,4,4,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"error":"StoneNotFound => index : 3 stoneCount = 0","board":[4,0,2,0,4,2,19,0,0,2,1,4,4,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":2,"board":[4,0,1,0,4,2,22,0,0,0,1,4,4,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"board":[4,0,1,0,4,2,22,0,0,0,0,5,4,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":1,"error":"StoneNotFound => index : 1 stoneCount = 0","board":[4,0,1,0,4,2,22,0,0,0,0,5,4,6],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":0,"board":[1,1,2,1,4,2,22,0,0,0,0,5,4,6],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":5,"board":[2,0,2,1,4,2,22,0,0,0,0,5,1,9],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"board":[2,0,2,0,5,2,22,0,0,0,0,5,1,9],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":5,"board":[2,0,2,0,5,2,22,0,0,0,0,5,0,10],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":4,"board":[3,1,2,0,5,2,22,0,0,0,0,1,1,11],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[3,1,2,0,5,2,22,0,0,0,0,1,1,11],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":2,"board":[3,1,1,1,5,2,22,0,0,0,0,1,1,11],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":4,"board":[3,1,1,1,5,2,22,0,0,0,0,0,2,11],"turnPlayerId":"kc:player1-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":3,"board":[3,1,1,0,6,2,22,0,0,0,0,0,2,11],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player1-10","pitIndex":13,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[3,1,1,0,6,2,22,0,0,0,0,0,2,11],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":5,"board":[3,1,1,0,6,2,22,0,0,0,0,0,1,12],"turnPlayerId":"kc:player2-10","state":"playing"},{"playerId":"kc:player2-10","pitIndex":5,"board":[0,0,0,0,0,0,22,0,0,0,0,0,0,26],"turnPlayerId":"kc:player2-10","state":"ended"},{"playerId":"kc:player2-10","pitIndex":5,"board":[0,0,0,0,0,0,22,0,0,0,0,0,0,26],"turnPlayerId":"kc:player2-10","state":"ended"}],"wonPlayerId":"kc:player2-10","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,22,0,0,0,0,0,0,26],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":13,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[0,1,2,4,5]}}],"playerId":"kc:player2-10","moveIndex":12},"id":"game-10","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":22},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":26}]},"player1Id":"kc:player1-10","player2Id":"kc:player2-10","turnPlayerId":"kc:player2-10","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,4,4,4,1,5,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[5,5,5,4,4,4,0,4,4,4,1,5,1,2],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":12},{"boardSnapshot":[1,6,6,5,5,4,0,4,4,4,1,5,1,2],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":0},{"boardSnapshot":[1,6,6,5,5,4,0,4,4,4,0,6,1,2],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[1,6,1,6,6,5,1,5,4,4,0,6,1,2],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":2},{"boardSnapshot":[1,6,1,6,6,5,1,5,4,1,1,7,2,2],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":9},{"boardSnapshot":[1,6,1,6,1,6,4,6,5,0,1,7,2,2],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":9,"bankIndex":6}}],"playerId":"kc:player1-10","moveIndex":4},{"boardSnapshot":[1,6,1,6,1,6,4,1,6,1,2,8,3,2],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":7},{"boardSnapshot":[1,6,0,7,1,6,4,1,6,1,2,8,3,2],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":2},{"boardSnapshot":[1,6,0,7,1,6,4,1,6,1,1,9,3,2],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[1,6,0,1,2,7,7,2,7,0,1,9,3,2],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":9,"bankIndex":6}}],"playerId":"kc:player1-10","moveIndex":3},{"boardSnapshot":[1,6,0,1,2,7,7,2,7,0,0,10,3,2],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[1,6,0,1,2,1,8,3,8,1,1,11,3,2],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":5},{"boardSnapshot":[2,7,0,1,2,1,8,3,1,2,2,12,4,3],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":8},{"boardSnapshot":[2,1,1,2,3,2,13,0,1,2,2,12,4,3],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-10","moveIndex":1},{"boardSnapshot":[3,2,2,3,4,3,13,1,2,3,2,1,5,4],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":11},{"boardSnapshot":[3,2,2,3,1,4,16,0,2,3,2,1,5,4],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":7,"bankIndex":6}}],"playerId":"kc:player1-10","moveIndex":4},{"boardSnapshot":[3,2,2,3,1,4,16,0,2,3,1,2,5,4],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[3,2,2,3,1,1,17,1,3,3,1,2,5,4],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":5},{"boardSnapshot":[4,3,3,3,1,1,17,1,3,3,1,2,1,5],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":12},{"boardSnapshot":[4,3,1,4,2,1,17,1,3,3,1,2,1,5],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":2},{"boardSnapshot":[4,3,1,4,2,1,17,0,4,3,1,2,1,5],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":7},{"boardSnapshot":[4,3,0,5,2,1,17,0,4,3,1,2,1,5],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":2},{"boardSnapshot":[4,3,0,5,2,1,17,0,4,1,2,3,1,5],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":9},{"boardSnapshot":[4,3,0,5,1,2,17,0,4,1,2,3,1,5],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":4},{"boardSnapshot":[4,3,0,5,1,2,17,0,4,1,2,1,2,6],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-10","moveIndex":11},{"boardSnapshot":[4,3,0,5,1,2,17,0,4,1,1,2,2,6],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[4,3,0,5,1,1,18,0,4,1,1,2,2,6],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-10","moveIndex":5},{"boardSnapshot":[4,3,0,1,2,2,19,1,4,1,1,2,2,6],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":3},{"boardSnapshot":[4,3,0,1,2,2,19,0,5,1,1,2,2,6],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":7},{"boardSnapshot":[4,3,0,0,3,2,19,0,5,1,1,2,2,6],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":3},{"boardSnapshot":[4,3,0,0,3,2,19,0,5,0,2,2,2,6],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":9},{"boardSnapshot":[4,1,1,1,3,2,19,0,5,0,2,2,2,6],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":1},{"boardSnapshot":[4,1,1,1,3,2,19,0,1,1,3,3,3,6],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":8},{"boardSnapshot":[4,1,1,0,4,2,19,0,1,1,3,3,3,6],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":3},{"boardSnapshot":[4,1,1,0,4,2,19,0,0,2,3,3,3,6],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":8},{"boardSnapshot":[4,0,2,0,4,2,19,0,0,2,3,3,3,6],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":1},{"boardSnapshot":[4,0,2,0,4,2,19,0,0,2,1,4,4,6],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[4,0,1,0,4,2,22,0,0,0,1,4,4,6],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-10","moveIndex":2},{"boardSnapshot":[4,0,1,0,4,2,22,0,0,0,0,5,4,6],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":10},{"boardSnapshot":[1,1,2,1,4,2,22,0,0,0,0,5,4,6],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":0},{"boardSnapshot":[2,0,2,1,4,2,22,0,0,0,0,5,1,9],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-10","moveIndex":12},{"boardSnapshot":[2,0,2,0,5,2,22,0,0,0,0,5,1,9],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":3},{"boardSnapshot":[2,0,2,0,5,2,22,0,0,0,0,5,0,10],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-10","moveIndex":12},{"boardSnapshot":[3,1,2,0,5,2,22,0,0,0,0,1,1,11],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":11},{"boardSnapshot":[3,1,1,1,5,2,22,0,0,0,0,1,1,11],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":2},{"boardSnapshot":[3,1,1,1,5,2,22,0,0,0,0,0,2,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-10","moveIndex":11},{"boardSnapshot":[3,1,1,0,6,2,22,0,0,0,0,0,2,11],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-10","moveIndex":3},{"boardSnapshot":[3,1,1,0,6,2,22,0,0,0,0,0,1,12],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-10","moveIndex":12},{"boardSnapshot":[0,0,0,0,0,0,22,0,0,0,0,0,0,26],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":13,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[0,1,2,4,5]}}],"playerId":"kc:player2-10","moveIndex":12}]}},{"id":"game-11","player1Id":"kc:player1-11","player2Id":"kc:player2-11","turnPlayerId":"kc:player1-11","moves":[{"playerId":"kc:player1-11","pitIndex":3,"board":[4,4,4,1,5,5,1,4,4,4,4,4,4,0],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":0,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[4,4,4,1,5,5,1,4,4,4,4,4,4,0],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":4,"board":[4,4,4,1,1,6,2,5,5,4,4,4,4,0],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":3,"board":[4,4,4,1,1,6,2,5,5,4,1,5,5,1],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":0,"board":[4,4,4,1,1,6,2,1,6,5,2,6,5,1],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":1,"board":[4,1,5,2,2,6,2,1,6,5,2,6,5,1],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":4,"board":[5,2,0,2,2,6,2,1,6,5,2,1,6,8],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":3,"board":[5,2,0,1,3,6,2,1,6,5,2,1,6,8],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":5,"board":[6,3,1,0,3,6,2,1,6,5,2,1,1,11],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":1,"board":[6,1,2,0,3,6,8,1,6,0,2,1,1,11],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":2,"error":"StoneNotFound => index : 9 stoneCount = 0","board":[6,1,2,0,3,6,8,1,6,0,2,1,1,11],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":1,"board":[6,1,2,0,3,6,8,1,1,1,3,2,2,12],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":2,"board":[6,1,2,0,3,6,8,1,1,0,4,2,2,12],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":1,"board":[6,0,3,0,3,6,8,1,1,0,4,2,2,12],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":1,"board":[6,0,3,0,3,6,8,1,0,1,4,2,2,12],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":4,"board":[6,0,3,0,1,7,9,1,0,1,4,2,2,12],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":2,"board":[6,0,1,1,2,7,9,1,0,1,4,2,2,12],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":2,"board":[6,0,1,1,2,7,9,1,0,0,5,2,2,12],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":2,"board":[6,0,0,2,2,7,9,1,0,0,5,2,2,12],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":0,"board":[6,0,0,2,0,7,9,0,0,0,5,2,2,15],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":3,"board":[6,0,0,1,1,7,9,0,0,0,5,2,2,15],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":3,"board":[7,0,0,1,1,7,9,0,0,0,1,3,3,16],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":4,"board":[7,0,0,1,0,8,9,0,0,0,1,3,3,16],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":3,"board":[7,0,0,1,0,8,9,0,0,0,0,4,3,16],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":3,"board":[7,0,0,0,1,8,9,0,0,0,0,4,3,16],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":0,"error":"StoneNotFound => index : 7 stoneCount = 0","board":[7,0,0,0,1,8,9,0,0,0,0,4,3,16],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":4,"board":[0,0,0,0,1,8,9,0,0,0,0,1,4,25],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":4,"board":[0,0,0,0,0,9,9,0,0,0,0,1,4,25],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":5,"board":[1,1,0,0,0,9,9,0,0,0,0,1,1,26],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":0,"board":[0,2,0,0,0,9,9,0,0,0,0,1,1,26],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":5,"board":[0,2,0,0,0,9,9,0,0,0,0,1,0,27],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":4,"board":[0,2,0,0,0,9,9,0,0,0,0,0,1,27],"turnPlayerId":"kc:player1-11","state":"playing"},{"playerId":"kc:player1-11","pitIndex":1,"board":[0,1,1,0,0,9,9,0,0,0,0,0,1,27],"turnPlayerId":"kc:player2-11","state":"playing"},{"playerId":"kc:player2-11","pitIndex":5,"board":[0,0,0,0,0,0,9,0,0,0,0,0,0,39],"turnPlayerId":"kc:player2-11","state":"ended"},{"playerId":"kc:player1-11","pitIndex":4,"board":[0,0,0,0,0,0,9,0,0,0,0,0,0,39],"turnPlayerId":"kc:player2-11","state":"ended"}],"wonPlayerId":"kc:player2-11","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,9,0,0,0,0,0,0,39],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":13,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[1,2,5]}}],"playerId":"kc:player2-11","moveIndex":12},"id":"game-11","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":9},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":39}]},"player1Id":"kc:player1-11","player2Id":"kc:player2-11","turnPlayerId":"kc:player2-11","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,1,5,5,1,4,4,4,4,4,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-11","moveIndex":3},{"boardSnapshot":[4,4,4,1,1,6,2,5,5,4,4,4,4,0],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":4},{"boardSnapshot":[4,4,4,1,1,6,2,5,5,4,1,5,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-11","moveIndex":10},{"boardSnapshot":[4,4,4,1,1,6,2,1,6,5,2,6,5,1],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":7},{"boardSnapshot":[4,1,5,2,2,6,2,1,6,5,2,6,5,1],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":1},{"boardSnapshot":[5,2,0,2,2,6,2,1,6,5,2,1,6,8],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":2,"bankIndex":13}}],"playerId":"kc:player2-11","moveIndex":11},{"boardSnapshot":[5,2,0,1,3,6,2,1,6,5,2,1,6,8],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":3},{"boardSnapshot":[6,3,1,0,3,6,2,1,6,5,2,1,1,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":3,"bankIndex":13}}],"playerId":"kc:player2-11","moveIndex":12},{"boardSnapshot":[6,1,2,0,3,6,8,1,6,0,2,1,1,11],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}}],"playerId":"kc:player1-11","moveIndex":1},{"boardSnapshot":[6,1,2,0,3,6,8,1,1,1,3,2,2,12],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-11","moveIndex":8},{"boardSnapshot":[6,1,2,0,3,6,8,1,1,0,4,2,2,12],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":9},{"boardSnapshot":[6,0,3,0,3,6,8,1,1,0,4,2,2,12],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":1},{"boardSnapshot":[6,0,3,0,3,6,8,1,0,1,4,2,2,12],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":8},{"boardSnapshot":[6,0,3,0,1,7,9,1,0,1,4,2,2,12],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-11","moveIndex":4},{"boardSnapshot":[6,0,1,1,2,7,9,1,0,1,4,2,2,12],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":2},{"boardSnapshot":[6,0,1,1,2,7,9,1,0,0,5,2,2,12],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":9},{"boardSnapshot":[6,0,0,2,2,7,9,1,0,0,5,2,2,12],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":2},{"boardSnapshot":[6,0,0,2,0,7,9,0,0,0,5,2,2,15],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-11","moveIndex":7},{"boardSnapshot":[6,0,0,1,1,7,9,0,0,0,5,2,2,15],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":3},{"boardSnapshot":[7,0,0,1,1,7,9,0,0,0,1,3,3,16],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":10},{"boardSnapshot":[7,0,0,1,0,8,9,0,0,0,1,3,3,16],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":4},{"boardSnapshot":[7,0,0,1,0,8,9,0,0,0,0,4,3,16],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":10},{"boardSnapshot":[7,0,0,0,1,8,9,0,0,0,0,4,3,16],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":3},{"boardSnapshot":[0,0,0,0,1,8,9,0,0,0,0,1,4,25],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-11","moveIndex":11},{"boardSnapshot":[0,0,0,0,0,9,9,0,0,0,0,1,4,25],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":4},{"boardSnapshot":[1,1,0,0,0,9,9,0,0,0,0,1,1,26],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":12},{"boardSnapshot":[0,2,0,0,0,9,9,0,0,0,0,1,1,26],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":0},{"boardSnapshot":[0,2,0,0,0,9,9,0,0,0,0,1,0,27],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-11","moveIndex":12},{"boardSnapshot":[0,2,0,0,0,9,9,0,0,0,0,0,1,27],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-11","moveIndex":11},{"boardSnapshot":[0,1,1,0,0,9,9,0,0,0,0,0,1,27],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-11","moveIndex":1},{"boardSnapshot":[0,0,0,0,0,0,9,0,0,0,0,0,0,39],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":13,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[1,2,5]}}],"playerId":"kc:player2-11","moveIndex":12}]}},{"id":"game-12","player1Id":"kc:player1-12","player2Id":"kc:player2-12","turnPlayerId":"kc:player2-12","moves":[{"playerId":"kc:player2-12","pitIndex":0,"board":[4,4,4,4,4,4,0,1,5,5,5,4,4,0],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":0,"board":[1,5,5,5,4,4,0,1,5,5,5,4,4,0],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":0,"board":[1,5,5,5,4,4,0,0,6,5,5,4,4,0],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":3,"board":[1,5,5,1,5,5,1,1,6,5,5,4,4,0],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":3,"board":[0,5,5,1,5,5,1,1,6,5,1,5,5,3],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":2,"board":[0,5,1,2,6,6,2,1,6,5,1,5,5,3],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":3,"board":[0,5,1,1,7,6,2,1,6,5,1,5,5,3],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":3,"board":[0,5,1,1,7,6,2,1,6,5,0,6,5,3],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":1,"board":[0,1,2,2,8,7,2,1,6,5,0,6,5,3],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[0,1,2,2,8,7,2,1,6,1,1,7,6,4],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[0,1,2,2,8,7,2,1,6,0,2,7,6,4],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":4,"board":[0,1,2,2,1,8,11,2,7,1,3,0,6,4],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":0,"board":[0,1,2,2,1,8,11,1,8,1,3,0,6,4],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":1,"board":[0,0,3,2,1,8,11,1,8,1,3,0,6,4],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":1,"board":[1,1,3,2,1,8,11,1,1,2,4,1,7,5],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":2,"board":[1,1,1,3,2,8,11,1,1,2,4,1,7,5],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":1,"board":[1,1,1,3,2,8,11,1,0,3,4,1,7,5],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":1,"board":[1,0,2,3,2,8,11,1,0,3,4,1,7,5],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":0,"board":[1,0,2,3,0,8,11,0,0,3,4,1,7,8],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":0,"board":[0,0,2,3,0,8,13,0,0,3,4,0,7,8],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":3,"board":[0,0,2,3,0,8,13,0,0,3,1,1,8,9],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[0,0,2,3,0,8,13,0,0,1,2,2,8,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":5,"board":[0,0,2,3,0,1,14,1,1,2,3,3,9,9],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":1,"board":[0,0,2,3,0,1,14,1,0,3,3,3,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":2,"board":[0,0,1,4,0,1,14,1,0,3,3,3,9,9],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[0,0,1,4,0,1,14,1,0,1,4,4,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":5,"board":[0,0,1,4,0,0,15,1,0,1,4,4,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":2,"board":[0,0,0,5,0,0,15,1,0,1,4,4,9,9],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":0,"board":[0,0,0,5,0,0,15,0,1,1,4,4,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":3,"board":[0,0,0,1,1,1,16,1,1,1,4,4,9,9],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":0,"board":[0,0,0,1,1,1,16,0,2,1,4,4,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":5,"board":[0,0,0,1,1,0,17,0,2,1,4,4,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,0,1,1,0,17,0,2,1,4,4,9,9],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":4,"board":[0,0,0,1,0,1,17,0,2,1,4,4,9,9],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":3,"board":[0,0,0,1,0,1,17,0,2,1,1,5,10,10],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":4,"board":[1,1,0,1,0,1,17,0,2,1,1,1,11,11],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":3,"board":[1,1,0,0,0,1,20,0,0,1,1,1,11,11],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":4,"board":[1,1,0,0,0,1,20,0,0,1,1,0,12,11],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":0,"board":[0,2,0,0,0,1,20,0,0,1,1,0,12,11],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[0,2,0,0,0,1,20,0,0,0,2,0,12,11],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":5,"board":[0,2,0,0,0,0,21,0,0,0,2,0,12,11],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":1,"board":[0,1,0,0,0,0,24,0,0,0,0,0,12,11],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":5,"board":[1,2,0,1,1,1,24,1,1,1,0,0,1,14],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":5,"board":[1,2,0,1,1,0,25,1,1,1,0,0,1,14],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":1,"board":[1,1,1,1,1,0,25,1,1,1,0,0,1,14],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":5,"board":[1,1,1,1,1,0,25,1,1,1,0,0,0,15],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":1,"board":[1,1,1,1,1,0,25,1,0,2,0,0,0,15],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":2,"board":[1,1,0,2,1,0,25,1,0,2,0,0,0,15],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":0,"board":[1,1,0,2,0,0,25,0,0,2,0,0,0,17],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":1,"board":[1,0,1,2,0,0,25,0,0,2,0,0,0,17],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[1,0,0,2,0,0,25,0,0,1,0,0,0,19],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":3,"board":[1,0,0,1,1,0,25,0,0,1,0,0,0,19],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":2,"board":[1,0,0,1,1,0,25,0,0,0,1,0,0,19],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":3,"board":[1,0,0,0,2,0,25,0,0,0,1,0,0,19],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":3,"board":[1,0,0,0,2,0,25,0,0,0,0,1,0,19],"turnPlayerId":"kc:player1-12","state":"playing"},{"playerId":"kc:player1-12","pitIndex":4,"board":[1,0,0,0,1,1,25,0,0,0,0,1,0,19],"turnPlayerId":"kc:player2-12","state":"playing"},{"playerId":"kc:player2-12","pitIndex":4,"board":[0,0,0,0,0,0,25,0,0,0,0,0,0,23],"turnPlayerId":"kc:player1-12","state":"ended"},{"playerId":"kc:player1-12","pitIndex":0,"board":[0,0,0,0,0,0,25,0,0,0,0,0,0,23],"turnPlayerId":"kc:player1-12","state":"ended"}],"wonPlayerId":"kc:player1-12","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,25,0,0,0,0,0,0,23],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":0}},{"index":12,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[4,5]}}],"playerId":"kc:player2-12","moveIndex":11},"id":"game-12","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":25},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":23}]},"player1Id":"kc:player1-12","player2Id":"kc:player2-12","turnPlayerId":"kc:player1-12","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,4,4,4,0,1,5,5,5,4,4,0],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[1,5,5,5,4,4,0,1,5,5,5,4,4,0],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":0},{"boardSnapshot":[1,5,5,5,4,4,0,0,6,5,5,4,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[1,5,5,1,5,5,1,1,6,5,5,4,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":3},{"boardSnapshot":[0,5,5,1,5,5,1,1,6,5,1,5,5,3],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-12","moveIndex":10},{"boardSnapshot":[0,5,1,2,6,6,2,1,6,5,1,5,5,3],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-12","moveIndex":2},{"boardSnapshot":[0,5,1,1,7,6,2,1,6,5,1,5,5,3],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":3},{"boardSnapshot":[0,5,1,1,7,6,2,1,6,5,0,6,5,3],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":10},{"boardSnapshot":[0,1,2,2,8,7,2,1,6,5,0,6,5,3],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":1},{"boardSnapshot":[0,1,2,2,8,7,2,1,6,1,1,7,6,4],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[0,1,2,2,8,7,2,1,6,0,2,7,6,4],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[0,1,2,2,1,8,11,2,7,1,3,0,6,4],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":11,"bankIndex":6}}],"playerId":"kc:player1-12","moveIndex":4},{"boardSnapshot":[0,1,2,2,1,8,11,1,8,1,3,0,6,4],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[0,0,3,2,1,8,11,1,8,1,3,0,6,4],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":1},{"boardSnapshot":[1,1,3,2,1,8,11,1,1,2,4,1,7,5],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":8},{"boardSnapshot":[1,1,1,3,2,8,11,1,1,2,4,1,7,5],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":2},{"boardSnapshot":[1,1,1,3,2,8,11,1,0,3,4,1,7,5],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":8},{"boardSnapshot":[1,0,2,3,2,8,11,1,0,3,4,1,7,5],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":1},{"boardSnapshot":[1,0,2,3,0,8,11,0,0,3,4,1,7,8],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[0,0,2,3,0,8,13,0,0,3,4,0,7,8],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}}],"playerId":"kc:player1-12","moveIndex":0},{"boardSnapshot":[0,0,2,3,0,8,13,0,0,3,1,1,8,9],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-12","moveIndex":10},{"boardSnapshot":[0,0,2,3,0,8,13,0,0,1,2,2,8,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[0,0,2,3,0,1,14,1,1,2,3,3,9,9],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":5},{"boardSnapshot":[0,0,2,3,0,1,14,1,0,3,3,3,9,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":8},{"boardSnapshot":[0,0,1,4,0,1,14,1,0,3,3,3,9,9],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":2},{"boardSnapshot":[0,0,1,4,0,1,14,1,0,1,4,4,9,9],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[0,0,1,4,0,0,15,1,0,1,4,4,9,9],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-12","moveIndex":5},{"boardSnapshot":[0,0,0,5,0,0,15,1,0,1,4,4,9,9],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":2},{"boardSnapshot":[0,0,0,5,0,0,15,0,1,1,4,4,9,9],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[0,0,0,1,1,1,16,1,1,1,4,4,9,9],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":3},{"boardSnapshot":[0,0,0,1,1,1,16,0,2,1,4,4,9,9],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[0,0,0,1,1,0,17,0,2,1,4,4,9,9],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-12","moveIndex":5},{"boardSnapshot":[0,0,0,1,0,1,17,0,2,1,4,4,9,9],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":4},{"boardSnapshot":[0,0,0,1,0,1,17,0,2,1,1,5,10,10],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-12","moveIndex":10},{"boardSnapshot":[1,1,0,1,0,1,17,0,2,1,1,1,11,11],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":11},{"boardSnapshot":[1,1,0,0,0,1,20,0,0,1,1,1,11,11],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":8}}],"playerId":"kc:player1-12","moveIndex":3},{"boardSnapshot":[1,1,0,0,0,1,20,0,0,1,1,0,12,11],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":11},{"boardSnapshot":[0,2,0,0,0,1,20,0,0,1,1,0,12,11],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":0},{"boardSnapshot":[0,2,0,0,0,1,20,0,0,0,2,0,12,11],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[0,2,0,0,0,0,21,0,0,0,2,0,12,11],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-12","moveIndex":5},{"boardSnapshot":[0,1,0,0,0,0,24,0,0,0,0,0,12,11],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-12","moveIndex":1},{"boardSnapshot":[1,2,0,1,1,1,24,1,1,1,0,0,1,14],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-12","moveIndex":12},{"boardSnapshot":[1,2,0,1,1,0,25,1,1,1,0,0,1,14],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-12","moveIndex":5},{"boardSnapshot":[1,1,1,1,1,0,25,1,1,1,0,0,1,14],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":1},{"boardSnapshot":[1,1,1,1,1,0,25,1,1,1,0,0,0,15],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-12","moveIndex":12},{"boardSnapshot":[1,1,1,1,1,0,25,1,0,2,0,0,0,15],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":8},{"boardSnapshot":[1,1,0,2,1,0,25,1,0,2,0,0,0,15],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":2},{"boardSnapshot":[1,1,0,2,0,0,25,0,0,2,0,0,0,17],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-12","moveIndex":7},{"boardSnapshot":[1,0,1,2,0,0,25,0,0,2,0,0,0,17],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":1},{"boardSnapshot":[1,0,0,2,0,0,25,0,0,1,0,0,0,19],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[1,0,0,1,1,0,25,0,0,1,0,0,0,19],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":3},{"boardSnapshot":[1,0,0,1,1,0,25,0,0,0,1,0,0,19],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":9},{"boardSnapshot":[1,0,0,0,2,0,25,0,0,0,1,0,0,19],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":3},{"boardSnapshot":[1,0,0,0,2,0,25,0,0,0,0,1,0,19],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-12","moveIndex":10},{"boardSnapshot":[1,0,0,0,1,1,25,0,0,0,0,1,0,19],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-12","moveIndex":4},{"boardSnapshot":[0,0,0,0,0,0,25,0,0,0,0,0,0,23],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":0}},{"index":12,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[4,5]}}],"playerId":"kc:player2-12","moveIndex":11}]}},{"id":"game-13","player1Id":"kc:player1-13","player2Id":"kc:player2-13","turnPlayerId":"kc:player1-13","moves":[{"playerId":"kc:player1-13","pitIndex":3,"board":[4,4,4,1,5,5,1,4,4,4,4,4,4,0],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":1,"board":[4,1,5,2,6,5,1,4,4,4,4,4,4,0],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":4,"board":[5,1,5,2,6,5,1,4,4,4,4,1,5,1],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":5,"board":[5,1,5,2,6,1,2,5,5,5,4,1,5,1],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":2,"board":[5,1,5,2,6,1,2,5,5,1,5,2,6,2],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":4,"board":[5,1,5,2,6,1,2,5,5,1,5,1,7,2],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":0,"board":[1,2,6,3,7,1,2,5,5,1,5,1,7,2],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":2,"board":[1,2,6,3,7,1,2,5,5,0,6,1,7,2],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":0,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,2,6,3,7,1,2,5,5,0,6,1,7,2],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":1,"board":[1,1,7,3,7,1,2,5,5,0,6,1,7,2],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":1,"board":[1,1,7,3,7,1,2,5,1,1,7,2,8,2],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,1,7,3,7,1,2,5,1,1,7,2,8,2],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":1,"board":[1,0,8,3,7,1,2,5,1,1,7,2,8,2],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":0,"board":[1,0,8,3,7,1,2,1,2,2,8,3,8,2],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":2,"board":[1,0,1,4,8,2,3,2,3,3,8,3,8,2],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":3,"board":[2,1,2,5,8,2,3,2,3,3,1,4,9,3],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":0,"board":[1,2,2,5,8,2,3,2,3,3,1,4,9,3],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,2,2,5,8,2,3,2,3,3,1,4,9,3],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":4,"board":[0,2,2,5,8,2,3,2,3,3,1,1,10,6],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":2,"board":[0,2,1,6,8,2,3,2,3,3,1,1,10,6],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":3,"board":[0,2,1,6,8,2,3,2,3,3,0,2,10,6],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":3,"board":[0,2,1,1,9,3,8,3,0,3,0,2,10,6],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":5,"board":[1,3,2,2,0,4,8,4,0,3,0,2,1,18],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":2,"board":[1,3,1,3,0,4,8,4,0,3,0,2,1,18],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":4,"board":[1,3,1,3,0,4,8,4,0,3,0,1,2,18],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":1,"board":[1,1,2,4,0,4,8,4,0,3,0,1,2,18],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":0,"board":[1,1,0,4,0,4,8,1,1,4,0,1,2,21],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":1,"board":[1,0,1,4,0,4,8,1,1,4,0,1,2,21],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":5,"board":[1,0,1,4,0,4,8,1,1,4,0,1,1,22],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":2,"board":[1,0,1,4,0,4,8,1,1,1,1,2,2,22],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":3,"board":[1,0,1,1,1,5,9,1,1,1,1,2,2,22],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":4,"board":[1,0,1,1,0,6,9,1,1,1,1,2,2,22],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":2,"board":[1,0,1,1,0,6,9,1,1,0,2,2,2,22],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":5,"board":[1,0,1,1,0,1,10,2,2,1,3,2,2,22],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":7,"error":"Player cannot move reason : isPitIndexCorrect = false isPlayerTurn = false","board":[1,0,1,1,0,1,10,2,2,1,3,2,2,22],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":1,"board":[1,0,1,1,0,1,10,2,1,2,3,2,2,22],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":3,"board":[1,0,1,0,0,1,12,2,0,2,3,2,2,22],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":3,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,0,1,0,0,1,12,2,0,2,3,2,2,22],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":5,"board":[1,0,1,0,0,1,12,2,0,2,3,2,1,23],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":3,"board":[1,0,1,0,0,1,12,2,0,2,1,3,2,23],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":1,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,0,1,0,0,1,12,2,0,2,1,3,2,23],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":5,"board":[1,0,1,0,0,0,13,2,0,2,1,3,2,23],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":3,"error":"StoneNotFound => index : 3 stoneCount = 0","board":[1,0,1,0,0,0,13,2,0,2,1,3,2,23],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":0,"board":[0,0,1,0,0,0,17,2,0,2,1,0,2,23],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":5,"board":[0,0,1,0,0,0,17,2,0,2,1,0,1,24],"turnPlayerId":"kc:player2-13","state":"playing"},{"playerId":"kc:player2-13","pitIndex":2,"board":[0,0,1,0,0,0,17,2,0,1,2,0,1,24],"turnPlayerId":"kc:player1-13","state":"playing"},{"playerId":"kc:player1-13","pitIndex":2,"board":[0,0,0,0,0,0,24,0,0,0,0,0,0,24],"turnPlayerId":"kc:player2-13","state":"ended"},{"playerId":"kc:player2-13","pitIndex":1,"board":[0,0,0,0,0,0,24,0,0,0,0,0,0,24],"turnPlayerId":"kc:player2-13","state":"ended"}],"wonPlayerId":null,"game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,24,0,0,0,0,0,0,24],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}},{"index":3,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,10,12]}}],"playerId":"kc:player1-13","moveIndex":2},"id":"game-13","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":24},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":24}]},"player1Id":"kc:player1-13","player2Id":"kc:player2-13","turnPlayerId":"kc:player2-13","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,4,1,5,5,1,4,4,4,4,4,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-13","moveIndex":3},{"boardSnapshot":[4,1,5,2,6,5,1,4,4,4,4,4,4,0],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":1},{"boardSnapshot":[5,1,5,2,6,5,1,4,4,4,4,1,5,1],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":11},{"boardSnapshot":[5,1,5,2,6,1,2,5,5,5,4,1,5,1],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":5},{"boardSnapshot":[5,1,5,2,6,1,2,5,5,1,5,2,6,2],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-13","moveIndex":9},{"boardSnapshot":[5,1,5,2,6,1,2,5,5,1,5,1,7,2],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":11},{"boardSnapshot":[1,2,6,3,7,1,2,5,5,1,5,1,7,2],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":0},{"boardSnapshot":[1,2,6,3,7,1,2,5,5,0,6,1,7,2],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":9},{"boardSnapshot":[1,1,7,3,7,1,2,5,5,0,6,1,7,2],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":1},{"boardSnapshot":[1,1,7,3,7,1,2,5,1,1,7,2,8,2],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":8},{"boardSnapshot":[1,0,8,3,7,1,2,5,1,1,7,2,8,2],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":1},{"boardSnapshot":[1,0,8,3,7,1,2,1,2,2,8,3,8,2],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":7},{"boardSnapshot":[1,0,1,4,8,2,3,2,3,3,8,3,8,2],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":2},{"boardSnapshot":[2,1,2,5,8,2,3,2,3,3,1,4,9,3],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":10},{"boardSnapshot":[1,2,2,5,8,2,3,2,3,3,1,4,9,3],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":0},{"boardSnapshot":[0,2,2,5,8,2,3,2,3,3,1,1,10,6],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":0,"bankIndex":13}}],"playerId":"kc:player2-13","moveIndex":11},{"boardSnapshot":[0,2,1,6,8,2,3,2,3,3,1,1,10,6],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":2},{"boardSnapshot":[0,2,1,6,8,2,3,2,3,3,0,2,10,6],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":10},{"boardSnapshot":[0,2,1,1,9,3,8,3,0,3,0,2,10,6],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":8,"bankIndex":6}}],"playerId":"kc:player1-13","moveIndex":3},{"boardSnapshot":[1,3,2,2,0,4,8,4,0,3,0,2,1,18],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-13","moveIndex":12},{"boardSnapshot":[1,3,1,3,0,4,8,4,0,3,0,2,1,18],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":2},{"boardSnapshot":[1,3,1,3,0,4,8,4,0,3,0,1,2,18],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":11},{"boardSnapshot":[1,1,2,4,0,4,8,4,0,3,0,1,2,18],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":1},{"boardSnapshot":[1,1,0,4,0,4,8,1,1,4,0,1,2,21],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":2}}],"playerId":"kc:player2-13","moveIndex":7},{"boardSnapshot":[1,0,1,4,0,4,8,1,1,4,0,1,2,21],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":1},{"boardSnapshot":[1,0,1,4,0,4,8,1,1,4,0,1,1,22],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-13","moveIndex":12},{"boardSnapshot":[1,0,1,4,0,4,8,1,1,1,1,2,2,22],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":9},{"boardSnapshot":[1,0,1,1,1,5,9,1,1,1,1,2,2,22],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-13","moveIndex":3},{"boardSnapshot":[1,0,1,1,0,6,9,1,1,1,1,2,2,22],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":4},{"boardSnapshot":[1,0,1,1,0,6,9,1,1,0,2,2,2,22],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":9},{"boardSnapshot":[1,0,1,1,0,1,10,2,2,1,3,2,2,22],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-13","moveIndex":5},{"boardSnapshot":[1,0,1,1,0,1,10,2,1,2,3,2,2,22],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":8},{"boardSnapshot":[1,0,1,0,0,1,12,2,0,2,3,2,2,22],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":8}}],"playerId":"kc:player1-13","moveIndex":3},{"boardSnapshot":[1,0,1,0,0,1,12,2,0,2,3,2,1,23],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-13","moveIndex":12},{"boardSnapshot":[1,0,1,0,0,1,12,2,0,2,1,3,2,23],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":10},{"boardSnapshot":[1,0,1,0,0,0,13,2,0,2,1,3,2,23],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-13","moveIndex":5},{"boardSnapshot":[0,0,1,0,0,0,17,2,0,2,1,0,2,23],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":11}}],"playerId":"kc:player1-13","moveIndex":0},{"boardSnapshot":[0,0,1,0,0,0,17,2,0,2,1,0,1,24],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-13","moveIndex":12},{"boardSnapshot":[0,0,1,0,0,0,17,2,0,1,2,0,1,24],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-13","moveIndex":9},{"boardSnapshot":[0,0,0,0,0,0,24,0,0,0,0,0,0,24],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":9}},{"index":3,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[7,10,12]}}],"playerId":"kc:player1-13","moveIndex":2}]}},{"id":"game-14","player1Id":"kc:player1-14","player2Id":"kc:player2-14","turnPlayerId":"kc:player1-14","moves":[{"playerId":"kc:player1-14","pitIndex":2,"board":[4,4,1,5,5,5,0,4,4,4,4,4,4,0],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[4,4,1,5,5,5,0,4,1,5,5,5,4,0],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[4,4,1,5,5,1,7,5,2,0,5,5,4,0],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[4,4,1,5,5,1,7,1,3,1,6,6,4,0],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":2,"board":[4,4,0,6,5,1,7,1,3,1,6,6,4,0],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[4,4,0,6,5,1,7,0,4,1,6,6,4,0],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":1,"board":[4,1,1,7,6,1,7,0,4,1,6,6,4,0],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[4,1,1,7,6,1,7,0,1,2,7,7,4,0],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":5,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[4,1,1,7,6,1,7,0,1,2,7,7,4,0],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":2,"board":[4,1,0,8,6,1,7,0,1,2,7,7,4,0],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":3,"board":[5,2,1,8,6,1,7,0,1,2,1,8,5,1],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":2,"board":[5,2,0,9,6,1,7,0,1,2,1,8,5,1],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":4,"board":[6,3,1,10,7,1,7,0,1,2,1,1,6,2],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":3,"board":[6,3,1,1,8,2,8,1,2,3,2,2,7,2],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":3,"board":[6,3,1,1,8,2,8,1,2,3,1,3,7,2],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":0,"board":[1,4,2,2,9,3,8,1,2,3,1,3,7,2],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":4,"board":[1,4,2,2,9,3,8,1,2,3,1,1,8,3],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[1,4,2,2,9,3,8,0,3,3,1,1,8,3],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[1,4,2,2,9,1,9,1,3,3,1,1,8,3],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":3,"board":[1,4,2,2,9,1,9,1,3,3,0,2,8,3],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[1,4,2,2,9,0,10,1,3,3,0,2,8,3],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":4,"board":[1,4,2,2,1,1,11,2,4,4,1,3,9,3],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[1,4,2,2,1,1,11,1,5,4,1,3,9,3],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":4,"board":[1,4,2,2,0,2,11,1,5,4,1,3,9,3],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":4,"board":[1,4,2,2,0,2,11,1,5,4,1,1,10,4],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":2,"board":[1,4,2,2,0,2,11,1,5,1,2,2,11,4],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":2,"board":[1,4,1,3,0,2,11,1,5,1,2,2,11,4],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[1,4,1,3,0,2,11,1,1,2,3,3,12,4],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[1,4,1,3,0,1,12,1,1,2,3,3,12,4],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":0,"board":[0,5,1,3,0,1,12,1,1,2,3,3,12,4],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":5,"board":[1,6,2,4,1,2,12,2,2,3,4,3,1,5],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":7,"error":"IndexOutOfRange => index : 14 [start, stop] = [0, 13]","board":[1,6,2,4,1,2,12,2,2,3,4,3,1,5],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":4,"board":[1,6,2,4,0,3,12,2,2,3,4,3,1,5],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":5,"board":[1,6,2,4,0,3,12,2,2,3,4,3,0,6],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":3,"board":[1,6,2,4,0,3,12,2,2,3,1,4,1,7],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":2,"board":[1,6,2,4,0,3,12,2,2,1,2,5,1,7],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":2,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[1,6,2,4,0,3,12,2,2,1,2,5,1,7],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":3,"board":[1,6,2,1,1,4,13,2,2,1,2,5,1,7],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":2,"board":[1,6,1,2,1,4,13,2,2,1,2,5,1,7],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":5,"board":[1,6,1,2,1,4,13,2,2,1,2,5,0,8],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[1,6,1,2,1,4,13,2,1,2,2,5,0,8],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":0,"board":[0,7,1,2,1,4,13,2,1,2,2,5,0,8],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":4,"board":[1,0,1,2,1,4,13,2,1,2,2,1,1,17],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":2,"board":[1,0,0,3,1,4,13,2,1,2,2,1,1,17],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":4,"board":[1,0,0,3,1,4,13,2,1,2,2,0,2,17],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":0,"board":[0,1,0,3,1,4,13,2,1,2,2,0,2,17],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[0,1,0,3,1,4,13,1,2,2,2,0,2,17],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":1,"board":[0,0,0,3,1,4,16,1,2,2,0,0,2,17],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":5,"board":[0,0,0,3,1,4,16,1,2,2,0,0,1,18],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[0,0,0,3,1,4,16,1,1,3,0,0,1,18],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":4,"board":[0,0,0,3,0,5,16,1,1,3,0,0,1,18],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":5,"board":[0,0,0,3,0,5,16,1,1,3,0,0,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[0,0,0,3,0,5,16,0,2,3,0,0,0,19],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[0,0,0,3,0,1,21,1,3,0,0,0,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":6,"error":"Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false","board":[0,0,0,3,0,1,21,1,3,0,0,0,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[0,0,0,3,0,1,21,0,4,0,0,0,0,19],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":3,"board":[0,0,0,1,1,2,21,0,4,0,0,0,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[0,0,0,1,1,2,21,0,1,1,1,1,0,19],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":4,"board":[0,0,0,1,0,3,21,0,1,1,1,1,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":3,"board":[0,0,0,1,0,3,21,0,1,1,0,2,0,19],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[0,0,0,1,0,1,22,1,1,1,0,2,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":1,"board":[0,0,0,1,0,1,22,1,0,2,0,2,0,19],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":3,"board":[0,0,0,0,1,1,22,1,0,2,0,2,0,19],"turnPlayerId":"kc:player2-14","state":"playing"},{"playerId":"kc:player2-14","pitIndex":0,"board":[0,0,0,0,0,1,22,0,0,2,0,2,0,21],"turnPlayerId":"kc:player1-14","state":"playing"},{"playerId":"kc:player1-14","pitIndex":5,"board":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"turnPlayerId":"kc:player1-14","state":"ended"},{"playerId":"kc:player2-14","pitIndex":14,"board":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"turnPlayerId":"kc:player1-14","state":"ended"}],"wonPlayerId":"kc:player1-14","game":{"currentHistoryItem":{"boardSnapshot":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11]}}],"playerId":"kc:player1-14","moveIndex":5},"id":"game-14","board":{"playerPitCount":6,"initialStoneCountInPits":4,"pits":[{"index":0,"stoneCount":0},{"index":1,"stoneCount":0},{"index":2,"stoneCount":0},{"index":3,"stoneCount":0},{"index":4,"stoneCount":0},{"index":5,"stoneCount":0},{"index":6,"stoneCount":27},{"index":7,"stoneCount":0},{"index":8,"stoneCount":0},{"index":9,"stoneCount":0},{"index":10,"stoneCount":0},{"index":11,"stoneCount":0},{"index":12,"stoneCount":0},{"index":13,"stoneCount":21}]},"player1Id":"kc:player1-14","player2Id":"kc:player2-14","turnPlayerId":"kc:player1-14","state":"ended","gameRules":[{},{},{},{}],"history":[{"boardSnapshot":[4,4,1,5,5,5,0,4,4,4,4,4,4,0],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[4,4,1,5,5,5,0,4,1,5,5,5,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[4,4,1,5,5,1,7,5,2,0,5,5,4,0],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":9,"bankIndex":6}}],"playerId":"kc:player1-14","moveIndex":5},{"boardSnapshot":[4,4,1,5,5,1,7,1,3,1,6,6,4,0],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[4,4,0,6,5,1,7,1,3,1,6,6,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[4,4,0,6,5,1,7,0,4,1,6,6,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[4,1,1,7,6,1,7,0,4,1,6,6,4,0],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":1},{"boardSnapshot":[4,1,1,7,6,1,7,0,1,2,7,7,4,0],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[4,1,0,8,6,1,7,0,1,2,7,7,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[5,2,1,8,6,1,7,0,1,2,1,8,5,1],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":10},{"boardSnapshot":[5,2,0,9,6,1,7,0,1,2,1,8,5,1],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[6,3,1,10,7,1,7,0,1,2,1,1,6,2],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":11},{"boardSnapshot":[6,3,1,1,8,2,8,1,2,3,2,2,7,2],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":3},{"boardSnapshot":[6,3,1,1,8,2,8,1,2,3,1,3,7,2],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":10},{"boardSnapshot":[1,4,2,2,9,3,8,1,2,3,1,3,7,2],"gameSteps":[{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":0},{"boardSnapshot":[1,4,2,2,9,3,8,1,2,3,1,1,8,3],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":11},{"boardSnapshot":[1,4,2,2,9,3,8,0,3,3,1,1,8,3],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[1,4,2,2,9,1,9,1,3,3,1,1,8,3],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":5},{"boardSnapshot":[1,4,2,2,9,1,9,1,3,3,0,2,8,3],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":10},{"boardSnapshot":[1,4,2,2,9,0,10,1,3,3,0,2,8,3],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-14","moveIndex":5},{"boardSnapshot":[1,4,2,2,1,1,11,2,4,4,1,3,9,3],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":4},{"boardSnapshot":[1,4,2,2,1,1,11,1,5,4,1,3,9,3],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[1,4,2,2,0,2,11,1,5,4,1,3,9,3],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":4},{"boardSnapshot":[1,4,2,2,0,2,11,1,5,4,1,1,10,4],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":11},{"boardSnapshot":[1,4,2,2,0,2,11,1,5,1,2,2,11,4],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":9},{"boardSnapshot":[1,4,1,3,0,2,11,1,5,1,2,2,11,4],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[1,4,1,3,0,2,11,1,1,2,3,3,12,4],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[1,4,1,3,0,1,12,1,1,2,3,3,12,4],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-14","moveIndex":5},{"boardSnapshot":[0,5,1,3,0,1,12,1,1,2,3,3,12,4],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":0},{"boardSnapshot":[1,6,2,4,1,2,12,2,2,3,4,3,1,5],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":12},{"boardSnapshot":[1,6,2,4,0,3,12,2,2,3,4,3,1,5],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":4},{"boardSnapshot":[1,6,2,4,0,3,12,2,2,3,4,3,0,6],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":12},{"boardSnapshot":[1,6,2,4,0,3,12,2,2,3,1,4,1,7],"gameSteps":[{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":10},{"boardSnapshot":[1,6,2,4,0,3,12,2,2,1,2,5,1,7],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":9},{"boardSnapshot":[1,6,2,1,1,4,13,2,2,1,2,5,1,7],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player1-14","moveIndex":3},{"boardSnapshot":[1,6,1,2,1,4,13,2,2,1,2,5,1,7],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[1,6,1,2,1,4,13,2,2,1,2,5,0,8],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":12},{"boardSnapshot":[1,6,1,2,1,4,13,2,1,2,2,5,0,8],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[0,7,1,2,1,4,13,2,1,2,2,5,0,8],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":0},{"boardSnapshot":[1,0,1,2,1,4,13,2,1,2,2,1,1,17],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":0,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":1,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":1,"bankIndex":13}}],"playerId":"kc:player2-14","moveIndex":11},{"boardSnapshot":[1,0,0,3,1,4,13,2,1,2,2,1,1,17],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":2},{"boardSnapshot":[1,0,0,3,1,4,13,2,1,2,2,0,2,17],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":11},{"boardSnapshot":[0,1,0,3,1,4,13,2,1,2,2,0,2,17],"gameSteps":[{"index":1,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":0},{"boardSnapshot":[0,1,0,3,1,4,13,1,2,2,2,0,2,17],"gameSteps":[{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[0,0,0,3,1,4,16,1,2,2,0,0,2,17],"gameSteps":[{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":10}}],"playerId":"kc:player1-14","moveIndex":1},{"boardSnapshot":[0,0,0,3,1,4,16,1,2,2,0,0,1,18],"gameSteps":[{"index":12,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":12},{"boardSnapshot":[0,0,0,3,1,4,16,1,1,3,0,0,1,18],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[0,0,0,3,0,5,16,1,1,3,0,0,1,18],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":4},{"boardSnapshot":[0,0,0,3,0,5,16,1,1,3,0,0,0,19],"gameSteps":[{"index":13,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":13,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null}],"playerId":"kc:player2-14","moveIndex":12},{"boardSnapshot":[0,0,0,3,0,5,16,0,2,3,0,0,0,19],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[0,0,0,3,0,1,21,1,3,0,0,0,0,19],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_DOUBLE_STONE_IN_PIT","data":{"pitIndex":9,"bankIndex":6}}],"playerId":"kc:player1-14","moveIndex":5},{"boardSnapshot":[0,0,0,3,0,1,21,0,4,0,0,0,0,19],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[0,0,0,1,1,2,21,0,4,0,0,0,0,19],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":3},{"boardSnapshot":[0,0,0,1,1,2,21,0,1,1,1,1,0,19],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":10,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[0,0,0,1,0,3,21,0,1,1,1,1,0,19],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":4},{"boardSnapshot":[0,0,0,1,0,3,21,0,1,1,0,2,0,19],"gameSteps":[{"index":11,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":10},{"boardSnapshot":[0,0,0,1,0,1,22,1,1,1,0,2,0,19],"gameSteps":[{"index":5,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":7,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":5},{"boardSnapshot":[0,0,0,1,0,1,22,1,0,2,0,2,0,19],"gameSteps":[{"index":9,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player2-14","moveIndex":8},{"boardSnapshot":[0,0,0,0,1,1,22,1,0,2,0,2,0,19],"gameSteps":[{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}],"playerId":"kc:player1-14","moveIndex":3},{"boardSnapshot":[0,0,0,0,0,1,22,0,0,2,0,2,0,21],"gameSteps":[{"index":8,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":8,"type":"GAME_STEP_LAST_STONE_IN_EMPTY_PIT","data":{"oppositeIndex":4}}],"playerId":"kc:player2-14","moveIndex":7},{"boardSnapshot":[0,0,0,0,0,0,27,0,0,0,0,0,0,21],"gameSteps":[{"index":6,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_LAST_STONE_IN_BANK","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[9,11]}}],"playerId":"kc:player1-14","moveIndex":5}]}}]} diff --git a/apps/backend/src/util/key_factory.ts b/apps/backend/src/util/key_factory.ts deleted file mode 100644 index fd05c2f..0000000 --- a/apps/backend/src/util/key_factory.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - - -export const generateKey = ()=>{ - return uuidv4(); -} \ No newline at end of file diff --git a/apps/backend/tests/mancala.test.ts b/apps/backend/tests/mancala.test.ts deleted file mode 100644 index 3ff4b52..0000000 --- a/apps/backend/tests/mancala.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CommonMancalaGame, MancalaGame } from "mancala.js"; - -function createGame(player1Id: string, player2Id: string): MancalaGame { - return new CommonMancalaGame("0", player1Id, player2Id); -} - -describe("mancala", function () { - const player1 = "0"; - const player2 = "0"; - it("turn test", function () { - const game = createGame(player1, player2); - expect(game.id).toBe("0"); - }); -}); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json deleted file mode 100644 index e1334ed..0000000 --- a/apps/backend/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "module": "commonjs", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "sourceMap": true - }, - "exclude": [ - "tests", - ] -} \ No newline at end of file diff --git a/apps/backend/yarn.lock b/apps/backend/yarn.lock deleted file mode 100644 index 1d46b16..0000000 --- a/apps/backend/yarn.lock +++ /dev/null @@ -1,3737 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@ampproject/remapping@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" - integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== - dependencies: - "@jridgewell/gen-mapping" "^0.1.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789" - integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg== - dependencies: - "@babel/highlight" "^7.16.7" - -"@babel/compat-data@^7.17.10": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.17.10.tgz#711dc726a492dfc8be8220028b1b92482362baab" - integrity sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw== - -"@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.7.2", "@babel/core@^7.8.0": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.17.10.tgz#74ef0fbf56b7dfc3f198fc2d927f4f03e12f4b05" - integrity sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA== - dependencies: - "@ampproject/remapping" "^2.1.0" - "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.17.10" - "@babel/helper-compilation-targets" "^7.17.10" - "@babel/helper-module-transforms" "^7.17.7" - "@babel/helpers" "^7.17.9" - "@babel/parser" "^7.17.10" - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.10" - "@babel/types" "^7.17.10" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.1" - semver "^6.3.0" - -"@babel/generator@^7.17.10", "@babel/generator@^7.7.2": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.17.10.tgz#c281fa35b0c349bbe9d02916f4ae08fc85ed7189" - integrity sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg== - dependencies: - "@babel/types" "^7.17.10" - "@jridgewell/gen-mapping" "^0.1.0" - jsesc "^2.5.1" - -"@babel/helper-compilation-targets@^7.17.10": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz#09c63106d47af93cf31803db6bc49fef354e2ebe" - integrity sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ== - dependencies: - "@babel/compat-data" "^7.17.10" - "@babel/helper-validator-option" "^7.16.7" - browserslist "^4.20.2" - semver "^6.3.0" - -"@babel/helper-environment-visitor@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz#ff484094a839bde9d89cd63cba017d7aae80ecd7" - integrity sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag== - dependencies: - "@babel/types" "^7.16.7" - -"@babel/helper-function-name@^7.17.9": - version "7.17.9" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz#136fcd54bc1da82fcb47565cf16fd8e444b1ff12" - integrity sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg== - dependencies: - "@babel/template" "^7.16.7" - "@babel/types" "^7.17.0" - -"@babel/helper-hoist-variables@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz#86bcb19a77a509c7b77d0e22323ef588fa58c246" - integrity sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg== - dependencies: - "@babel/types" "^7.16.7" - -"@babel/helper-module-imports@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz#25612a8091a999704461c8a222d0efec5d091437" - integrity sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg== - dependencies: - "@babel/types" "^7.16.7" - -"@babel/helper-module-transforms@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz#3943c7f777139e7954a5355c815263741a9c1cbd" - integrity sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw== - dependencies: - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-module-imports" "^7.16.7" - "@babel/helper-simple-access" "^7.17.7" - "@babel/helper-split-export-declaration" "^7.16.7" - "@babel/helper-validator-identifier" "^7.16.7" - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.3" - "@babel/types" "^7.17.0" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.8.0": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" - integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== - -"@babel/helper-simple-access@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz#aaa473de92b7987c6dfa7ce9a7d9674724823367" - integrity sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA== - dependencies: - "@babel/types" "^7.17.0" - -"@babel/helper-split-export-declaration@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz#0b648c0c42da9d3920d85ad585f2778620b8726b" - integrity sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw== - dependencies: - "@babel/types" "^7.16.7" - -"@babel/helper-validator-identifier@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" - integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== - -"@babel/helper-validator-option@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23" - integrity sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ== - -"@babel/helpers@^7.17.9": - version "7.17.9" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.17.9.tgz#b2af120821bfbe44f9907b1826e168e819375a1a" - integrity sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q== - dependencies: - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.9" - "@babel/types" "^7.17.0" - -"@babel/highlight@^7.16.7": - version "7.17.9" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.17.9.tgz#61b2ee7f32ea0454612def4fccdae0de232b73e3" - integrity sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg== - dependencies: - "@babel/helper-validator-identifier" "^7.16.7" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.7", "@babel/parser@^7.17.10": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.10.tgz#873b16db82a8909e0fbd7f115772f4b739f6ce78" - integrity sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ== - -"@babel/plugin-syntax-async-generators@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" - integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-bigint@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" - integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-class-properties@^7.8.3": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - dependencies: - "@babel/helper-plugin-utils" "^7.12.13" - -"@babel/plugin-syntax-import-meta@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" - integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-json-strings@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" - integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" - integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" - integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-numeric-separator@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" - integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-top-level-await@^7.8.3": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.17.10.tgz#80031e6042cad6a95ed753f672ebd23c30933195" - integrity sha512-xJefea1DWXW09pW4Tm9bjwVlPDyYA2it3fWlmEjpYz6alPvTUjL0EOzNzI/FEOyI3r4/J7uVH5UqKgl1TQ5hqQ== - dependencies: - "@babel/helper-plugin-utils" "^7.16.7" - -"@babel/template@^7.16.7", "@babel/template@^7.3.3": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" - integrity sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w== - dependencies: - "@babel/code-frame" "^7.16.7" - "@babel/parser" "^7.16.7" - "@babel/types" "^7.16.7" - -"@babel/traverse@^7.17.10", "@babel/traverse@^7.17.3", "@babel/traverse@^7.17.9", "@babel/traverse@^7.7.2": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.17.10.tgz#1ee1a5ac39f4eac844e6cf855b35520e5eb6f8b5" - integrity sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw== - dependencies: - "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.17.10" - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-function-name" "^7.17.9" - "@babel/helper-hoist-variables" "^7.16.7" - "@babel/helper-split-export-declaration" "^7.16.7" - "@babel/parser" "^7.17.10" - "@babel/types" "^7.17.10" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/types@^7.0.0", "@babel/types@^7.16.7", "@babel/types@^7.17.0", "@babel/types@^7.17.10", "@babel/types@^7.3.0", "@babel/types@^7.3.3": - version "7.17.10" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.10.tgz#d35d7b4467e439fcf06d195f8100e0fea7fc82c4" - integrity sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A== - dependencies: - "@babel/helper-validator-identifier" "^7.16.7" - to-fast-properties "^2.0.0" - -"@bcoe/v8-coverage@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" - integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== - -"@cspotcode/source-map-consumer@0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b" - integrity sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg== - -"@cspotcode/source-map-support@0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz#4789840aa859e46d2f3173727ab707c66bf344f5" - integrity sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA== - dependencies: - "@cspotcode/source-map-consumer" "0.8.0" - -"@istanbuljs/load-nyc-config@^1.0.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" - integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== - dependencies: - camelcase "^5.3.1" - find-up "^4.1.0" - get-package-type "^0.1.0" - js-yaml "^3.13.1" - resolve-from "^5.0.0" - -"@istanbuljs/schema@^0.1.2": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" - integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== - -"@jest/console@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-27.5.1.tgz#260fe7239602fe5130a94f1aa386eff54b014bba" - integrity sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^27.5.1" - jest-util "^27.5.1" - slash "^3.0.0" - -"@jest/core@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-27.5.1.tgz#267ac5f704e09dc52de2922cbf3af9edcd64b626" - integrity sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ== - dependencies: - "@jest/console" "^27.5.1" - "@jest/reporters" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - emittery "^0.8.1" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^27.5.1" - jest-config "^27.5.1" - jest-haste-map "^27.5.1" - jest-message-util "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-resolve-dependencies "^27.5.1" - jest-runner "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - jest-watcher "^27.5.1" - micromatch "^4.0.4" - rimraf "^3.0.0" - slash "^3.0.0" - strip-ansi "^6.0.0" - -"@jest/environment@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-27.5.1.tgz#d7425820511fe7158abbecc010140c3fd3be9c74" - integrity sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA== - dependencies: - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - jest-mock "^27.5.1" - -"@jest/fake-timers@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" - integrity sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ== - dependencies: - "@jest/types" "^27.5.1" - "@sinonjs/fake-timers" "^8.0.1" - "@types/node" "*" - jest-message-util "^27.5.1" - jest-mock "^27.5.1" - jest-util "^27.5.1" - -"@jest/globals@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-27.5.1.tgz#7ac06ce57ab966566c7963431cef458434601b2b" - integrity sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/types" "^27.5.1" - expect "^27.5.1" - -"@jest/reporters@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-27.5.1.tgz#ceda7be96170b03c923c37987b64015812ffec04" - integrity sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw== - dependencies: - "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.2" - graceful-fs "^4.2.9" - istanbul-lib-coverage "^3.0.0" - istanbul-lib-instrument "^5.1.0" - istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" - istanbul-reports "^3.1.3" - jest-haste-map "^27.5.1" - jest-resolve "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - slash "^3.0.0" - source-map "^0.6.0" - string-length "^4.0.1" - terminal-link "^2.0.0" - v8-to-istanbul "^8.1.0" - -"@jest/source-map@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" - integrity sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg== - dependencies: - callsites "^3.0.0" - graceful-fs "^4.2.9" - source-map "^0.6.0" - -"@jest/test-result@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-27.5.1.tgz#56a6585fa80f7cdab72b8c5fc2e871d03832f5bb" - integrity sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag== - dependencies: - "@jest/console" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz#4057e0e9cea4439e544c6353c6affe58d095745b" - integrity sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ== - dependencies: - "@jest/test-result" "^27.5.1" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-runtime "^27.5.1" - -"@jest/transform@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-27.5.1.tgz#6c3501dcc00c4c08915f292a600ece5ecfe1f409" - integrity sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw== - dependencies: - "@babel/core" "^7.1.0" - "@jest/types" "^27.5.1" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" - convert-source-map "^1.4.0" - fast-json-stable-stringify "^2.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-regex-util "^27.5.1" - jest-util "^27.5.1" - micromatch "^4.0.4" - pirates "^4.0.4" - slash "^3.0.0" - source-map "^0.6.1" - write-file-atomic "^3.0.0" - -"@jest/types@^26.6.2": - version "26.6.2" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" - integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^15.0.0" - chalk "^4.0.0" - -"@jest/types@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.5.1.tgz#3c79ec4a8ba61c170bf937bcf9e98a9df175ec80" - integrity sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^16.0.0" - chalk "^4.0.0" - -"@jridgewell/gen-mapping@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" - integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== - dependencies: - "@jridgewell/set-array" "^1.0.0" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@jridgewell/resolve-uri@^3.0.3": - version "3.0.7" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz#30cd49820a962aff48c8fffc5cd760151fca61fe" - integrity sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA== - -"@jridgewell/set-array@^1.0.0": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.1.tgz#36a6acc93987adcf0ba50c66908bd0b70de8afea" - integrity sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ== - -"@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.13" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c" - integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w== - -"@jridgewell/trace-mapping@^0.3.9": - version "0.3.10" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.10.tgz#db436f0917d655393851bc258918c00226c9b183" - integrity sha512-Q0YbBd6OTsXm8Y21+YUSDXupHnodNC2M4O18jtd3iwJ3+vMZNdKGols0a9G6JOK0dcJ3IdUUHoh908ZI6qhk8Q== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@mancala/core@0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@mancala/core/-/core-0.0.2.tgz#c50abb60f0598306cc78f36c799dfd8a9e7c274b" - integrity sha512-wnufrDxNMkdlXFYulxTtTe88Tw79j9RPC7iQh+W/c9V2yTUBoVk2FzdO0Wj9ZV2/nrrOqYI0IchjpoRyl3RDBQ== - dependencies: - "@types/uuid" "^9.0.8" - mancala.js "^0.0.2-beta.3" - tiny-emitter "^2.1.0" - uuid "^10.0.0" - -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - -"@sinonjs/commons@^1.7.0": - version "1.8.3" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" - integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== - dependencies: - type-detect "4.0.8" - -"@sinonjs/fake-timers@^8.0.1": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz#3fdc2b6cb58935b21bfb8d1625eb1300484316e7" - integrity sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg== - dependencies: - "@sinonjs/commons" "^1.7.0" - -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - dependencies: - defer-to-connect "^1.0.1" - -"@tootallnate/once@1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" - integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== - -"@tsconfig/node10@^1.0.7": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" - integrity sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg== - -"@tsconfig/node12@^1.0.7": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.9.tgz#62c1f6dee2ebd9aead80dc3afa56810e58e1a04c" - integrity sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw== - -"@tsconfig/node14@^1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2" - integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg== - -"@tsconfig/node16@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" - integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== - -"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": - version "7.1.19" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" - integrity sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw== - dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" - "@types/babel__generator" "*" - "@types/babel__template" "*" - "@types/babel__traverse" "*" - -"@types/babel__generator@*": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7" - integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg== - dependencies: - "@babel/types" "^7.0.0" - -"@types/babel__template@*": - version "7.4.1" - resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969" - integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g== - dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" - -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": - version "7.17.1" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.17.1.tgz#1a0e73e8c28c7e832656db372b779bfd2ef37314" - integrity sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA== - dependencies: - "@babel/types" "^7.3.0" - -"@types/body-parser@*": - version "1.19.2" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" - integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== - dependencies: - "@types/connect" "*" - "@types/node" "*" - -"@types/connect@*": - version "3.4.35" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" - integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== - dependencies: - "@types/node" "*" - -"@types/cors@^2.8.10": - version "2.8.12" - resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" - integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== - -"@types/express-serve-static-core@^4.17.18": - version "4.17.28" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8" - integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig== - dependencies: - "@types/node" "*" - "@types/qs" "*" - "@types/range-parser" "*" - -"@types/express@^4.17.12": - version "4.17.13" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" - integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== - dependencies: - "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.18" - "@types/qs" "*" - "@types/serve-static" "*" - -"@types/graceful-fs@^4.1.2": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" - integrity sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw== - dependencies: - "@types/node" "*" - -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" - integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== - -"@types/istanbul-lib-report@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== - dependencies: - "@types/istanbul-lib-coverage" "*" - -"@types/istanbul-reports@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" - integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== - dependencies: - "@types/istanbul-lib-report" "*" - -"@types/jest@^26.0.23": - version "26.0.24" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a" - integrity sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w== - dependencies: - jest-diff "^26.0.0" - pretty-format "^26.0.0" - -"@types/mime@^1": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" - integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== - -"@types/morgan@^1.9.3": - version "1.9.3" - resolved "https://registry.yarnpkg.com/@types/morgan/-/morgan-1.9.3.tgz#ae04180dff02c437312bc0cfb1e2960086b2f540" - integrity sha512-BiLcfVqGBZCyNCnCH3F4o2GmDLrpy0HeBVnNlyZG4fo88ZiE9SoiBe3C+2ezuwbjlEyT+PDZ17//TAlRxAn75Q== - dependencies: - "@types/node" "*" - -"@types/node@*": - version "17.0.31" - resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.31.tgz#a5bb84ecfa27eec5e1c802c6bbf8139bdb163a5d" - integrity sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q== - -"@types/node@^15.12.4": - version "15.14.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-15.14.9.tgz#bc43c990c3c9be7281868bbc7b8fdd6e2b57adfa" - integrity sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A== - -"@types/prettier@^2.1.5": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.6.0.tgz#efcbd41937f9ae7434c714ab698604822d890759" - integrity sha512-G/AdOadiZhnJp0jXCaBQU449W2h716OW/EoXeYkCytxKL06X1WCXB4DZpp8TpZ8eyIJVS1cw4lrlkkSYU21cDw== - -"@types/qs@*": - version "6.9.7" - resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" - integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== - -"@types/range-parser@*": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" - integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== - -"@types/serve-static@*": - version "1.13.10" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" - integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ== - dependencies: - "@types/mime" "^1" - "@types/node" "*" - -"@types/stack-utils@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" - integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== - -"@types/uuid@^8.3.0": - version "8.3.4" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" - integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== - -"@types/uuid@^9.0.8": - version "9.0.8" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" - integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== - -"@types/ws@^7.4.5": - version "7.4.7" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" - integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== - dependencies: - "@types/node" "*" - -"@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== - -"@types/yargs@^15.0.0": - version "15.0.14" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.14.tgz#26d821ddb89e70492160b66d10a0eb6df8f6fb06" - integrity sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ== - dependencies: - "@types/yargs-parser" "*" - -"@types/yargs@^16.0.0": - version "16.0.4" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" - integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw== - dependencies: - "@types/yargs-parser" "*" - -abab@^2.0.3, abab@^2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" - integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - -acorn-globals@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" - integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== - dependencies: - acorn "^7.1.1" - acorn-walk "^7.1.1" - -acorn-walk@^7.1.1: - version "7.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" - integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== - -acorn-walk@^8.1.1: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== - -acorn@^7.1.1: - version "7.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" - integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== - -acorn@^8.2.4, acorn@^8.4.1: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== - -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -ansi-align@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" - integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== - dependencies: - string-width "^4.1.0" - -ansi-escapes@^4.2.1: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - -ansi-regex@^5.0.0, ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - -anymatch@^3.0.3, anymatch@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -arg@^4.1.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" - integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= - -babel-jest@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.5.1.tgz#a1bf8d61928edfefd21da27eb86a695bfd691444" - integrity sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg== - dependencies: - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^27.5.1" - chalk "^4.0.0" - graceful-fs "^4.2.9" - slash "^3.0.0" - -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" - test-exclude "^6.0.0" - -babel-plugin-jest-hoist@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz#9be98ecf28c331eb9f5df9c72d6f89deb8181c2e" - integrity sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ== - dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.0.0" - "@types/babel__traverse" "^7.0.6" - -babel-preset-current-node-syntax@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" - integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== - dependencies: - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-bigint" "^7.8.3" - "@babel/plugin-syntax-class-properties" "^7.8.3" - "@babel/plugin-syntax-import-meta" "^7.8.3" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.8.3" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-top-level-await" "^7.8.3" - -babel-preset-jest@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz#91f10f58034cb7989cb4f962b69fa6eef6a6bc81" - integrity sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag== - dependencies: - babel-plugin-jest-hoist "^27.5.1" - babel-preset-current-node-syntax "^1.0.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -basic-auth@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" - integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== - dependencies: - safe-buffer "5.1.2" - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -body-parser@1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" - integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.10.3" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -browser-process-hrtime@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" - integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== - -browserslist@^4.20.2: - version "4.20.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.3.tgz#eb7572f49ec430e054f56d52ff0ebe9be915f8bf" - integrity sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg== - dependencies: - caniuse-lite "^1.0.30001332" - electron-to-chromium "^1.4.118" - escalade "^3.1.1" - node-releases "^2.0.3" - picocolors "^1.0.0" - -bs-logger@0.x: - version "0.2.6" - resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" - integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== - dependencies: - fast-json-stable-stringify "2.x" - -bser@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" - integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== - dependencies: - node-int64 "^0.4.0" - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" - -call-bind@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== - dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - -caniuse-lite@^1.0.30001332: - version "1.0.30001338" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001338.tgz#b5dd7a7941a51a16480bdf6ff82bded1628eec0d" - integrity sha512-1gLHWyfVoRDsHieO+CaeYe7jSo/MT7D7lhaXUiwwbuR5BwQxORs0f1tAwUSQr3YbxRXJvxHM/PA5FfPQRnsPeQ== - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.0.0, chalk@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -char-regex@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" - integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== - -chokidar@^3.5.2: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - -ci-info@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.0.tgz#b4ed1fb6818dea4803a55c623041f9165d2066b2" - integrity sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw== - -cjs-module-lexer@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" - integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== - -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - -clone-response@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" - integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= - dependencies: - mimic-response "^1.0.0" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= - -collect-v8-coverage@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" - integrity sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -configstore@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" - integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== - dependencies: - dot-prop "^5.2.0" - graceful-fs "^4.1.2" - make-dir "^3.0.0" - unique-string "^2.0.0" - write-file-atomic "^3.0.0" - xdg-basedir "^4.0.0" - -content-disposition@0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" - integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== - dependencies: - safe-buffer "~5.1.1" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= - -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - -cors@^2.8.5: - version "2.8.5" - resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" - integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== - dependencies: - object-assign "^4" - vary "^1" - -create-require@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" - integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== - -cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== - -cssom@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" - integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== - -cssom@~0.3.6: - version "0.3.8" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" - integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== - -cssstyle@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" - integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== - dependencies: - cssom "~0.3.6" - -data-urls@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" - integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== - dependencies: - abab "^2.0.3" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@4, debug@^4.1.0, debug@^4.1.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -decimal.js@^10.2.1: - version "10.3.1" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" - integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== - -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= - dependencies: - mimic-response "^1.0.0" - -dedent@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" - integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -deep-is@~0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -deepmerge@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" - integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== - -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= - -depd@2.0.0, depd@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -detect-newline@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" - integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== - -diff-sequences@^26.6.2: - version "26.6.2" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" - integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== - -diff-sequences@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" - integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== - -diff@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== - -domexception@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" - integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== - dependencies: - webidl-conversions "^5.0.0" - -dot-prop@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" - integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== - dependencies: - is-obj "^2.0.0" - -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= - -electron-to-chromium@^1.4.118: - version "1.4.137" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.137.tgz#186180a45617283f1c012284458510cd99d6787f" - integrity sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA== - -emittery@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860" - integrity sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - -escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - -escodegen@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" - integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== - dependencies: - esprima "^4.0.1" - estraverse "^5.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - -esprima@^4.0.0, esprima@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= - -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= - -expect@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74" - integrity sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw== - dependencies: - "@jest/types" "^27.5.1" - jest-get-type "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - -express@^4.17.1: - version "4.18.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" - integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.0" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.10.3" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - -fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@~2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= - -fb-watchman@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" - integrity sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== - dependencies: - bser "2.1.1" - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - -find-up@^4.0.0, find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= - -fsevents@^2.3.2, fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.0.2: - version "1.1.1" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" - integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - -get-package-type@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" - integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== - -get-stream@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" - integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== - dependencies: - pump "^3.0.0" - -get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -got@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" - -graceful-fs@^4.1.2, graceful-fs@^4.2.9: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-symbols@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -html-encoding-sniffer@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" - integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== - dependencies: - whatwg-encoding "^1.0.5" - -html-escaper@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" - integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== - -http-cache-semantics@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== - -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -http-proxy-agent@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" - integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== - dependencies: - "@tootallnate/once" "1" - agent-base "6" - debug "4" - -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -ignore-by-default@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" - integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= - -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= - -import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== - dependencies: - pkg-dir "^4.2.0" - resolve-cwd "^3.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ini@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - -ini@~1.3.0: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - -is-core-module@^2.8.1: - version "2.9.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" - integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== - dependencies: - has "^1.0.3" - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-generator-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" - integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== - -is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" - integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-potential-custom-element-name@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" - integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-typedarray@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= - -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= - -istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" - integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== - -istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz#31d18bdd127f825dd02ea7bfdfd906f8ab840e9f" - integrity sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - -istanbul-lib-report@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" - integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== - dependencies: - istanbul-lib-coverage "^3.0.0" - make-dir "^3.0.0" - supports-color "^7.1.0" - -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== - dependencies: - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" - -istanbul-reports@^3.1.3: - version "3.1.4" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.4.tgz#1b6f068ecbc6c331040aab5741991273e609e40c" - integrity sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw== - dependencies: - html-escaper "^2.0.0" - istanbul-lib-report "^3.0.0" - -jest-changed-files@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5" - integrity sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw== - dependencies: - "@jest/types" "^27.5.1" - execa "^5.0.0" - throat "^6.0.1" - -jest-circus@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-27.5.1.tgz#37a5a4459b7bf4406e53d637b49d22c65d125ecc" - integrity sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - dedent "^0.7.0" - expect "^27.5.1" - is-generator-fn "^2.0.0" - jest-each "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - slash "^3.0.0" - stack-utils "^2.0.3" - throat "^6.0.1" - -jest-cli@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-27.5.1.tgz#278794a6e6458ea8029547e6c6cbf673bd30b145" - integrity sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw== - dependencies: - "@jest/core" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - import-local "^3.0.2" - jest-config "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - prompts "^2.0.1" - yargs "^16.2.0" - -jest-config@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-27.5.1.tgz#5c387de33dca3f99ad6357ddeccd91bf3a0e4a41" - integrity sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA== - dependencies: - "@babel/core" "^7.8.0" - "@jest/test-sequencer" "^27.5.1" - "@jest/types" "^27.5.1" - babel-jest "^27.5.1" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.1" - graceful-fs "^4.2.9" - jest-circus "^27.5.1" - jest-environment-jsdom "^27.5.1" - jest-environment-node "^27.5.1" - jest-get-type "^27.5.1" - jest-jasmine2 "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-runner "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - micromatch "^4.0.4" - parse-json "^5.2.0" - pretty-format "^27.5.1" - slash "^3.0.0" - strip-json-comments "^3.1.1" - -jest-diff@^26.0.0: - version "26.6.2" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" - integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== - dependencies: - chalk "^4.0.0" - diff-sequences "^26.6.2" - jest-get-type "^26.3.0" - pretty-format "^26.6.2" - -jest-diff@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" - integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== - dependencies: - chalk "^4.0.0" - diff-sequences "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - -jest-docblock@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" - integrity sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ== - dependencies: - detect-newline "^3.0.0" - -jest-each@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-27.5.1.tgz#5bc87016f45ed9507fed6e4702a5b468a5b2c44e" - integrity sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ== - dependencies: - "@jest/types" "^27.5.1" - chalk "^4.0.0" - jest-get-type "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - -jest-environment-jsdom@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz#ea9ccd1fc610209655a77898f86b2b559516a546" - integrity sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - jest-mock "^27.5.1" - jest-util "^27.5.1" - jsdom "^16.6.0" - -jest-environment-node@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-27.5.1.tgz#dedc2cfe52fab6b8f5714b4808aefa85357a365e" - integrity sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - jest-mock "^27.5.1" - jest-util "^27.5.1" - -jest-get-type@^26.3.0: - version "26.3.0" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" - integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== - -jest-get-type@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" - integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== - -jest-haste-map@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.5.1.tgz#9fd8bd7e7b4fa502d9c6164c5640512b4e811e7f" - integrity sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng== - dependencies: - "@jest/types" "^27.5.1" - "@types/graceful-fs" "^4.1.2" - "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^27.5.1" - jest-serializer "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - micromatch "^4.0.4" - walker "^1.0.7" - optionalDependencies: - fsevents "^2.3.2" - -jest-jasmine2@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz#a037b0034ef49a9f3d71c4375a796f3b230d1ac4" - integrity sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/source-map" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - expect "^27.5.1" - is-generator-fn "^2.0.0" - jest-each "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - throat "^6.0.1" - -jest-leak-detector@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz#6ec9d54c3579dd6e3e66d70e3498adf80fde3fb8" - integrity sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ== - dependencies: - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - -jest-matcher-utils@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" - integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== - dependencies: - chalk "^4.0.0" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - -jest-message-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" - integrity sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^27.5.1" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^27.5.1" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-mock@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" - integrity sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - -jest-pnp-resolver@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" - integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== - -jest-regex-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-27.5.1.tgz#4da143f7e9fd1e542d4aa69617b38e4a78365b95" - integrity sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg== - -jest-resolve-dependencies@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz#d811ecc8305e731cc86dd79741ee98fed06f1da8" - integrity sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg== - dependencies: - "@jest/types" "^27.5.1" - jest-regex-util "^27.5.1" - jest-snapshot "^27.5.1" - -jest-resolve@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-27.5.1.tgz#a2f1c5a0796ec18fe9eb1536ac3814c23617b384" - integrity sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw== - dependencies: - "@jest/types" "^27.5.1" - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-pnp-resolver "^1.2.2" - jest-util "^27.5.1" - jest-validate "^27.5.1" - resolve "^1.20.0" - resolve.exports "^1.1.0" - slash "^3.0.0" - -jest-runner@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-27.5.1.tgz#071b27c1fa30d90540805c5645a0ec167c7b62e5" - integrity sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ== - dependencies: - "@jest/console" "^27.5.1" - "@jest/environment" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - emittery "^0.8.1" - graceful-fs "^4.2.9" - jest-docblock "^27.5.1" - jest-environment-jsdom "^27.5.1" - jest-environment-node "^27.5.1" - jest-haste-map "^27.5.1" - jest-leak-detector "^27.5.1" - jest-message-util "^27.5.1" - jest-resolve "^27.5.1" - jest-runtime "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - source-map-support "^0.5.6" - throat "^6.0.1" - -jest-runtime@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-27.5.1.tgz#4896003d7a334f7e8e4a53ba93fb9bcd3db0a1af" - integrity sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/globals" "^27.5.1" - "@jest/source-map" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - execa "^5.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-message-util "^27.5.1" - jest-mock "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - slash "^3.0.0" - strip-bom "^4.0.0" - -jest-serializer@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-27.5.1.tgz#81438410a30ea66fd57ff730835123dea1fb1f64" - integrity sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w== - dependencies: - "@types/node" "*" - graceful-fs "^4.2.9" - -jest-snapshot@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-27.5.1.tgz#b668d50d23d38054a51b42c4039cab59ae6eb6a1" - integrity sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA== - dependencies: - "@babel/core" "^7.7.2" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/traverse" "^7.7.2" - "@babel/types" "^7.0.0" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/babel__traverse" "^7.0.4" - "@types/prettier" "^2.1.5" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^27.5.1" - graceful-fs "^4.2.9" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - jest-haste-map "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-util "^27.5.1" - natural-compare "^1.4.0" - pretty-format "^27.5.1" - semver "^7.3.2" - -jest-util@^27.0.0, jest-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-27.5.1.tgz#3ba9771e8e31a0b85da48fe0b0891fb86c01c2f9" - integrity sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-validate@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" - integrity sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ== - dependencies: - "@jest/types" "^27.5.1" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^27.5.1" - leven "^3.1.0" - pretty-format "^27.5.1" - -jest-watcher@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-27.5.1.tgz#71bd85fb9bde3a2c2ec4dc353437971c43c642a2" - integrity sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw== - dependencies: - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - jest-util "^27.5.1" - string-length "^4.0.1" - -jest-worker@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -jest@^27.0.5: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest/-/jest-27.5.1.tgz#dadf33ba70a779be7a6fc33015843b51494f63fc" - integrity sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ== - dependencies: - "@jest/core" "^27.5.1" - import-local "^3.0.2" - jest-cli "^27.5.1" - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsdom@^16.6.0: - version "16.7.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" - integrity sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw== - dependencies: - abab "^2.0.5" - acorn "^8.2.4" - acorn-globals "^6.0.0" - cssom "^0.4.4" - cssstyle "^2.3.0" - data-urls "^2.0.0" - decimal.js "^10.2.1" - domexception "^2.0.1" - escodegen "^2.0.0" - form-data "^3.0.0" - html-encoding-sniffer "^2.0.1" - http-proxy-agent "^4.0.1" - https-proxy-agent "^5.0.0" - is-potential-custom-element-name "^1.0.1" - nwsapi "^2.2.0" - parse5 "6.0.1" - saxes "^5.0.1" - symbol-tree "^3.2.4" - tough-cookie "^4.0.0" - w3c-hr-time "^1.0.2" - w3c-xmlserializer "^2.0.0" - webidl-conversions "^6.1.0" - whatwg-encoding "^1.0.5" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.5.0" - ws "^7.4.6" - xml-name-validator "^3.0.0" - -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json5@2.x, json5@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" - integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== - -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -lodash.memoize@4.x: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= - -lodash@^4.7.0: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -make-dir@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - -make-error@1.x, make-error@^1.1.1: - version "1.3.6" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" - integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== - -makeerror@1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" - integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== - dependencies: - tmpl "1.0.5" - -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -micromatch@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -mimic-response@^1.0.0, mimic-response@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - -minimatch@^3.0.4: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimist@^1.2.0: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== - -morgan@^1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" - integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== - dependencies: - basic-auth "~2.0.1" - debug "2.6.9" - depd "~2.0.0" - on-finished "~2.3.0" - on-headers "~1.0.2" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@2.1.3, ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= - -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -node-int64@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" - integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= - -node-releases@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.4.tgz#f38252370c43854dc48aa431c766c6c398f40476" - integrity sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ== - -nodemon@^2.0.7: - version "2.0.16" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.16.tgz#d71b31bfdb226c25de34afea53486c8ef225fdef" - integrity sha512-zsrcaOfTWRuUzBn3P44RDliLlp263Z/76FPoHFr3cFFkOz0lTPAcIw8dCzfdVIx/t3AtDYCZRCDkoCojJqaG3w== - dependencies: - chokidar "^3.5.2" - debug "^3.2.7" - ignore-by-default "^1.0.1" - minimatch "^3.0.4" - pstree.remy "^1.1.8" - semver "^5.7.1" - supports-color "^5.5.0" - touch "^3.1.0" - undefsafe "^2.0.5" - update-notifier "^5.1.0" - -nopt@~1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" - integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4= - dependencies: - abbrev "1" - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -nwsapi@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" - integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== - -object-assign@^4: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= - -object-inspect@^1.9.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" - integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== - -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" - integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -optionator@^0.8.1: - version "0.8.3" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" - integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.6" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - word-wrap "~1.2.3" - -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - -parse-json@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -parse5@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" - integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== - -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pirates@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" - integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== - -pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= - -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= - -pretty-format@^26.0.0, pretty-format@^26.6.2: - version "26.6.2" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" - integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== - dependencies: - "@jest/types" "^26.6.2" - ansi-regex "^5.0.0" - ansi-styles "^4.0.0" - react-is "^17.0.1" - -pretty-format@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" - integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== - dependencies: - ansi-regex "^5.0.1" - ansi-styles "^5.0.0" - react-is "^17.0.1" - -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -psl@^1.1.33: - version "1.8.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" - integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== - -pstree.remy@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" - integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -punycode@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== - -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - -qs@6.10.3: - version "6.10.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" - integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== - dependencies: - side-channel "^1.0.4" - -range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - -rc@^1.2.8: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -react-is@^17.0.1: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -registry-auth-token@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" - integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw== - dependencies: - rc "^1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -resolve-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== - dependencies: - resolve-from "^5.0.0" - -resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve.exports@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.0.tgz#5ce842b94b05146c0e03076985d1d0e7e48c90c9" - integrity sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ== - -resolve@^1.20.0: - version "1.22.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" - integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== - dependencies: - is-core-module "^2.8.1" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= - dependencies: - lowercase-keys "^1.0.0" - -rimraf@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -safe-buffer@5.1.2, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -saxes@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" - integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== - dependencies: - xmlchars "^2.2.0" - -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - -semver@7.x, semver@^7.3.2, semver@^7.3.4: - version "7.3.7" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== - dependencies: - lru-cache "^6.0.0" - -semver@^5.7.1: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== - dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" - -signal-exit@^3.0.2, signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -source-map-support@^0.5.6: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -source-map@^0.7.3: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= - -stack-utils@^2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.5.tgz#d25265fca995154659dbbfba3b49254778d2fdd5" - integrity sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA== - dependencies: - escape-string-regexp "^2.0.0" - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -string-length@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" - integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== - dependencies: - char-regex "^1.0.2" - strip-ansi "^6.0.0" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-bom@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" - integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - -supports-color@^5.3.0, supports-color@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.0.0, supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-hyperlinks@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" - integrity sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ== - dependencies: - has-flag "^4.0.0" - supports-color "^7.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -symbol-tree@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" - integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== - -terminal-link@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" - integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== - dependencies: - ansi-escapes "^4.2.1" - supports-hyperlinks "^2.0.0" - -test-exclude@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" - integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== - dependencies: - "@istanbuljs/schema" "^0.1.2" - glob "^7.1.4" - minimatch "^3.0.4" - -throat@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375" - integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w== - -tiny-emitter@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - -tmpl@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" - integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= - -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -touch@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" - integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA== - dependencies: - nopt "~1.0.10" - -tough-cookie@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" - integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== - dependencies: - psl "^1.1.33" - punycode "^2.1.1" - universalify "^0.1.2" - -tr46@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" - integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== - dependencies: - punycode "^2.1.1" - -ts-jest@^27.0.3: - version "27.1.4" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-27.1.4.tgz#84d42cf0f4e7157a52e7c64b1492c46330943e00" - integrity sha512-qjkZlVPWVctAezwsOD1OPzbZ+k7zA5z3oxII4dGdZo5ggX/PL7kvwTM0pXTr10fAtbiVpJaL3bWd502zAhpgSQ== - dependencies: - bs-logger "0.x" - fast-json-stable-stringify "2.x" - jest-util "^27.0.0" - json5 "2.x" - lodash.memoize "4.x" - make-error "1.x" - semver "7.x" - yargs-parser "20.x" - -ts-node@^10.0.0: - version "10.7.0" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5" - integrity sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A== - dependencies: - "@cspotcode/source-map-support" "0.7.0" - "@tsconfig/node10" "^1.0.7" - "@tsconfig/node12" "^1.0.7" - "@tsconfig/node14" "^1.0.0" - "@tsconfig/node16" "^1.0.2" - acorn "^8.4.1" - acorn-walk "^8.1.1" - arg "^4.1.0" - create-require "^1.1.0" - diff "^4.0.1" - make-error "^1.1.1" - v8-compile-cache-lib "^3.0.0" - yn "3.1.1" - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= - dependencies: - prelude-ls "~1.1.2" - -type-detect@4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== - -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -typedarray-to-buffer@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" - integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== - dependencies: - is-typedarray "^1.0.0" - -typescript@^4.7.4: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== - -undefsafe@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" - integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== - -unique-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - -universalify@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= - dependencies: - prepend-http "^2.0.0" - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= - -uuid@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" - integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== - -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -v8-compile-cache-lib@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" - integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== - -v8-to-istanbul@^8.1.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed" - integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^1.6.0" - source-map "^0.7.3" - -vary@^1, vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= - -w3c-hr-time@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" - integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== - dependencies: - browser-process-hrtime "^1.0.0" - -w3c-xmlserializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" - integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== - dependencies: - xml-name-validator "^3.0.0" - -walker@^1.0.7: - version "1.0.8" - resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" - integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== - dependencies: - makeerror "1.0.12" - -webidl-conversions@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" - integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== - -webidl-conversions@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" - integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== - -whatwg-encoding@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" - integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== - dependencies: - iconv-lite "0.4.24" - -whatwg-mimetype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" - integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== - -whatwg-url@^8.0.0, whatwg-url@^8.5.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" - integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== - dependencies: - lodash "^4.7.0" - tr46 "^2.1.0" - webidl-conversions "^6.1.0" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - -word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -write-file-atomic@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" - integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== - dependencies: - imurmurhash "^0.1.4" - is-typedarray "^1.0.0" - signal-exit "^3.0.2" - typedarray-to-buffer "^3.1.5" - -ws@^7.4.6, ws@^7.5.0: - version "7.5.7" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.7.tgz#9e0ac77ee50af70d58326ecff7e85eb3fa375e67" - integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== - -xdg-basedir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" - integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== - -xml-name-validator@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" - integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== - -xmlchars@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" - integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yargs-parser@20.x, yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs@^16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - -yn@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" - integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== diff --git a/apps/compose.yaml b/apps/compose.yaml index 7d229cc..4f5e078 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -1,9 +1,84 @@ services: backend: image: jhalitaksoy/mancala-backend:latest + build: + context: .. + dockerfile: apps/backend/Dockerfile + environment: + # Must match the iss claim, i.e. the public Keycloak URL; https://auth.playmancala.app/realms/mancala in production + KEYCLOAK_ISSUER: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}/realms/mancala + # Fetch keys over the compose network instead of the public URL + KEYCLOAK_JWKS_URL: http://keycloak:8080/realms/mancala/protocol/openid-connect/certs + KEYCLOAK_CLIENT_IDS: mancala-web + LEGACY_USERKEY_AUTH: ${LEGACY_USERKEY_AUTH:-true} + DATABASE_JDBC_URL: jdbc:postgresql://postgres:5432/mancala + DATABASE_USERNAME: mancala + DATABASE_PASSWORD: ${MANCALA_DB_PASSWORD} + VALKEY_URL: redis://valkey:6379 ports: - "5000:6000" + depends_on: + - postgres + - valkey frontend: image: jhalitaksoy/mancala-frontend:latest + build: + context: .. + dockerfile: apps/frontend/Dockerfile + args: + KEYCLOAK_URL: ${KEYCLOAK_HOSTNAME:-http://localhost:8080} ports: - - "3000:3000" \ No newline at end of file + - "3000:3000" + # Shared by Keycloak and the game, each with its own database and user (see postgres/init). + postgres: + image: postgres:17.6 + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in apps/.env} + KEYCLOAK_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD in apps/.env} + MANCALA_DB_PASSWORD: ${MANCALA_DB_PASSWORD:?set MANCALA_DB_PASSWORD in apps/.env} + volumes: + - postgres-data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + ports: + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + # Live game state and caches; the append-only file keeps live games across restarts. + valkey: + image: valkey/valkey:9.1.2 + command: ["valkey-server", "--appendonly", "yes"] + volumes: + - valkey-data:/data + ports: + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + keycloak: + image: jhalitaksoy/mancala-keycloak:latest + build: ./keycloak + environment: + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD} + # Public URL of Keycloak; https://auth.playmancala.app in production + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:8080} + KC_HTTP_ENABLED: "true" + KC_PROXY_HEADERS: xforwarded + # Only used to create the master admin on the very first start + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in apps/.env} + ports: + - "127.0.0.1:8080:8080" + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres-data: + valkey-data: diff --git a/apps/frontend/.dockerignore b/apps/frontend/.dockerignore deleted file mode 100644 index 76add87..0000000 --- a/apps/frontend/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 8952481..fc4a1ae 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,24 +1,32 @@ +# Build from the repo root: docker build -f apps/frontend/Dockerfile . FROM node:20-alpine3.19 AS build -ARG SERVER_ADDRESS WS_SERVER_ADDRESS +ARG SERVER_ADDRESS WS_SERVER_ADDRESS KEYCLOAK_URL WORKDIR /app +# Local packages the frontend links to COPY package.json ./ +COPY packages packages +RUN yarn build:packages -COPY yarn.lock ./ +WORKDIR /app/apps/frontend + +COPY apps/frontend/package.json ./ + +COPY apps/frontend/yarn.lock ./ RUN yarn install -COPY . ./ +COPY apps/frontend ./ RUN yarn build FROM nginx:stable-alpine -COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html -COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY apps/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 3000 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 873596b..c3ffa38 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -18,10 +18,24 @@ yarn dev ### Development Dependencies -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. +This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root. + +### Login + +Every visitor has a Keycloak session (see `apps/keycloak`): the first visit logs in as a new guest without any +form, and the account menu on the home page lets guests log in or sign up. Run Keycloak locally with +`docker compose up keycloak` in `apps/`; the dev server must run on port 1234, which the `mancala-web` client allows. + +| Build variable | Default | +|---|---| +| `SERVER_ADDRESS` | `http://localhost:5005` | +| `WS_SERVER_ADDRESS` | `ws://localhost:5005` | +| `KEYCLOAK_URL` | `http://localhost:8080` | ### Docker Build +From the repo root: + ```bash -docker build --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 -t mancala-frontend . +docker build -f apps/frontend/Dockerfile --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 --build-arg KEYCLOAK_URL=http://localhost:8080 -t mancala-frontend . ``` \ No newline at end of file diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 78fc4de..0af3191 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -13,7 +13,7 @@ "author": "", "license": "ISC", "dependencies": { - "@mancala/core": "0.0.2", + "@mancala/core": "link:../../packages/core", "@szhsin/react-menu": "^3.1.2", "@types/": "szhsin/react-menu", "@types/eventemitter2": "^4.1.0", @@ -21,8 +21,9 @@ "@types/uuid": "^8.3.4", "eventemitter2": "^6.4.7", "i18next": "^23.11.5", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../../packages/mancala.js", "notyf": "^3.10.0", + "oidc-client-ts": "3.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.2", diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index 85b1831..4c0be81 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -6,19 +6,23 @@ import { Route, } from "react-router-dom"; import FloatingPanel from './components/FloatingPanel'; -import GamePage from './routes/GamePage'; +import GameRoute from './routes/GamePage'; import Home from './routes/Home'; import { initContext } from './context/context'; -import { RTMTWS, ConnectionState } from '@mancala/core'; -import { getColorByBrightness } from "@mancala/core"; +import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core'; import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; import PrivacyPage from './routes/PrivacyPage'; +import RulesPage from './routes/RulesPage'; +import LearnPage from './routes/LearnPage'; +import LessonPage from './routes/LessonPage'; import swal from 'sweetalert'; import Util from './util/Util'; import { server } from './const/config'; import { useTranslation } from 'react-i18next'; +import { applyThemeClass } from './theme/themes'; +import './styles/app.css'; const context = initContext(); @@ -26,7 +30,13 @@ const MancalaApp: FunctionComponent = () => { const { t, i18n } = useTranslation(); - const [userKey, setUserKey] = useState(undefined); + // Set once the server has authenticated the connection. + const [authUser, setAuthUser] = useState(undefined); + + // Pages compare this with the game's player ids. + const userKey = authUser?.id; + + const [hasSession, setHasSession] = useState(false); const [connectionState, setConnetionState] = useState("connecting"); @@ -36,7 +46,10 @@ const MancalaApp: FunctionComponent = () => { const onConnectionChange = (_connectionState: ConnectionState) => setConnetionState(_connectionState); - const onThemeChange = (theme: Theme) => setTheme(theme); + const onThemeChange = (theme: Theme) => { + applyThemeClass(theme); + setTheme(theme); + }; React.useEffect(() => { //@ts-ignore @@ -44,60 +57,90 @@ const MancalaApp: FunctionComponent = () => { i18n.changeLanguage(deviceLanguage === "tr-TR" ? "tr" : "en") }, []); - const connectRTMT = (userKey: string) => { + const onAuthenticated = (user: AuthUser) => setAuthUser(user); + + // If the session can no longer be refreshed, start over as a new guest. + const getAccessTokenOrLoginAgain = async () => { + try { + return await context.authService.getAccessToken(); + } catch (error) { + context.authService.loginAsGuest(); + throw error; + } + } + + const connectRTMT = () => { const rtmt = context.rtmt as RTMTWS; rtmt.on("error", onConnectionError); - rtmt.on("connectionchange", onConnectionChange) - const url = server.wsServerAdress + "?userKey=" + userKey; - rtmt.connectWebSocket(url, userKey) + rtmt.on("connectionchange", onConnectionChange); + rtmt.on("authenticated", onAuthenticated); + rtmt.connectWebSocket(server.wsServerAdress, getAccessTokenOrLoginAgain); return rtmt; } - const loadUserKeyAndConnectServer = () => { - context.userKeyStore.getUserKey().then((userKey: string) => { - setUserKey(userKey); - connectRTMT(userKey); - }).catch((error) => { - //TODO: check if it is network error! - swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error"); - console.error(error); - }); + const startSession = async () => { + const authService = context.authService; + if (authService.isCallback()) { + let returnTo = "/"; + try { + returnTo = await authService.handleCallback(); + } catch (error) { + // e.g. the callback page was reloaded; fall back to the stored session. + console.error("(Auth) Login callback failed", error); + } + window.history.replaceState(null, "", returnTo); + } + const user = await authService.getUser(); + if (!user) { + await authService.loginAsGuest(); + return; + } + setHasSession(true); + connectRTMT(); } const disposeApp = () => { context.rtmt?.dispose(); + context.rtmt.off("authenticated", onAuthenticated); context.themeManager.off("themechange", onThemeChange); } React.useEffect(() => { - loadUserKeyAndConnectServer(); + applyThemeClass(context.themeManager.theme); + startSession().catch((error) => { + swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error"); + console.error(error); + }); + const removeUserLoadedListener = context.authService.onUserLoaded( + (user) => (context.rtmt as RTMTWS).reauthenticate(user.access_token)); context.themeManager.on("themechange", onThemeChange); - return () => disposeApp(); + return () => { + removeUserLoadedListener(); + disposeApp(); + }; }, []); - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.boardColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); return ( <> - + {hasSession && - } /> + } /> - } > + } > }> }> + } /> + } /> + } /> - - - {Util.getTextByConnectionState(context, connectionState, t)} + } + + {Util.getTextByConnectionState(context, connectionState, t)} ); diff --git a/apps/frontend/src/auth/AuthService.ts b/apps/frontend/src/auth/AuthService.ts new file mode 100644 index 0000000..0ad08df --- /dev/null +++ b/apps/frontend/src/auth/AuthService.ts @@ -0,0 +1,86 @@ +import i18n from "i18next"; +import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; + +const CALLBACK_PATH = "/auth/callback"; + +type SigninState = { returnTo: string }; + +/** + * Keycloak login for the web app. Everyone has a session: visitors without one are logged in as a new guest, + * and guests can log in or sign up to switch to an account. + */ +export class AuthService { + private readonly userManager: UserManager; + + constructor(params: { keycloakUrl: string, realm: string, clientId: string }) { + const origin = window.location.origin; + this.userManager = new UserManager({ + authority: `${params.keycloakUrl}/realms/${params.realm}`, + client_id: params.clientId, + redirect_uri: origin + CALLBACK_PATH, + post_logout_redirect_uri: origin + "/", + response_type: "code", + // offline_access keeps guests' identity after the SSO session ends. + scope: "openid offline_access", + userStore: new WebStorageStateStore({ store: window.localStorage }), + // Renews with the refresh token shortly before the access token expires. + automaticSilentRenew: true, + monitorSession: false, + }); + } + + public isCallback(): boolean { + return window.location.pathname === CALLBACK_PATH; + } + + // Completes a login redirect and returns the path the user started from. + public async handleCallback(): Promise { + const user = await this.userManager.signinRedirectCallback(); + return (user.state as SigninState | undefined)?.returnTo ?? "/"; + } + + // The stored session, refreshed if its access token has expired. Null if there is no usable session. + public async getUser(): Promise { + const user = await this.userManager.getUser(); + if (!user || !user.expired) return user; + try { + return await this.userManager.signinSilent(); + } catch (error) { + console.warn("(Auth) Could not refresh the session", error); + await this.userManager.removeUser(); + return null; + } + } + + public async getAccessToken(): Promise { + const user = await this.getUser(); + if (!user) throw new Error("Not logged in"); + return user.access_token; + } + + public onUserLoaded(callback: (user: User) => void): () => void { + this.userManager.events.addUserLoaded(callback); + return () => this.userManager.events.removeUserLoaded(callback); + } + + public loginAsGuest(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ extraQueryParams: { guest: "true" }, state: { returnTo } }); + } + + // prompt=login shows the login form even though the guest already has a session. + public login(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ prompt: "login", ui_locales: i18n.language, state: { returnTo } }); + } + + public signUp(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ prompt: "create", ui_locales: i18n.language, state: { returnTo } }); + } + + public logout(): Promise { + return this.userManager.signoutRedirect(); + } +} + +function currentPath(): string { + return window.location.pathname + window.location.search; +} diff --git a/apps/frontend/src/components/BotLevelMenu.tsx b/apps/frontend/src/components/BotLevelMenu.tsx new file mode 100644 index 0000000..6a43278 --- /dev/null +++ b/apps/frontend/src/components/BotLevelMenu.tsx @@ -0,0 +1,36 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; +import { BotIcon } from "./Icons"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// "Play with bot" in the header: a menu of the levels, the one chosen last marked. +const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const menuButton = ( + + ); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( + + {t("ChooseBotLevel")} + {BOT_LEVELS.map((level) => ( + choose(level)}> + {t(getBotLevelTextKey(level))} + + ))} + + ); +}; + +export default BotLevelMenu; diff --git a/apps/frontend/src/components/BotLevelPicker.tsx b/apps/frontend/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..e190d71 --- /dev/null +++ b/apps/frontend/src/components/BotLevelPicker.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; + +// The four bot levels as buttons; the level chosen last is highlighted and remembered on this device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( +
+ {BOT_LEVELS.map((level) => ( + + ))} + +
+ ); +}; + +export default BotLevelPicker; diff --git a/apps/frontend/src/components/Button.tsx b/apps/frontend/src/components/Button.tsx deleted file mode 100644 index 105917e..0000000 --- a/apps/frontend/src/components/Button.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -import { getColorByBrightness } from "@mancala/core"; - -const Button: FunctionComponent<{ - context: Context; - text: String; - onClick: () => void; - color: string; -}> = ({ context, text, color, onClick }) => { - const textColor = getColorByBrightness( - color, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( - - ); -}; - -export default Button; diff --git a/apps/frontend/src/components/Center.tsx b/apps/frontend/src/components/Center.tsx deleted file mode 100644 index 19aeaf8..0000000 --- a/apps/frontend/src/components/Center.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Center: FunctionComponent = ({children}) => { - return ( -
- {children} - -
- ); -} - -export default Center; \ No newline at end of file diff --git a/apps/frontend/src/components/CircularPanel.tsx b/apps/frontend/src/components/CircularPanel.tsx deleted file mode 100644 index 2de9283..0000000 --- a/apps/frontend/src/components/CircularPanel.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const CircularPanel: FunctionComponent<{ - color: string; - style?: React.CSSProperties -}> = (props) => { - return ( -
- - {props.children} -
- ); -} - -export default CircularPanel; diff --git a/apps/frontend/src/components/FloatingPanel.tsx b/apps/frontend/src/components/FloatingPanel.tsx index 564ec06..779a95d 100644 --- a/apps/frontend/src/components/FloatingPanel.tsx +++ b/apps/frontend/src/components/FloatingPanel.tsx @@ -1,32 +1,38 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -const FloatingPanel: FunctionComponent<{ - context: Context; - color: string; - visible: boolean; -}> = (props) => { - if(props.visible === false) return <> +// A status pill in the bottom left corner, e.g. while connecting. +const FloatingPanel: FunctionComponent<{ visible: boolean }> = (props) => { + if (props.visible === false) return <> return ( -
+
+ + {props.children} - {props.children}
) }; -export default FloatingPanel; \ No newline at end of file +export default FloatingPanel; diff --git a/apps/frontend/src/components/GameClockView.tsx b/apps/frontend/src/components/GameClockView.tsx new file mode 100644 index 0000000..e52bb2b --- /dev/null +++ b/apps/frontend/src/components/GameClockView.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { CLOCK_LOW_TIME_MS, formatClock } from "@mancala/core"; + +// One player's clock: highlighted while it runs, in a warning colour when the running clock is low. +const GameClockView: FunctionComponent<{ + timeMs: number, + running: boolean, +}> = ({ timeMs, running }) => { + const lowTime = running && timeMs < CLOCK_LOW_TIME_MS; + return ( + + {formatClock(timeMs)} + + ); +} + +export default GameClockView; diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx new file mode 100644 index 0000000..37f93d1 --- /dev/null +++ b/apps/frontend/src/components/Icons.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; + +// Inline stroke icons in the current text colour. All are decorative; buttons carry their own labels. + +type IconProps = { size?: number, className?: string }; + +const stroke = (size: number, width: number, className?: string) => ({ + width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: width, + strokeLinecap: "round" as const, strokeLinejoin: "round" as const, className, "aria-hidden": true, +}); + +export const LogoIcon = ({ size = 34 }: IconProps) => ( + +); + +export const PlusIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ProfileIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LeaveIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ChevronIcon = ({ size = 20, className }: IconProps) => ( + +); + +export const CloseIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ReplayIcon = ({ size = 18 }: IconProps) => ( + +); + +export const FirstIcon = ({ size = 20 }: IconProps) => ( + +); + +export const PreviousIcon = ({ size = 20 }: IconProps) => ( + +); + +export const NextIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LastIcon = ({ size = 20 }: IconProps) => ( + +); + +export const PlayIcon = ({ size = 22 }: IconProps) => ( + +); + +export const PauseIcon = ({ size = 22 }: IconProps) => ( + +); + +export const BotIcon = ({ size = 20 }: IconProps) => ( + +); + +export const HelpIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/InfoPanel.tsx b/apps/frontend/src/components/InfoPanel.tsx deleted file mode 100644 index d15c1d5..0000000 --- a/apps/frontend/src/components/InfoPanel.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -import { Game, User } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import CircularPanel from "./CircularPanel"; -import { useTranslation } from "react-i18next"; - -function getInfoPanelTextByGameState(params: { - context: Context; - t: (text: string) => string; - game?: Game; - currentUser: User; - whitePlayer: User; - blackPlayer: User; - leftPlayer?: User; - isSpectator?: boolean; -}): string | undefined { - const { - context, - t, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - isSpectator - } = params; - - if (leftPlayer) { - return isSpectator ? `${leftPlayer.name} ${t("UserLeftTheGame")}` : - leftPlayer.id == currentUser.id ? t("YouLeftTheGame") : t("OpponentLeftTheGame"); - } - - const isGameEnded = game?.mancalaGame.state == "ended"; - if (isGameEnded) { - const wonPlayerID = game.mancalaGame.getWonPlayerId(); - let whoWon; - if (wonPlayerID) { - const wonPlayer = wonPlayerID == whitePlayer.id ? whitePlayer : blackPlayer; - whoWon = isSpectator ? `${wonPlayer.name} ${t("Won")}` : - game.mancalaGame.getWonPlayerId() === currentUser.id - ? t("YouWon") - : t("YouLost"); - } else { - whoWon = t("GameDraw"); - } - return t("GameEnded") + " " + whoWon; - } - - if (game) { - const playingPlayer = game.mancalaGame.checkIsPlayerTurn(whitePlayer.id) ? whitePlayer : blackPlayer; - return isSpectator ? `${playingPlayer.name} ${t("Playing")}` : game.mancalaGame.checkIsPlayerTurn(currentUser.id) - ? t("YourTurn") - : t("OpponentTurn"); - } - - return undefined; -} - -const InfoPanel: FunctionComponent<{ - context: Context; - game?: Game; - currentUser: User; - whitePlayer: User; - blackPlayer: User; - leftPlayer?: User; - style?: React.CSSProperties; - visible?: boolean; - isSpectator?: boolean; -}> = ({ - context, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - style, - visible, - isSpectator -}) => { - - const { t } = useTranslation(); - - if (visible === false) return <>; - const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false; - const containerColor = isUserTurn - ? context.themeManager.theme.playerTurnColor - : context.themeManager.theme.boardColor; - const textColor = getColorByBrightness( - containerColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const text = getInfoPanelTextByGameState({ - context, - t, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - isSpectator - }); - if (text) { - return ( - -

- {text} -

-
- ); - } else { - return (
) - } - }; - -export default InfoPanel; diff --git a/apps/frontend/src/components/LoadingComponent.tsx b/apps/frontend/src/components/LoadingComponent.tsx index ea0e15c..2538447 100644 --- a/apps/frontend/src/components/LoadingComponent.tsx +++ b/apps/frontend/src/components/LoadingComponent.tsx @@ -1,32 +1,33 @@ import * as React from 'react'; import { FunctionComponent } from 'react'; -import { Context } from '../context/context'; import { LoadingState } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import CircularPanel from './CircularPanel'; import { useTranslation } from 'react-i18next'; -const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState }> = ({ context, loadingState }) => { +const LoadingComponent: FunctionComponent<{ loadingState: LoadingState }> = ({ loadingState }) => { const { t } = useTranslation(); - if (loadingState.isUnset() || loadingState.isLoaded()) { - return <> - } - if (loadingState.isLoading() || loadingState.isError()) { - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.boardColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const text = loadingState.isLoading() ? t("Loading") +"..." : loadingState.errorMessage; - return ( - -

{`${text}`}

-
- ); - } - return <> + if (!loadingState.isLoading() && !loadingState.isError()) return <>; + const text = loadingState.isLoading() ? t("Loading") + "..." : loadingState.errorMessage; + return ( +
+ {loadingState.isLoading() && } + {text} + +
+ ); } -export default LoadingComponent; \ No newline at end of file +export default LoadingComponent; diff --git a/apps/frontend/src/components/PageContainer.tsx b/apps/frontend/src/components/PageContainer.tsx index 8740ee2..6db896a 100644 --- a/apps/frontend/src/components/PageContainer.tsx +++ b/apps/frontend/src/components/PageContainer.tsx @@ -1,25 +1,7 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { Theme } from "@mancala/core"; -const PageContainer: FunctionComponent<{ theme: Theme }> = (props) => { - return ( -
- - {props.children} -
- ); -} +// A page: the header, then the page's content. +const PageContainer: FunctionComponent = ({ children }) =>
{children}
; -export default PageContainer; \ No newline at end of file +export default PageContainer; diff --git a/apps/frontend/src/components/PracticeGameCard.tsx b/apps/frontend/src/components/PracticeGameCard.tsx new file mode 100644 index 0000000..eee85bd --- /dev/null +++ b/apps/frontend/src/components/PracticeGameCard.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { setHintsEnabled } from "@mancala/core"; +import { Context } from "../context/context"; +import Util from "../util/Util"; + +// The tutorial's last step: a game against the Easy bot with hints on (R3.1). +const PracticeGameCard: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const start = () => { + if (Util.checkConnectionAndMaybeAlert(context, t)) return; + setHintsEnabled(context.storage, true); + navigate("/loby?bot=easy"); + }; + return ( +
+
+ {t("PracticeGame")} +

{t("PracticeGameText")}

+
+ + +
+ ); +}; + +export default PracticeGameCard; diff --git a/apps/frontend/src/components/Row.tsx b/apps/frontend/src/components/Row.tsx deleted file mode 100644 index bc0db42..0000000 --- a/apps/frontend/src/components/Row.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Row: FunctionComponent = ({children}) => { - return ( -
- {children} - -
- ); -} - -export default Row; \ No newline at end of file diff --git a/apps/frontend/src/components/Space.tsx b/apps/frontend/src/components/Space.tsx deleted file mode 100644 index c1dfc5f..0000000 --- a/apps/frontend/src/components/Space.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Space: FunctionComponent<{ width?: string, height?: string }> = ({ width, height }) => { - return ( -
- ); -} - -export default Space; \ No newline at end of file diff --git a/apps/frontend/src/components/UserStatus.tsx b/apps/frontend/src/components/UserStatus.tsx deleted file mode 100644 index c4f767c..0000000 --- a/apps/frontend/src/components/UserStatus.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; -import { Context } from '../context/context'; -import { User } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import Space from './Space'; -import { useTranslation } from 'react-i18next'; - -export type LayoutMode = "right" | "left"; - -const UserStatus: FunctionComponent<{ - context: Context, - user: User, - layoutMode: LayoutMode, - visible?: boolean, - style?: React.CSSProperties -}> = ({ context, user, layoutMode, visible, style }) => { - if (visible === false) return <>; - - const { t } = useTranslation(); - - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.background, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( -
- {user.isAnonymous ? t("Anonymous") : user.name} - -
- -
- ); -} - -export default UserStatus; \ No newline at end of file diff --git a/apps/frontend/src/components/board/BoardToolbar.tsx b/apps/frontend/src/components/board/BoardToolbar.tsx deleted file mode 100644 index f51a029..0000000 --- a/apps/frontend/src/components/board/BoardToolbar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const BoardToolbar: FunctionComponent<{ visible?: boolean, style?: React.CSSProperties }> = ({ children, visible, style }) => { - if(visible === false) return <>; - return ( -
- {children} - -
- ); -} - -export default BoardToolbar; \ No newline at end of file diff --git a/apps/frontend/src/components/board/BoardView.css b/apps/frontend/src/components/board/BoardView.css new file mode 100644 index 0000000..e72f0d9 --- /dev/null +++ b/apps/frontend/src/components/board/BoardView.css @@ -0,0 +1,209 @@ +/* The board. Sized by its container: pits are a ninth of the width, up to 124 px. Below 600 px the board stands + upright: the top player's store at the top, the viewer's at the bottom, the viewer's pits in the left column. */ + +.bwrap { container-type: inline-size; } + +.board { + --pit: min(124px, calc(100cqw / 9.7)); + --pw: var(--pit); + --ph: var(--pit); + --stone: calc(var(--ph) * .21); + display: grid; + grid-template-columns: calc(var(--pit) * 1.1) repeat(6, var(--pit)) calc(var(--pit) * 1.1); + grid-template-rows: var(--pit) var(--pit); + column-gap: calc(var(--pit) * .13); + row-gap: calc(var(--pit) * .26); + width: max-content; + margin: 0 auto; + padding: calc(var(--pit) * .22); + border-radius: calc(var(--pit) * .34); + background: var(--boardBg); + box-shadow: var(--boardShadow); + transition: background .3s; +} + +.pit { + position: relative; + grid-row: var(--hr); + grid-column: var(--hc); + width: var(--pw); + height: var(--ph); + border-radius: 50%; + border: none; + padding: 0; + margin: 0; + background: var(--pit); + box-shadow: var(--pitShadow); + font: inherit; + color: inherit; + cursor: default; + transition: transform .18s ease, box-shadow .18s ease; +} + +.pit.mine { cursor: pointer; } + +@media (hover: hover) { + .pit.mine:hover { transform: translateY(-2px); box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); } +} + +.pit:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } + +.pit.pv, .store.pv, .pit.sel { box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); } + +.pit.land, .store.land, .pit.drop, .store.drop { box-shadow: var(--pitShadow), 0 0 0 3px var(--accent), 0 0 22px var(--accentGlow); } + +.pit.cap, .store.cap { box-shadow: var(--pitShadow), 0 0 0 3px var(--live), 0 0 22px var(--live); } + +.pit.last::after { + content: ''; + position: absolute; + inset: -7px; + border-radius: 50%; + border: 2px dashed var(--lastRing); + pointer-events: none; +} + +.store { + position: relative; + grid-row: 1 / 3; + border-radius: 999px; + background: var(--pit); + box-shadow: var(--pitShadow); + transition: box-shadow .18s; +} + +.store.l { grid-column: 1; } + +.store.r { grid-column: 8; } + +.st { + position: absolute; + width: var(--stone); + height: var(--stone); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: var(--stoneShadow), inset 0 -2px 4px rgba(0, 0, 0, .25); + transition: left .3s ease, top .3s ease; + pointer-events: none; +} + +.st::after { + content: ''; + position: absolute; + left: 22%; + top: 15%; + width: 32%; + height: 20%; + border-radius: 50%; + background: rgba(255, 255, 255, .6); +} + +.badge { + position: absolute; + left: 50%; + transform: translateX(-50%); + min-width: 30px; + height: 22px; + padding: 0 8px; + border-radius: 11px; + background: var(--chip); + color: var(--chipInk); + font-weight: 700; + font-size: 13px; + line-height: 22px; + box-sizing: border-box; + z-index: 2; + box-shadow: 0 0 0 2px var(--rim); + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.badge.t { top: -11px; } + +.badge.b { bottom: -11px; } + +.sbadge { + position: absolute; + left: 50%; + bottom: -14px; + transform: translateX(-50%); + min-width: 44px; + height: 28px; + padding: 0 10px; + border-radius: 14px; + background: var(--chip); + color: var(--chipInk); + font-weight: 800; + font-size: 16px; + line-height: 28px; + box-sizing: border-box; + box-shadow: 0 0 0 2px var(--rim); + text-align: center; + z-index: 2; + font-variant-numeric: tabular-nums; +} + +.store.r .sbadge { background: var(--accent); color: var(--onAccent); } + +.pvchip { + position: absolute; + right: -6px; + top: -6px; + height: 22px; + min-width: 26px; + padding: 0 6px; + box-sizing: border-box; + border-radius: 11px; + background: var(--accent); + color: var(--onAccent); + font-weight: 800; + font-size: 12px; + line-height: 22px; + z-index: 3; + text-align: center; + pointer-events: none; +} + +.hintchip { + position: absolute; + left: 50%; + top: -10px; + transform: translateX(-50%); + max-width: 120%; + padding: 2px 8px; + box-sizing: border-box; + border-radius: 10px; + background: var(--surface2); + color: var(--accent); + box-shadow: inset 0 0 0 1.5px var(--accent); + font-weight: 800; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + z-index: 3; + pointer-events: none; +} + +@container (max-width: 600px) { + .board { + --pw: min(170px, calc((100cqw - 70px) / 2)); + --ph: clamp(52px, 15cqw, 66px); + --stone: calc(var(--ph) * .24); + grid-template-columns: var(--pw) var(--pw); + grid-template-rows: calc(var(--ph) * 1.05) repeat(6, var(--ph)) calc(var(--ph) * 1.05); + column-gap: 22px; + row-gap: 10px; + padding: 24px 22px; + border-radius: 32px; + } + .pit { grid-row: var(--vr); grid-column: var(--vc); } + .store.l, .store.r { grid-column: 1 / 3; } + .store.l { grid-row: 1; } + .store.r { grid-row: 8; } + .badge.t { top: 50%; left: auto; right: -13px; transform: translateY(-50%); } + .badge.b { top: 50%; bottom: auto; left: -13px; transform: translateY(-50%); } + .store.l .sbadge { bottom: auto; top: -14px; } +} diff --git a/apps/frontend/src/components/board/BoardView.tsx b/apps/frontend/src/components/board/BoardView.tsx index 630d05e..d88bd51 100644 --- a/apps/frontend/src/components/board/BoardView.tsx +++ b/apps/frontend/src/components/board/BoardView.tsx @@ -1,74 +1,91 @@ import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import { BoardViewModel, PitViewModel } from "@mancala/core"; -import PitView from "./PitView"; -import StoreView from "./StoreView"; -import { Game } from "@mancala/core"; +import { FunctionComponent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BoardViewModel, Game, getSowingPath } from "@mancala/core"; import { Pit } from "mancala.js"; +import { Context } from "../../context/context"; +import { getWebTheme } from "../../theme/themes"; +import PitView, { PitMarks } from "./PitView"; +import StoreView from "./StoreView"; +import "./BoardView.css"; +// The board as the bottom player sees it: their pits along the bottom from left to right, their store on the right. +// It fills its container's width. const BoardView: FunctionComponent<{ game: Game; context: Context; - boardId: string; boardViewModel: BoardViewModel; + // Show player 2 at the bottom. revert: boolean; - onPitSelect: (index: number, pit: Pit) => void; -}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect }) => { - const mancalaGame = game?.mancalaGame; - const theme = context.themeManager.theme; + topName: string; + bottomName: string; + // Makes the bottom player's pits buttons. + onPitSelect?: (index: number, pit: Pit) => void; + // The bottom player can move now: their pits with stones are highlighted and preview their move. + canPlay?: boolean; + // Board index of the pit the last move was played from. + lastMoveIndex?: number; + // Board index where the shown move's last stone landed. + landIndex?: number; + // What the bottom player's moves would do, by board index (hints in games against the bot). + hints?: Record; +}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => { + const { t } = useTranslation(); + const [previewIndex, setPreviewIndex] = useState(undefined); + const board = game.mancalaGame.board; + const themeId = getWebTheme(context.themeManager.theme).id; + const stones = boardViewModel.pits.map((pit) => pit.stoneCount); - // Compute pit width based on viewport (each column is 11vw) - const pitWidth = typeof window !== "undefined" ? window.innerWidth * 0.11 : 100; + // Pits the hovered move drops stones into, with how many each, and where its last stone lands. + const preview: Record = {}; + const path = canPlay && previewIndex !== undefined ? getSowingPath(stones, previewIndex) : []; + path.forEach((index) => (preview[index] = (preview[index] ?? 0) + 1)); + const previewLand = path.length > 0 ? path[path.length - 1] : undefined; - const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => ( - onPitSelect(pit.index, pit)} - fontSize={pitWidth / 6} - /> - ); + const marksOf = (index: number): PitMarks => ({ + highlight: boardViewModel.pits[index]?.highlight, + preview: preview[index], + land: previewLand !== undefined ? index === previewLand : index === landIndex, + last: index === lastMoveIndex, + hint: hints?.[index], + }); - const createPitViewList = (pits: Pit[]) => - pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index])); + const topPits = revert ? board.player1Pits : board.player2Pits; + const bottomPits = revert ? board.player2Pits : board.player1Pits; + const [topStore, bottomStore] = revert + ? [board.player1BankIndex(), board.player2BankIndex()] + : [board.player2BankIndex(), board.player1BankIndex()]; - const player1Pits = createPitViewList(mancalaGame?.board.player1Pits); - const player2Pits = createPitViewList(mancalaGame?.board.player2Pits); - const player1BankIndex = mancalaGame?.board.player1BankIndex(); - const player2BankIndex = mancalaGame?.board.player2BankIndex(); - const player1BankViewModel = boardViewModel.pits[player1BankIndex]; - const player2BankViewModel = boardViewModel.pits[player2BankIndex]; + const pitView = (pit: Pit, position: number, top: boolean) => { + const stoneCount = stones[pit.index]; + const playable = !top && !!canPlay && stoneCount > 0; + const pitNumber = (top ? 5 - position : position) + 1; + return ( + onPitSelect(pit.index, board.pits[pit.index]) : undefined} + onPreview={(active) => setPreviewIndex(active ? pit.index : undefined)} /> + ); + }; return ( -
- - - {revert ? player1Pits?.reverse() : player2Pits?.reverse()} - {revert ? player2Pits : player1Pits} - +
+
+ + {[...topPits].reverse().map((pit, position) => pitView(pit, position, true))} + {bottomPits.map((pit, position) => pitView(pit, position, false))} + +
); }; diff --git a/apps/frontend/src/components/board/PitView.tsx b/apps/frontend/src/components/board/PitView.tsx index 052e9c4..4a389d1 100644 --- a/apps/frontend/src/components/board/PitView.tsx +++ b/apps/frontend/src/components/board/PitView.tsx @@ -1,83 +1,74 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness, PitViewModel } from "@mancala/core"; -import { Context } from "../../context/context"; -import Util from "../../util/Util"; -import StoneView from "./StoneView"; +import { PitHighlight } from "@mancala/core"; +import Stones from "./Stones"; -interface PitViewProps { - pitViewModel: PitViewModel; - context: Context; - onClick: () => void; - fontSize: number; +export type PitMarks = { + highlight?: PitHighlight; + // Stones a hovered move would drop here. + preview?: number; + // The hovered move's last stone lands here, or the shown move's did. + land?: boolean; + // The last move was played from here. + last?: boolean; + // What playing this pit would do, in games against the bot with hints on, e.g. "Extra turn". + hint?: string; +}; + +export function getMarkClasses(marks: PitMarks): string { + const highlight = marks.highlight === "capture" ? " cap" : marks.highlight === "selected" ? " sel" + : marks.highlight ? " drop" : ""; + return highlight + (marks.preview ? " pv" : "") + (marks.land ? " land" : "") + (marks.last ? " last" : ""); } -const PitView: FunctionComponent = ({ pitViewModel, context, onClick, fontSize }) => { - const textColor = getColorByBrightness( - pitViewModel.pitColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor +// A pit in the top or bottom row. Position is its column from the left, 0-5. The viewer's pits are buttons. +const PitView: FunctionComponent<{ + index: number; + stoneCount: number; + top: boolean; + position: number; + themeId: string; + label: string; + marks: PitMarks; + // The viewer can play this pit now. + playable?: boolean; + // Set for pits the viewer can click. + onClick?: () => void; + // Called with true when a playable pit is hovered or focused, with false when that ends. + onPreview?: (active: boolean) => void; +}> = ({ index, stoneCount, top, position, themeId, label, marks, playable, onClick, onPreview }) => { + const className = "pit" + (playable ? " mine" : "") + getMarkClasses(marks); + const style = { + "--hr": top ? "1" : "2", + "--hc": String(position + 2), + "--vr": String(position + 2), + "--vc": top ? "2" : "1", + } as React.CSSProperties; + const content = ( + <> + + {stoneCount} + {!!marks.preview && {`+${marks.preview}`}} + {!!marks.hint && {marks.hint}} + ); - - const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( - - )); - + if (!onClick) { + return
{content}
; + } return ( -
- {stones} - {pitViewModel.stoneCount > 0 && ( - - {pitViewModel.stoneCount} - - )} - -
+ onMouseEnter={() => playable && onPreview?.(true)} + onMouseLeave={() => onPreview?.(false)} + onFocus={() => playable && onPreview?.(true)} + onBlur={() => onPreview?.(false)}> + {content} + ); }; -export default PitView; \ No newline at end of file +export default PitView; diff --git a/apps/frontend/src/components/board/StoneView.tsx b/apps/frontend/src/components/board/StoneView.tsx deleted file mode 100644 index e795b33..0000000 --- a/apps/frontend/src/components/board/StoneView.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; - -const StoneView: FunctionComponent<{ color: string }> = ({ color }) => { - return ( -
- -
- ); -}; - -export default StoneView; \ No newline at end of file diff --git a/apps/frontend/src/components/board/Stones.tsx b/apps/frontend/src/components/board/Stones.tsx new file mode 100644 index 0000000..51fbac5 --- /dev/null +++ b/apps/frontend/src/components/board/Stones.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; + +// Stone colours per theme: highlight, body and shadow of a radial gradient. +const PALETTES: Record = { + ceviz: [["#fff1c9", "#e39a32", "#7d4610"], ["#d4fff5", "#2fa597", "#0c4a42"], ["#fffaf0", "#e2d3b6", "#94815f"], ["#ffd0c7", "#c94a3b", "#621a12"], ["#e6ecff", "#6b84c9", "#263a73"]], + gece: [["#ffffff", "#dfe3ee", "#8a92ad"], ["#f3f6ff", "#8fa8ff", "#2b3f92"], ["#fff6e6", "#e8bd78", "#8b5d1f"], ["#ffeef6", "#e7a3c4", "#8a3c62"]], + yesim: [["#e2ffee", "#3f9f6f", "#134a2d"], ["#8d939a", "#2b2f35", "#08090b"], ["#ffffff", "#efe9df", "#a59a88"], ["#ffe8c7", "#d38a3a", "#6e3c0d"]], +}; + +// A repeatable pseudo random number in [0, 1). +function random(seed: number): number { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +// The stones of a pit or store, scattered around its centre on a sunflower spiral. The same count and seed always +// give the same picture, so stones only move when the count changes. +const Stones: FunctionComponent<{ count: number, seed: number, themeId: string }> = ({ count, seed, themeId }) => { + const palette = PALETTES[themeId] ?? PALETTES.ceviz; + const stones = []; + for (let i = 0; i < count; i++) { + const angle = i * 2.39996 + seed; + const distance = count === 1 ? 0 : Math.sqrt((i + 0.6) / count); + const x = 50 + Math.cos(angle) * distance * 33 + (random(seed + i) - 0.5) * 3; + const y = 50 + Math.sin(angle) * distance * 33 + (random(seed + i * 3) - 0.5) * 3; + const [light, body, dark] = palette[Math.floor(random(seed * 5 + i * 7.1) * palette.length)]; + stones.push(); + } + return <>{stones}; +}; + +export default Stones; diff --git a/apps/frontend/src/components/board/StoreView.tsx b/apps/frontend/src/components/board/StoreView.tsx index 850f3ef..6d79b1a 100644 --- a/apps/frontend/src/components/board/StoreView.tsx +++ b/apps/frontend/src/components/board/StoreView.tsx @@ -1,59 +1,22 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import { getColorByBrightness } from "@mancala/core"; -import Util from "../../util/Util"; -import { PitViewModel } from "@mancala/core"; -import StoneView from "./StoneView"; +import { getMarkClasses, PitMarks } from "./PitView"; +import Stones from "./Stones"; +// A store: the top player's on the left ("l"), the viewer's on the right ("r"). const StoreView: FunctionComponent<{ - context: Context; - pitViewModel: PitViewModel; - gridColumn: string; - gridRow: string; -}> = ({ context, pitViewModel, gridColumn, gridRow }) => { - const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( - - )); - const textColor = getColorByBrightness( - pitViewModel.pitColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( -
- {stones} - - {stones.length} - - -
- ); -}; + index: number; + stoneCount: number; + side: "l" | "r"; + themeId: string; + label: string; + marks: PitMarks; +}> = ({ index, stoneCount, side, themeId, label, marks }) => ( +
+ + {stoneCount} + {!!marks.preview && {`+${marks.preview}`}} +
+); -export default StoreView; \ No newline at end of file +export default StoreView; diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx new file mode 100644 index 0000000..8757563 --- /dev/null +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseIcon } from "../Icons"; + +// A player's stones at the end, for the score columns. +export interface FinalScore { + name: string; + stones: number; + isWinner: boolean; + // The bottom player, whose wins are shown in the accent colour. + isBottom: boolean; +} + +// Over the board when a game ends: the result, why it ended, the score as two columns (name above stones), the +// series score, a line about a rematch, and the page's buttons. +const GameOverCard: FunctionComponent<{ + title: string, + reason?: string, + // The bottom player's first. + scores: [FinalScore, FinalScore], + seriesScore?: string, + status?: string, + // The status is something being waited for. + waiting?: boolean, + children: React.ReactNode, + onClose: () => void, +}> = ({ title, reason, scores, seriesScore, status, waiting, children, onClose }) => { + const { t } = useTranslation(); + const scoreColumn = (score: FinalScore) => ( +
+ {score.name} + {score.stones} +
+ ); + return ( +
+
+ +
{title}
+ {reason &&
{reason}
} +
`${s.name} ${s.stones}`).join(", ")}`}> + {scoreColumn(scores[0])} + + {scoreColumn(scores[1])} +
+ {seriesScore &&
{seriesScore}
} + {status && ( +
+ {waiting && } + {status} +
+ )} +
{children}
+
+
+ ); +}; + +export default GameOverCard; diff --git a/apps/frontend/src/components/game/PlayerCard.tsx b/apps/frontend/src/components/game/PlayerCard.tsx new file mode 100644 index 0000000..b0fcc95 --- /dev/null +++ b/apps/frontend/src/components/game/PlayerCard.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { getInitials } from "../../util/PlayerText"; + +// A player beside the board: avatar with initials and online status, name, a line of details and their clock. +// The player to move is ringed. Aligned to the right, everything is mirrored. +const PlayerCard: FunctionComponent<{ + name: string, + isGuest: boolean, + details: string, + active: boolean, + online?: boolean, + clock?: React.ReactNode, + alignRight?: boolean, +}> = ({ name, isGuest, details, active, online, clock, alignRight }) => { + const { t } = useTranslation(); + return ( +
+ +
+
{name}
+
+ {details} + {online !== undefined && {` · ${online ? t("Online") : t("Offline")}`}} +
+
+ {clock} +
+ ); +}; + +export default PlayerCard; diff --git a/apps/frontend/src/components/game/SeriesStrip.tsx b/apps/frontend/src/components/game/SeriesStrip.tsx new file mode 100644 index 0000000..cd8bcfb --- /dev/null +++ b/apps/frontend/src/components/game/SeriesStrip.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { getSeriesScore, Seat, SeriesGame } from "@mancala/core"; + +// The most boxes the strip shows; earlier games collapse into a "+N" box. +const MAX_BOXES = 8; + +// Above the board from the second game of a series (docs/specs/rematch, R3): the series score and one box per game, +// each with its winner's colour, the stores from the bottom player's side, a dot for who moved first and how it +// ended. Finished games open their replay in a new tab; the game being played is marked. +const SeriesStrip: FunctionComponent<{ + // Finished games, oldest first. + games: SeriesGame[], + // A game of the series is being played after them. + hasCurrentGame: boolean, + bottomSeat: Seat, + bottomName: string, + topName: string, +}> = ({ games, hasCurrentGame, bottomSeat, bottomName, topName }) => { + const { t } = useTranslation(); + const score = getSeriesScore(games, bottomSeat); + const finishedBoxes = MAX_BOXES - (hasCurrentGame ? 1 : 0); + const hiddenCount = games.length > finishedBoxes ? games.length - finishedBoxes + 1 : 0; + const shown = games.slice(hiddenCount); + + const renderGame = (game: SeriesGame, index: number) => { + const number = hiddenCount + index + 1; + const bottomStore = bottomSeat === "player1" ? game.player1Store : game.player2Store; + const topStore = bottomSeat === "player1" ? game.player2Store : game.player1Store; + const winner = game.result === "player1" || game.result === "player2" + ? (game.result === bottomSeat ? "mine" : "theirs") : "draw"; + const ending = game.endReason === "timeout" ? t("EndedOnTime") : game.endReason === "left" ? t("EndedByLeaving") : undefined; + const bottomFirst = game.firstSeat === bottomSeat; + const firstMark = ; + const label = [ + t("SeriesGameNumber", { number }), + `${bottomName} ${bottomStore} – ${topStore} ${topName}`, + `${t("MovedFirst")}: ${bottomFirst ? bottomName : topName}`, + ending, + ].filter(Boolean).join(", "); + return ( +
  • + + {ending ? `${number} · ${ending}` : number} + {bottomFirst && firstMark}{bottomStore}–{topStore}{!bottomFirst && firstMark} + +
  • + ); + }; + + return ( + + ); +}; + +export default SeriesStrip; diff --git a/apps/frontend/src/components/headerbar/AccountMenu.tsx b/apps/frontend/src/components/headerbar/AccountMenu.tsx new file mode 100644 index 0000000..f971cf6 --- /dev/null +++ b/apps/frontend/src/components/headerbar/AccountMenu.tsx @@ -0,0 +1,34 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { AuthUser, getPlayerDisplayName } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { ProfileIcon } from "../Icons"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// Guests can log in or sign up; logged in users can log out. +const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => { + const { t } = useTranslation(); + const authService = context.authService; + const menuButton = ( + + ); + const menuItems = authUser.isGuest ? [ + authService.login()}>{t("LogIn")}, + authService.signUp()}>{t("SignUp")}, + ] : [ + authService.logout()}>{t("LogOut")}, + ]; + return ( + + {getPlayerDisplayName(authUser, t)} + {menuItems} + + ); +} + +export default AccountMenu; diff --git a/apps/frontend/src/components/headerbar/AppHeader.tsx b/apps/frontend/src/components/headerbar/AppHeader.tsx new file mode 100644 index 0000000..bb22127 --- /dev/null +++ b/apps/frontend/src/components/headerbar/AppHeader.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { AuthUser } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { HelpIcon, LogoIcon } from "../Icons"; +import AccountMenu from "./AccountMenu"; +import ThemeSwatches from "./ThemeSwatches"; + +// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and +// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the +// rules over the game instead). +const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => { + const { t } = useTranslation(); + return ( +
    + + + {t("Mancala")} + + + {onHelp + ? + : } + {authUser && } + {children} +
    + ); +}; + +export default AppHeader; diff --git a/apps/frontend/src/components/headerbar/HeaderBar.tsx b/apps/frontend/src/components/headerbar/HeaderBar.tsx deleted file mode 100644 index 683f8c2..0000000 --- a/apps/frontend/src/components/headerbar/HeaderBar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react";; - -const HeaderBar: FunctionComponent<{ color?: string }> = ({children, color }) => { - return ( -
    - {children} - -
    ) -} -export default HeaderBar; diff --git a/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx b/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx deleted file mode 100644 index b012df0..0000000 --- a/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -//@ts-ignore -import MancalaIcon from "jsx:../../mancala.svg"; - -const HeaderbarIcon: FunctionComponent = () => { - return ( -
    - - -
    - ); -} - -export default HeaderbarIcon; \ No newline at end of file diff --git a/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx b/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx deleted file mode 100644 index 73e2bb0..0000000 --- a/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const HeaderbarTitle: FunctionComponent<{ title: string, color: string }> = ({ title, color }) => { - return ( -

    - {title} - -

    - ); -} - -export default HeaderbarTitle; \ No newline at end of file diff --git a/apps/frontend/src/components/headerbar/ThemeSwatches.tsx b/apps/frontend/src/components/headerbar/ThemeSwatches.tsx new file mode 100644 index 0000000..add43d9 --- /dev/null +++ b/apps/frontend/src/components/headerbar/ThemeSwatches.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { getWebTheme, webThemes } from "../../theme/themes"; + +const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t } = useTranslation(); + const current = getWebTheme(context.themeManager.theme); + return ( +
    + {webThemes.map((theme) => ( + + ))} +
    + ); +}; + +export default ThemeSwatches; diff --git a/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx b/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx deleted file mode 100644 index 6e45818..0000000 --- a/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Menu, MenuItem } from "@szhsin/react-menu"; -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import "@szhsin/react-menu/dist/index.css"; -import "@szhsin/react-menu/dist/transitions/slide.css" - -const ThemeSwitchMenu: FunctionComponent<{ context: Context, textColor: string }> = (props) => { - const { context, textColor } = props; - const menuButton = - light_mode - ; - const menuItems = context.themeManager.themes.map((theme, index) => { - return ( - { - const htmlElement: HTMLElement = event.target as HTMLElement; - if (htmlElement.localName === "li") htmlElement.style.background = "transparent"; - }} - //@ts-ignore - onMouseOut={(event) => { - const htmlElement: HTMLElement = event.target as HTMLElement; - if (htmlElement.localName === "li") htmlElement.style.background = "transparent"; - }} - onClick={() => (context.themeManager.theme = theme)}> -
    - {theme.name} - - - ); - }) - return ( -
    - - {menuItems} - - -
    - ); -} - -export default ThemeSwitchMenu; \ No newline at end of file diff --git a/apps/frontend/src/components/history/MoveList.css b/apps/frontend/src/components/history/MoveList.css new file mode 100644 index 0000000..448ec05 --- /dev/null +++ b/apps/frontend/src/components/history/MoveList.css @@ -0,0 +1,61 @@ +.moves { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; flex-shrink: 0; } + +.mhead { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 54px; + padding: 0 20px; + border: none; + background: transparent; + color: var(--ink); + font: inherit; + font-weight: 800; + font-size: 16px; + cursor: pointer; +} + +.mhead .muted { color: var(--muted); font-weight: 600; } +.mhead:focus-visible, .mrow:focus-visible { outline: 3px solid var(--accent); outline-offset: -3px; } + +.chev { flex: none; transition: transform .2s; } +.chev.closed { transform: rotate(-90deg); } + +.mlist { position: relative; max-height: 330px; overflow: auto; margin: 0; padding: 0 8px 8px; list-style: none; } + +.mrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 12px; + min-height: 44px; + padding: 8px 12px; + border-radius: 14px; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +@media (hover: hover) { .mrow:hover { background: var(--surface2); } } + +.mrow.on { background: var(--surface2); box-shadow: inset 3px 0 0 var(--accent); } +.mrow.on .tag { background: var(--surface); } +.mrow.on .tag.x { background: var(--accent); } +.mrow.on .tag.c { background: var(--ink); } + +.mn { width: 26px; color: var(--muted); font-variant-numeric: tabular-nums; } +.mdot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.mname { font-weight: 700; min-width: 120px; max-width: 14em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mpit { color: var(--muted); } +.mrow .tags { margin-left: auto; } + +@media (max-width: 640px) { + .mname { min-width: 0; } +} diff --git a/apps/frontend/src/components/history/MoveList.tsx b/apps/frontend/src/components/history/MoveList.tsx new file mode 100644 index 0000000..5f2d3ed --- /dev/null +++ b/apps/frontend/src/components/history/MoveList.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useRef, useState } from "react"; +import { describeMove, PlayedMove } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { ChevronIcon } from "../Icons"; +import MoveTags from "./MoveTags"; +import "./MoveList.css"; + +// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start. +const MoveList: FunctionComponent<{ + moves: PlayedMove[], + // Move whose position is shown. + selectedMove: number, + onSelect: (moveNumber: number) => void, + playerName: (playerId: string) => string, + // Moves of this player get an accent dot, the others a muted one. + accentPlayerId?: string, + // Live games list the newest move first, replays the start first. + newestFirst?: boolean, +}> = ({ moves, selectedMove, onSelect, playerName, accentPlayerId, newestFirst }) => { + const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(false); + const selectedRef = useRef(null); + const listRef = useRef(null); + + // Keep the shown move visible by scrolling the list only, never the page. + useEffect(() => { + const list = listRef.current, item = selectedRef.current; + if (!list || !item) return; + if (item.offsetTop < list.scrollTop) list.scrollTop = item.offsetTop; + else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) { + list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight; + } + }, [selectedMove, moves.length, collapsed]); + + const entry = (moveNumber: number) => { + const selected = moveNumber === selectedMove; + const move = moveNumber > 0 ? moves[moveNumber - 1] : undefined; + return ( +
  • + +
  • + ); + }; + + const moveNumbers = Array.from({ length: moves.length + 1 }, (_, i) => i); + if (newestFirst) moveNumbers.reverse(); + + return ( +
    + + {!collapsed &&
      {moveNumbers.map(entry)}
    } +
    + ); +}; + +export default MoveList; diff --git a/apps/frontend/src/components/history/MoveTags.tsx b/apps/frontend/src/components/history/MoveTags.tsx new file mode 100644 index 0000000..fab3934 --- /dev/null +++ b/apps/frontend/src/components/history/MoveTags.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { describeMove, PlayedMove } from "@mancala/core"; +import { useTranslation } from "react-i18next"; + +// What happened in a move, as tags: extra turn, capture, stones added to the store, game over. +const MoveTags: FunctionComponent<{ moves: PlayedMove[], moveNumber: number }> = ({ moves, moveNumber }) => { + const { t } = useTranslation(); + const move = describeMove(moves, moveNumber); + return ( + + {move.extraTurn && {t("ExtraTurn")}} + {move.capture && {t("Capture")}} + {move.storeGain > 0 && {t("StoreGain", { count: move.storeGain })}} + {move.gameOver && {t("GameOver")}} + + ); +}; + +export default MoveTags; diff --git a/apps/frontend/src/components/history/ReplayView.css b/apps/frontend/src/components/history/ReplayView.css new file mode 100644 index 0000000..b8d8ffa --- /dev/null +++ b/apps/frontend/src/components/history/ReplayView.css @@ -0,0 +1,32 @@ +.sum { display: flex; align-items: center; gap: 28px; padding: 22px 26px; border-radius: 28px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } +.sum-info { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.sum-info .pill { align-self: flex-start; } +.sum-meta { font-size: 14px; color: var(--muted); } +.sum-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; flex: 1; min-width: 0; } +.sum-vs .pn { font-weight: 700; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sum-vs .pn.r { text-align: right; } +.big { font-weight: 800; font-size: 34px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; } + +.ctl { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } +.ctlrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.tbtns { display: flex; align-items: center; gap: 8px; } +.tb { width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--surface2); color: var(--ink); display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } +.tb.big { width: 54px; height: 54px; background: var(--accent); color: var(--onAccent); } +.tb:disabled { opacity: .35; cursor: default; } +.speed { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); } +.segs { display: flex; gap: 2px; padding: 3px; border-radius: 24px; background: var(--surface2); } +.seg { min-width: 46px; height: 38px; border: none; border-radius: 19px; background: transparent; color: var(--muted); font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; } +.seg.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); } +.range { width: 100%; height: 28px; margin: 0; accent-color: var(--accent); cursor: pointer; } +.desc { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; color: var(--muted); } +.desc b { color: var(--ink); } +.tb:focus-visible, .seg:focus-visible, .range:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } + +@media (max-width: 640px) { + .sum { flex-direction: column; align-items: stretch; gap: 16px; padding: 18px; border-radius: 24px; } + .sum .btn { width: 100%; } + .big { font-size: 28px; } + .sum-vs .pn { font-size: 15px; } + .ctlrow { justify-content: center; } + .tbtns { width: 100%; justify-content: space-between; } +} diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx new file mode 100644 index 0000000..61e0973 --- /dev/null +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -0,0 +1,272 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"; +import { + BoardViewModel, + createGameAtMove, + createGameFromBoard, + DEFAULT_ANIMATION_INTERVAL, + GameRecord, + getBoardAfterMove, + describeMove, + getClockAtMove, + getPlayerDisplayName, + PitAnimator, + PlayerSummary, + Theme, +} from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { v4 } from "uuid"; +import { Context } from "../../context/context"; +import useMoveKeys from "../../hooks/useMoveKeys"; +import { createBoardViewModel } from "../../util/BoardViewModels"; +import { formatDate, formatDuration, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText"; +import BoardView from "../board/BoardView"; +import PageContainer from "../PageContainer"; +import AppHeader from "../headerbar/AppHeader"; +import { FirstIcon, LastIcon, NextIcon, PauseIcon, PlayIcon, PlusIcon, PreviousIcon, ReplayIcon } from "../Icons"; +import PlayerCard from "../game/PlayerCard"; +import GameClockView from "../GameClockView"; +import MoveList from "./MoveList"; +import MoveTags from "./MoveTags"; +import { GAME_STEP_GAME_MOVE } from "mancala.js"; +import "../../styles/game.css"; +import "./ReplayView.css"; + +const SPEEDS = [1, 2, 4]; +// Extra pause after a move's animation while playing, at 1x. +const PAUSE_BETWEEN_MOVES_MS = 700; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +function readMoveFromUrl(total: number): number { + const value = new URLSearchParams(window.location.search).get("move"); + const move = value === null ? NaN : Number(value); + return Number.isInteger(move) && move >= 0 && move <= total ? move : total; +} + +// Replay of a finished game: starts at the final position; moves can be stepped through, played at 1x/2x/4x or +// jumped to from the timeline and the move list. Players see the board from their own side. +const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: GameRecord, userKey?: string }> = ( + { context, theme, record, userKey }) => { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const moves = record.moves; + const total = moves.length; + const viewerId = getViewerSeat(record, userKey) ? userKey : undefined; + const revert = getViewerSeat(record, userKey) === "player2"; + + const baseGame = useMemo(() => { + const game = createGameFromBoard({ + id: record.id, player1Id: record.player1.userId, player2Id: record.player2.userId, + turnPlayerId: moves[total - 1]?.playerId ?? record.player1.userId, board: record.board, + }); + game.mancalaGame.state = "ended"; + return game; + }, [record.id]); + + const [moveNumber, setMoveNumber] = useState(() => readMoveFromUrl(total)); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [boardViewModel, setBoardViewModel] = useState( + () => createBoardViewModel(getBoardAfterMove(moves, moveNumber), theme)); + const animatorRef = useRef(); + + useEffect(() => { + // The animator changes its view model in place, so pass React a new object for every frame. + const animator = new PitAnimator(context.themeManager, + (viewModel) => setBoardViewModel({ id: v4(), pits: [...viewModel.pits] } as BoardViewModel)); + animator.setNewGame(createGameAtMove(baseGame, moves, moveNumber)); + animatorRef.current = animator; + return () => animator.dispose(); + }, [baseGame]); + + useEffect(() => { + window.history.replaceState(null, "", `${window.location.pathname}?move=${moveNumber}`); + }, [moveNumber]); + + // Shows a position without animation. + const showMove = (n: number) => { + const target = Math.min(Math.max(n, 0), total); + const animator = animatorRef.current; + if (animator) { + animator.clearCurrentInterval(); + animator.setNewGame(createGameAtMove(baseGame, moves, target)); + } + setMoveNumber(target); + }; + + // Plays the next move with the same animation as a live game. + const animateNextMove = () => { + const animator = animatorRef.current; + if (!animator || moveNumber >= total) return; + animator.clearCurrentInterval(); + animator.animationInterval = DEFAULT_ANIMATION_INTERVAL / speed; + animator.setNewGame(createGameAtMove(baseGame, moves, moveNumber)); + animator.setUpdatedGame(createGameAtMove(baseGame, moves, moveNumber + 1)); + setMoveNumber(moveNumber + 1); + }; + + const jumpTo = (n: number) => { + setPlaying(false); + showMove(n); + }; + + const togglePlay = () => { + if (playing) { + setPlaying(false); + } else { + if (moveNumber >= total) showMove(0); + setPlaying(true); + } + }; + + useEffect(() => { + if (!playing) return; + if (moveNumber >= total) { + setPlaying(false); + return; + } + // Wait for the previous move's animation (one step per sown stone and rule event) plus a pause. + const previousSteps = moveNumber > 0 ? moves[moveNumber - 1].gameSteps.length + 1 : 0; + const delay = (previousSteps * DEFAULT_ANIMATION_INTERVAL + PAUSE_BETWEEN_MOVES_MS) / speed; + const timer = setTimeout(animateNextMove, delay); + return () => clearTimeout(timer); + }, [playing, moveNumber, speed]); + + useMoveKeys({ + previous: () => jumpTo(moveNumber - 1), + next: () => { setPlaying(false); animateNextMove(); }, + togglePlay, + }); + + const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); + const playerName = (playerId: string) => + name(playerId === record.player1.userId ? record.player1 : record.player2); + const stones = getBoardAfterMove(moves, moveNumber); + const [topPlayer, topStore, bottomPlayer, bottomStore] = revert + ? [record.player1, PLAYER1_STORE, record.player2, PLAYER2_STORE] + : [record.player2, PLAYER2_STORE, record.player1, PLAYER1_STORE]; + const atEnd = moveNumber >= total; + const shownMove = moveNumber > 0 ? moves[moveNumber - 1] : undefined; + const nextMove = moves[moveNumber]; + // Where the shown move's last stone landed; not while a move is being animated. + const isAnimating = boardViewModel.pits.some((pit) => pit.highlight); + const sownSteps = shownMove?.gameSteps.filter((step) => step.type === GAME_STEP_GAME_MOVE) ?? []; + const landIndex = !isAnimating && sownSteps.length > 0 + ? sownSteps[sownSteps.length - 1].index % stones.length : undefined; + // Each player's clock after the shown move; games saved before the game clock have none. + const clockTimes = getClockAtMove(record, moveNumber); + const renderClock = (player: PlayerSummary) => clockTimes && ( + + ); + const playerCard = (player: PlayerSummary, storeIndex: number, alignRight: boolean) => ( + + ); + + const controlButton = (label: string, icon: React.ReactNode, onClick: () => void, disabled = false) => ( + + ); + + return ( + + + + + +
    +
    +
    + {getResultText(record, viewerId, t)} +
    + {[formatDate(record.endedAt, i18n.language), + `${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`, + t("MoveCount", { count: total })].join(" · ")} +
    +
    +
    + {name(topPlayer)} + {`${record.board[topStore]} – ${record.board[bottomStore]}`} + {name(bottomPlayer)} +
    + {total > 0 && ( + + )} +
    + +
    +
    {playerCard(topPlayer, topStore, false)}
    +
    {t("MoveOf", { number: moveNumber, total })}
    +
    {playerCard(bottomPlayer, bottomStore, true)}
    +
    + +
    +
    +
    +
    +
    + {controlButton(t("FirstMove"), , () => jumpTo(0), moveNumber === 0)} + {controlButton(t("PreviousMove"), , () => jumpTo(moveNumber - 1), moveNumber === 0)} + + {controlButton(t("NextMove"), , () => { setPlaying(false); animateNextMove(); }, atEnd)} + {controlButton(t("LastMove"), , () => jumpTo(total), atEnd)} +
    +
    + {t("Speed")} +
    + {SPEEDS.map((s) => ( + + ))} +
    +
    +
    + jumpTo(Number(event.target.value))} /> +
    + {moveNumber === 0 ? t("Start") : t("MoveNumber", { number: moveNumber })} + {shownMove + ? `${playerName(shownMove.playerId)} · ${t("PitNumber", { pit: describeMove(moves, moveNumber).pit })}` + : t("StartPosition")} + {shownMove && } +
    +
    + + +
    +
    +
    +
    + ); +}; + +export default ReplayView; diff --git a/apps/frontend/src/components/history/ReviewBanner.tsx b/apps/frontend/src/components/history/ReviewBanner.tsx new file mode 100644 index 0000000..088feb2 --- /dev/null +++ b/apps/frontend/src/components/history/ReviewBanner.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; + +// Shown during a live game while an earlier position is on the board. +const ReviewBanner: FunctionComponent<{ + moveNumber: number, + isYourTurn: boolean, + onBackToLive: () => void, +}> = ({ moveNumber, isYourTurn, onBackToLive }) => { + const { t } = useTranslation(); + return ( +
    + + {t("ViewingMove", { number: moveNumber })} + {isYourTurn && {` · ${t("YourTurn")}`}} + + +
    + ); +}; + +export default ReviewBanner; diff --git a/apps/frontend/src/components/home/ActiveGameBanner.tsx b/apps/frontend/src/components/home/ActiveGameBanner.tsx new file mode 100644 index 0000000..8b6219c --- /dev/null +++ b/apps/frontend/src/components/home/ActiveGameBanner.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getPlayerDisplayName, LiveGameSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import MiniBoard, { getStores } from "./MiniBoard"; + +// Shown at the top of the home page while the user has a game in progress. +const ActiveGameBanner: FunctionComponent<{ + game: LiveGameSummary, + userId: string, + onContinue: () => void, +}> = ({ game, userId, onContinue }) => { + const { t } = useTranslation(); + const opponent = game.player1.userId === userId ? game.player2 : game.player1; + const revert = game.player2.userId === userId; + const stores = getStores(game.board, revert); + const details = [ + game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn"), + t("MoveNumber", { number: game.moveCount }), + t("StoreScores", { mine: stores.bottom, theirs: stores.top }), + ].join(" · "); + return ( +
    +
    + {t("GameInProgress")} +

    {t("GameWithOpponent", { name: getPlayerDisplayName(opponent, t) ?? t("Anonymous") })}

    +

    {details}

    +
    + +
    +
    + + + +
    + ); +}; + +export default ActiveGameBanner; diff --git a/apps/frontend/src/components/home/GameCard.tsx b/apps/frontend/src/components/home/GameCard.tsx new file mode 100644 index 0000000..ce4e445 --- /dev/null +++ b/apps/frontend/src/components/home/GameCard.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getPlayerDisplayName, PlayerSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import MiniBoard, { getStores } from "./MiniBoard"; + +// A game as a card: the players with the score, a mini board, a status pill and details. Links to the game. +const GameCard: FunctionComponent<{ + id: string, + player1: PlayerSummary, + player2: PlayerSummary, + board: number[], + // Show player 2's side at the bottom. + revert?: boolean, + status: string, + // Class of the status pill: "live", "win", "lose" or "". + statusKind: string, + details: string, +}> = ({ id, player1, player2, board, revert, status, statusKind, details }) => { + const { t } = useTranslation(); + const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); + const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2]; + const stores = getStores(board, revert); + return ( + +
    + {name(topPlayer)} + {`${stores.top} – ${stores.bottom}`} + {name(bottomPlayer)} +
    + +
    + {status} + {details} +
    + + ); +}; + +export default GameCard; diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css new file mode 100644 index 0000000..3cdda22 --- /dev/null +++ b/apps/frontend/src/components/home/Home.css @@ -0,0 +1,56 @@ +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); + gap: 40px; + align-items: center; + padding: 32px; + border-radius: 32px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); +} + +.hero h1 { margin: 14px 0 8px; font-size: 34px; line-height: 1.1; letter-spacing: -.02em; } +.hero p { margin: 0 0 22px; color: var(--muted); font-size: 16px; } +.hero .row { display: flex; gap: 10px; flex-wrap: wrap; } +.hero-board { display: block; text-decoration: none; border-radius: 26px; } +.hero-board:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } + +.sec { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 18px 0 -10px; } +.sec h2 { margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -.01em; } +.secmeta { font-size: 14px; color: var(--muted); } +.empty { margin: 0; color: var(--muted); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } + +.card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + border-radius: 24px; + background: var(--surface); + color: var(--ink); + text-decoration: none; + box-shadow: inset 0 0 0 1px var(--line); + transition: transform .2s, box-shadow .2s; +} + +@media (hover: hover) { + .card:hover { transform: translateY(-3px); box-shadow: inset 0 0 0 1px var(--accentSoft), 0 16px 30px rgba(0, 0, 0, .18); } +} + +.vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 10px; font-weight: 700; font-size: 15px; } +.pn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pn.r { text-align: right; } +.score { font-weight: 800; font-size: 17px; font-variant-numeric: tabular-nums; padding: 2px 10px; border-radius: 10px; background: var(--surface2); white-space: nowrap; } +.meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); min-width: 0; } +.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.meta > span:last-child { text-align: right; } + +@media (max-width: 640px) { + .hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; } + .hero h1 { font-size: 25px; } + .hero .btn { flex: 1; } + .grid { grid-template-columns: 1fr; gap: 14px; } + .sec { margin: 12px 0 -4px; } +} diff --git a/apps/frontend/src/components/home/HomeSection.tsx b/apps/frontend/src/components/home/HomeSection.tsx new file mode 100644 index 0000000..778f3f9 --- /dev/null +++ b/apps/frontend/src/components/home/HomeSection.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; + +// A titled block of the home page; children are laid out as a grid of cards. +const HomeSection: FunctionComponent<{ title: string, subtitle: string, emptyText?: string }> = (props) => { + const isEmpty = React.Children.count(props.children) === 0; + return ( + <> +

    {props.title}

    {props.subtitle}
    + {isEmpty && props.emptyText ?

    {props.emptyText}

    :
    {props.children}
    } + + ); +}; + +export default HomeSection; diff --git a/apps/frontend/src/components/home/LiveGamesSection.tsx b/apps/frontend/src/components/home/LiveGamesSection.tsx new file mode 100644 index 0000000..7d8ad65 --- /dev/null +++ b/apps/frontend/src/components/home/LiveGamesSection.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useState } from "react"; +import { + channel_on_live_games, + channel_subscribe_live_games, + channel_unsubscribe_live_games, + LiveGameSummary, +} from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import GameCard from "./GameCard"; +import HomeSection from "./HomeSection"; + +// Live games, updated as they are played; hidden while nothing is being played. +const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t } = useTranslation(); + const [games, setGames] = useState([]); + + useEffect(() => { + const onLiveGames = (message: { games: LiveGameSummary[] }) => setGames(message.games); + // Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users. + const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {}); + context.rtmt.addMessageListener(channel_on_live_games, onLiveGames); + context.rtmt.on("open", subscribe); + if (context.rtmt.connectionState === "connected") subscribe(); + return () => { + context.rtmt.off("open", subscribe); + context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames); + if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {}); + }; + }, []); + + if (games.length === 0) return <>; + return ( + + {games.map((game) => ( + + ))} + + ); +}; + +export default LiveGamesSection; diff --git a/apps/frontend/src/components/home/MiniBoard.css b/apps/frontend/src/components/home/MiniBoard.css new file mode 100644 index 0000000..17d5c87 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.css @@ -0,0 +1,19 @@ +/* Mini board: the top player's store on the left, the bottom player's on the right */ + +.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } +.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms.l { grid-column: 1; } +.mini .ms.r { grid-column: 8; color: var(--accent); } +.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } +.mini.lg .mp { font-size: 16px; } +.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } +/* A move shown on the board: the pit played and where its last stone landed */ +.mini .played { box-shadow: inset 0 0 0 2px var(--accent); } +.mini .land, .mini .ms.r.land { background: var(--accent); color: var(--onAccent); } + +@media (max-width: 640px) { + .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } + .mini.lg .mp { font-size: 13px; } + .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } +} diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx new file mode 100644 index 0000000..8923dd3 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import "./MiniBoard.css"; + +const PLAYER1_PITS = [0, 1, 2, 3, 4, 5]; +const PLAYER2_PITS = [7, 8, 9, 10, 11, 12]; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. `played` and `landed` mark +// a move: the pit played and where its last stone landed. +const MiniBoard: FunctionComponent<{ + board: number[], revert?: boolean, large?: boolean, played?: number, landed?: number, +}> = ({ board, revert, large, played, landed }) => { + const [topPits, topStore, bottomPits, bottomStore] = revert + ? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE] + : [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE]; + const marks = (index: number) => (index === played ? " played" : "") + (index === landed ? " land" : ""); + const pit = (index: number) =>
    {board[index] || ""}
    ; + return ( + + ); +}; + +export const getStores = (board: number[], revert?: boolean) => revert + ? { top: board[PLAYER1_STORE], bottom: board[PLAYER2_STORE] } + : { top: board[PLAYER2_STORE], bottom: board[PLAYER1_STORE] }; + +export default MiniBoard; diff --git a/apps/frontend/src/components/home/PopularGamesSection.tsx b/apps/frontend/src/components/home/PopularGamesSection.tsx new file mode 100644 index 0000000..7e453d7 --- /dev/null +++ b/apps/frontend/src/components/home/PopularGamesSection.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useState } from "react"; +import { FinishedGameSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { formatDate, getResultPillClass, getResultText } from "../../util/GameResultText"; +import GameCard from "./GameCard"; +import HomeSection from "./HomeSection"; + +const GAME_COUNT = 6; + +// The most watched finished games. +const PopularGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t, i18n } = useTranslation(); + const [games, setGames] = useState(undefined); + + useEffect(() => { + let disposed = false; + context.gameStore.getPopularFinishedGames(GAME_COUNT) + .then((popular) => !disposed && setGames(popular)) + .catch((error) => console.error("(Home) Could not load popular games", error)); + return () => { disposed = true; }; + }, []); + + if (!games) return <>; + return ( + + {games.map((game) => ( + + ))} + + ); +}; + +export default PopularGamesSection; diff --git a/apps/frontend/src/components/home/RecentGamesSection.tsx b/apps/frontend/src/components/home/RecentGamesSection.tsx new file mode 100644 index 0000000..8201fc3 --- /dev/null +++ b/apps/frontend/src/components/home/RecentGamesSection.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useState } from "react"; +import { FinishedGameSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { formatDate, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText"; +import GameCard from "./GameCard"; +import HomeSection from "./HomeSection"; + +const GAME_COUNT = 3; + +// The user's last finished games, each shown from their side of the board. +const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => { + const { t, i18n } = useTranslation(); + const [games, setGames] = useState(undefined); + + useEffect(() => { + let disposed = false; + context.authService.getAccessToken() + .then((token) => context.gameStore.getMyRecentGames(token, GAME_COUNT)) + .then((recent) => !disposed && setGames(recent)) + .catch((error) => console.error("(Home) Could not load recent games", error)); + return () => { disposed = true; }; + }, [userId]); + + if (!games) return <>; + return ( + + {games.map((game) => ( + + ))} + + ); +}; + +export default RecentGamesSection; diff --git a/apps/frontend/src/components/home/TutorialBanner.tsx b/apps/frontend/src/components/home/TutorialBanner.tsx new file mode 100644 index 0000000..7d3d1da --- /dev/null +++ b/apps/frontend/src/components/home/TutorialBanner.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { FunctionComponent, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { hideTutorialBanner, shouldShowTutorialBanner } from "@mancala/core"; +import { Context } from "../../context/context"; + +// For newcomers: an offer to learn the game, until they open the lessons, play a game or say not now (R4). +const TutorialBanner: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [visible, setVisible] = useState(() => shouldShowTutorialBanner(context.storage)); + if (!visible) return null; + return ( +
    + {t("NewToMangala")} +
    + + +
    + +
    + ); +}; + +export default TutorialBanner; diff --git a/apps/frontend/src/components/rules/RuleBoard.tsx b/apps/frontend/src/components/rules/RuleBoard.tsx new file mode 100644 index 0000000..5d925ef --- /dev/null +++ b/apps/frontend/src/components/rules/RuleBoard.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getMoveOutcome, RuleExample } from "@mancala/core"; +import MiniBoard from "../home/MiniBoard"; +import { NextIcon } from "../Icons"; + +// A rule's example: the board before the move with the pit played marked, and after it with the landing pit marked. +// The board after the move comes from the engine. +const RuleBoard: FunctionComponent<{ example: RuleExample }> = ({ example }) => { + if (example.pitIndex === undefined) { + return
    ; + } + const outcome = getMoveOutcome(example.board, example.pitIndex); + return ( +
    + + + + +
    + ); +}; + +export default RuleBoard; diff --git a/apps/frontend/src/components/rules/RulesPanel.tsx b/apps/frontend/src/components/rules/RulesPanel.tsx new file mode 100644 index 0000000..566b0be --- /dev/null +++ b/apps/frontend/src/components/rules/RulesPanel.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseIcon } from "../Icons"; +import RulesView from "./RulesView"; + +// The rules over the game page, so a player can look a rule up without leaving the game (R1.4). +const RulesPanel: FunctionComponent<{ onClose: () => void }> = ({ onClose }) => { + const { t } = useTranslation(); + return ( +
    + + +
    + ); +}; + +export default RulesPanel; diff --git a/apps/frontend/src/components/rules/RulesView.tsx b/apps/frontend/src/components/rules/RulesView.tsx new file mode 100644 index 0000000..2ca7727 --- /dev/null +++ b/apps/frontend/src/components/rules/RulesView.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { RULES } from "@mancala/core"; +import RuleBoard from "./RuleBoard"; + +// Every rule in order with its text and example (docs/specs/tutorial, R1). `showLessons` adds a link to each lesson; +// left out where following it would leave a game. +const RulesView: FunctionComponent<{ showLessons?: boolean }> = ({ showLessons }) => { + const { t } = useTranslation(); + return ( +
      + {RULES.map((rule) => ( +
    1. +

      {t(rule.titleKey)}

      + {rule.textKeys.map((key) =>

      {t(key)}

      )} + {rule.example && } + {showLessons && rule.lesson && ( + {t("TryIt")} + )} +
    2. + ))} + +
    + ); +}; + +export default RulesView; diff --git a/apps/frontend/src/const/config.ts b/apps/frontend/src/const/config.ts index 4da4a82..c199ef8 100644 --- a/apps/frontend/src/const/config.ts +++ b/apps/frontend/src/const/config.ts @@ -15,4 +15,13 @@ export const server: Server = { wsServerAdress: process.env.WS_SERVER_ADDRESS ? `${process.env.WS_SERVER_ADDRESS}` : LOCAL_WS_SERVER_ADDRESS, } +export const LOCAL_KEYCLOAK_URL = 'http://localhost:8080'; + +export const keycloak = { + //@ts-ignore + url: process.env.KEYCLOAK_URL ? `${process.env.KEYCLOAK_URL}` : LOCAL_KEYCLOAK_URL, + realm: "mancala", + clientId: "mancala-web", +}; + export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000; \ No newline at end of file diff --git a/apps/frontend/src/context/context.tsx b/apps/frontend/src/context/context.tsx index 3dacda4..e881751 100644 --- a/apps/frontend/src/context/context.tsx +++ b/apps/frontend/src/context/context.tsx @@ -1,14 +1,15 @@ -import { server } from "../const/config"; +import { keycloak, server } from "../const/config"; import { RTMT, RTMTWS, ThemeManager } from "@mancala/core"; import { HttpServiceImpl } from "../service/HttpService"; import { GameStore, GameStoreImpl } from "../store/GameStore"; -import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore"; +import { AuthService } from "../auth/AuthService"; import { LocalStorage } from "../storage"; import { Storage } from '@mancala/core' +import { webThemes } from "../theme/themes"; export type Context = { rtmt: RTMT; - userKeyStore: UserKeyStore; + authService: AuthService; themeManager: ThemeManager; gameStore: GameStore; storage: Storage; @@ -17,13 +18,13 @@ export type Context = { export const initContext = (): Context => { const rtmt = new RTMTWS(); const httpService = new HttpServiceImpl(server.serverAdress); - const userKeyStore = new UserKeyStoreImpl({ httpService }); + const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId }); const gameStore = new GameStoreImpl({ httpService }); const storage = new LocalStorage(); - const themeManager = new ThemeManager(storage); + const themeManager = new ThemeManager(storage, webThemes); return { rtmt: rtmt, - userKeyStore: userKeyStore, + authService, themeManager, gameStore, storage diff --git a/apps/frontend/src/hooks/useGameClock.ts b/apps/frontend/src/hooks/useGameClock.ts new file mode 100644 index 0000000..2fdb317 --- /dev/null +++ b/apps/frontend/src/hooks/useGameClock.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef, useState } from "react"; +import { ClockState, ClockSync, Game, GameEndMessage } from "@mancala/core"; + +const TICK_MS = 250; + +// Both players' clocks in a live game, counting down between the server's messages. Undefined when the server sends +// no clock. +export default function useGameClock(game: Game | undefined, gameEnd: GameEndMessage | undefined): ClockState | undefined { + const sync = useRef(new ClockSync()).current; + const lastGame = useRef(); + // Every game message is a new object, so a changed reference means a new message. + if (game && game !== lastGame.current) { + sync.update(game); + lastGame.current = game; + } + if (gameEnd) sync.stop(gameEnd.clock); + + const [, setTick] = useState(0); + const isRunning = sync.isRunning; + useEffect(() => { + if (!isRunning) return; + const timer = setInterval(() => setTick((tick) => tick + 1), TICK_MS); + return () => clearInterval(timer); + }, [isRunning]); + + return sync.getTimes(); +} diff --git a/apps/frontend/src/hooks/useMoveKeys.ts b/apps/frontend/src/hooks/useMoveKeys.ts new file mode 100644 index 0000000..c4e7e9d --- /dev/null +++ b/apps/frontend/src/hooks/useMoveKeys.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +// Arrow keys step through moves and space toggles play; ignored while typing in a form field. +export default function useMoveKeys(handlers: { previous: () => void, next: () => void, togglePlay?: () => void }) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) && target.getAttribute("type") !== "range") return; + if (event.key === "ArrowLeft") handlers.previous(); + else if (event.key === "ArrowRight") handlers.next(); + else if (event.key === " " && handlers.togglePlay) handlers.togglePlay(); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); +} diff --git a/apps/frontend/src/hooks/useWindowDimensions.ts b/apps/frontend/src/hooks/useWindowDimensions.ts deleted file mode 100644 index 21daa13..0000000 --- a/apps/frontend/src/hooks/useWindowDimensions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useState, useEffect } from 'react'; - -//https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs - -function getWindowDimensions() { - const { innerWidth: width, innerHeight: height } = window; - return { - width, - height - }; -} - -export default function useWindowDimensions() { - const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); - - useEffect(() => { - function handleResize() { - setWindowDimensions(getWindowDimensions()); - } - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - return windowDimensions; -} diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html index 3e12ef0..156bef2 100644 --- a/apps/frontend/src/index.html +++ b/apps/frontend/src/index.html @@ -1,31 +1,18 @@ - + Mancala + + + - -
    + +
    - - - - - \ No newline at end of file + diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 1abb708..0f7955c 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -2,33 +2,58 @@ import { MancalaGame, Pit } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { Link } from 'react-router-dom'; import { v4 } from 'uuid'; import { PitAnimator } from '@mancala/core'; -import BoardToolbar from '../components/board/BoardToolbar'; import BoardView from '../components/board/BoardView'; -import Button from '../components/Button'; -import HeaderBar from '../components/headerbar/HeaderBar'; -import HeaderbarIcon from '../components/headerbar/HeaderbarIcon'; -import HeaderbarTitle from '../components/headerbar/HeaderbarTitle'; -import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; -import InfoPanel from '../components/InfoPanel'; import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; -import Row from '../components/Row'; -import UserStatus from '../components/UserStatus'; -import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events } from '@mancala/core'; +import AppHeader from '../components/headerbar/AppHeader'; +import { LeaveIcon, PlusIcon } from '../components/Icons'; +import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core'; +import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver, getSeriesScore } from '@mancala/core'; import { Context } from '../context/context'; -import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; -import { Theme } from '@mancala/core'; -import { getColorByBrightness } from "@mancala/core"; +import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; -import Center from '../components/Center'; import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; import { useTranslation } from 'react-i18next'; +import MoveList from '../components/history/MoveList'; +import ReviewBanner from '../components/history/ReviewBanner'; +import useMoveKeys from '../hooks/useMoveKeys'; +import { createBoardViewModel } from '../util/BoardViewModels'; +import { getResultText } from '../util/GameResultText'; +import ReplayView from '../components/history/ReplayView'; +import GameClockView from '../components/GameClockView'; +import useGameClock from '../hooks/useGameClock'; +import PlayerCard from '../components/game/PlayerCard'; +import GameOverCard from '../components/game/GameOverCard'; +import SeriesStrip from '../components/game/SeriesStrip'; +import RulesPanel from '../components/rules/RulesPanel'; +import '../styles/game.css'; + +// Where a rematch of the ended game stands (docs/specs/rematch): none before the end, available to ask, waiting for +// the opponent, requested by the opponent, or over (a player left, said no or looked for another game). +type RematchStatus = "none" | "available" | "waiting" | "requested" | "over"; + +interface RematchState { + status: RematchStatus; + // Translation key of what happened to the last request; worded with the opponent's name. + note?: string; +} + +// What the player is told when a request ends; nothing when they ended it themselves. +function getRematchNoteKey(status: RematchStatus, reason: RematchEndReason): string | undefined { + switch (reason) { + case "declined": return status === "waiting" ? "RematchDeclined" : undefined; + case "withdrawn": return status === "requested" ? "RematchWithdrawn" : undefined; + case "expired": return status === "waiting" ? "RematchNoAnswer" : status === "requested" ? "RematchRequestExpired" : undefined; + case "offline": + case "left": return status === "over" ? undefined : "RematchOpponentGone"; + case "closed": return status === "over" ? undefined : "RematchClosed"; + } +} const GamePage: FunctionComponent<{ context: Context, @@ -53,7 +78,23 @@ const GamePage: FunctionComponent<{ const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); - const { height, width } = useWindowDimensions(); + // Set while an earlier position is shown instead of the live board. + const [viewedMove, setViewedMove] = useState(undefined); + + // Set when the game has ended and was loaded from history; the page then shows the replay. + const [replayRecord, setReplayRecord] = useState(undefined); + + // Set when the server says the game ended, for whatever reason. + const [gameEnd, setGameEnd] = useState(undefined); + // The rules over the game; the game goes on behind them. + const [rulesOpen, setRulesOpen] = useState(false); + // Hints in games against the bot (docs/specs/tutorial, R3); remembered on the device. + const [hintsEnabled, setHintsEnabledState] = useState(() => getHintsEnabled(context.storage)); + + // Set when the player closes the game over card to look at the board. + const [gameOverClosed, setGameOverClosed] = useState(false); + + const [rematch, setRematch] = useState({ status: "none" }); const navigate = useNavigate(); @@ -84,6 +125,11 @@ const GamePage: FunctionComponent<{ return false; } + const getPlayerName = (userId: string) => { + const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]; + return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t); + } + const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => { const newGame: Game = message as Game; newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame); @@ -100,6 +146,11 @@ const GamePage: FunctionComponent<{ setUserKeyWhoLeave(userKeyWhoLeave); setHasOngoingAction(false); }; + const onGameEnd = (message: GameEndMessage) => { + setGameEnd(message); + setHasOngoingAction(false); + setRematch({ status: message.rematch === "available" ? "available" : "over" }); + }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; setGameUsersConnectionInfo(gameUsersConnectionInfo); @@ -107,13 +158,36 @@ const GamePage: FunctionComponent<{ const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => { const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message); + // The opponent asked for a rematch; the card comes back if it was closed. + const onRematchRequest = (message: RematchRequest) => { + if (message.gameId !== game.id) return; + setRematch({ status: "requested" }); + setGameOverClosed(false); + }; + const onRematchEnded = (message: RematchEnded) => { + if (message.gameId !== game.id) return; + setRematch((rematch) => ({ + status: isSeriesOver(message.reason) ? "over" : "available", + note: getRematchNoteKey(rematch.status, message.reason), + })); + }; + // The server sends a player's new game only to them: here, the rematch both have asked for. + const onGameStart = (message: Game) => { + if (message.id !== game.id) navigate(`/game/${message.id}`); + }; context.rtmt.addMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange); - checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id); + context.rtmt.addMessageListener(channel_on_game_end, onGameEnd); + context.rtmt.addMessageListener(channel_on_rematch_request, onRematchRequest); + context.rtmt.addMessageListener(channel_on_rematch_ended, onRematchEnded); + context.rtmt.addMessageListener(channel_on_game_start, onGameStart); return () => { - checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id); + context.rtmt.removeMessageListener(channel_on_rematch_request, onRematchRequest); + context.rtmt.removeMessageListener(channel_on_rematch_ended, onRematchEnded); + context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); + context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd); context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); @@ -121,6 +195,19 @@ const GamePage: FunctionComponent<{ } }; + // Spectators tell the server they are watching once their identity is known and the connection is open, + // and again after every reconnect, since the server forgets spectators who go offline. + React.useEffect(() => { + if (!game || !userKey || !checkIsSpectator(game)) return; + const listen = () => context.rtmt.sendMessage(channel_listen_game_events, game.id); + if (context.rtmt.connectionState === "connected") listen(); + context.rtmt.on("open", listen); + return () => { + context.rtmt.off("open", listen); + if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unlisten_game_events, game.id); + }; + }, [game?.id, userKey]); + const updateBoardViewModel = (boardViewModel: BoardViewModel) => { boardViewModel.id = v4(); setBoardId(boardViewModel.id); @@ -143,12 +230,13 @@ const GamePage: FunctionComponent<{ swal({ title: t("AreYouSureToLeaveGame"), icon: "warning", - buttons: [t("Yes"), t("Cancel")], + buttons: [t("Cancel"), t("Yes")], dangerMode: true, }) - .then((cancel) => { - if (!cancel) { + .then((confirm) => { + if (confirm) { context.rtmt.sendMessage(channel_leave_game, {}); + navigate("/"); } }); }; @@ -158,14 +246,41 @@ const GamePage: FunctionComponent<{ navigate("/loby") }; + // In a game against the bot, the game over card starts another one at the same level. + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + + // Once a rematch of a bot game is no longer possible, "Play again" starts a new game at the same level. + const onPlayAgainClick = () => { + if (Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${opponentBotLevel}`) + }; + + // Asks for a rematch, or accepts the opponent's request; the bot accepts at once. + const onRematchClick = () => { + if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return; + context.rtmt.sendMessage(channel_rematch, { gameId: game.id }); + setRematch({ status: "waiting" }); + }; + + // Withdraws the player's request, or says no to the opponent's, which ends the series. + const onDeclineRematchClick = () => { + if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return; + context.rtmt.sendMessage(channel_decline_rematch, { gameId: game.id }); + setRematch({ status: rematch.status === "waiting" ? "available" : "over" }); + }; + const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; } - if(userKeyWhoLeave) { + if(userKeyWhoLeave || gameEnd) { notyf.error(t("GameEnded")); return; } + if (viewedMove !== undefined) { + notyf.error(t("ViewingMove", { number: viewedMove })); + return; + } if(game.mancalaGame.state === "ended") { notyf.error(t("GameEnded")); return; @@ -191,8 +306,9 @@ const GamePage: FunctionComponent<{ return; } setHasOngoingAction(true); - boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor = - context.themeManager.theme.pitSelectedColor; + const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)]; + selectedPit.pitColor = context.themeManager.theme.pitSelectedColor; + selectedPit.highlight = "selected"; updateBoardViewModel(boardViewModel); const gameMove: GameMove = { index: pitIndexForUser }; context.rtmt.sendMessage(channel_game_move, gameMove); @@ -202,16 +318,22 @@ const GamePage: FunctionComponent<{ let pitAnimator: PitAnimator | undefined; let unlistenMessages: () => void; setLoadingStateGame(LoadingState.Loading()) - context.gameStore.get(params.gameId!!).then((game) => { - if (game) { + context.gameStore.get(params.gameId!!).then((loaded) => { + if (loaded?.kind === "live") { + const game = loaded.game; pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel); setPitAnimator(pitAnimator); onGameUpdate(pitAnimator, game); unlistenMessages = listenMessages(game, pitAnimator); setLoadingStateGame(LoadingState.Loaded({ value: game })) + } else if (loaded?.kind === "finished") { + setReplayRecord(loaded.record); } else { setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") })) } + }).catch((error) => { + console.error(error); + setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") })) }) return () => { unlistenMessages?.(); @@ -219,112 +341,279 @@ const GamePage: FunctionComponent<{ }; }, []); - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const isMobile = width < 600; + const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); - const renderNewGameBtn = isSpectator || (userKeyWhoLeave || !game || (game && game.mancalaGame.state == "ended")); + const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; + const isReviewing = viewedMove !== undefined && viewedMove < moves.length; + + // Hints are only for games against the bot, never between people (R3.5). + const hintsOn = !!opponentBotLevel && hintsEnabled; + const toggleHints = () => { + setHintsEnabled(context.storage, !hintsEnabled); + setHintsEnabledState(!hintsEnabled); + }; + const getHints = (): Record | undefined => { + if (!hintsOn || !mancalaGame || isReviewing || hasOngoingAction) return undefined; + if (mancalaGame.turnPlayerId !== userKey || mancalaGame.state === "ended") return undefined; + const stones = mancalaGame.board.getStoneArray(); + const first = isPlayer2 ? 7 : 0; + const hints: Record = {}; + for (let index = first; index < first + 6; index++) { + if (!stones[index]) continue; + const outcome = getMoveOutcome(stones, index); + if (outcome.capture) hints[index] = t("HintCaptures", { count: outcome.captured }); + else if (outcome.extraTurn) hints[index] = t("HintExtraTurn"); + } + return hints; + }; + // What the last move did and why, under the board (R3.3). + const getLastMoveExplanation = (): string | undefined => { + if (!hintsOn || moves.length === 0 || isReviewing) return undefined; + const last = moves[moves.length - 1]; + const outcome = getMoveOutcome(getBoardAfterMove(moves, moves.length - 1), last.moveIndex); + return explainMove(outcome, last.playerId === userKey ? "you" : "bot", t); + }; + const shownBoardViewModel = isReviewing && mancalaGame + ? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme) + : boardViewModel; + const isGameOver = !!(userKeyWhoLeave || gameEnd || mancalaGame?.state === "ended"); + const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey; + const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber)); + useMoveKeys({ + previous: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1), + next: () => !replayRecord && isReviewing && selectMove(viewedMove! + 1), + }); + + const playerInfo = (userId: string) => { + const info = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] + .find((connectionInfo) => connectionInfo?.userId === userId); + return { userId, name: info?.name, isGuest: info?.isGuest ?? true }; + }; + + // How the game ended, as a saved game would say it; from the server's message when it sent one. + const getEndSummary = (): FinishedGameSummary | undefined => { + if (!mancalaGame || !isGameOver) return undefined; + const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id); + const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2"; + if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary; + if (userKeyWhoLeave) { + const winner = userKeyWhoLeave === mancalaGame.player1Id ? "player2" : "player1"; + return { result: winner, endReason: "left", leftUserId: userKeyWhoLeave, player1, player2 } as FinishedGameSummary; + } + const winnerId = mancalaGame.getWonPlayerId(); + return { result: winnerId ? seatOf(winnerId) : "draw", endReason: "completed", player1, player2 } as FinishedGameSummary; + }; + const endSummary = getEndSummary(); + // "You won · Opponent left" becomes the title "You won" and the reason "Opponent left". + const [resultTitle, ...resultReasons] = endSummary + ? getResultText(endSummary, isSpectator ? undefined : userKey, t).split(" · ") + : []; + const leftUserId = userKeyWhoLeave ?? gameEnd?.leftUserId; + const resultReason = resultReasons.join(" · ") + || (isSpectator && leftUserId ? `${getPlayerName(leftUserId) ?? t("Anonymous")} ${t("UserLeftTheGame")}` : undefined); + + const clockTimes = useGameClock(game, gameEnd); + const renderClock = (playerId: string) => { + const seat = mancalaGame && getSeat(mancalaGame, playerId); + if (!clockTimes || !seat) return undefined; + const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms; + return ; + }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: getPlayerName(topLocatedUserId) ?? t("Anonymous"), + isGuest: playerInfo(topLocatedUserId).isGuest, isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"), + isGuest: playerInfo(bottomLocatedUserId).isGuest, isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true }; - const currentUser = isSpectator ? { - id: "2", - name: "Anonymous", - isOnline: context.rtmt.connectionState === "connected", - isAnonymous: true - } : bottomLocatedUser; - const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined; + if (replayRecord) { + return ; + } + + const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined; + + // The series' finished games, with this one once it has ended (R3). + const seriesGames = gameEnd?.series?.games ?? game?.series?.games ?? []; + const hasCurrentSeriesGame = !isGameOver; + const showSeries = seriesGames.length + (hasCurrentSeriesGame ? 1 : 0) > 1; + const bottomSeat = (mancalaGame && getSeat(mancalaGame, bottomLocatedUserId)) ?? "player1"; + // "You" for a player, the names for spectators (R3.7). + const seriesBottomName = isSpectator ? bottomLocatedUser.name : t("You"); + const getSeriesScoreText = (): string | undefined => { + if (!showSeries) return undefined; + const score = getSeriesScore(seriesGames, bottomSeat); + return t("SeriesScoreText", { first: seriesBottomName, firstWins: score.wins, secondWins: score.losses, second: topLocatedUser.name }); + }; + + const getRematchStatusText = (): string | undefined => { + if (isSpectator) return undefined; + const name = topLocatedUser.name; + if (rematch.status === "waiting" && !opponentBotLevel) return t("RematchWaiting", { name }); + if (rematch.status === "requested") return t("RematchRequested", { name }); + return rematch.note && t(rematch.note, { name }); + }; + + const renderGameOverButtons = () => { + const newGame = (primary: boolean) => ( + + ); + const home = ; + if (isSpectator) return [newGame(true), home]; + if (opponentBotLevel) { + const canRematch = rematch.status === "available" || rematch.status === "waiting"; + return [ + , + home, + ]; + } + switch (rematch.status) { + case "available": + return [, newGame(false), home]; + case "waiting": + return [, home]; + case "requested": + return [ + , + , + ]; + default: + return [newGame(true), home]; + } + }; + + const getTurnText = () => { + if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded"); + if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`; + return isYourTurn ? t("YourTurn") : t("OpponentTurn"); + }; + + const renderGame = () => { + const board = mancalaGame!.board; + const lastMoveExplanation = getLastMoveExplanation(); + const [topStore, bottomStore] = isPlayer2 + ? [board.player1BankIndex(), board.player2BankIndex()] + : [board.player2BankIndex(), board.player1BankIndex()]; + const shownStones = (index: number) => shownBoardViewModel!.pits[index].stoneCount; + const playerCard = (user: typeof topLocatedUser, storeIndex: number, role: string, alignRight: boolean) => ( + + ); + return ( +
    + {showSeries && ( +
    + +
    + )} +
    {playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}
    +
    +
    + {!isGameOver && } + {getTurnText()} +
    + {opponentBotLevel && ( + + )} +
    +
    {playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}
    +
    + {isReviewing && ( + setViewedMove(undefined)} /> + )} + + {lastMoveExplanation &&

    {lastMoveExplanation}

    } + {isGameOver && !gameOverClosed && ( + setGameOverClosed(true)}> + {renderGameOverButtons()} + + )} +
    +
    + getPlayerName(playerId) ?? t("Anonymous")} + accentPlayerId={bottomLocatedUserId} + newestFirst /> +
    +
    + ); + }; + return ( - + {renderHeaderBar()} - {renderMobileBoardToolbar()} - {buildBoardTopToolbar()} - {showBoardView && ( - - )} -
    - -
    +
    + {showBoardView && renderGame()} + +
    + {rulesOpen && setRulesOpen(false)} />}
    ); function renderHeaderBar() { - return - - - - - - - - - - - + : } + ; } } -export default GamePage; +// A page of its own for every game, so following a rematch to the new game's id starts the page afresh. +const GameRoute: FunctionComponent> = (props) => { + const { gameId } = useParams<{ gameId: string }>(); + return ; +}; +export default GameRoute; diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index 09da876..cb7f733 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,27 +1,43 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; -import { getColorByBrightness } from "@mancala/core"; -import { Theme } from "@mancala/core"; -import HeaderBar from "../components/headerbar/HeaderBar"; +import { LiveGameSummary } from "@mancala/core"; +import { AuthUser, BotLevel, Theme } from "@mancala/core"; +import AppHeader from "../components/headerbar/AppHeader"; import PageContainer from "../components/PageContainer"; -import Row from "../components/Row"; -import HeaderbarIcon from "../components/headerbar/HeaderbarIcon"; -import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; -import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; -import Button from "../components/Button"; +import { PlusIcon } from "../components/Icons"; +import BotLevelMenu from "../components/BotLevelMenu"; +import ActiveGameBanner from "../components/home/ActiveGameBanner"; +import TutorialBanner from "../components/home/TutorialBanner"; +import LiveGamesSection from "../components/home/LiveGamesSection"; +import RecentGamesSection from "../components/home/RecentGamesSection"; +import PopularGamesSection from "../components/home/PopularGamesSection"; +import "../components/home/Home.css"; import { Context } from "../context/context"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import Util from "../util/Util"; import { useTranslation } from "react-i18next"; const Home: FunctionComponent<{ context: Context, userKey?: string, + authUser?: AuthUser, theme: Theme, -}> = ({ context, userKey, theme }) => { +}> = ({ context, userKey, authUser, theme }) => { const navigate = useNavigate(); + const [activeGame, setActiveGame] = useState(null); + + useEffect(() => { + if (!authUser) return; + let disposed = false; + context.authService.getAccessToken() + .then((token) => context.gameStore.getActiveGame(token)) + .then((game) => !disposed && setActiveGame(game)) + .catch((error) => console.error("(Home) Could not load the active game", error)); + return () => { disposed = true; }; + }, [authUser?.id]); + const { t } = useTranslation(); const onNewGameClick = () => { @@ -29,31 +45,31 @@ const Home: FunctionComponent<{ navigate("/loby") }; - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); + const onBotLevelChosen = (level: BotLevel) => { + if(Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${level}`) + }; + return ( - - - - - - - - - - - - - + +
    + + {activeGame && authUser && ( + navigate(`/game/${activeGame.id}`)} /> + )} + + + {authUser && } +
    ); }; diff --git a/apps/frontend/src/routes/LearnPage.tsx b/apps/frontend/src/routes/LearnPage.tsx new file mode 100644 index 0000000..5e9d2f3 --- /dev/null +++ b/apps/frontend/src/routes/LearnPage.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import { FunctionComponent, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { getCompletedLessons, hideTutorialBanner, LESSON_RULES, Theme } from "@mancala/core"; +import AppHeader from "../components/headerbar/AppHeader"; +import PageContainer from "../components/PageContainer"; +import { Context } from "../context/context"; +import PracticeGameCard from "../components/PracticeGameCard"; + +// The lessons in order, the ones done marked (docs/specs/tutorial, R2.6). +const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => { + const { t } = useTranslation(); + useEffect(() => hideTutorialBanner(context.storage), []); + const completed = getCompletedLessons(context.storage); + return ( + + +
    +

    {t("LearnMangala")}

    +
      + {LESSON_RULES.map((rule, index) => { + const done = completed.includes(rule.id); + return ( +
    1. + + {done ? "✓" : index + 1} + {t(rule.titleKey)} + {done && {t("LessonDone")}} + +
    2. + ); + })} +
    + + {t("HowToPlay")} +
    + +
    + ); +}; + +export default LearnPage; diff --git a/apps/frontend/src/routes/LessonPage.tsx b/apps/frontend/src/routes/LessonPage.tsx new file mode 100644 index 0000000..dff950d --- /dev/null +++ b/apps/frontend/src/routes/LessonPage.tsx @@ -0,0 +1,203 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useRef, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { v4 } from "uuid"; +import { CommonMancalaGame } from "mancala.js"; +import { + BoardViewModel, completeLesson, explainMove, fulfilsTask, Game, getMoveOutcome, getNextLessonRule, getRule, + hideTutorialBanner, LESSON_RULES, MoveOutcome, PitAnimator, Theme, +} from "@mancala/core"; +import AppHeader from "../components/headerbar/AppHeader"; +import PageContainer from "../components/PageContainer"; +import BoardView from "../components/board/BoardView"; +import { Context } from "../context/context"; +import PracticeGameCard from "../components/PracticeGameCard"; + +const YOU = "you"; +const OPPONENT = "opponent"; + +// A game showing the lesson's board, with the real rules, the player at the bottom to move. +function createLessonGame(board: number[]): Game { + const mancalaGame = new CommonMancalaGame("lesson", YOU, OPPONENT); + board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount)); + mancalaGame.turnPlayerId = YOU; + mancalaGame.state = "playing"; + return { + id: "lesson", + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: YOU, isOnline: true }, + user2ConnectionInfo: { userId: OPPONENT, isOnline: true }, + }, + }; +} + +type Phase = "playing" | "animating" | "done"; + +// One rule taught on a prepared board (docs/specs/tutorial, R2). Everything runs in the browser on the TypeScript +// engine; nothing is sent to the server. +const LessonPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { ruleId } = useParams(); + const rule = getRule(ruleId); + const lesson = rule?.lesson; + const [game, setGame] = useState(undefined); + const [boardViewModel, setBoardViewModel] = useState(undefined); + const [phase, setPhase] = useState("playing"); + const [outcome, setOutcome] = useState(undefined); + const [playedIndex, setPlayedIndex] = useState(undefined); + const animator = useRef(undefined); + const timer = useRef | undefined>(undefined); + + const start = () => { + if (!lesson || !animator.current) return; + if (timer.current) clearTimeout(timer.current); + const lessonGame = createLessonGame(lesson.board); + setGame(lessonGame); + setOutcome(undefined); + setPhase("playing"); + animator.current.setNewGame(lessonGame); + }; + + useEffect(() => { + hideTutorialBanner(context.storage); + // The animator sends the same model, changed, at every step; a new one makes React draw each step. + animator.current = new PitAnimator(context.themeManager, (model) => { + setBoardViewModel(new BoardViewModel(v4(), model.pits)); + }); + start(); + return () => { + if (timer.current) clearTimeout(timer.current); + animator.current?.dispose(); + }; + }, [ruleId]); + + if (!rule || !lesson) { + return ( + + +
    {t("AllLessons")}
    +
    + ); + } + + const onPitSelect = (index: number) => { + if (phase !== "playing" || !game || !(lesson.board[index] > 0) || index > 5) return; + const moveOutcome = getMoveOutcome(lesson.board, index); + const played = createLessonGame(lesson.board); + played.mancalaGame.moveByPlayerPit(YOU, index); + setGame(played); + setPlayedIndex(index); + setPhase("animating"); + animator.current?.setUpdatedGame(played); + const steps = played.mancalaGame.history[0].gameSteps.length; + timer.current = setTimeout(() => { + setOutcome(moveOutcome); + setPhase("done"); + if (fulfilsTask(lesson.task, moveOutcome)) completeLesson(context.storage, rule.id); + }, (steps + 1) * animator.current!.animationInterval); + }; + + const fulfilled = outcome !== undefined && fulfilsTask(lesson.task, outcome); + const next = getNextLessonRule(rule.id); + const number = LESSON_RULES.findIndex((r) => r.id === rule.id) + 1; + + return ( + + +
    +
    + {t("LessonNumber", { number, count: LESSON_RULES.length })} +

    {t(rule.titleKey)}

    + {rule.textKeys.map((key) =>

    {t(key)}

    )} +
    +
    + {phase !== "done" && {t(lesson.taskKey)}} + {phase === "done" && outcome && ( + <> + {fulfilled && {t("WellDone")}} + {explainMove(outcome, "you", t)} +
    + {fulfilled && next && ( + + )} + {fulfilled && !next && ( + + )} + +
    + + )} +
    + {fulfilled && !next && } + {game && boardViewModel && ( +
    + onPitSelect(index)} + canPlay={phase === "playing"} + lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} /> +
    + )} +
    + +
    + ); +}; + +export default LessonPage; diff --git a/apps/frontend/src/routes/LobyPage.tsx b/apps/frontend/src/routes/LobyPage.tsx index 94923a8..f6fa335 100644 --- a/apps/frontend/src/routes/LobyPage.tsx +++ b/apps/frontend/src/routes/LobyPage.tsx @@ -1,37 +1,49 @@ -import { CommonMancalaGame, MancalaGame } from 'mancala.js'; +import { CommonMancalaGame } from 'mancala.js'; import * as React from 'react'; -import { FunctionComponent, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import Center from '../components/Center'; -import CircularPanel from '../components/CircularPanel'; -import HeaderBar from '../components/headerbar/HeaderBar'; -import HeaderbarIcon from '../components/headerbar/HeaderbarIcon'; -import HeaderbarTitle from '../components/headerbar/HeaderbarTitle'; -import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; +import { FunctionComponent, useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import AppHeader from '../components/headerbar/AppHeader'; import PageContainer from '../components/PageContainer'; -import Row from '../components/Row'; -import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core'; +import { markGamePlayed } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import { Context } from '../context/context'; import { Theme } from '@mancala/core'; -import { getColorByBrightness } from "@mancala/core"; -import Button from "../components/Button"; import { useTranslation } from 'react-i18next'; const LobyPage: FunctionComponent<{ context: Context, userKey?: string, theme: Theme -}> = ({ context, userKey, theme }) => { +}> = ({ context }) => { let navigate = useNavigate(); const { t } = useTranslation(); + // /loby?bot= starts a game against the bot at once instead of searching for an opponent. + const [searchParams] = useSearchParams(); + const botParam = searchParams.get("bot"); + const botLevel: BotLevel | undefined = isBotLevel(botParam) ? botParam : undefined; + + // Set when the server refuses a new game because the user has one in progress. + const [activeGameId, setActiveGameId] = useState(undefined); + + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = useState(false); + + const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId); + const onGameStart = (message: Object) => { const newGame: CommonMancalaGame = message as CommonMancalaGame; + markGamePlayed(context.storage); navigate(`/game/${newGame.id}`) } + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } + const onCancelNewGameClick = () => { if (context.rtmt.connectionState === "connected") { context.rtmt.sendMessage(channel_cancel_new_game, ""); @@ -40,56 +52,88 @@ const LobyPage: FunctionComponent<{ } useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); - context.rtmt.sendMessage("new_game", {}); + context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); + context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); } }, []); - - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.boardColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( - - - - - - - - - - - - - - -
    -
    - -

    {`${t("SearchingOpponent")} ${t("PleaseWait")}...`}

    -
    -
    -
    + return ( + + +
    +
    + {!activeGameId && } +

    {activeGameId ? t("GameInProgress") : botLevel ? t("PlayWithBot") : t("SearchingOpponent")}

    + {!activeGameId &&

    {`${t("PleaseWait")}...`}

    } + {!activeGameId && botOffered && ( +
    +

    {t("NoOpponentYet")}

    + +
    + )} +
    + {activeGameId && ( + + )} + +
    +
    +
    +
    ); } -export default LobyPage; \ No newline at end of file +export default LobyPage; diff --git a/apps/frontend/src/routes/PrivacyPage.tsx b/apps/frontend/src/routes/PrivacyPage.tsx index a5096e0..177dd1a 100644 --- a/apps/frontend/src/routes/PrivacyPage.tsx +++ b/apps/frontend/src/routes/PrivacyPage.tsx @@ -1,56 +1,19 @@ import * as React from 'react'; -import { FunctionComponent, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import HeaderBar from '../components/headerbar/HeaderBar'; -import HeaderbarIcon from '../components/headerbar/HeaderbarIcon'; -import HeaderbarTitle from '../components/headerbar/HeaderbarTitle'; -import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; +import { FunctionComponent } from 'react'; +import AppHeader from '../components/headerbar/AppHeader'; import PageContainer from '../components/PageContainer'; -import Row from '../components/Row'; import { Context } from '../context/context'; import { Theme } from '@mancala/core'; -import { getColorByBrightness } from "@mancala/core"; -import { useTranslation } from 'react-i18next'; const PrivacyPage: FunctionComponent<{ context: Context, userKey?: string, theme: Theme -}> = ({ context, userKey, theme }) => { - - const { t } = useTranslation(); - - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.background, - "#000", - "#aaa" - ); +}> = ({ context }) => { return ( - - - - - - - - - - - - - - -
    - + + +

    Privacy Policy

    The Mancala team and volunteers built the Mancala app as a open source app. This SERVICE is provided by mancala team and volunteers at no cost and is intended for use as is.

    @@ -82,9 +45,22 @@ const PrivacyPage: FunctionComponent<{

    Contact Us

    If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us.

    -
    + +
    ); } -export default PrivacyPage; \ No newline at end of file +export default PrivacyPage; diff --git a/apps/frontend/src/routes/RulesPage.tsx b/apps/frontend/src/routes/RulesPage.tsx new file mode 100644 index 0000000..752dbde --- /dev/null +++ b/apps/frontend/src/routes/RulesPage.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Theme } from "@mancala/core"; +import AppHeader from "../components/headerbar/AppHeader"; +import PageContainer from "../components/PageContainer"; +import RulesView from "../components/rules/RulesView"; +import { Context } from "../context/context"; + +// How to play: every rule with an example (docs/specs/tutorial, R1). +const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => { + const { t } = useTranslation(); + return ( + + +
    +
    +

    {t("HowToPlay")}

    + {t("Lessons")} +
    + +
    + +
    + ); +}; + +export default RulesPage; diff --git a/apps/frontend/src/service/HttpService.ts b/apps/frontend/src/service/HttpService.ts index 026638c..17624e5 100644 --- a/apps/frontend/src/service/HttpService.ts +++ b/apps/frontend/src/service/HttpService.ts @@ -7,13 +7,12 @@ export class HttpServiceImpl implements HttpService { this.serverAdress = serverAdress; } - public async get(route: string): Promise { + // With a token, the request is authenticated as that user. + public async get(route: string, token?: string): Promise { const url = this.serverAdress + route; - const requestOptions = { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }; - const response = await fetch(url, requestOptions); + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const response = await fetch(url, { method: 'GET', headers }); return response; } -} \ No newline at end of file +} diff --git a/apps/frontend/src/store/GameStore.ts b/apps/frontend/src/store/GameStore.ts index d4e62b0..0c8ca73 100644 --- a/apps/frontend/src/store/GameStore.ts +++ b/apps/frontend/src/store/GameStore.ts @@ -1,28 +1,55 @@ import { MancalaGame } from "mancala.js"; -import { Game } from "@mancala/core"; -import { HttpService } from "../service/HttpService"; +import { FinishedGameResponse, FinishedGameSummary, Game, GameRecord, LiveGameSummary } from "@mancala/core"; +import { HttpServiceImpl } from "../service/HttpService"; + +export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord }; export interface GameStore { - get(id: string): Promise; + // A live game, or the saved record of a finished one; undefined if there is no such game. + get(id: string): Promise; + getActiveGame(token: string): Promise; + getMyRecentGames(token: string, limit: number): Promise; + // The most watched finished games, also while games are live. + getPopularFinishedGames(limit: number): Promise; } export class GameStoreImpl implements GameStore { - httpService: HttpService; - constructor(props: { httpService: HttpService }) { + httpService: HttpServiceImpl; + constructor(props: { httpService: HttpServiceImpl }) { this.httpService = props.httpService; } - async get(id: string): Promise { - try { - const response = await this.httpService.get(`/game/${id}`); - const json = await response.json(); - const game: Game = json as Game; - game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame); - return game; - } catch (error) { - // todo check error - Promise.resolve(undefined); + async get(id: string): Promise { + const response = await this.httpService.get(`/game/${id}`); + if (response.status === 404) return undefined; + if (!response.ok) throw new Error(`Loading game ${id} failed: ${response.status}`); + const json = await response.json(); + if ((json as FinishedGameResponse).status === "finished") { + return { kind: "finished", record: (json as FinishedGameResponse).record }; } + const game = json as Game; + game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame); + return { kind: "live", game }; } -} \ No newline at end of file + async getActiveGame(token: string): Promise { + const json = await this.getJson("/games/me/active", token); + return json.game; + } + + async getMyRecentGames(token: string, limit: number): Promise { + const json = await this.getJson(`/games/me?limit=${limit}`, token); + return json.games; + } + + async getPopularFinishedGames(limit: number): Promise { + const json = await this.getJson(`/games/popular?kind=finished&limit=${limit}`); + return json.games; + } + + private async getJson(route: string, token?: string): Promise { + const response = await this.httpService.get(route, token); + if (!response.ok) throw new Error(`GET ${route} failed: ${response.status}`); + return response.json(); + } +} diff --git a/apps/frontend/src/store/KeyStore.ts b/apps/frontend/src/store/KeyStore.ts deleted file mode 100644 index 6923ff6..0000000 --- a/apps/frontend/src/store/KeyStore.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { HttpService } from "../service/HttpService" - -const user_key = "user_key" - -export interface UserKeyStore { - getUserKey: () => Promise; -} - -export class UserKeyStoreImpl implements UserKeyStore { - private httpService: HttpService; - private keyStoreHttp: UserKeyStore; - private keyStoreLocalStorage = new UserKeyStoreLocalStorage() - - constructor(props: { httpService: HttpService }) { - this.httpService = props.httpService; - this.keyStoreHttp = new UserKeyStoreLocalHttp({ httpService: this.httpService }); - } - - public async getUserKey(): Promise { - const maybeUserKey = await this.keyStoreLocalStorage.getUserKey(); - if (maybeUserKey === undefined) { - const userKey = await this.keyStoreHttp.getUserKey() - this.keyStoreLocalStorage.storeUserKey(userKey) - return Promise.resolve(userKey); - } else { - return Promise.resolve(maybeUserKey); - } - } -} - -export class UserKeyStoreLocalHttp implements UserKeyStore { - httpService: HttpService; - - constructor(params: { httpService: HttpService }) { - this.httpService = params.httpService; - } - - public async getUserKey(): Promise { - const response = await this.httpService.get("/register/") - return response.text(); - } -} - -export class UserKeyStoreLocalStorage { - public getUserKey(): Promise { - const userKey = localStorage.getItem(user_key) - return Promise.resolve(userKey === null ? undefined : userKey) - } - - public storeUserKey(userKey: string): void { - localStorage.setItem(user_key, userKey) - } -} diff --git a/apps/frontend/src/styles/app.css b/apps/frontend/src/styles/app.css new file mode 100644 index 0000000..99c6653 --- /dev/null +++ b/apps/frontend/src/styles/app.css @@ -0,0 +1,244 @@ +/* Themes, base styles and the building blocks shared by the pages. The theme class is set on . */ + +.t-ceviz { + --bg: #1f1712; --surface: #2a2019; --surface2: #382a20; --line: rgba(246, 234, 216, .10); + --ink: #f6ead8; --muted: #c4ad93; --accent: #f2b84b; --onAccent: #2a1a0a; + --accentSoft: rgba(242, 184, 75, .45); --accentGlow: rgba(242, 184, 75, .35); --lastRing: rgba(246, 234, 216, .55); + --chip: #1a110b; --chipInk: #f6ead8; --rim: #8a5d3b; --pit: #2c1d13; --live: #ff8a73; + --miniBg: #5b3d28; --miniPit: #2c1d13; + --boardBg: repeating-linear-gradient(94deg, rgba(255, 255, 255, .025) 0 3px, rgba(0, 0, 0, .04) 3px 9px, rgba(255, 255, 255, .015) 9px 14px), linear-gradient(180deg, #6e4a30, #573822); + --boardShadow: inset 0 1px 0 rgba(255, 255, 255, .12), inset 0 -3px 0 rgba(0, 0, 0, .25), 0 26px 50px rgba(0, 0, 0, .45), 0 0 0 1px #3a2516; + --pitShadow: inset 0 8px 16px rgba(0, 0, 0, .6), inset 0 -2px 0 rgba(255, 255, 255, .07), 0 1px 0 rgba(255, 255, 255, .08); + --stoneShadow: 0 3px 4px rgba(0, 0, 0, .45); + color-scheme: dark; +} + +.t-gece { + --bg: #0c1220; --surface: #141c30; --surface2: #1d2744; --line: rgba(238, 241, 248, .09); + --ink: #eef1f8; --muted: #9aa6c2; --accent: #e2b36a; --onAccent: #241707; + --accentSoft: rgba(226, 179, 106, .45); --accentGlow: rgba(226, 179, 106, .35); --lastRing: rgba(238, 241, 248, .5); + --chip: #0a0f1c; --chipInk: #eef1f8; --rim: #b8925a; --pit: #0b1020; --live: #ff8a7a; + --miniBg: #1d2a4d; --miniPit: #0b1020; + --boardBg: linear-gradient(180deg, #1d2a4d, #141d38); + --boardShadow: inset 0 0 0 2px #b8925a, inset 0 0 0 7px #18223f, inset 0 0 0 8px rgba(184, 146, 90, .35), 0 26px 50px rgba(0, 0, 0, .5); + --pitShadow: inset 0 8px 18px rgba(0, 0, 0, .7), inset 0 0 0 1px rgba(184, 146, 90, .28), 0 1px 0 rgba(255, 255, 255, .06); + --stoneShadow: 0 3px 5px rgba(0, 0, 0, .55); + color-scheme: dark; +} + +.t-yesim { + --bg: #e9ebe3; --surface: #f7f8f3; --surface2: #dde0d3; --line: rgba(29, 39, 33, .12); + --ink: #1d2721; --muted: #4f5d55; --accent: #2a7650; --onAccent: #ffffff; + --accentSoft: rgba(42, 118, 80, .4); --accentGlow: rgba(42, 118, 80, .28); --lastRing: rgba(29, 39, 33, .5); + --chip: #1d2721; --chipInk: #ffffff; --rim: #e9ebe3; --pit: #c3c7b6; --live: #b43a26; + --miniBg: #d3d6c8; --miniPit: #bcc0ae; + --boardBg: linear-gradient(180deg, #dcdfd2, #cfd2c3); + --boardShadow: inset 0 2px 0 rgba(255, 255, 255, .75), inset 0 -3px 0 rgba(0, 0, 0, .08), 0 22px 44px rgba(40, 50, 40, .2), 0 0 0 1px #bcc0ae; + --pitShadow: inset 0 7px 14px rgba(40, 50, 40, .38), inset 0 -2px 0 rgba(255, 255, 255, .55), 0 1px 0 rgba(255, 255, 255, .75); + --stoneShadow: 0 3px 4px rgba(30, 40, 30, .35); + color-scheme: light; +} + +html, body, #main { min-height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: 'Bricolage Grotesque', system-ui, sans-serif; + transition: background .3s, color .3s; +} + +#main { display: flex; flex-direction: column; min-height: 100vh; } + +a { color: inherit; } + +/* Page */ + +.page { display: flex; flex-direction: column; flex: 1; min-width: 0; } + +.wrap { + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: 28px 32px 56px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 26px; +} + +/* Header */ + +.hdr { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 32px; + background: var(--bg); + border-bottom: 1px solid var(--line); + transition: background .3s; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + margin-right: auto; + min-width: 0; + color: var(--ink); + text-decoration: none; + font-weight: 800; + font-size: 24px; + letter-spacing: -.02em; +} + +.logo svg { flex: none; } + +.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); } + +.sw { + width: 44px; + height: 44px; + border-radius: 50%; + border: none; + padding: 0; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); } + +.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); } + +/* Buttons */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 18px; + border-radius: 22px; + border: none; + font: inherit; + font-weight: 700; + font-size: 15px; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + box-sizing: border-box; +} + +.btn.pri { background: var(--accent); color: var(--onAccent); } + +.btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); } + +.btn.icon { width: 44px; padding: 0; } + +.btn:disabled { opacity: .4; cursor: default; } + +.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} + +/* Pills, cards */ + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border-radius: 13px; + font-size: 12px; + font-weight: 700; + background: var(--surface2); + color: var(--ink); + white-space: nowrap; +} + +.pill.live, .pill.lose { color: var(--live); } + +.pill.live::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + animation: kpulse 1.6s ease-in-out infinite; +} + +.pill.win { background: var(--accent); color: var(--onAccent); } + +.panel { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } + +.dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; animation: kpulse 1.6s ease-in-out infinite; } + +@keyframes kpulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .35; transform: scale(.7); } +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Account menu (@szhsin/react-menu) */ + +.szh-menu { + background: var(--surface); + color: var(--ink); + border-radius: 16px; + padding: 6px; + box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25); + font-family: 'Bricolage Grotesque', system-ui, sans-serif; +} + +.szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; } + +.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); } + +.szh-menu__item--hover { background: var(--surface2); } + +/* Dialogs (sweetalert) */ + +.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; } + +.swal-title, .swal-text { color: var(--ink); } + +.swal-button { border-radius: 22px; font-family: inherit; } + +@media (max-width: 640px) { + .hdr { padding: 10px 16px; gap: 8px; } + .logo { font-size: 20px; gap: 8px; } + .logo svg { width: 28px; height: 28px; } + .themes .sw { width: 40px; height: 40px; } + .hide-m { display: none !important; } + .ic-m { width: 44px; padding: 0; } + .wrap { padding: 16px 16px 32px; gap: 16px; } +} + +/* The account button makes the header too wide for the name on phones. */ +@media (max-width: 420px) { + .hdr.has-account .logo span { display: none; } +} + +@media (max-width: 360px) { + .logo span { display: none; } +} diff --git a/apps/frontend/src/styles/game.css b/apps/frontend/src/styles/game.css new file mode 100644 index 0000000..3a094dd --- /dev/null +++ b/apps/frontend/src/styles/game.css @@ -0,0 +1,219 @@ +/* Layout of the game and replay pages: the top player on the left, the turn in the middle, the bottom player on the + right, then the board and what is below it. On phones the bottom player moves below the board. */ + +.gl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-areas: "opp turn me" "board board board" "moves moves moves"; + gap: 26px 16px; + align-items: center; +} + +.a-opp { grid-area: opp; min-width: 0; } +.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; } +.a-me { grid-area: me; justify-self: end; min-width: 0; } +.a-board { grid-area: board; position: relative; min-width: 0; } +.gl.has-series { grid-template-areas: "series series series" "opp turn me" "board board board" "moves moves moves"; } +.a-series { grid-area: series; min-width: 0; } +.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; } + +/* Players */ + +.pl { display: flex; align-items: center; gap: 12px; min-width: 0; } +.pl.r { flex-direction: row-reverse; text-align: right; } + +.av { + position: relative; + width: 44px; + height: 44px; + flex: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 15px; + background: var(--chip); + color: var(--chipInk); + box-shadow: 0 0 0 2px var(--rim); + transition: box-shadow .25s; +} + +.pl.on .av { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); } + +.presence { + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + box-sizing: border-box; + background: var(--bg); + border: 2px solid var(--muted); +} + +.presence.online { background: var(--accent); border-color: var(--bg); } + +.pinfo { min-width: 0; } +.pname { font-weight: 700; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.psub { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.clock { + flex: none; + min-width: 56px; + height: 32px; + padding: 0 10px; + box-sizing: border-box; + border-radius: 16px; + font-weight: 800; + font-size: 15px; + line-height: 32px; + text-align: center; + font-variant-numeric: tabular-nums; + background: var(--surface); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--line); + transition: background .25s, color .25s; +} + +.clock.run { background: var(--accent); color: var(--onAccent); box-shadow: none; } +.clock.low { background: var(--live); color: #1a0d08; box-shadow: none; } + +/* Turn */ + +.turn { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 18px; + box-sizing: border-box; + border-radius: 20px; + font-weight: 800; + font-size: 15px; + background: var(--surface); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--line); + white-space: nowrap; + font-variant-numeric: tabular-nums; + transition: background .25s, color .25s; +} + +.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; } + +/* Hints in games against the bot */ + +.hints-toggle { min-height: 36px; padding: 0 14px; font-size: 14px; } +.hint-line { margin: 14px 0 0; text-align: center; color: var(--muted); font-weight: 600; line-height: 1.45; } + +/* Review banner, above the board */ + +.review { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 760px; + margin: 0 auto 22px; + padding: 6px 6px 6px 18px; + border-radius: 28px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--accentSoft); + font-size: 14px; + font-weight: 600; +} + +.review strong { color: var(--accent); } + +/* Game over card, over the board */ + +.over { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + border-radius: 32px; + background: rgba(0, 0, 0, .38); + backdrop-filter: blur(3px); +} + +.ocard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: calc(100% - 32px); + padding: 28px 32px; + box-sizing: border-box; + border-radius: 28px; + background: var(--surface); + box-shadow: 0 20px 50px rgba(0, 0, 0, .35); + text-align: center; +} + +.ocard .close { position: absolute; top: 8px; right: 8px; } +.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; } +.oreason { font-size: 15px; font-weight: 700; color: var(--muted); } +/* The score: each player's name above their stones, the bottom player's on the left. */ +.oscore { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 0 14px; width: 100%; min-width: 220px; margin: 2px 0 4px; } +.osc { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } +.oname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 700; color: var(--muted); } +.onum { font-size: 38px; font-weight: 800; line-height: 1; letter-spacing: -.02em; color: var(--muted); font-variant-numeric: tabular-nums; } +.osc:not(.win) .onum { opacity: .55; } +.osc.win .onum { color: var(--ink); } +.osc.win.mine .onum { color: var(--accent); } +.odash { padding-bottom: 6px; font-size: 22px; font-weight: 700; color: var(--muted); } +.oseries { font-size: 14px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } +.ostatus { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; } +.ostatus.waiting { color: var(--muted); } +.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; } + +/* Series strip: the score, then a box per game in the winner's colour, the game being played outlined. */ + +.series { display: flex; align-items: center; justify-content: center; gap: 10px 16px; flex-wrap: wrap; min-width: 0; } +.sscore { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 15px; white-space: nowrap; } +.sscore b { font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; } +.slabel { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); } +.sname { max-width: 140px; overflow: hidden; text-overflow: ellipsis; font-weight: 700; } +.sgames { display: flex; gap: 8px; max-width: 100%; margin: 0; padding: 2px; list-style: none; overflow-x: auto; scrollbar-width: none; } +.sgames::-webkit-scrollbar { display: none; } +.sg { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; + min-width: 62px; height: 52px; padding: 0 8px; box-sizing: border-box; border-radius: 14px; + background: var(--surface2); color: var(--ink); text-decoration: none; font-variant-numeric: tabular-nums; +} +a.sg:hover { filter: brightness(1.08); } +a.sg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.sg.mine { background: var(--accent); color: var(--onAccent); } +.sg.theirs { background: var(--ink); color: var(--bg); } +.sg.now { background: transparent; box-shadow: inset 0 0 0 2px var(--accent); } +.sg.more { color: var(--muted); font-weight: 800; } +.sn { font-size: 11px; font-weight: 700; line-height: 1.2; opacity: .75; white-space: nowrap; } +.ss { display: flex; align-items: center; gap: 3px; font-size: 15px; font-weight: 800; white-space: nowrap; } +.sfirst { width: 5px; height: 5px; border-radius: 50%; background: currentColor; } + +/* Tags of a move: extra turn, capture, stones to the store */ + +.tags { display: flex; gap: 6px; flex-wrap: wrap; } +.tag { height: 24px; padding: 0 9px; border-radius: 12px; font-size: 12px; font-weight: 700; line-height: 24px; background: var(--surface2); color: var(--ink); white-space: nowrap; } +.tag.x { background: var(--accent); color: var(--onAccent); } +.tag.c { background: var(--ink); color: var(--bg); } + +@media (max-width: 640px) { + .gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; } + .gl.has-series { grid-template-areas: "series series" "opp turn" "board board" "me me" "moves moves"; } + .series { flex-direction: column; gap: 8px; } + .sgames { justify-content: flex-start; } + .a-me { justify-self: start; } + .pl.r { flex-direction: row; text-align: left; } + .pl { gap: 8px; } + .clock { min-width: 50px; padding: 0 8px; font-size: 14px; } + .turn { min-height: 36px; padding: 6px 14px; font-size: 14px; white-space: normal; text-align: center; } + .review { margin-bottom: 16px; } + .ocard { padding: 26px 20px 20px; } + .otitle { font-size: 26px; } +} diff --git a/apps/frontend/src/theme/themes.ts b/apps/frontend/src/theme/themes.ts new file mode 100644 index 0000000..09baab5 --- /dev/null +++ b/apps/frontend/src/theme/themes.ts @@ -0,0 +1,94 @@ +import { Theme } from "@mancala/core"; + +// The web app's themes. Pages are coloured by the CSS variables of the theme's class (styles/app.css); the colours +// here are for code that still reads core's Theme, such as PitAnimator. +export type WebTheme = Theme & { + // CSS class on . + className: string; + // Translation key of the theme's name. + labelKey: string; + // Colours of the theme's swatch. + swatch: { board: string, accent: string }; +}; + +const walnut: WebTheme = { + id: "ceviz", + name: "Ceviz", + className: "t-ceviz", + labelKey: "ThemeWalnut", + swatch: { board: "#6e4a30", accent: "#f2b84b" }, + themePreviewColor: "#6e4a30", + background: "#1f1712", + appBarBgColor: "#2a2019", + textColor: "#2a1a0a", + textLightColor: "#f6ead8", + playerTurnColor: "#f2b84b", + boardColor: "#6e4a30", + pitColor: "#2c1d13", + pitSelectedColor: "#f2b84b", + stoneColor: "#e39a32", + stoneLightColor: "#fff1c9", + pitGameMoveAnimateColor: "#f2b84b", + pitEmptyPitAnimateColor: "#ff8a73", + pitLastStoneInBankPitAnimateColor: "#f2b84b", + pitGetRivalStonePitAnimateColor: "#ff8a73", +}; + +const night: WebTheme = { + id: "gece", + name: "Gece", + className: "t-gece", + labelKey: "ThemeNight", + swatch: { board: "#1d2a4d", accent: "#e2b36a" }, + themePreviewColor: "#1d2a4d", + background: "#0c1220", + appBarBgColor: "#141c30", + textColor: "#241707", + textLightColor: "#eef1f8", + playerTurnColor: "#e2b36a", + boardColor: "#1d2a4d", + pitColor: "#0b1020", + pitSelectedColor: "#e2b36a", + stoneColor: "#dfe3ee", + stoneLightColor: "#ffffff", + pitGameMoveAnimateColor: "#e2b36a", + pitEmptyPitAnimateColor: "#ff8a7a", + pitLastStoneInBankPitAnimateColor: "#e2b36a", + pitGetRivalStonePitAnimateColor: "#ff8a7a", +}; + +const jade: WebTheme = { + id: "yesim", + name: "Yeşim", + className: "t-yesim", + labelKey: "ThemeJade", + swatch: { board: "#cfd2c3", accent: "#2a7650" }, + themePreviewColor: "#cfd2c3", + background: "#e9ebe3", + appBarBgColor: "#f7f8f3", + textColor: "#1d2721", + textLightColor: "#ffffff", + playerTurnColor: "#2a7650", + boardColor: "#cfd2c3", + pitColor: "#c3c7b6", + pitSelectedColor: "#2a7650", + stoneColor: "#3f9f6f", + stoneLightColor: "#e2ffee", + pitGameMoveAnimateColor: "#2a7650", + pitEmptyPitAnimateColor: "#b43a26", + pitLastStoneInBankPitAnimateColor: "#2a7650", + pitGetRivalStonePitAnimateColor: "#b43a26", +}; + +// The first one is the default. +export const webThemes: WebTheme[] = [walnut, night, jade]; + +export function getWebTheme(theme: Theme): WebTheme { + return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut; +} + +export function applyThemeClass(theme: Theme) { + const root = document.documentElement; + webThemes.forEach((webTheme) => root.classList.remove(webTheme.className)); + root.classList.add(getWebTheme(theme).className); +} diff --git a/apps/frontend/src/util/BoardViewModels.ts b/apps/frontend/src/util/BoardViewModels.ts new file mode 100644 index 0000000..419fa84 --- /dev/null +++ b/apps/frontend/src/util/BoardViewModels.ts @@ -0,0 +1,9 @@ +import { BoardViewModel, BoardViewModelFactory, PitViewModelFactory, Theme } from "@mancala/core"; +import { v4 } from "uuid"; + +// A plain board (no highlights) showing the given stone counts. +export function createBoardViewModel(stones: number[], theme: Theme): BoardViewModel { + return BoardViewModelFactory.create(v4(), stones.map((stoneCount, index) => PitViewModelFactory.create({ + id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor, + }))); +} diff --git a/apps/frontend/src/util/GameResultText.ts b/apps/frontend/src/util/GameResultText.ts new file mode 100644 index 0000000..c843327 --- /dev/null +++ b/apps/frontend/src/util/GameResultText.ts @@ -0,0 +1,53 @@ +import { FinishedGameSummary, getPlayerDisplayName } from "@mancala/core"; + +type Translate = (key: string, options?: Record) => string; + +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +// The result as the viewer would say it: "You won · Opponent left" for a player, "alice won" for anyone else. +export function getResultText(game: FinishedGameSummary, viewerId: string | undefined, t: Translate): string { + if (game.result === null) return t("Abandoned"); + if (game.result === "draw") return t("Draw"); + const winner = game.result === "player1" ? game.player1 : game.player2; + const viewerSeat = getViewerSeat(game, viewerId); + if (!viewerSeat) return t("PlayerWon", { name: getPlayerDisplayName(winner, t) ?? t("Anonymous") }); + + const viewerWon = winner.userId === viewerId; + const outcome = viewerWon ? t("YouWon") : t("YouLost"); + if (game.endReason === "left") return `${outcome} · ${viewerWon ? t("OpponentLeft") : t("YouLeft")}`; + if (game.endReason === "timeout") return `${outcome} · ${viewerWon ? t("OpponentTimedOut") : t("YouTimedOut")}`; + return outcome; +} + +// Class of the result's pill: "win" when someone won (and the viewer did not lose), "lose" when the viewer lost. +export function getResultPillClass(game: FinishedGameSummary, viewerId: string | undefined): string { + if (game.result === null || game.result === "draw") return ""; + const viewerSeat = getViewerSeat(game, viewerId); + return viewerSeat && viewerSeat !== game.result ? "lose" : "win"; +} + +// Final stones in the stores, the viewer's first when they played. +export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string { + const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE]; + return getViewerSeat(game, viewerId) === "player2" ? `${player2} - ${player1}` : `${player1} - ${player2}`; +} + +export function getViewerSeat(game: FinishedGameSummary, viewerId: string | undefined): "player1" | "player2" | undefined { + if (viewerId === undefined) return undefined; + if (game.player1.userId === viewerId) return "player1"; + if (game.player2.userId === viewerId) return "player2"; + return undefined; +} + +export function formatDate(epochMillis: number, language: string): string { + return new Date(epochMillis).toLocaleDateString(language, { day: "numeric", month: "short", year: "numeric" }); +} + +// "12:05" or "1:02:05" +export function formatDuration(milliseconds: number): string { + const totalSeconds = Math.max(0, Math.round(milliseconds / 1000)); + const hours = Math.floor(totalSeconds / 3600), minutes = Math.floor(totalSeconds / 60) % 60, seconds = totalSeconds % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; +} diff --git a/apps/frontend/src/util/PlayerText.ts b/apps/frontend/src/util/PlayerText.ts new file mode 100644 index 0000000..d696a30 --- /dev/null +++ b/apps/frontend/src/util/PlayerText.ts @@ -0,0 +1,7 @@ +// Up to two characters for an avatar: a guest's code ("Guest 4F2A" → "4F"), otherwise the first letter. +export function getInitials(name: string | undefined, isGuest: boolean): string { + const trimmed = name?.trim(); + if (!trimmed) return "?"; + const words = trimmed.split(/\s+/); + return isGuest && words.length > 1 ? words[words.length - 1].slice(0, 2).toUpperCase() : trimmed[0].toUpperCase(); +} diff --git a/apps/frontend/yarn.lock b/apps/frontend/yarn.lock index f71f220..89e2592 100644 --- a/apps/frontend/yarn.lock +++ b/apps/frontend/yarn.lock @@ -313,15 +313,9 @@ resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-2.5.2.tgz#28f643fbc0bec30b07fbe95b137879b6b4d1c9c5" integrity sha512-zrBczSbXKxEyK2ijtbRdICDygRqWSRPpZMN5dD1T8VMEW5RIhIbwFWw2phDRXuBQdVDpSjalCIUMWMV2h3JaZA== -"@mancala/core@0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@mancala/core/-/core-0.0.2.tgz#c50abb60f0598306cc78f36c799dfd8a9e7c274b" - integrity sha512-wnufrDxNMkdlXFYulxTtTe88Tw79j9RPC7iQh+W/c9V2yTUBoVk2FzdO0Wj9ZV2/nrrOqYI0IchjpoRyl3RDBQ== - dependencies: - "@types/uuid" "^9.0.8" - mancala.js "^0.0.2-beta.3" - tiny-emitter "^2.1.0" - uuid "^10.0.0" +"@mancala/core@link:../../packages/core": + version "0.0.0" + uid "" "@mischnic/json-sourcemap@^0.1.0": version "0.1.0" @@ -1570,6 +1564,11 @@ json5@^2.2.0, json5@^2.2.1: resolved "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz" integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -1600,10 +1599,9 @@ loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../../packages/mancala.js": + version "0.0.0" + uid "" mdn-data@2.0.14: version "2.0.14" @@ -1693,6 +1691,13 @@ object-assign@^4.1.1: resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= +oidc-client-ts@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz#222c145d35e8d654ea4e1dee71cc5dde5e1c91a2" + integrity sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A== + dependencies: + jwt-decode "^4.0.0" + ordered-binary@^1.2.4: version "1.3.0" resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.3.0.tgz#a116d64c923278216e335602d279750b2ebd746e" diff --git a/apps/keycloak/.dockerignore b/apps/keycloak/.dockerignore new file mode 100644 index 0000000..4b09d5f --- /dev/null +++ b/apps/keycloak/.dockerignore @@ -0,0 +1 @@ +extension/target diff --git a/apps/keycloak/.gitignore b/apps/keycloak/.gitignore new file mode 100644 index 0000000..b7de373 --- /dev/null +++ b/apps/keycloak/.gitignore @@ -0,0 +1 @@ +extension/target/ diff --git a/apps/keycloak/Dockerfile b/apps/keycloak/Dockerfile new file mode 100644 index 0000000..01512b6 --- /dev/null +++ b/apps/keycloak/Dockerfile @@ -0,0 +1,23 @@ +# Keycloak with the mancala extension and realm baked in. +# Build context: apps/keycloak + +FROM maven:3.9.11-eclipse-temurin-21 AS extension +WORKDIR /build +COPY extension/pom.xml . +COPY extension/src src +RUN mvn -B -q package -Dmaven.test.skip=true + +# Keep the Keycloak version in sync with keycloak.version in extension/pom.xml +FROM quay.io/keycloak/keycloak:26.7.5 AS builder +ENV KC_DB=postgres +ENV KC_HEALTH_ENABLED=true +COPY --from=extension /build/target/mancala-keycloak-extension.jar /opt/keycloak/providers/ +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.7.5 +COPY --from=builder /opt/keycloak/ /opt/keycloak/ +# Imported on startup only if the realm does not exist yet. +COPY realm/mancala-realm.json /opt/keycloak/data/import/ +COPY themes/ /opt/keycloak/themes/ +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] +CMD ["start", "--optimized", "--import-realm"] diff --git a/apps/keycloak/README.md b/apps/keycloak/README.md new file mode 100644 index 0000000..e96e678 --- /dev/null +++ b/apps/keycloak/README.md @@ -0,0 +1,86 @@ +# Mancala Keycloak + +Keycloak is the only auth system for Mancala. Players can either log in with an account or play as a guest, +and both come back as normal Keycloak tokens from the `mancala` realm. + +## Guest login + +`extension/` adds a `mancala-guest-authenticator` to Keycloak. When an authorization request has `guest=true`, +it creates a new user named `guest-xxxxxxxx`, gives it the `guest` realm role and logs it in without showing a +form. Requests without the parameter fall through to the normal login form. + +``` +/realms/mancala/protocol/openid-connect/auth?client_id=mancala-web&response_type=code + &scope=openid%20offline_access&redirect_uri=...&code_challenge=...&code_challenge_method=S256 + &guest=true +``` + +- Clients can tell guests apart by `"guest"` in the access token's `realm_access.roles`. +- Guests should request `offline_access` so they keep their identity after the SSO session ends + (offline sessions expire after 30 days without use). +- A scheduled task deletes guests that are older than a day and have no session or offline session left. + Its timing can be changed with server options such as + `KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS` (default 3600), + `..._CLEANUP_MIN_AGE_SECONDS` (default 86400) and `..._CLEANUP_MAX_DELETES_PER_RUN` (default 1000). +- Each guest login creates a user, so the auth endpoint is rate limited per IP in `config/nginx.conf`. + +## Realm + +`realm/mancala-realm.json` holds the realm: the `mancala-web` public client (PKCE S256), the +`browser with guest` flow, the `guest` role, and the required actions with "Verify Profile" turned off so guests +are not asked for an email or a name. + +Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin +console too, or delete the realm and restart Keycloak. + +## Login theme + +`themes/mancala/login` gives the login, registration and password reset pages the game's look: its app bar and logo, +the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only +adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template +changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when +the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish; +the web app passes its language as `ui_locales`. + +To try a change without rebuilding the image, copy the theme into the running container, clear Keycloak's gzip cache +(it survives restarts) and restart: + +```sh +docker cp apps/keycloak/themes/mancala apps-keycloak-1:/opt/keycloak/themes/ +docker exec apps-keycloak-1 rm -rf /opt/keycloak/data/tmp/kc-gzip-cache +docker restart apps-keycloak-1 +``` + +## Running + +```sh +cd apps +cat > .env < + + 4.0.0 + + app.playmancala + mancala-keycloak-extension + 0.1.0 + jar + Mancala Keycloak Extension + Guest login authenticator and guest cleanup task for the mancala realm + + + 21 + UTF-8 + + 26.7.5 + 4.3.1 + 5.13.4 + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + + com.github.dasniko + testcontainers-keycloak + ${testcontainers-keycloak.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + mancala-keycloak-extension + + + src/test/resources + + + ../realm + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java new file mode 100644 index 0000000..8644a51 --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java @@ -0,0 +1,98 @@ +package app.playmancala.keycloak.guest; + +import java.security.SecureRandom; +import java.util.HexFormat; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; + +/** + * Logs the user in as a freshly created guest when the authorization request carries {@code guest=true}. + * Any other request is passed on ({@code attempted}) so the next alternative, normally the login form, runs. + * Guests are marked with the {@value #GUEST_ROLE} realm role, which ends up in the token's realm_access.roles. + */ +public class GuestAuthenticator implements Authenticator { + + public static final String GUEST_PARAM = "guest"; + public static final String GUEST_ROLE = "guest"; + public static final String USERNAME_PREFIX = "guest-"; + + private static final Logger LOG = Logger.getLogger(GuestAuthenticator.class); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int MAX_USERNAME_ATTEMPTS = 5; + + @Override + public void authenticate(AuthenticationFlowContext context) { + String guestParam = context.getAuthenticationSession().getClientNote( + AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + GUEST_PARAM); + if (!"true".equals(guestParam)) { + context.attempted(); + return; + } + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + RoleModel guestRole = realm.getRole(GUEST_ROLE); + if (guestRole == null) { + LOG.errorf("Realm '%s' has no '%s' role, guest login is disabled", realm.getName(), GUEST_ROLE); + context.attempted(); + return; + } + + String username = generateUsername(session, realm); + if (username == null) { + LOG.errorf("Could not find a free guest username in realm '%s'", realm.getName()); + context.attempted(); + return; + } + + UserModel user = session.users().addUser(realm, username); + user.setEnabled(true); + user.grantRole(guestRole); + LOG.debugf("Created guest user '%s' in realm '%s'", username, realm.getName()); + + context.setUser(user); + context.success(); + } + + private String generateUsername(KeycloakSession session, RealmModel realm) { + for (int i = 0; i < MAX_USERNAME_ATTEMPTS; i++) { + byte[] bytes = new byte[4]; + RANDOM.nextBytes(bytes); + String username = USERNAME_PREFIX + HexFormat.of().formatHex(bytes); + if (session.users().getUserByUsername(realm, username) == null) { + return username; + } + } + return null; + } + + @Override + public void action(AuthenticationFlowContext context) { + // No form, nothing to submit. + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } +} diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java new file mode 100644 index 0000000..39a054b --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java @@ -0,0 +1,98 @@ +package app.playmancala.keycloak.guest; + +import java.time.Duration; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.timer.TimerProvider; + +/** + * Registers {@link GuestAuthenticator} and schedules {@link GuestCleanupTask}. + * + * Cleanup can be tuned with server options, e.g. + * {@code --spi-authenticator--mancala-guest-authenticator--cleanup-interval-seconds=3600}. + */ +public class GuestAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "mancala-guest-authenticator"; + + private static final GuestAuthenticator SINGLETON = new GuestAuthenticator(); + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private long cleanupIntervalMillis; + private long cleanupMinAgeMillis; + private int cleanupMaxDeletesPerRun; + + @Override + public void init(Config.Scope config) { + cleanupIntervalMillis = Duration.ofSeconds(config.getLong("cleanup-interval-seconds", 3600L)).toMillis(); + cleanupMinAgeMillis = Duration.ofSeconds(config.getLong("cleanup-min-age-seconds", 86400L)).toMillis(); + cleanupMaxDeletesPerRun = config.getInt("cleanup-max-deletes-per-run", 1000); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + GuestCleanupTask task = new GuestCleanupTask(cleanupMinAgeMillis, cleanupMaxDeletesPerRun); + KeycloakModelUtils.runJobInTransaction(factory, session -> + session.getProvider(TimerProvider.class).scheduleTask(task, cleanupIntervalMillis, task.getTaskName())); + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Mancala guest login"; + } + + @Override + public String getReferenceCategory() { + return "guest"; + } + + @Override + public String getHelpText() { + return "Creates and logs in a new guest user when the authorization request contains guest=true."; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public List getConfigProperties() { + return List.of(); + } + + @Override + public void close() { + } +} diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java new file mode 100644 index 0000000..ef18898 --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java @@ -0,0 +1,73 @@ +package app.playmancala.keycloak.guest; + +import java.util.ArrayList; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserManager; +import org.keycloak.models.UserModel; +import org.keycloak.timer.ScheduledTask; + +/** + * Deletes guest users that are older than the minimum age and have neither a regular nor an offline session left. + * Offline sessions expire after the realm's offline session idle timeout, so a guest who stops playing is removed + * roughly that long after their last visit. + */ +public class GuestCleanupTask implements ScheduledTask { + + private static final Logger LOG = Logger.getLogger(GuestCleanupTask.class); + private static final int PAGE_SIZE = 200; + + private final long minAgeMillis; + private final int maxDeletesPerRun; + + public GuestCleanupTask(long minAgeMillis, int maxDeletesPerRun) { + this.minAgeMillis = minAgeMillis; + this.maxDeletesPerRun = maxDeletesPerRun; + } + + @Override + public void run(KeycloakSession session) { + long createdBefore = Time.currentTimeMillis() - minAgeMillis; + session.realms().getRealmsStream().forEach(realm -> cleanupRealm(session, realm, createdBefore)); + } + + private void cleanupRealm(KeycloakSession session, RealmModel realm, long createdBefore) { + RoleModel guestRole = realm.getRole(GuestAuthenticator.GUEST_ROLE); + if (guestRole == null) return; + + // User and session queries require the session to be bound to the realm they run in. + session.getContext().setRealm(realm); + List staleGuests = new ArrayList<>(); + for (int first = 0; staleGuests.size() < maxDeletesPerRun; first += PAGE_SIZE) { + List page = session.users().getRoleMembersStream(realm, guestRole, first, PAGE_SIZE).toList(); + if (page.isEmpty()) break; + page.stream() + .filter(user -> isStale(session, realm, user, createdBefore)) + .limit(maxDeletesPerRun - staleGuests.size()) + .forEach(staleGuests::add); + } + + UserManager userManager = new UserManager(session); + staleGuests.forEach(user -> userManager.removeUser(realm, user)); + if (!staleGuests.isEmpty()) { + LOG.infof("Removed %d inactive guest users from realm '%s'", staleGuests.size(), realm.getName()); + } + } + + private boolean isStale(KeycloakSession session, RealmModel realm, UserModel user, long createdBefore) { + Long created = user.getCreatedTimestamp(); + if (created == null || created > createdBefore) return false; + return session.sessions().getUserSessionsStream(realm, user).findAny().isEmpty() + && session.sessions().getOfflineUserSessionsStream(realm, user).findAny().isEmpty(); + } + + @Override + public String getTaskName() { + return "mancala-guest-cleanup"; + } +} diff --git a/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000..6274ae9 --- /dev/null +++ b/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +app.playmancala.keycloak.guest.GuestAuthenticatorFactory diff --git a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java new file mode 100644 index 0000000..5bfb016 --- /dev/null +++ b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java @@ -0,0 +1,218 @@ +package app.playmancala.keycloak.guest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.CookieManager; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.representations.idm.UserRepresentation; +import org.testcontainers.utility.MountableFile; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dasniko.testcontainers.keycloak.KeycloakContainer; + +/** + * Runs the extension, the real realm export and the login theme in a Keycloak container. Needs Docker. + */ +class GuestLoginTest { + + private static final String REALM = "mancala"; + private static final String CLIENT_ID = "mancala-web"; + private static final String REDIRECT_URI = "http://localhost:1234/auth/callback"; + private static final ObjectMapper JSON = new ObjectMapper(); + + private static KeycloakContainer keycloak; + + @BeforeAll + static void startKeycloak() { + keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5") + .withProviderClassesFrom("target/classes") + .withRealmImportFile("/mancala-realm.json") + .withCopyFileToContainer(MountableFile.forHostPath("../themes/mancala"), "/opt/keycloak/themes/mancala") + .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2") + .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0"); + keycloak.start(); + } + + @AfterAll + static void stopKeycloak() { + if (keycloak != null) keycloak.stop(); + } + + @Test + void guestRequestLogsInAsNewGuestWithoutShowingAForm() throws Exception { + JsonNode tokens = loginAsGuest(); + JsonNode accessToken = decodeJwt(tokens.get("access_token").asText()); + + assertTrue(accessToken.get("preferred_username").asText().startsWith(GuestAuthenticator.USERNAME_PREFIX)); + assertTrue(realmRoles(accessToken).contains(GuestAuthenticator.GUEST_ROLE)); + assertEquals("Offline", decodeJwt(tokens.get("refresh_token").asText()).get("typ").asText()); + } + + @Test + void eachGuestLoginCreatesADifferentUser() throws Exception { + String first = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText(); + String second = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText(); + assertNotEquals(first, second); + } + + @Test + void requestWithoutGuestParamShowsLoginForm() throws Exception { + HttpResponse response = newHttpClient().send( + HttpRequest.newBuilder(authorizationUri(Map.of(), randomUrlSafe(32))).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("id=\"kc-form-login\"")); + } + + @Test + void loginFormUsesTheMancalaThemeInTheRequestedLanguage() throws Exception { + HttpResponse response = newHttpClient().send( + HttpRequest.newBuilder(authorizationUri(Map.of("ui_locales", "tr"), randomUrlSafe(32))).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("/login/mancala/css/mancala.css"), "mancala stylesheet missing"); + assertTrue(response.body().contains("lang=\"tr\""), "page is not in Turkish"); + } + + @Test + void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception { + String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText(); + String idleGuest = createUser("guest-idle-" + UUID.randomUUID(), true); + String regularUser = createUser("player-" + UUID.randomUUID(), false); + + RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM); + long deadline = System.currentTimeMillis() + 20_000; + while (!realm.users().searchByUsername(idleGuest, true).isEmpty()) { + assertTrue(System.currentTimeMillis() < deadline, "idle guest was not cleaned up"); + Thread.sleep(500); + } + + assertFalse(realm.users().searchByUsername(activeGuest, true).isEmpty(), "guest with a session was removed"); + assertFalse(realm.users().searchByUsername(regularUser, true).isEmpty(), "regular user was removed"); + } + + private static String createUser(String username, boolean guest) { + RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM); + UserRepresentation user = new UserRepresentation(); + user.setUsername(username); + user.setEnabled(true); + realm.users().create(user).close(); + if (guest) { + String id = realm.users().searchByUsername(username, true).get(0).getId(); + realm.users().get(id).roles().realmLevel() + .add(List.of(realm.roles().get(GuestAuthenticator.GUEST_ROLE).toRepresentation())); + } + return username; + } + + /** Runs the authorization code flow with PKCE, as the web client will, and returns the token response. */ + private static JsonNode loginAsGuest() throws Exception { + HttpClient http = newHttpClient(); + String verifier = randomUrlSafe(32); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + + HttpResponse authResponse = http.send( + HttpRequest.newBuilder(authorizationUri(Map.of("guest", "true"), challenge)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(302, authResponse.statusCode(), "expected redirect back to the client, got: " + authResponse.body()); + + String location = authResponse.headers().firstValue("Location").orElseThrow(); + assertTrue(location.startsWith(REDIRECT_URI), location); + String code = queryParams(URI.create(location)).get("code"); + assertNotNull(code, location); + + HttpResponse tokenResponse = http.send( + HttpRequest.newBuilder(URI.create(realmUrl() + "/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form(Map.of( + "grant_type", "authorization_code", + "client_id", CLIENT_ID, + "redirect_uri", REDIRECT_URI, + "code", code, + "code_verifier", verifier)))) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, tokenResponse.statusCode(), tokenResponse.body()); + return JSON.readTree(tokenResponse.body()); + } + + private static URI authorizationUri(Map extraParams, String codeChallenge) { + Map params = new java.util.HashMap<>(Map.of( + "client_id", CLIENT_ID, + "response_type", "code", + "redirect_uri", REDIRECT_URI, + "scope", "openid offline_access", + "state", randomUrlSafe(8), + "code_challenge", codeChallenge, + "code_challenge_method", "S256")); + params.putAll(extraParams); + return URI.create(realmUrl() + "/protocol/openid-connect/auth?" + form(params)); + } + + private static String realmUrl() { + return keycloak.getAuthServerUrl() + "/realms/" + REALM; + } + + private static HttpClient newHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(new CookieManager()) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + private static JsonNode decodeJwt(String jwt) throws Exception { + return JSON.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])); + } + + private static List realmRoles(JsonNode accessToken) { + List roles = new java.util.ArrayList<>(); + accessToken.path("realm_access").path("roles").forEach(role -> roles.add(role.asText())); + return roles; + } + + private static Map queryParams(URI uri) { + return java.util.Arrays.stream(uri.getRawQuery().split("&")) + .map(pair -> pair.split("=", 2)) + .collect(Collectors.toMap(p -> p[0], p -> java.net.URLDecoder.decode(p[1], StandardCharsets.UTF_8))); + } + + private static String form(Map params) { + return params.entrySet().stream() + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } + + private static String randomUrlSafe(int bytes) { + byte[] buffer = new byte[bytes]; + new SecureRandom().nextBytes(buffer); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer); + } +} diff --git a/apps/keycloak/realm/mancala-realm.json b/apps/keycloak/realm/mancala-realm.json new file mode 100644 index 0000000..181f2e1 --- /dev/null +++ b/apps/keycloak/realm/mancala-realm.json @@ -0,0 +1,230 @@ +{ + "realm": "mancala", + "displayName": "Mancala", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "verifyEmail": false, + "bruteForceProtected": true, + "loginTheme": "mancala", + "internationalizationEnabled": true, + "supportedLocales": ["en", "tr"], + "defaultLocale": "en", + "offlineSessionIdleTimeout": 2592000, + "browserFlow": "browser with guest", + "requiredActions": [ + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 54, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 57, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": false, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": false, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 130, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "roles": { + "realm": [ + { + "name": "guest", + "description": "Anonymous player created by the mancala guest authenticator" + } + ] + }, + "clients": [ + { + "clientId": "mancala-web", + "name": "Mancala Web", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:1234/*", + "https://playmancala.app/*" + ], + "webOrigins": [ + "http://localhost:1234", + "https://playmancala.app" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:1234/*##https://playmancala.app/*" + } + } + ], + "authenticationFlows": [ + { + "alias": "browser with guest", + "description": "Browser flow that can log in as a new guest when the request has guest=true", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "authenticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "mancala-guest-authenticator", + "requirement": "ALTERNATIVE", + "priority": 20, + "authenticatorFlow": false, + "userSetupAllowed": false + }, + { + "flowAlias": "browser with guest forms", + "requirement": "ALTERNATIVE", + "priority": 30, + "authenticatorFlow": true, + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser with guest forms", + "description": "Username and password form", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "authenticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ] +} diff --git a/apps/keycloak/themes/mancala/login/resources/css/mancala.css b/apps/keycloak/themes/mancala/login/resources/css/mancala.css new file mode 100644 index 0000000..86d0b7d --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/css/mancala.css @@ -0,0 +1,294 @@ +/* + * Mancala look for keycloak.v2's login pages. Loaded after keycloak.v2's styles.css. + * Colors come from LightTheme and DarkTheme in packages/core/src/theme; text colors are darker (or lighter) than + * the game's where the game's own would not be readable on the board color. + * keycloak.v2 adds pf-v5-theme-dark to when the system prefers a dark color scheme. + */ + +:root { + --mancala-bg: #bbbbbb; + --mancala-bar: #7b7b7b; + --mancala-bar-text: #ebebeb; + --mancala-board: #9b9b9b; + --mancala-pit: #c4c4c4; + --mancala-pit-readonly: #b0b0b0; + --mancala-pit-edge: #4d4d4d; + --mancala-pit-shadow: rgba(0, 0, 0, 0.22); + --mancala-stone: #5b5b5b; + --mancala-stone-hover: #474747; + --mancala-stone-text: #f2f2f2; + --mancala-ink: #262626; + --mancala-ink-soft: #333333; + --mancala-ink-strong: #000000; + --mancala-hover: rgba(0, 0, 0, 0.08); + --mancala-divider: #6b6b6b; + --mancala-danger: #6b0900; + --mancala-board-image: url("../img/board.svg"); + --mancala-bar-height: 57px; + --mancala-font: serif; + + --pf-v5-global--FontFamily--text: var(--mancala-font); + --pf-v5-global--FontFamily--heading: var(--mancala-font); + --pf-v5-global--Color--100: var(--mancala-ink); + --pf-v5-global--Color--200: var(--mancala-ink-soft); + --pf-v5-global--link--Color: var(--mancala-ink); + --pf-v5-global--link--Color--hover: var(--mancala-ink-strong); + --pf-v5-global--danger-color--100: var(--mancala-danger); +} + +:root.pf-v5-theme-dark { + --mancala-bg: #414141; + --mancala-bar: #313131; + --mancala-bar-text: #aaaaaa; + --mancala-board: #313131; + --mancala-pit: #606060; + --mancala-pit-readonly: #4b4b4b; + --mancala-pit-edge: #8a8a8a; + --mancala-pit-shadow: rgba(0, 0, 0, 0.45); + --mancala-stone: #aaaaaa; + --mancala-stone-hover: #bdbdbd; + --mancala-stone-text: #1e1e1e; + --mancala-ink: #e0e0e0; + --mancala-ink-soft: #bbbbbb; + --mancala-ink-strong: #ffffff; + --mancala-hover: rgba(255, 255, 255, 0.08); + --mancala-divider: #555555; + --mancala-danger: #ff8a80; + --mancala-board-image: url("../img/board-dark.svg"); +} + +.login-pf body { + background: var(--mancala-bg); + font-family: var(--mancala-font); +} + +/* Header: the game's app bar with its logo and title */ + +#kc-header { + position: fixed; + inset: 0 0 auto 0; + z-index: 10; + height: var(--mancala-bar-height); + margin: 0; + padding: 0 4vw; + display: flex; + align-items: center; + background: var(--mancala-bar); +} + +#kc-header-wrapper { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + padding: 0; + font-family: var(--mancala-font); + font-size: 2em; + font-weight: bold; + line-height: 1; + letter-spacing: normal; + text-transform: none; + text-align: start; + color: var(--mancala-bar-text) !important; +} + +#kc-header-wrapper::before { + content: ""; + flex: none; + width: 30px; + height: 30px; + background: url("../img/logo.svg") center / contain no-repeat; +} + +.pf-v5-c-login { + min-height: 100vh; + padding-block-start: calc(var(--mancala-bar-height) + 2rem); + padding-block-end: 2rem; +} + +.pf-v5-c-login__container { + max-width: calc(30rem + 32px); + padding-inline: 16px; + grid-template-columns: minmax(0, 30rem); +} + +/* Card: the board, with a small board at the start of a game on top */ + +.pf-v5-c-login__main { + --pf-v5-c-login__main--BackgroundColor: var(--mancala-board); + --pf-v5-c-login__main--BoxShadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --pf-v5-c-login__main-footer-band--BorderTopColor: var(--mancala-divider); + padding-block-start: 2rem; + border-radius: 24px; + color: var(--mancala-ink); +} + +.pf-v5-c-login__main::before { + content: ""; + display: block; + width: min(calc(100% - 4rem), 260px); + aspect-ratio: 312 / 76; + margin: 0 auto; + background: var(--mancala-board-image) center / contain no-repeat; +} + +.pf-v5-c-login__main-header { + --pf-v5-c-login__main-header--PaddingTop: 1.5rem; + border-top: none; +} + +#kc-page-title { + color: var(--mancala-ink); + font-family: var(--mancala-font); + font-size: 1.5rem; + font-weight: bold; +} + +.pf-v5-c-login__main hr { + border-color: var(--mancala-divider); +} + +.pf-v5-c-login__main a:not(.pf-v5-c-button) { + color: var(--mancala-ink); + text-decoration: underline; +} + +.pf-v5-c-login__main a:not(.pf-v5-c-button):hover { + color: var(--mancala-ink-strong); +} + +/* Inputs: pits */ + +.pf-v5-c-form-control { + --pf-v5-c-form-control--BackgroundColor: var(--mancala-pit); + --pf-v5-c-form-control--Color: var(--mancala-ink); + --pf-v5-c-form-control--m-placeholder--Color: var(--mancala-ink-soft); + --pf-v5-c-form-control--m-readonly--BackgroundColor: var(--mancala-pit-readonly); + --pf-v5-c-form-control__toggle-icon--Color: var(--mancala-ink); + --pf-v5-c-form-control--PaddingLeft: 1rem; + --pf-v5-c-form-control--PaddingRight: 1rem; + border: 1px solid var(--mancala-pit-edge); + border-radius: 999px; + box-shadow: inset 0 2px 5px var(--mancala-pit-shadow); + font-family: var(--mancala-font); +} + +.pf-v5-c-form-control:has(textarea) { + border-radius: 16px; +} + +.pf-v5-c-form-control::before, +.pf-v5-c-form-control::after { + content: none; +} + +.pf-v5-c-form-control > :is(input, select, textarea) { + font-family: inherit; + background-color: transparent; + border-radius: inherit; +} + +.pf-v5-c-form-control > select { + padding-inline-start: 1rem; +} + +.pf-v5-c-form-control > :is(input, select, textarea):focus { + outline: none; +} + +.pf-v5-c-form-control:hover { + border-color: var(--mancala-ink); +} + +.pf-v5-c-form-control:focus-within { + border-color: var(--mancala-ink); + outline: 2px solid var(--mancala-ink); + outline-offset: 2px; +} + +.pf-v5-c-form-control.pf-m-error { + border-color: var(--mancala-danger); + box-shadow: inset 0 2px 5px var(--mancala-pit-shadow), 0 0 0 1px var(--mancala-danger); +} + +.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error, +.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text, +.pf-v5-c-login__main .pf-v5-c-helper-text__item-text.pf-m-error { + color: var(--mancala-danger); +} + +/* Password field and its show button form one pill */ + +.pf-v5-c-input-group > .pf-v5-c-input-group__item:first-child:not(:last-child) .pf-v5-c-form-control { + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +.pf-v5-c-input-group > .pf-v5-c-input-group__item:last-child:not(:first-child) .pf-v5-c-button { + border-radius: 0 999px 999px 0; +} + +/* Buttons: pill shaped like the game's */ + +.pf-v5-c-button { + --pf-v5-c-button--BorderRadius: 999px; + --pf-v5-c-button--after--BorderRadius: 999px; + + --pf-v5-c-button--m-primary--BackgroundColor: var(--mancala-stone); + --pf-v5-c-button--m-primary--hover--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--focus--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--active--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--hover--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--focus--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--active--Color: var(--mancala-stone-text); + + --pf-v5-c-button--m-secondary--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--focus--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--active--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--focus--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--active--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--BackgroundColor: var(--mancala-hover); + --pf-v5-c-button--m-secondary--focus--BackgroundColor: var(--mancala-hover); + --pf-v5-c-button--m-secondary--active--BackgroundColor: var(--mancala-hover); + + --pf-v5-c-button--m-link--Color: var(--mancala-ink); + --pf-v5-c-button--m-link--hover--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-link--focus--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-link--active--Color: var(--mancala-ink-strong); + + --pf-v5-c-button--m-control--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--hover--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--focus--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--active--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--Color: var(--mancala-ink); + --pf-v5-c-button--m-control--hover--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-control--focus--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-control--active--Color: var(--mancala-ink-strong); + font-family: var(--mancala-font); +} + +.pf-v5-c-button.pf-m-primary { + font-weight: bold; +} + +.pf-v5-c-button.pf-m-control::after { + border: 1px solid var(--mancala-pit-edge); + border-radius: inherit; +} + +.pf-v5-c-button.pf-m-control:hover::after { + border-color: var(--mancala-ink); +} + +.pf-v5-c-button:focus-visible { + outline: 2px solid var(--mancala-ink); + outline-offset: 2px; +} + +.pf-v5-c-check__input { + accent-color: var(--mancala-stone); +} diff --git a/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg b/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg new file mode 100644 index 0000000..c1edcbd --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/resources/img/board.svg b/apps/keycloak/themes/mancala/login/resources/img/board.svg new file mode 100644 index 0000000..2fad955 --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/board.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/resources/img/favicon.ico b/apps/keycloak/themes/mancala/login/resources/img/favicon.ico new file mode 100644 index 0000000..df145b3 Binary files /dev/null and b/apps/keycloak/themes/mancala/login/resources/img/favicon.ico differ diff --git a/apps/keycloak/themes/mancala/login/resources/img/logo.svg b/apps/keycloak/themes/mancala/login/resources/img/logo.svg new file mode 100644 index 0000000..cf65dfd --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/theme.properties b/apps/keycloak/themes/mancala/login/theme.properties new file mode 100644 index 0000000..ec25cc5 --- /dev/null +++ b/apps/keycloak/themes/mancala/login/theme.properties @@ -0,0 +1,6 @@ +# Login pages in the look of the game. Keeps keycloak.v2's templates and only adds a stylesheet, +# so Keycloak upgrades do not need template changes. +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css css/mancala.css diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 0788c59..1d29cea 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -89,8 +89,8 @@ android { applicationId "com.mancala_mobile" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 8 - versionName "0.5.0" + versionCode 9 + versionName "0.5.1" } signingConfigs { debug { diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index ad8f87b..5ba57b7 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,3 +1,4 @@ +const path = require('path'); const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); /** @@ -6,6 +7,13 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); * * @type {import('metro-config').MetroConfig} */ -const config = {}; +const config = { + // @mancala/core and mancala.js are linked from the repo's packages folder. + watchFolders: [path.resolve(__dirname, '../../packages')], + resolver: { + // Lets the linked packages find modules that only the app installs, such as @babel/runtime. + nodeModulesPaths: [path.resolve(__dirname, 'node_modules')], + }, +}; module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ba07720..dfef15d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -10,13 +10,13 @@ "test": "jest" }, "dependencies": { - "@mancala/core": "0.0.2", + "@mancala/core": "link:../../packages/core", "@react-native/new-app-screen": "0.82.1", "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.9.26", "@types/uuid": "^9.0.8", "i18next": "^23.10.1", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../../packages/mancala.js", "react": "19.1.1", "react-i18next": "^14.1.0", "react-native": "0.82.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 0b96a68..b33957b 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -15,6 +15,7 @@ import { NativeModules, I18nManager, Platform } from 'react-native' // https://github.com/uuidjs/uuid/issues/514#issuecomment-691475020 import 'react-native-get-random-values'; import HelpScreen from './screens/HelpScreen'; +import RulesScreen from './screens/RulesScreen'; import { getColorByBrightness } from "@mancala/core"; import { server } from './const/config'; @@ -58,7 +59,8 @@ function App() { rtmt.on("error", onConnectionError); rtmt.on("connectionchange", onConnectionChange) const url = server.wsServerAdress + "?userKey=" + userKey; - rtmt.connectWebSocket(url, userKey) + // Legacy userKey login; mobile moves to Keycloak tokens later. + rtmt.connectWebSocket(url) return rtmt; } const getTextByConnectionState = (context: Context, connectionState: ConnectionState): string => { @@ -119,6 +121,8 @@ function App() { options={{ title: t("Mancala"), headerStyle, headerTintColor }} /> + ); diff --git a/apps/mobile/src/components/BotLevelPicker.tsx b/apps/mobile/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..6cbda7a --- /dev/null +++ b/apps/mobile/src/components/BotLevelPicker.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from '@mancala/core'; +import { Context } from '../context/context'; +import CircularPanel from './CircularPanel'; + +// The four bot levels; the level chosen last is highlighted and remembered on the device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const theme = context.themeManager.theme; + const lastLevel = getLastBotLevel(context.storage); + return ( + + {BOT_LEVELS.map((level) => ( + { + setLastBotLevel(context.storage, level); + onChoose(level); + }}> + {t(getBotLevelTextKey(level))}} /> + + ))} + + ); +}; + +export default BotLevelPicker; diff --git a/apps/mobile/src/components/GameClockView.tsx b/apps/mobile/src/components/GameClockView.tsx new file mode 100644 index 0000000..267f58c --- /dev/null +++ b/apps/mobile/src/components/GameClockView.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { StyleSheet, Text } from 'react-native'; +import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core"; +import { Context } from '../context/context'; + +// Readable on every theme; the themes have no warning color of their own. +const LOW_TIME_COLOR = "#b3261e"; +const LOW_TIME_TEXT_COLOR = "#ffffff"; + +// One player's clock: highlighted while it runs, in a warning color when the running clock is low. +const GameClockView: FunctionComponent<{ + context: Context, + timeMs: number, + running: boolean, +}> = ({ context, timeMs, running }) => { + const theme = context.themeManager.theme; + const lowTime = running && timeMs < CLOCK_LOW_TIME_MS; + const backgroundColor = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent"; + const color = lowTime ? LOW_TIME_TEXT_COLOR : running + ? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor) + : getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); + return ( + + {formatClock(timeMs)} + + ); +} + +const styles = StyleSheet.create({ + clock: { + paddingVertical: 2, + paddingHorizontal: 8, + borderRadius: 30, + borderWidth: 2, + overflow: 'hidden', + fontWeight: 'bold', + fontVariant: ['tabular-nums'], + }, +}); + +export default GameClockView; diff --git a/apps/mobile/src/components/InfoPanel.tsx b/apps/mobile/src/components/InfoPanel.tsx index eb5680f..8c07e58 100644 --- a/apps/mobile/src/components/InfoPanel.tsx +++ b/apps/mobile/src/components/InfoPanel.tsx @@ -69,6 +69,8 @@ const InfoPanel: FunctionComponent<{ style?: ViewStyle; visible?: boolean; isSpectator?: boolean; + // Shown instead of the text derived from the game, e.g. why a game ended. + overrideText?: string; }> = ({ context, game, @@ -78,7 +80,8 @@ const InfoPanel: FunctionComponent<{ leftPlayer, style, visible, - isSpectator + isSpectator, + overrideText }) => { if (visible === false) return <>; const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false; @@ -90,7 +93,8 @@ const InfoPanel: FunctionComponent<{ context.themeManager.theme.textColor, context.themeManager.theme.textLightColor ); - const text = getInfoPanelTextByGameState({ + // Always computed: it uses a hook, so it must run on every render. + const gameStateText = getInfoPanelTextByGameState({ context, game, currentUser, @@ -99,6 +103,7 @@ const InfoPanel: FunctionComponent<{ leftPlayer, isSpectator }); + const text = overrideText ?? gameStateText; if (text) { return ( diff --git a/apps/mobile/src/components/RuleBoard.tsx b/apps/mobile/src/components/RuleBoard.tsx new file mode 100644 index 0000000..6707f0a --- /dev/null +++ b/apps/mobile/src/components/RuleBoard.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { Text, View } from 'react-native'; +import { getColorByBrightness, getMoveOutcome, RuleExample, Theme } from '@mancala/core'; + +const TOP_PITS = [12, 11, 10, 9, 8, 7]; +const BOTTOM_PITS = [0, 1, 2, 3, 4, 5]; + +// Stone counts on a small board as the bottom player sees it; `played` and `landed` mark a move. +const MiniBoard: FunctionComponent<{ board: number[], theme: Theme, played?: number, landed?: number }> = ({ board, theme, played, landed }) => { + const textOn = (color: string) => getColorByBrightness(color, theme.textColor, theme.textLightColor); + const cell = (index: number, store: boolean) => { + const color = index === landed ? theme.pitSelectedColor : theme.pitColor; + return ( + + {board[index] || ''} + + ); + }; + return ( + + {cell(13, true)} + + {TOP_PITS.map((index) => cell(index, false))} + {BOTTOM_PITS.map((index) => cell(index, false))} + + {cell(6, true)} + + ); +}; + +// A rule's example: the board before the move and after it, as the engine plays it. +const RuleBoard: FunctionComponent<{ example: RuleExample, theme: Theme }> = ({ example, theme }) => { + if (example.pitIndex === undefined) return ; + const outcome = getMoveOutcome(example.board, example.pitIndex); + return ( + + + ↓ + + + ); +}; + +export default RuleBoard; diff --git a/apps/mobile/src/components/UserStatus.tsx b/apps/mobile/src/components/UserStatus.tsx index e7a6dd6..6a91adc 100644 --- a/apps/mobile/src/components/UserStatus.tsx +++ b/apps/mobile/src/components/UserStatus.tsx @@ -15,8 +15,10 @@ const UserStatus: FunctionComponent<{ user: User, layoutMode: LayoutMode, visible?: boolean, - style?: ViewStyle -}> = ({ context, user, layoutMode, visible, style }) => { + style?: ViewStyle, + // Shown after the online status, e.g. the player's clock. + accessory?: React.ReactNode, +}> = ({ context, user, layoutMode, visible, style, accessory }) => { if (visible === false) return <>; const textColorOnBoard = getColorByBrightness( context.themeManager.theme.background, @@ -32,6 +34,7 @@ const UserStatus: FunctionComponent<{ + {accessory && <>{accessory}} ); } diff --git a/apps/mobile/src/hooks/useGameClock.ts b/apps/mobile/src/hooks/useGameClock.ts new file mode 100644 index 0000000..cc64163 --- /dev/null +++ b/apps/mobile/src/hooks/useGameClock.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef, useState } from "react"; +import { ClockState, ClockSync, Game, GameEndMessage } from "@mancala/core"; + +const TICK_MS = 250; + +// Both players' clocks in a live game, counting down between the server's messages. Undefined when the server sends +// no clock. +export default function useGameClock(game: Game | undefined, gameEnd: GameEndMessage | undefined): ClockState | undefined { + const sync = useRef(new ClockSync()).current; + const lastGame = useRef(undefined); + // Every game message is a new object, so a changed reference means a new message. + if (game && game !== lastGame.current) { + sync.update(game); + lastGame.current = game; + } + if (gameEnd) sync.stop(gameEnd.clock); + + const [, setTick] = useState(0); + const isRunning = sync.isRunning; + useEffect(() => { + if (!isRunning) return; + const timer = setInterval(() => setTick((tick) => tick + 1), TICK_MS); + return () => clearInterval(timer); + }, [isRunning]); + + return sync.getTimes(); +} diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx index b7cfa02..c979fc6 100644 --- a/apps/mobile/src/screens/GameScreen.tsx +++ b/apps/mobile/src/screens/GameScreen.tsx @@ -3,10 +3,10 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native' import { useTranslation } from 'react-i18next'; import { GameScreenProps } from '../types'; import { useState } from 'react'; -import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness } from "@mancala/core"; +import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat, getBotLevel, getPlayerDisplayName } from "@mancala/core"; import { MancalaGame, Pit } from 'mancala.js'; import { v4 } from 'uuid'; -import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move } from '@mancala/core'; +import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move, channel_on_game_end } from '@mancala/core'; import Util from '../util/Util'; import Snackbar from 'react-native-snackbar'; import PageContainer from '../components/PageContainer'; @@ -19,6 +19,8 @@ import BoardView from '../components/board/BoardView'; import CircularPanel from '../components/CircularPanel'; import { SvgXml } from 'react-native-svg'; import helpSvg from '../svg/help'; +import GameClockView from '../components/GameClockView'; +import useGameClock from '../hooks/useGameClock'; export function GameScreen({ navigation, route }: GameScreenProps) { const { context, gameId, userKey } = route.params; @@ -40,6 +42,9 @@ export function GameScreen({ navigation, route }: GameScreenProps) { const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); + // Set when the server says the game ended, for whatever reason. + const [gameEnd, setGameEnd] = useState(undefined); + const { height, width } = useWindowDimensions(); const [gameLoadingState, setLoadingStateGame] = useState>(LoadingState.Unset()); @@ -86,6 +91,11 @@ export function GameScreen({ navigation, route }: GameScreenProps) { setHasOngoingAction(false); context.storage.deleteString('lastGameId'); }; + const onGameEnd = (message: GameEndMessage) => { + setGameEnd(message); + setHasOngoingAction(false); + context.storage.deleteString('lastGameId'); + }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; setGameUsersConnectionInfo(gameUsersConnectionInfo); @@ -97,6 +107,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange); + context.rtmt.addMessageListener(channel_on_game_end, onGameEnd); checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id); return () => { checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id); @@ -104,6 +115,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.removeMessageListener(channel_on_user_connection_change, onUserConnectionChange); + context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd); } }; @@ -147,17 +159,19 @@ export function GameScreen({ navigation, route }: GameScreenProps) { }); }; + // In a game against the bot, a new game is another one at the same level. const onNewGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; context.storage.deleteString('lastGameId'); - navigation.replace("Loby", { context }) + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + navigation.replace("Loby", { context, botLevel: opponentBotLevel }) }; const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; } - if (userKeyWhoLeave) { + if (userKeyWhoLeave || gameEnd) { Snackbar.show({ text: t("GameEnded") }); return; } @@ -219,7 +233,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { }, []); const updateHeaderButton = () => { - const isGameEnded = game?.mancalaGame.state === "ended" || leftPlayer; + const isGameEnded = game?.mancalaGame.state === "ended" || leftPlayer || gameEnd; const isError = gameLoadingState.isError(); navigation.setOptions({ headerRight: () => ( @@ -238,10 +252,10 @@ export function GameScreen({ navigation, route }: GameScreenProps) { onLeaveGameClick(); } }}> - {isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}} /> + {isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}} /> { - navigation.push("Help", { context }) + navigation.push("Rules", { context }) }}> @@ -253,7 +267,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { React.useEffect(() => { updateHeaderButton(); - }, [navigation, game, userKeyWhoLeave, gameLoadingState]); + }, [navigation, game, userKeyWhoLeave, gameEnd, gameLoadingState]); const textColorOnAppBar = getColorByBrightness( context.themeManager.theme.appBarBgColor, @@ -266,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) { const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; + // Players have no names on mobile yet (legacy users), except the bot. + const topBotName = getPlayerDisplayName({ userId: topLocatedUserId }, t); + const bottomBotName = getPlayerDisplayName({ userId: bottomLocatedUserId }, t); const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: topBotName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true + isAnonymous: !topBotName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: bottomBotName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true + isAnonymous: !bottomBotName }; const currentUser = isSpectator ? { id: "2", @@ -285,6 +302,18 @@ export function GameScreen({ navigation, route }: GameScreenProps) { isAnonymous: true } : bottomLocatedUser; const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined; + // A game that ended on time is not over by the rules, so say why it ended. + const endText = gameEnd?.endReason !== "timeout" ? undefined + : isSpectator ? t("GameEnded") + : gameEnd.winnerId === userKey ? `${t("GameEnded")} · ${t("YouWon")} · ${t("OpponentTimedOut")}` + : `${t("GameEnded")} · ${t("YouLost")} · ${t("YouTimedOut")}`; + const clockTimes = useGameClock(game, gameEnd); + const renderClock = (playerId: string) => { + const seat = mancalaGame && getSeat(mancalaGame, playerId); + if (!clockTimes || !seat) return undefined; + const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms; + return ; + }; return ( {/* {renderHeaderBar()} */} @@ -336,10 +365,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) { function buildBoardTopToolbar() { return - + {buildInfoPanel({ visible: !isMobile })} - + ; } @@ -354,7 +385,8 @@ export function GameScreen({ navigation, route }: GameScreenProps) { blackPlayer={bottomLocatedUser} leftPlayer={leftPlayer} visible={params.visible} - isSpectator={isSpectator} /> + isSpectator={isSpectator} + overrideText={endText} /> ); } } diff --git a/apps/mobile/src/screens/HelpScreen.tsx b/apps/mobile/src/screens/HelpScreen.tsx index efd6997..726f19d 100644 --- a/apps/mobile/src/screens/HelpScreen.tsx +++ b/apps/mobile/src/screens/HelpScreen.tsx @@ -31,6 +31,9 @@ export default function HelpScreen({ navigation, route }: HelpScreenProps) { alignContent: "center", justifyContent: "center" }}> + navigation.push("Rules", { context })}> + {t('HowToPlay')} + {t('WebApp')} diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index aa7757b..0bbf6b4 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native'; import { useTranslation } from 'react-i18next'; import { HomeScreenProps } from '../types'; import CircularPanel from '../components/CircularPanel'; +import BotLevelPicker from '../components/BotLevelPicker'; import { SvgXml } from 'react-native-svg'; import helpSvg from '../svg/help'; import { getColorByBrightness } from "@mancala/core"; @@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { const { context } = route.params; const { t } = useTranslation(); const [lastGameId, setLastGameId] = React.useState(undefined); + const [choosingBotLevel, setChoosingBotLevel] = React.useState(false); const textColorOnAppBar = getColorByBrightness( context.themeManager.theme.appBarBgColor, @@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { navigation.replace('Loby', { context })}> {t('NewGame')}} /> + + {choosingBotLevel ? ( + + {t('ChooseBotLevel')} + navigation.replace('Loby', { context, botLevel })} /> + + ) : ( + setChoosingBotLevel(true)}> + {t('PlayWithBot')}} /> + + )} {lastGameId && ( <> diff --git a/apps/mobile/src/screens/LobyScreen.tsx b/apps/mobile/src/screens/LobyScreen.tsx index 7c5bf78..76648d4 100644 --- a/apps/mobile/src/screens/LobyScreen.tsx +++ b/apps/mobile/src/screens/LobyScreen.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import { LobyScreenProps } from '../types'; import { useEffect } from 'react'; import { CommonMancalaGame } from 'mancala.js'; -import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import CircularPanel from '../components/CircularPanel'; import { getColorByBrightness } from "@mancala/core"; import { SvgXml } from 'react-native-svg'; @@ -12,7 +13,13 @@ import helpSvg from '../svg/help'; export default function LobyScreen({ navigation, route }: LobyScreenProps) { - const { context } = route.params; + const { context, botLevel } = route.params; + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = React.useState(false); + + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } const { t } = useTranslation(); const onGameStart = async (message: Object) => { @@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); } }, []); @@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { return ( - {t('SearchingOpponent') + " " + t('PleaseWait') + "..."}} /> + {(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}} /> + {botOffered && ( + + {t('NoOpponentYet')} + + + )} {t('Cancel')}} /> diff --git a/apps/mobile/src/screens/RulesScreen.tsx b/apps/mobile/src/screens/RulesScreen.tsx new file mode 100644 index 0000000..c36402f --- /dev/null +++ b/apps/mobile/src/screens/RulesScreen.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { ScrollView, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { RULES } from '@mancala/core'; +import { RulesScreenProps } from '../types'; +import RuleBoard from '../components/RuleBoard'; + +// How to play: the same rules and examples as the web app (docs/specs/tutorial, R5.1). +export default function RulesScreen({ route }: RulesScreenProps) { + const { context } = route.params; + const { t } = useTranslation(); + const theme = context.themeManager.theme; + return ( + + {RULES.map((rule) => ( + + {t(rule.titleKey)} + {rule.textKeys.map((key) => ( + {t(key)} + ))} + {rule.example && } + + ))} + + ); +} diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 4b2db02..3e93b1b 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -1,11 +1,14 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { BotLevel } from '@mancala/core'; import { Context } from '../context/context'; export type RootStackParamList = { Home: { context: Context }, - Loby: { context: Context }, + // botLevel: start a game against the bot at once instead of searching for an opponent. + Loby: { context: Context, botLevel?: BotLevel }, Game: { context: Context, gameId: string, userKey: string } Help: { context: Context } + Rules: { context: Context } }; export type HomeScreenProps = NativeStackScreenProps; @@ -14,4 +17,6 @@ export type LobyScreenProps = NativeStackScreenProps export type GameScreenProps = NativeStackScreenProps; -export type HelpScreenProps = NativeStackScreenProps; \ No newline at end of file +export type HelpScreenProps = NativeStackScreenProps; + +export type RulesScreenProps = NativeStackScreenProps; diff --git a/apps/mobile/yarn.lock b/apps/mobile/yarn.lock index 04168dc..11ea220 100644 --- a/apps/mobile/yarn.lock +++ b/apps/mobile/yarn.lock @@ -1340,15 +1340,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@mancala/core@0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@mancala/core/-/core-0.0.2.tgz#c50abb60f0598306cc78f36c799dfd8a9e7c274b" - integrity sha512-wnufrDxNMkdlXFYulxTtTe88Tw79j9RPC7iQh+W/c9V2yTUBoVk2FzdO0Wj9ZV2/nrrOqYI0IchjpoRyl3RDBQ== - dependencies: - "@types/uuid" "^9.0.8" - mancala.js "^0.0.2-beta.3" - tiny-emitter "^2.1.0" - uuid "^10.0.0" +"@mancala/core@link:../../packages/core": + version "0.0.0" + uid "" "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" @@ -4784,10 +4778,9 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../../packages/mancala.js": + version "0.0.0" + uid "" marky@^1.2.2: version "1.3.0" diff --git a/apps/postgres/init/01-databases.sh b/apps/postgres/init/01-databases.sh new file mode 100755 index 0000000..0807b53 --- /dev/null +++ b/apps/postgres/init/01-databases.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Runs once, when the Postgres data directory is created: one database and user each for Keycloak and the game. +set -euo pipefail + +: "${KEYCLOAK_DB_PASSWORD:?KEYCLOAK_DB_PASSWORD is not set}" +: "${MANCALA_DB_PASSWORD:?MANCALA_DB_PASSWORD is not set}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -v keycloak_password="$KEYCLOAK_DB_PASSWORD" \ + -v mancala_password="$MANCALA_DB_PASSWORD" <<'EOSQL' +CREATE ROLE keycloak LOGIN PASSWORD :'keycloak_password'; +CREATE DATABASE keycloak OWNER keycloak; +CREATE ROLE mancala LOGIN PASSWORD :'mancala_password'; +CREATE DATABASE mancala OWNER mancala; +EOSQL diff --git a/config/nginx.conf b/config/nginx.conf index 02f14a1..44713a8 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,3 +1,5 @@ +limit_req_zone $binary_remote_addr zone=keycloak_auth:10m rate=10r/m; + server { listen 80 default_server; listen [::]:80 default_server; @@ -86,9 +88,27 @@ server { index index.html index.htm index.nginx-debian.html; server_name auth.playmancala.app; # managed by Certbot + # Keycloak sends large cookies and headers. + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Every guest login creates a Keycloak user, so limit how often one IP can start a login. + location = /realms/mancala/protocol/openid-connect/auth { + limit_req zone=keycloak_auth burst=20 nodelay; + limit_req_status 429; + proxy_pass http://localhost:8080; + } location / { - try_files $uri $uri/ =404; + proxy_pass http://localhost:8080; } listen [::]:443 ssl; # managed by Certbot diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md new file mode 100644 index 0000000..82f9c69 --- /dev/null +++ b/docs/specs/game-bot/design.md @@ -0,0 +1,239 @@ +# Game bot: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` +(the clock), `game-history` (saved games, live previews) and `quarkus-backend` (the Java backend and its engine). + +## Decisions + +| Decision | Reason | +|---|---| +| The bot runs in the backend, in Java | The rules are already in Java (`app.playmancala.engine`) and kept equal to the TypeScript engine by `EngineParityTest`. A bot game is then an ordinary server game: the clock, saving, spectators, previews and restarts work without changes in the clients (R4). A bot on the device would only add offline play, at the cost of a second bot in TypeScript (out of scope). | +| A compact board, `Position` (`int[14]` and whose turn it is), in the engine package | Searching means playing millions of moves; `MancalaGame` builds `Pit` objects, lists and animation steps for each, which is far too slow. `Position` plays a move into an existing array without allocating. It is in `engine`, not `bot`, so other code can use it too, e.g. checking a move before running it (see `TODO.md`, Security). It is tested against `MancalaGame` with the recorded parity games and random games. | +| `MancalaGame` is not rebuilt on `Position` now | `MancalaGame` also produces the game steps clients animate, the history and the JSON the Node backend wrote, and it must stay equal to the TypeScript engine. Rebuilding it is a separate refactor once `Position` has proven itself (out of scope). | +| Negamax with alpha-beta pruning, limited by depth and a node budget, not by time | Mangala is small (6 moves at most per turn), so alpha-beta is strong enough: at depth 12 the bot plays close to perfectly. Limits that do not depend on time make a move depend only on the position, the strength and the seed, so a game can be reproduced for debugging; a slower machine only takes longer. | +| Strength (0–100) sets three things: search depth, how much the bot prefers the best move (softmax temperature) and how often it plays a random move | Depth alone gives few, coarse steps and a predictable bot. With softmax, a weaker bot more often plays a move that is nearly as good, which looks like a human mistake; the random moves make Easy beatable by beginners (R2.3). Interpolating a table gives smooth steps between levels. | +| Each level is a range of strengths; the strength is drawn per game and varies by ±5 per move | R2.2. The ranges have gaps between them so neighbouring levels stay clearly apart (R2.4). | +| Randomness comes from a per-game seed and the move number | The bot's choice of a move is reproducible from the saved game, and a restart does not change how it plays (R4.6). The seed and strength are kept with the live game and the saved game. | +| Searching runs on its own small thread pool; the result goes back to the game loop, which checks that the game is still waiting for that move | The game loop must never wait (R3.5, `AGENTS.md`). The pool has a fixed number of threads, so many bot games share the CPU instead of starving the loop and the WebSocket threads. A result is dropped if the game ended or moved on meanwhile. | +| The bot's waiting time is computed on the server: the previous move's animation (300 ms per step, as `PitAnimator`) plus a random 1–3 seconds | R3.2. Clients do not need to know about bots for this. Searching happens during the wait, so the time it takes is not added to it. | +| Bot ids are `bot:`, e.g. `bot:medium` | One stable id per level reads well in saved games and lets later statistics group games by level. It is public and cannot belong to a person: Keycloak ids start with `kc:` and legacy ids must be UUIDs (`RtmtServer`). Because one id plays many games at once, `GameStore` does not map it to a game. Clients recognise bots by the id, so `PlayerSummary` keeps its shape. | +| The offer to play the bot (R1.1) is a timer in the client | The server does not need to know how long someone has waited. Matchmaking stays as it is; choosing a level sends a new message that also ends the search on the server. | +| Bot games are ordinary games everywhere: clock, saving, spectators, live previews, popular games | R4. Fewer special cases. The bot's moves go through the same code as a player's moves (R3.1). | + +## Shared types (`packages/core`) + +```ts +// rtmt/channel_names.ts +export const channel_new_bot_game = "new_bot_game"; // client -> server: { level: BotLevel } + +// models/Bot.ts +export type BotLevel = "easy" | "medium" | "hard" | "master"; +export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; +export const BOT_OFFER_DELAY_MS = 10_000; // R1.1 +export function isBotId(userId: string | undefined): boolean; // starts with "bot:" +export function getBotLevel(userId: string | undefined): BotLevel | undefined; +export function isBotLevel(value: unknown): value is BotLevel; +export function getBotLevelTextKey(level: BotLevel): string; // e.g. BotLevelMedium +export function getLastBotLevel(storage): BotLevel; // Medium until one is chosen (R1.4) +export function setLastBotLevel(storage, level: BotLevel): void; +``` + +- `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for + bots (R4.1). +- Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with + the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `ChooseBotLevel` "Choose how strong the bot + is" / "Botun ne kadar güçlü olacağını seç", `Bot` "Bot" / "Bot", `BotLevelEasy`, + `BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor", + "Usta", `PlayAgain` "Play again" / "Tekrar oyna". + +## Backend + +### Modules +``` +engine/Position.java compact board and the rules on it +bot/BotLevel.java the levels, their wire names and strength ranges +bot/BotSettings.java record (level, strength, seed); kept with a bot game +bot/BotStrength.java strength -> depth, temperature, random move chance +bot/BotSearch.java negamax with alpha-beta; scores of the root moves +bot/BotPolicy.java chooses a move: strength, search, softmax, random moves +bot/BotPlayer.java plays the bot's turns in live games +db/migrations/003_bot.sql bot column +``` +`engine` and `bot` (except `BotPlayer`) have no Quarkus or game loop code, so they can be tested and run on their +own. + +### `Position` +```java +public final class Position { + // CommonBoard layout: player 1's pits 0-5, bank 6; player 2's pits 7-12, bank 13. + public static Position initial(boolean player1ToMove); + public static Position of(int[] stones, boolean player1ToMove); + public static Position of(MancalaGame game); + public int[] stones(); // a copy + public boolean isPlayer1ToMove(); + public int legalPits(int[] into); // pit numbers 0-5 of the mover's non-empty pits; returns the count + // Plays pit 0-5 of the mover into `into` (may be this). Returns true if the mover plays again. + public boolean play(int pit, Position into); + public boolean isOver(); // one side is empty + public int bankDifference(); // mover's bank minus the opponent's +} +``` +`play` applies the rules in the engine's order: sowing (one stone goes to the next pit, otherwise the first stone +goes back into the played pit; the opponent's bank is skipped), last stone in an own empty pit, even count in an +opponent's pit, last stone in the own bank (extra turn) or the turn passes, and clearing the board when a side is +empty. + +### Search (`BotSearch`) +- Negamax with alpha-beta. A move that gives an extra turn keeps the side to move, so its child's score is not + negated. +- Scores are from the mover's view, in hundredths of a stone. An ended game scores + `±100_000 + 100 × final bank difference`; a game where a bank holds more than 24 stones is decided and scores as + ended. Other leaves: `100 × bank difference`. A term for the stones on each side (`PIT_STONE_WEIGHT`) is there + but set to 0: the levels met R2.4 without it, and stones on a side may still go to either player. +- Move order: extra turns first, then by the stones a move brings to the bank. Each node plays its moves once to + order them, into arrays kept per ply, so searching allocates nothing. +- Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an + exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed + depth are used; depth 1 always completes. + +### Strength (`BotLevel`, `BotStrength`) + +| Level | Wire name | Strength | +|---|---|---| +| Easy | `easy` | 5–25 | +| Medium | `medium` | 30–55 | +| Hard | `hard` | 60–80 | +| Master | `master` | 85–100 | + +Interpolated linearly between rows (depth rounded): + +| Strength | Depth | Temperature (stones) | Random move chance | +|---|---|---|---| +| 0 | 1 | 4 | 35% | +| 25 | 2 | 2 | 15% | +| 50 | 4 | 1 | 5% | +| 75 | 7 | 0.4 | 1% | +| 100 | 16 | 0.05 | 0% | + +The first table had depth 12 at strength 100 and already met R2.4. Searching deeper still helped: with best moves +only, depth 16 scored 64% against depth 12 (depth 14: 54%), at about 60 ms per move on average and 1 s at most +without a budget. So the top row is 16, and the node budget keeps the slowest moves near 0.2 s. + +`BotTournamentTest` with these values, 200 games per pair (October 2026, 24 cores, one thread): + +| Pair | Won | Lost | Drawn | Score | Stronger level's time per move | +|---|---|---|---|---|---| +| Medium vs Easy | 155 | 34 | 11 | 0.80 | < 0.1 ms | +| Hard vs Medium | 158 | 29 | 13 | 0.82 | 0.2 ms | +| Master vs Hard | 177 | 16 | 7 | 0.90 | 20 ms | +| Master vs Easy | 200 | 0 | 0 | 1.00 | 22 ms | + +### Choosing a move (`BotPolicy`) +```java +static int chooseMove(Position position, BotSettings bot, int moveNumber); // pit 0-5 +``` +1. A random source from `bot.seed()` and `moveNumber` (number of moves played in the game). +2. This move's strength: `bot.strength()` ±5, kept within 0–100. +3. With the random move chance: a random legal pit. +4. Otherwise search to the depth and pick a root move with probability ∝ `exp(score / (temperature × 100))`. +5. With a single legal pit, play it without searching. + +### Bot games in the game logic (`GameManager`) +- New channel `new_bot_game` with `{ level }`. An unknown level is logged and ignored. A player with a game in + progress gets `on_new_game_rejected` (R1.5). Otherwise `matchMaker.cancel(userId)`, then a game with the player + as player 1 and `bot:` as player 2, a random first mover (R1.6), and + `bot = new BotSettings(level, random strength in the level's range, random seed)`; then `startGame` as usual. +- `BackendGame` gains `BotSettings bot` (null for games between people) and `botId()`. +- The bot's `PlayerSummary` is `(bot:, "Bot", false)`. +- `onGameMove` is split: the socket handler finds the game and calls `playMove(game, playerId, pitIndex)`, which + holds today's logic (clock check, engine, clock charge, updates, ending). The bot calls the same method (R3.1). +- `onTurnStarted(Consumer)`: called after a game starts, after a move that does not end the game + (including an extra turn) and for each restored game. +- Connection info shows the bot online (R3.4); messages to the bot's id are not sent. +- `GameStore.setGameByUser` and `removeGameByPlayer` ignore bot ids. +- `PublicIds.publicUserId` returns bot ids as they are. + +### `BotPlayer` +```java +public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor, + RandomGenerator random) // random: thinking times only; used on the loop +``` +Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game: +1. On the loop: copy the position (`Position.of`), note the move number and compute + `readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step + of the last move plus 300 ms; before the first move it is 1.5 s, so the game page has opened. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master + 1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3). +2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used, + so the game never waits for a bot that failed. +3. Back on the loop (`loop.execute`, then `loop.schedule` for what is left until `readyAt`): if the game is still + live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call + `gameManager.playMove(game, botId, pit)`; otherwise drop the result. + +Games being worked on are kept in a set by identity, so a game restored with the same id is a new game for it. + +`searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least +1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`. + +### Restore (R4.6) +- `LiveGameSnapshot` gains `bot` (`@JsonInclude(NON_NULL)`); the snapshot stays version 2, since the field is + optional. `fromSnapshot` restores it. A snapshot with a bot player but no `bot` field (not expected) gets the + middle strength of the level and a new seed. +- `restoreLiveGames` calls the turn listeners, so a bot whose turn it was moves. + +### Saving (R4.3) +- `GameRecord` gains `bot` (`@JsonInclude(NON_NULL)`): `{ level, strength, seed }`, sent with `GET /game/:id`; + clients do not use it. It allows tuning the levels from real games later. +- Migration `003_bot`: `ALTER TABLE games ADD COLUMN bot jsonb;` (NULL for games between people). + `PostgresGameHistoryRepository` writes and reads it. + +### Configuration +`BOT_SEARCH_THREADS` (default: half the processors, at least 1). + +## Web app +- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with + `getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4). +- Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account + menu, with the last level checked; choosing a level goes to `/loby?bot=` (R1.3). +- `LobyPage`: with `?bot=`, it sends `new_bot_game` instead of `new_game`. Otherwise, after + `BOT_OFFER_DELAY_MS` without a game, it shows `NoOpponentYet` and the picker below the search; choosing a level + sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays. +- `GamePage` / `GameOverCard`: in a bot game, the main button is "Play again" and goes to `/loby?bot=` with + the bot's level (R4.4). +- Bot names: every place that shows a player uses `getPlayerDisplayName` with the player's `userId` (R4.1). + +## Mobile app +- `LobyScreen`: the same offer after `BOT_OFFER_DELAY_MS`, and a `botLevel` route parameter that sends + `new_bot_game` at once. +- `HomeScreen`: a "Play with bot" button with the level choice; the last level is kept in MMKV. +- Bot names through `getPlayerDisplayName`. + +## Testing +- `Position`: replays the recorded parity games (`/engine/recorded-games.json`) and a few thousand random games + move by move against `MancalaGame`, comparing stones, whose turn it is and the end. +- `BotSearch`: finds an extra turn, a capture and a win in one or two moves; the same position, depth and budget + give the same scores; the node budget is respected. +- `BotStrength` and `BotLevel`: interpolation, the ranges, strengths drawn stay in range. +- `BotPolicy`: the same seed and move number give the same move; random moves only at the expected rate; a + single legal pit is played; R2.5 with a fixed opponent. +- Calibration: `BotTournamentTest`, tagged `calibration` and left out of `./mvnw test` + (`./mvnw test -Dgroups=calibration`), plays 200 games for each pair of neighbouring levels and Master against + Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per + level. A quick version (20 games, Master against Easy) runs with the normal tests. +- `BotPlayer` with `FakeRtmt`, `ManualScheduler` and a direct executor: the bot moves after the delay; extra turns; + a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where + it is the bot's turn gets a move. (The fallback for a failing search has no test: `BotPolicy` is static and cannot + be made to fail from outside.) +- `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game; + unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and + `bot`; leaving and running out of time end the game as usual. +- Postgres repository: the `bot` column is saved and read. Snapshots with and without `bot` restore. +- End to end in Chrome with `GAME_CLOCK_MS=60000`: the offer appears after 10 s in the lobby and starts a game; + "Play with bot" on the home page; the bot moves after the animation; the game ends and shows in recent games and + the replay as "Bot · "; "Play again" starts a new bot game; a backend restart during the bot's turn; a + second browser watches a bot game from the live previews. + +Checked end to end in Chrome on 2026-10-16 with the default clock: the home menu and the lobby offer (shown after +10.3 s) start games at the chosen level; "Bot · Easy" / "Bot · Hard" on the game page, in recent games and in a +second browser's live previews; an Easy game played to the end (the bot waited 1.45–4 s per move, longer after long +animations); "Play again" starts a new game at the same level; the backend stopped right after a Master game started +and the bot made its first move after the restart. diff --git a/docs/specs/game-bot/requirements.md b/docs/specs/game-bot/requirements.md new file mode 100644 index 0000000..20d2cf3 --- /dev/null +++ b/docs/specs/game-bot/requirements.md @@ -0,0 +1,74 @@ +# Game bot: requirements + +Status: implemented on branch `feature/game-bot` (based on `develop`). +Scope: backend, `packages/core`, web app and mobile app. + +## Goals + +Players can always get a game: when no other player is looking for one, they can play against a bot. They choose +how strong the bot is, but within a level the bot is not always equally strong, so games against it differ from +each other. A game against the bot is a normal game: it has the clock, can be watched and is saved like any other. + +## Terms + +- **Bot**: a player run by the server. +- **Level**: what the player chooses: Easy, Medium, Hard or Master (Turkish: Kolay, Orta, Zor, Usta). +- **Strength**: a number from 0 to 100 that sets how well the bot plays. Each level is a range of strengths; the bot + takes a strength from it at the start of each game. +- **Bot game**: a game between a player and the bot. + +## Requirements + +### R1. Starting a bot game +- R1.1 When a player has been looking for an opponent for 10 seconds without one being found, the lobby offers a + game against the bot with the four levels to choose from. The search goes on while the offer is shown; if an + opponent is found first, the game with them starts as usual. +- R1.2 Choosing a level ends the search and starts a bot game at that level at once. +- R1.3 The home page also has a "Play with bot" button that opens the same choice of levels, without searching for + an opponent first. +- R1.4 The level chosen last is preselected the next time (kept on the device). +- R1.5 A player who has a game in progress cannot start a bot game; they are offered to continue that game, as when + they look for an opponent. +- R1.6 Who moves first is chosen at random, as in other games. + +### R2. Levels and strength +- R2.1 There are four levels, from weakest to strongest: Easy, Medium, Hard, Master. +- R2.2 At the start of each game the bot takes a random strength within the level's range. Within a game, it plays + each move with a strength that varies a little around that value. +- R2.3 Easy makes obvious mistakes and can be beaten by a beginner. Master plays close to perfectly and is rarely + beaten. +- R2.4 Each level is clearly stronger than the one below: played against each other by the server, the stronger + of two neighbouring levels wins at least 65% of the games, and Master wins at least 95% against Easy. +- R2.5 Two games at the same level from the same starting position do not play out the same way: in 20 such games + against the same sequence of the opponent's moves, the bot's first five moves are not all the same. + +### R3. How the bot plays +- R3.1 The bot plays by the same rules as players; its moves go through the same game logic. +- R3.2 The bot does not move instantly: it waits until the previous move's animation has finished, then about 1 to + 3 seconds more, a little longer at the higher levels. An extra turn is played the same way. +- R3.3 The bot has a clock like any player and its waiting counts on it. It never runs out of time. +- R3.4 The bot never leaves a game and is always shown as online. +- R3.5 Working out the bot's moves never delays other games: other players' moves are handled as fast as without + bots, even while many bot games are being played. + +### R4. During and after the game +- R4.1 The bot is shown as "Bot" with its level, e.g. "Bot · Medium" / "Bot · Orta", wherever players are shown: + the game page, live game previews, recent and popular games, and replays. +- R4.2 Everything that ends a game works as in other games: a player who leaves loses, a player whose time runs out + loses, a completed game is won by the player with more stones. +- R4.3 Bot games are saved like other games and appear in the player's recent games and can be replayed. +- R4.4 At the end of a bot game, the game over card offers "Play again", which starts a new bot game at the same + level. +- R4.5 Bot games can be watched by others and appear in live game previews like other games. +- R4.6 A bot game survives a backend restart: the bot goes on at the same strength, and if it was its turn, it moves. + +### R5. Mobile app +- R5.1 The mobile app has the offer in the lobby (R1.1, R1.2), the button on the home screen (R1.3) and shows the + bot's name with its level (R4.1). + +## Out of scope (TODO) +- Playing against the bot without a connection (a bot in the TypeScript engine on the device). +- Ratings or statistics; when they come, bot games should be kept apart from games against people. +- Hints or move analysis for players. +- Choosing an exact strength or who moves first. +- Rebuilding the game engine (`MancalaGame`, `Board`, the rules) on the compact board this feature adds. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md new file mode 100644 index 0000000..5ef03f9 --- /dev/null +++ b/docs/specs/game-bot/tasks.md @@ -0,0 +1,21 @@ +# Game bot: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity + games and random games. +- [x] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit + tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the + strength table until R2.4 holds, and write the final values into `design.md`. +- [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, + `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in + `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. +- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, + `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. +- [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot + game, bot names everywhere players are shown. +- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [x] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, + `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items). diff --git a/docs/specs/game-clock/design.md b/docs/specs/game-clock/design.md new file mode 100644 index 0000000..28cd173 --- /dev/null +++ b/docs/specs/game-clock/design.md @@ -0,0 +1,141 @@ +# Game clock: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-history` +(live game snapshots in Valkey, saved games in Postgres). + +## Decisions + +| Decision | Reason | +|---|---| +| The server is the only authority on time; clients only display it | Players cannot gain time by changing their device clock or client. A move arriving too late is refused on the server (R2.3). | +| The clock is stored as "time left when the turn started" plus `turnStartedAt`, not as a ticking value | The state only changes on moves, so nothing is written per second; the Valkey snapshot that is already written on every move carries it. The time left at any moment is computed. | +| A scheduled service checks all live games every second, in addition to the check when a move arrives | It ends games where nobody does anything (R2.2) and is simple to reason about and to restore after a restart. Because late moves are refused, checking once a second never changes a result; it only decides how soon the end is announced. Per-game timers were considered: more precise, but every move, end and restore would have to keep them in sync. A few hundred live games are a trivial loop. | +| The end reason "timeout" is reused | It keeps its meaning: the player whose turn it was ran out of time. The database check constraint, results, texts and the recent games list need no change. Old saved timeouts came from the offline rule; the difference is not shown. | +| The offline rules from `game-history` (`TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, `offlineSince`) are removed | The running clock ends any game where the player to move is away within 5 minutes (R6.1), so the old rules can no longer trigger. One mechanism instead of two. | +| 5 minutes, no increment, set by `GAME_CLOCK_MS` (default `300000`) | R1.1. The variable allows short clocks in tests and later tuning. The initial time travels with each game, so clients do not hard-code it and a running game keeps its time control if the setting changes. | +| Time while the backend is down is not charged (R5.1) | A deploy must not make anyone lose. On restore, the running clock restarts from the time left at the start of the turn; the player gets back what they spent on that turn before the restart. | +| Messages carry the server's time | Clients compute the offset between their clock and the server's and count down from it, so a wrong device clock does not matter (R3.3). | +| Animation and network delay are not compensated | A long move animates for a few seconds (300 ms per step) on the next player's time. This is accepted for now, like network delay; time added per move is the fix if it turns out to matter (out of scope). | + +## Shared types (`packages/core`) + +```ts +// models/GameClock.ts +interface GameClock { + initialMs: number; + // Time left for each player. For the player whose turn it is: at turnStartedAt. + player1Ms: number; + player2Ms: number; + // Server time when the running clock started; undefined when no clock runs (the game has ended). + turnStartedAt?: number; +} +const CLOCK_LOW_TIME_MS = 30_000; // R3.2 +``` + +- `Game` (live game messages) gains `clock?: GameClock` and `serverTime?: number` (server time when the message was + built). Optional, so older servers and clients keep working. +- `GameEndMessage` gains `clock?: GameClock`, the stopped clocks (R3.5). +- `MoveRecord` gains `remainingMs?: number`, the mover's time left after the move (R4.1). +- `FinishedGameSummary` gains `clockInitialMs?: number`; undefined for games saved before this feature (R4.3). + +Helpers in `packages/core/src/clock/`, used by the backend, the web app and the mobile app: +- `getClockTimes(clock, runningSeat, now)` → `{ player1Ms, player2Ms }`, never below zero. +- `ClockSync`: `update(game, receivedAt)` keeps the clock and the server offset (`serverTime - receivedAt`) from the + last message; `getTimes(now)` returns both players' time and the running seat. Framework-free so both apps can use it. +- `getClockAtMove(record, n)` → both players' time after move n of a saved game (n = 0: the initial time), from + `clockInitialMs` and the movers' `remainingMs`; undefined for games without clock data. On a timeout, the last + position shows the loser at zero. +- `formatClock(ms)` → `m:ss`, rounded up so `0:00` only shows when the time is gone. + +## Backend + +### Modules +``` +src/game/GameClock.ts pure functions on GameClock +src/game/GameClockScheduler.ts the scheduled service (R2.2) +src/db/migrations/002_clock.ts clock_initial_ms column +``` + +`GameClock.ts`: +- `startClock(initialMs, now)`: both players at `initialMs`, `turnStartedAt = now`. +- `remainingMs(clock, seat, runningSeat, now)`: the stored time, minus `now - turnStartedAt` for the running seat. +- `chargeMove(clock, seat, now)`: deducts the turn's time from the mover and sets `turnStartedAt = now`, for a turn + change and an extra turn alike (R1.2, R1.3). Returns the mover's time left. +- `stopClock(clock, runningSeat, now)`: deducts the running seat's time (at least down to zero) and clears + `turnStartedAt`. +- `resumeClock(clock, now)`: sets `turnStartedAt = now` without deducting anything (R5.1). + +### Game lifecycle (`GameManager`) +- `BackendGame` gains `clock: GameClock` and `moveRemainingMs: number[]`, aligned with `mancalaGame.history` like + `moveTimes`. +- **Start**: `startClock(config.gameClockMs, now)`. +- **Move**: before the engine is called, if the mover is the turn player and their time is gone, the move is not + played and the game ends with `timeout` (R2.3). After a move that was played (history grew), `chargeMove` and push + the result to `moveRemainingMs`. +- **End** (any reason): `stopClock`, then the final update and `GameEndMessage` carry the stopped clock. +- **`endGamesOutOfTime(now)`** (public): ends with `timeout` every live game whose running clock is at zero; the + winner is the opponent (R2.1). Each game is handled on its own, so an error in one is logged and does not stop the + others. +- **Messages**: `toClientGame` adds `clock` and `serverTime`. Reconnecting players already get `on_game_update`, so + R3.4 needs nothing more. +- **Removed**: `checkTimeouts`, the 10-second timer (`startTimers` / `stopTimers`), the `offlineSince` map and + `UserConnectionInfo.offlineSince`; `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS` in core. + +### Scheduled service (`GameClockScheduler`) +```ts +class GameClockScheduler { + constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */) + start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock + stop(): void +} +``` +`src/index.ts` starts it after `restoreLiveGames()`, replacing `gameManager.startTimers()`. +It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through +`GameRecorder` with retry as today. + +### Restore (R5) +- The snapshot becomes `version: 2` with `clock` and `moveRemainingMs`. `isLiveGameSnapshot` accepts 1 and 2. +- `fromSnapshot`: version 2 → `resumeClock(snapshot.clock, now)`; version 1 → `startClock(config.gameClockMs, now)` + and empty `moveRemainingMs` entries (R5.2). + +### Saving (R4.1) +- `toGameRecord` sets `clockInitialMs` and each move's `remainingMs` (from `moveRemainingMs`). +- Migration `002_clock`: `ALTER TABLE games ADD COLUMN clock_initial_ms integer;` (NULL for older games). + `remainingMs` lives in the existing `moves` jsonb. `PostgresGameHistoryRepository` writes and reads the column. + +### Configuration +`GAME_CLOCK_MS` (default `300000`) in `src/consts/config.ts`, passed into `GameManager`. + +## Web app + +- `hooks/useGameClock(game)`: a `ClockSync` updated on every game message (`on_game_start`, `on_game_update`, + `on_game_end`), re-rendering every 250 ms while a clock runs and not at all once the game has ended. +- `components/GameClockView.tsx`: one player's `m:ss`; highlighted with `playerTurnColor` while running; a warning + style below `CLOCK_LOW_TIME_MS`. +- `GamePage`: a clock beside each `UserStatus` in the board top toolbar, and in the mobile toolbar. The + "opponent is offline" countdown banner and the `OpponentOfflineCountdown` string are removed (R6.2). +- `ReplayView`: both clocks from `getClockAtMove(record, shownMove)`, hidden when `clockInitialMs` is missing (R4.2, + R4.3). +- Texts: `OpponentTimedOut` / `YouTimedOut` become "Opponent ran out of time" / "You ran out of time" in English; the + Turkish ones ("Rakibin süresi doldu", "Süren doldu") already fit. + +## Mobile app + +`GameScreen` shows the two clocks with `ClockSync` and `formatClock` from core, next to the players' names, with the +same running and low-time styles. Old app versions in the stores do not show the clock but are still bound by it; +the update should be released before or with the backend (see `TODO.md`). + +## Testing +- `packages/core`: `getClockTimes`, `ClockSync` (including a device clock that is off by minutes), `getClockAtMove` + (with and without clock data, timeout at the end), `formatClock`. +- Backend unit tests with the fake clock `GameManager` tests already use: + - a move deducts the turn's time; an extra turn keeps the clock running; the other clock does not change; + - a move after the time is gone is refused and ends the game with the opponent as winner; + - `endGamesOutOfTime` ends only games at zero, tells players and spectators, and saves the game; + - a completed game stops the clocks; + - restoring version 2 snapshots does not charge downtime; version 1 snapshots get a new clock; + - `GameClockScheduler` calls the sweep on its interval and stops (Jest fake timers). +- Postgres repository: `clock_initial_ms` and `remainingMs` survive saving and reading. +- End to end in Chrome with `GAME_CLOCK_MS=20000`: two guests play a few moves and the clocks agree on both pages; + one stops moving and loses on time with the right texts on both sides; a reload keeps the clocks; a backend + restart does not charge the time it was down; the replay shows the clocks. diff --git a/docs/specs/game-clock/requirements.md b/docs/specs/game-clock/requirements.md new file mode 100644 index 0000000..8829ad3 --- /dev/null +++ b/docs/specs/game-clock/requirements.md @@ -0,0 +1,71 @@ +# Game clock: requirements + +Status: implemented on branch `feature/game-clock` (based on `feature/game-history`). +Scope: backend, web app and mobile app. + +## Goals + +Every game has a chess-style clock: each player has 5 minutes for all of their moves. A player whose time runs out +loses. The server keeps the time and ends such games on its own, whether or not anyone is connected. + +## Terms + +- **Clock**: the time a player has left in a game. +- **Running clock**: the clock of the player whose turn it is. Only one clock runs at a time. +- **Turn**: from the moment a player is to move until the server receives their move. + +## Requirements + +### R1. Clock +- R1.1 Each player starts a game with 5 minutes. No time is added after a move. +- R1.2 Only the clock of the player whose turn it is runs. It starts when the game starts, for the player who moves + first. When a move passes the turn, the mover's clock stops and the opponent's starts. +- R1.3 When a move gives the mover an extra turn, the mover's clock keeps running. +- R1.4 The server measures the time: a turn lasts from when it started on the server to when the server receives the + move. Network delay and the time the client spends animating the opponent's move count as the player's time. +- R1.5 The clock runs whether or not the player is online. + +### R2. Running out of time +- R2.1 When a player's clock reaches zero, the game ends: the opponent wins and the end reason is "timeout". +- R2.2 The server ends such games on its own within 1 second of the time running out, even if no client is + connected or sends anything. +- R2.3 A move that reaches the server after the mover's time has run out is not played; the game ends as in R2.1. +- R2.4 Players see "You won · Opponent ran out of time" or "You lost · You ran out of time"; spectators see who + won, as for other endings. + +### R3. Showing the clocks +- R3.1 Players and spectators of a live game see both clocks next to the players' names as `m:ss`. The running + clock is highlighted. +- R3.2 When less than 30 seconds are left, the running clock is shown in a warning style. +- R3.3 The running clock counts down smoothly. It differs from the server's time by no more than about one second, + even if the device's clock is wrong. +- R3.4 After a reload or a reconnect, the clocks show the right times at once. +- R3.5 When the game ends, both clocks stop at the time they had; a player who ran out of time shows `0:00`. +- R3.6 The mobile app shows the clocks in the same way. + +### R4. Saved games and replay +- R4.1 A saved game keeps the time each player started with and, for every move, the time the mover had left after + it. +- R4.2 The replay of a game shows both clocks as they were after the move being shown, and the time each player + started with before the first move. +- R4.3 Games saved before this feature have no clock data; their replay shows no clocks. + +### R5. Restarts and deploys +- R5.1 Clocks survive a backend restart. Time while the backend is down does not count: after a restart, the + running clock goes on from where it was when the current turn started. +- R5.2 Games that are in progress when this feature is deployed get a new clock (5 minutes each) when the backend + restores them. + +### R6. Rules the clock replaces +- R6.1 The clock replaces two rules from `game-history` (R3.3 and R3.4): losing after 5 minutes offline on your turn, + and a game without a winner after both players have been offline for 30 minutes. Because the running clock does not + stop for an offline player, it ends every such game within 5 minutes. +- R6.2 The "opponent is offline" countdown is replaced by the clocks. The online / offline status of the players + stays. +- R6.3 Saved games that ended by the old rules still show as before ("timeout", "inactive"). + +## Out of scope (TODO) +- Other time controls, time added per move, or letting players choose. +- Clocks in the home page's live game previews and in the ongoing-game banner. +- Making up for network delay or animation time. +- Pausing a game, or more time by agreement. diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md new file mode 100644 index 0000000..a388a6a --- /dev/null +++ b/docs/specs/game-clock/tasks.md @@ -0,0 +1,21 @@ +# Game clock: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, + `GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`, + `formatClock` with unit tests; English timeout texts. +- [x] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on + `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and + `serverTime` in client messages; unit tests. +- [x] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove + `checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests. +- [x] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and + `clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests. +- [x] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile + layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, + `UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then). +- [x] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`. +- [x] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle. +- [x] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile + update before or with the backend; the out of scope items); mark `game-history` R3.3 and R3.4 as replaced. diff --git a/docs/specs/game-history/design.md b/docs/specs/game-history/design.md new file mode 100644 index 0000000..4c78fd1 --- /dev/null +++ b/docs/specs/game-history/design.md @@ -0,0 +1,205 @@ +# Game history and live games: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. + +## Decisions + +| Decision | Reason | +|---|---| +| **PostgreSQL** for saved games, not MongoDB | MongoDB moved to SSPL. Postgres already runs for Keycloak, so it adds no new technology; `jsonb` stores the move list as a document and the queries (a user's games, most spectated games) are relational. FerretDB was considered (Mongo API on Postgres) but adds a translation layer and compatibility gaps for no gain. | +| One Postgres server, separate databases and users for Keycloak and the game | One container to run and back up on a small server. | +| **Valkey** for live game state and short-lived caches, not Redis | Redis changed its license in 2024; Valkey is the BSD-licensed fork and is protocol compatible. Client: `iovalkey` (MIT). | +| The backend's memory stays the source of truth for live games; Valkey is a write-through copy | The backend runs as a single instance; reading from memory keeps moves fast and simple. Valkey only matters on restart (R1.2). | +| A game is written to Postgres once, when it ends | Live state is already durable in Valkey; one write per game keeps the database simple. | +| Database and cache failures never stop a game | R1.3. Writes are best effort with retry; reads fall back to empty lists. | +| Legacy ids are replaced with a hash before anything is saved or listed | R8.3. The database never holds a legacy userKey. | + +## Infrastructure + +`apps/compose.yaml`: +- `postgres` (`postgres:17.6`) replaces `keycloak-db`. An init script (`apps/postgres/init/01-databases.sh`) creates + the `keycloak` and `mancala` databases, each with its own user, from `KEYCLOAK_DB_PASSWORD` and + `MANCALA_DB_PASSWORD`. Exposed on `127.0.0.1:5433` for local development. +- `valkey` (`valkey/valkey:9.1.2`, append-only file on so live games survive a Valkey restart). Exposed on + `127.0.0.1:6379`. +- Backend configuration: `DATABASE_URL` (default `postgres://mancala:mancala@localhost:5433/mancala`) and + `VALKEY_URL` (default `redis://localhost:6379`). + +## Data model (PostgreSQL) + +Migrations live in `apps/backend/src/db/migrations/` as TypeScript files exporting SQL, applied in order at startup +and recorded in `schema_migrations`. + +```sql +CREATE TABLE games ( + id uuid PRIMARY KEY, + player1_id text NOT NULL, + player2_id text NOT NULL, + result text CHECK (result IN ('player1', 'player2', 'draw')), -- NULL: no winner (inactive) + end_reason text NOT NULL CHECK (end_reason IN ('completed', 'left', 'timeout', 'inactive')), + left_user_id text, + started_at timestamptz NOT NULL, + ended_at timestamptz NOT NULL, + move_count integer NOT NULL, + final_board jsonb NOT NULL, -- 14 stone counts + moves jsonb NOT NULL, -- MoveRecord[] + peak_spectators integer NOT NULL DEFAULT 0, + total_spectators integer NOT NULL DEFAULT 0 +); + +CREATE TABLE game_players ( + game_id uuid NOT NULL REFERENCES games (id) ON DELETE CASCADE, + seat smallint NOT NULL CHECK (seat IN (1, 2)), + user_id text NOT NULL, -- public id (see "Public ids") + name text, + is_guest boolean NOT NULL, + PRIMARY KEY (game_id, seat) +); + +CREATE INDEX game_players_user_id ON game_players (user_id); +CREATE INDEX games_popular ON games (peak_spectators DESC, ended_at DESC); +``` + +Saving uses `INSERT … ON CONFLICT (id) DO NOTHING` in one transaction, so retries are safe. + +## Public ids (R8.3) + +`publicUserId(id)`: Keycloak ids (`kc:…`) are returned unchanged; any other id is a legacy userKey and becomes +`legacy:` + the first 20 hex characters of its SHA-256. Applied to every id in saved games, popular lists and live +previews. Live game messages to the players and spectators of a game, and `GET /game/:id` for a live game, keep raw +ids because the current mobile app compares its userKey with them; this exposure already exists and goes away with +`LEGACY_USERKEY_AUTH` (see `TODO.md`). + +## Shared types (`packages/core`) + +```ts +interface PlayerSummary { userId: string; name?: string; isGuest: boolean } +interface MoveRecord { playerId: string; moveIndex: number; boardSnapshot: number[]; gameSteps: GameStep[]; at: number } +type GameResult = "player1" | "player2" | "draw"; +type EndReason = "completed" | "left" | "timeout" | "inactive"; + +interface LiveGameSummary { + id: string; player1: PlayerSummary; player2: PlayerSummary; board: number[]; + turnPlayerId: string; spectatorCount: number; moveCount: number; startedAt: number; +} +interface FinishedGameSummary { + id: string; player1: PlayerSummary; player2: PlayerSummary; board: number[]; + result: GameResult | null; endReason: EndReason; leftUserId?: string; + startedAt: number; endedAt: number; moveCount: number; peakSpectators: number; +} +interface GameRecord extends FinishedGameSummary { moves: MoveRecord[]; totalSpectators: number } // replay payload +``` + +Live `Game` messages gain `startedAt` and `moveTimes: number[]` (one per history entry), and `UserConnectionInfo` +gains `offlineSince?: number`. `TURN_TIMEOUT_MS` (5 minutes) is exported so the client can show the countdown. + +## Backend + +### Modules +``` +src/db/postgres.ts pool, migrations +src/history/GameHistoryRepository.ts interface + PostgresGameHistoryRepository +src/live/LiveGameStore.ts interface + ValkeyLiveGameStore (snapshots, pending saves) +src/cache/Cache.ts JSON get/set/delete with TTL in Valkey +src/game/GameSerialization.ts BackendGame <-> snapshot, rebuilding the engine objects +src/game/GameRecorder.ts turns a finished game into a GameRecord and saves it, with retry +src/game/LiveGamesBroadcaster.ts top live games, subscriptions, throttled push +src/game/publicIds.ts publicUserId and helpers +``` + +### Game lifecycle (`GameManager`) +- **Start**: as today, plus `startedAt`, empty `moveTimes`, spectator counters; the snapshot is written to Valkey. +- **Move**: the time is appended to `moveTimes`; the snapshot is written to Valkey; live previews are scheduled. +- **End** (R3): the outcome is decided, the game is removed from memory and from Valkey, players and spectators get + the final update, and `GameRecorder` saves the record. + - completed: `mancalaGame.getWonPlayerId()`, draw if none. + - left: the leaving player loses (`left_user_id` is set). + - timeout / inactive: decided by a watcher that runs every 10 seconds, using the time each player went offline + (`offlineSince`, kept per user and cleared when they reconnect). +- **New game while playing** (R2.3): `MatchMaker` is not called; the user gets `on_new_game_rejected` with the game + id. +- **Restart** (R1.2): on startup all snapshots are loaded from Valkey and put back into memory; both players count as + offline from that moment, so the timeout rules start fresh. Snapshots of games that are already saved are dropped instead. If Valkey is + unreachable at startup, the backend starts without restoring. +- **Messages**: players and spectators get a trimmed game (`id`, `mancalaGame`, `gameUsersConnectionInfo`, + `startedAt`, `moveTimes`, `spectatorCount`), never the spectator ids. When a game ends for any reason they also get + `on_game_end` (`GameEndMessage`); a player leaving still sends `on_game_user_leave` as before. +- **Fixes needed by this feature**: spectators are removed when they disconnect (the `for…in` loop in + `GameStoreImpl`), finished games are removed from memory, and the connection info is sent once at game start. + +### Saving with retry (R1.3) +`GameRecorder.save(record)`: on failure the record is stored in Valkey under `mancala:pending-save:{id}` (and kept in +memory if Valkey is down too) and retried every 30 seconds and on startup. + +### Valkey keys +| Key | Content | +|---|---| +| `mancala:live:{gameId}` | snapshot of a live game (JSON) | +| `mancala:pending-save:{gameId}` | game record that could not be saved yet | +| `mancala:cache:popular-finished` | popular finished games, 10 s TTL | +| `mancala:cache:recent:{userId}` | a user's recent games, 10 s TTL | + +Caches are deleted when a game ends. The client uses `enableOfflineQueue: false` so commands fail at once instead of +queueing while Valkey is down. + +### HTTP API +| Route | Auth | Response | +|---|---|---| +| `GET /games/me/active` | Bearer token | `{ game: LiveGameSummary \| null }` | +| `GET /games/me?limit=3` | Bearer token | `{ games: FinishedGameSummary[] }` (limit 1–20) | +| `GET /games/popular?limit=5` | none | `{ kind: "live" \| "finished", games: [...] }`; live games if any, else finished | +| `GET /game/:id` | none | a live game as today, else `{ status: "finished", record: GameRecord }`, else 404 | + +The token is the same Keycloak access token used for the WebSocket, checked with the existing `TokenVerifier`. + +### WebSocket channels (added to `channel_names`) +| Channel | Direction | Payload | +|---|---|---| +| `subscribe_live_games` / `unsubscribe_live_games` | client → server | – | +| `on_live_games` | server → client | `{ games: LiveGameSummary[] }`, sent on subscribe and at most once per second when the top 5 change | +| `on_new_game_rejected` | server → client | `{ gameId }` | + +Ordering for live games: spectator count, then most recent move. Preview subscribers are tracked per user and are +not spectators (R4.5). + +## Web app + +### Board size +`BoardView` gets an optional `width` (px). All board components size themselves from a CSS variable +`--board-unit` (default `1vw`, else `width / 92` px, the board being 92 units wide) instead of `vw`, so the same board renders at full width in a +game and small in previews. + +### Home page (`routes/Home.tsx`) +1. `ActiveGameBanner`: from `GET /games/me/active`; opponent, whose turn, **Continue**. +2. `LiveGamesSection`: `GET /games/popular` on load, then `subscribe_live_games`; a grid of `GamePreviewCard`s + (read-only board, names, spectator count). Title "Live games" or "Popular games" by `kind`. +3. `RecentGamesSection`: `GET /games/me?limit=3`; cards with opponent, result for this user, score, date, board. + +### Lobby +On `on_new_game_rejected` the lobby shows "You have a game in progress" with **Continue**. + +### Move history (shared by R6 and R7) +- `packages/core/src/history/`: `getInitialBoard()`, `getBoardAfterMove(moves, n)` (n = 0 is the start position), + `describeMove(moves, n)` → `{ number, playerId, pit (1–6), extraTurn, capture, storeGain, gameOver }`, and + `createGameAtMove(game, n)`, which builds the engine objects `PitAnimator` needs to animate move n. + - extra turn: a `GAME_STEP_LAST_STONE_IN_BANK` step; capture: `GAME_STEP_LAST_STONE_IN_EMPTY_PIT` or + `GAME_STEP_DOUBLE_STONE_IN_PIT`; game over: `GAME_STEP_BOARD_CLEARED`; store gain: the mover's store after the + move minus before. +- `MoveList`: the list, highlighting the shown move; clicking selects a move. +- Live game (`GamePage`): `viewedMove` state. When set, the board shows `getBoardAfterMove` and pits are disabled; + `ReviewBanner` shows the move number, "Your turn" when it applies, and **Back to live**. ← / → keys step. +- Countdown: if the opponent is offline on their turn, the info panel shows the time left from `offlineSince`. + +### Replay (`ReplayView`, R7) +`GamePage` renders `ReplayView` when `GET /game/:id` returns a finished record. It starts at the last move with the +result and **Watch from start**. Controls: ⏮ ◀ ▶/⏸ ▶| ⏭, speed 1x/2x/4x, a range input as the timeline, the +`MoveList`, keyboard (← → space). Forward steps go through `PitAnimator` with the games from `createGameAtMove`; +jumps set the board directly. `?move=n` is read on load and updated with `history.replaceState`. + +## Testing +- `packages/core`: unit tests for the history helpers against games played with the engine. +- Backend: unit tests for outcomes, timeouts and public ids with in-memory fakes; integration tests for the Postgres + repository and the Valkey store against real containers (`testcontainers`); the existing WebSocket tests keep + passing. +- End to end in Chrome: two guests play a full game; the home page shows it in recent games and as popular; replay + works; reviewing an earlier move during a live game; restarting the backend in the middle of a game. diff --git a/docs/specs/game-history/requirements.md b/docs/specs/game-history/requirements.md new file mode 100644 index 0000000..a05f3d8 --- /dev/null +++ b/docs/specs/game-history/requirements.md @@ -0,0 +1,92 @@ +# Game history and live games: requirements + +Status: implemented on branch `feature/game-history` (based on `feature/keycloak-auth`). +Scope: backend and web app. The mobile app is out of scope (see `TODO.md`). + +## Goals + +Every game is saved. Players can come back to a game in progress, see their past games, watch popular games live +from the home page, and step through the moves of any game, finished or in progress. + +## Terms + +- **Live game**: a game that is being played right now. +- **Finished game**: a game that has ended for any reason (see R3). +- **Spectator**: a user, other than the two players, who has the game page open. Home page previews do not count. +- **Popularity**: number of spectators; for finished games, the highest number of spectators the game had at once. + +## Requirements + +### R1. Every game is saved +- R1.1 When a game ends, it is stored permanently with its players (id, name, guest flag), result, end reason, + start and end time, every move (player, pit, board after the move, rule events, time of the move), final + board, peak spectator count and number of distinct spectators. +- R1.2 Games in progress survive a backend restart or deploy: after the backend starts again, players who reconnect + continue from the same position, with the same turn. +- R1.3 If the database or the cache is unreachable, games can still be played. A game that could not be saved + when it ended is saved later, once the database is reachable again. + +### R2. Ongoing game +- R2.1 A logged-in user (guest or account) who is a player in a live game sees, at the top of the home page, a + banner with the opponent's name, whose turn it is, and a **Continue** button that opens the game. +- R2.2 This works from any device or browser where the user is logged in with the same identity. +- R2.3 Starting a new game while a game is in progress is not possible: the user is told they have a game in + progress and is offered to continue it. + +### R3. How games end +- R3.1 **Completed**: the game ends by the rules; the player with more stones in their store wins, or it is a draw. +- R3.2 **Left**: a player who leaves the game loses; the other player wins. +- R3.3 **Timeout**: if the player whose turn it is has been offline for 5 minutes while the opponent is online, + the opponent wins. While the opponent is offline on their turn, the waiting player sees a countdown. + *Replaced by the game clock (`docs/specs/game-clock`): "timeout" now means the player to move ran out of time.* +- R3.4 **Inactive**: if both players have been offline for 30 minutes, the game ends without a winner. + *Replaced by the game clock, which keeps running while players are offline; saved games may still have this + reason.* + +### R4. Home page: live and popular games +- R4.1 The home page shows up to 5 live games, most spectated first, each as a preview of the real board with the + players' names and the spectator count. +- R4.2 Previews update live as moves are made, without reloading the page. +- R4.3 If no game is live, the same section shows the 5 most spectated finished games instead, titled as popular + games rather than live games. +- R4.4 Clicking a live game opens it in spectator mode; clicking a finished game opens its replay (R7). +- R4.5 Viewing previews does not change any game's spectator count. + +### R5. Home page: my recent games +- R5.1 A logged-in user sees their last 3 finished games in a separate section: opponent, result from their point + of view (won / lost / draw, and why if the game did not end by the rules), final score, date and a preview of the + final board. +- R5.2 Clicking a game opens its replay. + +### R6. Move history during a live game +- R6.1 Players and spectators of a live game see the list of moves made so far. Each entry shows the move number, + the player, the pit, and what happened: extra turn, capture, stones added to the player's store, game over. +- R6.2 New moves are appended as they happen. +- R6.3 Selecting a move (or using the arrow keys) shows the board as it was after that move. A banner says which + move is shown and offers **Back to live**. The game keeps going in the background. +- R6.4 While an earlier position is shown, the pits cannot be played. If it becomes the user's turn, the banner says + so. + +### R7. Replay of a finished game +- R7.1 Opening a finished game shows its final position, the result and a **Watch from start** button. +- R7.2 Controls: first move, previous move, play/pause, next move, last move, speed (1x, 2x, 4x), and a timeline to + jump to any move. Arrow keys step through moves, space plays or pauses. +- R7.3 Stepping forward animates the move the same way as in a live game; jumping (backwards, timeline, move list) + shows the position without animation. +- R7.4 The move list from R6.1 is shown and can be used to jump to a move. +- R7.5 A link can point to a specific move (`?move=12`). +- R7.6 A player who replays their own game sees the board from their side. +- R7.7 The game's duration is shown. + +### R8. Visibility and privacy +- R8.1 Finished games are public: anyone with the link can open them, and popular finished games appear on the + home page with the players' names. +- R8.2 "My recent games" and the ongoing-game banner are only shown to the user they belong to. +- R8.3 Ids of players who use the legacy userKey login (the current mobile app) are secrets. They are never exposed + in finished games, popular lists or live previews; a derived public id is shown instead. + +## Out of scope (TODO) +- Mobile app support. +- A page with all of a user's games (only the last 3 are shown). +- Showing deleted accounts differently. +- Live game previews and lists across several backend instances (the backend runs as a single instance). diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md new file mode 100644 index 0000000..501ba18 --- /dev/null +++ b/docs/specs/game-history/tasks.md @@ -0,0 +1,25 @@ +# Game history and live games: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Infrastructure**: `postgres` service with init script for the `keycloak` and `mancala` databases + (replacing `keycloak-db`), `valkey` service, backend `DATABASE_URL` / `VALKEY_URL`, `.env` variables, README. +- [x] 2. **Core types and history helpers**: `PlayerSummary`, `MoveRecord`, summaries, `GameRecord`, + `TURN_TIMEOUT_MS`, new channels, `offlineSince`; `getInitialBoard`, `getBoardAfterMove`, `describeMove`, + `createGameAtMove` with unit tests; localized strings. +- [x] 3. **Backend fixes**: remove spectators on disconnect, remove finished games from memory, send the connection + info once at game start. +- [x] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository` + (save, get, recent for user, popular), integration tests. +- [x] 5. **Valkey store**: live game snapshots and restore, pending saves, cache; integration tests. +- [x] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive), + timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup; + unit tests. +- [x] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`, + `/game/:id` for finished games, live games channel with throttled push; tests. +- [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop. +- [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby + rejection message. +- [x] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. +- [x] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. +- [x] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates. diff --git a/docs/specs/keycloak-auth/design.md b/docs/specs/keycloak-auth/design.md new file mode 100644 index 0000000..88b408c --- /dev/null +++ b/docs/specs/keycloak-auth/design.md @@ -0,0 +1,108 @@ +# Login with Keycloak: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. + +## Decisions + +| Decision | Reason | +|---|---| +| **Keycloak is the only auth system**; guests are Keycloak users too | Considered first: a separate auth service issuing guest tokens next to Keycloak, and guest tokens issued by the backend. Both meant two auth systems. With guests inside Keycloak, the backend trusts one token issuer (R8.1). | +| **Guests via a custom authenticator** (Java SPI extension), not the admin API | Creating guests through the admin API needs a privileged service account and a second component; an authenticator runs inside the normal login flow, so guests get ordinary sessions and refresh tokens. | +| The guest flow is triggered by a `guest=true` parameter on the authorization request | Keycloak keeps extra request parameters as client notes (`client_request_param_guest`), so the authenticator can read it without UI. Requests without it get the login form. | +| Guests are marked with a realm role, not a user attribute | Roles are in the access token by default (`realm_access.roles`) and need no user profile changes; the cleanup task can list role members. | +| Guests request `offline_access` | Offline sessions outlive the SSO session (30 days idle), which keeps a guest's identity across browser restarts (R1.2). | +| "Verify profile" is disabled for the realm | Otherwise Keycloak asks every new guest for an email and name. Registration still asks for the required fields. | +| The backend verifies tokens offline with the realm's public keys (`jose`) | No call to Keycloak per connection; Keycloak being down only stops new logins (R8.1). | +| The WebSocket authenticates with a first `auth` message, not a URL parameter | Tokens in URLs end up in proxy logs; browsers cannot set headers on WebSockets. | +| Keycloak user ids are prefixed with `kc:` | Legacy userKeys are UUIDs chosen by the client; the prefix makes it impossible for a legacy client to claim a Keycloak user's id (R7.2). | +| Web login with `oidc-client-ts` | Standard OIDC with PKCE and refresh tokens, without tying the app to a Keycloak-specific library. | +| Login, sign up and log out only on the home page | Changing identity during a game would lose the player's seat (R3.3). | + +## Keycloak (`apps/keycloak`) + +- **Image**: `Dockerfile` builds the extension with Maven and copies it with the realm file into + `quay.io/keycloak/keycloak:26.7.5`, started with `--import-realm` (the realm is imported only if it does not exist). +- **Realm** `mancala` (`realm/mancala-realm.json`): + - Client `mancala-web`: public, authorization code with PKCE S256, redirect URIs `http://localhost:1234/*` and + `https://playmancala.app/*`, the same post-logout redirects. + - Browser flow `browser with guest`: cookie (alternative) → `mancala-guest-authenticator` (alternative) → + username/password form (alternative). A returning user with a session is let through by the cookie step, so + `guest=true` with an existing session reuses that identity. + - Realm role `guest`; offline session idle timeout 30 days; registration and password reset on; brute force + protection on; required actions as Keycloak's defaults with `VERIFY_PROFILE` disabled. +- **Extension** (`extension/`, package `app.playmancala.keycloak.guest`): + - `GuestAuthenticator`: with `guest=true` creates `guest-` + 8 random hex characters, grants the `guest` role and + succeeds; otherwise `attempted()`. + - `GuestCleanupTask` (R1.3): scheduled through Keycloak's `TimerProvider`; deletes guests created more than + `cleanup-min-age-seconds` ago (default 86400) that have neither a user session nor an offline session; runs every + `cleanup-interval-seconds` (default 3600), at most `cleanup-max-deletes-per-run` (default 1000) per run. Options + are set as `KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__…`. +- **Compose**: `keycloak` service on `127.0.0.1:8080`, `KC_HOSTNAME` from `KEYCLOAK_HOSTNAME`, proxy headers on. +- **nginx** (`config/nginx.conf`): `auth.playmancala.app` proxies to Keycloak; the authorization endpoint is limited + to 10 requests per minute per IP with bursts of 20 (R1.4). + +## Backend (`apps/backend/src/auth`, `rtmt/rtmt_websocket.ts`) + +- `KeycloakTokenVerifier` checks signature (RS256, keys from `KEYCLOAK_JWKS_URL` or the issuer's certs endpoint, + cached by `jose`), `iss`, `exp`, `typ: Bearer` and that `azp` is one of `KEYCLOAK_CLIENT_IDS` (R2.2). It returns + `{ id: "kc:" + sub, name: preferred_username, isGuest: has role guest }`. +- WebSocket protocol (channels in `@mancala/core`): + + | Channel | Direction | Payload | + |---|---|---| + | `auth` | client → server | `{ token }`, right after connecting and after every token refresh | + | `on_auth` | server → client | `{ user: AuthUser }` | + | `on_auth_error` | server → client | `{ reason }`, then the socket is closed with code 4001 | + + Messages before `on_auth` are ignored; no `auth` within 5 seconds closes the socket (R2.4). A socket may send a + new token for the same user but not switch users. +- Connections: `Map>`; messages go to every socket of a user; the user is offline when the + set is empty; a closing socket only removes itself (R4). +- Legacy: with `LEGACY_USERKEY_AUTH=true`, `?userKey=` binds the socket to that id directly and `/register` + issues ids; non-UUID keys (e.g. `kc:…`) are refused (R7). +- Games store each player's name and guest flag when they start and send them in the connection info (R5.1). +- Malformed messages are ignored instead of throwing. + +| Variable | Default | +|---|---| +| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/mancala` | +| `KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | +| `KEYCLOAK_CLIENT_IDS` | `mancala-web` | +| `LEGACY_USERKEY_AUTH` | `true` | + +## Shared client code (`packages/core`) + +- `RTMTWS.connectWebSocket(url, getToken?)`: with a token provider it sends `auth` on every (re)connect and reports + `connected` (and emits `open`) only after `on_auth`; `reauthenticate(token)` sends a renewed token on the open + connection (R6.1); `user` holds the authenticated user. +- `getPlayerDisplayName(player, t)` formats names, including guests (R5.1). +- English and Turkish strings for the account menu. + +## Web app (`apps/frontend`) + +- `AuthService` wraps an `oidc-client-ts` `UserManager`: tokens in local storage, `automaticSilentRenew` with the + refresh token, `scope: "openid offline_access"`. + - `loginAsGuest()`: redirect with `guest=true`; `login()`: `prompt=login`, so the form appears even with a guest + session; `signUp()`: `prompt=create`; `logout()`: end-session redirect. + - The page the user started from is passed as `state` and restored after the callback (`/auth/callback`). +- Startup (`MancalaApp`): finish a login redirect if this is the callback, else load the stored session (renewing + an expired one), else redirect as a guest; then connect the WebSocket with `getAccessToken` as token provider. If + the session cannot be renewed, start over as a guest (R6.2). Renewed tokens are passed to `reauthenticate`. +- `AccountMenu` on the home page: guests see Log In and Sign Up, accounts see their name and Log Out (R3, R5.2). +- `KEYCLOAK_URL` is a build variable (default `http://localhost:8080`). + +## Mobile app + +Unchanged apart from calling `connectWebSocket(url)` without a token provider; it keeps the legacy userKey login. + +## Testing + +- Extension: integration tests start Keycloak with the extension and the real realm file in a container + (`testcontainers-keycloak`): guest login without a form and with the `guest` role and an offline refresh token, + a new user per guest login, the login form without `guest=true`, and the cleanup task. +- Backend: `KeycloakTokenVerifier` against locally signed tokens (valid, guest, wrong key, issuer, expiry, ID and + refresh tokens, other client, unsigned); WebSocket tests for the handshake, timeout, token refresh, several + sockets per user, late close after reconnect, legacy on and off, and malformed messages. +- End to end in Chrome against Keycloak, the backend and the dev server: first visit as guest, two guests matched + with names, reload keeping the identity, guest logging in to an account, logging out to a new guest, and token + renewal over an open connection with a 75-second token lifetime. diff --git a/docs/specs/keycloak-auth/requirements.md b/docs/specs/keycloak-auth/requirements.md new file mode 100644 index 0000000..9a70a36 --- /dev/null +++ b/docs/specs/keycloak-auth/requirements.md @@ -0,0 +1,71 @@ +# Login with Keycloak: requirements + +Status: implemented for the backend and the web app on branch `feature/keycloak-auth`; the mobile app still uses the +legacy userKey login (see `tasks.md`). Written after the implementation, from the decisions made while building it. + +## Goals + +People can play without signing up, and can sign up or log in when they want to. Every player has an identity that +cannot be taken over by someone else, and one system handles all of it. + +## Terms + +- **Guest**: a player who has not signed up. A guest is a Keycloak user with the `guest` role. +- **Account**: a Keycloak user who signed up with a username and password. +- **Legacy client**: an app version that identifies itself with a userKey from `GET /register` instead of a token + (the mobile app released before this work). + +## Requirements + +### R1. Playing without signing up +- R1.1 A first-time visitor of the web app is logged in as a new guest without seeing any form; the page briefly + passes through Keycloak and comes back to where the visitor started. +- R1.2 A returning guest keeps the same identity for 30 days after their last visit, also after closing the browser. +- R1.3 Guests who have not been back for 30 days, and are at least a day old, are deleted from Keycloak. +- R1.4 Starting guest logins is rate limited per IP address, since each one creates a user. + +### R2. Identities are secure +- R2.1 The server, not the client, decides who a connection belongs to, based on a token signed by Keycloak. +- R2.2 A token that is forged, expired, issued by another server, issued to another client, or is not an access + token is rejected. +- R2.3 Knowing another player's id (e.g. from a game) is not enough to act as that player. +- R2.4 A connection that does not authenticate within 5 seconds is closed; messages sent before authentication are + ignored. + +### R3. Accounts +- R3.1 A guest can sign up or log in from the home page; afterwards they play as that account. +- R3.2 A logged-in user can log out; they then continue as a new guest. +- R3.3 Logging in and signing up are only offered on the home page, so nobody changes identity during a game. + +### R4. Several devices +- R4.1 A user logged in on several devices or tabs is connected on all of them at once; all of them receive game + updates. +- R4.2 A user counts as offline only when their last connection closes. +- R4.3 Reconnecting does not drop the user's new connection when the old one closes late. + +### R5. Names +- R5.1 Players see each other's names during a game. Accounts show their username; guests show as "Guest" and four + characters of their generated username (e.g. "Guest 4F2A", "Misafir 4F2A" in Turkish). +- R5.2 The account menu on the home page shows the user's own name. + +### R6. Sessions stay valid +- R6.1 Access tokens are renewed before they expire without interrupting the game connection. +- R6.2 If the session can no longer be renewed, the user continues as a new guest. + +### R7. Old app versions keep working +- R7.1 While `LEGACY_USERKEY_AUTH` is on (the default), legacy clients connect with `?userKey=` and use `/register` + as before. +- R7.2 A legacy client cannot use the id of a Keycloak user. +- R7.3 When the setting is off, userKey connections and `/register` are refused. + +### R8. One auth system +- R8.1 Keycloak is the only place where users and sessions live; the backend only checks tokens and does not call + Keycloak per request. +- R8.2 Keycloak, its configuration and its extension are part of this repository and can be run locally with + Docker Compose. + +## Out of scope (see `TODO.md`) +- Login in the mobile app. +- Turning a guest into an account without changing identity. +- Social logins. +- Deleting accounts. diff --git a/docs/specs/keycloak-auth/tasks.md b/docs/specs/keycloak-auth/tasks.md new file mode 100644 index 0000000..e9674b3 --- /dev/null +++ b/docs/specs/keycloak-auth/tasks.md @@ -0,0 +1,17 @@ +# Login with Keycloak: tasks + +Tasks 1 to 4 were built before this spec was written; their commits are listed for reference. + +- [x] 1. **Keycloak**: guest authenticator and cleanup task, realm export, Docker image, compose service, nginx + proxy and rate limit, integration tests. (`7c99a76`) +- [x] 2. **Backend**: token verifier, WebSocket `auth` handshake, several sockets per user, player names in games, + `LEGACY_USERKEY_AUTH`, tests. (`30ad51c`) +- [x] 3. **Local packages**: apps and `@mancala/core` link the packages in this repo instead of the npm releases, so + the auth channels and types can be shared. (`e165b0a`) +- [x] 4. **Web app**: `AuthService` with `oidc-client-ts`, startup flow, account menu, token renewal over the open + connection, names on the game page; auth channels and types moved to core. (`f5dcf93`) +- [ ] 5. **Mobile app**: `mancala-mobile` client in the realm and in `KEYCLOAK_CLIENT_IDS`, `react-native-app-auth`, + guest login on first launch, token login instead of `?userKey=`, account screens, names; tested on a device. + Details in `TODO.md`. +- [ ] 6. **Legacy off**: once old app versions are no longer used, set `LEGACY_USERKEY_AUTH=false`, which also + removes `/register`. diff --git a/docs/specs/quarkus-backend/design.md b/docs/specs/quarkus-backend/design.md new file mode 100644 index 0000000..65e7dbe --- /dev/null +++ b/docs/specs/quarkus-backend/design.md @@ -0,0 +1,137 @@ +# Quarkus backend: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. The behaviour being ported is +described in `keycloak-auth`, `game-history` and `game-clock`; this document covers how the Java backend is built. + +## Decisions + +| Decision | Reason | +|---|---| +| Quarkus 3.40 (latest stable; 4.0 is in beta), Java 25, JVM mode | R1. Java 25 is the current LTS. Native images would need reflection config for the JSON models and buy little for one long-running process. | +| A port of the rules to Java (`app.playmancala.engine`) instead of running `mancala.js` in the JVM (GraalJS) | The engine is about 500 lines with no dependencies; a port is simpler to run, debug and test than an embedded JS runtime. The clients keep using the TypeScript engine for display. | +| Engine parity is tested against games recorded with the TypeScript engine | R2.2. A Node script plays seeded random games (including illegal moves) with `packages/mancala.js` and writes every move and the resulting game JSON to a fixture; a Java test replays them. Run the script again after changing the rules. | +| All game state is owned by one thread, the **game loop** | The Node backend relies on running on one thread: `GameManager`, the stores, matchmaking, live previews and the WebSocket registry have no locks. A single-threaded scheduled executor keeps that model, so the game logic carries over line by line without races. WebSocket events, timers and API reads that touch live games run on it. A few hundred games are far from what one thread handles. | +| Blocking I/O never runs on the game loop (except the restore at startup) | Postgres writes run on the recorder's own thread, Valkey writes on a store thread that keeps them in order (a game's `SET` must not overtake its `DEL`), and token checks on a worker. Results that change state are posted back to the loop. Snapshots are copied on the loop, so serialising them elsewhere is safe. | +| Plain JDBC with the existing SQL and the existing migration runner (`schema_migrations`, advisory lock) | R5.1. Flyway or Liquibase would have to be baselined on databases the Node backend already migrated. Two migrations and a 40-line runner do not justify it. Hibernate adds nothing for two tables written once per game. | +| `quarkus-redis-client` (blocking `RedisDataSource`) for Valkey | Valkey speaks the Redis protocol. Short connect and command timeouts make calls fail fast while Valkey is down, like `enableOfflineQueue: false` did. | +| `quarkus-websockets-next` endpoint at `/`, a thin adapter over a framework-free `RtmtServer` | R3.1. The endpoint only forwards open, text and close events to the game loop; `RtmtServer` holds the protocol (auth handshake, timeouts, sockets per user, ping) and is unit-tested with fake sockets and a manual clock. | +| jose4j for Keycloak tokens, not `quarkus-oidc` | Tokens arrive in a WebSocket message, not only in an HTTP header, so they are verified in code. jose4j (managed by the Quarkus BOM) fetches and caches the realm's JWKS like `jose` did. The checks are unchanged: RS256, issuer, `sub`, `exp`, `typ = Bearer`, `azp` in `KEYCLOAK_CLIENT_IDS`. | +| Explicit JSON models (records) with `@JsonInclude(NON_NULL)` only on optional fields | R3.2, R5. TypeScript leaves out `undefined` but keeps `null`; e.g. `result: null` and `{ game: null }` must stay while `name` and `leftUserId` are left out. | +| `DATABASE_URL` becomes `DATABASE_JDBC_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | R7.1. The JDBC driver does not take `postgres://user:pass@host/db`; parsing it ourselves is not worth it. Compose builds the new variables from the same `MANCALA_DB_PASSWORD`. | +| The project is built in `apps/backend-quarkus` and replaces `apps/backend` in the last task | Every commit on the branch has a working backend, and the two can be compared side by side; the final move keeps the path that the Dockerfile, compose and CI refer to. | +| `mvn quarkus:dev` debug port moved to 5006 | Quarkus dev mode listens for a debugger on 5005, the backend's port. | + +## Layout + +``` +apps/backend/ + pom.xml + Dockerfile multi-stage: Maven + Temurin 25 JDK, then Temurin 25 JRE + scripts/record-engine-games.js writes the engine parity fixture from packages/mancala.js + src/main/resources/ + application.properties + db/migrations/001_games.sql, 002_clock.sql + src/main/java/app/playmancala/ + engine/ Board, Pit, PitType, GameStep, MoveHistoryItem, MancalaGame, GameState, GameRule, + CommonMancalaGame and the four rules (port of packages/mancala.js) + model/ wire types: AuthUser, PlayerInfo, PlayerSummary, UserConnectionInfo, GameUsersConnectionInfo, + GameClock, Seat, GameResult, EndReason, GameEndMessage, MoveRecord, GameRecord, + FinishedGameSummary, LiveGameSummary, ClientGame, Channels; Json (the shared ObjectMapper) + loop/ GameLoop (the game thread), Scheduler (what the logic needs from it), Cancellable + auth/ TokenVerifier, KeycloakTokenVerifier (made from config by TokenVerifierProducer), InvalidTokenException + rtmt/ Rtmt (interface used by the game logic), RtmtServer, RtmtSocket, RtmtEndpoint (Quarkus) + game/ BackendGame, GameStore, MatchMaker, GameManager, GameClocks, GameClockScheduler, + GameSerialization (LiveGameSnapshot), GameRecorder, LiveGames, PublicIds, CrashLog + history/ GameHistoryRepository, PostgresGameHistoryRepository, Migrations, GameHistoryService + live/ LiveGameStore, ValkeyLiveGameStore, Cache, ValkeyCache + http/ GameResource (every route), BearerAuth, ErrorMapper (503 for failures) + Backend.java wiring and startup (a @Startup bean) +``` + +The game logic (`engine`, `game`, `history` services, `rtmt.RtmtServer`) is plain Java built with constructors, as +the TypeScript classes were, so unit tests build it with fakes. `Backend` creates it from CDI-managed infrastructure +(datasource, Redis client, config) and exposes what the HTTP resources and the WebSocket endpoint need. + +## Engine + +A line-by-line port. Classes keep their TypeScript names and methods, so a change to one engine is easy to repeat in +the other. The JSON of `MancalaGame` matches `JSON.stringify` of the TypeScript object, because clients rebuild it +with `MancalaGame.createFromMancalaGame`: + +```json +{ "id", "board": { "pits": [{ "index", "stoneCount" }], "playerPitCount", "initialStoneCountInPits" }, + "player1Id", "player2Id", "turnPlayerId", "state", "gameRules": [{}, {}, {}, {}], + "history": [{ "boardSnapshot", "gameSteps": [{ "index", "type", "data" }], "playerId", "moveIndex" }], + "currentHistoryItem" } +``` + +Errors keep their TypeScript messages (`IndexOutOfRange …`, `StoneNotFound …`, `Player cannot move reason : …`); +they are `IllegalStateException`s. + +## Threads + +``` +WebSocket event loop ──open/text/close──▶ game loop (1 thread) ──▶ GameManager, LiveGames, MatchMaker, RtmtServer + │ ▲ + token check (worker) ◀───────────┘ └── timers: clock check 1 s, ping 2 s, auth timeout, preview push +HTTP worker ──call(read live state)──▶ game loop, waits for the result; reads saved games itself +game loop ──snapshot──▶ store thread ──▶ Valkey (ordered) +game loop ──record──▶ recorder thread ──▶ Postgres (retry every 30 s), then cache invalidation +``` + +`Scheduler` is the part of the game loop the logic uses: `execute`, `schedule(delay)`, `scheduleAtFixedRate` and +`now()`. Tests use `ManualScheduler`, which runs tasks at once and timers when the test advances its clock, in +place of Jest's fake timers. Every task is wrapped so an exception is logged and never stops a repeating timer. + +`GameRecorder` and the live game store take an `Executor`; tests pass a direct one, so saves finish before the test +looks, in place of `flushPromises`. + +## WebSocket + +`RtmtEndpoint` (`@WebSocket(path = "/")`) handles `@OnOpen`, `@OnTextMessage` and `@OnClose` as `@NonBlocking` and +only posts them to the game loop, keeping the order of a socket's events. It wraps the connection as an +`RtmtSocket` (`id`, `query`, `send`, `close(code, reason)`, `isOpen`). Sends are asynchronous and never block the +loop. + +`RtmtServer` ports `rtmt_websocket.ts`: per socket an `alive` flag, the user id once authenticated and the auth +timeout; per user the set of sockets and the `AuthUser`. Message JSON is parsed on the loop. A token is checked on a +worker and the outcome handled on the loop, where a socket closed in the meantime is ignored. + +## HTTP + +Quarkus REST resources, blocking (run on worker threads). Reads of live games go through `GameLoop.call`, and the +client game is serialised on the loop because it refers to the live `MancalaGame`. `BearerAuth` checks the +`Authorization` header and answers 401. Any other exception becomes 503, as the Express error handler did. CORS and +the access log are Quarkus HTTP settings. + +## Startup + +`Backend` (`@Startup`) builds everything, then queues on the game loop, before any WebSocket event can be queued: +wait up to 5 s for Valkey, restore live games, start the clock check, the ping and the recorder's retries. +Migrations run on their own thread and are retried every 30 s until Postgres is reachable. A restore that takes long +(Postgres down: each check waits for the pool's 5 s timeout) delays socket events but loses none. + +## Configuration + +| Variable | Default | Quarkus property | +|---|---|---| +| `PORT` | `5005` (`6000` in the image) | `quarkus.http.port` | +| `KEYCLOAK_ISSUER`, `KEYCLOAK_JWKS_URL`, `KEYCLOAK_CLIENT_IDS`, `LEGACY_USERKEY_AUTH` | as before | `mancala.auth.*` | +| `GAME_CLOCK_MS` | `300000` | `mancala.game-clock-ms` | +| `VALKEY_URL` | `redis://localhost:6379` | `quarkus.redis.hosts` | +| `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5433/mancala` | `quarkus.datasource.jdbc.url` | +| `DATABASE_USERNAME`, `DATABASE_PASSWORD` | `mancala` | `quarkus.datasource.username`, `.password` | + +Tests use Quarkus Dev Services with the compose images (`postgres:17.6`, `valkey/valkey:9.1.2`); the unit tests need +no Docker. + +## Testing + +- Unit (no Docker): engine and parity fixture, game manager, live games, clock scheduler, token verifier (local key + pair and JWKS), `RtmtServer` with fake sockets. +- `@QuarkusTest` (Docker): Postgres repository and migrations, Valkey store and cache (including an unreachable + Valkey), HTTP API, and the WebSocket endpoint with a real client and a test token verifier. +- Manual, in Chrome with `playwright-core` against the local stack (R8.2): two players log in as guests and play a + game to the end while a third user spectates and the home page shows the live preview; a player reloads mid-game; + the backend is restarted mid-game and the game goes on with the clocks intact; the finished game appears in recent + games and replays; a short `GAME_CLOCK_MS` ends a game on time. diff --git a/docs/specs/quarkus-backend/requirements.md b/docs/specs/quarkus-backend/requirements.md new file mode 100644 index 0000000..49879e0 --- /dev/null +++ b/docs/specs/quarkus-backend/requirements.md @@ -0,0 +1,88 @@ +# Quarkus backend: requirements + +Status: implemented on branch `feature/quarkus-backend`; checked in Chrome against the local stack. +Scope: `apps/backend`, its Docker image, `apps/compose.yaml` and the docs. + +## Goals + +The backend is rewritten in Java with Quarkus on the latest Java LTS (Java 25). Players, the web app and the mobile +app notice nothing: the WebSocket protocol, the HTTP API and the stored data stay as they are, and the new backend +can replace the Node backend in a running deployment without ending live games. + +## Terms + +- **Node backend**: the Express + `ws` backend in `apps/backend` before this change. +- **Java backend**: its replacement. +- **Wire format**: the JSON sent over the WebSocket and the HTTP API, as the clients in `packages/core`, + `apps/frontend` and `apps/mobile` read it. + +## Requirements + +### R1. Platform +- R1.1 The backend is a Quarkus application (the latest stable 3.x release) compiled for and run on Java 25. +- R1.2 It is built with Maven; dependencies have exact versions (the Quarkus platform BOM pins the extensions). +- R1.3 It runs in JVM mode. A native image is out of scope. +- R1.4 It no longer depends on `packages/core` or `packages/mancala.js`; the game rules it needs are part of the + Java backend. + +### R2. Game rules +- R2.1 The Java backend plays games by exactly the rules of `packages/mancala.js`: the same moves are allowed, the + same errors are raised, and every move produces the same board, the same history entry (board snapshot and game + steps with their data) and the same next player. +- R2.2 A test replays games recorded with the TypeScript engine and compares every result, including the JSON a + client receives, so the two engines cannot drift apart unnoticed. The recording can be regenerated with a script. + +### R3. WebSocket protocol +- R3.1 Clients connect to the server's root path, as now (`ws://localhost:5005`, `wss://api.playmancala.app`). +- R3.2 Every channel in `packages/core/src/rtmt/channel_names.ts` behaves as in the Node backend: the same messages + are sent to the same users in the same order, with the same JSON. This covers authentication (`auth`, `on_auth`, + `on_auth_error`, close code 4001, the 5 second timeout, token refresh), matchmaking, moves, leaving, spectating, + connection changes, live game previews and the ping/pong keep-alive. +- R3.3 Legacy clients that connect with `?userKey=` work while `LEGACY_USERKEY_AUTH` is on. +- R3.4 A user can be connected from several sockets; the user is offline when the last one closes. + +### R4. HTTP API +- R4.1 `GET /`, `GET /register/` (only with legacy auth), `GET /game/:id`, `GET /games/popular`, + `GET /games/me/active` and `GET /games/me` answer with the same status codes and JSON as in the Node backend. +- R4.2 CORS allows every origin, as now. +- R4.3 Requests are written to `access.log` in the common log format, as now. + +### R5. Data +- R5.1 The Java backend uses the existing Postgres schema and the `schema_migrations` table: on a database the Node + backend migrated, it applies nothing; on an empty one it creates the same tables. +- R5.2 It restores live games that the Node backend saved in Valkey (snapshot versions 1 and 2), and writes snapshots + the Node backend can restore, so a rollback also keeps live games. +- R5.3 It saves finished games that the Node backend left pending in Valkey, and pending records it leaves can be + saved by the Node backend. +- R5.4 Saved games and cached lists have the same JSON as before; a game saved by either backend reads the same. + +### R6. Behaviour when a dependency is down +Unchanged from `game-history` and `game-clock`: +- R6.1 Games are played while Postgres or Valkey is down. Finished games are kept and saved once Postgres is back; + lists are served without the missing data. +- R6.2 The backend starts while Postgres is down (migrations are retried) and while Valkey is down (live games are + then not restored). +- R6.3 Live games survive a restart; the time the backend is down is not charged to the clock. + +### R7. Configuration and deployment +- R7.1 The settings stay environment variables with the same names and defaults (`PORT`, `KEYCLOAK_*`, + `LEGACY_USERKEY_AUTH`, `VALKEY_URL`, `GAME_CLOCK_MS`), except the database: `DATABASE_URL` is replaced by + `DATABASE_JDBC_URL`, `DATABASE_USERNAME` and `DATABASE_PASSWORD`. `apps/.env` needs no change. +- R7.2 The Docker image is still built from the repo root with `docker build -f apps/backend/Dockerfile .`, listens + on port 6000 in the container and runs under `apps/compose.yaml` as before. +- R7.3 `mvn quarkus:dev` runs the backend locally on port 5005 against the compose services, like `yarn dev` did. + +### R8. Tests and docs +- R8.1 The Node backend's tests are ported: game manager, live games, clock scheduler, token verifier, WebSocket + server, HTTP API, Postgres repository and Valkey store and cache. Tests that need Postgres or Valkey run them in + Docker, as now. +- R8.2 The web app is played against the Java backend in Chrome: login, a full game between two users, a spectator, + live previews, reconnecting, the game clock, restart with a live game, and the replay of the saved game. +- R8.3 `README.md`, `apps/backend/README.md`, `AGENTS.md` and `TODO.md` describe the Java backend. + +## Out of scope + +- Changes to the protocol, the API or the stored data, including fixing their known quirks (e.g. `on_game_crashed` + sending `{}`). +- Running more than one backend instance. +- Publishing the Java engine as a library, or replacing the TypeScript engine in the clients. diff --git a/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md new file mode 100644 index 0000000..1994bf3 --- /dev/null +++ b/docs/specs/quarkus-backend/tasks.md @@ -0,0 +1,20 @@ +# Quarkus backend: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [x] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port + with unit tests; `scripts/record-engine-games.js` and the parity test (R2). +- [x] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, + `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. +- [x] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, + `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported + unit tests. +- [x] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, + `ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services. +- [x] 6. **WebSocket and HTTP**: `RtmtServer` with unit tests, `RtmtEndpoint`, the HTTP resources, `Backend` + wiring and startup, configuration; `@QuarkusTest`s for the API and the WebSocket. +- [x] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose, + remove the Node sources; build the image. +- [x] 8. **End to end and docs**: the Chrome tests from `design.md` against the local stack; `README.md`, + `apps/backend/README.md`, `AGENTS.md`, `TODO.md`. diff --git a/docs/specs/rematch/design.md b/docs/specs/rematch/design.md new file mode 100644 index 0000000..e1dd0df --- /dev/null +++ b/docs/specs/rematch/design.md @@ -0,0 +1,143 @@ +# Rematch and series: design + +See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` (the end of a game), +`game-history` (saved games, replays), `game-bot` ("Play again") and `web-redesign` (the game over card, whose design +already has a rematch button). + +## Decisions + +| Decision | Reason | +|---|---| +| The server owns series and rematch requests; clients only ask, answer and show | Both players must see the same series, and the server already decides who moves first (R2). | +| A series is carried by its live game (`BackendGame.series`): its id and the results of the games before | No new store to keep in step: the series lives and is snapshotted with the game, so it survives restarts (R5.2), and is dropped with it. | +| Rematch requests are kept in memory only, keyed by the finished game's id | They last at most 10 minutes; losing them in a restart costs a click (R5.2). Valkey would add writes for little gain. | +| Series entries name seats (`player1`/`player2`), not user ids | The strip needs no ids, and nothing new can leak a legacy user's secret id (`PublicIds`). Seats stay the same through a series (R2.3). | +| A rematch starts with the usual `on_game_start`; the game page follows it to the new game | Clients already handle a game start; a new route per game keeps reloads, links and replays as they are. | +| "Play again" against the bot becomes a rematch the bot accepts at once | One flow for both kinds of games; bot series get the strip and alternating first moves (R4). | +| Every new game gets a series id, the first game too; rematches keep it | A game alone is a series of one, so grouping later needs no special case (R5.1). Games saved before have none. | +| No fixed series length | The user chose open series; "best of N" can be added on top (out of scope). | + +## Backend + +### Model + +```java +// One finished game of a series, as the strip shows it. +record SeriesGame(String gameId, Seat firstSeat, GameResult result, EndReason endReason, + int player1Store, int player2Store) {} + +// The series a live game belongs to: its id and the games played before this one, oldest first. +record Series(String id, List games) { + Series next(SeriesGame finished); // with `finished` appended +} +``` + +- `BackendGame` gains `series` (final; never null) and `firstSeat()`, the seat that moved first: the seat of the first + move's player, or of the player to move before any move. Derived rather than stored, so restored games and tests that + set the player to move need nothing more. +- `ClientGame` gains `series` (`@JsonInclude(NON_NULL)`, the games before this one) and `firstSeat`. +- `GameEndMessage` gains `series` (`NON_NULL`) with the ended game appended, and `rematch` (`NON_NULL`): `"available"` + when a rematch can be asked for, left out after a game a player left (R1.1). +- `LiveGameSnapshot` gains `series`, optional: snapshots without it restore as a new series of one game. +- `GameRecord` gains `seriesId` (`NON_NULL`); migration `004_series` adds `games.series_id uuid` (NULL for older + games) and an index on it. + +### Who moves first (R2) + +`createMancalaGame(userId1, userId2, bot, series, firstSeat)`: `firstSeat` null draws at random (R2.1), as now. A +rematch passes the seat opposite to the previous game's first seat (R2.2) and the same user order, so the seats stay +(R2.3). + +### Rematch requests (`RematchOffers`, on the game loop) + +`RematchOffers` is built next to `GameManager`, like `BotPlayer`, and hooks into it: `onGameEnded` (every ending +except a crash, with the end message) and `onPlayerSearching` (`new_game` and `new_bot_game`, before they are handled). +It listens to its own channels and to connection changes, and starts the next game with `createMancalaGame` and +`startGame`. When a game ends, it opens an entry unless a player left (R1.1). An entry keeps what the next game needs: the players in seat order, the bot settings' level, the +series with the ended game appended and the next first seat; plus who has asked, and two timers. + +``` +on_game_end ─► entry (open, 10 min) ── 10 min ──► gone (R1.7) + rematch from A ─► A asked; send on_rematch_request to B; 60 s timer + ├─ rematch from B ─► start the next game; entry gone (R1.4) + ├─ decline_rematch from B ─► on_rematch_ended(declined); entry gone (R1.6) + ├─ decline_rematch from A ─► on_rematch_ended(withdrawn); entry stays open + ├─ 60 s ─► on_rematch_ended(expired); entry stays open + ├─ A or B offline ─► on_rematch_ended(offline); entry stays open + └─ A or B sends new_game / new_bot_game ─► on_rematch_ended(left); entry gone (R1.6) +``` + +- `on_rematch_ended` goes to both players: each client knows whether it asked and words the reason from that. +- A request to an opponent who is offline ends at once with `offline` (R1.5). +- `rematch` for a game without an entry (over 10 minutes, or the series ended) answers `on_rematch_ended(closed)`. +- Against the bot, `rematch` starts the next game at once (R4.1, R4.2); the bot takes a new strength as in + `onNewBotGame`. +- The players' user ids map to the entry (`entryByUser`), so `new_game` and going offline find it; a player is in at + most one entry, the one of their last game. +- Before starting the next game, the manager checks that neither player has a game in progress (they cannot, since + matchmaking removes the entry, but a restored or crashed state should not give a player two games). + +### Channels (`Channels`, `channel_names.ts`) + +| Channel | Direction | Message | +|---|---|---| +| `rematch` | client → server | `{ gameId }`: ask for a rematch of that ended game, or accept the opponent's request | +| `decline_rematch` | client → server | `{ gameId }`: say no, or withdraw one's own request | +| `on_rematch_request` | server → client | `{ gameId }`: the opponent asked; the client knows who that is | +| `on_rematch_ended` | server → client | `{ gameId, reason }`, reason one of `declined`, `withdrawn`, `expired`, `offline`, `left`, `closed` (10 minutes, or a new game) | + +The next game reaches both players as `on_game_start`, like a game from the lobby. + +## Core (`packages/core`) + +- Channel names; types `Series`, `SeriesGame`, `RematchRequest`, `RematchEnded`; `Game.series`, `Game.firstSeat`, + `GameEndMessage.series` and `rematch`. +- `getSeriesScore(series, seat)`: wins of the seat and of the other seat, draws apart. +- Texts: `Rematch`, `RematchWaiting` ("Waiting for {{name}}…"), `RematchRequested` ("{{name}} wants a rematch"), + `RematchDeclined`, `RematchExpired`, `RematchOpponentGone`, `No`, `Series`, `SeriesScore`, `MovedFirst`, + `GamesNotShown` ("+{{count}}"), in `en.ts` and `tr.ts`. + +## Web app + +- **Following a rematch**: `GamePage` listens to `on_game_start` while a game of the user is over and navigates to + `/game/`. The route renders `GamePage` with `key={gameId}`, so the page starts afresh for the new game (its + load effect runs once per mount). +- **Game over card** (`GameOverCard`) gets a `rematch` state from the page: + - `available`: "Rematch" (primary), "New Game", "Home". + - `waiting`: "Waiting for Ahmet…" with "Cancel" (`decline_rematch`). + - `requested`: "Ahmet wants a rematch" with "Rematch" (primary) and "No". A request reopens a closed card (R1.3). + - `ended`: the reason as a short line, then "New Game" (and "Rematch" again unless the series is over, R1.6). + - Bot games: "Play again" sends `rematch` (R4.1); the lobby's `?bot=` stays for the home page and the lobby offer. + - The final score as two columns, the bottom player's first: the name (small, muted) above the store count + (large), the winner's count in their colour (R3.8). + - The series score line once the series has more than one game (R3.6). +- **Series strip** (`SeriesStrip`, R3): the first row of the game grid, above the players and the board, shown from + the second game of a series. The + score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the + top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a + "time" or "left" after the game's number. The current game's box is outlined and says "now". Finished boxes are links to + `/game/` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below + 600 px the strip scrolls sideways inside itself, never the page. +- Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons. + +## Mobile app + +Unchanged (R6). New fields in `on_game_start`, `on_game_update` and `on_game_end` are ignored by the mobile app; it +has no listeners for the new channels. + +## Testing + +- Backend unit tests (fakes, `ManualScheduler`): both ask → a game with the same seats, the other first seat and the + series appended; the third game alternates again; no rematch after a player left; decline, withdraw, 60 s expiry, + offline, `new_game` and `new_bot_game` each end the request with the right reason and to the right player; asking + again after withdraw and expiry; the 10 minute limit; the bot accepts at once at the same level; a player in a new + game cannot be pulled into a rematch. +- Serialisation: `series` and `firstSeat` round-trip through snapshots; old snapshots without them restore; the JSON of + `ClientGame` and `GameEndMessage` without a series is unchanged. +- Storage (`@QuarkusTest`): `series_id` saved and read; migration on a database with games from before. +- Core: `getSeriesScore`, text keys in both languages. +- Web: build and the temporary type check. +- End to end in Chrome, two browsers: a game ends, A asks, B sees the request and accepts, both land on the new game + with the other player moving first and the strip showing game 1; three games in a row alternate; B declines and A + is told; A asks and B does not answer within 60 s; a player who left gets no rematch; "Play again" against the bot + continues the series; a strip box opens the replay in a new tab; the strip at phone width. diff --git a/docs/specs/rematch/requirements.md b/docs/specs/rematch/requirements.md new file mode 100644 index 0000000..24f6575 --- /dev/null +++ b/docs/specs/rematch/requirements.md @@ -0,0 +1,77 @@ +# Rematch and series: requirements + +Status: implemented on branch `feature/rematch` (based on `develop`); checked in Chrome against the local stack. +Scope: backend, `packages/core`, web app. The mobile app comes later (see R6). + +## Goals + +Two players who enjoyed a game can play again at once, without going back to the lobby, and see how their games +went: one box per game and the score of the series. Who moves first alternates, so neither player keeps the +advantage of the first move. + +## Terms + +- **Rematch**: a new game between the same two players, started from the game over card when both ask for it. +- **Series**: a game and the rematches that follow it, one after another. A series has no fixed length; it ends when + a rematch is not played. +- **Series score**: games won by each player in the series; draws count for neither. +- **Rematch request**: one player's "Rematch" waiting for the other's answer. + +## Requirements + +### R1. Asking for a rematch +- R1.1 When a game between two people ends, the game over card offers "Rematch" (Turkish: "Rövanş") to both + players, unless a player left the game: then the series is over and the card offers "New Game" as now. +- R1.2 The player who asks sees that they are waiting for the opponent and can withdraw the request. +- R1.3 The opponent sees the request on their game over card ("Ahmet wants a rematch") with "Rematch" and "No", + also when they had closed the card. +- R1.4 When both have asked, the new game starts for both at once, and their game pages show it. +- R1.5 A request ends without a game when: the opponent says no, the asking player withdraws, either player starts + looking for another game or starts a bot game, either player goes offline, or the opponent does not answer within + 60 seconds. The asking player is told why ("Ahmet declined", "Ahmet is no longer here"). +- R1.6 "No", or either player starting another game or a search, ends the series: the card offers only "New Game". + After a withdrawn or unanswered request, either player can ask again. +- R1.7 A rematch is possible for 10 minutes after the game ended; after that the card only offers "New Game". + +### R2. Who moves first +- R2.1 The first game of a series starts with a player chosen at random, as now. +- R2.2 In every rematch, the player who did not move first in the previous game of the series moves first. +- R2.3 The players keep their sides of the board in a rematch. + +### R3. Series score +- R3.1 From the second game of a series on, the game page shows the series above the board: the series score + ("You 2 – 1 Ahmet") and one box per game, in order. +- R3.2 A finished game's box shows who won (in that player's colour, a draw neutral), the stones in both stores, a + mark for who moved first, and whether it ended on time or because someone left. +- R3.3 The box of the game being played is marked as the current game. +- R3.4 A finished game's box opens its replay in a new tab, so the live game is not left. +- R3.5 With more games than fit, the strip shows the latest games and how many earlier ones are not shown; the + score always counts all games. +- R3.6 The game over card shows the series score once the series has more than one game. +- R3.7 Spectators see the series strip too, with the players' names instead of "You". +- R3.8 The game over card shows the final score as two columns, each player's name above their stones, so names + and numbers do not run together ("Guest 6894 18 – 30 Bot · Hard" was hard to read). + +### R4. Games against the bot +- R4.1 "Play again" after a bot game is a rematch: it starts at once, at the same level (the bot takes a new strength + as in any new game), and continues the series, with the series strip and alternating first moves. +- R4.2 The bot never declines a rematch. + +### R5. Saving and restarts +- R5.1 Every saved game records which series it belongs to, so that the history can group series later. +- R5.2 A live game keeps its series across a backend restart (the strip shows the same games). Rematch requests + waiting at the time of the restart are dropped; the players can ask again. + +### R6. Mobile app +- R6.1 The mobile app gets rematch and the series strip with its new design (`TODO.md`, Web redesign). Until then it + is unchanged: its players never see a rematch request, so a request from a web player to a mobile player ends + after 60 seconds (R1.5). + +### R7. Languages +- R7.1 All texts exist in English and Turkish. + +## Out of scope (TODO) +- Series of a fixed length ("best of 3") and declaring a series winner. +- Grouping series in the game history and on the home page. +- Taking spectators along to the next game of a series. +- Rematch and the series strip in the mobile app (R6). diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md new file mode 100644 index 0000000..ae5af7f --- /dev/null +++ b/docs/specs/rematch/tasks.md @@ -0,0 +1,18 @@ +# Rematch and series: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [x] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in + `createMancalaGame`; `series` and `firstSeat` in `ClientGame` and `GameEndMessage`, `series` in snapshots (with + the restore of old snapshots); unit and serialisation tests. +- [x] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on + `new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests. +- [x] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests. +- [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests. +- [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch + states, "Play again" as a rematch, the score in two columns. +- [x] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators. +- [x] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes), + `TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note + on the rematch button. diff --git a/docs/specs/tutorial/design.md b/docs/specs/tutorial/design.md new file mode 100644 index 0000000..c075457 --- /dev/null +++ b/docs/specs/tutorial/design.md @@ -0,0 +1,153 @@ +# Tutorial: design + +See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `web-redesign` (board, pages, +themes) and `game-bot` (the Easy bot for the practice game). + +## Decisions + +| Decision | Reason | +|---|---| +| Rules, lessons and hints are defined once in `packages/core` | The web and mobile apps show the same rules (R5.1) and the texts stay in one place. | +| The "after" board of every example and the outcome of every lesson move come from running the engine (`mancala.js`), never written by hand | The texts and pictures cannot drift from what the game does (R1.3). A rule change shows up in the tutorial at once, and a test fails if a lesson no longer works. | +| Lessons run entirely in the client on the TypeScript engine | No server, no login, no waiting (R2.5). The engine is already in every client. | +| A lesson's task is an outcome ("end in your store"), not a pit | Players who find another move with the same effect are right too; the check is the engine's game steps, the same ones the move list uses. | +| The practice game is an ordinary Easy bot game with hints on; hints are a client feature of any bot game | No new server state (R3.1). Hints only show the immediate effect of a move, which the board's preview mostly shows already, so they give little away; still, they are limited to bot games (R3.5). | +| Hints look one move ahead only | They explain the rules, not strategy. Looking further would need the bot's search on the client or a new server message (out of scope). | +| Progress, "not now" and the hints setting are kept in the device's storage | Enough for learning the rules (R2.6, R3.4, R4.1); accounts are not needed for any of it. | +| Mobile gets the rules screen only for now | The mobile app still has the old board and game screen; lessons and hints there would be built twice (R5.2). | +| The end-of-game lesson teaches the current rule | `TODO.md` (Game rules) asks whether the remaining stones should always go to the player whose side is empty. The tutorial follows the engine; because its boards come from the engine, a rule change only needs the texts updated. | + +## The rules as taught (R1.3) + +Checked against `packages/mancala.js` (`Board.move`, the four `GR…` rules): + +1. **Board and goal**: two rows of 6 pits with 4 stones each, a store on each side. Stones in your store are yours; + at the end, more stones in your store wins (48 stones: 25 always wins). A draw is possible. +2. **Sowing**: take all stones from one of your pits; put the first back into the same pit and one into each pit + after it. A single stone moves to the next pit. Your own store gets a stone on the way, the opponent's is + skipped. +3. **Extra turn**: if the last stone lands in your store, you play again. +4. **Even capture**: if the last stone lands in an opponent's pit and makes its count even, you take all stones in + it. +5. **Empty pit capture**: if the last stone lands in an empty pit of yours and the opponent's pit opposite has + stones, you take both. +6. **End of the game**: the game ends when one side's pits are empty; the player whose side is empty gets the + stones left on the other side. Then the stores are counted. +7. **Clock**: 5 minutes per player for the whole game; the clock of the player to move runs; when it reaches zero, + that player loses. + +## Shared code (`packages/core/src/tutorial/`) + +```ts +type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock"; + +interface RuleExample { + board: number[]; // 14 stone counts, player 1 (bottom) to move + pit: number; // board index played +} + +type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame"; + +interface Lesson { + board: number[]; // player 1 (bottom) to move + task: LessonTask; + taskKey: string; // e.g. "LessonTaskExtraTurn": "Find the pit whose last stone lands in your store" +} + +interface Rule { + id: RuleId; + titleKey: string; + textKeys: string[]; // at most three sentences (R1.2) + example?: RuleExample; + lesson?: Lesson; // every rule except the clock (R2.1) +} + +export const RULES: Rule[]; + +// The immediate effect of playing a pit, by running the engine on a copy of the board. +interface MoveOutcome { + path: number[]; // where the stones went (getSowingPath) + extraTurn: boolean; + capture?: "even" | "emptyPit"; + captured: number; // stones taken from the opponent's side + storeGain: number; + endsGame: boolean; + board: number[]; // after the move +} +export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome; // the mover follows from the pit +export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean; + +// "Why did that happen" for a wrong lesson move and for the practice game (R2.3, R3.3). +export function explainMove(outcome: MoveOutcome, mover: "you" | "bot", t): string; +``` + +`getMoveOutcome` sets the stones on a `CommonMancalaGame`, plays the pit and reads the game steps, as `describeMove` +does for the move list. None of the six lessons needs the opponent to move, so lessons have no scripted replies +(R2.4 has nothing to do yet). `describeMove` gains `capture` kind and `captured`, so the +move list, hints and explanations share one reading of the steps. + +Lesson positions (player 1, bottom, to move; pits 0–5, store 6), checked with the rules above. Task 2 tests that +each has a move that fulfils its task and, where there is more than one legal move, one that does not: + +| Lesson | Board | Task | Moves that fulfil it | +|---|---|---|---| +| Board and goal | start position | `anyMove` | all | +| Sowing | `[4,4,1,4,4,4, 1, 4,4,4,4,4,4, 2]` | `anyMove`; the text points at pit 3's single stone, which moves to pit 4 | all | +| Extra turn | start position | `extraTurn` | pit 4 | +| Even capture | `[4,4,4,4,4,4, 0, 4,3,4,4,4,4, 1]` | `evenCapture` | pit 6: its last stone makes the opponent's 3 a 4 | +| Empty pit capture | `[2,0,4,4,4,4, 3, 4,4,4,4,5,4, 2]` | `emptyPitCapture` | pit 1: its last stone lands in the empty pit 2, opposite 5 stones | +| End of the game | `[0,0,0,0,0,1, 20, 2,3,1,0,2,1, 18]` | `endGame` | pit 6 (the only move): your side is empty and the 9 stones opposite are yours, 30–18 | + +Texts: `RuleTitle`, `RuleText1..3`, `LessonTask…`, `MoveExplain…`, `HowToPlay`, `Learn`, `Hints`, +`NewToMangala`, `StartTutorial`, `NotNow`, `PracticeGame`, in `en.ts` and `tr.ts`. + +Storage helpers next to the rules: `getCompletedLessons` / `completeLesson`, `isTutorialBannerHidden` / +`hideTutorialBanner`, `getHintsEnabled` / `setHintsEnabled`, `markGamePlayed`. + +## Web app + +Routes: +- `/rules`: `RulesPage` with `RulesView`. +- `/learn`: the lesson list with progress, then the practice game button. +- `/learn/:ruleId`: `LessonPage`. + +Components: +- `RulesView`: the rules in order; each with its texts, a `RuleBoard` and "Try it" linking to the lesson (R1.5). +- `RuleBoard`: two `MiniBoard`s, before and after the example move, the played pit and the landing pit marked. +- `RulesPanel`: `RulesView` in a side panel (full screen below 600 px) over the game page; opened from a "How to + play" button in the game page's header; the game keeps running behind it (R1.4). +- `AppHeader`: a help icon button "How to play" on every page, going to `/rules` (or opening `RulesPanel` on the game + page). +- `LessonPage`: the rule's text and task above a `BoardView` on a local game (`createGameFromBoard`), animated with + `PitAnimator` like a live game. On a move: `getMoveOutcome`, animate, then praise and "Next lesson" or + `explainMove` and "Try again", which resets the board. +- `TutorialBanner` on the home page for newcomers (R4). +- Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map + (pit → "Extra turn" / "Takes 5"), drawn as a chip above the pit; pits whose move does neither get no chip. Below + the board a line with `explainMove` of the last move. A "Hints" switch next to the turn indicator, only in bot + games; hidden while looking back at earlier moves. +- Practice game: the lesson list's last button sets hints on and goes to `/loby?bot=easy`. + +## Mobile app + +`RulesScreen`: the rules' texts and a simple stone-count drawing of each example (before/after), reachable from +`HelpScreen` and from the game screen's header (R5.1). + +## Testing +- Core: every lesson position fulfils its task with some move and (except `anyMove`) fails it with another; + `getMoveOutcome` against the engine on random positions; every rule example produces the effect its rule is about + (the even capture example really captures, etc.); `explainMove` for each kind of move in both languages; every + text key exists in `en` and `tr`. +- Web: build and the temporary type check. +- End to end in Chrome: a newcomer sees the banner and "Not now" hides it; all six lessons, each first with a wrong + move (explanation, reset) and then a right one; the list shows them done; the rules page and the rules panel during + a game (the clock goes on); a practice game shows hints and explanations, and a game between two people shows + none. +- Mobile: type check and bundle; the rules screen on a device when one is available. + +Checked end to end in Chrome on 2026-10-16: a newcomer's banner, "Not now" hiding it, and the banner gone after the +lessons; all six lessons from the banner, the four with a wrong move first (explained, then reset) and each done; +the list showing all six done and the last lesson offering the practice game; the rules page and, during a game, +the rules panel with the clock running behind it (5:00 → 4:57); a practice game with hint chips that match the board +and an explanation of each move, hints turned off with the switch; a game between two people with hints turned on +on both devices showing no switch and no hints; the rules page and the lesson list at phone width. diff --git a/docs/specs/tutorial/requirements.md b/docs/specs/tutorial/requirements.md new file mode 100644 index 0000000..db35ea0 --- /dev/null +++ b/docs/specs/tutorial/requirements.md @@ -0,0 +1,83 @@ +# Tutorial: requirements + +Status: implemented on branch `feature/tutorial` (based on `develop`). +Scope: `packages/core`, web app; the mobile app gets the rules only (see R5). + +## Goals + +Someone who has never played Mangala can learn it in a few minutes, and anyone can look a rule up later, also in +the middle of a game. Three parts, from reading to playing: + +1. **Rules**: one page with every rule, a short text and a small board for each. Always reachable. +2. **Lessons**: one short interactive step per rule; the player makes the move the rule is about on a prepared + board and sees what happens. Runs in the browser, without the server. +3. **Practice game**: a real game against the Easy bot with hints: which moves give an extra turn or capture, and + after every move a sentence about what happened. + +## Terms + +- **Store**: the large pit on each side (in the texts "hazine"); stones in it count for the player. +- **Lesson**: one rule taught on a prepared position with a task, e.g. "End your move in your store". +- **Hint**: in the practice game, the effect a move would have right away (extra turn, capture, stones to store). + Hints never look further ahead than the move itself. +- **Newcomer**: someone on a device where no game has been played and the tutorial has not been opened or + dismissed. + +## Requirements + +### R1. Rules +- R1.1 A rules page ("How to play" / "Nasıl oynanır") lists, in order: the board and the goal, sowing, the extra + turn, capturing an even pit, capturing from an empty pit, the end of the game and who wins, and the clock. +- R1.2 Each rule has at most three sentences and a small board that shows it (before and after the move). +- R1.3 The texts describe exactly what the game does (`packages/mancala.js` and the backend's port), including the + details beginners miss: the first stone goes back into the pit it was taken from, a single stone moves to the next + pit, the opponent's store is skipped. +- R1.4 The rules can be reached from the header on every page, and from the game page without leaving the game + (a panel over the game; the game and its clock go on). +- R1.5 Each rule links to its lesson. + +### R2. Lessons +- R2.1 There is a lesson for each rule in R1.1 except the clock: six in all, in the order of R1.1. +- R2.2 A lesson shows the rule's text, a board with a prepared position and a task. The player plays on the real + board (same look, animation and move preview as a game). +- R2.3 A move that fulfils the task is praised and the next lesson is offered. Any other move is animated, then + explained ("This move ended in the opponent's pit, so the turn passed") and the position is set back so they can + try again. +- R2.4 Where a lesson needs the opponent to move, the opponent's move is scripted and shown like the bot's. +- R2.5 Lessons work without a connection to the server and need no login. +- R2.6 Completed lessons are remembered on the device and marked in the list. Lessons can be taken in any order + and repeated. +- R2.7 After the last lesson, the player is offered the practice game. + +### R3. Practice game +- R3.1 The practice game is a game against the Easy bot with hints turned on, started from the tutorial. It is + otherwise a normal bot game (clock, saving, history). +- R3.2 While it is the player's turn, every pit whose move gives an extra turn or captures is marked, with what it + does ("Extra turn", "Captures 5"). +- R3.3 After every move, of either side, one sentence says what happened, built from the move: e.g. "The bot's + last stone landed in its store: it plays again" or "You captured 6 stones from an even pit". +- R3.4 Hints can be turned on and off in every game against the bot; the choice is remembered on the device. +- R3.5 Hints are only available in games against the bot. Games between people never show them, also not to + spectators. + +### R4. Newcomers +- R4.1 A newcomer sees a banner on the home page: "New to Mangala? Learn it in 3 minutes" with "Start" and + "Not now". "Not now" hides it for good on that device. +- R4.2 The banner does not show once the tutorial has been opened or a game has been played on the device. +- R4.3 Nothing is forced: a newcomer can start a game without the tutorial. + +### R5. Mobile app +- R5.1 The mobile app shows the same rules (same texts, from `packages/core`) on a rules screen reachable from the + help screen and from the game screen's header. +- R5.2 Lessons and the practice game's hints on mobile are out of scope until the mobile app gets the new design + (`TODO.md`, Web redesign). + +### R6. Languages +- R6.1 All texts exist in English and Turkish. Turkish uses the usual Mangala words (kuyu, hazine, taş). + +## Out of scope (TODO) +- Lessons and hints in the mobile app (R5.2). +- Hints that look ahead (warning about a move that gives the opponent a capture); the bot's search could provide + them later. +- Strategy lessons beyond the rules. +- Keeping tutorial progress per account instead of per device. diff --git a/docs/specs/tutorial/tasks.md b/docs/specs/tutorial/tasks.md new file mode 100644 index 0000000..9a5f06a --- /dev/null +++ b/docs/specs/tutorial/tasks.md @@ -0,0 +1,16 @@ +# Tutorial: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [x] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and + the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests. +- [x] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the + game page. +- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and + reset, scripted opponent replies. +- [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button. +- [x] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`. +- [x] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle. +- [x] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope + items). diff --git a/docs/specs/web-redesign/design.md b/docs/specs/web-redesign/design.md new file mode 100644 index 0000000..d4cf400 --- /dev/null +++ b/docs/specs/web-redesign/design.md @@ -0,0 +1,42 @@ +# Web redesign: design + +## Themes + +The design's themes are CSS custom properties (`--bg`, `--surface`, `--ink`, `--accent`, board and pit shadows, …) +under a class per theme (`t-ceviz`, `t-gece`, `t-yesim`), set on `` so that menus and dialogs outside the React +root get them too. + +The web app still uses core's `ThemeManager`, because `PitAnimator` listens to it. `ThemeManager` takes the list of +themes (the default stays the three themes the mobile app uses); the web app passes its own three, which are core +`Theme` objects with colours taken from the design, and maps the theme id to the CSS class. + +Reason: one source of truth for the chosen theme, no change for the mobile app. + +## Board highlights + +`PitAnimator` marks animated pits by changing `pitColor`, which the new board does not use. `PitViewModel` gets an +optional `highlight` (`"sow" | "capture" | "store" | "selected"`) that `PitAnimator` sets next to the colour; the web +board turns it into rings. The mobile app ignores it. + +## Move preview + +`getSowingPath(stones, pitIndex)` in core returns the pits a move drops stones into, in order, following +`Board.move` (one stone goes to the next pit; more stones start with the played pit; every pit and store is passed, +including the opponent's store). The board counts the stones per pit for the "+n" chips; the last index is where the +last stone lands. + +## Styles + +The design's styles are plain CSS files next to the components (`*.css`, imported from the component), not +styled-jsx: they use container queries (`@container`) and `cqw` units, which Parcel's CSS transformer supports and +styled-jsx 5.0 does not. Class names follow the design. + +## Popular games + +`GET /games/popular?kind=finished` always returns finished games, so the home page can show live and popular games +at once. Without the parameter the endpoint behaves as before, which the mobile app relies on. + +## Not done + +- The design's "Rematch" button: added later with rematches and series (`docs/specs/rematch`). +- The mobile app. diff --git a/docs/specs/web-redesign/requirements.md b/docs/specs/web-redesign/requirements.md new file mode 100644 index 0000000..a6360b5 --- /dev/null +++ b/docs/specs/web-redesign/requirements.md @@ -0,0 +1,52 @@ +# Web redesign: requirements + +Status: implemented on branch `feature/web-redesign` (based on `feature/game-clock`). +Scope: web app, with small additions to core and the backend. The mobile app keeps its current look. + +The design is the canvas "Köçürme Board Tasarımı" (claude.ai artifact `8AnZr74jA68LGDBfwA1vyd`): home ("Lobi"), +game ("Oyun") and watch/replay ("İzle / Tekrar") screens, each at desktop (1440 px) and phone (390 px) width. The +phone screens are the same pages at a narrow width, not a separate app. + +## Requirements + +### R1. Look +- R1.1 Three themes: Ceviz (walnut, dark), Gece (night, dark) and Yeşim (jade, light). The header shows them as + swatches; the choice is kept in the browser. Themes saved by older versions fall back to Ceviz. +- R1.2 The typeface is Bricolage Grotesque; icons are inline SVG. +- R1.3 Every page has the same sticky header: logo and name (links home), theme swatches, account menu on the home + page, and one main action ("New Game", or "Leave" during a game). On phones the action shows its icon only. +- R1.4 Pages work from 320 px wide: no horizontal scrolling, touch targets at least 44 px. + +### R2. Board +- R2.1 Pits and stores are wells on a board; stones are drawn as coloured stones scattered in the well, with the count + in a badge. The viewer's store is on the right and its badge uses the accent colour. +- R2.2 Below 600 px of board width the board stands upright: the top player's store at the top, the viewer's at + the bottom, the viewer's pits in the left column. +- R2.3 On the viewer's turn, the pits they can play are buttons; hovering one previews the move: a "+n" chip on each + pit that receives stones and a ring on the pit where the last stone lands. +- R2.4 While a move is animated, pits receiving stones are ringed; captures and stones landing in the store are + marked too. The pit the last move was played from has a dashed ring. + +### R3. Game page +- R3.1 The top player is shown on the left, the bottom player on the right, and the turn between them ("Your turn", + "Opponent's turn", or what ended the game). On phones the bottom player is shown below the board. +- R3.2 Each player shows an avatar with initials, name, role ("You" / "Opponent"), store count, clock and whether + they are online. The player to move is ringed. +- R3.3 When the game ends a card over the board shows the result, the score, "New Game" and "Home"; it can be + closed to see the board. +- R3.4 The move list shows the newest move first, with tags for extra turns, captures and stones added to the store. + Selecting a move still shows that position (the review banner offers to go back to live). + +### R4. Replay page +- R4.1 A summary card shows the result, date, duration, move count, both players with the score, and "Watch from + start". +- R4.2 Below the board a controls card has first/previous/play/next/last buttons, the speed (1x, 2x, 4x), a timeline + and a line describing the shown move with its tags. +- R4.3 The move list shows the oldest move first, starting with "Start"; the shown move is highlighted. + +### R5. Home page +- R5.1 A user with a game in progress sees it first: opponent, whose turn, move number, both store counts, a large + mini board and "Continue". +- R5.2 "Live games", "Popular games" and "My recent games" are grids of cards: both players with the score, a mini + board with the stone counts and a status pill (live, result, abandoned) with details. "Live games" is hidden when + nothing is being played; "Popular games" always lists finished games. diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md new file mode 100644 index 0000000..49bac83 --- /dev/null +++ b/docs/specs/web-redesign/tasks.md @@ -0,0 +1,15 @@ +# Web redesign: tasks + +Work through the tasks in order; each ends with its checks passing and a commit. Tick a task when it is committed. + +- [x] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath` + with unit tests; new texts (en, tr). +- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test. +- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu, + action) on every page; restyle the lobby, privacy page, loading and connection panels. +- [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. +- [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. +- [x] 6. **Web app replay page**: summary card, controls card, move list. +- [x] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. +- [x] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; + `AGENTS.md` and `TODO.md`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..36a1e0c --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "mancala", + "private": true, + "scripts": { + "build:packages": "yarn --cwd packages/mancala.js install --frozen-lockfile && yarn --cwd packages/mancala.js build && yarn --cwd packages/core install --frozen-lockfile && yarn --cwd packages/core build" + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 0d6ed36..29560c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ "license": "MIT", "dependencies": { "@types/uuid": "^9.0.8", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../mancala.js", "tiny-emitter": "^2.1.0", "uuid": "^10.0.0" }, diff --git a/packages/core/src/animation/PitAnimator.ts b/packages/core/src/animation/PitAnimator.ts index c211881..035e309 100644 --- a/packages/core/src/animation/PitAnimator.ts +++ b/packages/core/src/animation/PitAnimator.ts @@ -7,6 +7,7 @@ import { GAME_STEP_LAST_STONE_IN_BANK, GAME_STEP_DOUBLE_STONE_IN_PIT, MancalaGame, + MoveHistoryItem, } from "mancala.js"; import { v4 } from "uuid"; import { BoardViewModel } from "../viewmodel/BoardViewModel"; @@ -15,7 +16,8 @@ import { Game } from "../models"; import { getColorByBrightness } from "../util"; import { Theme, ThemeManager } from "../theme"; -const animationUpdateInterval = 300; +export const DEFAULT_ANIMATION_INTERVAL = 300; +const animationUpdateInterval = DEFAULT_ANIMATION_INTERVAL; export class PitAnimator { themeManager: ThemeManager; @@ -28,12 +30,17 @@ export class PitAnimator { animationIndex: number = 0; currentHistoryItem: HistoryItem | undefined; + // Time between animation steps, in milliseconds. + animationInterval: number; + constructor( themeManager: ThemeManager, - onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void + onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void, + animationInterval: number = animationUpdateInterval ) { this.themeManager = themeManager; this.onBoardViewModelUpdate = onBoardViewModelUpdate; + this.animationInterval = animationInterval; this.themeManager.on("themechange", this.onThemeChange.bind(this)); } @@ -86,12 +93,6 @@ export class PitAnimator { this.animationIndex++; } - getGameMoveStepCount(historyItem: HistoryItem) { - return historyItem.gameSteps.filter( - (gameStep) => gameStep.type === GAME_STEP_GAME_MOVE - ).length; - } - animatePit( index: number, boardViewModel: BoardViewModel, @@ -100,42 +101,42 @@ export class PitAnimator { if (!this.currentHistoryItem || !this.game || !this.mancalaGame) return; const pitViewModel = boardViewModel.pits[index]; if (this.animationIndex === 0) { - //This is one stone move case, TODO: beautify it later - if (this.getGameMoveStepCount(this.currentHistoryItem) === 1) { - const previousPitIndex = gameStep.index - 1; - if (previousPitIndex > 0) { - boardViewModel.pits[previousPitIndex].stoneCount = 0; - } - } else { - pitViewModel.stoneCount = 0; - } + // The played pit empties before its stones are sown (a single stone goes on to the next pit). + const { moveIndex } = this.currentHistoryItem as MoveHistoryItem; + boardViewModel.pits[moveIndex].stoneCount = 0; } const theme = this.themeManager.theme; if (gameStep.type === GAME_STEP_GAME_MOVE) { pitViewModel.stoneCount += 1; pitViewModel.pitColor = theme.pitGameMoveAnimateColor; + pitViewModel.highlight = "sow"; } else if (gameStep.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) { pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor; + pitViewModel.highlight = "capture"; pitViewModel.stoneCount = 0; const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly( gameStep.data.oppositeIndex ); const oppositePitViewModel = boardViewModel.pits[oppositeIndex]; oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor; + oppositePitViewModel.highlight = "capture"; oppositePitViewModel.stoneCount = 0; } else if (gameStep.type === GAME_STEP_LAST_STONE_IN_BANK) { pitViewModel.pitColor = theme.pitLastStoneInBankPitAnimateColor; + pitViewModel.highlight = "store"; } else if (gameStep.type === GAME_STEP_BOARD_CLEARED) { for (const index of gameStep.data.pitIndexesThatHasStone) { const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(index); const oppositePitViewModel = boardViewModel.pits[oppositeIndex]; oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor; + oppositePitViewModel.highlight = "capture"; oppositePitViewModel.stoneCount = 0; } } else if (gameStep.type === GAME_STEP_DOUBLE_STONE_IN_PIT) { const _index = this.mancalaGame.board.getPitIndexCircularly(index); const pitViewModel = boardViewModel.pits[_index]; pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor; + pitViewModel.highlight = "capture"; pitViewModel.stoneCount = 0; } pitViewModel.stoneColor = getColorByBrightness( @@ -151,7 +152,7 @@ export class PitAnimator { //@ts-ignore this.currentIntervalID = setInterval( () => this.onAnimate(), - animationUpdateInterval + this.animationInterval ); } diff --git a/packages/core/src/clock/Clock.ts b/packages/core/src/clock/Clock.ts new file mode 100644 index 0000000..aef42f2 --- /dev/null +++ b/packages/core/src/clock/Clock.ts @@ -0,0 +1,83 @@ +import { Game } from "../models/Game"; +import { ClockTimes, GameClock, Seat } from "../models/GameClock"; +import { GameRecord } from "../models/GameHistory"; + +export interface ClockState extends ClockTimes { + // Undefined when no clock runs. + runningSeat?: Seat; +} + +export function getSeat(players: { player1Id: string, player2Id: string }, playerId: string): Seat | undefined { + if (playerId === players.player1Id) return "player1"; + if (playerId === players.player2Id) return "player2"; + return undefined; +} + +// Both players' time at `now` (server time); never below zero. +export function getClockTimes(clock: GameClock, runningSeat: Seat | undefined, now: number): ClockTimes { + const elapsed = runningSeat && clock.turnStartedAt !== undefined ? Math.max(0, now - clock.turnStartedAt) : 0; + return { + player1Ms: Math.max(0, clock.player1Ms - (runningSeat === "player1" ? elapsed : 0)), + player2Ms: Math.max(0, clock.player2Ms - (runningSeat === "player2" ? elapsed : 0)), + }; +} + +// "4:59"; rounded up, so "0:00" only shows when the time is gone. +export function formatClock(milliseconds: number): string { + const seconds = Math.ceil(Math.max(0, milliseconds) / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +type ClockRecord = Pick; + +// Both players' time after the given move of a saved game; move 0 is the start. Undefined for games saved before +// the game clock. +export function getClockAtMove(record: ClockRecord, moveNumber: number): ClockTimes | undefined { + if (record.clockInitialMs === undefined) return undefined; + const n = Math.min(Math.max(moveNumber, 0), record.moves.length); + const times: ClockTimes = { player1Ms: record.clockInitialMs, player2Ms: record.clockInitialMs }; + for (const move of record.moves.slice(0, n)) { + if (move.remainingMs === undefined) continue; + if (move.playerId === record.player1.userId) times.player1Ms = move.remainingMs; + else times.player2Ms = move.remainingMs; + } + // The player who ran out of time did not get to move again; their clock ends at zero. + if (n === record.moves.length && record.endReason === "timeout" && record.result !== null) { + if (record.result === "player1") times.player2Ms = 0; + if (record.result === "player2") times.player1Ms = 0; + } + return times; +} + +// Follows the clock of a live game from the server's messages and counts down between them, in server time. +export class ClockSync { + private clock?: GameClock; + private runningSeat?: Seat; + // Server time minus local time. + private offsetMs = 0; + + // From a live game message (game start, update, reconnect). + update(game: Game, receivedAt = Date.now()) { + const { mancalaGame } = game; + this.clock = game.clock; + this.runningSeat = mancalaGame.state === "ended" ? undefined : getSeat(mancalaGame, mancalaGame.turnPlayerId); + if (game.serverTime !== undefined) this.offsetMs = game.serverTime - receivedAt; + } + + // When the game has ended; the server sends the stopped clocks. + stop(clock?: GameClock) { + if (clock) this.clock = clock; + this.runningSeat = undefined; + } + + get isRunning(): boolean { + return this.clock?.turnStartedAt !== undefined && this.runningSeat !== undefined; + } + + // Undefined when the server sends no clock. + getTimes(now = Date.now()): ClockState | undefined { + if (!this.clock) return undefined; + const runningSeat = this.isRunning ? this.runningSeat : undefined; + return { ...getClockTimes(this.clock, runningSeat, now + this.offsetMs), runningSeat }; + } +} diff --git a/packages/core/src/clock/index.ts b/packages/core/src/clock/index.ts new file mode 100644 index 0000000..9b8b971 --- /dev/null +++ b/packages/core/src/clock/index.ts @@ -0,0 +1 @@ +export * from './Clock' diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts new file mode 100644 index 0000000..3af04c3 --- /dev/null +++ b/packages/core/src/history/MoveHistory.ts @@ -0,0 +1,143 @@ +import { + Bank, + Board, + CommonBoard, + GameStep, + GAME_STEP_BOARD_CLEARED, + GAME_STEP_DOUBLE_STONE_IN_PIT, + GAME_STEP_LAST_STONE_IN_BANK, + GAME_STEP_LAST_STONE_IN_EMPTY_PIT, + MancalaGame, + Pit, +} from "mancala.js"; +import { Game } from "../models/Game"; + +// A played move: an entry of MancalaGame.history in a live game, or a MoveRecord of a saved game. +export interface PlayedMove { + playerId: string; + moveIndex: number; + boardSnapshot: number[]; + gameSteps: GameStep[]; +} + +export type CaptureKind = "even" | "emptyPit"; + +export interface MoveDescription { + // 1-based. + number: number; + playerId: string; + // 1-6, counted from the player's own side. + pit: number; + extraTurn: boolean; + capture: boolean; + captureKind?: CaptureKind; + // Stones taken from the opponent's pit by a capture. + captured: number; + // Stones the mover's store gained with this move. + storeGain: number; + gameOver: boolean; +} + +const referenceBoard = new CommonBoard(); + +export function getInitialBoard(): number[] { + return new CommonBoard().getStoneArray(); +} + +// Stones in every pit after the given move; move 0 is the start position. +export function getBoardAfterMove(moves: PlayedMove[], moveNumber: number): number[] { + const n = Math.min(Math.max(moveNumber, 0), moves.length); + return n === 0 ? getInitialBoard() : [...moves[n - 1].boardSnapshot]; +} + +export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescription { + const move = moves[moveNumber - 1]; + if (!move) throw new Error(`No move ${moveNumber}, the game has ${moves.length} moves`); + const before = getBoardAfterMove(moves, moveNumber - 1); + const isPlayer1Move = move.moveIndex <= referenceBoard.player1PitStopIndex(); + const firstPitIndex = isPlayer1Move ? referenceBoard.player1PitStartIndex() : referenceBoard.player2PitStartIndex(); + const storeIndex = isPlayer1Move ? referenceBoard.player1BankIndex() : referenceBoard.player2BankIndex(); + const stepTypes = move.gameSteps.map((step) => step.type); + return { + number: moveNumber, + playerId: move.playerId, + pit: move.moveIndex - firstPitIndex + 1, + extraTurn: stepTypes.includes(GAME_STEP_LAST_STONE_IN_BANK), + capture: stepTypes.includes(GAME_STEP_LAST_STONE_IN_EMPTY_PIT) || stepTypes.includes(GAME_STEP_DOUBLE_STONE_IN_PIT), + captureKind: getCaptureKind(move.gameSteps), + captured: getCapturedStones(before, move.moveIndex, move.gameSteps), + storeGain: move.boardSnapshot[storeIndex] - before[storeIndex], + gameOver: stepTypes.includes(GAME_STEP_BOARD_CLEARED), + }; +} + +// Stones a capture took from the opponent's pit: what was there plus what this move dropped in. 0 without a capture. +export function getCapturedStones(before: number[], pitIndex: number, gameSteps: GameStep[]): number { + const path = getSowingPath(before, pitIndex); + const capturedFrom = (index: number) => before[index] + path.filter((i) => i === index).length; + for (const step of gameSteps) { + if (step.type === GAME_STEP_DOUBLE_STONE_IN_PIT) return capturedFrom(step.data.pitIndex); + if (step.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) return capturedFrom(step.data.oppositeIndex); + } + return 0; +} + +export function getCaptureKind(gameSteps: GameStep[]): CaptureKind | undefined { + if (gameSteps.some((step) => step.type === GAME_STEP_DOUBLE_STONE_IN_PIT)) return "even"; + if (gameSteps.some((step) => step.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT)) return "emptyPit"; + return undefined; +} + +// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them (see +// Board.getSowingPath): the opponent's store is skipped. The last index is where the last stone lands. Empty for an +// empty pit or a store. +export function getSowingPath(stones: number[], pitIndex: number): number[] { + return referenceBoard.getSowingPath(pitIndex, stones[pitIndex] ?? 0); +} + +// The game as it was after the given move, for showing or animating that position (e.g. with PitAnimator, +// whose last history entry is the move it animates). Only for display: the rebuilt game has no rules. +export function createGameAtMove(game: Game, moves: PlayedMove[], moveNumber: number): Game { + const n = Math.min(Math.max(moveNumber, 0), moves.length); + const stones = getBoardAfterMove(moves, n); + const pits = stones.map((stoneCount, index) => + referenceBoard.checkPitTypeIsBankByIndex(index) ? new Bank(index, stoneCount) : new Pit(index, stoneCount)); + const { id, player1Id, player2Id } = game.mancalaGame; + const nextMove = moves[n]; + const mancalaGame = new MancalaGame( + id, + new Board(referenceBoard.playerPitCount, referenceBoard.initialStoneCountInPits, pits), + player1Id, + player2Id, + nextMove ? nextMove.playerId : game.mancalaGame.turnPlayerId, + [], + moves.slice(0, n).map((move) => ({ ...move, gameSteps: [...move.gameSteps] })), + n === moves.length ? game.mancalaGame.state : "playing" + ); + return { ...game, mancalaGame }; +} + +// A game showing the given stones, for displaying a board (e.g. a preview) when only the stone counts are known. +export function createGameFromBoard(params: { + id: string, player1Id: string, player2Id: string, turnPlayerId: string, board: number[], +}): Game { + const pits = params.board.map((stoneCount, index) => + referenceBoard.checkPitTypeIsBankByIndex(index) ? new Bank(index, stoneCount) : new Pit(index, stoneCount)); + const mancalaGame = new MancalaGame( + params.id, + new Board(referenceBoard.playerPitCount, referenceBoard.initialStoneCountInPits, pits), + params.player1Id, + params.player2Id, + params.turnPlayerId, + [], + [] + ); + return { + id: params.id, + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: params.player1Id, isOnline: false }, + user2ConnectionInfo: { userId: params.player2Id, isOnline: false }, + }, + }; +} diff --git a/packages/core/src/history/index.ts b/packages/core/src/history/index.ts new file mode 100644 index 0000000..aefb898 --- /dev/null +++ b/packages/core/src/history/index.ts @@ -0,0 +1 @@ +export * from './MoveHistory' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 977d591..2670e24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,4 +7,7 @@ export * from './util/index' export * from './animation/index' export * from './factory/index' export * from './viewmodel/index' -export * from './service/index' \ No newline at end of file +export * from './service/index' +export * from './history/index' +export * from './clock/index' +export * from './tutorial/index' diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index cce07cd..36c277c 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -38,5 +38,160 @@ export const en = { WebApp: "Goto WebApp", Privacy: "Open Privacy Policy", Home: "Home", - RejoinGame: "Rejoin Game" + RejoinGame: "Rejoin Game", + Guest: "Guest", + LogIn: "Log In", + SignUp: "Sign Up", + LogOut: "Log Out", + LiveGames: "Live Games", + PopularGames: "Popular Games", + MyRecentGames: "My Recent Games", + NoGamesYet: "No games yet", + Watching: "{{count}} watching", + GameInProgress: "You have a game in progress", + Continue: "Continue", + Versus: "vs", + Draw: "Draw", + OpponentLeft: "Opponent left", + YouLeft: "You left", + OpponentTimedOut: "Opponent ran out of time", + YouTimedOut: "You ran out of time", + Abandoned: "Abandoned", + PlayerWon: "{{name}} won", + Moves: "Moves", + PitNumber: "Pit {{pit}}", + ExtraTurn: "Extra turn", + Capture: "Capture", + StoreGain: "+{{count}} to store", + GameOver: "Game over", + ViewingMove: "Viewing move {{number}}", + MoveOf: "Move {{number}} / {{total}}", + MoveNumber: "Move {{number}}", + Start: "Start", + BackToLive: "Back to live", + WatchFromStart: "Watch from start", + Speed: "Speed", + Duration: "Duration", + You: "You", + Opponent: "Opponent", + StoreCount: "Store {{count}}", + Online: "Online", + Offline: "Offline", + ChooseTheme: "Choose theme", + ThemeWalnut: "Walnut theme", + ThemeNight: "Night theme", + ThemeJade: "Jade theme", + Account: "Account", + NewestFirst: "newest first", + BeingPlayedNow: "Being played now", + MostWatched: "Most watched", + YourGames: "Your games", + GameWithOpponent: "Your game with {{name}} is on", + StoreScores: "Your store {{mine}}, opponent's {{theirs}}", + Live: "Live", + Close: "Close", + Play: "Play", + Pause: "Pause", + FirstMove: "Go to start", + PreviousMove: "Previous move", + NextMove: "Next move", + LastMove: "Go to end", + MovePosition: "Move position", + PlaybackControls: "Playback controls", + PlaybackSpeed: "Playback speed", + GameSummary: "Game summary", + StartPosition: "4 stones in every pit", + MoveCount: "{{count}} moves", + PitLabel: "{{name}}, pit {{pit}}, {{count}} stones", + StoreLabel: "{{name}}'s store, {{count}} stones", + OpenGame: "Open the game", + Score: "Score", + PlayWithBot: "Play with bot", + NoOpponentYet: "No opponent yet. Play with the bot?", + ChooseBotLevel: "Choose how strong the bot is", + Bot: "Bot", + BotLevelEasy: "Easy", + BotLevelMedium: "Medium", + BotLevelHard: "Hard", + BotLevelMaster: "Master", + PlayAgain: "Play again", + RuleBoardTitle: "The board and the goal", + RuleBoardText1: "Each player has the 6 pits on their side of the board and a store, the large pit at the end of their row. Every pit starts with 4 stones.", + RuleBoardText2: "Stones that reach your store are yours. On your turn you play one of your pits, then it is the opponent's turn.", + RuleBoardText3: "When the game ends, the player with more stones in their store wins. There are 48 stones, so 25 always win.", + RuleSowingTitle: "Sowing", + RuleSowingText1: "Take all the stones from one of your pits. Put the first back into the same pit and one into each pit after it, towards your store.", + RuleSowingText2: "A pit with a single stone moves it to the next pit.", + RuleSowingText3: "Your store gets a stone when you pass it; the opponent's store is skipped.", + RuleExtraTurnTitle: "Extra turn", + RuleExtraTurnText1: "If your last stone lands in your store, you play again.", + RuleExtraTurnText2: "Count the stones: a pit whose stones end exactly in your store gives you another move.", + RuleEvenCaptureTitle: "Capturing an even pit", + RuleEvenCaptureText1: "If your last stone lands in one of the opponent's pits and makes the stones there even, you take all of them into your store.", + RuleEvenCaptureText2: "If the number is odd, nothing is taken and the turn passes.", + RuleEmptyPitCaptureTitle: "Capturing from an empty pit", + RuleEmptyPitCaptureText1: "If your last stone lands in an empty pit of yours and the opponent's pit opposite has stones, you take that stone and all the stones opposite.", + RuleEmptyPitCaptureText2: "If the pit opposite is empty, nothing is taken.", + RuleEndOfGameTitle: "End of the game", + RuleEndOfGameText1: "The game ends as soon as all pits on one side are empty.", + RuleEndOfGameText2: "The player whose side is empty gets all the stones left on the other side.", + RuleEndOfGameText3: "Then the stores are counted: more stones wins, the same number is a draw.", + RuleClockTitle: "The clock", + RuleClockText1: "Each player has 5 minutes for the whole game. Only the clock of the player whose turn it is runs.", + RuleClockText2: "A player whose time runs out loses.", + LessonTaskAnyMove: "Play one of your pits in the bottom row and watch the stones.", + LessonTaskSowing: "Play any pit. Try the pit with a single stone too.", + LessonTaskExtraTurn: "Find the pit whose last stone lands in your store.", + LessonTaskEvenCapture: "Make one of the opponent's pits even with your last stone.", + LessonTaskEmptyPitCapture: "End your move in your empty pit to take the stones opposite.", + LessonTaskEndGame: "Play your last stone and see who gets the stones that are left.", + MoveExplainExtraTurnYou: "Your last stone landed in your store: you play again.", + MoveExplainExtraTurnBot: "The bot's last stone landed in its store: it plays again.", + MoveExplainEvenYou: "Your last stone made an opponent's pit even: you took {{count}} stones.", + MoveExplainEvenBot: "The bot's last stone made one of your pits even: it took {{count}} stones.", + MoveExplainEmptyPitYou: "Your last stone landed in an empty pit of yours: you took it and the {{count}} stones opposite.", + MoveExplainEmptyPitBot: "The bot's last stone landed in an empty pit of its own: it took it and your {{count}} stones opposite.", + MoveExplainOddYou: "Your last stone left {{count}} stones in the opponent's pit, an odd number: nothing was taken and the turn passes.", + MoveExplainOddBot: "The bot's last stone left {{count}} stones in your pit, an odd number: it took nothing.", + MoveExplainOwnPitYou: "Your last stone landed in one of your pits that was not empty: the turn passes.", + MoveExplainOwnPitBot: "The bot's last stone landed in one of its own pits: your turn.", + MoveExplainEmptyOppositeYou: "Your last stone landed in an empty pit of yours, but the pit opposite was empty: nothing to take.", + MoveExplainEmptyOppositeBot: "The bot's last stone landed in its empty pit, but your pit opposite was empty: it took nothing.", + MoveExplainGameOver: "One side is empty, so the game is over.", + HowToPlay: "How to play", + LearnMangala: "Learn Mangala", + Lessons: "Lessons", + TryIt: "Try it", + Hints: "Hints", + HintExtraTurn: "Extra turn", + HintCaptures: "Takes {{count}}", + NewToMangala: "New to Mangala? Learn it in 3 minutes.", + StartTutorial: "Start", + NotNow: "Not now", + PracticeGame: "Practice game", + PracticeGameText: "Play the Easy bot with hints on.", + WellDone: "Well done!", + NextLesson: "Next lesson", + TryAgain: "Try again", + LessonDone: "Done", + AllLessons: "All lessons", + LessonNumber: "Lesson {{number}} of {{count}}", + Rematch: "Rematch", + No: "No", + RematchWaiting: "Waiting for {{name}}…", + RematchRequested: "{{name}} wants a rematch", + RematchDeclined: "{{name}} declined the rematch", + RematchWithdrawn: "{{name}} withdrew the rematch", + RematchNoAnswer: "{{name}} did not answer", + RematchRequestExpired: "The rematch request expired", + RematchOpponentGone: "{{name}} is no longer here", + RematchClosed: "A rematch is no longer possible", + Series: "Series", + SeriesScoreText: "Series: {{first}} {{firstWins}} – {{secondWins}} {{second}}", + SeriesGameNumber: "Game {{number}}", + SeriesCurrentGame: "Now", + SeriesEarlierGames: "{{count}} earlier games", + MovedFirst: "Moved first", + EndedOnTime: "time", + EndedByLeaving: "left", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index 5b8ce70..83d6bdc 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -38,5 +38,160 @@ export const tr = { WebApp: "Web Uygulamasına Git", Privacy: "Gizlilik Politikasını Göster", Home: "Ana Sayfa", - RejoinGame: "Oyuna Geri Dön" + RejoinGame: "Oyuna Geri Dön", + Guest: "Misafir", + LogIn: "Giriş Yap", + SignUp: "Kayıt Ol", + LogOut: "Çıkış Yap", + LiveGames: "Canlı Oyunlar", + PopularGames: "Popüler Oyunlar", + MyRecentGames: "Son Oyunlarım", + NoGamesYet: "Henüz oyun yok", + Watching: "{{count}} izliyor", + GameInProgress: "Devam eden bir oyunun var", + Continue: "Devam Et", + Versus: "-", + Draw: "Berabere", + OpponentLeft: "Rakip ayrıldı", + YouLeft: "Sen ayrıldın", + OpponentTimedOut: "Rakibin süresi doldu", + YouTimedOut: "Süren doldu", + Abandoned: "Yarıda kaldı", + PlayerWon: "{{name}} kazandı", + Moves: "Hamleler", + PitNumber: "{{pit}}. kuyu", + ExtraTurn: "Ekstra tur", + Capture: "Taş aldı", + StoreGain: "Hazineye +{{count}}", + GameOver: "Oyun bitti", + ViewingMove: "{{number}}. hamleye bakıyorsun", + MoveOf: "Hamle {{number}} / {{total}}", + MoveNumber: "{{number}}. hamle", + Start: "Başlangıç", + BackToLive: "Canlıya dön", + WatchFromStart: "Baştan izle", + Speed: "Hız", + Duration: "Süre", + You: "Sen", + Opponent: "Rakip", + StoreCount: "Hazine {{count}}", + Online: "Çevrimiçi", + Offline: "Çevrimdışı", + ChooseTheme: "Tema seç", + ThemeWalnut: "Ceviz tema", + ThemeNight: "Gece tema", + ThemeJade: "Yeşim tema", + Account: "Hesap", + NewestFirst: "en yeni üstte", + BeingPlayedNow: "Şu an oynanıyor", + MostWatched: "En çok izlenenler", + YourGames: "Senin maçların", + GameWithOpponent: "{{name}} ile oyunun sürüyor", + StoreScores: "Hazinen {{mine}}, rakibin {{theirs}}", + Live: "Canlı", + Close: "Kapat", + Play: "Oynat", + Pause: "Duraklat", + FirstMove: "Başa dön", + PreviousMove: "Önceki hamle", + NextMove: "Sonraki hamle", + LastMove: "Sona git", + MovePosition: "Hamle konumu", + PlaybackControls: "Oynatma kontrolleri", + PlaybackSpeed: "Oynatma hızı", + GameSummary: "Oyun özeti", + StartPosition: "Her kuyuda 4 taş", + MoveCount: "{{count}} hamle", + PitLabel: "{{name}}, {{pit}}. kuyu, {{count}} taş", + StoreLabel: "{{name}} hazinesi, {{count}} taş", + OpenGame: "Oyunu aç", + Score: "Skor", + PlayWithBot: "Bota karşı oyna", + NoOpponentYet: "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", + ChooseBotLevel: "Botun ne kadar güçlü olacağını seç", + Bot: "Bot", + BotLevelEasy: "Kolay", + BotLevelMedium: "Orta", + BotLevelHard: "Zor", + BotLevelMaster: "Usta", + PlayAgain: "Tekrar oyna", + RuleBoardTitle: "Tahta ve amaç", + RuleBoardText1: "Her oyuncunun tahtada kendi tarafında 6 kuyusu ve sırasının sonunda büyük bir hazinesi vardır. Her kuyuda başta 4 taş bulunur.", + RuleBoardText2: "Hazinene giren taşlar senindir. Sıran geldiğinde kendi kuyularından birini oynarsın, sonra sıra rakibe geçer.", + RuleBoardText3: "Oyun bittiğinde hazinesinde daha çok taş olan kazanır. Toplam 48 taş var, yani 25 taş her zaman kazandırır.", + RuleSowingTitle: "Taşları dağıtmak", + RuleSowingText1: "Kuyularından birindeki bütün taşları al. İlkini aynı kuyuya geri bırak, kalanları hazinene doğru sıradaki kuyulara birer birer bırak.", + RuleSowingText2: "Kuyuda tek taş varsa o taş bir sonraki kuyuya geçer.", + RuleSowingText3: "Hazinenin üzerinden geçerken ona da bir taş bırakırsın; rakibin hazinesi atlanır.", + RuleExtraTurnTitle: "Bir hamle daha", + RuleExtraTurnText1: "Son taşın hazinene düşerse bir kez daha oynarsın.", + RuleExtraTurnText2: "Taşları say: taşları tam hazinende biten kuyu sana bir hamle daha kazandırır.", + RuleEvenCaptureTitle: "Çift yapıp almak", + RuleEvenCaptureText1: "Son taşın rakibin bir kuyusuna düşer ve oradaki taş sayısını çift yaparsa, o kuyudaki bütün taşları hazinene alırsın.", + RuleEvenCaptureText2: "Sayı tek olursa bir şey alınmaz, sıra rakibe geçer.", + RuleEmptyPitCaptureTitle: "Boş kuyudan almak", + RuleEmptyPitCaptureText1: "Son taşın kendi tarafındaki boş bir kuyuya düşer ve rakibin karşıdaki kuyusunda taş varsa, o taşı ve karşıdaki bütün taşları hazinene alırsın.", + RuleEmptyPitCaptureText2: "Karşıdaki kuyu boşsa bir şey alınmaz.", + RuleEndOfGameTitle: "Oyunun sonu", + RuleEndOfGameText1: "Bir taraftaki bütün kuyular boşaldığı anda oyun biter.", + RuleEndOfGameText2: "Tarafı boşalan oyuncu, karşı tarafta kalan bütün taşları alır.", + RuleEndOfGameText3: "Sonra hazineler sayılır: çok taşı olan kazanır, eşitse oyun berabere biter.", + RuleClockTitle: "Süre", + RuleClockText1: "Her oyuncunun bütün oyun için 5 dakikası vardır. Yalnızca sırası gelen oyuncunun süresi işler.", + RuleClockText2: "Süresi biten oyuncu kaybeder.", + LessonTaskAnyMove: "Alt sıradaki kuyularından birini oyna ve taşları izle.", + LessonTaskSowing: "Herhangi bir kuyuyu oyna. Tek taşlı kuyuyu da dene.", + LessonTaskExtraTurn: "Son taşı hazinene düşen kuyuyu bul.", + LessonTaskEvenCapture: "Son taşınla rakibin bir kuyusunu çift yap.", + LessonTaskEmptyPitCapture: "Hamleni boş kuyunda bitir ve karşıdaki taşları al.", + LessonTaskEndGame: "Son taşını oyna ve kalan taşların kime gittiğini gör.", + MoveExplainExtraTurnYou: "Son taşın hazinene düştü: bir kez daha oynuyorsun.", + MoveExplainExtraTurnBot: "Botun son taşı kendi hazinesine düştü: bir kez daha oynuyor.", + MoveExplainEvenYou: "Son taşın rakibin kuyusunu çift yaptı: {{count}} taş aldın.", + MoveExplainEvenBot: "Botun son taşı senin bir kuyunu çift yaptı: {{count}} taş aldı.", + MoveExplainEmptyPitYou: "Son taşın kendi boş kuyuna düştü: onu ve karşıdaki {{count}} taşı aldın.", + MoveExplainEmptyPitBot: "Botun son taşı kendi boş kuyusuna düştü: onu ve karşısındaki {{count}} taşını aldı.", + MoveExplainOddYou: "Son taşın rakibin kuyusunda {{count}} taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti.", + MoveExplainOddBot: "Botun son taşı senin kuyunda {{count}} taş bıraktı, bu tek sayı: bir şey almadı.", + MoveExplainOwnPitYou: "Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti.", + MoveExplainOwnPitBot: "Botun son taşı kendi kuyusuna düştü: sıra sende.", + MoveExplainEmptyOppositeYou: "Son taşın boş kuyuna düştü ama karşıdaki kuyu da boştu: alınacak taş yok.", + MoveExplainEmptyOppositeBot: "Botun son taşı boş kuyusuna düştü ama karşısındaki kuyun boştu: bir şey almadı.", + MoveExplainGameOver: "Bir taraf boşaldı, oyun bitti.", + HowToPlay: "Nasıl oynanır", + LearnMangala: "Mangala öğren", + Lessons: "Dersler", + TryIt: "Dene", + Hints: "İpuçları", + HintExtraTurn: "Bir hamle daha", + HintCaptures: "{{count}} taş alır", + NewToMangala: "Mangala'da yeni misin? 3 dakikada öğren.", + StartTutorial: "Başla", + NotNow: "Şimdi değil", + PracticeGame: "Alıştırma oyunu", + PracticeGameText: "İpuçları açıkken Kolay bota karşı oyna.", + WellDone: "Harika!", + NextLesson: "Sonraki ders", + TryAgain: "Tekrar dene", + LessonDone: "Tamamlandı", + AllLessons: "Tüm dersler", + LessonNumber: "Ders {{number}} / {{count}}", + Rematch: "Rövanş", + No: "Hayır", + RematchWaiting: "{{name}} bekleniyor…", + RematchRequested: "{{name}} rövanş istiyor", + RematchDeclined: "{{name}} rövanşı kabul etmedi", + RematchWithdrawn: "{{name}} rövanştan vazgeçti", + RematchNoAnswer: "{{name}} cevap vermedi", + RematchRequestExpired: "Rövanş isteğinin süresi doldu", + RematchOpponentGone: "{{name}} artık burada değil", + RematchClosed: "Rövanş artık mümkün değil", + Series: "Seri", + SeriesScoreText: "Seri: {{first}} {{firstWins}} – {{secondWins}} {{second}}", + SeriesGameNumber: "{{number}}. oyun", + SeriesCurrentGame: "Şimdi", + SeriesEarlierGames: "{{count}} önceki oyun", + MovedFirst: "İlk hamle", + EndedOnTime: "süre", + EndedByLeaving: "ayrıldı", }; \ No newline at end of file diff --git a/packages/core/src/models/AuthUser.ts b/packages/core/src/models/AuthUser.ts new file mode 100644 index 0000000..faa3518 --- /dev/null +++ b/packages/core/src/models/AuthUser.ts @@ -0,0 +1,7 @@ +export interface AuthUser { + // Stable user id, also used as player id in games. + id: string; + // Undefined for legacy userKey clients. + name?: string; + isGuest: boolean; +} diff --git a/packages/core/src/models/Bot.ts b/packages/core/src/models/Bot.ts new file mode 100644 index 0000000..90254e7 --- /dev/null +++ b/packages/core/src/models/Bot.ts @@ -0,0 +1,42 @@ +// Games against the bot (docs/specs/game-bot). The bot plays with the user id `bot:`. +export type BotLevel = "easy" | "medium" | "hard" | "master"; + +// Weakest first. +export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; + +// How long the lobby searches for an opponent before it offers the bot. +export const BOT_OFFER_DELAY_MS = 10_000; + +const BOT_ID_PREFIX = "bot:"; + +export function isBotId(userId: string | undefined): boolean { + return userId?.startsWith(BOT_ID_PREFIX) ?? false; +} + +// The level of a bot id, or undefined if it is not one. +export function getBotLevel(userId: string | undefined): BotLevel | undefined { + if (!userId || !isBotId(userId)) return undefined; + const level = userId.slice(BOT_ID_PREFIX.length); + return (BOT_LEVELS as string[]).includes(level) ? level as BotLevel : undefined; +} + +export function isBotLevel(value: unknown): value is BotLevel { + return typeof value === "string" && (BOT_LEVELS as string[]).includes(value); +} + +// Translation key of a level's name, e.g. BotLevelMedium. +export function getBotLevelTextKey(level: BotLevel): string { + return `BotLevel${level.charAt(0).toUpperCase()}${level.slice(1)}`; +} + +const LAST_BOT_LEVEL_KEY = "botLevel"; + +// The level chosen last on this device; Medium at first. +export function getLastBotLevel(storage: { getString(key: string): string | undefined }): BotLevel { + const saved = storage.getString(LAST_BOT_LEVEL_KEY); + return isBotLevel(saved) ? saved : "medium"; +} + +export function setLastBotLevel(storage: { setString(key: string, value: string): void }, level: BotLevel): void { + storage.setString(LAST_BOT_LEVEL_KEY, level); +} diff --git a/packages/core/src/models/Game.ts b/packages/core/src/models/Game.ts index ec37311..45421d6 100644 --- a/packages/core/src/models/Game.ts +++ b/packages/core/src/models/Game.ts @@ -1,10 +1,24 @@ import { MancalaGame } from "mancala.js"; +import { GameClock, Seat } from "./GameClock"; +import { Series } from "./Series"; import { UserConnectionInfo } from "./UserConnectionInfo"; export interface Game { id: string; mancalaGame: MancalaGame; gameUsersConnectionInfo: GameUsersConnectionInfo; + // Epoch milliseconds; sent by servers that save games. + startedAt?: number; + // Time of each move, aligned with mancalaGame.history. + moveTimes?: number[]; + // Sent by servers with a game clock. + clock?: GameClock; + // Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock. + serverTime?: number; + // The series and the games before this one (docs/specs/rematch); sent by servers with rematches. + series?: Series; + // The seat that moved first. + firstSeat?: Seat; } export interface GameUsersConnectionInfo { diff --git a/packages/core/src/models/GameClock.ts b/packages/core/src/models/GameClock.ts new file mode 100644 index 0000000..059c211 --- /dev/null +++ b/packages/core/src/models/GameClock.ts @@ -0,0 +1,20 @@ +// Below this, the running clock is shown as low on time. +export const CLOCK_LOW_TIME_MS = 30_000; + +export type Seat = "player1" | "player2"; + +// The players' clocks in a live game. The server keeps the time; clients only display it. +export interface GameClock { + // Time each player started the game with. + initialMs: number; + // Time left for each player. For the player whose turn it is, at turnStartedAt. + player1Ms: number; + player2Ms: number; + // Server time (epoch milliseconds) when the running clock started; undefined when no clock runs. + turnStartedAt?: number; +} + +export interface ClockTimes { + player1Ms: number; + player2Ms: number; +} diff --git a/packages/core/src/models/GameHistory.ts b/packages/core/src/models/GameHistory.ts new file mode 100644 index 0000000..f8830b6 --- /dev/null +++ b/packages/core/src/models/GameHistory.ts @@ -0,0 +1,84 @@ +import { GameStep } from "mancala.js"; +import { GameClock } from "./GameClock"; +import { Series } from "./Series"; + +export interface PlayerSummary { + userId: string; + name?: string; + isGuest: boolean; +} + +export interface MoveRecord { + playerId: string; + // Board index of the pit that was played. + moveIndex: number; + // Stones in every pit and store after the move. + boardSnapshot: number[]; + gameSteps: GameStep[]; + // Epoch milliseconds. + at: number; + // The mover's clock after the move; undefined for games saved before the game clock. + remainingMs?: number; +} + +export type GameResult = "player1" | "player2" | "draw"; + +export type EndReason = "completed" | "left" | "timeout" | "inactive"; + +export interface LiveGameSummary { + id: string; + player1: PlayerSummary; + player2: PlayerSummary; + board: number[]; + turnPlayerId: string; + spectatorCount: number; + moveCount: number; + startedAt: number; +} + +export interface FinishedGameSummary { + id: string; + player1: PlayerSummary; + player2: PlayerSummary; + // Final board. + board: number[]; + // Null when the game ended without a winner. + result: GameResult | null; + endReason: EndReason; + leftUserId?: string; + startedAt: number; + endedAt: number; + moveCount: number; + peakSpectators: number; + // Time each player started with; undefined for games saved before the game clock. + clockInitialMs?: number; +} + +export interface GameRecord extends FinishedGameSummary { + moves: MoveRecord[]; + // Distinct users who watched the game. + totalSpectators: number; + // Undefined for games saved before rematches. + seriesId?: string; +} + +// GET /game/:id for a game that has ended. +export interface FinishedGameResponse { + status: "finished"; + record: GameRecord; +} + +// on_game_end, sent to the players and spectators of a live game when it ends for any reason. +export interface GameEndMessage { + gameId: string; + result: GameResult | null; + endReason: EndReason; + winnerId?: string; + leftUserId?: string; + // The stopped clocks. + clock?: GameClock; + // The series with the game that ended. + series?: Series; + // "available" when the players can ask for a rematch, i.e. unless a player left. + rematch?: "available"; +} diff --git a/packages/core/src/models/Series.ts b/packages/core/src/models/Series.ts new file mode 100644 index 0000000..a052791 --- /dev/null +++ b/packages/core/src/models/Series.ts @@ -0,0 +1,53 @@ +import { EndReason, GameResult } from "./GameHistory"; +import { Seat } from "./GameClock"; + +// Rematches and series (docs/specs/rematch). + +// One finished game of a series. Seats, not user ids: the players keep their seats through a series. +export interface SeriesGame { + gameId: string; + firstSeat: Seat; + // Null when the game ended without a winner. + result: GameResult | null; + endReason: EndReason; + player1Store: number; + player2Store: number; +} + +// A game and the rematches after it; `games` are the finished ones, oldest first. +export interface Series { + id: string; + games: SeriesGame[]; +} + +// on_rematch_request: the opponent asks for a rematch of the game that ended. +export interface RematchRequest { + gameId: string; +} + +// declined: the opponent said no; withdrawn: the player who asked took it back; expired: no answer within a minute; +// offline: a player went offline; left: a player looked for another game; closed: a rematch is no longer possible. +export type RematchEndReason = "declined" | "withdrawn" | "expired" | "offline" | "left" | "closed"; + +// on_rematch_ended, to both players. +export interface RematchEnded { + gameId: string; + reason: RematchEndReason; +} + +// After these, the series is over and only a new game is offered. +export function isSeriesOver(reason: RematchEndReason): boolean { + return reason === "declined" || reason === "left" || reason === "closed"; +} + +export interface SeriesScore { + // Games won by the seat and by the other seat; draws and games without a winner count for neither. + wins: number; + losses: number; +} + +export function getSeriesScore(games: SeriesGame[], seat: Seat): SeriesScore { + const wins = games.filter((game) => game.result === seat).length; + const losses = games.filter((game) => game.result !== null && game.result !== "draw" && game.result !== seat).length; + return { wins, losses }; +} diff --git a/packages/core/src/models/UserConnectionInfo.ts b/packages/core/src/models/UserConnectionInfo.ts index 3ce426f..69e6a87 100644 --- a/packages/core/src/models/UserConnectionInfo.ts +++ b/packages/core/src/models/UserConnectionInfo.ts @@ -1,4 +1,7 @@ export interface UserConnectionInfo { userId: string; isOnline: boolean; -} \ No newline at end of file + // Sent by servers with Keycloak auth; undefined for legacy userKey players. + name?: string; + isGuest?: boolean; +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index d83e80d..ec0ae1c 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -2,4 +2,9 @@ export * from './Game' export * from './GameMove' export * from './LoadingState' export * from './User' -export * from './UserConnectionInfo' \ No newline at end of file +export * from './UserConnectionInfo' +export * from './AuthUser' +export * from './GameHistory' +export * from './GameClock' +export * from './Bot' +export * from './Series' diff --git a/packages/core/src/rtmt/channel_names.ts b/packages/core/src/rtmt/channel_names.ts index af41238..e3559d8 100644 --- a/packages/core/src/rtmt/channel_names.ts +++ b/packages/core/src/rtmt/channel_names.ts @@ -1,5 +1,7 @@ export const channel_new_game = "new_game" export const channel_cancel_new_game = "cancel_new_game" +// client -> server: { level: BotLevel }, starts a game against the bot; ends a search for an opponent. +export const channel_new_bot_game = "new_bot_game" export const channel_on_game_start = "on_game_start" export const channel_game_move = "game_move" export const channel_on_game_update = "on_game_update" @@ -11,4 +13,28 @@ export const channel_ping = "ping" export const channel_pong = "pong" export const channel_on_user_connection_change = "channel_on_user_connection_change" export const channel_listen_game_events = "channel_listen_game_events" -export const channel_unlisten_game_events = "channel_unlisten_game_events" \ No newline at end of file +export const channel_unlisten_game_events = "channel_unlisten_game_events" +// Auth handshake. client -> server: { token } right after connecting and again after a token refresh; +// other messages are ignored until the server answers on_auth with { user }. +export const channel_auth = "auth" +export const channel_on_auth = "on_auth" +// server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED. +export const channel_on_auth_error = "on_auth_error" +export const CLOSE_AUTH_FAILED = 4001 + +// Home page previews. client -> server: subscribe_live_games / unsubscribe_live_games; +// server -> client: on_live_games with { games: LiveGameSummary[] }. +export const channel_subscribe_live_games = "subscribe_live_games" +export const channel_unsubscribe_live_games = "unsubscribe_live_games" +export const channel_on_live_games = "on_live_games" +// server -> client: { gameId }, when new_game is sent while the user has a game in progress. +export const channel_on_new_game_rejected = "on_new_game_rejected" + +// Rematches (docs/specs/rematch). client -> server: rematch { gameId } asks for a rematch of an ended game or accepts +// the opponent's request; decline_rematch { gameId } says no or withdraws one's own request. +// server -> client: on_rematch_request (RematchRequest) when the opponent asks; on_rematch_ended (RematchEnded) to both +// players when a request or the chance of a rematch ends. The next game arrives as on_game_start. +export const channel_rematch = "rematch" +export const channel_decline_rematch = "decline_rematch" +export const channel_on_rematch_request = "on_rematch_request" +export const channel_on_rematch_ended = "on_rematch_ended" diff --git a/packages/core/src/rtmt/client/rtmt.ts b/packages/core/src/rtmt/client/rtmt.ts index 0407cea..a17ddca 100644 --- a/packages/core/src/rtmt/client/rtmt.ts +++ b/packages/core/src/rtmt/client/rtmt.ts @@ -1,19 +1,25 @@ import { TinyEmitter } from "tiny-emitter" +import { AuthUser } from "../../models/AuthUser"; export type OnMessage = (message: Object) => any export type ConnectionState = "none" | "connecting" | "error" | "connected" | "closed" | "reconnecting"; -export type RtmtEventTypes = "open" | "close" | "connected" | "error" | "disconnected" | "message" | "connectionchange"; +export type RtmtEventTypes = "open" | "close" | "connected" | "error" | "disconnected" | "message" | "connectionchange" | "authenticated"; export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000; +// Supplies a current access token; called on every (re)connect. +export type TokenProvider = () => Promise; + export interface RTMT extends TinyEmitter { get connectionState(): ConnectionState; + // The user the server authenticated this connection as, once connected with a token. + get user(): AuthUser | undefined; sendMessage: (channel: string, message: Object) => void; addMessageListener(channel: string, callback: (message: any) => void): any; removeMessageListener(channel: string, callback: (message: any) => void): any; on(event: RtmtEventTypes, callback: (...value: any[]) => void): this; off(event: RtmtEventTypes, callback: (...value: any[]) => void): this; dispose(): any; -} \ No newline at end of file +} diff --git a/packages/core/src/rtmt/client/rtmt_websocket.ts b/packages/core/src/rtmt/client/rtmt_websocket.ts index 5cbf749..fefe147 100644 --- a/packages/core/src/rtmt/client/rtmt_websocket.ts +++ b/packages/core/src/rtmt/client/rtmt_websocket.ts @@ -1,7 +1,8 @@ -import { channel_ping, channel_pong } from "../channel_names"; +import { channel_auth, channel_on_auth, channel_on_auth_error, channel_ping, channel_pong } from "../channel_names"; import { decode, encode } from "../encode_decode_message"; -import { ConnectionState, RTMT, RTMT_WS_PING_INTERVAL, RTMT_WS_PING_INTERVAL_BUFFER_TIME, RtmtEventTypes } from "./rtmt"; +import { ConnectionState, RTMT, RTMT_WS_PING_INTERVAL, RTMT_WS_PING_INTERVAL_BUFFER_TIME, RtmtEventTypes, TokenProvider } from "./rtmt"; import { TinyEmitter } from "tiny-emitter"; +import { AuthUser } from "../../models/AuthUser"; const MESSAGE_CHANNEL_PREFIX = "message_channel"; @@ -9,13 +10,18 @@ export class RTMTWS extends TinyEmitter implements RTMT { private webSocket?: WebSocket; private pingTimeout?: number = undefined; private _connectionState: ConnectionState = "none"; - private userKey: string = ""; private url: string = ""; + private getToken?: TokenProvider; + private _user?: AuthUser; get connectionState(): ConnectionState { return this._connectionState; } + get user(): AuthUser | undefined { + return this._user; + } + protected setConnectionState(connectionState: ConnectionState) { this._connectionState = connectionState; this.emit("connectionchange", this._connectionState); @@ -38,13 +44,20 @@ export class RTMTWS extends TinyEmitter implements RTMT { this.webSocket = undefined; } - public connectWebSocket(url: string, userKey: string) { + // With getToken the connection authenticates with a token and is "connected" once the server accepts it. + public connectWebSocket(url: string, getToken?: TokenProvider) { this.setConnectionState("connecting"); - this.userKey = userKey; this.url = url; + this.getToken = getToken; this.createWebSocket(url); } + // Sends a refreshed token for the current connection. + public reauthenticate(token: string) { + if (this._connectionState !== "connected" || !this.getToken) return; + this.sendMessage(channel_auth, { token }); + } + private reconnectWebSocket() { this.setConnectionState("reconnecting"); this.disposeWebSocket(); @@ -53,10 +66,30 @@ export class RTMTWS extends TinyEmitter implements RTMT { protected onWebSocketOpen(webSocket: WebSocket) { this.webSocket = webSocket; - this.setConnectionState("connected"); console.info("(RTMT) WebSocket has opened"); this.heartbeat(); - this.emit("open"); + if (!this.getToken) { + this.setConnectionState("connected"); + this.emit("open"); + return; + } + this.getToken().then((token) => { + if (this.webSocket === webSocket) this.sendMessage(channel_auth, { token }); + }).catch((error) => { + console.error("(RTMT) Could not get a token", error); + this.emit("error", error); + webSocket.close(); + }); + } + + private onAuthenticated(user: AuthUser) { + const isNewConnection = this._connectionState !== "connected"; + this._user = user; + this.emit("authenticated", user); + if (isNewConnection) { + this.setConnectionState("connected"); + this.emit("open"); + } } protected onWebSocketMessage(webSocket: WebSocket, event: any) { @@ -105,6 +138,15 @@ export class RTMTWS extends TinyEmitter implements RTMT { this.sendMessage(channel_pong, {}); return; } + if (channel === channel_on_auth) { + this.onAuthenticated((message as { user: AuthUser }).user); + return; + } + if (channel === channel_on_auth_error) { + // The server closes the socket next; reconnecting fetches a fresh token. + console.error("(RTMT) Authentication failed", message); + return; + } // TODO: Maybe we should warn if there is not any listener for channel this.emit(MESSAGE_CHANNEL_PREFIX + channel, message); } diff --git a/packages/core/src/theme/ThemeManager.ts b/packages/core/src/theme/ThemeManager.ts index 95ad66b..39dfba6 100644 --- a/packages/core/src/theme/ThemeManager.ts +++ b/packages/core/src/theme/ThemeManager.ts @@ -13,11 +13,14 @@ export type ThemeManagerEvents = "themechange"; export class ThemeManager extends TinyEmitter { private _theme: Theme; - private _storage: any; - constructor(storage: Storage) { + private _storage: Storage; + private _themes: Theme[]; + // The first theme is the default. + constructor(storage: Storage, themeList: Theme[] = themes) { super(); this._storage = storage; - this._theme = this.readFromLocalStorage() || lightTheme; + this._themes = themeList; + this._theme = this.readFromLocalStorage() || themeList[0]; } public get theme() { @@ -31,17 +34,17 @@ export class ThemeManager extends TinyEmitter { } private writetToLocalStorage(value: Theme) { - this._storage.set(THEME_ID, value.id); + this._storage.setString(THEME_ID, value.id); } private readFromLocalStorage(): Theme | undefined { const themeID = this._storage.getString(THEME_ID); - const theme = themes.find((eachTheme: Theme) => themeID === eachTheme.id); + const theme = this._themes.find((eachTheme: Theme) => themeID === eachTheme.id); return theme; } public get themes(): Theme[] { - return themes; + return this._themes; } public on(event: ThemeManagerEvents, callback: (...value: any[]) => void): this { diff --git a/packages/core/src/tutorial/MoveOutcome.ts b/packages/core/src/tutorial/MoveOutcome.ts new file mode 100644 index 0000000..d965e92 --- /dev/null +++ b/packages/core/src/tutorial/MoveOutcome.ts @@ -0,0 +1,108 @@ +import { + CommonMancalaGame, + GAME_STEP_BOARD_CLEARED, + GAME_STEP_LAST_STONE_IN_BANK, +} from "mancala.js"; +import { CaptureKind, getCapturedStones, getCaptureKind, getSowingPath } from "../history/MoveHistory"; + +const PLAYER1 = "player1"; +const PLAYER2 = "player2"; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +// Where a move's last stone landed, from the mover's point of view. +export type Landing = "ownStore" | "ownPit" | "opponentPit"; + +// The immediate effect of playing a pit, worked out by the engine (docs/specs/tutorial). +export interface MoveOutcome { + // Board index that was played; 0-5 for player 1, 7-12 for player 2. + pitIndex: number; + player1Moved: boolean; + // Board indexes the stones were dropped into, in order. + path: number[]; + landing: Landing; + // Stones in the landing pit before the last stone arrived. + stonesBeforeLanding: number; + extraTurn: boolean; + capture?: CaptureKind; + // Stones taken from the opponent's pit by a capture. + captured: number; + // Stones the mover's store gained. + storeGain: number; + endsGame: boolean; + // Stones in every pit after the move. + board: number[]; +} + +export const isPlayer1Pit = (index: number) => index >= 0 && index < PLAYER1_STORE; +export const isPlayer2Pit = (index: number) => index > PLAYER1_STORE && index < PLAYER2_STORE; + +// Plays the pit on a copy of the board with the real rules. Throws for a store or an empty pit. +export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome { + const player1Moved = isPlayer1Pit(pitIndex); + if (!player1Moved && !isPlayer2Pit(pitIndex)) throw new Error(`Not a pit: ${pitIndex}`); + if (!(board[pitIndex] > 0)) throw new Error(`Pit ${pitIndex} is empty`); + const game = new CommonMancalaGame("move-outcome", PLAYER1, PLAYER2); + board.forEach((stoneCount, index) => (game.board.pits[index].stoneCount = stoneCount)); + const mover = player1Moved ? PLAYER1 : PLAYER2; + game.turnPlayerId = mover; + game.moveByPlayerPit(mover, player1Moved ? pitIndex : pitIndex - PLAYER1_STORE - 1); + const gameSteps = game.history[game.history.length - 1].gameSteps; + const after = game.board.getStoneArray(); + const path = getSowingPath(board, pitIndex); + const last = path[path.length - 1]; + const ownStore = player1Moved ? PLAYER1_STORE : PLAYER2_STORE; + const isOwnPit = player1Moved ? isPlayer1Pit : isPlayer2Pit; + const dropsBeforeLast = path.slice(0, -1).filter((i) => i === last).length; + return { + pitIndex, + player1Moved, + path, + landing: last === ownStore ? "ownStore" : isOwnPit(last) ? "ownPit" : "opponentPit", + stonesBeforeLanding: (last === pitIndex ? 0 : board[last]) + dropsBeforeLast, + extraTurn: gameSteps.some((step) => step.type === GAME_STEP_LAST_STONE_IN_BANK), + capture: getCaptureKind(gameSteps), + captured: getCapturedStones(board, pitIndex, gameSteps), + storeGain: after[ownStore] - board[ownStore], + endsGame: gameSteps.some((step) => step.type === GAME_STEP_BOARD_CLEARED), + board: after, + }; +} + +export type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame"; + +export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean { + switch (task) { + case "anyMove": return true; + case "extraTurn": return outcome.extraTurn; + case "evenCapture": return outcome.capture === "even"; + case "emptyPitCapture": return outcome.capture === "emptyPit"; + case "endGame": return outcome.endsGame; + } +} + +export type Mover = "you" | "bot"; + +type Translate = (key: string, options?: Record) => string; + +// What happened in a move and why, in one or two sentences (lessons and hints). +export function explainMove(outcome: MoveOutcome, mover: Mover, t: Translate): string { + const who = mover === "you" ? "You" : "Bot"; + const sentences: string[] = []; + if (outcome.capture === "even") { + sentences.push(t(`MoveExplainEven${who}`, { count: outcome.captured })); + } else if (outcome.capture === "emptyPit") { + sentences.push(t(`MoveExplainEmptyPit${who}`, { count: outcome.captured })); + } else if (outcome.landing === "ownStore") { + // A move that ends the game gives no turn to play. + if (!outcome.endsGame) sentences.push(t(`MoveExplainExtraTurn${who}`)); + } else if (outcome.landing === "opponentPit") { + sentences.push(t(`MoveExplainOdd${who}`, { count: outcome.stonesBeforeLanding + 1 })); + } else if (outcome.stonesBeforeLanding === 0) { + sentences.push(t(`MoveExplainEmptyOpposite${who}`)); + } else { + sentences.push(t(`MoveExplainOwnPit${who}`)); + } + if (outcome.endsGame) sentences.push(t("MoveExplainGameOver")); + return sentences.join(" "); +} diff --git a/packages/core/src/tutorial/Rules.ts b/packages/core/src/tutorial/Rules.ts new file mode 100644 index 0000000..936e052 --- /dev/null +++ b/packages/core/src/tutorial/Rules.ts @@ -0,0 +1,83 @@ +import { LessonTask } from "./MoveOutcome"; + +export type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock"; + +// A rule shown on a board: the position, player 1 (bottom) to move, and the pit played, if any. +export interface RuleExample { + board: number[]; + pitIndex?: number; +} + +// The rule taught on a prepared position (player 1, bottom, to move) with a task. +export interface Lesson { + board: number[]; + task: LessonTask; + taskKey: string; +} + +export interface Rule { + id: RuleId; + titleKey: string; + // At most three sentences. + textKeys: string[]; + example?: RuleExample; + lesson?: Lesson; +} + +const START = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]; +const SOWING = [4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2]; +const EVEN_CAPTURE = [4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1]; +const EMPTY_PIT_CAPTURE = [2, 0, 4, 4, 4, 4, 3, 4, 4, 4, 4, 5, 4, 2]; +const END_OF_GAME = [0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18]; + +const texts = (id: string, count: number) => Array.from({ length: count }, (_, i) => `Rule${id}Text${i + 1}`); + +// The rules of the game in the order they are taught (docs/specs/tutorial). Every board here is checked against the +// engine by the tests; the boards after a move are never written by hand. +export const RULES: Rule[] = [ + { + id: "board", titleKey: "RuleBoardTitle", textKeys: texts("Board", 3), + example: { board: START }, + lesson: { board: START, task: "anyMove", taskKey: "LessonTaskAnyMove" }, + }, + { + id: "sowing", titleKey: "RuleSowingTitle", textKeys: texts("Sowing", 3), + example: { board: SOWING, pitIndex: 0 }, + lesson: { board: SOWING, task: "anyMove", taskKey: "LessonTaskSowing" }, + }, + { + id: "extraTurn", titleKey: "RuleExtraTurnTitle", textKeys: texts("ExtraTurn", 2), + example: { board: START, pitIndex: 3 }, + lesson: { board: START, task: "extraTurn", taskKey: "LessonTaskExtraTurn" }, + }, + { + id: "evenCapture", titleKey: "RuleEvenCaptureTitle", textKeys: texts("EvenCapture", 2), + example: { board: EVEN_CAPTURE, pitIndex: 5 }, + lesson: { board: EVEN_CAPTURE, task: "evenCapture", taskKey: "LessonTaskEvenCapture" }, + }, + { + id: "emptyPitCapture", titleKey: "RuleEmptyPitCaptureTitle", textKeys: texts("EmptyPitCapture", 2), + example: { board: EMPTY_PIT_CAPTURE, pitIndex: 0 }, + lesson: { board: EMPTY_PIT_CAPTURE, task: "emptyPitCapture", taskKey: "LessonTaskEmptyPitCapture" }, + }, + { + id: "endOfGame", titleKey: "RuleEndOfGameTitle", textKeys: texts("EndOfGame", 3), + example: { board: END_OF_GAME, pitIndex: 5 }, + lesson: { board: END_OF_GAME, task: "endGame", taskKey: "LessonTaskEndGame" }, + }, + { + id: "clock", titleKey: "RuleClockTitle", textKeys: texts("Clock", 2), + }, +]; + +export const LESSON_RULES: Rule[] = RULES.filter((rule) => rule.lesson); + +export function getRule(id: string | undefined): Rule | undefined { + return RULES.find((rule) => rule.id === id); +} + +// The lesson after the given one, or undefined after the last. +export function getNextLessonRule(id: RuleId): Rule | undefined { + const index = LESSON_RULES.findIndex((rule) => rule.id === id); + return index >= 0 ? LESSON_RULES[index + 1] : undefined; +} diff --git a/packages/core/src/tutorial/TutorialStorage.ts b/packages/core/src/tutorial/TutorialStorage.ts new file mode 100644 index 0000000..4a944a1 --- /dev/null +++ b/packages/core/src/tutorial/TutorialStorage.ts @@ -0,0 +1,45 @@ +import { Storage } from "../storage/storage"; +import { RuleId } from "./Rules"; + +// What the tutorial remembers on the device (docs/specs/tutorial, R2.6, R3.4, R4). +const COMPLETED_LESSONS_KEY = "tutorialCompletedLessons"; +const BANNER_HIDDEN_KEY = "tutorialBannerHidden"; +const HINTS_KEY = "botGameHints"; +const GAME_PLAYED_KEY = "gamePlayed"; + +export function getCompletedLessons(storage: Storage): RuleId[] { + try { + const saved = JSON.parse(storage.getString(COMPLETED_LESSONS_KEY) ?? "[]"); + return Array.isArray(saved) ? saved : []; + } catch { + return []; + } +} + +export function completeLesson(storage: Storage, id: RuleId): void { + const completed = getCompletedLessons(storage); + if (!completed.includes(id)) storage.setString(COMPLETED_LESSONS_KEY, JSON.stringify([...completed, id])); +} + +export function hideTutorialBanner(storage: Storage): void { + storage.setString(BANNER_HIDDEN_KEY, "true"); +} + +export function markGamePlayed(storage: Storage): void { + storage.setString(GAME_PLAYED_KEY, "true"); +} + +// For newcomers only: nobody played a game on this device, opened the tutorial or dismissed the banner (R4). +export function shouldShowTutorialBanner(storage: Storage): boolean { + return storage.getString(BANNER_HIDDEN_KEY) !== "true" && storage.getString(GAME_PLAYED_KEY) !== "true" + && getCompletedLessons(storage).length === 0; +} + +// Hints in games against the bot; off until turned on. +export function getHintsEnabled(storage: Storage): boolean { + return storage.getString(HINTS_KEY) === "true"; +} + +export function setHintsEnabled(storage: Storage, enabled: boolean): void { + storage.setString(HINTS_KEY, String(enabled)); +} diff --git a/packages/core/src/tutorial/index.ts b/packages/core/src/tutorial/index.ts new file mode 100644 index 0000000..d5458f3 --- /dev/null +++ b/packages/core/src/tutorial/index.ts @@ -0,0 +1,3 @@ +export * from './MoveOutcome' +export * from './Rules' +export * from './TutorialStorage' diff --git a/packages/core/src/util/PlayerUtil.ts b/packages/core/src/util/PlayerUtil.ts new file mode 100644 index 0000000..1c169d6 --- /dev/null +++ b/packages/core/src/util/PlayerUtil.ts @@ -0,0 +1,19 @@ +import { getBotLevel, getBotLevelTextKey } from "../models/Bot"; + +const GUEST_USERNAME_PREFIX = "guest-"; + +// Name to show for a player, or undefined if the server sent none (legacy userKey players). +// Guests show as e.g. "Guest 4F2A" instead of their generated username, the bot as e.g. "Bot · Medium". +export function getPlayerDisplayName( + player: { userId?: string, name?: string, isGuest?: boolean } | undefined, + t: (key: string) => string +): string | undefined { + const botLevel = getBotLevel(player?.userId); + if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`; + if (!player?.name) return undefined; + if (player.isGuest) { + const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name; + return `${t("Guest")} ${suffix.slice(0, 4).toUpperCase()}`; + } + return player.name; +} diff --git a/packages/core/src/util/index.ts b/packages/core/src/util/index.ts index a096cdb..c5b3418 100644 --- a/packages/core/src/util/index.ts +++ b/packages/core/src/util/index.ts @@ -1 +1,2 @@ -export * from './ColorUtil' \ No newline at end of file +export * from './ColorUtil' +export * from './PlayerUtil' diff --git a/packages/core/src/viewmodel/PitViewModel.ts b/packages/core/src/viewmodel/PitViewModel.ts index 7af6e0b..e845b7f 100644 --- a/packages/core/src/viewmodel/PitViewModel.ts +++ b/packages/core/src/viewmodel/PitViewModel.ts @@ -1,18 +1,25 @@ +// Why a pit is marked during a move animation: a stone was sown into it, stones were captured from it, the last stone +// landed in a store, or the player picked it and the move is on its way to the server. +export type PitHighlight = "sow" | "capture" | "store" | "selected"; + export class PitViewModel { id: string; stoneCount: number; stoneColor: string; pitColor: string; + highlight?: PitHighlight; constructor( id: string, stoneCount: number, stoneColor: string, - pitColor: string + pitColor: string, + highlight?: PitHighlight ) { this.id = id; this.stoneCount = stoneCount; this.stoneColor = stoneColor; this.pitColor = pitColor; + this.highlight = highlight; } -} +} \ No newline at end of file diff --git a/packages/core/tests/animation/PitAnimator.test.ts b/packages/core/tests/animation/PitAnimator.test.ts new file mode 100644 index 0000000..7a3f3af --- /dev/null +++ b/packages/core/tests/animation/PitAnimator.test.ts @@ -0,0 +1,68 @@ +import { CommonMancalaGame, MancalaGame } from "mancala.js"; +import { PitAnimator } from "../../src/animation/PitAnimator"; +import { Game } from "../../src/models"; +import { ThemeManager } from "../../src/theme"; +import { BoardViewModel } from "../../src/viewmodel/BoardViewModel"; + +const P1 = "player-1"; +const P2 = "player-2"; + +const memoryStorage = () => { + const values = new Map(); + return { + getString: (key: string) => values.get(key), + setString: (key: string, value: string) => void values.set(key, value), + deleteString: (key: string) => void values.delete(key), + }; +}; + +const asGame = (mancalaGame: MancalaGame): Game => ({ + id: mancalaGame.id, + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: P1, isOnline: true }, + user2ConnectionInfo: { userId: P2, isOnline: true }, + }, +}); + +// Plays the move on a copy of the game and returns the stones the animator shows after each step. +const animateMove = (setUp: (game: MancalaGame) => void, playerId: string, pit: number): number[][] => { + jest.useFakeTimers(); + const createGame = () => { + const game = new CommonMancalaGame("game-1", P1, P2); + game.turnPlayerId = playerId; + setUp(game); + return game; + }; + const before = createGame(); + const after = createGame(); + after.moveByPlayerPit(playerId, pit); + + const frames: number[][] = []; + const animator = new PitAnimator(new ThemeManager(memoryStorage()), (boardViewModel: BoardViewModel) => + frames.push(boardViewModel.pits.map((pitViewModel) => pitViewModel.stoneCount)), 10); + animator.setNewGame(asGame(before)); + frames.length = 0; + animator.setUpdatedGame(asGame(after)); + jest.advanceTimersByTime(10 * (after.history[0].gameSteps.length + 1)); + jest.useRealTimers(); + return frames.slice(1); +}; + +describe("PitAnimator", () => { + it("empties the played pit when a single stone moves on", () => { + for (const pit of [0, 3]) { + const frames = animateMove((game) => { + game.board.pits[pit].stoneCount = 1; + }, P1, pit); + expect(frames[0][pit]).toBe(0); + expect(frames[0][pit + 1]).toBe(5); + } + }); + + it("empties the played pit and sows from it when several stones move", () => { + const frames = animateMove(() => undefined, P1, 0); + expect(frames[0].slice(0, 4)).toEqual([1, 4, 4, 4]); + expect(frames[3].slice(0, 4)).toEqual([1, 5, 5, 5]); + }); +}); diff --git a/packages/core/tests/clock/Clock.test.ts b/packages/core/tests/clock/Clock.test.ts new file mode 100644 index 0000000..4c01529 --- /dev/null +++ b/packages/core/tests/clock/Clock.test.ts @@ -0,0 +1,136 @@ +import { CommonMancalaGame } from "mancala.js"; +import { ClockSync, formatClock, getClockAtMove, getClockTimes } from "../../src/clock"; +import { Game, GameClock, GameRecord, MoveRecord } from "../../src/models"; + +const P1 = "player-1"; +const P2 = "player-2"; +const MINUTE = 60_000; + +const clock = (overrides: Partial = {}): GameClock => + ({ initialMs: 5 * MINUTE, player1Ms: 5 * MINUTE, player2Ms: 5 * MINUTE, turnStartedAt: 1_000_000, ...overrides }); + +const liveGame = (turnPlayerId: string, gameClock: GameClock | undefined, serverTime?: number): Game => { + const mancalaGame = new CommonMancalaGame("game-1", P1, P2); + mancalaGame.turnPlayerId = turnPlayerId; + return { + id: "game-1", + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: P1, isOnline: true }, + user2ConnectionInfo: { userId: P2, isOnline: true }, + }, + clock: gameClock, + serverTime, + }; +}; + +const move = (playerId: string, remainingMs?: number): MoveRecord => + ({ playerId, moveIndex: 0, boardSnapshot: [], gameSteps: [], at: 0, remainingMs }); + +const record = (overrides: Partial): GameRecord => ({ + id: "game-1", + player1: { userId: P1, isGuest: true }, + player2: { userId: P2, isGuest: true }, + board: [], + result: "player1", + endReason: "completed", + startedAt: 0, + endedAt: 0, + moveCount: 0, + peakSpectators: 0, + totalSpectators: 0, + moves: [], + clockInitialMs: 5 * MINUTE, + ...overrides, +}); + +describe("getClockTimes", () => { + it("deducts the time since the turn started from the running seat only", () => { + expect(getClockTimes(clock(), "player1", 1_000_000 + 20_000)).toEqual({ player1Ms: 280_000, player2Ms: 300_000 }); + expect(getClockTimes(clock(), "player2", 1_000_000 + 20_000)).toEqual({ player1Ms: 300_000, player2Ms: 280_000 }); + }); + + it("does not count down when no clock runs", () => { + expect(getClockTimes(clock({ turnStartedAt: undefined }), "player1", 5_000_000)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + expect(getClockTimes(clock(), undefined, 5_000_000)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + }); + + it("never goes below zero", () => { + expect(getClockTimes(clock({ player1Ms: 1_000 }), "player1", 1_000_000 + 60_000).player1Ms).toBe(0); + }); +}); + +describe("formatClock", () => { + it("shows minutes and seconds, rounding up", () => { + expect(formatClock(300_000)).toBe("5:00"); + expect(formatClock(299_001)).toBe("5:00"); + expect(formatClock(299_000)).toBe("4:59"); + expect(formatClock(9_500)).toBe("0:10"); + expect(formatClock(1)).toBe("0:01"); + expect(formatClock(0)).toBe("0:00"); + expect(formatClock(-5)).toBe("0:00"); + }); +}); + +describe("ClockSync", () => { + it("counts down the running clock in server time, whatever the local clock says", () => { + const sync = new ClockSync(); + const localTimeAtReceive = 1_000_000 + 10 * MINUTE; // the device clock is 10 minutes fast + sync.update(liveGame(P2, clock({ turnStartedAt: 1_000_000 }), 1_000_000 + 5_000), localTimeAtReceive); + + expect(sync.isRunning).toBe(true); + expect(sync.getTimes(localTimeAtReceive)).toEqual({ player1Ms: 300_000, player2Ms: 295_000, runningSeat: "player2" }); + expect(sync.getTimes(localTimeAtReceive + 3_000)).toEqual({ player1Ms: 300_000, player2Ms: 292_000, runningSeat: "player2" }); + }); + + it("stops with the clocks the server sends when the game ends", () => { + const sync = new ClockSync(); + sync.update(liveGame(P1, clock(), 1_000_000), 1_000_000); + sync.stop(clock({ player1Ms: 0, turnStartedAt: undefined })); + + expect(sync.isRunning).toBe(false); + expect(sync.getTimes(9_000_000)).toEqual({ player1Ms: 0, player2Ms: 300_000, runningSeat: undefined }); + }); + + it("does not run once the game has ended", () => { + const game = liveGame(P1, clock(), 1_000_000); + game.mancalaGame.state = "ended"; + const sync = new ClockSync(); + sync.update(game, 1_000_000); + + expect(sync.isRunning).toBe(false); + expect(sync.getTimes(2_000_000)?.player1Ms).toBe(300_000); + }); + + it("has no times when the server sends no clock", () => { + const sync = new ClockSync(); + sync.update(liveGame(P1, undefined), 0); + expect(sync.getTimes()).toBeUndefined(); + expect(sync.isRunning).toBe(false); + }); +}); + +describe("getClockAtMove", () => { + const moves = [move(P1, 290_000), move(P2, 280_000), move(P2, 270_000), move(P1, 250_000)]; + + it("starts with the initial time", () => { + expect(getClockAtMove(record({ moves }), 0)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + }); + + it("shows each player's time after their last move so far", () => { + expect(getClockAtMove(record({ moves }), 1)).toEqual({ player1Ms: 290_000, player2Ms: 300_000 }); + expect(getClockAtMove(record({ moves }), 3)).toEqual({ player1Ms: 290_000, player2Ms: 270_000 }); + expect(getClockAtMove(record({ moves }), 4)).toEqual({ player1Ms: 250_000, player2Ms: 270_000 }); + expect(getClockAtMove(record({ moves }), 99)).toEqual({ player1Ms: 250_000, player2Ms: 270_000 }); + }); + + it("shows the player who ran out of time at zero in the final position", () => { + const timedOut = record({ moves, endReason: "timeout", result: "player1" }); + expect(getClockAtMove(timedOut, 4)).toEqual({ player1Ms: 250_000, player2Ms: 0 }); + expect(getClockAtMove(timedOut, 3)).toEqual({ player1Ms: 290_000, player2Ms: 270_000 }); + }); + + it("is undefined for games saved before the game clock", () => { + expect(getClockAtMove(record({ moves: [move(P1)], clockInitialMs: undefined }), 1)).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts new file mode 100644 index 0000000..e76fb28 --- /dev/null +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -0,0 +1,181 @@ +import { CommonMancalaGame, GAME_STEP_GAME_MOVE, MancalaGame } from "mancala.js"; +import { createGameAtMove, describeMove, getBoardAfterMove, getInitialBoard, getSowingPath, PlayedMove } from "../../src/history"; +import { Game } from "../../src/models"; + +const P1 = "player-1"; +const P2 = "player-2"; + +const newGame = (): MancalaGame => { + const game = new CommonMancalaGame("game-1", P1, P2); + game.turnPlayerId = P1; + return game; +}; + +const movesOf = (game: MancalaGame) => game.history as unknown as PlayedMove[]; + +const asGame = (mancalaGame: MancalaGame): Game => ({ + id: mancalaGame.id, + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: P1, isOnline: true }, + user2ConnectionInfo: { userId: P2, isOnline: true }, + }, +}); + +// Deterministic pseudo random numbers, so failures can be reproduced. +const seededRandom = (seed: number) => () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; +}; + +const playRandomGame = (seed: number): MancalaGame => { + const random = seededRandom(seed); + const game = newGame(); + while (game.state !== "ended") { + const playable = [0, 1, 2, 3, 4, 5].filter((pit) => + game.board.pits[game.getBoardIndexByPlayerId(game.turnPlayerId, pit)].stoneCount > 0); + game.moveByPlayerPit(game.turnPlayerId, playable[Math.floor(random() * playable.length)]); + } + return game; +}; + +describe("getBoardAfterMove", () => { + it("returns the start position for move 0", () => { + expect(getInitialBoard()).toEqual([4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]); + expect(getBoardAfterMove([], 0)).toEqual(getInitialBoard()); + }); + + it("returns the board after each move and clamps out of range move numbers", () => { + const game = playRandomGame(1); + const moves = movesOf(game); + moves.forEach((move, i) => expect(getBoardAfterMove(moves, i + 1)).toEqual(move.boardSnapshot)); + expect(getBoardAfterMove(moves, moves.length + 5)).toEqual(game.board.getStoneArray()); + expect(getBoardAfterMove(moves, -1)).toEqual(getInitialBoard()); + }); +}); + +describe("describeMove", () => { + it("describes a move that ends in the player's own store", () => { + const game = newGame(); + game.moveByPlayerPit(P1, 3); // 4 stones from the 4th pit: pits 4, 5, 6 and the store + expect(describeMove(movesOf(game), 1)).toEqual({ + number: 1, playerId: P1, pit: 4, extraTurn: true, capture: false, captured: 0, storeGain: 1, gameOver: false, + }); + }); + + it("counts pits from player 2's own side", () => { + const game = newGame(); + game.moveByPlayerPit(P1, 0); + game.moveByPlayerPit(P2, 1); + const description = describeMove(movesOf(game), 2); + expect(description.playerId).toBe(P2); + expect(description.pit).toBe(2); + }); + + it("reports a capture and the stones it adds to the store", () => { + const game = newGame(); + game.moveByPlayerPit(P1, 0); + // Set up the position after move 1: P1's single stone in pit 0 lands in the empty pit 1, + // whose opposite pit 11 holds 5 stones. + game.board.pits.forEach((pit) => (pit.stoneCount = 0)); + game.board.pits[0].stoneCount = 1; + game.board.pits[11].stoneCount = 5; + game.board.pits[4].stoneCount = 1; // keep both sides from running out of stones, + game.board.pits[12].stoneCount = 1; // which would end the game with this move + game.turnPlayerId = P1; + game.history[0].boardSnapshot = game.board.getStoneArray(); + game.moveByPlayerPit(P1, 0); + expect(describeMove(movesOf(game), 2)).toMatchObject({ pit: 1, capture: true, captureKind: "emptyPit", captured: 5, storeGain: 6 }); + }); + + it("marks only the last move of a finished game as game over, and store gains add up to the final stores", () => { + for (let seed = 1; seed <= 25; seed++) { + const game = playRandomGame(seed); + const moves = movesOf(game); + const descriptions = moves.map((_, i) => describeMove(moves, i + 1)); + expect(descriptions.filter((d) => d.gameOver).map((d) => d.number)).toEqual([moves.length]); + const gainOf = (playerId: string) => descriptions.filter((d) => d.playerId === playerId) + .reduce((sum, d) => sum + d.storeGain, 0); + const final = game.board.getStoneArray(); + // The sweep at the end can fill the store of the player who did not move, so compare the totals. + expect(gainOf(P1) + gainOf(P2)).toBeLessThanOrEqual(final[6] + final[13]); + expect(descriptions.every((d) => d.pit >= 1 && d.pit <= 6)).toBe(true); + } + }); + + it("rejects a move number that does not exist", () => { + expect(() => describeMove([], 1)).toThrow(); + }); +}); + +describe("createGameAtMove", () => { + it("rebuilds the position and history up to the move, without touching the original game", () => { + const played = playRandomGame(7); + const moves = movesOf(played); + const original = JSON.stringify(played.history); + + const atMove3 = createGameAtMove(asGame(played), moves, 3).mancalaGame; + expect(atMove3.board.getStoneArray()).toEqual(moves[2].boardSnapshot); + expect(atMove3.history).toHaveLength(3); + expect(atMove3.turnPlayerId).toBe(moves[3].playerId); + expect(atMove3.state).toBe("playing"); + expect(atMove3.board.pits[6].isBank).toBe(true); + + const atEnd = createGameAtMove(asGame(played), moves, moves.length).mancalaGame; + expect(atEnd.state).toBe("ended"); + expect(atEnd.board.getStoneArray()).toEqual(played.board.getStoneArray()); + + atMove3.history[0].gameSteps.push({ index: 0, type: "x", data: null }); + expect(JSON.stringify(played.history)).toBe(original); + }); + + it("returns the start position for move 0", () => { + const played = playRandomGame(3); + const start = createGameAtMove(asGame(played), movesOf(played), 0).mancalaGame; + expect(start.board.getStoneArray()).toEqual(getInitialBoard()); + expect(start.history).toHaveLength(0); + }); +}); + +describe("getSowingPath", () => { + it("starts with the played pit, or the next pit for a single stone", () => { + const stones = getInitialBoard(); + expect(getSowingPath(stones, 2)).toEqual([2, 3, 4, 5]); + stones[4] = 1; + expect(getSowingPath(stones, 4)).toEqual([5]); + }); + + it("passes the player's own store, skips the opponent's and wraps around the board", () => { + const stones = getInitialBoard(); + stones[12] = 4; + expect(getSowingPath(stones, 12)).toEqual([12, 13, 0, 1]); + stones[5] = 10; + expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 0, 1]); + }); + + it("is empty for an empty pit or a store", () => { + const stones = getInitialBoard(); + stones[3] = 0; + expect(getSowingPath(stones, 3)).toEqual([]); + expect(getSowingPath(stones, 6)).toEqual([]); + }); + + it("matches the pits the engine sows into", () => { + const random = seededRandom(7); + for (let gameNumber = 0; gameNumber < 20; gameNumber++) { + const game = newGame(); + while (game.state !== "ended") { + const before = game.board.getStoneArray(); + const playable = [0, 1, 2, 3, 4, 5].filter((pit) => + before[game.getBoardIndexByPlayerId(game.turnPlayerId, pit)] > 0); + const pit = playable[Math.floor(random() * playable.length)]; + const boardIndex = game.getBoardIndexByPlayerId(game.turnPlayerId, pit); + game.moveByPlayerPit(game.turnPlayerId, pit); + const sown = game.history[game.history.length - 1].gameSteps + .filter((step) => step.type === GAME_STEP_GAME_MOVE) + .map((step) => game.board.getPitIndexCircularly(step.index)); + expect(getSowingPath(before, boardIndex)).toEqual(sown); + } + } + }); +}); diff --git a/packages/core/tests/models/Bot.test.ts b/packages/core/tests/models/Bot.test.ts new file mode 100644 index 0000000..e711d97 --- /dev/null +++ b/packages/core/tests/models/Bot.test.ts @@ -0,0 +1,55 @@ +import { BOT_LEVELS, getBotLevel, getBotLevelTextKey, getLastBotLevel, isBotId, isBotLevel, setLastBotLevel } from "../../src/models"; +import { en, tr } from "../../src/localization"; +import { getPlayerDisplayName } from "../../src/util"; + +describe("bot ids", () => { + it("recognises bot ids and their levels", () => { + expect(isBotId("bot:medium")).toBe(true); + expect(isBotId("kc:alice")).toBe(false); + expect(isBotId(undefined)).toBe(false); + expect(getBotLevel("bot:master")).toBe("master"); + expect(getBotLevel("bot:impossible")).toBeUndefined(); + expect(getBotLevel("kc:alice")).toBeUndefined(); + }); + + it("checks levels", () => { + expect(isBotLevel("hard")).toBe(true); + expect(isBotLevel("impossible")).toBe(false); + expect(isBotLevel(3)).toBe(false); + }); + + it("has a name for every level in both languages", () => { + for (const level of BOT_LEVELS) { + const key = getBotLevelTextKey(level) as keyof typeof en; + expect(en[key]).toBeTruthy(); + expect(tr[key as keyof typeof tr]).toBeTruthy(); + } + }); +}); + +describe("last bot level", () => { + it("is Medium until one is chosen, then the chosen one", () => { + const values = new Map(); + const storage = { getString: (key: string) => values.get(key), setString: (key: string, value: string) => { values.set(key, value); } }; + expect(getLastBotLevel(storage)).toBe("medium"); + setLastBotLevel(storage, "master"); + expect(getLastBotLevel(storage)).toBe("master"); + values.set("botLevel", "impossible"); + expect(getLastBotLevel(storage)).toBe("medium"); + }); +}); + +describe("getPlayerDisplayName", () => { + const t = (key: string) => (tr as Record)[key] ?? key; + + it("shows the bot with its level", () => { + expect(getPlayerDisplayName({ userId: "bot:medium", name: "Bot", isGuest: false }, t)).toBe("Bot · Orta"); + expect(getPlayerDisplayName({ userId: "bot:easy" }, t)).toBe("Bot · Kolay"); + }); + + it("still shows people and guests as before", () => { + expect(getPlayerDisplayName({ userId: "kc:alice", name: "alice", isGuest: false }, t)).toBe("alice"); + expect(getPlayerDisplayName({ userId: "kc:g", name: "guest-4f2a9b", isGuest: true }, t)).toBe("Misafir 4F2A"); + expect(getPlayerDisplayName({ userId: "0b9f6d1e" }, t)).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/models/Series.test.ts b/packages/core/tests/models/Series.test.ts new file mode 100644 index 0000000..2802655 --- /dev/null +++ b/packages/core/tests/models/Series.test.ts @@ -0,0 +1,30 @@ +import { getSeriesScore, isSeriesOver, SeriesGame } from "../../src/models"; +import { en, tr } from "../../src/localization"; + +const game = (result: SeriesGame["result"]): SeriesGame => + ({ gameId: "g", firstSeat: "player1", result, endReason: "completed", player1Store: 24, player2Store: 24 }); + +describe("series", () => { + it("counts the wins of each seat, leaving out draws and games without a winner", () => { + const games = [game("player1"), game("player2"), game("draw"), game("player1"), game(null)]; + expect(getSeriesScore(games, "player1")).toEqual({ wins: 2, losses: 1 }); + expect(getSeriesScore(games, "player2")).toEqual({ wins: 1, losses: 2 }); + expect(getSeriesScore([], "player1")).toEqual({ wins: 0, losses: 0 }); + }); + + it("knows which endings of a request end the series", () => { + expect(["declined", "left", "closed"].every((reason) => isSeriesOver(reason as any))).toBe(true); + expect(["withdrawn", "expired", "offline"].some((reason) => isSeriesOver(reason as any))).toBe(false); + }); + + it("has every rematch text in English and Turkish", () => { + const keys = ["Rematch", "No", "RematchWaiting", "RematchRequested", "RematchDeclined", "RematchWithdrawn", + "RematchNoAnswer", "RematchRequestExpired", "RematchOpponentGone", "RematchClosed", "Series", + "SeriesScoreText", "SeriesGameNumber", "SeriesCurrentGame", "SeriesEarlierGames", "MovedFirst", "EndedOnTime", + "EndedByLeaving"]; + for (const key of keys) { + expect(en).toHaveProperty(key); + expect(tr).toHaveProperty(key); + } + }); +}); diff --git a/packages/core/tests/tutorial/Tutorial.test.ts b/packages/core/tests/tutorial/Tutorial.test.ts new file mode 100644 index 0000000..5ab0fb3 --- /dev/null +++ b/packages/core/tests/tutorial/Tutorial.test.ts @@ -0,0 +1,200 @@ +import { CommonMancalaGame } from "mancala.js"; +import { en, tr } from "../../src/localization"; +import { + completeLesson, + explainMove, + fulfilsTask, + getCompletedLessons, + getHintsEnabled, + getMoveOutcome, + getNextLessonRule, + hideTutorialBanner, + LESSON_RULES, + markGamePlayed, + MoveOutcome, + RULES, + setHintsEnabled, + shouldShowTutorialBanner, +} from "../../src/tutorial"; +import { describeMove, PlayedMove } from "../../src/history"; + +const player1Pits = (board: number[]) => [0, 1, 2, 3, 4, 5].filter((index) => board[index] > 0); + +const translator = (texts: Record) => (key: string, options?: Record) => { + const text = texts[key]; + if (text === undefined) throw new Error(`Missing text ${key}`); + return text.replace(/{{(\w+)}}/g, (_, name) => String(options?.[name])); +}; + +const memoryStorage = () => { + const values = new Map(); + return { + getString: (key: string) => values.get(key), + setString: (key: string, value: string) => { values.set(key, value); }, + deleteString: (key: string) => { values.delete(key); }, + }; +}; + +describe("lessons", () => { + it("teach every rule but the clock, in order", () => { + expect(LESSON_RULES.map((rule) => rule.id)) + .toEqual(["board", "sowing", "extraTurn", "evenCapture", "emptyPitCapture", "endOfGame"]); + expect(getNextLessonRule("sowing")?.id).toBe("extraTurn"); + expect(getNextLessonRule("endOfGame")).toBeUndefined(); + }); + + it.each(LESSON_RULES.map((rule) => [rule.id, rule.lesson!] as const))("%s can be done and failed", (_, lesson) => { + expect(lesson.board.reduce((a, b) => a + b, 0)).toBe(48); + const pits = player1Pits(lesson.board); + const results = pits.map((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit))); + expect(results).toContain(true); + if (lesson.task !== "anyMove" && pits.length > 1) expect(results).toContain(false); + }); + + it("are done by the moves the spec names", () => { + const fulfilling = (id: string) => { + const lesson = LESSON_RULES.find((rule) => rule.id === id)!.lesson!; + return player1Pits(lesson.board).filter((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit))); + }; + expect(fulfilling("extraTurn")).toEqual([3]); + expect(fulfilling("evenCapture")).toEqual([5]); + expect(fulfilling("emptyPitCapture")).toEqual([0]); + expect(fulfilling("endOfGame")).toEqual([5]); + }); +}); + +describe("rule examples", () => { + const example = (id: string) => { + const rule = RULES.find((r) => r.id === id)!; + return getMoveOutcome(rule.example!.board, rule.example!.pitIndex!); + }; + + it("show what their rule is about", () => { + const sowing = example("sowing"); + expect(sowing.path).toEqual([0, 1, 2, 3]); + expect(example("extraTurn").extraTurn).toBe(true); + expect(example("evenCapture")).toMatchObject({ capture: "even", captured: 4, storeGain: 5 }); + expect(example("emptyPitCapture")).toMatchObject({ capture: "emptyPit", captured: 5, storeGain: 6 }); + expect(example("endOfGame")).toMatchObject({ endsGame: true, extraTurn: true, storeGain: 10 }); + expect(example("endOfGame").board).toEqual([0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18]); + }); + + it("moves a single stone to the next pit", () => { + const single = getMoveOutcome([4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2], 2); + expect(single.path).toEqual([3]); + expect(single).toMatchObject({ landing: "ownPit", stonesBeforeLanding: 4 }); + }); + + it("has a title and texts in both languages", () => { + for (const rule of RULES) { + for (const key of [rule.titleKey, ...rule.textKeys, ...(rule.lesson ? [rule.lesson.taskKey] : [])]) { + expect(en).toHaveProperty(key); + expect(tr).toHaveProperty(key); + } + expect(rule.textKeys.length).toBeLessThanOrEqual(3); + } + }); +}); + +describe("getMoveOutcome", () => { + it("matches the engine in random games, for both players", () => { + let seed = 7; + const random = (n: number) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; }; + for (let gameNumber = 0; gameNumber < 300; gameNumber++) { + const game = new CommonMancalaGame("g", "p1", "p2"); + game.turnPlayerId = random(2) ? "p1" : "p2"; + const moves: PlayedMove[] = []; + const isEnded = () => (game.state as string) === "ended"; + while (!isEnded()) { + const before = game.board.getStoneArray(); + const player1 = game.turnPlayerId === "p1"; + const first = player1 ? 0 : 7; + const pits = [0, 1, 2, 3, 4, 5].filter((pit) => before[first + pit] > 0); + const pit = pits[random(pits.length)]; + const outcome = getMoveOutcome(before, first + pit); + const mover = game.turnPlayerId; + game.moveByPlayerPit(mover, pit); + expect(outcome.board).toEqual(game.board.getStoneArray()); + if (!isEnded()) expect(outcome.extraTurn).toBe(game.turnPlayerId === mover); + expect(outcome.endsGame).toBe(isEnded()); + moves.push({ playerId: mover, moveIndex: first + pit, boardSnapshot: game.board.getStoneArray(), + gameSteps: game.history[game.history.length - 1].gameSteps }); + const description = describeMove(moves, moves.length); + expect(description.captured).toBe(outcome.captured); + expect(description.captureKind).toBe(outcome.capture); + } + } + }); + + it("refuses stores and empty pits", () => { + expect(() => getMoveOutcome([4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0], 6)).toThrow(); + expect(() => getMoveOutcome([0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0], 0)).toThrow(); + }); +}); + +describe("explainMove", () => { + const outcome = (board: number[], pit: number): MoveOutcome => getMoveOutcome(board, pit); + const start = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]; + + it("says why in Turkish and English", () => { + const t = translator(tr); + expect(explainMove(outcome(start, 3), "you", t)).toBe("Son taşın hazinene düştü: bir kez daha oynuyorsun."); + expect(explainMove(outcome(start, 4), "you", t)) + .toBe("Son taşın rakibin kuyusunda 5 taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti."); + expect(explainMove(outcome(start, 0), "you", t)).toBe("Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti."); + expect(explainMove(outcome([4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1], 5), "you", translator(en))) + .toBe("Your last stone made an opponent's pit even: you took 4 stones."); + expect(explainMove(outcome([0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18], 5), "you", translator(en))) + .toBe("One side is empty, so the game is over."); + }); + + it("explains the bot's moves from the player's side", () => { + // The bot is player 2: pit 7 with 2 stones ends in its empty pit 8, opposite player 1's pit 4. + const board = [4, 4, 4, 4, 3, 4, 0, 2, 0, 4, 4, 4, 4, 9]; + expect(explainMove(outcome(board, 7), "bot", translator(tr))) + .toBe("Botun son taşı kendi boş kuyusuna düştü: onu ve karşısındaki 3 taşını aldı."); + }); + + it("has every sentence in both languages", () => { + for (const texts of [en, tr]) { + for (const who of ["You", "Bot"]) { + for (const kind of ["ExtraTurn", "Even", "EmptyPit", "Odd", "OwnPit", "EmptyOpposite"]) { + expect(texts).toHaveProperty(`MoveExplain${kind}${who}`); + } + } + } + }); +}); + +describe("tutorial storage", () => { + it("remembers completed lessons once each", () => { + const storage = memoryStorage(); + expect(getCompletedLessons(storage)).toEqual([]); + completeLesson(storage, "sowing"); + completeLesson(storage, "sowing"); + completeLesson(storage, "board"); + expect(getCompletedLessons(storage)).toEqual(["sowing", "board"]); + storage.setString("tutorialCompletedLessons", "not json"); + expect(getCompletedLessons(storage)).toEqual([]); + }); + + it("shows the banner to newcomers only", () => { + const dismissed = memoryStorage(); + expect(shouldShowTutorialBanner(dismissed)).toBe(true); + hideTutorialBanner(dismissed); + expect(shouldShowTutorialBanner(dismissed)).toBe(false); + const played = memoryStorage(); + markGamePlayed(played); + expect(shouldShowTutorialBanner(played)).toBe(false); + const learning = memoryStorage(); + completeLesson(learning, "board"); + expect(shouldShowTutorialBanner(learning)).toBe(false); + }); + + it("keeps hints off until turned on", () => { + const storage = memoryStorage(); + expect(getHintsEnabled(storage)).toBe(false); + setHintsEnabled(storage, true); + expect(getHintsEnabled(storage)).toBe(true); + }); +}); diff --git a/packages/core/yarn.lock b/packages/core/yarn.lock index 318200d..4fc3c3f 100644 --- a/packages/core/yarn.lock +++ b/packages/core/yarn.lock @@ -2334,10 +2334,9 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../mancala.js": + version "0.0.0" + uid "" merge-stream@^2.0.0: version "2.0.0" diff --git a/packages/mancala.js/src/core/Board.ts b/packages/mancala.js/src/core/Board.ts index 0e383c1..063186f 100644 --- a/packages/mancala.js/src/core/Board.ts +++ b/packages/mancala.js/src/core/Board.ts @@ -107,19 +107,34 @@ export class Board { `StoneNotFound => index : ${index} stoneCount = ${pit.stoneCount}` ); } - const stepCount = pit.stoneCount; - this.pits[index].stoneCount = 0; - const startIndex = stepCount === 1 ? index + 1 : index; - this.moveInternal(startIndex, stepCount); + const path = this.getSowingPath(index); + pit.stoneCount = 0; + this.fireOnGameMoveStart(path[0]); + path.forEach((pathIndex) => this.onGameMoveStep(pathIndex)); + this.fireOnGameMoveEnd(path[path.length - 1]); } - private moveInternal(startIndex: number, stepCount: number) { - this.fireOnGameMoveStart(startIndex); - for (let i = startIndex; i < startIndex + stepCount; i++) { - this.onGameMoveStep(i); + // Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the first + // stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped. The last + // index is where the last stone lands. Empty for a bank or an empty pit. + public getSowingPath( + index: number, + stoneCount = this.pits[index].stoneCount + ): number[] { + this.checkIndeAndMaybeThrowError(index); + if (stoneCount <= 0 || this.checkPitTypeIsBankByIndex(index)) return []; + const opponentBankIndex = + this.getPitTypeByIndex(index) === 'player1Pit' + ? this.player2BankIndex() + : this.player1BankIndex(); + const path: number[] = []; + let current = + stoneCount === 1 ? this.getNextPitIndexCircularly(index) : index; + while (path.length < stoneCount) { + if (current !== opponentBankIndex) path.push(current); + current = this.getNextPitIndexCircularly(current); } - const stopIndex = startIndex + stepCount - 1; - this.fireOnGameMoveEnd(stopIndex); + return path; } public onGameMoveStep(index: number) { diff --git a/packages/mancala.js/tests/Board.test.ts b/packages/mancala.js/tests/Board.test.ts index 85ca668..5b76ccd 100644 --- a/packages/mancala.js/tests/Board.test.ts +++ b/packages/mancala.js/tests/Board.test.ts @@ -103,9 +103,33 @@ describe('Board Test', () => { expect(board.getStoneArray()).toStrictEqual(initialBoard); board.move(5); expect(board.getStoneArray()).toStrictEqual([ - 5, 5, 5, 5, 5, 2, 1, 5, 5, 5, 5, 5, 5, 1 + 5, 5, 5, 5, 5, 2, 2, 5, 5, 5, 5, 5, 5, 0 ]); }); + test('move skips the opponent bank for both players', () => { + const board = new Board(6, 4); + board.pits[12].stoneCount = 10; + board.move(12); + expect(board.getStoneArray()).toStrictEqual([ + 5, 5, 5, 5, 5, 5, 0, 5, 5, 4, 4, 4, 1, 1 + ]); + }); + test('test getSowingPath', () => { + const board = new Board(6, 4); + expect(board.getSowingPath(0)).toStrictEqual([0, 1, 2, 3]); + expect(board.getSowingPath(0, 1)).toStrictEqual([1]); + expect(board.getSowingPath(5, 2)).toStrictEqual([5, 6]); + expect(board.getSowingPath(5, 9)).toStrictEqual([ + 5, 6, 7, 8, 9, 10, 11, 12, 0 + ]); + expect(board.getSowingPath(12, 3)).toStrictEqual([12, 13, 0]); + expect(board.getSowingPath(11, 10)).toStrictEqual([ + 11, 12, 13, 0, 1, 2, 3, 4, 5, 7 + ]); + expect(board.getSowingPath(0, 0)).toStrictEqual([]); + expect(board.getSowingPath(6)).toStrictEqual([]); + expect(board.getSowingPath(13)).toStrictEqual([]); + }); test('test getOppositePitIndex', () => { const board = new Board(6, 4); diff --git a/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts b/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts index 0d07a12..1698a30 100644 --- a/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts +++ b/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts @@ -33,7 +33,7 @@ describe('GRDoubleStoneInPit Test', () => { expect(game.history[game.history.length - 1].gameSteps).toStrictEqual([ new GameStep(12, GAME_STEP_GAME_MOVE), new GameStep(13, GAME_STEP_GAME_MOVE), - new GameStep(14, GAME_STEP_GAME_MOVE), + new GameStep(0, GAME_STEP_GAME_MOVE), new GameStep(0, GAME_STEP_DOUBLE_STONE_IN_PIT, { pitIndex: 0, bankIndex: 13