From ec833b4ec59e8c57b6f3b4642f0fa2244d298077 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:00:44 +0300 Subject: [PATCH 1/8] docs(backend): spec the move to Java and Quarkus Requirements, design and tasks for rewriting the backend in Java 25 with Quarkus while keeping the protocol, the API and the stored data. --- docs/specs/quarkus-backend/design.md | 137 +++++++++++++++++++++ docs/specs/quarkus-backend/requirements.md | 88 +++++++++++++ docs/specs/quarkus-backend/tasks.md | 20 +++ 3 files changed, 245 insertions(+) create mode 100644 docs/specs/quarkus-backend/design.md create mode 100644 docs/specs/quarkus-backend/requirements.md create mode 100644 docs/specs/quarkus-backend/tasks.md diff --git a/docs/specs/quarkus-backend/design.md b/docs/specs/quarkus-backend/design.md new file mode 100644 index 0000000..bd84774 --- /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/ AuthConfig, TokenVerifier, KeycloakTokenVerifier, 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, RootResource, BearerAuth, exception mapper + 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..a163408 --- /dev/null +++ b/docs/specs/quarkus-backend/requirements.md @@ -0,0 +1,88 @@ +# Quarkus backend: requirements + +Status: implemented on branch `feature/quarkus-backend`. +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..fe996d8 --- /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. +- [ ] 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). +- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, + `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. +- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, + `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported + unit tests. +- [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, + `ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services. +- [ ] 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. +- [ ] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose, + remove the Node sources; build the image. +- [ ] 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`. From f0ffd6ba870542da846ef25efb57e5fff4d2774a Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:06:43 +0300 Subject: [PATCH 2/8] feat(backend): add the Quarkus project with a Java port of the engine Quarkus 3.40 on Java 25, built with the Maven wrapper. The engine is a line-by-line port of packages/mancala.js; a parity test replays games recorded with the TypeScript engine, including illegal moves, and compares every board, turn, error and the game JSON. --- apps/backend-quarkus/.gitignore | 9 + .../.mvn/wrapper/maven-wrapper.properties | 4 + apps/backend-quarkus/mvnw | 295 ++++++++++++++++++ apps/backend-quarkus/mvnw.cmd | 189 +++++++++++ apps/backend-quarkus/pom.xml | 108 +++++++ .../scripts/record-engine-games.js | 61 ++++ .../java/app/playmancala/engine/Bank.java | 15 + .../java/app/playmancala/engine/Board.java | 221 +++++++++++++ .../app/playmancala/engine/CommonBoard.java | 10 + .../playmancala/engine/CommonMancalaGame.java | 18 ++ .../java/app/playmancala/engine/GameRule.java | 12 + .../app/playmancala/engine/GameState.java | 9 + .../java/app/playmancala/engine/GameStep.java | 13 + .../app/playmancala/engine/MancalaGame.java | 213 +++++++++++++ .../playmancala/engine/MoveHistoryItem.java | 49 +++ .../main/java/app/playmancala/engine/Pit.java | 40 +++ .../java/app/playmancala/engine/PitType.java | 16 + .../engine/rules/GRClearBoardAtEnd.java | 44 +++ .../engine/rules/GRDoubleStoneInPit.java | 37 +++ .../engine/rules/GRLastStoneInBank.java | 23 ++ .../engine/rules/GRLastStoneInEmptyPit.java | 40 +++ .../main/java/app/playmancala/model/Json.java | 33 ++ .../src/main/resources/application.properties | 0 .../playmancala/engine/EngineParityTest.java | 58 ++++ .../playmancala/engine/MancalaGameTest.java | 184 +++++++++++ .../test/resources/engine/recorded-games.json | 1 + docs/specs/quarkus-backend/tasks.md | 2 +- 27 files changed, 1703 insertions(+), 1 deletion(-) create mode 100644 apps/backend-quarkus/.gitignore create mode 100644 apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties create mode 100755 apps/backend-quarkus/mvnw create mode 100644 apps/backend-quarkus/mvnw.cmd create mode 100644 apps/backend-quarkus/pom.xml create mode 100644 apps/backend-quarkus/scripts/record-engine-games.js create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/Bank.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/Board.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonBoard.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonMancalaGame.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/GameRule.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/GameState.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/GameStep.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/MancalaGame.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/MoveHistoryItem.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/Pit.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/PitType.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/Json.java create mode 100644 apps/backend-quarkus/src/main/resources/application.properties create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/engine/EngineParityTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/engine/MancalaGameTest.java create mode 100644 apps/backend-quarkus/src/test/resources/engine/recorded-games.json diff --git a/apps/backend-quarkus/.gitignore b/apps/backend-quarkus/.gitignore new file mode 100644 index 0000000..f7e7fea --- /dev/null +++ b/apps/backend-quarkus/.gitignore @@ -0,0 +1,9 @@ +target/ +.idea +*.iml +.vscode +.factorypath + +# Written at runtime +access*.log +crashes/ diff --git a/apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties b/apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..a3c407f --- /dev/null +++ b/apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,4 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce diff --git a/apps/backend-quarkus/mvnw b/apps/backend-quarkus/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/mvnw.cmd b/apps/backend-quarkus/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/pom.xml b/apps/backend-quarkus/pom.xml new file mode 100644 index 0000000..5f59152 --- /dev/null +++ b/apps/backend-quarkus/pom.xml @@ -0,0 +1,108 @@ + + + 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 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest-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} + + + + + + diff --git a/apps/backend-quarkus/scripts/record-engine-games.js b/apps/backend-quarkus/scripts/record-engine-games.js new file mode 100644 index 0000000..e9ec499 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/Bank.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/Bank.java new file mode 100644 index 0000000..29d9c72 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/Board.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/Board.java new file mode 100644 index 0000000..f53986c --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/CommonBoard.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonBoard.java new file mode 100644 index 0000000..c04efc0 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/CommonMancalaGame.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonMancalaGame.java new file mode 100644 index 0000000..bb57c10 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/GameRule.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/GameRule.java new file mode 100644 index 0000000..9780c85 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/GameState.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/GameState.java new file mode 100644 index 0000000..a20c354 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/GameStep.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/GameStep.java new file mode 100644 index 0000000..342d870 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/MancalaGame.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/MancalaGame.java new file mode 100644 index 0000000..32dafe6 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/MoveHistoryItem.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/MoveHistoryItem.java new file mode 100644 index 0000000..f43f859 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/Pit.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/Pit.java new file mode 100644 index 0000000..cd38b62 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/PitType.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/PitType.java new file mode 100644 index 0000000..8a468b3 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java new file mode 100644 index 0000000..a331de8 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java new file mode 100644 index 0000000..107ed8d --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java new file mode 100644 index 0000000..85c3b7f --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java b/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java new file mode 100644 index 0000000..5dd4183 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/Json.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/Json.java new file mode 100644 index 0000000..58b0686 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/resources/application.properties b/apps/backend-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/engine/EngineParityTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/engine/EngineParityTest.java new file mode 100644 index 0000000..fb8e484 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/engine/MancalaGameTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/engine/MancalaGameTest.java new file mode 100644 index 0000000..c0de01d --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/resources/engine/recorded-games.json b/apps/backend-quarkus/src/test/resources/engine/recorded-games.json new file mode 100644 index 0000000..cb6c3fc --- /dev/null +++ b/apps/backend-quarkus/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/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index fe996d8..8c2c1ef 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -3,7 +3,7 @@ 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. -- [ ] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port +- [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). - [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. From f0246cca61862700438481267f09a9f5784323b6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:09:16 +0300 Subject: [PATCH 3/8] feat(backend): add the wire types, the game loop and token verification Records for the JSON the clients read, leaving out only the fields TypeScript leaves undefined. GameLoop is the single thread that owns live state; ManualScheduler replaces Jest's fake timers in tests. Keycloak tokens are verified with jose4j and the same checks as before. --- .../auth/InvalidTokenException.java | 7 ++ .../auth/KeycloakTokenVerifier.java | 87 ++++++++++++++ .../app/playmancala/auth/TokenVerifier.java | 8 ++ .../app/playmancala/loop/Cancellable.java | 6 + .../java/app/playmancala/loop/GameLoop.java | 88 ++++++++++++++ .../java/app/playmancala/loop/Scheduler.java | 15 +++ .../java/app/playmancala/model/AuthUser.java | 11 ++ .../java/app/playmancala/model/Channels.java | 36 ++++++ .../app/playmancala/model/ClientGame.java | 18 +++ .../java/app/playmancala/model/EndReason.java | 20 ++++ .../model/FinishedGameSummary.java | 24 ++++ .../java/app/playmancala/model/GameClock.java | 12 ++ .../app/playmancala/model/GameEndMessage.java | 16 +++ .../app/playmancala/model/GameRecord.java | 36 ++++++ .../app/playmancala/model/GameResult.java | 18 +++ .../model/GameUsersConnectionInfo.java | 4 + .../playmancala/model/LiveGameSummary.java | 15 +++ .../app/playmancala/model/MoveRecord.java | 17 +++ .../app/playmancala/model/PlayerSummary.java | 15 +++ .../main/java/app/playmancala/model/Seat.java | 8 ++ .../playmancala/model/UserConnectionInfo.java | 15 +++ .../auth/KeycloakTokenVerifierTest.java | 112 ++++++++++++++++++ .../app/playmancala/loop/ManualScheduler.java | 85 +++++++++++++ .../playmancala/loop/ManualSchedulerTest.java | 26 ++++ docs/specs/quarkus-backend/tasks.md | 2 +- 25 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java new file mode 100644 index 0000000..a4915f0 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java new file mode 100644 index 0000000..825a8f6 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java new file mode 100644 index 0000000..26be99c --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/loop/Cancellable.java b/apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java new file mode 100644 index 0000000..2e5344e --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/loop/GameLoop.java b/apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java new file mode 100644 index 0000000..72a02f3 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/loop/Scheduler.java b/apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java new file mode 100644 index 0000000..c20381e --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/AuthUser.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java new file mode 100644 index 0000000..2b7dd37 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/Channels.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java new file mode 100644 index 0000000..3336683 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java @@ -0,0 +1,36 @@ +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"; + 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"; + + private Channels() { + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java new file mode 100644 index 0000000..f4ffebc --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java @@ -0,0 +1,18 @@ +package app.playmancala.model; + +import java.util.List; + +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. +public record ClientGame( + String id, + MancalaGame mancalaGame, + GameUsersConnectionInfo gameUsersConnectionInfo, + long startedAt, + List moveTimes, + int spectatorCount, + GameClock clock, + long serverTime) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java new file mode 100644 index 0000000..e419d04 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java new file mode 100644 index 0000000..afd6ead --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/GameClock.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java new file mode 100644 index 0000000..301b923 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java new file mode 100644 index 0000000..c300b97 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java @@ -0,0 +1,16 @@ +package app.playmancala.model; + +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. +@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" }) +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) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java new file mode 100644 index 0000000..325ab9c --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java @@ -0,0 +1,36 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it +// is a public id. +@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) +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) { + + 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-quarkus/src/main/java/app/playmancala/model/GameResult.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java new file mode 100644 index 0000000..43a3488 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java new file mode 100644 index 0000000..78976af --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java new file mode 100644 index 0000000..85088d1 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/MoveRecord.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java new file mode 100644 index 0000000..5685248 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java new file mode 100644 index 0000000..e9ee937 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/Seat.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java new file mode 100644 index 0000000..651f4d3 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java new file mode 100644 index 0000000..9916780 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java new file mode 100644 index 0000000..22d2eb6 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java b/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java new file mode 100644 index 0000000..8ee6b63 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java new file mode 100644 index 0000000..aac52b8 --- /dev/null +++ b/apps/backend-quarkus/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/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index 8c2c1ef..6551a56 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [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). -- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, +- [x] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. - [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported From 219b50b491fa4fdd8d1233e1d3d70f30527c2338 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:23:35 +0300 Subject: [PATCH 4/8] feat(backend): port the game logic to Java GameManager, matchmaking, the game clock, live game snapshots, saving finished games and live previews, ported from the Node backend with its tests. Snapshots keep the Node backend's format; a test restores one the Node backend wrote. Valkey writes and saves run off the game loop. --- apps/backend-quarkus/pom.xml | 1 + .../app/playmancala/game/BackendGame.java | 58 ++ .../java/app/playmancala/game/CrashLog.java | 50 ++ .../playmancala/game/GameClockScheduler.java | 32 + .../java/app/playmancala/game/GameClocks.java | 61 ++ .../app/playmancala/game/GameManager.java | 351 ++++++++++ .../app/playmancala/game/GameRecorder.java | 109 ++++ .../playmancala/game/GameSerialization.java | 157 +++++ .../java/app/playmancala/game/GameStore.java | 48 ++ .../java/app/playmancala/game/LiveGames.java | 93 +++ .../java/app/playmancala/game/MatchMaker.java | 39 ++ .../java/app/playmancala/game/PublicIds.java | 29 + .../history/GameHistoryRepository.java | 22 + .../app/playmancala/live/LiveGameStore.java | 24 + .../main/java/app/playmancala/rtmt/Rtmt.java | 24 + .../java/app/playmancala/fakes/FakeRtmt.java | 82 +++ .../fakes/InMemoryGameHistoryRepository.java | 48 ++ .../fakes/InMemoryLiveGameStore.java | 58 ++ .../game/GameClockSchedulerTest.java | 29 + .../app/playmancala/game/GameManagerTest.java | 606 ++++++++++++++++++ .../app/playmancala/game/LiveGamesTest.java | 126 ++++ docs/specs/quarkus-backend/tasks.md | 2 +- 22 files changed, 2048 insertions(+), 1 deletion(-) create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java diff --git a/apps/backend-quarkus/pom.xml b/apps/backend-quarkus/pom.xml index 5f59152..0569e61 100644 --- a/apps/backend-quarkus/pom.xml +++ b/apps/backend-quarkus/pom.xml @@ -100,6 +100,7 @@ org.jboss.logmanager.LogManager ${maven.home} + ${project.build.directory}/crashes diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java new file mode 100644 index 0000000..570bbf4 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java @@ -0,0 +1,58 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import app.playmancala.engine.MancalaGame; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.PlayerSummary; + +// 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; + + public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, + List allSpectatorIds, int peakSpectators, Map players, long startedAt, + List moveTimes, GameClock clock, List moveRemainingMs) { + 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); + } + + 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-quarkus/src/main/java/app/playmancala/game/CrashLog.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java new file mode 100644 index 0000000..6c2c1c0 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java new file mode 100644 index 0000000..6747859 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/game/GameClocks.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java new file mode 100644 index 0000000..b617989 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/game/GameManager.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java new file mode 100644 index 0000000..545155a --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java @@ -0,0 +1,351 @@ +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.LongSupplier; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; + +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.GameUsersConnectionInfo; +import app.playmancala.model.PlayerSummary; +import app.playmancala.model.Seat; +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); + + 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<>(); + + 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); + } + + private void listenRtmtMessages() { + rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { + 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.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 onGameMove(String userId, JsonNode gameMove) { + BackendGame game = gameStore.getGameByUser(userId); + if (game == null) { + LOG.debug("Game not found!"); + return; + } + 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; + } + JsonNode index = gameMove.path("index"); + if (!index.canConvertToInt() || !index.isIntegralNumber()) { + throw new IllegalStateException("Invalid move: " + gameMove); + } + int movesBefore = mancalaGame.getHistory().size(); + Seat moverSeat = seatOf(mancalaGame, userId); + mancalaGame.moveByPlayerPit(userId, index.asInt()); + 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(); + } + } 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()); + GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending), + ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId()); + 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(); + } + + // 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(); + } + 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) { + rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); + rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); + List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, 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()); + } + + public BackendGame createMancalaGame(String userId1, String userId2) { + MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); + mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? 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()); + gameStore.set(game.id, game); + gameStore.setGameByUser(userId1, game); + gameStore.setGameByUser(userId2, game); + return game; + } + + private PlayerSummary getPlayerInfo(String userId) { + 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, rtmt.isClientOnline(player1.userId())), + UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId()))); + } + + public void startGame(BackendGame game) { + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_START, toClientGame(game)); + sendUserConnectionInfo(game); + saveLiveGame(game); + notifyLiveGamesChanged(); + } + + 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 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-quarkus/src/main/java/app/playmancala/game/GameRecorder.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java new file mode 100644 index 0000000..4a9f3b1 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java new file mode 100644 index 0000000..7331021 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java @@ -0,0 +1,157 @@ +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 org.jboss.logging.Logger; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +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.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) { + } + + 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))); + } + + // 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); + } + + // 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()); + } + + // 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-quarkus/src/main/java/app/playmancala/game/GameStore.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java new file mode 100644 index 0000000..2387f78 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java @@ -0,0 +1,48 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +// 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); + } + + public void setGameByUser(String userId, BackendGame game) { + 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-quarkus/src/main/java/app/playmancala/game/LiveGames.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java new file mode 100644 index 0000000..440f886 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/game/MatchMaker.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java new file mode 100644 index 0000000..e9c87fd --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java new file mode 100644 index 0000000..9ee5652 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java @@ -0,0 +1,29 @@ +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; + +// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash +// instead. Keycloak 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)) { + 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-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java b/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java new file mode 100644 index 0000000..c9ae70f --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java b/apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java new file mode 100644 index 0000000..a843497 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java b/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java new file mode 100644 index 0000000..6a57eb2 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java new file mode 100644 index 0000000..111f98a --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java new file mode 100644 index 0000000..5d3bf27 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java new file mode 100644 index 0000000..2b3f788 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java new file mode 100644 index 0000000..6730c82 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java new file mode 100644 index 0000000..d925a3b --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java @@ -0,0 +1,606 @@ +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; + +// 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 sendsTheGameAsTheNodeBackendDid() { + BackendGame game = startGame(); + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes", + "spectatorCount", "clock", "serverTime"), 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()); + assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed", + "winnerId", ALICE.id())), 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()); + 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"), + fieldNames(written)); + } + } + + static List fieldNames(JsonNode node) { + return node.propertyStream().map(Map.Entry::getKey).toList(); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java new file mode 100644 index 0000000..037ade6 --- /dev/null +++ b/apps/backend-quarkus/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/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index 6551a56..10bd8a0 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. 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. -- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, +- [x] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported unit tests. - [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, From d86fca8c544c69381e0f346399dbf13ca8d05fd4 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:26:28 +0300 Subject: [PATCH 5/8] feat(backend): save games in Postgres and live state in Valkey The Node backend's migrations, runner, queries, keys and JSON, so the same database and Valkey serve either backend. Integration tests run Postgres and Valkey with Quarkus Dev Services, including a game row the Node backend wrote and an unreachable Valkey. --- .../history/GameHistoryService.java | 91 ++++++++ .../app/playmancala/history/Migrations.java | 100 +++++++++ .../PostgresGameHistoryRepository.java | 200 ++++++++++++++++++ .../main/java/app/playmancala/live/Cache.java | 14 ++ .../app/playmancala/live/ValkeyCache.java | 52 +++++ .../playmancala/live/ValkeyLiveGameStore.java | 87 ++++++++ .../src/main/resources/application.properties | 41 ++++ .../resources/db/migrations/001_games.sql | 27 +++ .../resources/db/migrations/002_clock.sql | 2 + .../PostgresGameHistoryRepositoryTest.java | 189 +++++++++++++++++ .../app/playmancala/live/ValkeyStoreTest.java | 131 ++++++++++++ docs/specs/quarkus-backend/tasks.md | 2 +- 12 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryService.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/history/Migrations.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/live/Cache.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyCache.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java create mode 100644 apps/backend-quarkus/src/main/resources/db/migrations/001_games.sql create mode 100644 apps/backend-quarkus/src/main/resources/db/migrations/002_clock.sql create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/live/ValkeyStoreTest.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryService.java b/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryService.java new file mode 100644 index 0000000..645d0e6 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/history/Migrations.java b/apps/backend-quarkus/src/main/java/app/playmancala/history/Migrations.java new file mode 100644 index 0000000..136b54c --- /dev/null +++ b/apps/backend-quarkus/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"); + + 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-quarkus/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java b/apps/backend-quarkus/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java new file mode 100644 index 0000000..b3ed987 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java @@ -0,0 +1,200 @@ +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.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) + VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0), + ?, ?::jsonb, ?::jsonb, ?, ?, ?) + 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())); + 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 " + 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")); + } + } + }); + } + + @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-quarkus/src/main/java/app/playmancala/live/Cache.java b/apps/backend-quarkus/src/main/java/app/playmancala/live/Cache.java new file mode 100644 index 0000000..d41dbf1 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/live/ValkeyCache.java b/apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyCache.java new file mode 100644 index 0000000..eced22f --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java b/apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java new file mode 100644 index 0000000..34a4455 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/resources/application.properties b/apps/backend-quarkus/src/main/resources/application.properties index e69de29..4875640 100644 --- a/apps/backend-quarkus/src/main/resources/application.properties +++ b/apps/backend-quarkus/src/main/resources/application.properties @@ -0,0 +1,41 @@ +# 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} + +# 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.connect-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-quarkus/src/main/resources/db/migrations/001_games.sql b/apps/backend-quarkus/src/main/resources/db/migrations/001_games.sql new file mode 100644 index 0000000..f5eb0fc --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/resources/db/migrations/002_clock.sql b/apps/backend-quarkus/src/main/resources/db/migrations/002_clock.sql new file mode 100644 index 0000000..10c4a38 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java new file mode 100644 index 0000000..dcf8bb9 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java @@ -0,0 +1,189 @@ +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.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"), 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 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-quarkus/src/test/java/app/playmancala/live/ValkeyStoreTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/live/ValkeyStoreTest.java new file mode 100644 index 0000000..f53217e --- /dev/null +++ b/apps/backend-quarkus/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/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index 10bd8a0..653c7ec 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported unit tests. -- [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, +- [x] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, `ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services. - [ ] 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. From 0c112eb71606d0743a031a39e6b1b87a7c71e31a Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:33:07 +0300 Subject: [PATCH 6/8] feat(backend): serve the WebSocket protocol and the HTTP API RtmtServer ports the WebSocket protocol (auth handshake, sockets per user, ping) and runs on the game loop; the websockets-next endpoint at / only forwards socket events. The HTTP routes, CORS and the access log match the Express app. Backend wires everything and restores live games before handling socket events. End-to-end tests play games over real sockets against Postgres and Valkey. --- apps/backend-quarkus/pom.xml | 6 +- .../main/java/app/playmancala/Backend.java | 186 ++++++++++ .../auth/TokenVerifierProducer.java | 23 ++ .../java/app/playmancala/http/BearerAuth.java | 35 ++ .../app/playmancala/http/ErrorMapper.java | 20 ++ .../app/playmancala/http/GameResource.java | 125 +++++++ .../app/playmancala/rtmt/RtmtEndpoint.java | 93 +++++ .../java/app/playmancala/rtmt/RtmtServer.java | 256 ++++++++++++++ .../java/app/playmancala/rtmt/RtmtSocket.java | 15 + .../src/main/resources/application.properties | 2 +- .../app/playmancala/fakes/InMemoryCache.java | 35 ++ .../history/GameHistoryServiceTest.java | 102 ++++++ .../java/app/playmancala/http/ApiTest.java | 221 ++++++++++++ .../app/playmancala/rtmt/RtmtServerTest.java | 324 ++++++++++++++++++ .../support/FakeTokenVerifier.java | 21 ++ .../app/playmancala/support/TestClient.java | 96 ++++++ docs/specs/quarkus-backend/tasks.md | 2 +- 17 files changed, 1559 insertions(+), 3 deletions(-) create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/Backend.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifierProducer.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/http/BearerAuth.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/http/ErrorMapper.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/http/GameResource.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtServer.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtSocket.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryCache.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/history/GameHistoryServiceTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/http/ApiTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/rtmt/RtmtServerTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/support/FakeTokenVerifier.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/support/TestClient.java diff --git a/apps/backend-quarkus/pom.xml b/apps/backend-quarkus/pom.xml index 0569e61..52f430f 100644 --- a/apps/backend-quarkus/pom.xml +++ b/apps/backend-quarkus/pom.xml @@ -38,7 +38,11 @@ io.quarkus - quarkus-rest-jackson + quarkus-rest + + + io.quarkus + quarkus-jackson io.quarkus diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/Backend.java b/apps/backend-quarkus/src/main/java/app/playmancala/Backend.java new file mode 100644 index 0000000..2386982 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/Backend.java @@ -0,0 +1,186 @@ +package app.playmancala; + +import java.sql.SQLException; +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.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.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; + + private GameLoop loop; + // Valkey writes, in order. + private ExecutorService storeWriter; + // Migrations and saving finished games. + private ScheduledExecutorService recorderIo; + // Token checks. + private ExecutorService verifier; + + 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(); + + 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); + 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(); + } + + 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-quarkus/src/main/java/app/playmancala/auth/TokenVerifierProducer.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifierProducer.java new file mode 100644 index 0000000..da25e80 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/http/BearerAuth.java b/apps/backend-quarkus/src/main/java/app/playmancala/http/BearerAuth.java new file mode 100644 index 0000000..cc69264 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/http/ErrorMapper.java b/apps/backend-quarkus/src/main/java/app/playmancala/http/ErrorMapper.java new file mode 100644 index 0000000..7b01516 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/http/GameResource.java b/apps/backend-quarkus/src/main/java/app/playmancala/http/GameResource.java new file mode 100644 index 0000000..9addfc3 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java b/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java new file mode 100644 index 0000000..de16978 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/rtmt/RtmtServer.java b/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtServer.java new file mode 100644 index 0000000..50eaa73 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/java/app/playmancala/rtmt/RtmtSocket.java b/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtSocket.java new file mode 100644 index 0000000..f8af2a2 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/main/resources/application.properties b/apps/backend-quarkus/src/main/resources/application.properties index 4875640..b55fd58 100644 --- a/apps/backend-quarkus/src/main/resources/application.properties +++ b/apps/backend-quarkus/src/main/resources/application.properties @@ -35,7 +35,7 @@ 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.connect-timeout=1S +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-quarkus/src/test/java/app/playmancala/fakes/InMemoryCache.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryCache.java new file mode 100644 index 0000000..f5a8148 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/history/GameHistoryServiceTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/history/GameHistoryServiceTest.java new file mode 100644 index 0000000..cbc75f7 --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/http/ApiTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/http/ApiTest.java new file mode 100644 index 0000000..8bb82bf --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/rtmt/RtmtServerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/rtmt/RtmtServerTest.java new file mode 100644 index 0000000..0d3d29f --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/support/FakeTokenVerifier.java b/apps/backend-quarkus/src/test/java/app/playmancala/support/FakeTokenVerifier.java new file mode 100644 index 0000000..797682c --- /dev/null +++ b/apps/backend-quarkus/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-quarkus/src/test/java/app/playmancala/support/TestClient.java b/apps/backend-quarkus/src/test/java/app/playmancala/support/TestClient.java new file mode 100644 index 0000000..89c362a --- /dev/null +++ b/apps/backend-quarkus/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/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index 653c7ec..d30c6a5 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. unit tests. - [x] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, `ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services. -- [ ] 6. **WebSocket and HTTP**: `RtmtServer` with unit tests, `RtmtEndpoint`, the HTTP resources, `Backend` +- [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. - [ ] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose, remove the Node sources; build the image. From 6aeff37eb886a3a767e0b8cbc097074bda515f93 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:47:40 +0300 Subject: [PATCH 7/8] feat(backend): replace the Node backend with the Quarkus backend The Quarkus project moves to apps/backend and the Express sources go. The image is built from the repo root as before, with Temurin 25, and runs as a non-root user on port 6000. Compose passes the database as a JDBC URL with the same MANCALA_DB_PASSWORD. --- .dockerignore | 1 + apps/backend-quarkus/.gitignore | 9 - apps/backend/.gitignore | 43 +- .../.mvn/wrapper/maven-wrapper.properties | 0 apps/backend/.vscode/launch.json | 25 - apps/backend/Dockerfile | 41 +- apps/backend/README.md | 82 - apps/backend/build_and_deploy.sh | 4 - apps/backend/jest.config.js | 6 - apps/{backend-quarkus => backend}/mvnw | 0 apps/{backend-quarkus => backend}/mvnw.cmd | 0 apps/backend/package.json | 44 - apps/{backend-quarkus => backend}/pom.xml | 0 .../scripts/record-engine-games.js | 0 apps/backend/src/auth/AuthConfig.ts | 16 - apps/backend/src/auth/AuthUser.ts | 1 - .../backend/src/auth/KeycloakTokenVerifier.ts | 72 - apps/backend/src/auth/TokenVerifier.ts | 13 - apps/backend/src/auth/index.ts | 4 - apps/backend/src/cache/Cache.ts | 40 - apps/backend/src/consts/config.ts | 9 - apps/backend/src/db/migrations/001_games.ts | 29 - apps/backend/src/db/migrations/002_clock.ts | 4 - apps/backend/src/db/migrations/index.ts | 13 - apps/backend/src/db/postgres.ts | 41 - apps/backend/src/game/GameClock.ts | 33 - apps/backend/src/game/GameClockScheduler.ts | 25 - apps/backend/src/game/GameCrashManager.ts | 38 - apps/backend/src/game/GameManager.ts | 402 -- apps/backend/src/game/GameRecorder.ts | 82 - apps/backend/src/game/GameSerialization.ts | 139 - apps/backend/src/game/LiveGames.ts | 89 - apps/backend/src/game/gamestore/GameStore.ts | 19 - .../src/game/gamestore/GameStoreImpl.ts | 50 - apps/backend/src/game/publicIds.ts | 11 - .../src/history/GameHistoryRepository.ts | 115 - .../backend/src/history/GameHistoryService.ts | 65 - apps/backend/src/index.ts | 86 - apps/backend/src/live/LiveGameStore.ts | 61 - apps/backend/src/live/valkey.ts | 33 - .../main/java/app/playmancala/Backend.java | 0 .../auth/InvalidTokenException.java | 0 .../auth/KeycloakTokenVerifier.java | 0 .../app/playmancala/auth/TokenVerifier.java | 0 .../auth/TokenVerifierProducer.java | 0 .../java/app/playmancala/engine/Bank.java | 0 .../java/app/playmancala/engine/Board.java | 0 .../app/playmancala/engine/CommonBoard.java | 0 .../playmancala/engine/CommonMancalaGame.java | 0 .../java/app/playmancala/engine/GameRule.java | 0 .../app/playmancala/engine/GameState.java | 0 .../java/app/playmancala/engine/GameStep.java | 0 .../app/playmancala/engine/MancalaGame.java | 0 .../playmancala/engine/MoveHistoryItem.java | 0 .../main/java/app/playmancala/engine/Pit.java | 0 .../java/app/playmancala/engine/PitType.java | 0 .../engine/rules/GRClearBoardAtEnd.java | 0 .../engine/rules/GRDoubleStoneInPit.java | 0 .../engine/rules/GRLastStoneInBank.java | 0 .../engine/rules/GRLastStoneInEmptyPit.java | 0 .../app/playmancala/game/BackendGame.java | 0 .../java/app/playmancala/game/CrashLog.java | 0 .../playmancala/game/GameClockScheduler.java | 0 .../java/app/playmancala/game/GameClocks.java | 0 .../app/playmancala/game/GameManager.java | 0 .../app/playmancala/game/GameRecorder.java | 0 .../playmancala/game/GameSerialization.java | 0 .../java/app/playmancala/game/GameStore.java | 0 .../java/app/playmancala/game/LiveGames.java | 0 .../java/app/playmancala/game/MatchMaker.java | 0 .../java/app/playmancala/game/PublicIds.java | 0 .../history/GameHistoryRepository.java | 0 .../history/GameHistoryService.java | 0 .../app/playmancala/history/Migrations.java | 0 .../PostgresGameHistoryRepository.java | 0 .../java/app/playmancala/http/BearerAuth.java | 0 .../app/playmancala/http/ErrorMapper.java | 0 .../app/playmancala/http/GameResource.java | 0 .../main/java/app/playmancala/live/Cache.java | 0 .../app/playmancala/live/LiveGameStore.java | 0 .../app/playmancala/live/ValkeyCache.java | 0 .../playmancala/live/ValkeyLiveGameStore.java | 0 .../app/playmancala/loop/Cancellable.java | 0 .../java/app/playmancala/loop/GameLoop.java | 0 .../java/app/playmancala/loop/Scheduler.java | 0 .../java/app/playmancala/model/AuthUser.java | 0 .../java/app/playmancala/model/Channels.java | 0 .../app/playmancala/model/ClientGame.java | 0 .../java/app/playmancala/model/EndReason.java | 0 .../model/FinishedGameSummary.java | 0 .../java/app/playmancala/model/GameClock.java | 0 .../app/playmancala/model/GameEndMessage.java | 0 .../app/playmancala/model/GameRecord.java | 0 .../app/playmancala/model/GameResult.java | 0 .../model/GameUsersConnectionInfo.java | 0 .../main/java/app/playmancala/model/Json.java | 0 .../playmancala/model/LiveGameSummary.java | 0 .../app/playmancala/model/MoveRecord.java | 0 .../app/playmancala/model/PlayerSummary.java | 0 .../main/java/app/playmancala/model/Seat.java | 0 .../playmancala/model/UserConnectionInfo.java | 0 .../main/java/app/playmancala/rtmt/Rtmt.java | 0 .../app/playmancala/rtmt/RtmtEndpoint.java | 0 .../java/app/playmancala/rtmt/RtmtServer.java | 0 .../java/app/playmancala/rtmt/RtmtSocket.java | 0 .../src/main/resources/application.properties | 0 .../resources/db/migrations/001_games.sql | 0 .../resources/db/migrations/002_clock.sql | 0 apps/backend/src/matchmaker/MatchMaker.ts | 7 - apps/backend/src/matchmaker/MatchMakerImpl.ts | 45 - apps/backend/src/models/Game.ts | 24 - .../backend/src/rtmt/encode_decode_message.ts | 15 - apps/backend/src/rtmt/rtmt.ts | 13 - apps/backend/src/rtmt/rtmt_websocket.ts | 239 - apps/backend/src/server/ExpressApp.ts | 113 - apps/backend/src/server/WebServer.ts | 21 - apps/backend/src/server/authMiddleware.ts | 21 - .../auth/KeycloakTokenVerifierTest.java | 0 .../playmancala/engine/EngineParityTest.java | 0 .../playmancala/engine/MancalaGameTest.java | 0 .../java/app/playmancala/fakes/FakeRtmt.java | 0 .../app/playmancala/fakes/InMemoryCache.java | 0 .../fakes/InMemoryGameHistoryRepository.java | 0 .../fakes/InMemoryLiveGameStore.java | 0 .../game/GameClockSchedulerTest.java | 0 .../app/playmancala/game/GameManagerTest.java | 0 .../app/playmancala/game/LiveGamesTest.java | 0 .../history/GameHistoryServiceTest.java | 0 .../PostgresGameHistoryRepositoryTest.java | 0 .../java/app/playmancala/http/ApiTest.java | 0 .../app/playmancala/live/ValkeyStoreTest.java | 0 .../app/playmancala/loop/ManualScheduler.java | 0 .../playmancala/loop/ManualSchedulerTest.java | 0 .../app/playmancala/rtmt/RtmtServerTest.java | 0 .../support/FakeTokenVerifier.java | 0 .../app/playmancala/support/TestClient.java | 0 .../test/resources/engine/recorded-games.json | 0 apps/backend/src/util/key_factory.ts | 6 - .../tests/auth/KeycloakTokenVerifier.test.ts | 61 - apps/backend/tests/game/FakeRtmt.ts | 53 - .../tests/game/GameClockScheduler.test.ts | 22 - apps/backend/tests/game/GameManager.test.ts | 462 -- apps/backend/tests/game/LiveGames.test.ts | 92 - apps/backend/tests/game/fakes.ts | 82 - .../PostgresGameHistoryRepository.test.ts | 127 - apps/backend/tests/live/ValkeyStore.test.ts | 113 - apps/backend/tests/mancala.test.ts | 14 - .../backend/tests/rtmt/rtmt_websocket.test.ts | 209 - apps/backend/tests/server/ExpressApp.test.ts | 210 - apps/backend/tests/support/NodeEnvironment.js | 19 - apps/backend/tests/support/containers.ts | 31 - apps/backend/tsconfig.json | 16 - apps/backend/yarn.lock | 4805 ----------------- apps/compose.yaml | 4 +- docs/specs/quarkus-backend/tasks.md | 2 +- 155 files changed, 33 insertions(+), 8538 deletions(-) delete mode 100644 apps/backend-quarkus/.gitignore rename apps/{backend-quarkus => backend}/.mvn/wrapper/maven-wrapper.properties (100%) delete mode 100644 apps/backend/.vscode/launch.json delete mode 100644 apps/backend/README.md delete mode 100644 apps/backend/build_and_deploy.sh delete mode 100644 apps/backend/jest.config.js rename apps/{backend-quarkus => backend}/mvnw (100%) rename apps/{backend-quarkus => backend}/mvnw.cmd (100%) delete mode 100644 apps/backend/package.json rename apps/{backend-quarkus => backend}/pom.xml (100%) rename apps/{backend-quarkus => backend}/scripts/record-engine-games.js (100%) delete mode 100644 apps/backend/src/auth/AuthConfig.ts delete mode 100644 apps/backend/src/auth/AuthUser.ts delete mode 100644 apps/backend/src/auth/KeycloakTokenVerifier.ts delete mode 100644 apps/backend/src/auth/TokenVerifier.ts delete mode 100644 apps/backend/src/auth/index.ts delete mode 100644 apps/backend/src/cache/Cache.ts delete mode 100644 apps/backend/src/consts/config.ts delete mode 100644 apps/backend/src/db/migrations/001_games.ts delete mode 100644 apps/backend/src/db/migrations/002_clock.ts delete mode 100644 apps/backend/src/db/migrations/index.ts delete mode 100644 apps/backend/src/db/postgres.ts delete mode 100644 apps/backend/src/game/GameClock.ts delete mode 100644 apps/backend/src/game/GameClockScheduler.ts delete mode 100644 apps/backend/src/game/GameCrashManager.ts delete mode 100644 apps/backend/src/game/GameManager.ts delete mode 100644 apps/backend/src/game/GameRecorder.ts delete mode 100644 apps/backend/src/game/GameSerialization.ts delete mode 100644 apps/backend/src/game/LiveGames.ts delete mode 100644 apps/backend/src/game/gamestore/GameStore.ts delete mode 100644 apps/backend/src/game/gamestore/GameStoreImpl.ts delete mode 100644 apps/backend/src/game/publicIds.ts delete mode 100644 apps/backend/src/history/GameHistoryRepository.ts delete mode 100644 apps/backend/src/history/GameHistoryService.ts delete mode 100644 apps/backend/src/index.ts delete mode 100644 apps/backend/src/live/LiveGameStore.ts delete mode 100644 apps/backend/src/live/valkey.ts rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/Backend.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/auth/InvalidTokenException.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/auth/TokenVerifier.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/auth/TokenVerifierProducer.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/Bank.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/Board.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/CommonBoard.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/CommonMancalaGame.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/GameRule.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/GameState.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/GameStep.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/MancalaGame.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/MoveHistoryItem.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/Pit.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/PitType.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/BackendGame.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/CrashLog.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/GameClockScheduler.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/GameClocks.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/GameManager.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/GameRecorder.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/GameSerialization.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/GameStore.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/LiveGames.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/MatchMaker.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/game/PublicIds.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/history/GameHistoryRepository.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/history/GameHistoryService.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/history/Migrations.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/http/BearerAuth.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/http/ErrorMapper.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/http/GameResource.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/live/Cache.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/live/LiveGameStore.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/live/ValkeyCache.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/loop/Cancellable.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/loop/GameLoop.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/loop/Scheduler.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/AuthUser.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/Channels.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/ClientGame.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/EndReason.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/FinishedGameSummary.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/GameClock.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/GameEndMessage.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/GameRecord.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/GameResult.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/Json.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/LiveGameSummary.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/MoveRecord.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/PlayerSummary.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/Seat.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/model/UserConnectionInfo.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/rtmt/Rtmt.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/rtmt/RtmtServer.java (100%) rename apps/{backend-quarkus => backend}/src/main/java/app/playmancala/rtmt/RtmtSocket.java (100%) rename apps/{backend-quarkus => backend}/src/main/resources/application.properties (100%) rename apps/{backend-quarkus => backend}/src/main/resources/db/migrations/001_games.sql (100%) rename apps/{backend-quarkus => backend}/src/main/resources/db/migrations/002_clock.sql (100%) delete mode 100644 apps/backend/src/matchmaker/MatchMaker.ts delete mode 100644 apps/backend/src/matchmaker/MatchMakerImpl.ts delete mode 100644 apps/backend/src/models/Game.ts delete mode 100644 apps/backend/src/rtmt/encode_decode_message.ts delete mode 100644 apps/backend/src/rtmt/rtmt.ts delete mode 100644 apps/backend/src/rtmt/rtmt_websocket.ts delete mode 100644 apps/backend/src/server/ExpressApp.ts delete mode 100644 apps/backend/src/server/WebServer.ts delete mode 100644 apps/backend/src/server/authMiddleware.ts rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/engine/EngineParityTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/engine/MancalaGameTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/fakes/FakeRtmt.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/fakes/InMemoryCache.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/game/GameClockSchedulerTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/game/GameManagerTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/game/LiveGamesTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/history/GameHistoryServiceTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/http/ApiTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/live/ValkeyStoreTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/loop/ManualScheduler.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/loop/ManualSchedulerTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/rtmt/RtmtServerTest.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/support/FakeTokenVerifier.java (100%) rename apps/{backend-quarkus => backend}/src/test/java/app/playmancala/support/TestClient.java (100%) rename apps/{backend-quarkus => backend}/src/test/resources/engine/recorded-games.json (100%) delete mode 100644 apps/backend/src/util/key_factory.ts delete mode 100644 apps/backend/tests/auth/KeycloakTokenVerifier.test.ts delete mode 100644 apps/backend/tests/game/FakeRtmt.ts delete mode 100644 apps/backend/tests/game/GameClockScheduler.test.ts delete mode 100644 apps/backend/tests/game/GameManager.test.ts delete mode 100644 apps/backend/tests/game/LiveGames.test.ts delete mode 100644 apps/backend/tests/game/fakes.ts delete mode 100644 apps/backend/tests/history/PostgresGameHistoryRepository.test.ts delete mode 100644 apps/backend/tests/live/ValkeyStore.test.ts delete mode 100644 apps/backend/tests/mancala.test.ts delete mode 100644 apps/backend/tests/rtmt/rtmt_websocket.test.ts delete mode 100644 apps/backend/tests/server/ExpressApp.test.ts delete mode 100644 apps/backend/tests/support/NodeEnvironment.js delete mode 100644 apps/backend/tests/support/containers.ts delete mode 100644 apps/backend/tsconfig.json delete mode 100644 apps/backend/yarn.lock diff --git a/.dockerignore b/.dockerignore index b49f95b..de82437 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ # Images for apps/backend and apps/frontend are built with the repo root as context. **/node_modules **/dist +**/target **/.parcel-cache .git apps/mobile diff --git a/apps/backend-quarkus/.gitignore b/apps/backend-quarkus/.gitignore deleted file mode 100644 index f7e7fea..0000000 --- a/apps/backend-quarkus/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -target/ -.idea -*.iml -.vscode -.factorypath - -# Written at runtime -access*.log -crashes/ diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 9750e23..f7e7fea 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -1,40 +1,9 @@ -lib-cov -*.seed -*.log -*.csv -*.dat -*.out -*.pid -*.gz -*.swp - -pids -logs -results -tmp - -# Build -public/css/main.css - -# Coverage reports -coverage - -# API keys and secrets -.env - -# Dependency directory -node_modules -bower_components - -# Editors +target/ .idea *.iml +.vscode +.factorypath -# OS metadata -.DS_Store -Thumbs.db - -# Ignore built ts files -dist/**/* - -crashes \ No newline at end of file +# Written at runtime +access*.log +crashes/ diff --git a/apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties b/apps/backend/.mvn/wrapper/maven-wrapper.properties similarity index 100% rename from apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties rename to apps/backend/.mvn/wrapper/maven-wrapper.properties diff --git a/apps/backend/.vscode/launch.json b/apps/backend/.vscode/launch.json deleted file mode 100644 index 8a9f849..0000000 --- a/apps/backend/.vscode/launch.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch Program", - "program": "${workspaceFolder}/src/index.ts", - "preLaunchTask": "tsc: build - tsconfig.json", - "outFiles": ["${workspaceFolder}/dist/**/*.js"] - }, - { - "type": "node", - "request": "launch", - "name": "Jest Current File", - "program": "${workspaceFolder}/node_modules/.bin/jest", - "args": ["${relativeFile}"], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "windows": { - "program": "${workspaceFolder}/node_modules/jest/bin/jest" - } - } - ] - } \ No newline at end of file diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 6d115d2..02eef4c 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,26 +1,29 @@ # Build from the repo root: docker build -f apps/backend/Dockerfile . -FROM node:lts-buster-slim +FROM eclipse-temurin:25.0.4_7-jdk-noble AS build + +# The Maven wrapper checks the .zip it downloads; without unzip it would fetch a .tar.gz that fails the check. +RUN apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# Dependencies first, so they are cached until the pom changes +COPY apps/backend/.mvn .mvn +COPY apps/backend/mvnw apps/backend/pom.xml ./ +RUN ./mvnw -B -q quarkus:go-offline + +COPY apps/backend/src src +RUN ./mvnw -B -q package -DskipTests + +FROM eclipse-temurin:25.0.4_7-jre-noble ENV PORT=6000 -WORKDIR /usr/src/app - -# Local packages the backend links to -COPY package.json package.json -COPY packages packages -RUN yarn build:packages - -WORKDIR /usr/src/app/apps/backend - -COPY apps/backend/package.json package.json -COPY apps/backend/yarn.lock yarn.lock - -RUN yarn - -COPY apps/backend . - -RUN yarn build +# The app writes access.log and crashes/ to its working directory. +RUN useradd --system --create-home mancala && install -d -o mancala /home/mancala/app +WORKDIR /home/mancala/app +COPY --from=build --chown=mancala /build/target/quarkus-app/ ./ +USER mancala EXPOSE ${PORT} -CMD [ "yarn", "start"] +CMD ["java", "-jar", "quarkus-run.jar"] diff --git a/apps/backend/README.md b/apps/backend/README.md deleted file mode 100644 index 9950c19..0000000 --- a/apps/backend/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Mancala Backend - -This is the backend code for the Mancala project, written in Express.js. - -## Getting Started - -### Install Packages - -```bash -yarn install -``` - -### Start in Development Mode - -```bash -yarn dev -``` - -### Start in Release Mode - -```bash -yarn build -yarn start -``` - -### Development Dependencies - -This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root. - -## Authentication - -Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token -over the WebSocket right after connecting: - -```json -{ "channel": "auth", "message": { "token": "" } } -``` - -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:`. - -| 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 | - -## 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_URL` | `postgres://mancala:mancala@localhost:5433/mancala` | Saved games; migrations run on startup | -| `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. - -## 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 | - -| 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 | - -Tests use Docker (testcontainers) for Postgres and Valkey. 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 10139cf..0000000 --- a/apps/backend/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - transform: {'^.+\\.ts?$': 'ts-jest'}, - testEnvironment: '/tests/support/NodeEnvironment.js', - testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - }; diff --git a/apps/backend-quarkus/mvnw b/apps/backend/mvnw similarity index 100% rename from apps/backend-quarkus/mvnw rename to apps/backend/mvnw diff --git a/apps/backend-quarkus/mvnw.cmd b/apps/backend/mvnw.cmd similarity index 100% rename from apps/backend-quarkus/mvnw.cmd rename to apps/backend/mvnw.cmd diff --git a/apps/backend/package.json b/apps/backend/package.json deleted file mode 100644 index 2b08a10..0000000 --- a/apps/backend/package.json +++ /dev/null @@ -1,44 +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": "link:../../packages/core", - "@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", - "iovalkey": "0.4.0", - "jose": "5.10.0", - "mancala.js": "link:../../packages/mancala.js", - "morgan": "^1.10.0", - "pg": "8.23.1", - "uuid": "^8.3.2", - "ws": "^7.5.0" - }, - "devDependencies": { - "@types/express": "^4.17.12", - "@types/jest": "^26.0.23", - "@types/node": "^15.12.4", - "@types/pg": "8.23.1", - "jest": "^27.0.5", - "nodemon": "^2.0.7", - "testcontainers": "12.2.0", - "ts-jest": "^27.0.3", - "ts-node": "^10.0.0", - "typescript": "^4.7.4" - } -} diff --git a/apps/backend-quarkus/pom.xml b/apps/backend/pom.xml similarity index 100% rename from apps/backend-quarkus/pom.xml rename to apps/backend/pom.xml diff --git a/apps/backend-quarkus/scripts/record-engine-games.js b/apps/backend/scripts/record-engine-games.js similarity index 100% rename from apps/backend-quarkus/scripts/record-engine-games.js rename to apps/backend/scripts/record-engine-games.js diff --git a/apps/backend/src/auth/AuthConfig.ts b/apps/backend/src/auth/AuthConfig.ts deleted file mode 100644 index 3a543c2..0000000 --- a/apps/backend/src/auth/AuthConfig.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface AuthConfig { - keycloakIssuer: string; - keycloakJwksUrl?: string; - keycloakClientIds: string[]; - // Accept old clients that identify themselves with ?userKey= instead of a token. - legacyUserKeyAuth: boolean; -} - -export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { - return { - keycloakIssuer: env.KEYCLOAK_ISSUER || "http://localhost:8080/realms/mancala", - keycloakJwksUrl: env.KEYCLOAK_JWKS_URL || undefined, - keycloakClientIds: (env.KEYCLOAK_CLIENT_IDS || "mancala-web").split(",").map((id) => id.trim()).filter(Boolean), - legacyUserKeyAuth: (env.LEGACY_USERKEY_AUTH ?? "true") === "true", - }; -} diff --git a/apps/backend/src/auth/AuthUser.ts b/apps/backend/src/auth/AuthUser.ts deleted file mode 100644 index 95adb3f..0000000 --- a/apps/backend/src/auth/AuthUser.ts +++ /dev/null @@ -1 +0,0 @@ -export type { AuthUser } from "@mancala/core"; diff --git a/apps/backend/src/auth/KeycloakTokenVerifier.ts b/apps/backend/src/auth/KeycloakTokenVerifier.ts deleted file mode 100644 index 43f918c..0000000 --- a/apps/backend/src/auth/KeycloakTokenVerifier.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createRemoteJWKSet, errors, JWTPayload, JWTVerifyGetKey, jwtVerify } from "jose"; -import { AuthUser } from "./AuthUser"; -import { InvalidTokenError, TokenVerifier } from "./TokenVerifier"; - -export const KEYCLOAK_USER_ID_PREFIX = "kc:"; -export const GUEST_ROLE = "guest"; - -export interface KeycloakTokenVerifierOptions { - // Realm issuer, e.g. https://auth.playmancala.app/realms/mancala - issuer: string; - // Clients whose access tokens are accepted (checked against the azp claim). - allowedClientIds: string[]; - // Where to fetch signing keys from. Defaults to the issuer's certs endpoint. - jwksUrl?: string; - // Overrides key lookup, for tests. - keys?: JWTVerifyGetKey; -} - -type KeycloakAccessToken = JWTPayload & { - typ?: string; - azp?: string; - preferred_username?: string; - realm_access?: { roles?: string[] }; -}; - -/** - * 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. - */ -export class KeycloakTokenVerifier implements TokenVerifier { - private readonly issuer: string; - private readonly allowedClientIds: string[]; - private readonly keys: JWTVerifyGetKey; - - constructor(options: KeycloakTokenVerifierOptions) { - this.issuer = options.issuer; - this.allowedClientIds = options.allowedClientIds; - this.keys = options.keys ?? - createRemoteJWKSet(new URL(options.jwksUrl ?? `${options.issuer}/protocol/openid-connect/certs`)); - } - - async verify(token: string): Promise { - let payload: KeycloakAccessToken; - try { - ({ payload } = await jwtVerify(token, this.keys, { - issuer: this.issuer, - algorithms: ["RS256"], - requiredClaims: ["sub", "exp"], - })); - } catch (err) { - if (err instanceof errors.JOSEError) { - throw new InvalidTokenError(err.code); - } - throw err; - } - - // Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens. - if (payload.typ !== "Bearer") { - throw new InvalidTokenError("not an access token"); - } - if (!payload.azp || !this.allowedClientIds.includes(payload.azp)) { - throw new InvalidTokenError("token issued to an unknown client"); - } - - return { - id: KEYCLOAK_USER_ID_PREFIX + payload.sub, - name: payload.preferred_username, - isGuest: payload.realm_access?.roles?.includes(GUEST_ROLE) ?? false, - }; - } -} diff --git a/apps/backend/src/auth/TokenVerifier.ts b/apps/backend/src/auth/TokenVerifier.ts deleted file mode 100644 index 9406a46..0000000 --- a/apps/backend/src/auth/TokenVerifier.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AuthUser } from "./AuthUser"; - -export class InvalidTokenError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidTokenError"; - } -} - -export interface TokenVerifier { - // Resolves to the token's user, or rejects with InvalidTokenError. - verify(token: string): Promise; -} diff --git a/apps/backend/src/auth/index.ts b/apps/backend/src/auth/index.ts deleted file mode 100644 index 43eeddb..0000000 --- a/apps/backend/src/auth/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./AuthUser"; -export * from "./AuthConfig"; -export * from "./TokenVerifier"; -export * from "./KeycloakTokenVerifier"; diff --git a/apps/backend/src/cache/Cache.ts b/apps/backend/src/cache/Cache.ts deleted file mode 100644 index eb2e3d5..0000000 --- a/apps/backend/src/cache/Cache.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Valkey } from "iovalkey"; - -const PREFIX = "mancala:cache:"; - -// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value. -export interface Cache { - getJson(key: string): Promise; - setJson(key: string, value: unknown, ttlSeconds: number): Promise; - delete(...keys: string[]): Promise; -} - -export class ValkeyCache implements Cache { - constructor(private readonly client: Valkey) { } - - async getJson(key: string): Promise { - try { - const value = await this.client.get(PREFIX + key); - return value === null ? undefined : JSON.parse(value) as T; - } catch (err) { - return undefined; - } - } - - async setJson(key: string, value: unknown, ttlSeconds: number): Promise { - try { - await this.client.set(PREFIX + key, JSON.stringify(value), "EX", ttlSeconds); - } catch (err) { - // A missing cache entry only costs a database query. - } - } - - async delete(...keys: string[]): Promise { - if (keys.length === 0) return; - try { - await this.client.del(...keys.map((key) => PREFIX + key)); - } catch (err) { - // Entries expire on their own. - } - } -} diff --git a/apps/backend/src/consts/config.ts b/apps/backend/src/consts/config.ts deleted file mode 100644 index 909455f..0000000 --- a/apps/backend/src/consts/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const RTMT_WS_PING_INTERVAL = 2000; - -// Time each player has for a whole game (docs/specs/game-clock). -export const GAME_CLOCK_MS = positiveInteger(process.env.GAME_CLOCK_MS) ?? 5 * 60 * 1000; - -function positiveInteger(value: string | undefined): number | undefined { - const number = Number(value); - return value && Number.isInteger(number) && number > 0 ? number : undefined; -} diff --git a/apps/backend/src/db/migrations/001_games.ts b/apps/backend/src/db/migrations/001_games.ts deleted file mode 100644 index 837068b..0000000 --- a/apps/backend/src/db/migrations/001_games.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const 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')), - 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/db/migrations/002_clock.ts b/apps/backend/src/db/migrations/002_clock.ts deleted file mode 100644 index 461ecff..0000000 --- a/apps/backend/src/db/migrations/002_clock.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Time each player started with; NULL for games saved before the game clock. Each move's clock is in `moves`. -export const sql = ` -ALTER TABLE games ADD COLUMN clock_initial_ms integer; -`; diff --git a/apps/backend/src/db/migrations/index.ts b/apps/backend/src/db/migrations/index.ts deleted file mode 100644 index ce0b17f..0000000 --- a/apps/backend/src/db/migrations/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { sql as games001 } from "./001_games"; -import { sql as clock002 } from "./002_clock"; - -export interface Migration { - version: string; - sql: string; -} - -// Applied in this order; never change a migration that has been released, add a new one instead. -export const migrations: Migration[] = [ - { version: "001_games", sql: games001 }, - { version: "002_clock", sql: clock002 }, -]; diff --git a/apps/backend/src/db/postgres.ts b/apps/backend/src/db/postgres.ts deleted file mode 100644 index 5ab05c3..0000000 --- a/apps/backend/src/db/postgres.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Pool } from "pg"; -import { Migration, migrations as allMigrations } from "./migrations"; - -const MIGRATION_LOCK_ID = 727274; - -export function createPool(databaseUrl: string): Pool { - const pool = new Pool({ connectionString: databaseUrl, max: 5, connectionTimeoutMillis: 5000 }); - // Without a listener, an error on an idle connection (e.g. Postgres restarting) would crash the process. - pool.on("error", (err) => console.error("(DB) Idle connection error", err.message)); - return pool; -} - -// Applies the migrations that have not run yet, each in its own transaction. -export async function runMigrations(pool: Pool, migrations: Migration[] = allMigrations): Promise { - const client = await pool.connect(); - try { - await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]); - await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations ( - version text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - )`); - const { rows } = await client.query<{ version: string }>("SELECT version FROM schema_migrations"); - const applied = new Set(rows.map((row) => row.version)); - for (const migration of migrations) { - if (applied.has(migration.version)) continue; - try { - await client.query("BEGIN"); - await client.query(migration.sql); - await client.query("INSERT INTO schema_migrations (version) VALUES ($1)", [migration.version]); - await client.query("COMMIT"); - } catch (err) { - await client.query("ROLLBACK"); - throw err; - } - console.info(`(DB) Applied migration ${migration.version}`); - } - } finally { - await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_ID]).catch(() => undefined); - client.release(); - } -} diff --git a/apps/backend/src/game/GameClock.ts b/apps/backend/src/game/GameClock.ts deleted file mode 100644 index aa1f8cd..0000000 --- a/apps/backend/src/game/GameClock.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { GameClock, getClockTimes, Seat } from "@mancala/core"; - -// 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. - -export function startClock(initialMs: number, now: number): GameClock { - return { initialMs, player1Ms: initialMs, player2Ms: initialMs, turnStartedAt: now }; -} - -export function getRemainingMs(clock: GameClock, seat: Seat, runningSeat: Seat | undefined, now: number): number { - const times = getClockTimes(clock, runningSeat, now); - return 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. -export function chargeMove(clock: GameClock, moverSeat: Seat, now: number): { clock: GameClock, remainingMs: number } { - const remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now); - const charged = { ...clock, turnStartedAt: now }; - if (moverSeat === "player1") charged.player1Ms = remainingMs; - else charged.player2Ms = remainingMs; - return { clock: charged, remainingMs }; -} - -// Deducts the running seat's time, if any, and stops the clock. -export function stopClock(clock: GameClock, runningSeat: Seat | undefined, now: number): GameClock { - const times = getClockTimes(clock, runningSeat, now); - return { initialMs: clock.initialMs, ...times }; -} - -// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart. -export function resumeClock(clock: GameClock, now: number): GameClock { - return { ...clock, turnStartedAt: now }; -} diff --git a/apps/backend/src/game/GameClockScheduler.ts b/apps/backend/src/game/GameClockScheduler.ts deleted file mode 100644 index c8a1ab1..0000000 --- a/apps/backend/src/game/GameClockScheduler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { GameManager } from "./GameManager"; - -export const CLOCK_CHECK_INTERVAL_MS = 1_000; - -// 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. -export class GameClockScheduler { - private timer?: NodeJS.Timeout; - - constructor( - private readonly gameManager: Pick, - private readonly intervalMs = CLOCK_CHECK_INTERVAL_MS, - ) { } - - start() { - if (this.timer) return; - this.timer = setInterval(() => this.gameManager.endGamesOutOfTime(), this.intervalMs); - } - - stop() { - if (this.timer) clearInterval(this.timer); - this.timer = undefined; - } -} 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 8a67049..0000000 --- a/apps/backend/src/game/GameManager.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { CommonMancalaGame } 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_end, - channel_on_game_start, - channel_on_game_update, - channel_on_game_user_leave, - channel_on_new_game_rejected, - channel_on_user_connection_change, - channel_unlisten_game_events, - Game, - GameEndMessage, - GameMove, - GameUsersConnectionInfo, - getSeat, - Seat, - UserConnectionInfo, -} from "@mancala/core"; -import { GameCrashManager } from "./GameCrashManager"; -import { MatchMaker } from "../matchmaker/MatchMaker"; -import { BackendGame, PlayerInfo } from "../models/Game"; -import { LiveGameStore } from "../live/LiveGameStore"; -import { GameRecorder } from "./GameRecorder"; -import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, toSnapshot } from "./GameSerialization"; -import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock"; -import { GAME_CLOCK_MS } from "../consts/config"; - -// What players and spectators receive; leaves out spectator ids and other server-side state. -export type ClientGame = Game & { - startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable, serverTime: number, -}; - -export class GameManager { - gameStore: GameStore; - rtmt: RTMT; - matchMaker: MatchMaker; - private readonly liveGameStore: LiveGameStore; - private readonly recorder: GameRecorder; - private readonly clock: () => number; - // Time each player has for a new game. - private readonly gameClockMs: number; - private readonly liveGamesListeners: (() => void)[] = []; - - constructor(params: { - gameStore: GameStore, - rtmt: RTMT, - matchMaker: MatchMaker, - liveGameStore: LiveGameStore, - recorder: GameRecorder, - clock?: () => number, - gameClockMs?: number, - }) { - this.gameStore = params.gameStore; - this.rtmt = params.rtmt; - this.matchMaker = params.matchMaker; - this.liveGameStore = params.liveGameStore; - this.recorder = params.recorder; - this.clock = params.clock ?? Date.now; - this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS; - this.initialize(); - } - - private initialize() { - this.listenOnPlayersPaired(); - this.listenRtmtMessages(); - this.listenUserConnectionChange(); - } - - // Called whenever a live game starts, changes or ends. - public onLiveGamesChanged(listener: () => void) { - this.liveGamesListeners.push(listener); - } - - private listenRtmtMessages() { - this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => { - const activeGame = this.gameStore.getGameByUser(userKey); - if (activeGame) { - this.rtmt.sendMessage(userKey, channel_on_new_game_rejected, { gameId: activeGame.id }); - return; - } - 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 { - const now = this.clock(); - // A move that arrives after the mover's time has run out is not played. - if (userKey === game.mancalaGame.turnPlayerId && this.isOutOfTime(game, now)) { - this.endGameOnTime(game); - return; - } - const movesBefore = game.mancalaGame.history.length; - const moverSeat = getSeat(game.mancalaGame, userKey); - game.mancalaGame.moveByPlayerPit(userKey, gameMove.index); - if (game.mancalaGame.history.length > movesBefore && moverSeat) { - game.moveTimes.push(now); - const charged = chargeMove(game.clock, moverSeat, now); - game.clock = charged.clock; - game.moveRemainingMs.push(charged.remainingMs); - } - game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); - this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game)); - if (game.mancalaGame.state == "ended") { - this.endGame(game, { endReason: "completed", winnerId: game.mancalaGame.getWonPlayerId() }); - } else { - this.saveLiveGame(game); - this.notifyLiveGamesChanged(); - } - } 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.endGame(game, { endReason: "left", winnerId: this.getOpponentId(game, userKey), leftUserId: userKey }); - } - } - - // Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it. - private endGame(game: BackendGame, ending: GameEnding) { - if (this.gameStore.get(game.id) !== game) return; - this.deleteGame(game); - game.clock = stopClock(game.clock, this.getRunningSeat(game), this.clock()); - const endMessage: GameEndMessage = { - gameId: game.id, result: getResult(game, ending), endReason: ending.endReason, clock: game.clock, - }; - if (ending.winnerId !== undefined) endMessage.winnerId = ending.winnerId; - if (ending.leftUserId !== undefined) { - endMessage.leftUserId = ending.leftUserId; - this.sendMessageToPlayersAndSpectators(game, channel_on_game_user_leave, ending.leftUserId); - } - this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage); - game.spectatorIds = []; - - const record = toGameRecord(game, ending, this.clock()); - this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err)); - this.recorder.save(record); - this.notifyLiveGamesChanged(); - } - - // The player whose turn it is ran out of time: the opponent wins. - private endGameOnTime(game: BackendGame) { - const { turnPlayerId } = game.mancalaGame; - this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) }); - } - - private isOutOfTime(game: BackendGame, now: number): boolean { - const runningSeat = this.getRunningSeat(game); - return runningSeat !== undefined && getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0; - } - - // The seat whose clock runs; undefined once the game has ended. - private getRunningSeat(game: BackendGame): Seat | undefined { - const { mancalaGame } = game; - if (mancalaGame.state === "ended" || game.clock.turnStartedAt === undefined) return undefined; - return getSeat(mancalaGame, mancalaGame.turnPlayerId); - } - - // 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 endGamesOutOfTime(now = this.clock()) { - for (const game of this.gameStore.getAll()) { - try { - if (this.isOutOfTime(game, now)) this.endGameOnTime(game); - } catch (err) { - console.error(`(GameManager) Could not end game ${game.id} on time:`, err); - } - } - } - - // Puts the live games saved in Valkey back into memory, e.g. after a restart or deploy. - public async restoreLiveGames(): Promise { - let snapshots: unknown[]; - try { - snapshots = await this.liveGameStore.loadGames(); - } catch (err) { - console.error("(GameManager) Could not load live games:", (err as Error).message); - return 0; - } - let restored = 0; - for (const snapshot of snapshots) { - if (!isLiveGameSnapshot(snapshot) || this.gameStore.get(snapshot.id)) continue; - // A game that ended but whose snapshot could not be removed must not come back. - if (await this.recorder.isRecorded(snapshot.id).catch(() => false)) { - this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err)); - continue; - } - const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs }); - this.gameStore.set(game.id, game); - for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) { - this.gameStore.setGameByUser(playerId, game); - } - game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); - restored++; - } - if (restored > 0) { - console.info(`(GameManager) Restored ${restored} live game(s)`); - this.notifyLiveGamesChanged(); - } - return restored; - } - - 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); - if (!game.allSpectatorIds.includes(userId)) game.allSpectatorIds.push(userId); - game.peakSpectators = Math.max(game.peakSpectators, game.spectatorIds.length); - this.saveLiveGame(game); - this.notifyLiveGamesChanged(); - } - } - } - - 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); - this.notifyLiveGamesChanged(); - } - } - } - - private checkUserIdisPlayer(game: BackendGame, userId: string): boolean { - return game.mancalaGame.player1Id === userId || game.mancalaGame.player2Id === userId; - } - - private getOpponentId(game: BackendGame, userId: string): string { - const { player1Id, player2Id } = game.mancalaGame; - return userId === player1Id ? player2Id : player1Id; - } - - 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)); - } - - public toClientGame(game: BackendGame): ClientGame { - return { - id: game.id, - mancalaGame: game.mancalaGame, - gameUsersConnectionInfo: game.gameUsersConnectionInfo, - startedAt: game.startedAt, - moveTimes: game.moveTimes, - spectatorCount: game.spectatorIds.length, - clock: game.clock, - serverTime: this.clock(), - }; - } - - private listenOnPlayersPaired() { - this.matchMaker.listenOnPlayersPaired((player1Id: string, player2Id: string) => { - const game = this.createMancalaGame(player1Id, player2Id); - this.startGame(game); - }); - } - - 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 now = this.clock(); - - const players: Record = { - [userKey1]: this.getPlayerInfo(userKey1), - [userKey2]: this.getPlayerInfo(userKey2), - }; - const game: BackendGame = { - id: mancalaGame.id, - mancalaGame, - gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromPlayers(players[userKey1], players[userKey2]), - spectatorIds: [], - allSpectatorIds: [], - peakSpectators: 0, - players, - startedAt: now, - moveTimes: [], - clock: startClock(this.gameClockMs, now), - moveRemainingMs: [], - }; - - this.gameStore.set(game.id, game); - this.gameStore.setGameByUser(userKey1, game); - this.gameStore.setGameByUser(userKey2, game); - return game; - } - - private getPlayerInfo(userId: string): PlayerInfo { - const user = this.rtmt.getUser(userId); - return { userId, name: user?.name, isGuest: user?.isGuest ?? true }; - } - - public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { - const { player1Id, player2Id } = game.mancalaGame; - const player = (id: string): PlayerInfo => game.players[id] ?? { userId: id, isGuest: true }; - return this.getGameUsersConnectionInfoFromPlayers(player(player1Id), player(player2Id)); - } - - private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { - const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => - ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) }); - return { - user1ConnectionInfo: toConnectionInfo(player1), - user2ConnectionInfo: toConnectionInfo(player2) - }; - } - - public startGame(game: BackendGame) { - this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, this.toClientGame(game)); - this.sendUserConnectionInfo(game); - this.saveLiveGame(game); - this.notifyLiveGamesChanged(); - } - - public sendUserConnectionInfo(game: BackendGame) { - game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); - this.sendMessageToPlayersAndSpectators(game, channel_on_user_connection_change, game.gameUsersConnectionInfo); - } - - public deleteGame(game: BackendGame) { - if (game) { - this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id); - this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id); - this.gameStore.remove(game.id); - } - } - - private saveLiveGame(game: BackendGame) { - this.liveGameStore.saveGame(game.id, toSnapshot(game)).catch((err) => this.logStoreError(err)); - } - - private logStoreError(err: Error) { - console.error("(GameManager) Live game store:", err.message); - } - - private notifyLiveGamesChanged() { - this.liveGamesListeners.forEach((listener) => listener()); - } - - 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, this.toClientGame(game)); - } - }); - } -} diff --git a/apps/backend/src/game/GameRecorder.ts b/apps/backend/src/game/GameRecorder.ts deleted file mode 100644 index 8330ed9..0000000 --- a/apps/backend/src/game/GameRecorder.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { GameRecord } from "@mancala/core"; -import { GameHistoryRepository } from "../history/GameHistoryRepository"; -import { LiveGameStore } from "../live/LiveGameStore"; - -const RETRY_INTERVAL_MS = 30_000; - -// 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. -export class GameRecorder { - // Being saved right now. - private readonly saving = new Map(); - // Failed to save; retried later. - private readonly pending = new Map(); - private readonly savedListeners: ((record: GameRecord) => void)[] = []; - private retryTimer?: NodeJS.Timeout; - private retrying = false; - - constructor(private readonly repository: GameHistoryRepository, private readonly liveGameStore: LiveGameStore) { } - - onSaved(listener: (record: GameRecord) => void) { - this.savedListeners.push(listener); - } - - async save(record: GameRecord): Promise { - this.saving.set(record.id, record); - try { - await this.repository.save(record); - this.saving.delete(record.id); - this.notifySaved(record); - } catch (err) { - console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message); - this.pending.set(record.id, record); - this.saving.delete(record.id); - await this.liveGameStore.savePendingRecord(record).catch(() => undefined); - } - } - - // Finished games that are not in the database yet, so readers can include them. - getUnsaved(): GameRecord[] { - return [...this.saving.values(), ...this.pending.values()]; - } - - // True if the game is saved or waiting to be saved. Throws if the database cannot be asked. - async isRecorded(gameId: string): Promise { - return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined; - } - - async retryPending(): Promise { - if (this.retrying) return; - this.retrying = true; - try { - const stored = await this.liveGameStore.loadPendingRecords().catch(() => []); - stored.forEach((record) => this.pending.set(record.id, record)); - for (const record of [...this.pending.values()]) { - try { - await this.repository.save(record); - } catch (err) { - return; // still unreachable; try again later - } - this.pending.delete(record.id); - await this.liveGameStore.removePendingRecord(record.id).catch(() => undefined); - console.info(`(GameRecorder) Saved game ${record.id} after retrying`); - this.notifySaved(record); - } - } finally { - this.retrying = false; - } - } - - start() { - this.retryPending(); - this.retryTimer = setInterval(() => this.retryPending(), RETRY_INTERVAL_MS); - } - - stop() { - if (this.retryTimer) clearInterval(this.retryTimer); - } - - private notifySaved(record: GameRecord) { - this.savedListeners.forEach((listener) => listener(record)); - } -} diff --git a/apps/backend/src/game/GameSerialization.ts b/apps/backend/src/game/GameSerialization.ts deleted file mode 100644 index d88374e..0000000 --- a/apps/backend/src/game/GameSerialization.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { EndReason, GameClock, GameRecord, GameResult, MoveRecord } from "@mancala/core"; -import { CommonMancalaGame, GameState, GameStep } from "mancala.js"; -import { BackendGame, PlayerInfo } from "../models/Game"; -import { publicUserId } from "./publicIds"; -import { resumeClock, startClock } from "./GameClock"; - -type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] }; - -// What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock. -export interface LiveGameSnapshot { - version: 1 | 2; - id: string; - player1Id: string; - player2Id: string; - turnPlayerId: string; - state: GameState; - board: number[]; - history: HistoryEntry[]; - players: Record; - startedAt: number; - moveTimes: number[]; - peakSpectators: number; - allSpectatorIds: string[]; - clock?: GameClock; - moveRemainingMs?: (number | null)[]; -} - -export interface GameEnding { - endReason: EndReason; - // Undefined when the game ends without a winner. - winnerId?: string; - leftUserId?: string; -} - -export function toSnapshot(game: BackendGame): LiveGameSnapshot { - const { mancalaGame } = game; - return { - version: 2, - id: game.id, - player1Id: mancalaGame.player1Id, - player2Id: mancalaGame.player2Id, - turnPlayerId: mancalaGame.turnPlayerId, - state: mancalaGame.state, - board: mancalaGame.board.getStoneArray(), - history: mancalaGame.history as unknown as HistoryEntry[], - players: game.players, - startedAt: game.startedAt, - moveTimes: game.moveTimes, - peakSpectators: game.peakSpectators, - allSpectatorIds: game.allSpectatorIds, - clock: game.clock, - moveRemainingMs: game.moveRemainingMs, - }; -} - -export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot { - const snapshot = value as LiveGameSnapshot; - if (typeof snapshot !== "object" || snapshot === null) return false; - const valid = typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14 - && Array.isArray(snapshot.history) && typeof snapshot.players === "object"; - if (snapshot.version === 1) return valid; - return snapshot.version === 2 && valid && typeof snapshot.clock?.initialMs === "number" - && Array.isArray(snapshot.moveRemainingMs); -} - -// 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. -export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame { - const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id); - snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount)); - mancalaGame.turnPlayerId = snapshot.turnPlayerId; - mancalaGame.state = snapshot.state; - mancalaGame.history.push(...snapshot.history); - const player = (id: string): PlayerInfo => snapshot.players[id] ?? { userId: id, isGuest: true }; - return { - id: snapshot.id, - mancalaGame, - gameUsersConnectionInfo: { - user1ConnectionInfo: { ...player(snapshot.player1Id), isOnline: false }, - user2ConnectionInfo: { ...player(snapshot.player2Id), isOnline: false }, - }, - spectatorIds: [], - allSpectatorIds: snapshot.allSpectatorIds, - peakSpectators: snapshot.peakSpectators, - players: snapshot.players, - startedAt: snapshot.startedAt, - moveTimes: snapshot.moveTimes, - clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now), - moveRemainingMs: snapshot.moveRemainingMs ?? snapshot.history.map(() => null), - }; -} - -export function getResult(game: BackendGame, ending: GameEnding): GameResult | null { - const { player1Id, player2Id } = game.mancalaGame; - if (ending.winnerId === player1Id) return "player1"; - if (ending.winnerId === player2Id) return "player2"; - return ending.endReason === "completed" ? "draw" : null; -} - -// The record saved for a finished game; every id in it is a public id. -export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: number): GameRecord { - const { mancalaGame } = game; - const history = mancalaGame.history as unknown as HistoryEntry[]; - const playerSummary = (id: string) => { - const player = game.players[id]; - const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true }; - return player?.name === undefined ? summary : { ...summary, name: player.name }; - }; - const moves: MoveRecord[] = history.map((entry, i) => { - const move: MoveRecord = { - playerId: publicUserId(entry.playerId), - moveIndex: entry.moveIndex, - boardSnapshot: entry.boardSnapshot, - gameSteps: entry.gameSteps, - at: game.moveTimes[i] ?? endedAt, - }; - const remainingMs = game.moveRemainingMs[i]; - if (typeof remainingMs === "number") move.remainingMs = remainingMs; - return move; - }); - const record: GameRecord = { - id: game.id, - player1: playerSummary(mancalaGame.player1Id), - player2: playerSummary(mancalaGame.player2Id), - board: mancalaGame.board.getStoneArray(), - result: getResult(game, ending), - endReason: ending.endReason, - startedAt: game.startedAt, - endedAt, - moveCount: moves.length, - peakSpectators: game.peakSpectators, - totalSpectators: game.allSpectatorIds.length, - moves, - clockInitialMs: game.clock.initialMs, - }; - if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId); - return record; -} diff --git a/apps/backend/src/game/LiveGames.ts b/apps/backend/src/game/LiveGames.ts deleted file mode 100644 index 8884941..0000000 --- a/apps/backend/src/game/LiveGames.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - channel_on_live_games, - channel_subscribe_live_games, - channel_unsubscribe_live_games, - LiveGameSummary, -} from "@mancala/core"; -import { RTMT } from "../rtmt/rtmt"; -import { BackendGame } from "../models/Game"; -import { GameStore } from "./gamestore/GameStore"; -import { GameManager } from "./GameManager"; -import { publicUserId } from "./publicIds"; - -export const LIVE_GAMES_PUSH_COUNT = 5; -const PUSH_INTERVAL_MS = 1000; - -export function toLiveGameSummary(game: BackendGame): LiveGameSummary { - const { mancalaGame } = game; - const player = (id: string) => { - const info = game.players[id]; - const summary = { userId: publicUserId(id), isGuest: info?.isGuest ?? true }; - return info?.name === undefined ? summary : { ...summary, name: info.name }; - }; - return { - id: game.id, - player1: player(mancalaGame.player1Id), - player2: player(mancalaGame.player2Id), - board: mancalaGame.board.getStoneArray(), - turnPlayerId: publicUserId(mancalaGame.turnPlayerId), - spectatorCount: game.spectatorIds.length, - moveCount: mancalaGame.history.length, - startedAt: game.startedAt, - }; -} - -const lastActivity = (game: BackendGame) => game.moveTimes[game.moveTimes.length - 1] ?? game.startedAt; - -// 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. -export class LiveGames { - private readonly subscribers = new Set(); - private lastPushed = ""; - private lastPushAt = 0; - private pushTimer?: NodeJS.Timeout; - - constructor(private readonly params: { rtmt: RTMT, gameStore: GameStore, gameManager: GameManager }) { - const { rtmt, gameManager } = params; - rtmt.listenMessage(channel_subscribe_live_games, (userId: string) => this.subscribe(userId)); - rtmt.listenMessage(channel_unsubscribe_live_games, (userId: string) => this.subscribers.delete(userId)); - rtmt.listenOnClientConnectionChange((userId: string, isOnline: boolean) => { - if (!isOnline) this.subscribers.delete(userId); - }); - gameManager.onLiveGamesChanged(() => this.schedulePush()); - } - - getTop(limit: number): LiveGameSummary[] { - return this.params.gameStore.getAll() - .sort((a, b) => b.spectatorIds.length - a.spectatorIds.length || lastActivity(b) - lastActivity(a)) - .slice(0, limit) - .map(toLiveGameSummary); - } - - dispose() { - if (this.pushTimer) clearTimeout(this.pushTimer); - } - - private subscribe(userId: string) { - this.subscribers.add(userId); - this.params.rtmt.sendMessage(userId, channel_on_live_games, { games: this.getTop(LIVE_GAMES_PUSH_COUNT) }); - } - - private schedulePush() { - if (this.pushTimer) return; - const wait = Math.max(0, this.lastPushAt + PUSH_INTERVAL_MS - Date.now()); - this.pushTimer = setTimeout(() => { - this.pushTimer = undefined; - this.push(); - }, wait); - } - - private push() { - const games = this.getTop(LIVE_GAMES_PUSH_COUNT); - const serialized = JSON.stringify(games); - if (serialized === this.lastPushed) return; - this.lastPushed = serialized; - this.lastPushAt = Date.now(); - this.subscribers.forEach((userId) => this.params.rtmt.sendMessage(userId, channel_on_live_games, { games })); - } -} diff --git a/apps/backend/src/game/gamestore/GameStore.ts b/apps/backend/src/game/gamestore/GameStore.ts deleted file mode 100644 index 3e9c524..0000000 --- a/apps/backend/src/game/gamestore/GameStore.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Game } from "@mancala/core"; -import { BackendGame } from "../../models/Game"; - -export interface GameStore { - get(id: string): BackendGame | undefined; - getAll(): BackendGame[]; - 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 6c77908..0000000 --- a/apps/backend/src/game/gamestore/GameStoreImpl.ts +++ /dev/null @@ -1,50 +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); - } - getAll(): BackendGame[] { - return [...this.gameStore.values()]; - } - 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 game of this.gameStore.values()) { - 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/game/publicIds.ts b/apps/backend/src/game/publicIds.ts deleted file mode 100644 index 2b3657a..0000000 --- a/apps/backend/src/game/publicIds.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createHash } from "crypto"; -import { KEYCLOAK_USER_ID_PREFIX } from "../auth"; - -const LEGACY_PUBLIC_ID_PREFIX = "legacy:"; - -// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash -// instead. Keycloak ids are not secret and stay as they are. -export function publicUserId(userId: string): string { - if (userId.startsWith(KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) return userId; - return LEGACY_PUBLIC_ID_PREFIX + createHash("sha256").update(userId).digest("hex").slice(0, 20); -} diff --git a/apps/backend/src/history/GameHistoryRepository.ts b/apps/backend/src/history/GameHistoryRepository.ts deleted file mode 100644 index 055dff9..0000000 --- a/apps/backend/src/history/GameHistoryRepository.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { FinishedGameSummary, GameRecord, MoveRecord, PlayerSummary } from "@mancala/core"; -import { Pool, PoolClient } from "pg"; - -// Saved, finished games. Ids in records are public ids (see publicIds.ts). -export interface GameHistoryRepository { - // Saving the same game twice is a no-op, so failed saves can be retried. - save(record: GameRecord): Promise; - get(id: string): Promise; - // The user's finished games, most recent first. - getRecentForUser(userId: string, limit: number): Promise; - // Finished games with the most spectators, most recent first among equals. - getPopular(limit: number): Promise; -} - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -const 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`; - -const 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`; - -type SummaryRow = { - id: string; result: FinishedGameSummary["result"]; end_reason: FinishedGameSummary["endReason"]; - left_user_id: string | null; started_at: Date; ended_at: Date; move_count: number; final_board: number[]; - peak_spectators: number; clock_initial_ms: number | null; - p1_user_id: string; p1_name: string | null; p1_is_guest: boolean; - p2_user_id: string; p2_name: string | null; p2_is_guest: boolean; -}; - -const toPlayer = (userId: string, name: string | null, isGuest: boolean): PlayerSummary => - name === null ? { userId, isGuest } : { userId, name, isGuest }; - -function toSummary(row: SummaryRow): FinishedGameSummary { - const summary: FinishedGameSummary = { - id: row.id, - player1: toPlayer(row.p1_user_id, row.p1_name, row.p1_is_guest), - player2: toPlayer(row.p2_user_id, row.p2_name, row.p2_is_guest), - board: row.final_board, - result: row.result, - endReason: row.end_reason, - startedAt: row.started_at.getTime(), - endedAt: row.ended_at.getTime(), - moveCount: row.move_count, - peakSpectators: row.peak_spectators, - }; - if (row.left_user_id !== null) summary.leftUserId = row.left_user_id; - if (row.clock_initial_ms !== null) summary.clockInitialMs = row.clock_initial_ms; - return summary; -} - -export class PostgresGameHistoryRepository implements GameHistoryRepository { - constructor(private readonly pool: Pool) { } - - async save(record: GameRecord): Promise { - await this.inTransaction(async (client) => { - const inserted = await client.query( - `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 ($1, $2, $3, $4, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0), - $9, $10, $11, $12, $13, $14) - ON CONFLICT (id) DO NOTHING`, - [record.id, record.player1.userId, record.player2.userId, record.result, record.endReason, - record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount, - JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators, - record.totalSpectators, record.clockInitialMs ?? null]); - if (inserted.rowCount === 0) return; - for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) { - await client.query( - "INSERT INTO game_players (game_id, seat, user_id, name, is_guest) VALUES ($1, $2, $3, $4, $5)", - [record.id, seat, player.userId, player.name ?? null, player.isGuest]); - } - }); - } - - async get(id: string): Promise { - if (!UUID_PATTERN.test(id)) return undefined; - const { rows } = await this.pool.query( - `SELECT ${SUMMARY_COLUMNS}, g.moves, g.total_spectators ${FROM_GAMES} WHERE g.id = $1`, [id]); - if (rows.length === 0) return undefined; - return { ...toSummary(rows[0]), moves: rows[0].moves, totalSpectators: rows[0].total_spectators }; - } - - async getRecentForUser(userId: string, limit: number): Promise { - const { rows } = await this.pool.query( - `SELECT ${SUMMARY_COLUMNS} ${FROM_GAMES} - WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = $1) - ORDER BY g.ended_at DESC LIMIT $2`, [userId, limit]); - return rows.map(toSummary); - } - - async getPopular(limit: number): Promise { - const { rows } = await this.pool.query( - `SELECT ${SUMMARY_COLUMNS} ${FROM_GAMES} ORDER BY g.peak_spectators DESC, g.ended_at DESC LIMIT $1`, - [limit]); - return rows.map(toSummary); - } - - private async inTransaction(work: (client: PoolClient) => Promise): Promise { - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - await work(client); - await client.query("COMMIT"); - } catch (err) { - await client.query("ROLLBACK").catch(() => undefined); - throw err; - } finally { - client.release(); - } - } -} diff --git a/apps/backend/src/history/GameHistoryService.ts b/apps/backend/src/history/GameHistoryService.ts deleted file mode 100644 index 3844590..0000000 --- a/apps/backend/src/history/GameHistoryService.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { FinishedGameSummary, GameRecord } from "@mancala/core"; -import { Cache } from "../cache/Cache"; -import { GameHistoryRepository } from "./GameHistoryRepository"; - -export const MAX_LIST_LIMIT = 20; -const CACHE_TTL_SECONDS = 10; -const POPULAR_KEY = "popular-finished"; -const recentKey = (userId: string) => `recent:${userId}`; - -// Games that ended but are not in the database yet (being saved, or waiting for a retry). -export type UnsavedGames = () => GameRecord[]; - -const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary; - -const byNewest = (a: FinishedGameSummary, b: FinishedGameSummary) => b.endedAt - a.endedAt; -const byPopularity = (a: FinishedGameSummary, b: FinishedGameSummary) => - b.peakSpectators - a.peakSpectators || byNewest(a, b); - -// 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. -export class GameHistoryService { - constructor( - private readonly repository: GameHistoryRepository, - private readonly cache: Cache, - private readonly unsaved: UnsavedGames = () => [], - ) { } - - async get(id: string): Promise { - return this.unsaved().find((record) => record.id === id) ?? this.repository.get(id); - } - - async getRecentForUser(userId: string, limit: number): Promise { - const saved = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT)); - const unsaved = this.unsaved().filter((r) => r.player1.userId === userId || r.player2.userId === userId); - return merge(saved, unsaved, byNewest).slice(0, limit); - } - - async getPopular(limit: number): Promise { - const saved = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT)); - return merge(saved, this.unsaved(), byPopularity).slice(0, limit); - } - - // Call when a game has been saved, so lists show it right away. - onGameSaved(record: GameRecord): Promise { - return this.cache.delete(POPULAR_KEY, recentKey(record.player1.userId), recentKey(record.player2.userId)); - } - - private async cached(key: string, load: () => Promise): Promise { - const hit = await this.cache.getJson(key); - if (hit) return hit; - try { - const games = await load(); - await this.cache.setJson(key, games, CACHE_TTL_SECONDS); - return games; - } catch (err) { - console.error("(History) Could not load games:", (err as Error).message); - return []; - } - } -} - -function merge(saved: FinishedGameSummary[], unsaved: GameRecord[], order: typeof byNewest): FinishedGameSummary[] { - const savedIds = new Set(saved.map((game) => game.id)); - return [...saved, ...unsaved.filter((record) => !savedIds.has(record.id)).map(toSummary)].sort(order); -} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts deleted file mode 100644 index 5cde744..0000000 --- a/apps/backend/src/index.ts +++ /dev/null @@ -1,86 +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"; -import { KeycloakTokenVerifier, readAuthConfig } from "./auth"; -import { createPool, runMigrations } from "./db/postgres"; -import { PostgresGameHistoryRepository } from "./history/GameHistoryRepository"; -import { createValkeyClient } from "./live/valkey"; -import { ValkeyLiveGameStore } from "./live/LiveGameStore"; -import { GameRecorder } from "./game/GameRecorder"; -import { LiveGames } from "./game/LiveGames"; -import { GameHistoryService } from "./history/GameHistoryService"; -import { ValkeyCache } from "./cache/Cache"; -import { GameClockScheduler } from "./game/GameClockScheduler"; - -const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala"; -const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379"; -const MIGRATION_RETRY_MS = 30_000; -const VALKEY_STARTUP_WAIT_MS = 5_000; - -async function main() { - const authConfig = readAuthConfig(); - const tokenVerifier = new KeycloakTokenVerifier({ - issuer: authConfig.keycloakIssuer, - jwksUrl: authConfig.keycloakJwksUrl, - allowedClientIds: authConfig.keycloakClientIds, - }); - - const pool = createPool(DATABASE_URL); - migrateWhenReachable(pool); - const valkey = createValkeyClient(VALKEY_URL); - await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS); - - const liveGameStore = new ValkeyLiveGameStore(valkey); - const repository = new PostgresGameHistoryRepository(pool); - const recorder = new GameRecorder(repository, liveGameStore); - const history = new GameHistoryService(repository, new ValkeyCache(valkey), () => recorder.getUnsaved()); - recorder.onSaved((record) => history.onGameSaved(record)); - - const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); - const gameStore = new GameStoreImpl(); - const matchMaker = new MatchMakerImpl({ rtmt }); - const gameManager = new GameManager({ rtmt, gameStore, matchMaker, liveGameStore, recorder }); - const liveGames = new LiveGames({ rtmt, gameStore, gameManager }); - - // Before accepting connections, so reconnecting players find their games. - await gameManager.restoreLiveGames(); - recorder.start(); - new GameClockScheduler(gameManager).start(); - - const expressApp = new ExpressApp({ - gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth, - }); - const server = new WebServer({ expressApp: expressApp.app }); - rtmt.initWebSocket(server.server); -} - -// The game runs without the database; saved games wait in the recorder until migrations have run. -function migrateWhenReachable(pool: ReturnType) { - runMigrations(pool).catch((err) => { - console.error(`(DB) Migrations failed, retrying in ${MIGRATION_RETRY_MS / 1000}s:`, err.message); - setTimeout(() => migrateWhenReachable(pool), MIGRATION_RETRY_MS); - }); -} - -function waitForReady(client: ReturnType, timeoutMs: number): Promise { - if (client.status === "ready") return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(() => { - console.error("(Valkey) Not reachable yet; starting without restoring live games"); - resolve(); - }, timeoutMs); - client.once("ready", () => { - clearTimeout(timer); - resolve(); - }); - }); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/backend/src/live/LiveGameStore.ts b/apps/backend/src/live/LiveGameStore.ts deleted file mode 100644 index a8abcdf..0000000 --- a/apps/backend/src/live/LiveGameStore.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { GameRecord } from "@mancala/core"; -import { Valkey } from "iovalkey"; -import { scanKeys } from "./valkey"; - -const LIVE_PREFIX = "mancala:live:"; -const PENDING_SAVE_PREFIX = "mancala:pending-save:"; - -// 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 reject when Valkey is unreachable. -export interface LiveGameStore { - saveGame(gameId: string, snapshot: unknown): Promise; - removeGame(gameId: string): Promise; - loadGames(): Promise; - savePendingRecord(record: GameRecord): Promise; - removePendingRecord(gameId: string): Promise; - loadPendingRecords(): Promise; -} - -export class ValkeyLiveGameStore implements LiveGameStore { - constructor(private readonly client: Valkey) { } - - async saveGame(gameId: string, snapshot: unknown): Promise { - await this.client.set(LIVE_PREFIX + gameId, JSON.stringify(snapshot)); - } - - async removeGame(gameId: string): Promise { - await this.client.del(LIVE_PREFIX + gameId); - } - - loadGames(): Promise { - return this.loadAll(LIVE_PREFIX); - } - - async savePendingRecord(record: GameRecord): Promise { - await this.client.set(PENDING_SAVE_PREFIX + record.id, JSON.stringify(record)); - } - - async removePendingRecord(gameId: string): Promise { - await this.client.del(PENDING_SAVE_PREFIX + gameId); - } - - loadPendingRecords(): Promise { - return this.loadAll(PENDING_SAVE_PREFIX) as Promise; - } - - private async loadAll(prefix: string): Promise { - const keys = await scanKeys(this.client, prefix + "*"); - if (keys.length === 0) return []; - const values = await this.client.mget(keys); - const parsed: unknown[] = []; - values.forEach((value, i) => { - if (value === null) return; - try { - parsed.push(JSON.parse(value)); - } catch { - console.error(`(Valkey) Ignoring unreadable value in ${keys[i]}`); - } - }); - return parsed; - } -} diff --git a/apps/backend/src/live/valkey.ts b/apps/backend/src/live/valkey.ts deleted file mode 100644 index 82aee4a..0000000 --- a/apps/backend/src/live/valkey.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { RedisOptions, Valkey } from "iovalkey"; - -export function createValkeyClient(url: string, options: RedisOptions = {}): Valkey { - const client = new Valkey(url, { - // Fail commands at once while Valkey is down instead of queueing them; callers fall back. - enableOfflineQueue: false, - maxRetriesPerRequest: 1, - ...options, - }); - // Without a listener, connection errors would be unhandled 'error' events and crash the process. - let lastError = ""; - client.on("error", (err: Error) => { - if (err.message !== lastError) console.error("(Valkey) " + err.message); - lastError = err.message; - }); - client.on("ready", () => { - lastError = ""; - console.info("(Valkey) Connected"); - }); - return client; -} - -// Keys matching the pattern, without blocking Valkey the way KEYS would. -export async function scanKeys(client: Valkey, pattern: string): Promise { - const keys: string[] = []; - let cursor = "0"; - do { - const [next, batch] = await client.scan(cursor, "MATCH", pattern, "COUNT", 100); - keys.push(...batch); - cursor = next; - } while (cursor !== "0"); - return keys; -} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/Backend.java b/apps/backend/src/main/java/app/playmancala/Backend.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/Backend.java rename to apps/backend/src/main/java/app/playmancala/Backend.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java b/apps/backend/src/main/java/app/playmancala/auth/InvalidTokenException.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java rename to apps/backend/src/main/java/app/playmancala/auth/InvalidTokenException.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java b/apps/backend/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java rename to apps/backend/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java b/apps/backend/src/main/java/app/playmancala/auth/TokenVerifier.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java rename to apps/backend/src/main/java/app/playmancala/auth/TokenVerifier.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifierProducer.java b/apps/backend/src/main/java/app/playmancala/auth/TokenVerifierProducer.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifierProducer.java rename to apps/backend/src/main/java/app/playmancala/auth/TokenVerifierProducer.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/Bank.java b/apps/backend/src/main/java/app/playmancala/engine/Bank.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/Bank.java rename to apps/backend/src/main/java/app/playmancala/engine/Bank.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/Board.java b/apps/backend/src/main/java/app/playmancala/engine/Board.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/Board.java rename to apps/backend/src/main/java/app/playmancala/engine/Board.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonBoard.java b/apps/backend/src/main/java/app/playmancala/engine/CommonBoard.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonBoard.java rename to apps/backend/src/main/java/app/playmancala/engine/CommonBoard.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonMancalaGame.java b/apps/backend/src/main/java/app/playmancala/engine/CommonMancalaGame.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/CommonMancalaGame.java rename to apps/backend/src/main/java/app/playmancala/engine/CommonMancalaGame.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/GameRule.java b/apps/backend/src/main/java/app/playmancala/engine/GameRule.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/GameRule.java rename to apps/backend/src/main/java/app/playmancala/engine/GameRule.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/GameState.java b/apps/backend/src/main/java/app/playmancala/engine/GameState.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/GameState.java rename to apps/backend/src/main/java/app/playmancala/engine/GameState.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/GameStep.java b/apps/backend/src/main/java/app/playmancala/engine/GameStep.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/GameStep.java rename to apps/backend/src/main/java/app/playmancala/engine/GameStep.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/MancalaGame.java b/apps/backend/src/main/java/app/playmancala/engine/MancalaGame.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/MancalaGame.java rename to apps/backend/src/main/java/app/playmancala/engine/MancalaGame.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/MoveHistoryItem.java b/apps/backend/src/main/java/app/playmancala/engine/MoveHistoryItem.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/MoveHistoryItem.java rename to apps/backend/src/main/java/app/playmancala/engine/MoveHistoryItem.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/Pit.java b/apps/backend/src/main/java/app/playmancala/engine/Pit.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/Pit.java rename to apps/backend/src/main/java/app/playmancala/engine/Pit.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/PitType.java b/apps/backend/src/main/java/app/playmancala/engine/PitType.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/PitType.java rename to apps/backend/src/main/java/app/playmancala/engine/PitType.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java rename to apps/backend/src/main/java/app/playmancala/engine/rules/GRClearBoardAtEnd.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java rename to apps/backend/src/main/java/app/playmancala/engine/rules/GRDoubleStoneInPit.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java rename to apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInBank.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java b/apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java rename to apps/backend/src/main/java/app/playmancala/engine/rules/GRLastStoneInEmptyPit.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java rename to apps/backend/src/main/java/app/playmancala/game/BackendGame.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java b/apps/backend/src/main/java/app/playmancala/game/CrashLog.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java rename to apps/backend/src/main/java/app/playmancala/game/CrashLog.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java b/apps/backend/src/main/java/app/playmancala/game/GameClockScheduler.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java rename to apps/backend/src/main/java/app/playmancala/game/GameClockScheduler.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java b/apps/backend/src/main/java/app/playmancala/game/GameClocks.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java rename to apps/backend/src/main/java/app/playmancala/game/GameClocks.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java rename to apps/backend/src/main/java/app/playmancala/game/GameManager.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java b/apps/backend/src/main/java/app/playmancala/game/GameRecorder.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java rename to apps/backend/src/main/java/app/playmancala/game/GameRecorder.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java rename to apps/backend/src/main/java/app/playmancala/game/GameSerialization.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java b/apps/backend/src/main/java/app/playmancala/game/GameStore.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java rename to apps/backend/src/main/java/app/playmancala/game/GameStore.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java b/apps/backend/src/main/java/app/playmancala/game/LiveGames.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java rename to apps/backend/src/main/java/app/playmancala/game/LiveGames.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java b/apps/backend/src/main/java/app/playmancala/game/MatchMaker.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java rename to apps/backend/src/main/java/app/playmancala/game/MatchMaker.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java rename to apps/backend/src/main/java/app/playmancala/game/PublicIds.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/GameHistoryRepository.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java rename to apps/backend/src/main/java/app/playmancala/history/GameHistoryRepository.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryService.java b/apps/backend/src/main/java/app/playmancala/history/GameHistoryService.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryService.java rename to apps/backend/src/main/java/app/playmancala/history/GameHistoryService.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/history/Migrations.java b/apps/backend/src/main/java/app/playmancala/history/Migrations.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/history/Migrations.java rename to apps/backend/src/main/java/app/playmancala/history/Migrations.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java rename to apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/http/BearerAuth.java b/apps/backend/src/main/java/app/playmancala/http/BearerAuth.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/http/BearerAuth.java rename to apps/backend/src/main/java/app/playmancala/http/BearerAuth.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/http/ErrorMapper.java b/apps/backend/src/main/java/app/playmancala/http/ErrorMapper.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/http/ErrorMapper.java rename to apps/backend/src/main/java/app/playmancala/http/ErrorMapper.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/http/GameResource.java b/apps/backend/src/main/java/app/playmancala/http/GameResource.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/http/GameResource.java rename to apps/backend/src/main/java/app/playmancala/http/GameResource.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/live/Cache.java b/apps/backend/src/main/java/app/playmancala/live/Cache.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/live/Cache.java rename to apps/backend/src/main/java/app/playmancala/live/Cache.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java b/apps/backend/src/main/java/app/playmancala/live/LiveGameStore.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java rename to apps/backend/src/main/java/app/playmancala/live/LiveGameStore.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyCache.java b/apps/backend/src/main/java/app/playmancala/live/ValkeyCache.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyCache.java rename to apps/backend/src/main/java/app/playmancala/live/ValkeyCache.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java b/apps/backend/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java rename to apps/backend/src/main/java/app/playmancala/live/ValkeyLiveGameStore.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java b/apps/backend/src/main/java/app/playmancala/loop/Cancellable.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java rename to apps/backend/src/main/java/app/playmancala/loop/Cancellable.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java b/apps/backend/src/main/java/app/playmancala/loop/GameLoop.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java rename to apps/backend/src/main/java/app/playmancala/loop/GameLoop.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java b/apps/backend/src/main/java/app/playmancala/loop/Scheduler.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java rename to apps/backend/src/main/java/app/playmancala/loop/Scheduler.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java b/apps/backend/src/main/java/app/playmancala/model/AuthUser.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java rename to apps/backend/src/main/java/app/playmancala/model/AuthUser.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java b/apps/backend/src/main/java/app/playmancala/model/Channels.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java rename to apps/backend/src/main/java/app/playmancala/model/Channels.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java b/apps/backend/src/main/java/app/playmancala/model/ClientGame.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java rename to apps/backend/src/main/java/app/playmancala/model/ClientGame.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java b/apps/backend/src/main/java/app/playmancala/model/EndReason.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java rename to apps/backend/src/main/java/app/playmancala/model/EndReason.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java b/apps/backend/src/main/java/app/playmancala/model/FinishedGameSummary.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java rename to apps/backend/src/main/java/app/playmancala/model/FinishedGameSummary.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java b/apps/backend/src/main/java/app/playmancala/model/GameClock.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java rename to apps/backend/src/main/java/app/playmancala/model/GameClock.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java rename to apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java rename to apps/backend/src/main/java/app/playmancala/model/GameRecord.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java b/apps/backend/src/main/java/app/playmancala/model/GameResult.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java rename to apps/backend/src/main/java/app/playmancala/model/GameResult.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java b/apps/backend/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java rename to apps/backend/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/Json.java b/apps/backend/src/main/java/app/playmancala/model/Json.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/Json.java rename to apps/backend/src/main/java/app/playmancala/model/Json.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java b/apps/backend/src/main/java/app/playmancala/model/LiveGameSummary.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java rename to apps/backend/src/main/java/app/playmancala/model/LiveGameSummary.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java b/apps/backend/src/main/java/app/playmancala/model/MoveRecord.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java rename to apps/backend/src/main/java/app/playmancala/model/MoveRecord.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java b/apps/backend/src/main/java/app/playmancala/model/PlayerSummary.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java rename to apps/backend/src/main/java/app/playmancala/model/PlayerSummary.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java b/apps/backend/src/main/java/app/playmancala/model/Seat.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java rename to apps/backend/src/main/java/app/playmancala/model/Seat.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java b/apps/backend/src/main/java/app/playmancala/model/UserConnectionInfo.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java rename to apps/backend/src/main/java/app/playmancala/model/UserConnectionInfo.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java b/apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java rename to apps/backend/src/main/java/app/playmancala/rtmt/Rtmt.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java rename to apps/backend/src/main/java/app/playmancala/rtmt/RtmtEndpoint.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtServer.java b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtServer.java rename to apps/backend/src/main/java/app/playmancala/rtmt/RtmtServer.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtSocket.java b/apps/backend/src/main/java/app/playmancala/rtmt/RtmtSocket.java similarity index 100% rename from apps/backend-quarkus/src/main/java/app/playmancala/rtmt/RtmtSocket.java rename to apps/backend/src/main/java/app/playmancala/rtmt/RtmtSocket.java diff --git a/apps/backend-quarkus/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties similarity index 100% rename from apps/backend-quarkus/src/main/resources/application.properties rename to apps/backend/src/main/resources/application.properties diff --git a/apps/backend-quarkus/src/main/resources/db/migrations/001_games.sql b/apps/backend/src/main/resources/db/migrations/001_games.sql similarity index 100% rename from apps/backend-quarkus/src/main/resources/db/migrations/001_games.sql rename to apps/backend/src/main/resources/db/migrations/001_games.sql diff --git a/apps/backend-quarkus/src/main/resources/db/migrations/002_clock.sql b/apps/backend/src/main/resources/db/migrations/002_clock.sql similarity index 100% rename from apps/backend-quarkus/src/main/resources/db/migrations/002_clock.sql rename to apps/backend/src/main/resources/db/migrations/002_clock.sql 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 c9fd6ff..0000000 --- a/apps/backend/src/models/Game.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Game, GameClock } from "@mancala/core"; - -export interface PlayerInfo { - userId: string; - // Undefined for legacy userKey clients. - name?: string; - isGuest: boolean; -} - -export interface BackendGame extends Game { - // Users watching right now. - spectatorIds: string[]; - // Everyone who watched at some point, and the most at once. - allSpectatorIds: string[]; - peakSpectators: number; - // Keyed by user id, captured when the game is created. - players: Record; - startedAt: number; - // Time of each move, aligned with mancalaGame.history. - moveTimes: number[]; - clock: GameClock; - // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. - moveRemainingMs: (number | null)[]; -} 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 adc97c0..0000000 --- a/apps/backend/src/rtmt/rtmt.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AuthUser } from "../auth"; - -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; - getUser(clientID: string): AuthUser | undefined; - // Adds a listener; every listener is called. - listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void; -} diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts deleted file mode 100644 index e4fd828..0000000 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { OnClientConnectionChange, OnMessage, RTMT } from "./rtmt" - -import WebSocket from "ws" -import * as http from 'http'; -import { channel_auth, channel_on_auth, channel_on_auth_error, channel_ping, channel_pong, CLOSE_AUTH_FAILED } from "@mancala/core"; -import { RTMT_WS_PING_INTERVAL } from "../consts/config"; -import { decode, encode, Message } from "./encode_decode_message"; -import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth"; - -const DEFAULT_AUTH_TIMEOUT_MS = 5000; -const LEGACY_USER_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -type ClientSocket = WebSocket & { - isAlive?: boolean; - // Set once the socket is authenticated. - userId?: string; - authTimeout?: NodeJS.Timeout; -}; - -export class RTMTWS implements RTMT { - - private messageChannels = new Map() - - private wsServer: WebSocket.Server | null = null - - // A user can be connected from several devices or tabs at once. - private sockets = new Map>() - - private users = new Map() - - private pingInterval?: NodeJS.Timeout = undefined; - - private readonly tokenVerifier: TokenVerifier; - - private readonly legacyUserKeyAuth: boolean; - - private readonly authTimeoutMs: number; - - private readonly connectionChangeListeners: OnClientConnectionChange[] = []; - - constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) { - this.tokenVerifier = params.tokenVerifier; - this.legacyUserKeyAuth = params.legacyUserKeyAuth; - this.authTimeoutMs = params.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; - } - - initWebSocket(server: http.Server) { - const wsServer = new WebSocket.Server({ server }) - this.wsServer = wsServer - wsServer.on("connection", (ws: ClientSocket, req: http.IncomingMessage) => this.onConnection(ws, req)) - this.startPingInterval(wsServer); - } - - private onConnection(ws: ClientSocket, req: http.IncomingMessage) { - console.log("(RTMT) Client Connected"); - this.setWebSocketAsAlive(ws); - - ws.on("message", (data: WebSocket.Data) => { - this.onWebSocketMessage(ws, data.toString()) - }) - - ws.on("close", (code: number, reason: string) => { - console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason); - if (ws.authTimeout) clearTimeout(ws.authTimeout); - this.unbindSocket(ws); - }) - - ws.on("error", (err: Error) => { - // "close" follows, which cleans up. - console.log("(RTMT) WS Closed with error! error : " + err.message); - }) - - const legacyUserKey = new URL(req.url ?? "/", "http://localhost").searchParams.get("userKey"); - if (legacyUserKey !== null) { - if (this.legacyUserKeyAuth && LEGACY_USER_KEY_PATTERN.test(legacyUserKey)) { - this.bindSocket(ws, { id: legacyUserKey, isGuest: true }); - } else { - this.rejectSocket(ws, "userKey login is not accepted"); - } - return; - } - - ws.authTimeout = setTimeout(() => { - if (!ws.userId) this.rejectSocket(ws, "authentication timeout"); - }, this.authTimeoutMs); - } - - private authenticate(ws: ClientSocket, payload: any) { - const token = payload?.token; - if (typeof token !== "string") { - this.rejectSocket(ws, "missing token"); - return; - } - this.tokenVerifier.verify(token).then((user) => { - if (ws.readyState !== WebSocket.OPEN) return; - if (ws.userId && ws.userId !== user.id) { - this.rejectSocket(ws, "token belongs to another user"); - return; - } - if (ws.authTimeout) clearTimeout(ws.authTimeout); - if (ws.userId) { - // Token refresh on an already authenticated socket. - this.users.set(user.id, user); - } else { - this.bindSocket(ws, user); - } - this.sendMessageInternal(ws, channel_on_auth, { user }); - }).catch((err) => { - if (!(err instanceof InvalidTokenError)) console.error("(RTMT) Token verification failed", err); - this.rejectSocket(ws, err instanceof InvalidTokenError ? "invalid token" : "authentication failed"); - }); - } - - private rejectSocket(ws: ClientSocket, reason: string) { - if (ws.readyState !== WebSocket.OPEN) return; - this.sendMessageInternal(ws, channel_on_auth_error, { reason }); - ws.close(CLOSE_AUTH_FAILED, reason); - } - - private bindSocket(ws: ClientSocket, user: AuthUser) { - ws.userId = user.id; - this.users.set(user.id, user); - let userSockets = this.sockets.get(user.id); - if (!userSockets) { - userSockets = new Set(); - this.sockets.set(user.id, userSockets); - } - userSockets.add(ws); - // Fired for every new socket so each device gets the current game state. - this.connectionChangeListeners.forEach((listener) => listener(user.id, true)); - } - - private unbindSocket(ws: ClientSocket) { - const userId = ws.userId; - if (!userId) return; - const userSockets = this.sockets.get(userId); - if (!userSockets || !userSockets.delete(ws)) return; - if (userSockets.size === 0) { - this.sockets.delete(userId); - this.users.delete(userId); - this.connectionChangeListeners.forEach((listener) => listener(userId, false)); - } - } - - dispose() { - this.stopPingInterval(); - } - - sendMessage(clientID: string, channel: string, message: Object) { - if (this.wsServer) { - const userSockets = this.sockets.get(clientID) - if (userSockets) { - userSockets.forEach((ws) => this.sendMessageInternal(ws, channel, message)); - } else { - console.log("(RTMT) Client not connected!"); - } - } else { - console.log('(RTMT) ws is undefined') - } - } - - sendMessageInternal(ws: WebSocket, channel: string, message: Object) { - if (ws.readyState !== WebSocket.OPEN) return; - const data = encode({ channel: channel, message: message }) - ws.send(data) - } - - listenMessage(channel: string, callback: OnMessage) { - this.messageChannels.set(channel, callback) - } - - private onWebSocketMessage(ws: ClientSocket, messageBytes: string) { - let message: Message; - try { - message = decode(messageBytes); - } catch (err) { - console.log("(RTMT) Ignoring malformed message"); - return; - } - if (!message || typeof message.channel !== "string") return; - - if (message.channel === channel_pong) { - this.setWebSocketAsAlive(ws); - return; - } - if (message.channel === channel_auth) { - this.authenticate(ws, message.message); - return; - } - if (!ws.userId) { - console.log("(RTMT) Ignoring message from unauthenticated client on channel " + message.channel); - return; - } - this.onMessage(ws.userId, message.channel, message.message); - } - - onMessage(clientID: string, channel: string, message: Object) { - 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(() => { - wsServer.clients.forEach((ws: ClientSocket) => { - if (ws.isAlive === false) { - console.log("(RTMT) Terminated WebSocket Connection"); - return ws.terminate(); - } - ws.isAlive = false; - this.sendMessageInternal(ws, channel_ping, {}); - }); - }, RTMT_WS_PING_INTERVAL); - } - - stopPingInterval() { - if (this.pingInterval) clearInterval(this.pingInterval); - } - - setWebSocketAsAlive(ws: ClientSocket) { - ws.isAlive = true; - } - - isClientOnline(clientID: string): boolean { - return this.sockets.has(clientID); - } - - getUser(clientID: string): AuthUser | undefined { - return this.users.get(clientID); - } - - listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) { - this.connectionChangeListeners.push(onUserConnectionChange); - } -} diff --git a/apps/backend/src/server/ExpressApp.ts b/apps/backend/src/server/ExpressApp.ts deleted file mode 100644 index c523f74..0000000 --- a/apps/backend/src/server/ExpressApp.ts +++ /dev/null @@ -1,113 +0,0 @@ -import cors from "cors"; -import express from "express"; -import { NextFunction, Request, Response } from "express"; -import morgan from "morgan"; -import fs from "fs"; -import { FinishedGameResponse } from "@mancala/core"; -import { generateKey } from "../util/key_factory"; -import { GameStore } from "../game/gamestore/GameStore"; -import { GameManager } from "../game/GameManager"; -import { LiveGames, toLiveGameSummary } from "../game/LiveGames"; -import { GameHistoryService, MAX_LIST_LIMIT } from "../history/GameHistoryService"; -import { AuthUser, TokenVerifier } from "../auth"; -import { requireUser } from "./authMiddleware"; - -type AsyncHandler = (req: Request, res: Response) => Promise; - -// Express 4 does not catch rejected promises from handlers. -const handleAsync = (handler: AsyncHandler) => (req: Request, res: Response, next: NextFunction) => - handler(req, res).catch(next); - -const parseLimit = (value: unknown, fallback: number): number => { - const limit = Number.parseInt(String(value ?? ""), 10); - return Number.isNaN(limit) ? fallback : Math.min(Math.max(limit, 1), MAX_LIST_LIMIT); -}; - -export class ExpressApp { - app: Express.Application; - private readonly params: { - gameStore: GameStore, - gameManager: GameManager, - liveGames: LiveGames, - history: GameHistoryService, - tokenVerifier: TokenVerifier, - legacyUserKeyAuth: boolean, - accessLog?: boolean, - }; - - constructor(params: ExpressApp["params"]) { - this.params = params; - this.app = this.createExpressApp(); - } - - private createAccessLogMiddleware() { - return morgan("common", { - stream: fs.createWriteStream("./access.log", { flags: "a" }), - }); - } - - public createExpressApp(): Express.Application { - const { gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth } = this.params; - const app = express(); - app.use(cors()); - if (this.params.accessLog !== false) { - app.use(this.createAccessLogMiddleware()); - app.use(morgan("dev")); - } - app.get("/", (req: Request, res: Response) => { - res.send("Server up and running! ci test1"); - }); - // Issues userKeys for legacy clients; token clients get their identity from Keycloak. - if (legacyUserKeyAuth) { - app.get("/register/", (req: Request, res: Response) => { - res.send(generateKey()); - }); - } - - // A live game as players receive it, or the saved record of a finished game. - app.get("/game/:id", handleAsync(async (req, res) => { - const game = gameStore.get(req.params.id); - if (game) { - res.send(gameManager.toClientGame(game)); - return; - } - const record = await history.get(req.params.id); - if (record) { - const response: FinishedGameResponse = { status: "finished", record }; - res.send(response); - } else { - res.sendStatus(404); - } - })); - - // 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. - app.get("/games/popular", handleAsync(async (req, res) => { - const limit = parseLimit(req.query.limit, 5); - const live = req.query.kind === "finished" ? [] : liveGames.getTop(limit); - if (live.length > 0) { - res.send({ kind: "live", games: live }); - } else { - res.send({ kind: "finished", games: await history.getPopular(limit) }); - } - })); - - app.get("/games/me/active", requireUser(tokenVerifier), (req: Request, res: Response) => { - const user = res.locals.user as AuthUser; - const game = gameStore.getGameByUser(user.id); - res.send({ game: game ? toLiveGameSummary(game) : null }); - }); - - app.get("/games/me", requireUser(tokenVerifier), handleAsync(async (req, res) => { - const user = res.locals.user as AuthUser; - res.send({ games: await history.getRecentForUser(user.id, parseLimit(req.query.limit, 3)) }); - })); - - app.use((err: Error, req: Request, res: Response, next: NextFunction) => { - console.error(`(HTTP) ${req.method} ${req.path} failed:`, err.message); - res.sendStatus(503); - }); - - 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/server/authMiddleware.ts b/apps/backend/src/server/authMiddleware.ts deleted file mode 100644 index 8f612e6..0000000 --- a/apps/backend/src/server/authMiddleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth"; - -// Requires an "Authorization: Bearer " header with the same Keycloak token the WebSocket uses. -// The user is available as res.locals.user. -export function requireUser(tokenVerifier: TokenVerifier) { - return async (req: Request, res: Response, next: NextFunction) => { - const match = /^Bearer (.+)$/.exec(req.headers.authorization ?? ""); - if (!match) { - res.sendStatus(401); - return; - } - try { - res.locals.user = await tokenVerifier.verify(match[1]) as AuthUser; - next(); - } catch (err) { - if (!(err instanceof InvalidTokenError)) console.error("(Auth) Token verification failed", err); - res.sendStatus(401); - } - }; -} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java b/apps/backend/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java rename to apps/backend/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/engine/EngineParityTest.java b/apps/backend/src/test/java/app/playmancala/engine/EngineParityTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/engine/EngineParityTest.java rename to apps/backend/src/test/java/app/playmancala/engine/EngineParityTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/engine/MancalaGameTest.java b/apps/backend/src/test/java/app/playmancala/engine/MancalaGameTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/engine/MancalaGameTest.java rename to apps/backend/src/test/java/app/playmancala/engine/MancalaGameTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java b/apps/backend/src/test/java/app/playmancala/fakes/FakeRtmt.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java rename to apps/backend/src/test/java/app/playmancala/fakes/FakeRtmt.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryCache.java b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryCache.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryCache.java rename to apps/backend/src/test/java/app/playmancala/fakes/InMemoryCache.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java rename to apps/backend/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java b/apps/backend/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java rename to apps/backend/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java b/apps/backend/src/test/java/app/playmancala/game/GameClockSchedulerTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java rename to apps/backend/src/test/java/app/playmancala/game/GameClockSchedulerTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java rename to apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java b/apps/backend/src/test/java/app/playmancala/game/LiveGamesTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java rename to apps/backend/src/test/java/app/playmancala/game/LiveGamesTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/history/GameHistoryServiceTest.java b/apps/backend/src/test/java/app/playmancala/history/GameHistoryServiceTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/history/GameHistoryServiceTest.java rename to apps/backend/src/test/java/app/playmancala/history/GameHistoryServiceTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java rename to apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/http/ApiTest.java b/apps/backend/src/test/java/app/playmancala/http/ApiTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/http/ApiTest.java rename to apps/backend/src/test/java/app/playmancala/http/ApiTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/live/ValkeyStoreTest.java b/apps/backend/src/test/java/app/playmancala/live/ValkeyStoreTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/live/ValkeyStoreTest.java rename to apps/backend/src/test/java/app/playmancala/live/ValkeyStoreTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java b/apps/backend/src/test/java/app/playmancala/loop/ManualScheduler.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java rename to apps/backend/src/test/java/app/playmancala/loop/ManualScheduler.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java b/apps/backend/src/test/java/app/playmancala/loop/ManualSchedulerTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java rename to apps/backend/src/test/java/app/playmancala/loop/ManualSchedulerTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/rtmt/RtmtServerTest.java b/apps/backend/src/test/java/app/playmancala/rtmt/RtmtServerTest.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/rtmt/RtmtServerTest.java rename to apps/backend/src/test/java/app/playmancala/rtmt/RtmtServerTest.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/support/FakeTokenVerifier.java b/apps/backend/src/test/java/app/playmancala/support/FakeTokenVerifier.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/support/FakeTokenVerifier.java rename to apps/backend/src/test/java/app/playmancala/support/FakeTokenVerifier.java diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/support/TestClient.java b/apps/backend/src/test/java/app/playmancala/support/TestClient.java similarity index 100% rename from apps/backend-quarkus/src/test/java/app/playmancala/support/TestClient.java rename to apps/backend/src/test/java/app/playmancala/support/TestClient.java diff --git a/apps/backend-quarkus/src/test/resources/engine/recorded-games.json b/apps/backend/src/test/resources/engine/recorded-games.json similarity index 100% rename from apps/backend-quarkus/src/test/resources/engine/recorded-games.json rename to apps/backend/src/test/resources/engine/recorded-games.json 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/auth/KeycloakTokenVerifier.test.ts b/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts deleted file mode 100644 index 8cbc735..0000000 --- a/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createLocalJWKSet, exportJWK, generateKeyPair, JWK, JWTPayload, KeyLike, SignJWT } from "jose"; -import { InvalidTokenError, KeycloakTokenVerifier } from "../../src/auth"; - -const ISSUER = "http://keycloak.test/realms/mancala"; - -describe("KeycloakTokenVerifier", () => { - let privateKey: KeyLike; - let otherPrivateKey: KeyLike; - let verifier: KeycloakTokenVerifier; - - beforeAll(async () => { - const keyPair = await generateKeyPair("RS256"); - privateKey = keyPair.privateKey; - otherPrivateKey = (await generateKeyPair("RS256")).privateKey; - const publicJwk: JWK = { ...(await exportJWK(keyPair.publicKey)), kid: "test-key", alg: "RS256" }; - verifier = new KeycloakTokenVerifier({ - issuer: ISSUER, - allowedClientIds: ["mancala-web"], - keys: createLocalJWKSet({ keys: [publicJwk] }), - }); - }); - - const sign = (claims: JWTPayload, options: { key?: KeyLike, expiresIn?: string, issuer?: string } = {}) => - new SignJWT({ typ: "Bearer", azp: "mancala-web", preferred_username: "alice", ...claims }) - .setProtectedHeader({ alg: "RS256", kid: "test-key" }) - .setSubject("user-1") - .setIssuer(options.issuer ?? ISSUER) - .setIssuedAt() - .setExpirationTime(options.expiresIn ?? "5m") - .sign(options.key ?? privateKey); - - it("returns the user of a valid access token", async () => { - const user = await verifier.verify(await sign({ realm_access: { roles: ["offline_access"] } })); - expect(user).toEqual({ id: "kc:user-1", name: "alice", isGuest: false }); - }); - - it("marks users with the guest role as guests", async () => { - const user = await verifier.verify(await sign({ preferred_username: "guest-1a2b3c4d", realm_access: { roles: ["guest"] } })); - expect(user).toEqual({ id: "kc:user-1", name: "guest-1a2b3c4d", isGuest: true }); - }); - - it.each([ - ["signed with another key", () => sign({}, { key: otherPrivateKey })], - ["from another issuer", () => sign({}, { issuer: "http://evil.test/realms/mancala" })], - ["expired", () => sign({}, { expiresIn: "-1m" })], - ["an ID token", () => sign({ typ: "ID" })], - ["a refresh token", () => sign({ typ: "Refresh" })], - ["issued to another client", () => sign({ azp: "some-other-app" })], - ["not a JWT", async () => "not-a-token"], - ])("rejects a token %s", async (_, makeToken) => { - await expect(verifier.verify(await makeToken())).rejects.toBeInstanceOf(InvalidTokenError); - }); - - it("rejects unsigned tokens", async () => { - const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); - const payload = Buffer.from(JSON.stringify({ - iss: ISSUER, sub: "user-1", typ: "Bearer", azp: "mancala-web", exp: Math.floor(Date.now() / 1000) + 300, - })).toString("base64url"); - await expect(verifier.verify(`${header}.${payload}.`)).rejects.toBeInstanceOf(InvalidTokenError); - }); -}); diff --git a/apps/backend/tests/game/FakeRtmt.ts b/apps/backend/tests/game/FakeRtmt.ts deleted file mode 100644 index 9ce2d4f..0000000 --- a/apps/backend/tests/game/FakeRtmt.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { AuthUser } from "../../src/auth"; -import { OnClientConnectionChange, OnMessage, RTMT } from "../../src/rtmt/rtmt"; - -export type SentMessage = { clientID: string, channel: string, message: any }; - -// In-memory RTMT: tests connect users, send messages as them and inspect what the server sent back. -export class FakeRtmt implements RTMT { - readonly sent: SentMessage[] = []; - private readonly listeners = new Map(); - private readonly online = new Map(); - private readonly connectionListeners: OnClientConnectionChange[] = []; - - connect(user: AuthUser) { - this.online.set(user.id, user); - this.connectionListeners.forEach((listener) => listener(user.id, true)); - } - - disconnect(userId: string) { - this.online.delete(userId); - this.connectionListeners.forEach((listener) => listener(userId, false)); - } - - receive(userId: string, channel: string, message: Object = {}) { - const listener = this.listeners.get(channel); - if (!listener) throw new Error(`No listener for ${channel}`); - listener(userId, message); - } - - messagesTo(userId: string, channel: string): any[] { - return this.sent.filter((m) => m.clientID === userId && m.channel === channel).map((m) => m.message); - } - - sendMessage(clientID: string, channel: string, message: Object) { - // Copy, like serialising over the wire would. - this.sent.push({ clientID, channel, message: JSON.parse(JSON.stringify(message)) }); - } - - listenMessage(channel: string, callback: OnMessage) { - this.listeners.set(channel, callback); - } - - isClientOnline(clientID: string): boolean { - return this.online.has(clientID); - } - - getUser(clientID: string): AuthUser | undefined { - return this.online.get(clientID); - } - - listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void { - this.connectionListeners.push(onUserConnectionChange); - } -} diff --git a/apps/backend/tests/game/GameClockScheduler.test.ts b/apps/backend/tests/game/GameClockScheduler.test.ts deleted file mode 100644 index c5f8dce..0000000 --- a/apps/backend/tests/game/GameClockScheduler.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GameClockScheduler } from "../../src/game/GameClockScheduler"; - -describe("GameClockScheduler", () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it("ends games out of time on every interval until stopped", () => { - const gameManager = { endGamesOutOfTime: jest.fn() }; - const scheduler = new GameClockScheduler(gameManager, 1_000); - scheduler.start(); - scheduler.start(); // starting twice does not run it twice - - jest.advanceTimersByTime(999); - expect(gameManager.endGamesOutOfTime).not.toHaveBeenCalled(); - jest.advanceTimersByTime(2_001); - expect(gameManager.endGamesOutOfTime).toHaveBeenCalledTimes(3); - - scheduler.stop(); - jest.advanceTimersByTime(10_000); - expect(gameManager.endGamesOutOfTime).toHaveBeenCalledTimes(3); - }); -}); diff --git a/apps/backend/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts deleted file mode 100644 index 2ad8a21..0000000 --- a/apps/backend/tests/game/GameManager.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { - channel_game_move, - channel_leave_game, - channel_listen_game_events, - channel_new_game, - channel_on_game_end, - channel_on_game_start, - channel_on_game_update, - channel_on_game_user_leave, - channel_on_new_game_rejected, - channel_on_user_connection_change, -} from "@mancala/core"; -import { GameManager } from "../../src/game/GameManager"; -import { GameRecorder } from "../../src/game/GameRecorder"; -import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; -import { publicUserId } from "../../src/game/publicIds"; -import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; -import { BackendGame } from "../../src/models/Game"; -import { FakeRtmt } from "./FakeRtmt"; -import { flushPromises, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes"; - -const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; -const BOB = { id: "kc:bob", name: "guest-1a2b3c4d", isGuest: true }; -const CAROL = { id: "kc:carol", name: "carol", isGuest: false }; -const DAVE = { id: "kc:dave", name: "dave", isGuest: false }; -const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true }; -const CLOCK_MS = 5 * 60_000; - -describe("GameManager", () => { - let now: number; - let rtmt: FakeRtmt; - let gameStore: GameStoreImpl; - let liveGameStore: InMemoryLiveGameStore; - let repository: InMemoryGameHistoryRepository; - let gameManager: GameManager; - - const createManager = () => { - rtmt = new FakeRtmt(); - gameStore = new GameStoreImpl(); - const recorder = new GameRecorder(repository, liveGameStore); - gameManager = new GameManager({ - rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now, - gameClockMs: CLOCK_MS, - }); - return recorder; - }; - - beforeEach(() => { - now = Date.UTC(2026, 9, 16, 12); - liveGameStore = new InMemoryLiveGameStore(); - repository = new InMemoryGameHistoryRepository(); - createManager(); - [ALICE, BOB, CAROL, DAVE].forEach((user) => rtmt.connect(user)); - }); - - const startGame = (first = ALICE, second = BOB): BackendGame => { - rtmt.receive(first.id, channel_new_game); - rtmt.receive(second.id, channel_new_game); - const game = gameStore.getGameByUser(first.id); - if (!game) throw new Error("game did not start"); - return game; - }; - - // Plays the current player's first non-empty pit. - const playAnyMove = (game: BackendGame) => { - const { turnPlayerId } = game.mancalaGame; - const pit = [0, 1, 2, 3, 4, 5].find((p) => - game.mancalaGame.board.pits[game.mancalaGame.getBoardIndexByPlayerId(turnPlayerId, p)].stoneCount > 0)!; - rtmt.receive(turnPlayerId, channel_game_move, { index: pit }); - }; - - // Leaves the given player a single stone next to their store; playing it empties their side and ends the game. - const setUpLastMove = (game: BackendGame, playerId: string) => { - const isPlayer1 = game.mancalaGame.player1Id === playerId; - const pits = game.mancalaGame.board.pits; - pits.forEach((pit) => (pit.stoneCount = 0)); - pits[isPlayer1 ? 5 : 12].stoneCount = 1; - pits[isPlayer1 ? 7 : 0].stoneCount = 1; - game.mancalaGame.turnPlayerId = playerId; - }; - - describe("while a game is played", () => { - it("sends the connection info once when a game starts", () => { - startGame(); - expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)).toHaveLength(1); - expect(rtmt.messagesTo(ALICE.id, channel_on_user_connection_change)).toHaveLength(1); - }); - - it("does not send spectator ids to clients", () => { - const game = startGame(); - rtmt.receive(CAROL.id, channel_listen_game_events, game.id); - playAnyMove(game); - const update = rtmt.messagesTo(ALICE.id, channel_on_game_update).pop(); - expect(update).not.toHaveProperty("spectatorIds"); - expect(update).not.toHaveProperty("allSpectatorIds"); - expect(update.spectatorCount).toBe(1); - }); - - it("records the time of each move", () => { - const game = startGame(); - now += 1000; - playAnyMove(game); - now += 2000; - playAnyMove(game); - expect(game.moveTimes).toEqual([game.startedAt + 1000, game.startedAt + 3000]); - expect(rtmt.messagesTo(ALICE.id, channel_on_game_update).pop().moveTimes).toHaveLength(2); - }); - - it("rejects a new game for a player who has one in progress", () => { - const game = startGame(); - rtmt.receive(ALICE.id, channel_new_game); - rtmt.receive(CAROL.id, channel_new_game); - expect(rtmt.messagesTo(ALICE.id, channel_on_new_game_rejected)).toEqual([{ gameId: game.id }]); - expect(gameStore.getGameByUser(ALICE.id)).toBe(game); - expect(gameStore.getGameByUser(CAROL.id)).toBeUndefined(); - }); - - it("counts spectators: removed on disconnect, peak and distinct total kept", async () => { - const game = startGame(); - rtmt.receive(CAROL.id, channel_listen_game_events, game.id); - rtmt.receive(DAVE.id, channel_listen_game_events, game.id); - rtmt.disconnect(CAROL.id); - rtmt.connect(CAROL); - rtmt.receive(CAROL.id, channel_listen_game_events, game.id); - rtmt.disconnect(DAVE.id); - expect(game.spectatorIds).toEqual([CAROL.id]); - expect(game.peakSpectators).toBe(2); - - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - expect(repository.records.get(game.id)).toMatchObject({ peakSpectators: 2, totalSpectators: 2 }); - }); - - it("keeps a snapshot of the live game until it ends", async () => { - const game = startGame(); - playAnyMove(game); - await flushPromises(); - expect(liveGameStore.games.get(game.id)).toMatchObject({ id: game.id, moveTimes: [now] }); - - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - expect(liveGameStore.games.has(game.id)).toBe(false); - }); - }); - - describe("clock", () => { - const seatOf = (game: BackendGame, playerId: string) => game.mancalaGame.player1Id === playerId ? "player1Ms" : "player2Ms"; - const opponentOf = (playerId: string) => playerId === ALICE.id ? BOB.id : ALICE.id; - - it("starts with the full time for both players, running for the player who moves first", () => { - const game = startGame(); - expect(game.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now }); - expect(rtmt.messagesTo(ALICE.id, channel_on_game_start)[0]).toMatchObject({ clock: game.clock, serverTime: now }); - }); - - it("a move deducts the turn's time from the mover only and starts the opponent's clock", () => { - const game = startGame(); - const first = game.mancalaGame.turnPlayerId; - now += 7_000; - playAnyMove(game); - expect(game.mancalaGame.turnPlayerId).toBe(opponentOf(first)); - expect(game.clock[seatOf(game, first)]).toBe(CLOCK_MS - 7_000); - expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS); - expect(game.clock.turnStartedAt).toBe(now); - expect(rtmt.messagesTo(BOB.id, channel_on_game_update).pop()).toMatchObject({ clock: game.clock, serverTime: now }); - - now += 3_000; - playAnyMove(game); - expect(game.clock[seatOf(game, opponentOf(first))]).toBe(CLOCK_MS - 3_000); - expect(game.moveRemainingMs).toEqual([CLOCK_MS - 7_000, CLOCK_MS - 3_000]); - }); - - it("an extra turn keeps the mover's clock running", () => { - const game = startGame(); - const mover = game.mancalaGame.turnPlayerId; - now += 2_000; - // Four stones from the fourth pit: the last one lands in the mover's store. - rtmt.receive(mover, channel_game_move, { index: 3 }); - expect(game.mancalaGame.turnPlayerId).toBe(mover); - now += 1_000; - playAnyMove(game); - expect(game.moveRemainingMs).toEqual([CLOCK_MS - 2_000, CLOCK_MS - 3_000]); - }); - - it("a move with time left is played", () => { - const game = startGame(); - now += CLOCK_MS - 1; - playAnyMove(game); - expect(game.mancalaGame.history).toHaveLength(1); - expect(game.moveRemainingMs).toEqual([1]); - }); - - it("a move that arrives after the time has run out is not played, and the opponent wins", async () => { - const game = startGame(); - const mover = game.mancalaGame.turnPlayerId; - now += CLOCK_MS; - playAnyMove(game); - await flushPromises(); - - expect(game.mancalaGame.history).toHaveLength(0); - expect(gameStore.get(game.id)).toBeUndefined(); - const winnerSeat = game.mancalaGame.player1Id === mover ? "player2" : "player1"; - expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout", result: winnerSeat }); - const end = rtmt.messagesTo(mover, channel_on_game_end)[0]; - expect(end).toMatchObject({ endReason: "timeout", winnerId: opponentOf(mover) }); - expect(end.clock).toEqual({ initialMs: CLOCK_MS, [seatOf(game, mover)]: 0, [seatOf(game, opponentOf(mover))]: CLOCK_MS }); - }); - - it("stops when the game ends, after deducting the running player's time", () => { - const game = startGame(); - const mover = game.mancalaGame.turnPlayerId; - now += 4_000; - rtmt.receive(ALICE.id, channel_leave_game); - expect(game.clock).toEqual({ - initialMs: CLOCK_MS, [seatOf(game, mover)]: CLOCK_MS - 4_000, [seatOf(game, opponentOf(mover))]: CLOCK_MS, - }); - expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock); - }); - - it("stops when the game is completed by the rules", () => { - const game = startGame(); - playAnyMove(game); - setUpLastMove(game, ALICE.id); - now += 1_000; - rtmt.receive(ALICE.id, channel_game_move, { index: 5 }); - expect(game.clock.turnStartedAt).toBeUndefined(); - expect(rtmt.messagesTo(BOB.id, channel_on_game_end)[0].clock).toEqual(game.clock); - }); - }); - - describe("ending games on time", () => { - it("ends only the games whose running clock has reached zero, telling players and spectators", async () => { - const late = startGame(ALICE, BOB); - rtmt.receive(DAVE.id, channel_listen_game_events, late.id); - now += 60_000; - const early = startGame(CAROL, { ...DAVE, id: "kc:erin", name: "erin" }); - const latePlayer = late.mancalaGame.turnPlayerId; - - now += CLOCK_MS - 60_000 - 1; - gameManager.endGamesOutOfTime(); - expect(gameStore.get(late.id)).toBe(late); - - now += 1; - gameManager.endGamesOutOfTime(); - await flushPromises(); - expect(gameStore.get(late.id)).toBeUndefined(); - expect(gameStore.get(early.id)).toBe(early); - const winnerSeat = late.mancalaGame.player1Id === latePlayer ? "player2" : "player1"; - expect(repository.records.get(late.id)).toMatchObject({ endReason: "timeout", result: winnerSeat }); - for (const user of [ALICE, BOB, DAVE]) { - expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([ - expect.objectContaining({ gameId: late.id, endReason: "timeout", result: winnerSeat }), - ]); - } - }); - - it("keeps the clock running while players are offline, so games nobody comes back to end too", async () => { - const game = startGame(); - rtmt.disconnect(ALICE.id); - rtmt.disconnect(BOB.id); - now += CLOCK_MS; - gameManager.endGamesOutOfTime(); - await flushPromises(); - expect(repository.records.get(game.id)).toMatchObject({ endReason: "timeout" }); - }); - - it("no longer sends when players went offline", () => { - startGame(); - rtmt.disconnect(BOB.id); - const info = rtmt.messagesTo(ALICE.id, channel_on_user_connection_change).pop(); - expect([info.user1ConnectionInfo, info.user2ConnectionInfo].find((i: any) => i.userId === BOB.id)) - .toEqual({ userId: BOB.id, name: BOB.name, isGuest: true, isOnline: false }); - }); - }); - - describe("how games end", () => { - it("completed: the winner by the rules, with every move saved", async () => { - const game = startGame(); - playAnyMove(game); - setUpLastMove(game, ALICE.id); - rtmt.receive(ALICE.id, channel_game_move, { index: 5 }); - await flushPromises(); - - // Alice's stone goes into her store and empties her side, so she also collects Bob's last stone: 2-0. - const aliceSeat = game.mancalaGame.player1Id === ALICE.id ? "player1" : "player2"; - const record = repository.records.get(game.id)!; - expect(record.endReason).toBe("completed"); - expect(record.result).toBe(aliceSeat); - expect(record.moves).toHaveLength(2); - expect(record.moves.map((m) => m.at)).toEqual(game.moveTimes); - expect(gameStore.get(game.id)).toBeUndefined(); - expect(rtmt.messagesTo(BOB.id, channel_on_game_end)).toEqual([ - { gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) }, - ]); - }); - - it("left: the player who leaves loses", async () => { - const game = startGame(); - rtmt.receive(CAROL.id, channel_listen_game_events, game.id); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - - const record = repository.records.get(game.id)!; - const bobSeat = game.mancalaGame.player1Id === BOB.id ? "player1" : "player2"; - expect(record).toMatchObject({ endReason: "left", result: bobSeat, leftUserId: ALICE.id }); - for (const user of [ALICE, BOB, CAROL]) { - expect(rtmt.messagesTo(user.id, channel_on_game_user_leave)).toEqual([ALICE.id]); - expect(rtmt.messagesTo(user.id, channel_on_game_end)).toEqual([ - { - gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id, - clock: expect.any(Object), - }, - ]); - } - }); - - }); - - describe("saving", () => { - it("saves the initial time and each move's time left", async () => { - const game = startGame(); - now += 4_000; - playAnyMove(game); - now += 6_000; - playAnyMove(game); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - - const record = repository.records.get(game.id)!; - expect(record.clockInitialMs).toBe(CLOCK_MS); - expect(record.moves.map((move) => move.remainingMs)).toEqual([CLOCK_MS - 4_000, CLOCK_MS - 6_000]); - }); - - it("replaces legacy ids with public ids in saved games", async () => { - rtmt.connect({ id: LEGACY.id, isGuest: true }); - const game = startGame(ALICE, { ...LEGACY, name: undefined } as any); - playAnyMove(game); - rtmt.receive(LEGACY.id, channel_leave_game); - await flushPromises(); - - const saved = JSON.stringify(repository.records.get(game.id)); - expect(saved).not.toContain(LEGACY.id); - expect(saved).toContain(publicUserId(LEGACY.id)); - expect(saved).toContain(ALICE.id); - }); - - it("keeps a game that could not be saved and saves it once the database is back", async () => { - repository.failing = true; - const game = startGame(); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - expect(liveGameStore.pending.has(game.id)).toBe(true); - - // A restarted backend picks the pending record up from Valkey. - repository.failing = false; - const recorder = createManager(); - await recorder.retryPending(); - expect(repository.records.has(game.id)).toBe(true); - expect(liveGameStore.pending.has(game.id)).toBe(false); - }); - - it("keeps games playable while Valkey and the database are down", async () => { - liveGameStore.failing = true; - repository.failing = true; - const game = startGame(); - playAnyMove(game); - playAnyMove(game); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - expect(game.mancalaGame.history).toHaveLength(2); - expect(gameStore.get(game.id)).toBeUndefined(); - }); - }); - - describe("restart", () => { - it("restores live games, which can then be played on", async () => { - const game = startGame(); - playAnyMove(game); - playAnyMove(game); - await flushPromises(); - const board = game.mancalaGame.board.getStoneArray(); - const turnPlayer = game.mancalaGame.turnPlayerId; - - createManager(); - expect(await gameManager.restoreLiveGames()).toBe(1); - const restored = gameStore.getGameByUser(ALICE.id)!; - expect(restored.id).toBe(game.id); - expect(restored.mancalaGame.board.getStoneArray()).toEqual(board); - expect(restored.mancalaGame.turnPlayerId).toBe(turnPlayer); - expect(restored.mancalaGame.history).toHaveLength(2); - expect(gameStore.getGameByUser(BOB.id)).toBe(restored); - - rtmt.connect(ALICE); - rtmt.connect(BOB); - expect(rtmt.messagesTo(ALICE.id, channel_on_game_update)).toHaveLength(1); - playAnyMove(restored); - expect(restored.mancalaGame.history).toHaveLength(3); - }); - - it("restores the clock without charging the time the backend was down", async () => { - const game = startGame(); - const first = game.mancalaGame.turnPlayerId; - const firstSeat = game.mancalaGame.player1Id === first ? "player1Ms" : "player2Ms"; - const secondSeat = firstSeat === "player1Ms" ? "player2Ms" : "player1Ms"; - now += 10_000; - playAnyMove(game); - await flushPromises(); - now += 5_000; // the second player thinks, then the backend goes down for a minute - now += 60_000; - - createManager(); - await gameManager.restoreLiveGames(); - const restored = gameStore.getGameByUser(ALICE.id)!; - expect(restored.clock).toEqual({ - initialMs: CLOCK_MS, [firstSeat]: CLOCK_MS - 10_000, [secondSeat]: CLOCK_MS, turnStartedAt: now, - }); - expect(restored.moveRemainingMs).toEqual([CLOCK_MS - 10_000]); - - now += 1_000; - playAnyMove(restored); - expect(restored.clock[secondSeat]).toBe(CLOCK_MS - 1_000); - }); - - it("gives games from before the game clock a new clock", async () => { - const game = startGame(); - playAnyMove(game); - playAnyMove(game); - await flushPromises(); - const { clock, moveRemainingMs, ...rest } = liveGameStore.games.get(game.id) as any; - liveGameStore.games.set(game.id, { ...rest, version: 1 }); - - now += 60_000; - createManager(); - expect(await gameManager.restoreLiveGames()).toBe(1); - const restored = gameStore.getGameByUser(ALICE.id)!; - expect(restored.clock).toEqual({ initialMs: CLOCK_MS, player1Ms: CLOCK_MS, player2Ms: CLOCK_MS, turnStartedAt: now }); - expect(restored.moveRemainingMs).toEqual([null, null]); - - now += 2_000; - playAnyMove(restored); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - const record = repository.records.get(game.id)!; - expect(record.clockInitialMs).toBe(CLOCK_MS); - expect(record.moves.map((move) => move.remainingMs)).toEqual([undefined, undefined, CLOCK_MS - 2_000]); - }); - - it("does not bring back a game that has already been saved", async () => { - const game = startGame(); - await flushPromises(); - const snapshot = liveGameStore.games.get(game.id); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - liveGameStore.games.set(game.id, snapshot); // as if removing it had failed - - createManager(); - expect(await gameManager.restoreLiveGames()).toBe(0); - await flushPromises(); - expect(liveGameStore.games.has(game.id)).toBe(false); - }); - }); -}); diff --git a/apps/backend/tests/game/LiveGames.test.ts b/apps/backend/tests/game/LiveGames.test.ts deleted file mode 100644 index ef950a5..0000000 --- a/apps/backend/tests/game/LiveGames.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - channel_game_move, - channel_listen_game_events, - channel_new_game, - channel_on_live_games, - channel_subscribe_live_games, - channel_unsubscribe_live_games, -} from "@mancala/core"; -import { GameManager } from "../../src/game/GameManager"; -import { GameRecorder } from "../../src/game/GameRecorder"; -import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; -import { LiveGames } from "../../src/game/LiveGames"; -import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; -import { FakeRtmt } from "./FakeRtmt"; -import { InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes"; - -const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; -const BOB = { id: "kc:bob", name: "bob", isGuest: false }; -const VIEWER = { id: "kc:viewer", name: "viewer", isGuest: false }; -const SPECTATOR = { id: "kc:spectator", name: "spectator", isGuest: false }; - -describe("LiveGames", () => { - let rtmt: FakeRtmt; - let gameStore: GameStoreImpl; - let liveGames: LiveGames; - - beforeEach(() => { - jest.useFakeTimers("modern"); - rtmt = new FakeRtmt(); - gameStore = new GameStoreImpl(); - const recorder = new GameRecorder(new InMemoryGameHistoryRepository(), new InMemoryLiveGameStore()); - const gameManager = new GameManager({ - rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore: new InMemoryLiveGameStore(), recorder, - }); - liveGames = new LiveGames({ rtmt, gameStore, gameManager }); - [ALICE, BOB, VIEWER, SPECTATOR].forEach((user) => rtmt.connect(user)); - }); - - afterEach(() => { - liveGames.dispose(); - jest.useRealTimers(); - }); - - const pushes = () => rtmt.messagesTo(VIEWER.id, channel_on_live_games); - - const startGame = () => { - rtmt.receive(ALICE.id, channel_new_game); - rtmt.receive(BOB.id, channel_new_game); - return gameStore.getGameByUser(ALICE.id)!; - }; - - it("sends the current games on subscribe", () => { - const game = startGame(); - rtmt.receive(VIEWER.id, channel_subscribe_live_games); - expect(pushes()).toHaveLength(1); - expect(pushes()[0].games.map((g: any) => g.id)).toEqual([game.id]); - }); - - it("pushes changes at most once per second", () => { - rtmt.receive(VIEWER.id, channel_subscribe_live_games); - const game = startGame(); - jest.advanceTimersByTime(0); - expect(pushes()).toHaveLength(2); - - // Two changes right after a push are sent together, a second later. - rtmt.receive(game.mancalaGame.turnPlayerId, channel_game_move, { index: 0 }); - rtmt.receive(SPECTATOR.id, channel_listen_game_events, game.id); - jest.advanceTimersByTime(999); - expect(pushes()).toHaveLength(2); - jest.advanceTimersByTime(1); - expect(pushes()).toHaveLength(3); - expect(pushes()[2].games[0]).toMatchObject({ moveCount: 1, spectatorCount: 1 }); - }); - - it("does not count subscribers as spectators", () => { - const game = startGame(); - rtmt.receive(VIEWER.id, channel_subscribe_live_games); - expect(game.spectatorIds).toEqual([]); - expect(pushes()[0].games[0].spectatorCount).toBe(0); - }); - - it("stops pushing to users who unsubscribe or go offline", () => { - rtmt.receive(VIEWER.id, channel_subscribe_live_games); - rtmt.receive(SPECTATOR.id, channel_subscribe_live_games); - rtmt.receive(VIEWER.id, channel_unsubscribe_live_games); - rtmt.disconnect(SPECTATOR.id); - const before = rtmt.sent.length; - startGame(); - jest.advanceTimersByTime(1000); - expect(rtmt.sent.slice(before).filter((m) => m.channel === channel_on_live_games)).toEqual([]); - }); -}); diff --git a/apps/backend/tests/game/fakes.ts b/apps/backend/tests/game/fakes.ts deleted file mode 100644 index 22bc272..0000000 --- a/apps/backend/tests/game/fakes.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { FinishedGameSummary, GameRecord } from "@mancala/core"; -import { GameHistoryRepository } from "../../src/history/GameHistoryRepository"; -import { LiveGameStore } from "../../src/live/LiveGameStore"; -import { Cache } from "../../src/cache/Cache"; - -export class InMemoryLiveGameStore implements LiveGameStore { - readonly games = new Map(); - readonly pending = new Map(); - failing = false; - - private check() { - if (this.failing) return Promise.reject(new Error("Valkey is down")); - return Promise.resolve(); - } - - async saveGame(gameId: string, snapshot: unknown) { - await this.check(); - this.games.set(gameId, JSON.parse(JSON.stringify(snapshot))); - } - async removeGame(gameId: string) { - await this.check(); - this.games.delete(gameId); - } - async loadGames() { - await this.check(); - return [...this.games.values()]; - } - async savePendingRecord(record: GameRecord) { - await this.check(); - this.pending.set(record.id, record); - } - async removePendingRecord(gameId: string) { - await this.check(); - this.pending.delete(gameId); - } - async loadPendingRecords() { - await this.check(); - return [...this.pending.values()]; - } -} - -// Lists leave out the moves, like the Postgres repository. -const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary; - -export class InMemoryGameHistoryRepository implements GameHistoryRepository { - readonly records = new Map(); - failing = false; - - async save(record: GameRecord) { - if (this.failing) throw new Error("database is down"); - if (!this.records.has(record.id)) this.records.set(record.id, record); - } - async get(id: string) { - if (this.failing) throw new Error("database is down"); - return this.records.get(id); - } - async getRecentForUser(userId: string, limit: number): Promise { - return [...this.records.values()] - .filter((r) => r.player1.userId === userId || r.player2.userId === userId) - .sort((a, b) => b.endedAt - a.endedAt).slice(0, limit).map(toSummary); - } - async getPopular(limit: number): Promise { - return [...this.records.values()] - .sort((a, b) => b.peakSpectators - a.peakSpectators || b.endedAt - a.endedAt).slice(0, limit).map(toSummary); - } -} - -// Lets fire-and-forget promises (saving to Valkey or the database) settle. -export const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - -export class InMemoryCache implements Cache { - readonly values = new Map(); - async getJson(key: string) { - return this.values.get(key) as T | undefined; - } - async setJson(key: string, value: unknown) { - this.values.set(key, JSON.parse(JSON.stringify(value))); - } - async delete(...keys: string[]) { - keys.forEach((key) => this.values.delete(key)); - } -} diff --git a/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts b/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts deleted file mode 100644 index 7aaecc0..0000000 --- a/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { GameRecord } from "@mancala/core"; -import { Pool } from "pg"; -import { v4 as uuid } from "uuid"; -import { createPool, runMigrations } from "../../src/db/postgres"; -import { PostgresGameHistoryRepository } from "../../src/history/GameHistoryRepository"; -import { RunningService, startPostgres } from "../support/containers"; - -jest.setTimeout(120_000); - -const ALICE = { userId: "kc:alice", name: "alice", isGuest: false }; -const BOB = { userId: "kc:bob", name: "bob", isGuest: false }; -const GUEST = { userId: "kc:guest", name: "guest-1a2b3c4d", isGuest: true }; -const LEGACY = { userId: "legacy:0123456789abcdef0123", isGuest: true }; - -let endedAt = Date.UTC(2026, 9, 1); - -const record = (overrides: Partial = {}): GameRecord => { - endedAt += 60_000; - return { - id: uuid(), - player1: ALICE, - player2: BOB, - board: [0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18], - result: "player1", - endReason: "completed", - startedAt: endedAt - 600_000, - endedAt, - moveCount: 1, - peakSpectators: 0, - totalSpectators: 0, - moves: [{ - playerId: ALICE.userId, moveIndex: 3, - boardSnapshot: [4, 4, 4, 0, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0], - gameSteps: [{ index: 6, type: "GAME_STEP_LAST_STONE_IN_BANK", data: null }], - at: endedAt - 599_000, - }], - ...overrides, - }; -}; - -describe("PostgresGameHistoryRepository", () => { - let postgres: RunningService; - let pool: Pool; - let repository: PostgresGameHistoryRepository; - - beforeAll(async () => { - postgres = await startPostgres(); - pool = createPool(postgres.url); - await runMigrations(pool); - repository = new PostgresGameHistoryRepository(pool); - }); - - afterAll(async () => { - await pool?.end(); - await postgres?.container.stop(); - }); - - beforeEach(() => pool.query("TRUNCATE games CASCADE")); - - it("runs migrations only once", async () => { - await runMigrations(pool); - const { rows } = await pool.query("SELECT version FROM schema_migrations"); - expect(rows).toEqual([{ version: "001_games" }, { version: "002_clock" }]); - }); - - it("saves a game and reads it back unchanged", async () => { - const game = record({ player2: LEGACY, result: null, endReason: "inactive", peakSpectators: 3, totalSpectators: 5 }); - await repository.save(game); - expect(await repository.get(game.id)).toEqual(game); - }); - - it("keeps the clock: the initial time and each move's time left", async () => { - const game = record({ clockInitialMs: 300_000 }); - game.moves[0].remainingMs = 291_500; - await repository.save(game); - expect(await repository.get(game.id)).toEqual(game); - expect((await repository.getRecentForUser(ALICE.userId, 1))[0].clockInitialMs).toBe(300_000); - }); - - it("reads games saved before the game clock without clock data", async () => { - const game = record(); - await repository.save(game); - const saved = await repository.get(game.id); - expect(saved).not.toHaveProperty("clockInitialMs"); - expect(saved?.moves[0]).not.toHaveProperty("remainingMs"); - }); - - it("keeps leftUserId for games a player left", async () => { - const game = record({ result: "player2", endReason: "left", leftUserId: ALICE.userId }); - await repository.save(game); - expect((await repository.get(game.id))?.leftUserId).toBe(ALICE.userId); - }); - - it("ignores saving the same game again", async () => { - const game = record(); - await repository.save(game); - await repository.save({ ...game, result: "draw" }); - expect((await repository.get(game.id))?.result).toBe("player1"); - }); - - it("returns undefined for unknown and malformed ids", async () => { - expect(await repository.get(uuid())).toBeUndefined(); - expect(await repository.get("not-a-uuid")).toBeUndefined(); - }); - - it("lists a user's games newest first, without the moves", async () => { - const first = record(); - const second = record({ player1: GUEST, player2: ALICE }); - const otherPlayers = record({ player1: BOB, player2: GUEST }); - for (const game of [first, second, otherPlayers]) await repository.save(game); - - const recent = await repository.getRecentForUser(ALICE.userId, 3); - expect(recent.map((game) => game.id)).toEqual([second.id, first.id]); - expect(recent[0]).not.toHaveProperty("moves"); - expect(await repository.getRecentForUser(ALICE.userId, 1)).toHaveLength(1); - }); - - it("lists popular games by peak spectators, then newest", async () => { - const quiet = record({ peakSpectators: 0 }); - const popular = record({ peakSpectators: 9 }); - const alsoPopularButNewer = record({ peakSpectators: 9 }); - for (const game of [quiet, popular, alsoPopularButNewer]) await repository.save(game); - - const ids = (await repository.getPopular(5)).map((game) => game.id); - expect(ids).toEqual([alsoPopularButNewer.id, popular.id, quiet.id]); - }); -}); diff --git a/apps/backend/tests/live/ValkeyStore.test.ts b/apps/backend/tests/live/ValkeyStore.test.ts deleted file mode 100644 index 6446f26..0000000 --- a/apps/backend/tests/live/ValkeyStore.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { GameRecord } from "@mancala/core"; -import { Valkey } from "iovalkey"; -import { ValkeyCache } from "../../src/cache/Cache"; -import { ValkeyLiveGameStore } from "../../src/live/LiveGameStore"; -import { createValkeyClient } from "../../src/live/valkey"; -import { RunningService, startValkey } from "../support/containers"; - -jest.setTimeout(120_000); - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const record = (id: string): GameRecord => ({ - id, - player1: { userId: "kc:alice", name: "alice", isGuest: false }, - player2: { userId: "kc:bob", name: "bob", isGuest: false }, - board: [0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18], - result: "player1", endReason: "completed", - startedAt: 1, endedAt: 2, moveCount: 0, peakSpectators: 0, totalSpectators: 0, moves: [], -}); - -describe("Valkey store and cache", () => { - let valkey: RunningService; - let client: Valkey; - - beforeAll(async () => { - valkey = await startValkey(); - client = createValkeyClient(valkey.url); - await new Promise((resolve) => client.once("ready", resolve)); - }); - - afterAll(async () => { - client?.disconnect(); - await valkey?.container.stop(); - }); - - beforeEach(() => client.flushall()); - - describe("ValkeyLiveGameStore", () => { - it("saves, loads and removes live game snapshots", async () => { - const store = new ValkeyLiveGameStore(client); - await store.saveGame("g1", { id: "g1", moves: 1 }); - await store.saveGame("g2", { id: "g2", moves: 2 }); - await store.saveGame("g1", { id: "g1", moves: 3 }); - const sortById = (games: unknown[]) => (games as { id: string }[]).sort((a, b) => a.id.localeCompare(b.id)); - expect(sortById(await store.loadGames())).toEqual([{ id: "g1", moves: 3 }, { id: "g2", moves: 2 }]); - - await store.removeGame("g1"); - expect(await store.loadGames()).toEqual([{ id: "g2", moves: 2 }]); - }); - - it("keeps pending records separate from live games", async () => { - const store = new ValkeyLiveGameStore(client); - await store.saveGame("g1", { id: "g1" }); - await store.savePendingRecord(record("r1")); - expect(await store.loadPendingRecords()).toEqual([record("r1")]); - expect(await store.loadGames()).toEqual([{ id: "g1" }]); - - await store.removePendingRecord("r1"); - expect(await store.loadPendingRecords()).toEqual([]); - }); - - it("skips values it cannot read", async () => { - const store = new ValkeyLiveGameStore(client); - await client.set("mancala:live:broken", "{not json"); - await store.saveGame("g1", { id: "g1" }); - expect(await store.loadGames()).toEqual([{ id: "g1" }]); - }); - }); - - describe("ValkeyCache", () => { - it("returns cached values until they expire", async () => { - const cache = new ValkeyCache(client); - await cache.setJson("popular", [1, 2, 3], 1); - expect(await cache.getJson("popular")).toEqual([1, 2, 3]); - await sleep(1500); - expect(await cache.getJson("popular")).toBeUndefined(); - }); - - it("deletes entries", async () => { - const cache = new ValkeyCache(client); - await cache.setJson("a", 1, 60); - await cache.setJson("b", 2, 60); - await cache.delete("a", "b"); - expect(await cache.getJson("a")).toBeUndefined(); - expect(await cache.getJson("b")).toBeUndefined(); - }); - }); - - describe("when Valkey is unreachable", () => { - let offline: Valkey; - - beforeAll(() => { - // Nothing listens on port 1. No reconnect attempts, so nothing is left running after the tests. - offline = createValkeyClient("redis://127.0.0.1:1", { retryStrategy: () => null, disconnectTimeout: 50 }); - }); - - afterAll(() => offline.disconnect()); - - it("the cache answers with misses instead of failing", async () => { - const cache = new ValkeyCache(offline); - await expect(cache.setJson("a", 1, 60)).resolves.toBeUndefined(); - await expect(cache.getJson("a")).resolves.toBeUndefined(); - await expect(cache.delete("a")).resolves.toBeUndefined(); - }); - - it("the store rejects at once, so callers can fall back", async () => { - const store = new ValkeyLiveGameStore(offline); - const started = Date.now(); - await expect(store.saveGame("g1", {})).rejects.toThrow(); - expect(Date.now() - started).toBeLessThan(1000); - }); - }); -}); 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/tests/rtmt/rtmt_websocket.test.ts b/apps/backend/tests/rtmt/rtmt_websocket.test.ts deleted file mode 100644 index ce1e3fa..0000000 --- a/apps/backend/tests/rtmt/rtmt_websocket.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import * as http from "http"; -import { AddressInfo } from "net"; -import WebSocket from "ws"; -import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth"; -import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "@mancala/core"; -import { RTMTWS } from "../../src/rtmt/rtmt_websocket"; - -const LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11"; - -// Accepts tokens of the form "valid:". -const fakeVerifier: TokenVerifier = { - async verify(token: string): Promise { - if (!token.startsWith("valid:")) throw new InvalidTokenError("bad token"); - const id = token.slice("valid:".length); - return { id, name: `name-of-${id}`, isGuest: false }; - }, -}; - -type Received = { channel: string, message: any }; - -class TestClient { - readonly messages: Received[] = []; - readonly closed: Promise; - constructor(readonly ws: WebSocket) { - ws.on("message", (data) => this.messages.push(JSON.parse(data.toString()))); - this.closed = new Promise((resolve) => ws.on("close", (code) => resolve(code))); - } - send(channel: string, message: any) { - this.ws.send(JSON.stringify({ channel, message })); - } - async waitFor(channel: string): Promise { - for (let i = 0; i < 100; i++) { - const found = this.messages.find((m) => m.channel === channel); - if (found) return found.message; - await sleep(20); - } - throw new Error(`no message on ${channel}, got ${JSON.stringify(this.messages)}`); - } -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -describe("RTMTWS", () => { - let server: http.Server; - let rtmt: RTMTWS; - let clients: TestClient[]; - let connectionChanges: [string, boolean][]; - - const start = async (legacyUserKeyAuth: boolean) => { - server = http.createServer(); - rtmt = new RTMTWS({ tokenVerifier: fakeVerifier, legacyUserKeyAuth, authTimeoutMs: 300 }); - const changes: [string, boolean][] = []; - connectionChanges = changes; - rtmt.listenOnClientConnectionChange((id, isOnline) => changes.push([id, isOnline])); - rtmt.initWebSocket(server); - await new Promise((resolve) => server.listen(0, resolve)); - }; - - const connect = async (query = ""): Promise => { - const port = (server.address() as AddressInfo).port; - const ws = new WebSocket(`ws://localhost:${port}/${query}`); - const client = new TestClient(ws); - clients.push(client); - await new Promise((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); }); - return client; - }; - - const connectAs = async (id: string): Promise => { - const client = await connect(); - client.send(channel_auth, { token: `valid:${id}` }); - await client.waitFor(channel_on_auth); - return client; - }; - - beforeEach(() => { clients = []; }); - - afterEach(async () => { - clients.forEach((c) => c.ws.terminate()); - rtmt.dispose(); - await new Promise((resolve) => server.close(resolve)); - }); - - describe("token auth", () => { - beforeEach(() => start(false)); - - it("authenticates a socket and routes its messages as that user", async () => { - const received: [string, any][] = []; - rtmt.listenMessage("hello", (id, message) => received.push([id, message])); - - const client = await connect(); - client.send(channel_auth, { token: "valid:alice" }); - expect(await client.waitFor(channel_on_auth)).toEqual({ user: { id: "alice", name: "name-of-alice", isGuest: false } }); - - client.send("hello", { n: 1 }); - await sleep(50); - expect(received).toEqual([["alice", { n: 1 }]]); - expect(rtmt.isClientOnline("alice")).toBe(true); - expect(rtmt.getUser("alice")?.name).toBe("name-of-alice"); - }); - - it("ignores messages before authentication", async () => { - const received: string[] = []; - rtmt.listenMessage("hello", (id) => received.push(id)); - const client = await connect(); - client.send("hello", {}); - await sleep(50); - expect(received).toEqual([]); - }); - - it("closes the socket on an invalid token", async () => { - const client = await connect(); - client.send(channel_auth, { token: "forged" }); - expect(await client.waitFor(channel_on_auth_error)).toEqual({ reason: "invalid token" }); - expect(await client.closed).toBe(CLOSE_AUTH_FAILED); - }); - - it("closes the socket when no token arrives in time", async () => { - const client = await connect(); - expect(await client.closed).toBe(CLOSE_AUTH_FAILED); - }); - - it("accepts a refreshed token for the same user but not for another user", async () => { - const client = await connectAs("alice"); - client.messages.length = 0; - client.send(channel_auth, { token: "valid:alice" }); - await client.waitFor(channel_on_auth); - - client.send(channel_auth, { token: "valid:mallory" }); - expect(await client.waitFor(channel_on_auth_error)).toEqual({ reason: "token belongs to another user" }); - expect(await client.closed).toBe(CLOSE_AUTH_FAILED); - }); - - it("rejects userKey connections when legacy auth is off", async () => { - const client = await connect(`?userKey=${LEGACY_KEY}`); - expect(await client.closed).toBe(CLOSE_AUTH_FAILED); - }); - - it("survives malformed messages", async () => { - const client = await connectAs("alice"); - client.ws.send("{not json"); - client.ws.send("null"); - client.messages.length = 0; - client.send(channel_auth, { token: "valid:alice" }); - await client.waitFor(channel_on_auth); - }); - }); - - describe("several sockets per user", () => { - beforeEach(() => start(false)); - - it("delivers messages to every socket of the user", async () => { - const phone = await connectAs("alice"); - const laptop = await connectAs("alice"); - rtmt.sendMessage("alice", "news", { n: 1 }); - expect(await phone.waitFor("news")).toEqual({ n: 1 }); - expect(await laptop.waitFor("news")).toEqual({ n: 1 }); - }); - - it("keeps the user online until the last socket closes", async () => { - const phone = await connectAs("alice"); - const laptop = await connectAs("alice"); - - phone.ws.close(); - await phone.closed; - await sleep(50); - expect(rtmt.isClientOnline("alice")).toBe(true); - expect(connectionChanges).not.toContainEqual(["alice", false]); - - laptop.ws.close(); - await laptop.closed; - await sleep(50); - expect(rtmt.isClientOnline("alice")).toBe(false); - expect(connectionChanges).toContainEqual(["alice", false]); - }); - - it("does not drop a reconnected socket when the old one closes late", async () => { - const oldSocket = await connectAs("alice"); - const newSocket = await connectAs("alice"); - oldSocket.ws.terminate(); - await oldSocket.closed; - await sleep(50); - - rtmt.sendMessage("alice", "news", {}); - expect(await newSocket.waitFor("news")).toEqual({}); - }); - }); - - describe("legacy userKey auth", () => { - beforeEach(() => start(true)); - - it("accepts a UUID userKey", async () => { - await connect(`?userKey=${LEGACY_KEY}`); - await sleep(50); - expect(rtmt.isClientOnline(LEGACY_KEY)).toBe(true); - expect(rtmt.getUser(LEGACY_KEY)).toEqual({ id: LEGACY_KEY, isGuest: true }); - }); - - it("cannot claim a Keycloak user's id", async () => { - const client = await connect("?userKey=kc:0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11"); - expect(await client.closed).toBe(CLOSE_AUTH_FAILED); - }); - - it("cannot switch a legacy socket to a token identity", async () => { - const client = await connect(`?userKey=${LEGACY_KEY}`); - client.send(channel_auth, { token: "valid:kc:alice" }); - expect(await client.closed).toBe(CLOSE_AUTH_FAILED); - }); - }); -}); diff --git a/apps/backend/tests/server/ExpressApp.test.ts b/apps/backend/tests/server/ExpressApp.test.ts deleted file mode 100644 index 95a6931..0000000 --- a/apps/backend/tests/server/ExpressApp.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import * as http from "http"; -import { AddressInfo } from "net"; -import { - channel_game_move, - channel_leave_game, - channel_listen_game_events, - channel_new_game, - GameRecord, -} from "@mancala/core"; -import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth"; -import { GameManager } from "../../src/game/GameManager"; -import { GameRecorder } from "../../src/game/GameRecorder"; -import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl"; -import { LiveGames } from "../../src/game/LiveGames"; -import { publicUserId } from "../../src/game/publicIds"; -import { GameHistoryService } from "../../src/history/GameHistoryService"; -import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl"; -import { ExpressApp } from "../../src/server/ExpressApp"; -import { FakeRtmt } from "../game/FakeRtmt"; -import { flushPromises, InMemoryCache, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "../game/fakes"; - -const ALICE = { id: "kc:alice", name: "alice", isGuest: false }; -const BOB = { id: "kc:bob", name: "bob", isGuest: false }; -const CAROL = { id: "kc:carol", name: "carol", isGuest: false }; -const DAVE = { id: "kc:dave", name: "dave", isGuest: false }; -const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true }; - -// Accepts tokens of the form "valid:". -const verifier: TokenVerifier = { - async verify(token: string): Promise { - if (!token.startsWith("valid:")) throw new InvalidTokenError("bad token"); - return { id: token.slice("valid:".length), isGuest: false }; - }, -}; - -describe("HTTP API", () => { - let rtmt: FakeRtmt; - let gameStore: GameStoreImpl; - let repository: InMemoryGameHistoryRepository; - let gameManager: GameManager; - let liveGames: LiveGames; - let server: http.Server; - let baseUrl: string; - - beforeEach(async () => { - rtmt = new FakeRtmt(); - gameStore = new GameStoreImpl(); - repository = new InMemoryGameHistoryRepository(); - const liveGameStore = new InMemoryLiveGameStore(); - const recorder = new GameRecorder(repository, liveGameStore); - const history = new GameHistoryService(repository, new InMemoryCache(), () => recorder.getUnsaved()); - recorder.onSaved((record) => history.onGameSaved(record)); - gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder }); - liveGames = new LiveGames({ rtmt, gameStore, gameManager }); - const app = new ExpressApp({ - gameStore, gameManager, liveGames, history, tokenVerifier: verifier, legacyUserKeyAuth: true, accessLog: false, - }).app as http.RequestListener; - server = http.createServer(app); - await new Promise((resolve) => server.listen(0, resolve)); - baseUrl = `http://localhost:${(server.address() as AddressInfo).port}`; - [ALICE, BOB, CAROL, DAVE].forEach((user) => rtmt.connect(user)); - }); - - afterEach(async () => { - liveGames.dispose(); - await new Promise((resolve) => server.close(resolve)); - }); - - const get = async (path: string, userId?: string) => { - const response = await fetch(baseUrl + path, userId ? { headers: { Authorization: `Bearer valid:${userId}` } } : {}); - const body = response.headers.get("content-type")?.includes("json") ? await response.json() : undefined; - return { status: response.status, body }; - }; - - const startGame = (first: { id: string } = ALICE, second: { id: string } = BOB) => { - rtmt.receive(first.id, channel_new_game); - rtmt.receive(second.id, channel_new_game); - return gameStore.getGameByUser(first.id)!; - }; - - const finishGame = async (first: { id: string } = ALICE, second: { id: string } = BOB) => { - const game = startGame(first, second); - rtmt.receive(first.id, channel_leave_game); - await flushPromises(); - return repository.records.get(game.id)!; - }; - - describe("GET /game/:id", () => { - it("returns a live game as players receive it", async () => { - const game = startGame(); - rtmt.receive(CAROL.id, channel_listen_game_events, game.id); - const { status, body } = await get(`/game/${game.id}`); - expect(status).toBe(200); - expect(body.id).toBe(game.id); - expect(body.mancalaGame.player1Id).toBe(game.mancalaGame.player1Id); - expect(body.spectatorCount).toBe(1); - expect(body).not.toHaveProperty("spectatorIds"); - }); - - it("returns the saved record of a finished game", async () => { - const record = await finishGame(); - const { status, body } = await get(`/game/${record.id}`); - expect(status).toBe(200); - expect(body).toEqual({ status: "finished", record }); - }); - - it("answers 404 for unknown games and 503 when the database is down", async () => { - expect((await get("/game/unknown")).status).toBe(404); - repository.failing = true; - expect((await get("/game/unknown")).status).toBe(503); - }); - }); - - describe("GET /games/popular", () => { - it("lists live games, most spectated first, with public ids", async () => { - rtmt.connect(LEGACY); - const quiet = startGame(ALICE, LEGACY); - const watched = startGame(BOB, CAROL); - rtmt.receive(DAVE.id, channel_listen_game_events, watched.id); - - const { body } = await get("/games/popular?limit=5"); - expect(body.kind).toBe("live"); - expect(body.games.map((g: any) => g.id)).toEqual([watched.id, quiet.id]); - expect(body.games[0].spectatorCount).toBe(1); - expect(JSON.stringify(body)).not.toContain(LEGACY.id); - expect(JSON.stringify(body)).toContain(publicUserId(LEGACY.id)); - }); - - it("lists the most spectated finished games when nothing is live", async () => { - const watched = startGame(); - rtmt.receive(CAROL.id, channel_listen_game_events, watched.id); - rtmt.receive(ALICE.id, channel_leave_game); - const quiet = await finishGame(CAROL, DAVE); - await flushPromises(); - - const { body } = await get("/games/popular"); - expect(body.kind).toBe("finished"); - expect(body.games.map((g: any) => g.id)).toEqual([watched.id, quiet.id]); - expect(body.games[0]).not.toHaveProperty("moves"); - }); - - it("lists finished games while games are live when asked for finished games", async () => { - const finished = await finishGame(CAROL, DAVE); - await flushPromises(); - startGame(); - - expect((await get("/games/popular")).body.kind).toBe("live"); - const { body } = await get("/games/popular?kind=finished"); - expect(body.kind).toBe("finished"); - expect(body.games.map((g: any) => g.id)).toEqual([finished.id]); - }); - }); - - describe("GET /games/me/active", () => { - it("requires a valid token", async () => { - expect((await get("/games/me/active")).status).toBe(401); - const response = await fetch(baseUrl + "/games/me/active", { headers: { Authorization: "Bearer forged" } }); - expect(response.status).toBe(401); - }); - - it("returns the user's live game, or null", async () => { - expect((await get("/games/me/active", ALICE.id)).body).toEqual({ game: null }); - const game = startGame(); - const { body } = await get("/games/me/active", ALICE.id); - expect(body.game).toMatchObject({ id: game.id, turnPlayerId: game.mancalaGame.turnPlayerId, moveCount: 0 }); - expect(body.game.player1.name).toBeDefined(); - }); - }); - - describe("GET /games/me", () => { - it("requires a valid token", async () => { - expect((await get("/games/me")).status).toBe(401); - }); - - it("returns the user's latest finished games, and shows a new one right away despite the cache", async () => { - const first = await finishGame(ALICE, BOB); - await finishGame(CAROL, DAVE); - expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([first.id]); - - const second = await finishGame(BOB, ALICE); - await flushPromises(); - const { body } = await get("/games/me?limit=1", ALICE.id); - expect(body.games.map((g: GameRecord) => g.id)).toEqual([second.id]); - }); - - it("returns an empty list while the database is down", async () => { - repository.failing = true; - expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] }); - }); - - it("includes a game that ended but is not saved yet", async () => { - repository.failing = true; - const game = startGame(); - rtmt.receive(ALICE.id, channel_leave_game); - await flushPromises(); - - expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]); - expect((await get("/games/popular")).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]); - const { body } = await get(`/game/${game.id}`); - expect(body.status).toBe("finished"); - expect(body.record.moves).toEqual([]); - }); - }); - - it("moves keep working with the API in place", async () => { - const game = startGame(); - rtmt.receive(game.mancalaGame.turnPlayerId, channel_game_move, { index: 0 }); - expect((await get(`/game/${game.id}`)).body.mancalaGame.history).toHaveLength(1); - }); -}); diff --git a/apps/backend/tests/support/NodeEnvironment.js b/apps/backend/tests/support/NodeEnvironment.js deleted file mode 100644 index e388e58..0000000 --- a/apps/backend/tests/support/NodeEnvironment.js +++ /dev/null @@ -1,19 +0,0 @@ -// Jest 27's node environment runs tests in a context that lacks Node's newer web globals (ReadableStream, Blob, -// ...), which the HTTP client used by testcontainers needs. Copy whatever the context is missing from Node. -const NodeEnvironment = require("jest-environment-node"); - -// Reading these makes Node warn about its experimental web storage. -const SKIPPED = new Set(["localStorage", "sessionStorage"]); - -class NodeEnvironmentWithWebGlobals extends NodeEnvironment { - constructor(config, context) { - super(config, context); - for (const name of Object.getOwnPropertyNames(globalThis)) { - if (!SKIPPED.has(name) && !(name in this.global)) { - this.global[name] = globalThis[name]; - } - } - } -} - -module.exports = NodeEnvironmentWithWebGlobals; diff --git a/apps/backend/tests/support/containers.ts b/apps/backend/tests/support/containers.ts deleted file mode 100644 index bc59972..0000000 --- a/apps/backend/tests/support/containers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { GenericContainer, StartedTestContainer, Wait } from "testcontainers"; - -// Same images as apps/compose.yaml. -const POSTGRES_IMAGE = "postgres:17.6"; -const VALKEY_IMAGE = "valkey/valkey:9.1.2"; - -export interface RunningService { - url: string; - container: StartedTestContainer; -} - -export async function startPostgres(): Promise { - const container = await new GenericContainer(POSTGRES_IMAGE) - .withEnvironment({ POSTGRES_USER: "mancala", POSTGRES_PASSWORD: "mancala", POSTGRES_DB: "mancala" }) - .withExposedPorts(5432) - // Postgres logs this twice: once for the init run, once when it is really up. - .withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2)) - .start(); - return { - url: `postgres://mancala:mancala@${container.getHost()}:${container.getMappedPort(5432)}/mancala`, - container, - }; -} - -export async function startValkey(): Promise { - const container = await new GenericContainer(VALKEY_IMAGE) - .withExposedPorts(6379) - .withWaitStrategy(Wait.forLogMessage(/Ready to accept connections/)) - .start(); - return { url: `redis://${container.getHost()}:${container.getMappedPort(6379)}`, container }; -} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json deleted file mode 100644 index cb46546..0000000 --- a/apps/backend/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "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 f8ab311..0000000 --- a/apps/backend/yarn.lock +++ /dev/null @@ -1,4805 +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" - -"@balena/dockerignore@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" - integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q== - -"@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" - -"@grpc/grpc-js@^1.11.1": - version "1.14.6" - resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.6.tgz#f3f3d65315bf9b45252f575e4cc142148c587cac" - integrity sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g== - dependencies: - "@grpc/proto-loader" "^0.8.0" - "@js-sdsl/ordered-map" "^4.4.2" - -"@grpc/proto-loader@^0.7.13": - version "0.7.15" - resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" - integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== - dependencies: - lodash.camelcase "^4.3.0" - long "^5.0.0" - protobufjs "^7.2.5" - yargs "^17.7.2" - -"@grpc/proto-loader@^0.8.0": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.1.tgz#5a6b290ccbfb1ae2f6775afb74e9898bd8c5d4e8" - integrity sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg== - dependencies: - lodash.camelcase "^4.3.0" - long "^5.0.0" - protobufjs "^7.5.5" - yargs "^17.7.2" - -"@iovalkey/commands@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@iovalkey/commands/-/commands-0.1.0.tgz#62648b19106690ee8ce7d11aaf6249a242f927b0" - integrity sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg== - -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.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" - -"@js-sdsl/ordered-map@^4.4.2": - version "4.4.2" - resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" - integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== - -"@kwsites/file-exists@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" - integrity sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw== - dependencies: - debug "^4.1.1" - -"@mancala/core@link:../../packages/core": - version "0.0.0" - uid "" - -"@pkgjs/parseargs@^0.11.0": - version "0.11.0" - resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" - integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== - -"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" - integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== - -"@protobufjs/base64@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" - integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== - -"@protobufjs/codegen@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" - integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== - -"@protobufjs/eventemitter@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" - integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== - -"@protobufjs/fetch@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" - integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== - dependencies: - "@protobufjs/aspromise" "^1.1.1" - -"@protobufjs/float@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" - integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== - -"@protobufjs/path@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" - integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== - -"@protobufjs/pool@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" - integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== - -"@protobufjs/utf8@^1.1.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.2.tgz#78d476333d85d5b1c792e257bca74ba080da49a4" - integrity sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug== - -"@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/docker-modem@*": - version "3.0.6" - resolved "https://registry.yarnpkg.com/@types/docker-modem/-/docker-modem-3.0.6.tgz#1f9262fcf85425b158ca725699a03eb23cddbf87" - integrity sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg== - dependencies: - "@types/node" "*" - "@types/ssh2" "*" - -"@types/dockerode@^4.0.1": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-4.0.2.tgz#ced50a0b9c2633edd5a0c6e7b0a21537d38e427d" - integrity sha512-hA4myZ0XzwHG/p/eCLElYsQS3y1XZfvi6AfLFKVp1SNpPkO6J3xLatacAFVkReTxXRoIa3eHJmhJi9w6I3+zNA== - dependencies: - "@types/docker-modem" "*" - "@types/node" "*" - "@types/ssh2" "*" - -"@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@>=13.7.0": - version "26.6.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.5.tgz#cb35209e7767c503551b5fa42a48231c7441d979" - integrity sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg== - dependencies: - undici-types "~8.9.0" - -"@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/node@^18.11.18": - version "18.19.130" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" - integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== - dependencies: - undici-types "~5.26.4" - -"@types/pg@8.23.1": - version "8.23.1" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.23.1.tgz#7e712ce02cf44ad100862127f10f52e9c6b9d227" - integrity sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A== - dependencies: - "@types/node" "*" - pg-protocol "*" - pg-types "^2.2.0" - -"@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/ssh2-streams@*": - version "0.1.13" - resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz#f8d34a22be50fb8dbafbb2bbc289add0d22daa51" - integrity sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA== - dependencies: - "@types/node" "*" - -"@types/ssh2@*": - version "1.15.6" - resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.6.tgz#2481573c48c5d42e24d931e6e47d3a67d3d38162" - integrity sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w== - dependencies: - "@types/node" "^18.11.18" - -"@types/ssh2@^0.5.48": - version "0.5.52" - resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741" - integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg== - dependencies: - "@types/node" "*" - "@types/ssh2-streams" "*" - -"@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== - -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - -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-regex@^6.2.2: - version "6.4.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.4.0.tgz#69093d6436d30dbb9b04fe7bdcbcfe16c697d8a1" - integrity sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw== - -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== - -ansi-styles@^6.1.0: - version "6.2.3" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" - integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== - -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" - -archiver-utils@^5.0.0, archiver-utils@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" - integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== - dependencies: - glob "^10.0.0" - graceful-fs "^4.2.0" - is-stream "^2.0.1" - lazystream "^1.0.0" - lodash "^4.17.15" - normalize-path "^3.0.0" - readable-stream "^4.0.0" - -archiver@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" - integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== - dependencies: - archiver-utils "^5.0.2" - async "^3.2.4" - buffer-crc32 "^1.0.0" - readable-stream "^4.0.0" - readdir-glob "^1.1.2" - tar-stream "^3.0.0" - zip-stream "^6.0.1" - -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= - -asn1@^0.2.6: - version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -async-lock@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f" - integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ== - -async@^3.2.4: - version "3.2.6" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" - integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= - -b4a@^1.6.4, b4a@^1.8.1, b4a@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.9.0.tgz#6336313b6e76b299c397afa68676ec6ebc788845" - integrity sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ== - -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== - -bare-events@^2.5.4, bare-events@^2.7.0: - version "2.9.2" - resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.2.tgz#01d377b64c3c7167b28b52da7add3221aecf2d7f" - integrity sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA== - -bare-fs@^4.0.1, bare-fs@^4.8.2: - version "4.8.4" - resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.4.tgz#f342288e7e5d9d8e79917078e1f45950507828c5" - integrity sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ== - dependencies: - bare-events "^2.5.4" - bare-path "^3.0.0" - bare-stream "^2.6.4" - bare-url "^2.2.2" - fast-fifo "^1.3.2" - -bare-path@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.2.tgz#67261bc74a9ba0105ba139b8638f218c09e8d82e" - integrity sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw== - -bare-stream@^2.6.4: - version "2.13.4" - resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.4.tgz#61d448a268d1efd992103aae6bd729332f278ac0" - integrity sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA== - dependencies: - b4a "^1.8.1" - streamx "^2.25.0" - teex "^1.0.1" - -bare-url@^2.2.2: - version "2.5.5" - resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.5.tgz#2e8c18db11393aa6cae6ff246fdcda34615aacd3" - integrity sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q== - dependencies: - bare-path "^3.0.0" - -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -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" - -bcrypt-pbkdf@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== - dependencies: - tweetnacl "^0.14.3" - -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== - -bl@^4.0.3: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - -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" - -brace-expansion@^2.0.1, brace-expansion@^2.0.2: - version "2.1.7" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" - integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== - dependencies: - balanced-match "^1.0.0" - -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-crc32@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405" - integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w== - -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== - -buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -buffer@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" - integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.2.1" - -buildcheck@~0.0.6: - version "0.0.7" - resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.7.tgz#07a5e76c10ead8fa67d9e4c587b68f49e8f29d61" - integrity sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA== - -byline@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" - integrity sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q== - -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" - -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== - -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" - -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - 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" - -cluster-key-slot@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" - integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== - -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" - -compress-commons@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-6.0.2.tgz#26d31251a66b9d6ba23a84064ecd3a6a71d2609e" - integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== - dependencies: - crc-32 "^1.2.0" - crc32-stream "^6.0.0" - is-stream "^2.0.1" - normalize-path "^3.0.0" - readable-stream "^4.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== - -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - -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" - -cpu-features@~0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" - integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== - dependencies: - buildcheck "~0.0.6" - nan "^2.19.0" - -crc-32@^1.2.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" - integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== - -crc32-stream@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-6.0.0.tgz#8529a3868f8b27abb915f6c3617c0fadedbf9430" - integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== - dependencies: - crc-32 "^1.2.0" - readable-stream "^4.0.0" - -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" - -cross-spawn@^7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" - integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== - 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" - -debug@^4.3.4, debug@^4.4.3: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -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= - -denque@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" - integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== - -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== - -docker-compose@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-1.5.0.tgz#f46f6651ba08bdca3edc28f34dc6866c0e6c53ed" - integrity sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw== - dependencies: - yaml "^2.2.2" - -docker-modem@^5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.7.tgz#57f3f0e2c7a893e66a0d4a626f9cbc933d77157b" - integrity sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA== - dependencies: - debug "^4.1.1" - readable-stream "^3.5.0" - split-ca "^1.0.1" - ssh2 "^1.15.0" - -dockerode@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-5.0.1.tgz#c389c0e32e825a818a3964f8c9b26f60aa306be1" - integrity sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA== - dependencies: - "@balena/dockerignore" "^1.0.2" - "@grpc/grpc-js" "^1.11.1" - "@grpc/proto-loader" "^0.7.13" - docker-modem "^5.0.7" - protobufjs "^7.3.2" - tar-fs "^2.1.4" - -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= - -eastasianwidth@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" - integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - -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== - -emoji-regex@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" - integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== - -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" - -end-of-stream@^1.4.1: - version "1.4.5" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" - integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== - 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= - -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - -events-universal@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/events-universal/-/events-universal-1.0.1.tgz#b56a84fd611b6610e0a2d0f09f80fdf931e2dfe6" - integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== - dependencies: - bare-events "^2.7.0" - -events@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - -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-fifo@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" - integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== - -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" - -foreground-child@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" - integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== - dependencies: - cross-spawn "^7.0.6" - signal-exit "^4.0.1" - -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-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - -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-port@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" - integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== - -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@^10.0.0: - version "10.5.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" - integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== - dependencies: - foreground-child "^3.1.0" - jackspeak "^3.1.2" - minimatch "^9.0.4" - minipass "^7.1.2" - package-json-from-dist "^1.0.0" - path-scurry "^1.11.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== - -graceful-fs@^4.2.0, graceful-fs@^4.2.4: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -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" - -ieee754@^1.1.13, ieee754@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -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, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: - 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== - -iovalkey@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/iovalkey/-/iovalkey-0.4.0.tgz#65316e7aabc980191f2122709569ec878b880d3b" - integrity sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw== - dependencies: - "@iovalkey/commands" "^0.1.0" - cluster-key-slot "^1.1.0" - debug "^4.3.4" - denque "^2.1.0" - lodash.defaults "^4.2.0" - lodash.isarguments "^3.1.0" - redis-errors "^1.2.0" - redis-parser "^3.0.0" - standard-as-callback "^2.1.0" - -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, is-stream@^2.0.1: - 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== - -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - -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" - -jackspeak@^3.1.2: - version "3.4.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" - integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== - dependencies: - "@isaacs/cliui" "^8.0.2" - optionalDependencies: - "@pkgjs/parseargs" "^0.11.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" - -jose@5.10.0: - version "5.10.0" - resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" - integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== - -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" - -lazystream@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" - integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== - dependencies: - readable-stream "^2.0.5" - -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.camelcase@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" - integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== - -lodash.defaults@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" - integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== - -lodash.isarguments@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" - integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== - -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.17.15: - version "4.18.1" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" - integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== - -lodash@^4.7.0: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -long@^5.0.0, long@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" - integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== - -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@^10.2.0: - version "10.4.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" - integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== - -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@link:../../packages/mancala.js": - version "0.0.0" - uid "" - -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" - -minimatch@^5.1.0: - version "5.1.9" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" - integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^9.0.4: - version "9.0.9" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" - integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== - dependencies: - brace-expansion "^2.0.2" - -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== - -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.3" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" - integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== - -mkdirp-classic@^0.5.2: - version "0.5.3" - resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" - integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== - -mkdirp@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" - integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== - -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, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -nan@^2.19.0, nan@^2.23.0: - version "2.29.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.29.0.tgz#95bace50f153289240573c08401acc63a30659d4" - integrity sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA== - -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-from-dist@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" - integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== - -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-scurry@^1.11.1: - version "1.11.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" - integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== - dependencies: - lru-cache "^10.2.0" - minipass "^5.0.0 || ^6.0.2 || ^7.0.0" - -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= - -pg-cloudflare@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz#0fc904c9a8b3ca3a4f71e16bcc9171224ecbb2a8" - integrity sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ== - -pg-connection-string@^2.14.1: - version "2.14.1" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.1.tgz#d9529fe2497c0cac8a9f5f6f059679c52f1bd964" - integrity sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA== - -pg-int8@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" - integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== - -pg-pool@^3.14.0: - version "3.14.0" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" - integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== - -pg-protocol@*, pg-protocol@^1.16.1: - version "1.16.1" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.1.tgz#87a3951b57002682d63166c029859bd834e64bf1" - integrity sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg== - -pg-types@2.2.0, pg-types@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" - integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== - dependencies: - pg-int8 "1.0.1" - postgres-array "~2.0.0" - postgres-bytea "~1.0.0" - postgres-date "~1.0.4" - postgres-interval "^1.1.0" - -pg@8.23.1: - version "8.23.1" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.1.tgz#e4848051a04aa2f968fd5671d075f89e5e7918b9" - integrity sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ== - dependencies: - pg-connection-string "^2.14.1" - pg-pool "^3.14.0" - pg-protocol "^1.16.1" - pg-types "2.2.0" - pgpass "1.0.5" - optionalDependencies: - pg-cloudflare "^1.4.1" - -pgpass@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" - integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== - dependencies: - split2 "^4.1.0" - -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" - -postgres-array@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" - integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== - -postgres-bytea@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a" - integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ== - -postgres-date@~1.0.4: - version "1.0.7" - resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" - integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== - -postgres-interval@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" - integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== - dependencies: - xtend "^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" - -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== - -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" - -proper-lockfile@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" - integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== - dependencies: - graceful-fs "^4.2.4" - retry "^0.12.0" - signal-exit "^3.0.2" - -properties-reader@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/properties-reader/-/properties-reader-3.0.1.tgz#576af69708759bb75672bfc162b80cc8a3d1bdb2" - integrity sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g== - dependencies: - "@kwsites/file-exists" "^1.1.1" - mkdirp "^3.0.1" - -protobufjs@^7.2.5, protobufjs@^7.3.2, protobufjs@^7.5.5: - version "7.6.6" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.6.tgz#7a3923e8e32b0ee2ff689f8eed6e455c090ac67e" - integrity sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg== - dependencies: - "@protobufjs/aspromise" "^1.1.2" - "@protobufjs/base64" "^1.1.2" - "@protobufjs/codegen" "^2.0.5" - "@protobufjs/eventemitter" "^1.1.1" - "@protobufjs/fetch" "^1.1.1" - "@protobufjs/float" "^1.0.2" - "@protobufjs/path" "^1.1.2" - "@protobufjs/pool" "^1.1.0" - "@protobufjs/utf8" "^1.1.1" - "@types/node" ">=13.7.0" - long "^5.3.2" - -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== - -readable-stream@^2.0.5: - version "2.3.8" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^4.0.0: - version "4.8.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.8.0.tgz#22af894e6d60cb0064410fc376367ec4ce470adb" - integrity sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ== - dependencies: - abort-controller "^3.0.0" - buffer "^6.0.3" - events "^3.3.0" - process "^0.11.10" - string_decoder "^1.3.0" - -readdir-glob@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584" - integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== - dependencies: - minimatch "^5.1.0" - -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" - -redis-errors@^1.0.0, redis-errors@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" - integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== - -redis-parser@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" - integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== - dependencies: - redis-errors "^1.0.0" - -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" - -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== - -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.0, 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, safe-buffer@~5.2.0: - 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", safer-buffer@~2.1.0: - 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== - -signal-exit@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" - integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== - -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== - -split-ca@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" - integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ== - -split2@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" - integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== - -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= - -ssh-remote-port-forward@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" - integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== - dependencies: - "@types/ssh2" "^0.5.48" - ssh2 "^1.4.0" - -ssh2@^1.15.0, ssh2@^1.4.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" - integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== - dependencies: - asn1 "^0.2.6" - bcrypt-pbkdf "^1.0.2" - optionalDependencies: - cpu-features "~0.0.10" - nan "^2.23.0" - -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" - -standard-as-callback@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" - integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== - -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== - -streamx@^2.12.5, streamx@^2.25.0, streamx@^2.28.1: - version "2.28.1" - resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.1.tgz#376cd42a089505a69bec0efdb5189aa45586141f" - integrity sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA== - dependencies: - events-universal "^1.0.0" - fast-fifo "^1.3.2" - text-decoder "^1.1.0" - -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-cjs@npm:string-width@^4.2.0": - 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" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: - 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" - -string-width@^5.0.1, string-width@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" - integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - dependencies: - eastasianwidth "^0.2.0" - emoji-regex "^9.2.2" - strip-ansi "^7.0.1" - -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -"strip-ansi-cjs@npm: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-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-ansi@^7.0.1: - version "7.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" - integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== - dependencies: - ansi-regex "^6.2.2" - -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== - -tar-fs@^2.1.4: - version "2.1.5" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.5.tgz#33e9c29413dce0c58ada7ff77db4e5a30afffe70" - integrity sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw== - dependencies: - chownr "^1.1.1" - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^2.1.4" - -tar-fs@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.3.tgz#05668cc68a30741c3813f9c16593b8dec7dcbcd1" - integrity sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ== - dependencies: - pump "^3.0.0" - tar-stream "^3.1.5" - optionalDependencies: - bare-fs "^4.0.1" - bare-path "^3.0.0" - -tar-stream@^2.1.4: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" - integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== - dependencies: - bl "^4.0.3" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar-stream@^3.0.0, tar-stream@^3.1.5: - version "3.2.2" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.2.tgz#aec7f257e712192a177a93aa0450c2e76855c8be" - integrity sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg== - dependencies: - b4a "^1.9.0" - bare-fs "^4.8.2" - fast-fifo "^1.3.2" - streamx "^2.28.1" - -teex@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/teex/-/teex-1.0.1.tgz#b8fa7245ef8e8effa8078281946c85ab780a0b12" - integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== - dependencies: - streamx "^2.12.5" - -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" - -testcontainers@12.2.0: - version "12.2.0" - resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-12.2.0.tgz#d2e99b8895d1283f7a2b629ca14b1ac9b65cc2e2" - integrity sha512-Pd0c2eU0DdIuK+Pb3c+vmBUdKjDLgxZmPecQGESFAKyn5LTqSaSw8l7htfMlLwabb/QsUILe6XkxMJw8Ly/ThA== - dependencies: - "@balena/dockerignore" "^1.0.2" - "@types/dockerode" "^4.0.1" - archiver "^7.0.1" - async-lock "^1.4.1" - byline "^5.0.0" - debug "^4.4.3" - docker-compose "^1.5.0" - dockerode "^5.0.1" - get-port "^5.1.1" - proper-lockfile "^4.1.2" - properties-reader "^3.0.1" - ssh-remote-port-forward "^1.0.4" - tar-fs "^3.1.3" - tmp "^0.2.7" - undici "^8.10.2" - -text-decoder@^1.1.0: - version "1.2.7" - resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.7.tgz#5d073a9a74b9c0a9d28dfadcab96b604af57d8ba" - integrity sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ== - dependencies: - b4a "^1.6.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== - -tmp@^0.2.7: - version "0.2.7" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059" - integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== - -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" - -tweetnacl@^0.14.3: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - -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== - -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== - -undici-types@~8.9.0: - version "8.9.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" - integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== - -undici@^8.10.2: - version "8.12.2" - resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.2.tgz#e869237430adcc5de6bca798c94edd7db9dac5ac" - integrity sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg== - -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" - -util-deprecate@^1.0.1, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -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-cjs@npm: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" - -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" - -wrap-ansi@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" - integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== - dependencies: - ansi-styles "^6.1.0" - string-width "^5.0.1" - strip-ansi "^7.0.1" - -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== - -xtend@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -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== - -yaml@^2.2.2: - version "2.9.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.1.tgz#c16233fb31944e705cfefaff38795587f57588ce" - integrity sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw== - -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-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -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" - -yargs@^17.7.2: - version "17.7.3" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" - integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yn@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" - integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== - -zip-stream@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-6.0.1.tgz#e141b930ed60ccaf5d7fa9c8260e0d1748a2bbfb" - integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== - dependencies: - archiver-utils "^5.0.0" - compress-commons "^6.0.2" - readable-stream "^4.0.0" diff --git a/apps/compose.yaml b/apps/compose.yaml index f9fbc60..4f5e078 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -11,7 +11,9 @@ services: 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_URL: postgres://mancala:${MANCALA_DB_PASSWORD}@postgres:5432/mancala + DATABASE_JDBC_URL: jdbc:postgresql://postgres:5432/mancala + DATABASE_USERNAME: mancala + DATABASE_PASSWORD: ${MANCALA_DB_PASSWORD} VALKEY_URL: redis://valkey:6379 ports: - "5000:6000" diff --git a/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index d30c6a5..fcce883 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -14,7 +14,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. `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. -- [ ] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose, +- [x] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose, remove the Node sources; build the image. - [ ] 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`. From 0903f614559bf0de3320697e8edaa05db2146848 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:53:13 +0300 Subject: [PATCH 8/8] docs: describe the Java backend The backend README, how to run and test it, what is easy to get wrong with the game loop and the shared data formats, and the deployment note. The web app was played against it in Chrome: a full game with a spectator, a reload, a restart mid-game, the replay and a game lost on time. --- AGENTS.md | 47 ++++++---- README.md | 12 ++- TODO.md | 14 ++- apps/backend/README.md | 104 +++++++++++++++++++++ docs/specs/quarkus-backend/design.md | 4 +- docs/specs/quarkus-backend/requirements.md | 2 +- docs/specs/quarkus-backend/tasks.md | 2 +- 7 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 apps/backend/README.md diff --git a/AGENTS.md b/AGENTS.md index 43892ab..4ac959d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,13 @@ saved time, add it here. ## Project -Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app and a Node backend. +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; rules are plug-ins in `src/common/game_rules` | +| `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` | Express + `ws`: matchmaking, games, auth, saved games, HTTP API | +| `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 | @@ -22,15 +22,15 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app | `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) and `web-redesign` (the web app's look: themes, board, -pages). +`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board, +pages) and `quarkus-backend` (the backend's move from Node to Java and Quarkus). ## 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 && yarn dev # http://localhost:5005 +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) ``` @@ -42,9 +42,9 @@ unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`. | What | Command | Notes | |---|---|---| -| Engine | `cd packages/mancala.js && npx jest` | | +| 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 && npx tsc --noEmit -p . && npx jest` | Needs Docker: Postgres and Valkey run in testcontainers | +| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services | | 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 | @@ -66,25 +66,38 @@ There is no automated end-to-end suite. Changes to the game flow were checked by ## Things that are easy to get wrong **Packages and builds** -- The 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 the backend, and restart Parcel after deleting - `apps/frontend/.parcel-cache`. +- 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`. + 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 `publicUserId()`; clients get `toClientGame()`, never spectator ids. + 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 `getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for manual tests. -- `RTMT.listenOnClientConnectionChange` adds a listener (several are allowed). -- Jest 27 lacks Node's web globals that testcontainers needs; `tests/support/NodeEnvironment.js` copies them in. -- A Valkey client pointed at a dead port keeps Jest alive for `disconnectTimeout`; tests pass a short one. + 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). +- 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 diff --git a/README.md b/README.md index 6d1c657..4716a9a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Mancala is an open source application for playing mancala game online. ## Development -The apps use the local `packages/mancala.js` and `packages/core` through `link:` dependencies, so build those first: +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 @@ -26,8 +27,13 @@ EOF docker compose up -d --build postgres valkey keycloak ``` -Postgres is on `localhost:5433` and Valkey on `localhost:6379`; the backend's defaults point there. Then run -`yarn dev` in `apps/backend` (port 5005) and in `apps/frontend` (port 1234). +Postgres is on `localhost:5433` and Valkey on `localhost:6379`; the backend's defaults point there. Then run the +backend (Java 25 and Quarkus, see `apps/backend/README.md`) and the web app: + +```sh +cd apps/backend && ./mvnw quarkus:dev # port 5005 +cd apps/frontend && yarn dev # port 1234 +``` Docker images for the backend and frontend are built from the repo root: diff --git a/TODO.md b/TODO.md index 21efd86..7b73634 100644 --- a/TODO.md +++ b/TODO.md @@ -69,6 +69,10 @@ See `docs/specs/web-redesign`. - [ ] Deploy Keycloak (`apps/.env` with `KEYCLOAK_DB_PASSWORD`, `KEYCLOAK_ADMIN_PASSWORD`, `KEYCLOAK_HOSTNAME=https://auth.playmancala.app`) and reload nginx with the new `auth.playmancala.app` block - [ ] Deploy the backend before the web app; the new web app needs token auth on the server +- [ ] The backend is now Java (Quarkus, see `docs/specs/quarkus-backend`); the compose file passes the database as + `DATABASE_JDBC_URL`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` built from the same `MANCALA_DB_PASSWORD`, so + `apps/.env` needs no change. It reads the Node backend's database and live games, so it can replace it (and be + rolled back) without ending games - [ ] Release the mobile update with the game clock before or with the backend: older app versions do not show the clock and do not handle `on_game_end`, so a game they lose on time does not end for them - [ ] Back up the Postgres volume (Keycloak and saved games), e.g. a daily `pg_dumpall` @@ -83,7 +87,8 @@ See `docs/specs/web-redesign`. `~/.gradle/gradle.properties` or environment variables, and consider resetting the upload key - [ ] Add `apps/mobile/mancala-upload-key.jks` to `.gitignore` (only `android/app/mancala-upload-key.jks` is ignored) - [ ] Validate moves before running them instead of treating every invalid move as a crash: each one writes a - file to `crashes/` and is broadcast to the players (`GameManager.onGameError`) + file to `crashes/` and is broadcast to the players (`GameManager.onGameError`, kept as it was in the Node + backend) ## Game rules @@ -95,7 +100,8 @@ See `docs/specs/web-redesign`. ## Bugs - [ ] `PitAnimator.dispose` removes a newly bound function, so the theme listener is never removed -- [ ] `onGameError` broadcasts an `Error`, which serializes to `{}` +- [ ] `onGameError` broadcasts `{}` instead of the error (the Java backend sends `{}` on purpose, as the Node backend + did) ## Maintenance @@ -103,6 +109,8 @@ See `docs/specs/web-redesign`. - [ ] Mixed lockfiles: `packages/*` and `apps/mobile` have both `yarn.lock` and `package-lock.json` - [ ] Align TypeScript and Jest versions across packages - [ ] Tests for `MatchMaker`; `PitAnimator` is only tested for the first frame of a move -- [ ] CI that runs the tests +- [ ] CI that runs the tests (the backend's need a JDK 25 and Docker) +- [ ] The rules exist twice, in `packages/mancala.js` and in the backend's Java port; a parity test keeps them alike, + but every rule change has to be made in both - [ ] Pre-existing type errors: 4 in the frontend, 11 in mobile; the frontend has no `tsconfig.json`, so nothing type-checks it diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..fb5c3ef --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,104 @@ +# Mancala Backend + +The game server: matchmaking, live games over a WebSocket, saved games and a small HTTP API. Java 25 and Quarkus 3.40, +built with Maven. The design and the reasons for it are in `docs/specs/quarkus-backend`. + +## Getting started + +You need a JDK 25 (e.g. Temurin); Maven comes with the wrapper. Postgres, Valkey and Keycloak run with Docker Compose, +see the README in the repo root. + +```sh +./mvnw quarkus:dev # http://localhost:5005; a debugger can attach on 5006 +./mvnw test # needs Docker: Postgres and Valkey run with Quarkus Dev Services +./mvnw package # target/quarkus-app/quarkus-run.jar +java -jar target/quarkus-app/quarkus-run.jar +``` + +The Docker image is built from the repo root: `docker build -f apps/backend/Dockerfile .`. It listens on port 6000. + +## Code + +| Package | What | +|---|---| +| `engine` | The game rules, a port of `packages/mancala.js` | +| `model` | The JSON sent to clients and stored, and the shared `ObjectMapper` | +| `loop` | `GameLoop`, the single thread that owns all live game state | +| `auth` | Keycloak token verification | +| `rtmt` | The WebSocket protocol (`RtmtServer`) and the Quarkus endpoint | +| `game` | Games, matchmaking, the clock, live previews, snapshots and saving finished games | +| `history` | Saved games in Postgres, migrations, the lists for the API | +| `live` | Valkey: live game snapshots, games waiting to be saved, the cache | +| `http` | The HTTP API | +| `Backend` | Wiring and startup | + +All live state (games, matchmaking, sockets) is only touched on the game loop, like on the Node backend's event loop. +Blocking work never runs there: Postgres writes run on the recorder's thread, Valkey writes on their own ordered +thread, token checks on virtual threads, HTTP requests on Quarkus' workers (which read live games through +`GameLoop.call`). + +The clients use the TypeScript engine, the server the Java one; they must play exactly alike. +`EngineParityTest` replays games recorded with the TypeScript engine. After changing the rules in both, record them +again: + +```sh +yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js +``` + +## Authentication + +Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token +over the WebSocket right after connecting: + +```json +{ "channel": "auth", "message": { "token": "" } } +``` + +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:`. + +| 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 | + +| 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/docs/specs/quarkus-backend/design.md b/docs/specs/quarkus-backend/design.md index bd84774..65e7dbe 100644 --- a/docs/specs/quarkus-backend/design.md +++ b/docs/specs/quarkus-backend/design.md @@ -38,13 +38,13 @@ apps/backend/ 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/ AuthConfig, TokenVerifier, KeycloakTokenVerifier, InvalidTokenException + 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, RootResource, BearerAuth, exception mapper + http/ GameResource (every route), BearerAuth, ErrorMapper (503 for failures) Backend.java wiring and startup (a @Startup bean) ``` diff --git a/docs/specs/quarkus-backend/requirements.md b/docs/specs/quarkus-backend/requirements.md index a163408..49879e0 100644 --- a/docs/specs/quarkus-backend/requirements.md +++ b/docs/specs/quarkus-backend/requirements.md @@ -1,6 +1,6 @@ # Quarkus backend: requirements -Status: implemented on branch `feature/quarkus-backend`. +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 diff --git a/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index fcce883..1994bf3 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -16,5 +16,5 @@ Work through the tasks in order; each ends with its tests passing and a commit. 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. -- [ ] 8. **End to end and docs**: the Chrome tests from `design.md` against the local stack; `README.md`, +- [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`.