From 7c99a76af7feac7b7621020f4b412835276d86c4 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Wed, 14 Oct 2026 01:46:48 +0300 Subject: [PATCH 01/82] feat(keycloak): add Keycloak with guest login extension - Custom authenticator creates and logs in a guest user when the authorization request has guest=true; other requests get the login form - Scheduled task removes guests with no remaining sessions - Realm export with mancala-web PKCE client, guest role and guest-aware browser flow (verify profile disabled so guests are not asked for details) - Dockerfile building the extension into a Keycloak 26.7.5 image - Keycloak and Postgres in compose; nginx proxies auth.playmancala.app and rate limits the auth endpoint - Integration tests run the extension and realm in a Keycloak container --- apps/.gitignore | 1 + apps/compose.yaml | 37 ++- apps/keycloak/.dockerignore | 1 + apps/keycloak/.gitignore | 1 + apps/keycloak/Dockerfile | 22 ++ apps/keycloak/README.md | 62 +++++ apps/keycloak/extension/pom.xml | 75 ++++++ .../keycloak/guest/GuestAuthenticator.java | 98 ++++++++ .../guest/GuestAuthenticatorFactory.java | 98 ++++++++ .../keycloak/guest/GuestCleanupTask.java | 73 ++++++ ...ycloak.authentication.AuthenticatorFactory | 1 + .../keycloak/guest/GuestLoginTest.java | 205 ++++++++++++++++ apps/keycloak/realm/mancala-realm.json | 226 ++++++++++++++++++ config/nginx.conf | 22 +- 14 files changed, 920 insertions(+), 2 deletions(-) create mode 100644 apps/.gitignore create mode 100644 apps/keycloak/.dockerignore create mode 100644 apps/keycloak/.gitignore create mode 100644 apps/keycloak/Dockerfile create mode 100644 apps/keycloak/README.md create mode 100644 apps/keycloak/extension/pom.xml create mode 100644 apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java create mode 100644 apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java create mode 100644 apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java create mode 100644 apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory create mode 100644 apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java create mode 100644 apps/keycloak/realm/mancala-realm.json diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1 @@ +.env diff --git a/apps/compose.yaml b/apps/compose.yaml index 7d229cc..bacfccd 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -6,4 +6,39 @@ services: frontend: image: jhalitaksoy/mancala-frontend:latest ports: - - "3000:3000" \ No newline at end of file + - "3000:3000" + keycloak-db: + image: postgres:17.6 + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD in apps/.env} + volumes: + - keycloak-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 5s + timeout: 5s + retries: 10 + keycloak: + image: jhalitaksoy/mancala-keycloak:latest + build: ./keycloak + environment: + KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD} + # Public URL of Keycloak; https://auth.playmancala.app in production + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:8080} + KC_HTTP_ENABLED: "true" + KC_PROXY_HEADERS: xforwarded + # Only used to create the master admin on the very first start + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in apps/.env} + ports: + - "127.0.0.1:8080:8080" + depends_on: + keycloak-db: + condition: service_healthy + +volumes: + keycloak-db: diff --git a/apps/keycloak/.dockerignore b/apps/keycloak/.dockerignore new file mode 100644 index 0000000..4b09d5f --- /dev/null +++ b/apps/keycloak/.dockerignore @@ -0,0 +1 @@ +extension/target diff --git a/apps/keycloak/.gitignore b/apps/keycloak/.gitignore new file mode 100644 index 0000000..b7de373 --- /dev/null +++ b/apps/keycloak/.gitignore @@ -0,0 +1 @@ +extension/target/ diff --git a/apps/keycloak/Dockerfile b/apps/keycloak/Dockerfile new file mode 100644 index 0000000..e3d9541 --- /dev/null +++ b/apps/keycloak/Dockerfile @@ -0,0 +1,22 @@ +# Keycloak with the mancala extension and realm baked in. +# Build context: apps/keycloak + +FROM maven:3.9.11-eclipse-temurin-21 AS extension +WORKDIR /build +COPY extension/pom.xml . +COPY extension/src src +RUN mvn -B -q package -Dmaven.test.skip=true + +# Keep the Keycloak version in sync with keycloak.version in extension/pom.xml +FROM quay.io/keycloak/keycloak:26.7.5 AS builder +ENV KC_DB=postgres +ENV KC_HEALTH_ENABLED=true +COPY --from=extension /build/target/mancala-keycloak-extension.jar /opt/keycloak/providers/ +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.7.5 +COPY --from=builder /opt/keycloak/ /opt/keycloak/ +# Imported on startup only if the realm does not exist yet. +COPY realm/mancala-realm.json /opt/keycloak/data/import/ +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] +CMD ["start", "--optimized", "--import-realm"] diff --git a/apps/keycloak/README.md b/apps/keycloak/README.md new file mode 100644 index 0000000..b4377e2 --- /dev/null +++ b/apps/keycloak/README.md @@ -0,0 +1,62 @@ +# Mancala Keycloak + +Keycloak is the only auth system for Mancala. Players can either log in with an account or play as a guest, +and both come back as normal Keycloak tokens from the `mancala` realm. + +## Guest login + +`extension/` adds a `mancala-guest-authenticator` to Keycloak. When an authorization request has `guest=true`, +it creates a new user named `guest-xxxxxxxx`, gives it the `guest` realm role and logs it in without showing a +form. Requests without the parameter fall through to the normal login form. + +``` +/realms/mancala/protocol/openid-connect/auth?client_id=mancala-web&response_type=code + &scope=openid%20offline_access&redirect_uri=...&code_challenge=...&code_challenge_method=S256 + &guest=true +``` + +- Clients can tell guests apart by `"guest"` in the access token's `realm_access.roles`. +- Guests should request `offline_access` so they keep their identity after the SSO session ends + (offline sessions expire after 30 days without use). +- A scheduled task deletes guests that are older than a day and have no session or offline session left. + Its timing can be changed with server options such as + `KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS` (default 3600), + `..._CLEANUP_MIN_AGE_SECONDS` (default 86400) and `..._CLEANUP_MAX_DELETES_PER_RUN` (default 1000). +- Each guest login creates a user, so the auth endpoint is rate limited per IP in `config/nginx.conf`. + +## Realm + +`realm/mancala-realm.json` holds the realm: the `mancala-web` public client (PKCE S256), the +`browser with guest` flow, the `guest` role, and the required actions with "Verify Profile" turned off so guests +are not asked for an email or a name. + +Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin +console too, or delete the realm and restart Keycloak. + +## Running + +```sh +cd apps +cat > .env < + + 4.0.0 + + app.playmancala + mancala-keycloak-extension + 0.1.0 + jar + Mancala Keycloak Extension + Guest login authenticator and guest cleanup task for the mancala realm + + + 21 + UTF-8 + + 26.7.5 + 4.3.1 + 5.13.4 + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + + com.github.dasniko + testcontainers-keycloak + ${testcontainers-keycloak.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + mancala-keycloak-extension + + + src/test/resources + + + ../realm + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java new file mode 100644 index 0000000..8644a51 --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java @@ -0,0 +1,98 @@ +package app.playmancala.keycloak.guest; + +import java.security.SecureRandom; +import java.util.HexFormat; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; + +/** + * Logs the user in as a freshly created guest when the authorization request carries {@code guest=true}. + * Any other request is passed on ({@code attempted}) so the next alternative, normally the login form, runs. + * Guests are marked with the {@value #GUEST_ROLE} realm role, which ends up in the token's realm_access.roles. + */ +public class GuestAuthenticator implements Authenticator { + + public static final String GUEST_PARAM = "guest"; + public static final String GUEST_ROLE = "guest"; + public static final String USERNAME_PREFIX = "guest-"; + + private static final Logger LOG = Logger.getLogger(GuestAuthenticator.class); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int MAX_USERNAME_ATTEMPTS = 5; + + @Override + public void authenticate(AuthenticationFlowContext context) { + String guestParam = context.getAuthenticationSession().getClientNote( + AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + GUEST_PARAM); + if (!"true".equals(guestParam)) { + context.attempted(); + return; + } + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + RoleModel guestRole = realm.getRole(GUEST_ROLE); + if (guestRole == null) { + LOG.errorf("Realm '%s' has no '%s' role, guest login is disabled", realm.getName(), GUEST_ROLE); + context.attempted(); + return; + } + + String username = generateUsername(session, realm); + if (username == null) { + LOG.errorf("Could not find a free guest username in realm '%s'", realm.getName()); + context.attempted(); + return; + } + + UserModel user = session.users().addUser(realm, username); + user.setEnabled(true); + user.grantRole(guestRole); + LOG.debugf("Created guest user '%s' in realm '%s'", username, realm.getName()); + + context.setUser(user); + context.success(); + } + + private String generateUsername(KeycloakSession session, RealmModel realm) { + for (int i = 0; i < MAX_USERNAME_ATTEMPTS; i++) { + byte[] bytes = new byte[4]; + RANDOM.nextBytes(bytes); + String username = USERNAME_PREFIX + HexFormat.of().formatHex(bytes); + if (session.users().getUserByUsername(realm, username) == null) { + return username; + } + } + return null; + } + + @Override + public void action(AuthenticationFlowContext context) { + // No form, nothing to submit. + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } +} diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java new file mode 100644 index 0000000..39a054b --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java @@ -0,0 +1,98 @@ +package app.playmancala.keycloak.guest; + +import java.time.Duration; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.timer.TimerProvider; + +/** + * Registers {@link GuestAuthenticator} and schedules {@link GuestCleanupTask}. + * + * Cleanup can be tuned with server options, e.g. + * {@code --spi-authenticator--mancala-guest-authenticator--cleanup-interval-seconds=3600}. + */ +public class GuestAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "mancala-guest-authenticator"; + + private static final GuestAuthenticator SINGLETON = new GuestAuthenticator(); + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private long cleanupIntervalMillis; + private long cleanupMinAgeMillis; + private int cleanupMaxDeletesPerRun; + + @Override + public void init(Config.Scope config) { + cleanupIntervalMillis = Duration.ofSeconds(config.getLong("cleanup-interval-seconds", 3600L)).toMillis(); + cleanupMinAgeMillis = Duration.ofSeconds(config.getLong("cleanup-min-age-seconds", 86400L)).toMillis(); + cleanupMaxDeletesPerRun = config.getInt("cleanup-max-deletes-per-run", 1000); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + GuestCleanupTask task = new GuestCleanupTask(cleanupMinAgeMillis, cleanupMaxDeletesPerRun); + KeycloakModelUtils.runJobInTransaction(factory, session -> + session.getProvider(TimerProvider.class).scheduleTask(task, cleanupIntervalMillis, task.getTaskName())); + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Mancala guest login"; + } + + @Override + public String getReferenceCategory() { + return "guest"; + } + + @Override + public String getHelpText() { + return "Creates and logs in a new guest user when the authorization request contains guest=true."; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public List getConfigProperties() { + return List.of(); + } + + @Override + public void close() { + } +} diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java new file mode 100644 index 0000000..ef18898 --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java @@ -0,0 +1,73 @@ +package app.playmancala.keycloak.guest; + +import java.util.ArrayList; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserManager; +import org.keycloak.models.UserModel; +import org.keycloak.timer.ScheduledTask; + +/** + * Deletes guest users that are older than the minimum age and have neither a regular nor an offline session left. + * Offline sessions expire after the realm's offline session idle timeout, so a guest who stops playing is removed + * roughly that long after their last visit. + */ +public class GuestCleanupTask implements ScheduledTask { + + private static final Logger LOG = Logger.getLogger(GuestCleanupTask.class); + private static final int PAGE_SIZE = 200; + + private final long minAgeMillis; + private final int maxDeletesPerRun; + + public GuestCleanupTask(long minAgeMillis, int maxDeletesPerRun) { + this.minAgeMillis = minAgeMillis; + this.maxDeletesPerRun = maxDeletesPerRun; + } + + @Override + public void run(KeycloakSession session) { + long createdBefore = Time.currentTimeMillis() - minAgeMillis; + session.realms().getRealmsStream().forEach(realm -> cleanupRealm(session, realm, createdBefore)); + } + + private void cleanupRealm(KeycloakSession session, RealmModel realm, long createdBefore) { + RoleModel guestRole = realm.getRole(GuestAuthenticator.GUEST_ROLE); + if (guestRole == null) return; + + // User and session queries require the session to be bound to the realm they run in. + session.getContext().setRealm(realm); + List staleGuests = new ArrayList<>(); + for (int first = 0; staleGuests.size() < maxDeletesPerRun; first += PAGE_SIZE) { + List page = session.users().getRoleMembersStream(realm, guestRole, first, PAGE_SIZE).toList(); + if (page.isEmpty()) break; + page.stream() + .filter(user -> isStale(session, realm, user, createdBefore)) + .limit(maxDeletesPerRun - staleGuests.size()) + .forEach(staleGuests::add); + } + + UserManager userManager = new UserManager(session); + staleGuests.forEach(user -> userManager.removeUser(realm, user)); + if (!staleGuests.isEmpty()) { + LOG.infof("Removed %d inactive guest users from realm '%s'", staleGuests.size(), realm.getName()); + } + } + + private boolean isStale(KeycloakSession session, RealmModel realm, UserModel user, long createdBefore) { + Long created = user.getCreatedTimestamp(); + if (created == null || created > createdBefore) return false; + return session.sessions().getUserSessionsStream(realm, user).findAny().isEmpty() + && session.sessions().getOfflineUserSessionsStream(realm, user).findAny().isEmpty(); + } + + @Override + public String getTaskName() { + return "mancala-guest-cleanup"; + } +} diff --git a/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000..6274ae9 --- /dev/null +++ b/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +app.playmancala.keycloak.guest.GuestAuthenticatorFactory diff --git a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java new file mode 100644 index 0000000..115c46b --- /dev/null +++ b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java @@ -0,0 +1,205 @@ +package app.playmancala.keycloak.guest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.CookieManager; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.representations.idm.UserRepresentation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dasniko.testcontainers.keycloak.KeycloakContainer; + +/** + * Runs the extension and the real realm export in a Keycloak container. Needs Docker. + */ +class GuestLoginTest { + + private static final String REALM = "mancala"; + private static final String CLIENT_ID = "mancala-web"; + private static final String REDIRECT_URI = "http://localhost:1234/auth/callback"; + private static final ObjectMapper JSON = new ObjectMapper(); + + private static KeycloakContainer keycloak; + + @BeforeAll + static void startKeycloak() { + keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5") + .withProviderClassesFrom("target/classes") + .withRealmImportFile("/mancala-realm.json") + .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2") + .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0"); + keycloak.start(); + } + + @AfterAll + static void stopKeycloak() { + if (keycloak != null) keycloak.stop(); + } + + @Test + void guestRequestLogsInAsNewGuestWithoutShowingAForm() throws Exception { + JsonNode tokens = loginAsGuest(); + JsonNode accessToken = decodeJwt(tokens.get("access_token").asText()); + + assertTrue(accessToken.get("preferred_username").asText().startsWith(GuestAuthenticator.USERNAME_PREFIX)); + assertTrue(realmRoles(accessToken).contains(GuestAuthenticator.GUEST_ROLE)); + assertEquals("Offline", decodeJwt(tokens.get("refresh_token").asText()).get("typ").asText()); + } + + @Test + void eachGuestLoginCreatesADifferentUser() throws Exception { + String first = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText(); + String second = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText(); + assertNotEquals(first, second); + } + + @Test + void requestWithoutGuestParamShowsLoginForm() throws Exception { + HttpResponse response = newHttpClient().send( + HttpRequest.newBuilder(authorizationUri(Map.of(), randomUrlSafe(32))).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("id=\"kc-form-login\"")); + } + + @Test + void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception { + String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText(); + String idleGuest = createUser("guest-idle-" + UUID.randomUUID(), true); + String regularUser = createUser("player-" + UUID.randomUUID(), false); + + RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM); + long deadline = System.currentTimeMillis() + 20_000; + while (!realm.users().searchByUsername(idleGuest, true).isEmpty()) { + assertTrue(System.currentTimeMillis() < deadline, "idle guest was not cleaned up"); + Thread.sleep(500); + } + + assertFalse(realm.users().searchByUsername(activeGuest, true).isEmpty(), "guest with a session was removed"); + assertFalse(realm.users().searchByUsername(regularUser, true).isEmpty(), "regular user was removed"); + } + + private static String createUser(String username, boolean guest) { + RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM); + UserRepresentation user = new UserRepresentation(); + user.setUsername(username); + user.setEnabled(true); + realm.users().create(user).close(); + if (guest) { + String id = realm.users().searchByUsername(username, true).get(0).getId(); + realm.users().get(id).roles().realmLevel() + .add(List.of(realm.roles().get(GuestAuthenticator.GUEST_ROLE).toRepresentation())); + } + return username; + } + + /** Runs the authorization code flow with PKCE, as the web client will, and returns the token response. */ + private static JsonNode loginAsGuest() throws Exception { + HttpClient http = newHttpClient(); + String verifier = randomUrlSafe(32); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + + HttpResponse authResponse = http.send( + HttpRequest.newBuilder(authorizationUri(Map.of("guest", "true"), challenge)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(302, authResponse.statusCode(), "expected redirect back to the client, got: " + authResponse.body()); + + String location = authResponse.headers().firstValue("Location").orElseThrow(); + assertTrue(location.startsWith(REDIRECT_URI), location); + String code = queryParams(URI.create(location)).get("code"); + assertNotNull(code, location); + + HttpResponse tokenResponse = http.send( + HttpRequest.newBuilder(URI.create(realmUrl() + "/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form(Map.of( + "grant_type", "authorization_code", + "client_id", CLIENT_ID, + "redirect_uri", REDIRECT_URI, + "code", code, + "code_verifier", verifier)))) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, tokenResponse.statusCode(), tokenResponse.body()); + return JSON.readTree(tokenResponse.body()); + } + + private static URI authorizationUri(Map extraParams, String codeChallenge) { + Map params = new java.util.HashMap<>(Map.of( + "client_id", CLIENT_ID, + "response_type", "code", + "redirect_uri", REDIRECT_URI, + "scope", "openid offline_access", + "state", randomUrlSafe(8), + "code_challenge", codeChallenge, + "code_challenge_method", "S256")); + params.putAll(extraParams); + return URI.create(realmUrl() + "/protocol/openid-connect/auth?" + form(params)); + } + + private static String realmUrl() { + return keycloak.getAuthServerUrl() + "/realms/" + REALM; + } + + private static HttpClient newHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(new CookieManager()) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + private static JsonNode decodeJwt(String jwt) throws Exception { + return JSON.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])); + } + + private static List realmRoles(JsonNode accessToken) { + List roles = new java.util.ArrayList<>(); + accessToken.path("realm_access").path("roles").forEach(role -> roles.add(role.asText())); + return roles; + } + + private static Map queryParams(URI uri) { + return java.util.Arrays.stream(uri.getRawQuery().split("&")) + .map(pair -> pair.split("=", 2)) + .collect(Collectors.toMap(p -> p[0], p -> java.net.URLDecoder.decode(p[1], StandardCharsets.UTF_8))); + } + + private static String form(Map params) { + return params.entrySet().stream() + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } + + private static String randomUrlSafe(int bytes) { + byte[] buffer = new byte[bytes]; + new SecureRandom().nextBytes(buffer); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer); + } +} diff --git a/apps/keycloak/realm/mancala-realm.json b/apps/keycloak/realm/mancala-realm.json new file mode 100644 index 0000000..38007f4 --- /dev/null +++ b/apps/keycloak/realm/mancala-realm.json @@ -0,0 +1,226 @@ +{ + "realm": "mancala", + "displayName": "Mancala", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "verifyEmail": false, + "bruteForceProtected": true, + "offlineSessionIdleTimeout": 2592000, + "browserFlow": "browser with guest", + "requiredActions": [ + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 54, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 57, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": false, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": false, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 130, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "roles": { + "realm": [ + { + "name": "guest", + "description": "Anonymous player created by the mancala guest authenticator" + } + ] + }, + "clients": [ + { + "clientId": "mancala-web", + "name": "Mancala Web", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:1234/*", + "https://playmancala.app/*" + ], + "webOrigins": [ + "http://localhost:1234", + "https://playmancala.app" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:1234/*##https://playmancala.app/*" + } + } + ], + "authenticationFlows": [ + { + "alias": "browser with guest", + "description": "Browser flow that can log in as a new guest when the request has guest=true", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "authenticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "mancala-guest-authenticator", + "requirement": "ALTERNATIVE", + "priority": 20, + "authenticatorFlow": false, + "userSetupAllowed": false + }, + { + "flowAlias": "browser with guest forms", + "requirement": "ALTERNATIVE", + "priority": 30, + "authenticatorFlow": true, + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser with guest forms", + "description": "Username and password form", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "authenticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ] +} diff --git a/config/nginx.conf b/config/nginx.conf index 02f14a1..44713a8 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,3 +1,5 @@ +limit_req_zone $binary_remote_addr zone=keycloak_auth:10m rate=10r/m; + server { listen 80 default_server; listen [::]:80 default_server; @@ -86,9 +88,27 @@ server { index index.html index.htm index.nginx-debian.html; server_name auth.playmancala.app; # managed by Certbot + # Keycloak sends large cookies and headers. + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Every guest login creates a Keycloak user, so limit how often one IP can start a login. + location = /realms/mancala/protocol/openid-connect/auth { + limit_req zone=keycloak_auth burst=20 nodelay; + limit_req_status 429; + proxy_pass http://localhost:8080; + } location / { - try_files $uri $uri/ =404; + proxy_pass http://localhost:8080; } listen [::]:443 ssl; # managed by Certbot -- 2.45.2 From 30ad51c493f0a53085ba7958106cbf88e661f5a2 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Wed, 14 Oct 2026 01:52:07 +0300 Subject: [PATCH 02/82] feat(backend): authenticate WebSocket clients with Keycloak tokens - New auth module verifies Keycloak access tokens offline against the realm's signing keys (issuer, RS256, typ=Bearer, allowed azp); guests are recognised by the guest realm role - Clients send an auth message after connecting and get on_auth or on_auth_error; unauthenticated sockets are closed after 5 seconds and their messages ignored; a socket can refresh its token but not switch user - A user can be connected from several sockets; a late close of an old socket no longer drops the new one - Malformed messages are ignored instead of throwing - Games store player names and guest flags and send them with the connection info - Legacy ?userKey= clients and /register stay available behind LEGACY_USERKEY_AUTH; legacy keys must be UUIDs so they cannot take a Keycloak user's kc: id - TypeScript target raised to es2020 for async/await --- apps/backend/README.md | 23 +- apps/backend/package.json | 1 + apps/backend/src/auth/AuthConfig.ts | 16 ++ apps/backend/src/auth/AuthUser.ts | 7 + .../backend/src/auth/KeycloakTokenVerifier.ts | 72 ++++++ apps/backend/src/auth/TokenVerifier.ts | 13 ++ apps/backend/src/auth/channels.ts | 11 + apps/backend/src/auth/index.ts | 4 + apps/backend/src/game/GameManager.ts | 33 ++- apps/backend/src/index.ts | 14 +- apps/backend/src/models/Game.ts | 16 +- apps/backend/src/rtmt/rtmt.ts | 5 +- apps/backend/src/rtmt/rtmt_websocket.ts | 217 +++++++++++++----- apps/backend/src/server/ExpressApp.ts | 13 +- .../tests/auth/KeycloakTokenVerifier.test.ts | 61 +++++ .../backend/tests/rtmt/rtmt_websocket.test.ts | 209 +++++++++++++++++ apps/backend/tsconfig.json | 2 +- apps/backend/yarn.lock | 5 + apps/compose.yaml | 7 + 19 files changed, 645 insertions(+), 84 deletions(-) create mode 100644 apps/backend/src/auth/AuthConfig.ts create mode 100644 apps/backend/src/auth/AuthUser.ts create mode 100644 apps/backend/src/auth/KeycloakTokenVerifier.ts create mode 100644 apps/backend/src/auth/TokenVerifier.ts create mode 100644 apps/backend/src/auth/channels.ts create mode 100644 apps/backend/src/auth/index.ts create mode 100644 apps/backend/tests/auth/KeycloakTokenVerifier.test.ts create mode 100644 apps/backend/tests/rtmt/rtmt_websocket.test.ts diff --git a/apps/backend/README.md b/apps/backend/README.md index 656bead..a312021 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -25,4 +25,25 @@ yarn start ### Development Dependencies -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. \ No newline at end of file +This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. + +## 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 | diff --git a/apps/backend/package.json b/apps/backend/package.json index ac5e34b..3ba8b50 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -21,6 +21,7 @@ "@types/ws": "^7.4.5", "cors": "^2.8.5", "express": "^4.17.1", + "jose": "5.10.0", "mancala.js": "^0.0.2-beta.3", "morgan": "^1.10.0", "uuid": "^8.3.2", diff --git a/apps/backend/src/auth/AuthConfig.ts b/apps/backend/src/auth/AuthConfig.ts new file mode 100644 index 0000000..3a543c2 --- /dev/null +++ b/apps/backend/src/auth/AuthConfig.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..5965ed3 --- /dev/null +++ b/apps/backend/src/auth/AuthUser.ts @@ -0,0 +1,7 @@ +export interface AuthUser { + // Stable user id used everywhere in the game (player ids, rtmt client ids). + id: string; + // Display name shown to other players. Undefined for legacy userKey clients. + name?: string; + isGuest: boolean; +} diff --git a/apps/backend/src/auth/KeycloakTokenVerifier.ts b/apps/backend/src/auth/KeycloakTokenVerifier.ts new file mode 100644 index 0000000..43f918c --- /dev/null +++ b/apps/backend/src/auth/KeycloakTokenVerifier.ts @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..9406a46 --- /dev/null +++ b/apps/backend/src/auth/TokenVerifier.ts @@ -0,0 +1,13 @@ +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/channels.ts b/apps/backend/src/auth/channels.ts new file mode 100644 index 0000000..11e2d40 --- /dev/null +++ b/apps/backend/src/auth/channels.ts @@ -0,0 +1,11 @@ +// WebSocket auth protocol. Move to @mancala/core together with the client implementation. + +// client -> server: { token: string } — Keycloak access token. Send right after connecting, and again after refreshing. +// Other messages are ignored until on_auth arrives. +export const channel_auth = "auth"; +// server -> client: { user: AuthUser } — the socket is authenticated as this user. +export const channel_on_auth = "on_auth"; +// server -> client: { reason: string } — sent right before the socket is closed with CLOSE_AUTH_FAILED. +export const channel_on_auth_error = "on_auth_error"; + +export const CLOSE_AUTH_FAILED = 4001; diff --git a/apps/backend/src/auth/index.ts b/apps/backend/src/auth/index.ts new file mode 100644 index 0000000..43eeddb --- /dev/null +++ b/apps/backend/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from "./AuthUser"; +export * from "./AuthConfig"; +export * from "./TokenVerifier"; +export * from "./KeycloakTokenVerifier"; diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index 2350240..bc10c95 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -19,7 +19,7 @@ import { GameMove } from "@mancala/core"; import { GameCrashManager } from "./GameCrashManager"; import { MatchMaker } from "../matchmaker/MatchMaker"; import { GameUsersConnectionInfo } from "@mancala/core"; -import { BackendGame } from "../models/Game"; +import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game"; export class GameManager { gameStore: GameStore; @@ -142,18 +142,21 @@ export class GameManager { }); } - public fireOnPlayerConnected(playerId: string) { } - public createMancalaGame(userKey1: string, userKey2: string): BackendGame { const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2); const random = Math.random(); mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2; + const players: Record = { + [userKey1]: this.getPlayerInfo(userKey1), + [userKey2]: this.getPlayerInfo(userKey2), + }; const game: BackendGame = { id: mancalaGame.id, mancalaGame, - gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromUsers(mancalaGame.player1Id, mancalaGame.player2Id), - spectatorIds: [] + gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromPlayers(players[userKey1], players[userKey2]), + spectatorIds: [], + players, }; this.gameStore.set(game.id, game); @@ -162,16 +165,22 @@ export class GameManager { return game; } - public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { - return this.getGameUsersConnectionInfoFromUsers(game.mancalaGame.player1Id, game.mancalaGame.player2Id); + private getPlayerInfo(userId: string): PlayerInfo { + const user = this.rtmt.getUser(userId); + return { userId, name: user?.name, isGuest: user?.isGuest ?? true }; } - public getGameUsersConnectionInfoFromUsers(user1Id: string, user2Id: string): GameUsersConnectionInfo { - const isPlayer1Online = this.rtmt.isClientOnline(user1Id); - const isPlayer2Online = this.rtmt.isClientOnline(user2Id); + public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { + const { player1Id, player2Id } = game.mancalaGame; + return this.getGameUsersConnectionInfoFromPlayers(game.players[player1Id], game.players[player2Id]); + } + + private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { + const toConnectionInfo = (player: PlayerInfo): PlayerConnectionInfo => + ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) }); return { - user1ConnectionInfo: { userId: user1Id, isOnline: isPlayer1Online }, - user2ConnectionInfo: { userId: user2Id, isOnline: isPlayer2Online } + user1ConnectionInfo: toConnectionInfo(player1), + user2ConnectionInfo: toConnectionInfo(player2) }; } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index e5d51e7..493cbdc 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,13 +5,21 @@ 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"; -const rtmt = new RTMTWS(); +const authConfig = readAuthConfig(); +const tokenVerifier = new KeycloakTokenVerifier({ + issuer: authConfig.keycloakIssuer, + jwksUrl: authConfig.keycloakJwksUrl, + allowedClientIds: authConfig.keycloakClientIds, +}); + +const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); const gameStore = new GameStoreImpl(); const matchMaker = new MatchMakerImpl({ rtmt }); const gameManager = new GameManager({ rtmt, gameStore, matchMaker }) -const expressApp = new ExpressApp({ gameStore }); +const expressApp = new ExpressApp({ gameStore, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); const server = new WebServer({ expressApp: expressApp.app }); -rtmt.initWebSocket(server.server, (userKey: string) => gameManager.fireOnPlayerConnected(userKey)); +rtmt.initWebSocket(server.server); diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index 01854f5..b5d851d 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -1,5 +1,17 @@ -import { Game } from "@mancala/core"; +import { Game, UserConnectionInfo } from "@mancala/core"; + +export interface PlayerInfo { + userId: string; + // Undefined for legacy userKey clients. + name?: string; + isGuest: boolean; +} + +// Sent to clients in place of UserConnectionInfo; the extra fields are ignored by older clients. +export type PlayerConnectionInfo = UserConnectionInfo & PlayerInfo; export interface BackendGame extends Game { spectatorIds: string[]; -} \ No newline at end of file + // Keyed by user id, captured when the game is created. + players: Record; +} diff --git a/apps/backend/src/rtmt/rtmt.ts b/apps/backend/src/rtmt/rtmt.ts index fe735d7..f1845bf 100644 --- a/apps/backend/src/rtmt/rtmt.ts +++ b/apps/backend/src/rtmt/rtmt.ts @@ -1,3 +1,5 @@ +import { AuthUser } from "../auth"; + export type OnMessage = (clientID: string, message: Object) => any export type OnClientConnectionChange = (clientID: string, isOnline : boolean) => any @@ -5,5 +7,6 @@ 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; listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void; -} \ No newline at end of file +} diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts index fd3078d..a527cd9 100644 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ b/apps/backend/src/rtmt/rtmt_websocket.ts @@ -4,66 +4,154 @@ import WebSocket from "ws" import * as http from 'http'; import { channel_ping, channel_pong } from "@mancala/core"; import { RTMT_WS_PING_INTERVAL } from "../consts/config"; -import { decode, encode } from "./encode_decode_message"; +import { decode, encode, Message } from "./encode_decode_message"; +import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth"; +import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../auth/channels"; + +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: Map + private messageChannels = new Map() - private wsServer: WebSocket.Server | null + private wsServer: WebSocket.Server | null = null - public clients = new Map() + // 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; + onUserConnectionChange?: OnClientConnectionChange; - constructor() { - this.messageChannels = new Map() - this.wsServer = null + 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, onopen: (userKey: string) => any) { + initWebSocket(server: http.Server) { const wsServer = new WebSocket.Server({ server }) this.wsServer = wsServer - this.clients = new Map() - - wsServer.on("connection", (ws: WebSocket, req: Request) => { - console.log("(RTMT) Client Connected"); - const regexResult = req.url.split(RegExp("\/\?userKey=")); - const clientID = regexResult[1] - this.clients.set(clientID, ws) - this.onUserConnectionChange?.(clientID, true); - ws.on("message", (messageBytes: string) => { - this.onWebSocketMessage(clientID, messageBytes) - }) - - ws.on("close", (code: number, reason: string) => { - console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason); - this.clients.delete(clientID) - this.onUserConnectionChange?.(clientID, false); - }) - - ws.on("error", (err: Error) => { - console.log("(RTMT) WS Closed with error! error : " + err.message); - this.clients.delete(clientID) - }) - - this.setWebSocketAsAlive(ws); - onopen(clientID) - }) + 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.onUserConnectionChange?.(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.onUserConnectionChange?.(userId, false); + } + } + dispose() { this.stopPingInterval(); } sendMessage(clientID: string, channel: string, message: Object) { if (this.wsServer) { - const client = this.clients.get(clientID) - if (client) { - this.sendMessageInternal(client, channel, message); + const userSockets = this.sockets.get(clientID) + if (userSockets) { + userSockets.forEach((ws) => this.sendMessageInternal(ws, channel, message)); } else { console.log("(RTMT) Client not connected!"); } @@ -73,6 +161,7 @@ export class RTMTWS implements RTMT { } sendMessageInternal(ws: WebSocket, channel: string, message: Object) { + if (ws.readyState !== WebSocket.OPEN) return; const data = encode({ channel: channel, message: message }) ws.send(data) } @@ -81,20 +170,32 @@ export class RTMTWS implements RTMT { this.messageChannels.set(channel, callback) } - onWebSocketMessage(clientID: string, messageBytes: string) { - const message = decode(messageBytes) - this.onMessage(clientID, message.channel, message.message) + 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) { - if (channel === channel_pong) { - const ws = this.clients.get(clientID); - if (ws) { - this.heartbeat(ws); - } - return; - } const callback = this.messageChannels.get(channel) if (callback) { callback(clientID, message) @@ -106,16 +207,13 @@ export class RTMTWS implements RTMT { startPingInterval(wsServer: WebSocket.Server) { this.stopPingInterval(); this.pingInterval = setInterval(() => { - const sendMessageInternal = this.sendMessageInternal; - wsServer.clients.forEach(function each(ws) { - //@ts-ignore + wsServer.clients.forEach((ws: ClientSocket) => { if (ws.isAlive === false) { console.log("(RTMT) Terminated WebSocket Connection"); return ws.terminate(); } - //@ts-ignore ws.isAlive = false; - sendMessageInternal(ws, channel_ping, {}); + this.sendMessageInternal(ws, channel_ping, {}); }); }, RTMT_WS_PING_INTERVAL); } @@ -124,20 +222,19 @@ export class RTMTWS implements RTMT { if (this.pingInterval) clearInterval(this.pingInterval); } - heartbeat(ws: WebSocket) { - this.setWebSocketAsAlive(ws); - } - - setWebSocketAsAlive(ws: WebSocket) { - //@ts-ignore + setWebSocketAsAlive(ws: ClientSocket) { ws.isAlive = true; } isClientOnline(clientID: string): boolean { - return this.clients.has(clientID); + return this.sockets.has(clientID); + } + + getUser(clientID: string): AuthUser | undefined { + return this.users.get(clientID); } listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) { this.onUserConnectionChange = onUserConnectionChange; } -} \ No newline at end of file +} diff --git a/apps/backend/src/server/ExpressApp.ts b/apps/backend/src/server/ExpressApp.ts index e8a0d72..bebe6a2 100644 --- a/apps/backend/src/server/ExpressApp.ts +++ b/apps/backend/src/server/ExpressApp.ts @@ -9,8 +9,10 @@ import { GameStore } from "../game/gamestore/GameStore"; export class ExpressApp { app: Express.Application; gameStore: GameStore; - constructor(params: { gameStore: GameStore }) { + legacyUserKeyAuth: boolean; + constructor(params: { gameStore: GameStore, legacyUserKeyAuth: boolean }) { this.gameStore = params.gameStore; + this.legacyUserKeyAuth = params.legacyUserKeyAuth; this.app = this.createExpressApp(); } private createAccessLogMiddleware() { @@ -26,9 +28,12 @@ export class ExpressApp { app.get("/", (req: Request, res: Response) => { res.send("Server up and running! ci test1"); }); - app.get("/register/", (req: Request, res: Response) => { - res.send(generateKey()); - }); + // Issues userKeys for legacy clients; token clients get their identity from Keycloak. + if (this.legacyUserKeyAuth) { + app.get("/register/", (req: Request, res: Response) => { + res.send(generateKey()); + }); + } app.get("/game/:id", (req: Request, res: Response) => { const gameId = req.params.id; diff --git a/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts b/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts new file mode 100644 index 0000000..8cbc735 --- /dev/null +++ b/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts @@ -0,0 +1,61 @@ +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/rtmt/rtmt_websocket.test.ts b/apps/backend/tests/rtmt/rtmt_websocket.test.ts new file mode 100644 index 0000000..872cc63 --- /dev/null +++ b/apps/backend/tests/rtmt/rtmt_websocket.test.ts @@ -0,0 +1,209 @@ +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 "../../src/auth/channels"; +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/tsconfig.json b/apps/backend/tsconfig.json index e1334ed..cb46546 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", diff --git a/apps/backend/yarn.lock b/apps/backend/yarn.lock index 1d46b16..b54a4a8 100644 --- a/apps/backend/yarn.lock +++ b/apps/backend/yarn.lock @@ -2466,6 +2466,11 @@ jest@^27.0.5: 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" diff --git a/apps/compose.yaml b/apps/compose.yaml index bacfccd..8aacd5e 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -1,6 +1,13 @@ services: backend: image: jhalitaksoy/mancala-backend:latest + environment: + # Must match the iss claim, i.e. the public Keycloak URL; https://auth.playmancala.app/realms/mancala in production + KEYCLOAK_ISSUER: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}/realms/mancala + # Fetch keys over the compose network instead of the public URL + KEYCLOAK_JWKS_URL: http://keycloak:8080/realms/mancala/protocol/openid-connect/certs + KEYCLOAK_CLIENT_IDS: mancala-web + LEGACY_USERKEY_AUTH: ${LEGACY_USERKEY_AUTH:-true} ports: - "5000:6000" frontend: -- 2.45.2 From e165b0a0eb9198d50ab6da8b01076a34bb32c6dc Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Wed, 14 Oct 2026 02:09:05 +0300 Subject: [PATCH 03/82] build: use local mancala.js and @mancala/core packages - Apps and @mancala/core depend on the packages in this repo through link: dependencies instead of the npm releases, so changes no longer need a publish - Root package.json gets a build:packages script that installs and builds both packages - Backend and frontend Docker images are built from the repo root so they can include the packages; compose builds them that way - Metro watches the packages folder and resolves their imports from the app's node_modules --- .dockerignore | 7 +++++++ README.md | 17 +++++++++++++++++ apps/backend/.dockerignore | 1 - apps/backend/Dockerfile | 15 +++++++++++---- apps/backend/README.md | 2 +- apps/backend/package.json | 4 ++-- apps/backend/yarn.lock | 19 ++++++------------- apps/compose.yaml | 6 ++++++ apps/frontend/.dockerignore | 2 -- apps/frontend/Dockerfile | 16 ++++++++++++---- apps/frontend/package.json | 4 ++-- apps/frontend/yarn.lock | 19 ++++++------------- apps/mobile/metro.config.js | 10 +++++++++- apps/mobile/package.json | 4 ++-- apps/mobile/yarn.lock | 19 ++++++------------- package.json | 7 +++++++ packages/core/package.json | 2 +- packages/core/yarn.lock | 7 +++---- 18 files changed, 98 insertions(+), 63 deletions(-) create mode 100644 .dockerignore delete mode 100644 apps/backend/.dockerignore delete mode 100644 apps/frontend/.dockerignore create mode 100644 package.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b49f95b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Images for apps/backend and apps/frontend are built with the repo root as context. +**/node_modules +**/dist +**/.parcel-cache +.git +apps/mobile +apps/keycloak diff --git a/README.md b/README.md index 0cb83ec..545cd3d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ ### Mancala 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: + +```sh +yarn build:packages # from the repo root +``` + +After changing a package, rebuild it (or run `yarn dev` in it to rebuild on change); the apps pick up the new `dist`. + +Docker images for the backend and frontend are built from the repo root: + +```sh +docker build -f apps/backend/Dockerfile -t jhalitaksoy/mancala-backend . +docker build -f apps/frontend/Dockerfile -t jhalitaksoy/mancala-frontend . +``` diff --git a/apps/backend/.dockerignore b/apps/backend/.dockerignore deleted file mode 100644 index b512c09..0000000 --- a/apps/backend/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 9cc2217..6d115d2 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,19 +1,26 @@ +# Build from the repo root: docker build -f apps/backend/Dockerfile . FROM node:lts-buster-slim ENV PORT=6000 -# Create app directory WORKDIR /usr/src/app +# Local packages the backend links to COPY package.json package.json -COPY yarn.lock yarn.lock +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 . . +COPY apps/backend . RUN yarn build EXPOSE ${PORT} -CMD [ "yarn", "start"] \ No newline at end of file +CMD [ "yarn", "start"] diff --git a/apps/backend/README.md b/apps/backend/README.md index a312021..d3ac8e5 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -25,7 +25,7 @@ yarn start ### Development Dependencies -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. +This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root. ## Authentication diff --git a/apps/backend/package.json b/apps/backend/package.json index 3ba8b50..724060a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -14,7 +14,7 @@ "author": "", "license": "ISC", "dependencies": { - "@mancala/core": "0.0.2", + "@mancala/core": "link:../../packages/core", "@types/cors": "^2.8.10", "@types/morgan": "^1.9.3", "@types/uuid": "^8.3.0", @@ -22,7 +22,7 @@ "cors": "^2.8.5", "express": "^4.17.1", "jose": "5.10.0", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../../packages/mancala.js", "morgan": "^1.10.0", "uuid": "^8.3.2", "ws": "^7.5.0" diff --git a/apps/backend/yarn.lock b/apps/backend/yarn.lock index b54a4a8..3e3e5c5 100644 --- a/apps/backend/yarn.lock +++ b/apps/backend/yarn.lock @@ -525,15 +525,9 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@mancala/core@0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@mancala/core/-/core-0.0.2.tgz#c50abb60f0598306cc78f36c799dfd8a9e7c274b" - integrity sha512-wnufrDxNMkdlXFYulxTtTe88Tw79j9RPC7iQh+W/c9V2yTUBoVk2FzdO0Wj9ZV2/nrrOqYI0IchjpoRyl3RDBQ== - dependencies: - "@types/uuid" "^9.0.8" - mancala.js "^0.0.2-beta.3" - tiny-emitter "^2.1.0" - uuid "^10.0.0" +"@mancala/core@link:../../packages/core": + version "0.0.0" + uid "" "@sindresorhus/is@^0.14.0": version "0.14.0" @@ -2627,10 +2621,9 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../../packages/mancala.js": + version "0.0.0" + uid "" media-typer@0.3.0: version "0.3.0" diff --git a/apps/compose.yaml b/apps/compose.yaml index 8aacd5e..6b030d7 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -1,6 +1,9 @@ services: backend: image: jhalitaksoy/mancala-backend:latest + build: + context: .. + dockerfile: apps/backend/Dockerfile environment: # Must match the iss claim, i.e. the public Keycloak URL; https://auth.playmancala.app/realms/mancala in production KEYCLOAK_ISSUER: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}/realms/mancala @@ -12,6 +15,9 @@ services: - "5000:6000" frontend: image: jhalitaksoy/mancala-frontend:latest + build: + context: .. + dockerfile: apps/frontend/Dockerfile ports: - "3000:3000" keycloak-db: diff --git a/apps/frontend/.dockerignore b/apps/frontend/.dockerignore deleted file mode 100644 index 76add87..0000000 --- a/apps/frontend/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 8952481..a213b7a 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,24 +1,32 @@ +# Build from the repo root: docker build -f apps/frontend/Dockerfile . FROM node:20-alpine3.19 AS build ARG SERVER_ADDRESS WS_SERVER_ADDRESS WORKDIR /app +# Local packages the frontend links to COPY package.json ./ +COPY packages packages +RUN yarn build:packages -COPY yarn.lock ./ +WORKDIR /app/apps/frontend + +COPY apps/frontend/package.json ./ + +COPY apps/frontend/yarn.lock ./ RUN yarn install -COPY . ./ +COPY apps/frontend ./ RUN yarn build FROM nginx:stable-alpine -COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html -COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY apps/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 3000 diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 78fc4de..b4a74b8 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -13,7 +13,7 @@ "author": "", "license": "ISC", "dependencies": { - "@mancala/core": "0.0.2", + "@mancala/core": "link:../../packages/core", "@szhsin/react-menu": "^3.1.2", "@types/": "szhsin/react-menu", "@types/eventemitter2": "^4.1.0", @@ -21,7 +21,7 @@ "@types/uuid": "^8.3.4", "eventemitter2": "^6.4.7", "i18next": "^23.11.5", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../../packages/mancala.js", "notyf": "^3.10.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/apps/frontend/yarn.lock b/apps/frontend/yarn.lock index f71f220..3e53ab2 100644 --- a/apps/frontend/yarn.lock +++ b/apps/frontend/yarn.lock @@ -313,15 +313,9 @@ resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-2.5.2.tgz#28f643fbc0bec30b07fbe95b137879b6b4d1c9c5" integrity sha512-zrBczSbXKxEyK2ijtbRdICDygRqWSRPpZMN5dD1T8VMEW5RIhIbwFWw2phDRXuBQdVDpSjalCIUMWMV2h3JaZA== -"@mancala/core@0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@mancala/core/-/core-0.0.2.tgz#c50abb60f0598306cc78f36c799dfd8a9e7c274b" - integrity sha512-wnufrDxNMkdlXFYulxTtTe88Tw79j9RPC7iQh+W/c9V2yTUBoVk2FzdO0Wj9ZV2/nrrOqYI0IchjpoRyl3RDBQ== - dependencies: - "@types/uuid" "^9.0.8" - mancala.js "^0.0.2-beta.3" - tiny-emitter "^2.1.0" - uuid "^10.0.0" +"@mancala/core@link:../../packages/core": + version "0.0.0" + uid "" "@mischnic/json-sourcemap@^0.1.0": version "0.1.0" @@ -1600,10 +1594,9 @@ loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../../packages/mancala.js": + version "0.0.0" + uid "" mdn-data@2.0.14: version "2.0.14" diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index ad8f87b..5ba57b7 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,3 +1,4 @@ +const path = require('path'); const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); /** @@ -6,6 +7,13 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); * * @type {import('metro-config').MetroConfig} */ -const config = {}; +const config = { + // @mancala/core and mancala.js are linked from the repo's packages folder. + watchFolders: [path.resolve(__dirname, '../../packages')], + resolver: { + // Lets the linked packages find modules that only the app installs, such as @babel/runtime. + nodeModulesPaths: [path.resolve(__dirname, 'node_modules')], + }, +}; module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ba07720..dfef15d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -10,13 +10,13 @@ "test": "jest" }, "dependencies": { - "@mancala/core": "0.0.2", + "@mancala/core": "link:../../packages/core", "@react-native/new-app-screen": "0.82.1", "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.9.26", "@types/uuid": "^9.0.8", "i18next": "^23.10.1", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../../packages/mancala.js", "react": "19.1.1", "react-i18next": "^14.1.0", "react-native": "0.82.1", diff --git a/apps/mobile/yarn.lock b/apps/mobile/yarn.lock index 04168dc..11ea220 100644 --- a/apps/mobile/yarn.lock +++ b/apps/mobile/yarn.lock @@ -1340,15 +1340,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@mancala/core@0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@mancala/core/-/core-0.0.2.tgz#c50abb60f0598306cc78f36c799dfd8a9e7c274b" - integrity sha512-wnufrDxNMkdlXFYulxTtTe88Tw79j9RPC7iQh+W/c9V2yTUBoVk2FzdO0Wj9ZV2/nrrOqYI0IchjpoRyl3RDBQ== - dependencies: - "@types/uuid" "^9.0.8" - mancala.js "^0.0.2-beta.3" - tiny-emitter "^2.1.0" - uuid "^10.0.0" +"@mancala/core@link:../../packages/core": + version "0.0.0" + uid "" "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" @@ -4784,10 +4778,9 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../../packages/mancala.js": + version "0.0.0" + uid "" marky@^1.2.2: version "1.3.0" diff --git a/package.json b/package.json new file mode 100644 index 0000000..36a1e0c --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "mancala", + "private": true, + "scripts": { + "build:packages": "yarn --cwd packages/mancala.js install --frozen-lockfile && yarn --cwd packages/mancala.js build && yarn --cwd packages/core install --frozen-lockfile && yarn --cwd packages/core build" + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 0d6ed36..29560c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ "license": "MIT", "dependencies": { "@types/uuid": "^9.0.8", - "mancala.js": "^0.0.2-beta.3", + "mancala.js": "link:../mancala.js", "tiny-emitter": "^2.1.0", "uuid": "^10.0.0" }, diff --git a/packages/core/yarn.lock b/packages/core/yarn.lock index 318200d..4fc3c3f 100644 --- a/packages/core/yarn.lock +++ b/packages/core/yarn.lock @@ -2334,10 +2334,9 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -mancala.js@^0.0.2-beta.3: - version "0.0.2-beta.3" - resolved "https://registry.yarnpkg.com/mancala.js/-/mancala.js-0.0.2-beta.3.tgz#78edfa220e1a7172351a07f255eb81180845226a" - integrity sha512-LPmQ/VT4/JWFdp/YSB7k63zK7GyflApyh4M26t23a9uXFRSpBcWSePtNFpHU/xY2+1gVjlbOwQjup2QW3Tue7w== +"mancala.js@link:../mancala.js": + version "0.0.0" + uid "" merge-stream@^2.0.0: version "2.0.0" -- 2.45.2 From f5dcf930fa6424e311d8a7c6312a5305fbd722c6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Wed, 14 Oct 2026 02:22:13 +0300 Subject: [PATCH 04/82] feat(frontend): log in with Keycloak, as a guest or with an account - First visit logs in as a new guest through Keycloak without a form; the session, including an offline refresh token, is kept in local storage - Account menu on the home page: guests can log in or sign up, users can log out (which starts a new guest session) - The WebSocket client in core authenticates with the access token after connecting, reports "connected" once the server accepts it, and re-sends renewed tokens on the open connection - Game page shows player names; guests appear as "Guest 4F2A" - Auth channels, AuthUser and player names in UserConnectionInfo move to core and the backend uses them from there - Mobile keeps using userKey login for now --- apps/backend/src/auth/AuthUser.ts | 8 +- apps/backend/src/auth/channels.ts | 11 --- apps/backend/src/game/GameManager.ts | 6 +- apps/backend/src/models/Game.ts | 5 +- apps/backend/src/rtmt/rtmt_websocket.ts | 3 +- .../backend/tests/rtmt/rtmt_websocket.test.ts | 2 +- apps/compose.yaml | 2 + apps/frontend/Dockerfile | 2 +- apps/frontend/README.md | 18 +++- apps/frontend/package.json | 1 + apps/frontend/src/MancalaApp.tsx | 77 ++++++++++++----- apps/frontend/src/auth/AuthService.ts | 85 +++++++++++++++++++ .../src/components/headerbar/AccountMenu.tsx | 50 +++++++++++ apps/frontend/src/const/config.ts | 9 ++ apps/frontend/src/context/context.tsx | 10 +-- apps/frontend/src/routes/GamePage.tsx | 17 ++-- apps/frontend/src/routes/Home.tsx | 7 +- apps/frontend/src/store/KeyStore.ts | 53 ------------ apps/frontend/yarn.lock | 12 +++ apps/mobile/src/App.tsx | 3 +- packages/core/src/localization/en.ts | 6 +- packages/core/src/localization/tr.ts | 6 +- packages/core/src/models/AuthUser.ts | 7 ++ .../core/src/models/UserConnectionInfo.ts | 5 +- packages/core/src/models/index.ts | 3 +- packages/core/src/rtmt/channel_names.ts | 9 +- packages/core/src/rtmt/client/rtmt.ts | 10 ++- .../core/src/rtmt/client/rtmt_websocket.ts | 56 ++++++++++-- packages/core/src/util/PlayerUtil.ts | 15 ++++ packages/core/src/util/index.ts | 3 +- 30 files changed, 369 insertions(+), 132 deletions(-) delete mode 100644 apps/backend/src/auth/channels.ts create mode 100644 apps/frontend/src/auth/AuthService.ts create mode 100644 apps/frontend/src/components/headerbar/AccountMenu.tsx delete mode 100644 apps/frontend/src/store/KeyStore.ts create mode 100644 packages/core/src/models/AuthUser.ts create mode 100644 packages/core/src/util/PlayerUtil.ts diff --git a/apps/backend/src/auth/AuthUser.ts b/apps/backend/src/auth/AuthUser.ts index 5965ed3..95adb3f 100644 --- a/apps/backend/src/auth/AuthUser.ts +++ b/apps/backend/src/auth/AuthUser.ts @@ -1,7 +1 @@ -export interface AuthUser { - // Stable user id used everywhere in the game (player ids, rtmt client ids). - id: string; - // Display name shown to other players. Undefined for legacy userKey clients. - name?: string; - isGuest: boolean; -} +export type { AuthUser } from "@mancala/core"; diff --git a/apps/backend/src/auth/channels.ts b/apps/backend/src/auth/channels.ts deleted file mode 100644 index 11e2d40..0000000 --- a/apps/backend/src/auth/channels.ts +++ /dev/null @@ -1,11 +0,0 @@ -// WebSocket auth protocol. Move to @mancala/core together with the client implementation. - -// client -> server: { token: string } — Keycloak access token. Send right after connecting, and again after refreshing. -// Other messages are ignored until on_auth arrives. -export const channel_auth = "auth"; -// server -> client: { user: AuthUser } — the socket is authenticated as this user. -export const channel_on_auth = "on_auth"; -// server -> client: { reason: string } — sent right before the socket is closed with CLOSE_AUTH_FAILED. -export const channel_on_auth_error = "on_auth_error"; - -export const CLOSE_AUTH_FAILED = 4001; diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index bc10c95..d7a5b1a 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -18,8 +18,8 @@ import { import { GameMove } from "@mancala/core"; import { GameCrashManager } from "./GameCrashManager"; import { MatchMaker } from "../matchmaker/MatchMaker"; -import { GameUsersConnectionInfo } from "@mancala/core"; -import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game"; +import { GameUsersConnectionInfo, UserConnectionInfo } from "@mancala/core"; +import { BackendGame, PlayerInfo } from "../models/Game"; export class GameManager { gameStore: GameStore; @@ -176,7 +176,7 @@ export class GameManager { } private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { - const toConnectionInfo = (player: PlayerInfo): PlayerConnectionInfo => + const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) }); return { user1ConnectionInfo: toConnectionInfo(player1), diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index b5d851d..601a64d 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -1,4 +1,4 @@ -import { Game, UserConnectionInfo } from "@mancala/core"; +import { Game } from "@mancala/core"; export interface PlayerInfo { userId: string; @@ -7,9 +7,6 @@ export interface PlayerInfo { isGuest: boolean; } -// Sent to clients in place of UserConnectionInfo; the extra fields are ignored by older clients. -export type PlayerConnectionInfo = UserConnectionInfo & PlayerInfo; - export interface BackendGame extends Game { spectatorIds: string[]; // Keyed by user id, captured when the game is created. diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts index a527cd9..df4b784 100644 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ b/apps/backend/src/rtmt/rtmt_websocket.ts @@ -2,11 +2,10 @@ import { OnClientConnectionChange, OnMessage, RTMT } from "./rtmt" import WebSocket from "ws" import * as http from 'http'; -import { channel_ping, channel_pong } from "@mancala/core"; +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"; -import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../auth/channels"; 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; diff --git a/apps/backend/tests/rtmt/rtmt_websocket.test.ts b/apps/backend/tests/rtmt/rtmt_websocket.test.ts index 872cc63..ce1e3fa 100644 --- a/apps/backend/tests/rtmt/rtmt_websocket.test.ts +++ b/apps/backend/tests/rtmt/rtmt_websocket.test.ts @@ -2,7 +2,7 @@ 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 "../../src/auth/channels"; +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"; diff --git a/apps/compose.yaml b/apps/compose.yaml index 6b030d7..39e9a41 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -18,6 +18,8 @@ services: build: context: .. dockerfile: apps/frontend/Dockerfile + args: + KEYCLOAK_URL: ${KEYCLOAK_HOSTNAME:-http://localhost:8080} ports: - "3000:3000" keycloak-db: diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index a213b7a..fc4a1ae 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,7 +1,7 @@ # Build from the repo root: docker build -f apps/frontend/Dockerfile . FROM node:20-alpine3.19 AS build -ARG SERVER_ADDRESS WS_SERVER_ADDRESS +ARG SERVER_ADDRESS WS_SERVER_ADDRESS KEYCLOAK_URL WORKDIR /app diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 873596b..c3ffa38 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -18,10 +18,24 @@ yarn dev ### Development Dependencies -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. +This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root. + +### Login + +Every visitor has a Keycloak session (see `apps/keycloak`): the first visit logs in as a new guest without any +form, and the account menu on the home page lets guests log in or sign up. Run Keycloak locally with +`docker compose up keycloak` in `apps/`; the dev server must run on port 1234, which the `mancala-web` client allows. + +| Build variable | Default | +|---|---| +| `SERVER_ADDRESS` | `http://localhost:5005` | +| `WS_SERVER_ADDRESS` | `ws://localhost:5005` | +| `KEYCLOAK_URL` | `http://localhost:8080` | ### Docker Build +From the repo root: + ```bash -docker build --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 -t mancala-frontend . +docker build -f apps/frontend/Dockerfile --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 --build-arg KEYCLOAK_URL=http://localhost:8080 -t mancala-frontend . ``` \ No newline at end of file diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b4a74b8..0af3191 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -23,6 +23,7 @@ "i18next": "^23.11.5", "mancala.js": "link:../../packages/mancala.js", "notyf": "^3.10.0", + "oidc-client-ts": "3.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.2", diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index 85b1831..b381e91 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -10,7 +10,7 @@ import GamePage from './routes/GamePage'; import Home from './routes/Home'; import { initContext } from './context/context'; -import { RTMTWS, ConnectionState } from '@mancala/core'; +import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core'; import { getColorByBrightness } from "@mancala/core"; import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; @@ -26,7 +26,13 @@ const MancalaApp: FunctionComponent = () => { const { t, i18n } = useTranslation(); - const [userKey, setUserKey] = useState(undefined); + // Set once the server has authenticated the connection. + const [authUser, setAuthUser] = useState(undefined); + + // Pages compare this with the game's player ids. + const userKey = authUser?.id; + + const [hasSession, setHasSession] = useState(false); const [connectionState, setConnetionState] = useState("connecting"); @@ -44,35 +50,66 @@ const MancalaApp: FunctionComponent = () => { i18n.changeLanguage(deviceLanguage === "tr-TR" ? "tr" : "en") }, []); - const connectRTMT = (userKey: string) => { + const onAuthenticated = (user: AuthUser) => setAuthUser(user); + + // If the session can no longer be refreshed, start over as a new guest. + const getAccessTokenOrLoginAgain = async () => { + try { + return await context.authService.getAccessToken(); + } catch (error) { + context.authService.loginAsGuest(); + throw error; + } + } + + const connectRTMT = () => { const rtmt = context.rtmt as RTMTWS; rtmt.on("error", onConnectionError); - rtmt.on("connectionchange", onConnectionChange) - const url = server.wsServerAdress + "?userKey=" + userKey; - rtmt.connectWebSocket(url, userKey) + rtmt.on("connectionchange", onConnectionChange); + rtmt.on("authenticated", onAuthenticated); + rtmt.connectWebSocket(server.wsServerAdress, getAccessTokenOrLoginAgain); return rtmt; } - const loadUserKeyAndConnectServer = () => { - context.userKeyStore.getUserKey().then((userKey: string) => { - setUserKey(userKey); - connectRTMT(userKey); - }).catch((error) => { - //TODO: check if it is network error! - swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error"); - console.error(error); - }); + const startSession = async () => { + const authService = context.authService; + if (authService.isCallback()) { + let returnTo = "/"; + try { + returnTo = await authService.handleCallback(); + } catch (error) { + // e.g. the callback page was reloaded; fall back to the stored session. + console.error("(Auth) Login callback failed", error); + } + window.history.replaceState(null, "", returnTo); + } + const user = await authService.getUser(); + if (!user) { + await authService.loginAsGuest(); + return; + } + setHasSession(true); + connectRTMT(); } const disposeApp = () => { context.rtmt?.dispose(); + context.rtmt.off("authenticated", onAuthenticated); context.themeManager.off("themechange", onThemeChange); } React.useEffect(() => { - loadUserKeyAndConnectServer(); + startSession().catch((error) => { + swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error"); + console.error(error); + }); + const removeUserLoadedListener = context.authService.onUserLoaded( + (user) => (context.rtmt as RTMTWS).reauthenticate(user.access_token)); context.themeManager.on("themechange", onThemeChange); - return () => disposeApp(); + return () => { + removeUserLoadedListener(); + disposeApp(); + }; }, []); const textColorOnBoard = getColorByBrightness( @@ -82,9 +119,9 @@ const MancalaApp: FunctionComponent = () => { ); return ( <> - + {hasSession && - } /> + } /> } > @@ -95,7 +132,7 @@ const MancalaApp: FunctionComponent = () => { - + } {Util.getTextByConnectionState(context, connectionState, t)} diff --git a/apps/frontend/src/auth/AuthService.ts b/apps/frontend/src/auth/AuthService.ts new file mode 100644 index 0000000..6629a18 --- /dev/null +++ b/apps/frontend/src/auth/AuthService.ts @@ -0,0 +1,85 @@ +import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; + +const CALLBACK_PATH = "/auth/callback"; + +type SigninState = { returnTo: string }; + +/** + * Keycloak login for the web app. Everyone has a session: visitors without one are logged in as a new guest, + * and guests can log in or sign up to switch to an account. + */ +export class AuthService { + private readonly userManager: UserManager; + + constructor(params: { keycloakUrl: string, realm: string, clientId: string }) { + const origin = window.location.origin; + this.userManager = new UserManager({ + authority: `${params.keycloakUrl}/realms/${params.realm}`, + client_id: params.clientId, + redirect_uri: origin + CALLBACK_PATH, + post_logout_redirect_uri: origin + "/", + response_type: "code", + // offline_access keeps guests' identity after the SSO session ends. + scope: "openid offline_access", + userStore: new WebStorageStateStore({ store: window.localStorage }), + // Renews with the refresh token shortly before the access token expires. + automaticSilentRenew: true, + monitorSession: false, + }); + } + + public isCallback(): boolean { + return window.location.pathname === CALLBACK_PATH; + } + + // Completes a login redirect and returns the path the user started from. + public async handleCallback(): Promise { + const user = await this.userManager.signinRedirectCallback(); + return (user.state as SigninState | undefined)?.returnTo ?? "/"; + } + + // The stored session, refreshed if its access token has expired. Null if there is no usable session. + public async getUser(): Promise { + const user = await this.userManager.getUser(); + if (!user || !user.expired) return user; + try { + return await this.userManager.signinSilent(); + } catch (error) { + console.warn("(Auth) Could not refresh the session", error); + await this.userManager.removeUser(); + return null; + } + } + + public async getAccessToken(): Promise { + const user = await this.getUser(); + if (!user) throw new Error("Not logged in"); + return user.access_token; + } + + public onUserLoaded(callback: (user: User) => void): () => void { + this.userManager.events.addUserLoaded(callback); + return () => this.userManager.events.removeUserLoaded(callback); + } + + public loginAsGuest(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ extraQueryParams: { guest: "true" }, state: { returnTo } }); + } + + // prompt=login shows the login form even though the guest already has a session. + public login(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ prompt: "login", state: { returnTo } }); + } + + public signUp(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ prompt: "create", state: { returnTo } }); + } + + public logout(): Promise { + return this.userManager.signoutRedirect(); + } +} + +function currentPath(): string { + return window.location.pathname + window.location.search; +} diff --git a/apps/frontend/src/components/headerbar/AccountMenu.tsx b/apps/frontend/src/components/headerbar/AccountMenu.tsx new file mode 100644 index 0000000..dc20841 --- /dev/null +++ b/apps/frontend/src/components/headerbar/AccountMenu.tsx @@ -0,0 +1,50 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { AuthUser, getPlayerDisplayName } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// Guests can log in or sign up; logged in users can log out. +const AccountMenu: FunctionComponent<{ context: Context, textColor: string, authUser?: AuthUser }> = (props) => { + const { context, textColor, authUser } = props; + const { t } = useTranslation(); + if (!authUser) return <>; + + const authService = context.authService; + const menuButton = + account_circle + ; + const itemStyle = { color: textColor }; + const menuItems = authUser.isGuest ? [ + authService.login()}>{t("LogIn")}, + authService.signUp()}>{t("SignUp")}, + ] : [ + authService.logout()}>{t("LogOut")}, + ]; + return ( +
+ + {getPlayerDisplayName(authUser, t)} + {menuItems} + + +
+ ); +} + +export default AccountMenu; diff --git a/apps/frontend/src/const/config.ts b/apps/frontend/src/const/config.ts index 4da4a82..c199ef8 100644 --- a/apps/frontend/src/const/config.ts +++ b/apps/frontend/src/const/config.ts @@ -15,4 +15,13 @@ export const server: Server = { wsServerAdress: process.env.WS_SERVER_ADDRESS ? `${process.env.WS_SERVER_ADDRESS}` : LOCAL_WS_SERVER_ADDRESS, } +export const LOCAL_KEYCLOAK_URL = 'http://localhost:8080'; + +export const keycloak = { + //@ts-ignore + url: process.env.KEYCLOAK_URL ? `${process.env.KEYCLOAK_URL}` : LOCAL_KEYCLOAK_URL, + realm: "mancala", + clientId: "mancala-web", +}; + export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000; \ No newline at end of file diff --git a/apps/frontend/src/context/context.tsx b/apps/frontend/src/context/context.tsx index 3dacda4..b848039 100644 --- a/apps/frontend/src/context/context.tsx +++ b/apps/frontend/src/context/context.tsx @@ -1,14 +1,14 @@ -import { server } from "../const/config"; +import { keycloak, server } from "../const/config"; import { RTMT, RTMTWS, ThemeManager } from "@mancala/core"; import { HttpServiceImpl } from "../service/HttpService"; import { GameStore, GameStoreImpl } from "../store/GameStore"; -import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore"; +import { AuthService } from "../auth/AuthService"; import { LocalStorage } from "../storage"; import { Storage } from '@mancala/core' export type Context = { rtmt: RTMT; - userKeyStore: UserKeyStore; + authService: AuthService; themeManager: ThemeManager; gameStore: GameStore; storage: Storage; @@ -17,13 +17,13 @@ export type Context = { export const initContext = (): Context => { const rtmt = new RTMTWS(); const httpService = new HttpServiceImpl(server.serverAdress); - const userKeyStore = new UserKeyStoreImpl({ httpService }); + const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId }); const gameStore = new GameStoreImpl({ httpService }); const storage = new LocalStorage(); const themeManager = new ThemeManager(storage); return { rtmt: rtmt, - userKeyStore: userKeyStore, + authService, themeManager, gameStore, storage diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 1abb708..68e9f20 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -22,7 +22,7 @@ import { Context } from '../context/context'; import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { Theme } from '@mancala/core'; -import { getColorByBrightness } from "@mancala/core"; +import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core"; import { BoardViewModel } from "@mancala/core"; import Center from '../components/Center'; import notyf from '../util/Notyf'; @@ -84,6 +84,11 @@ const GamePage: FunctionComponent<{ return false; } + const getPlayerName = (userId: string) => { + const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]; + return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t); + } + const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => { const newGame: Game = message as Game; newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame); @@ -230,17 +235,19 @@ const GamePage: FunctionComponent<{ const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; + const topLocatedUserName = getPlayerName(topLocatedUserId); + const bottomLocatedUserName = getPlayerName(bottomLocatedUserId); const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: topLocatedUserName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true + isAnonymous: !topLocatedUserName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: bottomLocatedUserName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true + isAnonymous: !bottomLocatedUserName }; const currentUser = isSpectator ? { id: "2", diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index 09da876..6e83ac0 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,13 +1,14 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { getColorByBrightness } from "@mancala/core"; -import { Theme } from "@mancala/core"; +import { AuthUser, Theme } from "@mancala/core"; import HeaderBar from "../components/headerbar/HeaderBar"; import PageContainer from "../components/PageContainer"; import Row from "../components/Row"; import HeaderbarIcon from "../components/headerbar/HeaderbarIcon"; import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; +import AccountMenu from "../components/headerbar/AccountMenu"; import Button from "../components/Button"; import { Context } from "../context/context"; import { Link, useNavigate } from "react-router-dom"; @@ -17,8 +18,9 @@ import { useTranslation } from "react-i18next"; const Home: FunctionComponent<{ context: Context, userKey?: string, + authUser?: AuthUser, theme: Theme, -}> = ({ context, userKey, theme }) => { +}> = ({ context, userKey, authUser, theme }) => { const navigate = useNavigate(); @@ -47,6 +49,7 @@ const Home: FunctionComponent<{ + + {!collapsed && ( +
    + {entry(0, {t("Start")})} + {moves.map((move, i) => entry(i + 1, ( + <> + {i + 1}. + {playerName(move.playerId)} + {describe(i + 1)} + + )))} +
+ )} + + + ); +}; + +export default MoveList; diff --git a/apps/frontend/src/components/history/ReviewBanner.tsx b/apps/frontend/src/components/history/ReviewBanner.tsx new file mode 100644 index 0000000..553166f --- /dev/null +++ b/apps/frontend/src/components/history/ReviewBanner.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getColorByBrightness } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import Button from "../Button"; + +// Shown during a live game while an earlier position is on the board. +const ReviewBanner: FunctionComponent<{ + context: Context, + moveNumber: number, + isYourTurn: boolean, + onBackToLive: () => void, +}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => { + const { t } = useTranslation(); + const theme = context.themeManager.theme; + const background = isYourTurn ? theme.playerTurnColor : theme.boardColor; + const textColor = getColorByBrightness(background, theme.textColor, theme.textLightColor); + return ( +
+ + {`⟲ ${t("ViewingMove", { number: moveNumber })}`} + {isYourTurn && {` · ${t("YourTurn")}`}} + +
+ ); +}; + +export default ReviewBanner; diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx index f0f10ed..fec02b6 100644 --- a/apps/frontend/src/components/home/GamePreviewCard.tsx +++ b/apps/frontend/src/components/home/GamePreviewCard.tsx @@ -92,7 +92,8 @@ const GamePreviewCard: FunctionComponent<{ .board { pointer-events: none; } - .line { + /* Built in a map callback, which styled-jsx does not scope. */ + .card :global(.line) { font-size: 0.9em; opacity: 0.85; } diff --git a/apps/frontend/src/hooks/useMoveKeys.ts b/apps/frontend/src/hooks/useMoveKeys.ts new file mode 100644 index 0000000..c4e7e9d --- /dev/null +++ b/apps/frontend/src/hooks/useMoveKeys.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +// Arrow keys step through moves and space toggles play; ignored while typing in a form field. +export default function useMoveKeys(handlers: { previous: () => void, next: () => void, togglePlay?: () => void }) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) && target.getAttribute("type") !== "range") return; + if (event.key === "ArrowLeft") handlers.previous(); + else if (event.key === "ArrowRight") handlers.next(); + else if (event.key === " " && handlers.togglePlay) handlers.togglePlay(); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); +} diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 7889633..96e8bba 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import Row from '../components/Row'; import UserStatus from '../components/UserStatus'; -import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events } from '@mancala/core'; +import { channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, PlayedMove, TURN_TIMEOUT_MS } from '@mancala/core'; import { Context } from '../context/context'; import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; @@ -29,6 +29,12 @@ import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; import { useTranslation } from 'react-i18next'; +import MoveList from '../components/history/MoveList'; +import ReviewBanner from '../components/history/ReviewBanner'; +import useMoveKeys from '../hooks/useMoveKeys'; +import { createBoardViewModel } from '../util/BoardViewModels'; +import { getResultText } from '../util/GameResultText'; +import CircularPanel from '../components/CircularPanel'; const GamePage: FunctionComponent<{ context: Context, @@ -53,6 +59,15 @@ const GamePage: FunctionComponent<{ const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); + // Set while an earlier position is shown instead of the live board. + const [viewedMove, setViewedMove] = useState(undefined); + + // Set when the server says the game ended, for whatever reason. + const [gameEnd, setGameEnd] = useState(undefined); + + // Ticks every second while a countdown is shown. + const [now, setNow] = useState(Date.now()); + const { height, width } = useWindowDimensions(); const navigate = useNavigate(); @@ -105,6 +120,10 @@ const GamePage: FunctionComponent<{ setUserKeyWhoLeave(userKeyWhoLeave); setHasOngoingAction(false); }; + const onGameEnd = (message: GameEndMessage) => { + setGameEnd(message); + setHasOngoingAction(false); + }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; setGameUsersConnectionInfo(gameUsersConnectionInfo); @@ -116,7 +135,9 @@ const GamePage: FunctionComponent<{ context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange); + context.rtmt.addMessageListener(channel_on_game_end, onGameEnd); return () => { + context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd); context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); @@ -178,10 +199,14 @@ const GamePage: FunctionComponent<{ if (!game || isSpectator || !userKey) { return; } - if(userKeyWhoLeave) { + if(userKeyWhoLeave || gameEnd) { notyf.error(t("GameEnded")); return; } + if (viewedMove !== undefined) { + notyf.error(t("ViewingMove", { number: viewedMove })); + return; + } if(game.mancalaGame.state === "ended") { notyf.error(t("GameEnded")); return; @@ -246,7 +271,51 @@ const GamePage: FunctionComponent<{ ); const isMobile = width < 600; - const renderNewGameBtn = isSpectator || (userKeyWhoLeave || !game || (game && game.mancalaGame.state == "ended")); + const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); + + const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; + const isReviewing = viewedMove !== undefined && viewedMove < moves.length; + const shownBoardViewModel = isReviewing && mancalaGame + ? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme) + : boardViewModel; + const isGameOver = !!(userKeyWhoLeave || gameEnd || mancalaGame?.state === "ended"); + const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey; + const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber)); + useMoveKeys({ + previous: () => selectMove((isReviewing ? viewedMove! : moves.length) - 1), + next: () => isReviewing && selectMove(viewedMove! + 1), + }); + + const playerInfo = (userId: string) => { + const info = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] + .find((connectionInfo) => connectionInfo?.userId === userId); + return { userId, name: info?.name, isGuest: info?.isGuest ?? true }; + }; + // A game that ended by a timeout or inactivity is not over by the rules, so say why it ended. + const endText = gameEnd && (gameEnd.endReason === "timeout" || gameEnd.endReason === "inactive") && mancalaGame + ? `${t("GameEnded")} · ${getResultText({ + result: gameEnd.result, endReason: gameEnd.endReason, + player1: playerInfo(mancalaGame.player1Id), + player2: playerInfo(mancalaGame.player2Id), + } as FinishedGameSummary, isSpectator ? undefined : userKey, t)}` + : undefined; + + // Countdown for the waiting player while the opponent is offline on their turn. + const opponentInfo = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] + .find((info) => info && info.userId !== userKey); + const countdownMs = !isSpectator && !isGameOver && opponentInfo && !opponentInfo.isOnline + && opponentInfo.offlineSince !== undefined && mancalaGame?.turnPlayerId === opponentInfo.userId + ? Math.max(0, opponentInfo.offlineSince + TURN_TIMEOUT_MS - now) + : undefined; + React.useEffect(() => { + if (countdownMs === undefined) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [countdownMs === undefined]); + const formatCountdown = (ms: number) => { + const seconds = Math.ceil(ms / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; + }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; @@ -276,15 +345,36 @@ const GamePage: FunctionComponent<{ {renderHeaderBar()} {renderMobileBoardToolbar()} {buildBoardTopToolbar()} + {showBoardView && countdownMs !== undefined && ( + + {t("OpponentOfflineCountdown", { time: formatCountdown(countdownMs) })} + + )} + {showBoardView && isReviewing && ( + setViewedMove(undefined)} /> + )} {showBoardView && ( )} + {showBoardView && ( + getPlayerName(playerId) ?? t("Anonymous")} + collapsedByDefault={isMobile} /> + )}
@@ -343,7 +433,8 @@ const GamePage: FunctionComponent<{ blackPlayer={bottomLocatedUser} leftPlayer={leftPlayer} visible={params.visible} - isSpectator={isSpectator} /> + isSpectator={isSpectator} + overrideText={endText} /> ); } } diff --git a/apps/frontend/src/util/BoardViewModels.ts b/apps/frontend/src/util/BoardViewModels.ts new file mode 100644 index 0000000..419fa84 --- /dev/null +++ b/apps/frontend/src/util/BoardViewModels.ts @@ -0,0 +1,9 @@ +import { BoardViewModel, BoardViewModelFactory, PitViewModelFactory, Theme } from "@mancala/core"; +import { v4 } from "uuid"; + +// A plain board (no highlights) showing the given stone counts. +export function createBoardViewModel(stones: number[], theme: Theme): BoardViewModel { + return BoardViewModelFactory.create(v4(), stones.map((stoneCount, index) => PitViewModelFactory.create({ + id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor, + }))); +} diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index 6dc1edb..f33773c 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -20,6 +20,6 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop. - [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby rejection message. -- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. +- [x] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. - [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. - [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates. -- 2.45.2 From 3bb151dbeda0496050953d0ea12bd05e86bc31cf Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:34:49 +0300 Subject: [PATCH 17/82] feat(frontend): replay finished games move by move - Opening a finished game shows its final position, result, score, date and duration, with Watch from start - First / previous / play-pause / next / last, speed 1x, 2x, 4x, a timeline, the move list and arrow keys and space - Stepping forward animates the move like a live game; jumps show the position directly - ?move=n links to a move and follows the shown move - Players see the board from their own side - PitAnimator takes its step interval as a parameter --- .../src/components/history/MoveList.tsx | 16 +- .../src/components/history/ReplayView.tsx | 316 ++++++++++++++++++ apps/frontend/src/routes/GamePage.tsx | 16 +- apps/frontend/src/util/GameResultText.ts | 8 + apps/frontend/src/util/MoveText.ts | 15 + docs/specs/game-history/tasks.md | 2 +- packages/core/src/animation/PitAnimator.ts | 12 +- 7 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 apps/frontend/src/components/history/ReplayView.tsx create mode 100644 apps/frontend/src/util/MoveText.ts diff --git a/apps/frontend/src/components/history/MoveList.tsx b/apps/frontend/src/components/history/MoveList.tsx index 3daf240..ca0016a 100644 --- a/apps/frontend/src/components/history/MoveList.tsx +++ b/apps/frontend/src/components/history/MoveList.tsx @@ -1,8 +1,9 @@ import * as React from "react"; import { FunctionComponent, useEffect, useRef, useState } from "react"; -import { describeMove, getColorByBrightness, PlayedMove } from "@mancala/core"; +import { getColorByBrightness, PlayedMove } from "@mancala/core"; import { useTranslation } from "react-i18next"; import { Context } from "../../context/context"; +import { getMoveText } from "../../util/MoveText"; // The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start. const MoveList: FunctionComponent<{ @@ -31,17 +32,6 @@ const MoveList: FunctionComponent<{ } }, [selectedMove, moves.length, collapsed]); - const describe = (moveNumber: number): string => { - const move = describeMove(moves, moveNumber); - const events = [ - move.extraTurn ? `★ ${t("ExtraTurn")}` : undefined, - move.capture ? `✂ ${t("Capture")}` : undefined, - move.storeGain > 0 ? t("StoreGain", { count: move.storeGain }) : undefined, - move.gameOver ? `⚑ ${t("GameOver")}` : undefined, - ].filter(Boolean); - return [t("PitNumber", { pit: move.pit }), ...events].join(" · "); - }; - const entry = (moveNumber: number, label: React.ReactNode) => (
  • {i + 1}. {playerName(move.playerId)} - {describe(i + 1)} + {getMoveText(moves, i + 1, t)} )))} diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx new file mode 100644 index 0000000..bcf3dfa --- /dev/null +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -0,0 +1,316 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"; +import { + BoardViewModel, + createGameAtMove, + createGameFromBoard, + DEFAULT_ANIMATION_INTERVAL, + GameRecord, + getBoardAfterMove, + getColorByBrightness, + getPlayerDisplayName, + PitAnimator, + PlayerSummary, + Theme, +} from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { v4 } from "uuid"; +import { Context } from "../../context/context"; +import useMoveKeys from "../../hooks/useMoveKeys"; +import { createBoardViewModel } from "../../util/BoardViewModels"; +import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText"; +import { getMoveText } from "../../util/MoveText"; +import BoardView from "../board/BoardView"; +import Button from "../Button"; +import HeaderBar from "../headerbar/HeaderBar"; +import HeaderbarIcon from "../headerbar/HeaderbarIcon"; +import HeaderbarTitle from "../headerbar/HeaderbarTitle"; +import ThemeSwitchMenu from "../headerbar/ThemeSwitchMenu"; +import PageContainer from "../PageContainer"; +import Row from "../Row"; +import MoveList from "./MoveList"; + +const SPEEDS = [1, 2, 4]; +// Extra pause after a move's animation while playing, at 1x. +const PAUSE_BETWEEN_MOVES_MS = 700; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +function readMoveFromUrl(total: number): number { + const value = new URLSearchParams(window.location.search).get("move"); + const move = value === null ? NaN : Number(value); + return Number.isInteger(move) && move >= 0 && move <= total ? move : total; +} + +// Replay of a finished game: starts at the final position; moves can be stepped through, played at 1x/2x/4x or +// jumped to from the timeline and the move list. Players see the board from their own side. +const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: GameRecord, userKey?: string }> = ( + { context, theme, record, userKey }) => { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const moves = record.moves; + const total = moves.length; + const viewerId = getViewerSeat(record, userKey) ? userKey : undefined; + const revert = getViewerSeat(record, userKey) === "player2"; + + const baseGame = useMemo(() => { + const game = createGameFromBoard({ + id: record.id, player1Id: record.player1.userId, player2Id: record.player2.userId, + turnPlayerId: moves[total - 1]?.playerId ?? record.player1.userId, board: record.board, + }); + game.mancalaGame.state = "ended"; + return game; + }, [record.id]); + + const [moveNumber, setMoveNumber] = useState(() => readMoveFromUrl(total)); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [boardViewModel, setBoardViewModel] = useState( + () => createBoardViewModel(getBoardAfterMove(moves, moveNumber), theme)); + const animatorRef = useRef(); + + useEffect(() => { + // The animator changes its view model in place, so pass React a new object for every frame. + const animator = new PitAnimator(context.themeManager, + (viewModel) => setBoardViewModel({ id: v4(), pits: [...viewModel.pits] } as BoardViewModel)); + animator.setNewGame(createGameAtMove(baseGame, moves, moveNumber)); + animatorRef.current = animator; + return () => animator.dispose(); + }, [baseGame]); + + useEffect(() => { + window.history.replaceState(null, "", `${window.location.pathname}?move=${moveNumber}`); + }, [moveNumber]); + + // Shows a position without animation. + const showMove = (n: number) => { + const target = Math.min(Math.max(n, 0), total); + const animator = animatorRef.current; + if (animator) { + animator.clearCurrentInterval(); + animator.setNewGame(createGameAtMove(baseGame, moves, target)); + } + setMoveNumber(target); + }; + + // Plays the next move with the same animation as a live game. + const animateNextMove = () => { + const animator = animatorRef.current; + if (!animator || moveNumber >= total) return; + animator.clearCurrentInterval(); + animator.animationInterval = DEFAULT_ANIMATION_INTERVAL / speed; + animator.setNewGame(createGameAtMove(baseGame, moves, moveNumber)); + animator.setUpdatedGame(createGameAtMove(baseGame, moves, moveNumber + 1)); + setMoveNumber(moveNumber + 1); + }; + + const jumpTo = (n: number) => { + setPlaying(false); + showMove(n); + }; + + const togglePlay = () => { + if (playing) { + setPlaying(false); + } else { + if (moveNumber >= total) showMove(0); + setPlaying(true); + } + }; + + useEffect(() => { + if (!playing) return; + if (moveNumber >= total) { + setPlaying(false); + return; + } + // Wait for the previous move's animation (one step per sown stone and rule event) plus a pause. + const previousSteps = moveNumber > 0 ? moves[moveNumber - 1].gameSteps.length + 1 : 0; + const delay = (previousSteps * DEFAULT_ANIMATION_INTERVAL + PAUSE_BETWEEN_MOVES_MS) / speed; + const timer = setTimeout(animateNextMove, delay); + return () => clearTimeout(timer); + }, [playing, moveNumber, speed]); + + useMoveKeys({ + previous: () => jumpTo(moveNumber - 1), + next: () => { setPlaying(false); animateNextMove(); }, + togglePlay, + }); + + const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); + const textColorOnAppBar = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); + const panelTextColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor); + const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); + const playerName = (playerId: string) => + name(playerId === record.player1.userId ? record.player1 : record.player2); + const stones = getBoardAfterMove(moves, moveNumber); + const [topPlayer, topStore, bottomPlayer, bottomStore] = revert + ? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]] + : [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]]; + const atEnd = moveNumber >= total; + + const controlButton = (label: string, title: string, onClick: () => void, disabled = false) => ( + + ); + + return ( + + + + + + + + + + + + ))} + + +
    + {t("MoveOf", { number: moveNumber, total })} + + {moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`} + +
    + jumpTo(Number(event.target.value))} /> + + + + + +
    + ); +}; + +export default ReplayView; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 96e8bba..cc73552 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import Row from '../components/Row'; import UserStatus from '../components/UserStatus'; -import { channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, PlayedMove, TURN_TIMEOUT_MS } from '@mancala/core'; +import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, PlayedMove, TURN_TIMEOUT_MS } from '@mancala/core'; import { Context } from '../context/context'; import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; @@ -35,6 +35,7 @@ import useMoveKeys from '../hooks/useMoveKeys'; import { createBoardViewModel } from '../util/BoardViewModels'; import { getResultText } from '../util/GameResultText'; import CircularPanel from '../components/CircularPanel'; +import ReplayView from '../components/history/ReplayView'; const GamePage: FunctionComponent<{ context: Context, @@ -62,6 +63,9 @@ const GamePage: FunctionComponent<{ // Set while an earlier position is shown instead of the live board. const [viewedMove, setViewedMove] = useState(undefined); + // Set when the game has ended and was loaded from history; the page then shows the replay. + const [replayRecord, setReplayRecord] = useState(undefined); + // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); @@ -251,6 +255,8 @@ const GamePage: FunctionComponent<{ onGameUpdate(pitAnimator, game); unlistenMessages = listenMessages(game, pitAnimator); setLoadingStateGame(LoadingState.Loaded({ value: game })) + } else if (loaded?.kind === "finished") { + setReplayRecord(loaded.record); } else { setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") })) } @@ -282,8 +288,8 @@ const GamePage: FunctionComponent<{ const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey; const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber)); useMoveKeys({ - previous: () => selectMove((isReviewing ? viewedMove! : moves.length) - 1), - next: () => isReviewing && selectMove(viewedMove! + 1), + previous: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1), + next: () => !replayRecord && isReviewing && selectMove(viewedMove! + 1), }); const playerInfo = (userId: string) => { @@ -340,6 +346,10 @@ const GamePage: FunctionComponent<{ isAnonymous: true } : bottomLocatedUser; const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined; + if (replayRecord) { + return ; + } + return ( {renderHeaderBar()} diff --git a/apps/frontend/src/util/GameResultText.ts b/apps/frontend/src/util/GameResultText.ts index eac4764..a1d1607 100644 --- a/apps/frontend/src/util/GameResultText.ts +++ b/apps/frontend/src/util/GameResultText.ts @@ -36,3 +36,11 @@ export function getViewerSeat(game: FinishedGameSummary, viewerId: string | unde export function formatDate(epochMillis: number, language: string): string { return new Date(epochMillis).toLocaleDateString(language, { day: "numeric", month: "short", year: "numeric" }); } + +// "12:05" or "1:02:05" +export function formatDuration(milliseconds: number): string { + const totalSeconds = Math.max(0, Math.round(milliseconds / 1000)); + const hours = Math.floor(totalSeconds / 3600), minutes = Math.floor(totalSeconds / 60) % 60, seconds = totalSeconds % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; +} diff --git a/apps/frontend/src/util/MoveText.ts b/apps/frontend/src/util/MoveText.ts new file mode 100644 index 0000000..7c97ec8 --- /dev/null +++ b/apps/frontend/src/util/MoveText.ts @@ -0,0 +1,15 @@ +import { describeMove, PlayedMove } from "@mancala/core"; + +type Translate = (key: string, options?: Record) => string; + +// "Pit 3 · ★ Extra turn · +1 to store" +export function getMoveText(moves: PlayedMove[], moveNumber: number, t: Translate): string { + const move = describeMove(moves, moveNumber); + const events = [ + move.extraTurn ? `★ ${t("ExtraTurn")}` : undefined, + move.capture ? `✂ ${t("Capture")}` : undefined, + move.storeGain > 0 ? t("StoreGain", { count: move.storeGain }) : undefined, + move.gameOver ? `⚑ ${t("GameOver")}` : undefined, + ].filter(Boolean); + return [t("PitNumber", { pit: move.pit }), ...events].join(" · "); +} diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index f33773c..feaf618 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -21,5 +21,5 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby rejection message. - [x] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. -- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. +- [x] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. - [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates. diff --git a/packages/core/src/animation/PitAnimator.ts b/packages/core/src/animation/PitAnimator.ts index c211881..9adea4a 100644 --- a/packages/core/src/animation/PitAnimator.ts +++ b/packages/core/src/animation/PitAnimator.ts @@ -15,7 +15,8 @@ import { Game } from "../models"; import { getColorByBrightness } from "../util"; import { Theme, ThemeManager } from "../theme"; -const animationUpdateInterval = 300; +export const DEFAULT_ANIMATION_INTERVAL = 300; +const animationUpdateInterval = DEFAULT_ANIMATION_INTERVAL; export class PitAnimator { themeManager: ThemeManager; @@ -28,12 +29,17 @@ export class PitAnimator { animationIndex: number = 0; currentHistoryItem: HistoryItem | undefined; + // Time between animation steps, in milliseconds. + animationInterval: number; + constructor( themeManager: ThemeManager, - onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void + onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void, + animationInterval: number = animationUpdateInterval ) { this.themeManager = themeManager; this.onBoardViewModelUpdate = onBoardViewModelUpdate; + this.animationInterval = animationInterval; this.themeManager.on("themechange", this.onThemeChange.bind(this)); } @@ -151,7 +157,7 @@ export class PitAnimator { //@ts-ignore this.currentIntervalID = setInterval( () => this.onAnimate(), - animationUpdateInterval + this.animationInterval ); } -- 2.45.2 From b94c9336dde0a0f4c5980eefc9fb603f7d3e85fd Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:36:48 +0300 Subject: [PATCH 18/82] docs: document game history and update the TODO - Backend README: DATABASE_URL, VALKEY_URL, the new routes and how the backend behaves when Postgres or Valkey is down - TODO: drop the fixed spectator and memory bugs, add game history on mobile, a page with all games, single-instance limits, backups and the new .env variables - Spec marked as implemented, all tasks ticked --- TODO.md | 28 ++++++++++++++++++------- apps/backend/README.md | 22 +++++++++++++++++++ docs/specs/game-history/requirements.md | 2 +- docs/specs/game-history/tasks.md | 2 +- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index aee924a..2688f21 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,13 @@ The web app logs in with Keycloak; the mobile app still uses the legacy `?userKe - [ ] Delete the unused copy of the WebSocket client in `apps/mobile/src/rtmt` - [ ] Test on a device +### Game history on mobile + +The web app has game history (see `docs/specs/game-history`); the mobile app needs Keycloak login first. + +- [ ] Ongoing-game banner, live and popular games, recent games on the home screen +- [ ] Move list and looking back during a live game; replay of finished games + ### After the mobile release - [ ] Turn off `LEGACY_USERKEY_AUTH` once old app versions are no longer in use; this also removes `/register` @@ -25,10 +32,18 @@ The web app logs in with Keycloak; the mobile app still uses the legacy `?userKe ### Later - [ ] Let guests turn their guest user into an account without changing identity (Keycloak required action) -- [ ] Stats and game history for logged in users (needs a database) +- [ ] Statistics for logged in users (wins, losses), now that games are saved - [ ] Social logins (Keycloak identity providers) - [ ] Restrict the Keycloak admin console in production +## Game history + +- [ ] A page with all of a user's games (the home page shows the last 3) +- [ ] Show games of deleted accounts differently, once accounts can be deleted +- [ ] Live game state, previews and spectators assume a single backend instance; share them (e.g. Valkey pub/sub) + before running more than one +- [ ] If Valkey is unreachable when the backend starts, live games are not restored until the next restart + ## Deployment - [ ] Build the backend and frontend images from the repo root in CI: @@ -37,6 +52,9 @@ The web app logs in with Keycloak; the mobile app still uses the legacy `?userKe - [ ] 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 +- [ ] Back up the Postgres volume (Keycloak and saved games), e.g. a daily `pg_dumpall` +- [ ] Set `MANCALA_DB_PASSWORD` and `POSTGRES_PASSWORD` in `apps/.env` on the server; the `postgres` volume replaces + `keycloak-db`, so Keycloak starts with a fresh database there ## Security @@ -48,16 +66,10 @@ The web app logs in with Keycloak; the mobile app still uses the legacy `?userKe ## Bugs -- [ ] `GameStoreImpl.getSpeactatingGameIdsByUser` loops with `for...in` over an iterator, so it never finds - anything and spectators are never removed on disconnect; use `for...of` -- [ ] Finished and abandoned games are never removed from `gameStore` (`deleteGame` only removes the user - mappings), so they stay in memory until restart - [ ] `PitAnimator.animatePit`: `previousPitIndex > 0` should be `>= 0`; a one-stone move from pit 0 does not empty that pit during the animation - [ ] `PitAnimator.dispose` removes a newly bound function, so the theme listener is never removed - [ ] Sowing puts stones into the opponent's store; check against the intended Mangala rules and add a test -- [ ] `MatchMaker.join` does not check whether the player is already in a game -- [ ] `GameManager.startGame` sends the connection info twice - [ ] `onGameError` broadcasts an `Error`, which serializes to `{}` ## Maintenance @@ -65,7 +77,7 @@ The web app logs in with Keycloak; the mobile app still uses the legacy `?userKe - [ ] The web and mobile apps duplicate components and stores; move shared code into `packages/core` - [ ] Mixed lockfiles: `packages/*` and `apps/mobile` have both `yarn.lock` and `package-lock.json` - [ ] Align TypeScript and Jest versions across packages -- [ ] Tests for `GameManager`, `MatchMaker` and `PitAnimator` +- [ ] Tests for `MatchMaker` and `PitAnimator` - [ ] CI that runs the tests - [ ] 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 index d3ac8e5..970153a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -47,3 +47,25 @@ User ids of Keycloak users are the token subject prefixed with `kc:`. | `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. + +| 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/docs/specs/game-history/requirements.md b/docs/specs/game-history/requirements.md index 23adf54..f6f4462 100644 --- a/docs/specs/game-history/requirements.md +++ b/docs/specs/game-history/requirements.md @@ -1,6 +1,6 @@ # Game history and live games: requirements -Status: approved, in development (branch `feature/game-history`, based on `feature/keycloak-auth`). +Status: implemented on branch `feature/game-history` (based on `feature/keycloak-auth`). Scope: backend and web app. The mobile app is out of scope (see `TODO.md`). ## Goals diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index feaf618..501ba18 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -22,4 +22,4 @@ Work through the tasks in order; each ends with its tests passing and a commit. rejection message. - [x] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. - [x] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. -- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates. +- [x] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates. -- 2.45.2 From ab2424b648d635b6c2394916432d0d1a835c7d72 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:40:57 +0300 Subject: [PATCH 19/82] docs: add Keycloak login spec Requirements, design decisions and tasks for the Keycloak login, written after the implementation; mobile login and turning off legacy userKey login remain open. --- docs/specs/keycloak-auth/design.md | 108 +++++++++++++++++++++++ docs/specs/keycloak-auth/requirements.md | 71 +++++++++++++++ docs/specs/keycloak-auth/tasks.md | 17 ++++ 3 files changed, 196 insertions(+) create mode 100644 docs/specs/keycloak-auth/design.md create mode 100644 docs/specs/keycloak-auth/requirements.md create mode 100644 docs/specs/keycloak-auth/tasks.md diff --git a/docs/specs/keycloak-auth/design.md b/docs/specs/keycloak-auth/design.md new file mode 100644 index 0000000..88b408c --- /dev/null +++ b/docs/specs/keycloak-auth/design.md @@ -0,0 +1,108 @@ +# Login with Keycloak: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. + +## Decisions + +| Decision | Reason | +|---|---| +| **Keycloak is the only auth system**; guests are Keycloak users too | Considered first: a separate auth service issuing guest tokens next to Keycloak, and guest tokens issued by the backend. Both meant two auth systems. With guests inside Keycloak, the backend trusts one token issuer (R8.1). | +| **Guests via a custom authenticator** (Java SPI extension), not the admin API | Creating guests through the admin API needs a privileged service account and a second component; an authenticator runs inside the normal login flow, so guests get ordinary sessions and refresh tokens. | +| The guest flow is triggered by a `guest=true` parameter on the authorization request | Keycloak keeps extra request parameters as client notes (`client_request_param_guest`), so the authenticator can read it without UI. Requests without it get the login form. | +| Guests are marked with a realm role, not a user attribute | Roles are in the access token by default (`realm_access.roles`) and need no user profile changes; the cleanup task can list role members. | +| Guests request `offline_access` | Offline sessions outlive the SSO session (30 days idle), which keeps a guest's identity across browser restarts (R1.2). | +| "Verify profile" is disabled for the realm | Otherwise Keycloak asks every new guest for an email and name. Registration still asks for the required fields. | +| The backend verifies tokens offline with the realm's public keys (`jose`) | No call to Keycloak per connection; Keycloak being down only stops new logins (R8.1). | +| The WebSocket authenticates with a first `auth` message, not a URL parameter | Tokens in URLs end up in proxy logs; browsers cannot set headers on WebSockets. | +| Keycloak user ids are prefixed with `kc:` | Legacy userKeys are UUIDs chosen by the client; the prefix makes it impossible for a legacy client to claim a Keycloak user's id (R7.2). | +| Web login with `oidc-client-ts` | Standard OIDC with PKCE and refresh tokens, without tying the app to a Keycloak-specific library. | +| Login, sign up and log out only on the home page | Changing identity during a game would lose the player's seat (R3.3). | + +## Keycloak (`apps/keycloak`) + +- **Image**: `Dockerfile` builds the extension with Maven and copies it with the realm file into + `quay.io/keycloak/keycloak:26.7.5`, started with `--import-realm` (the realm is imported only if it does not exist). +- **Realm** `mancala` (`realm/mancala-realm.json`): + - Client `mancala-web`: public, authorization code with PKCE S256, redirect URIs `http://localhost:1234/*` and + `https://playmancala.app/*`, the same post-logout redirects. + - Browser flow `browser with guest`: cookie (alternative) → `mancala-guest-authenticator` (alternative) → + username/password form (alternative). A returning user with a session is let through by the cookie step, so + `guest=true` with an existing session reuses that identity. + - Realm role `guest`; offline session idle timeout 30 days; registration and password reset on; brute force + protection on; required actions as Keycloak's defaults with `VERIFY_PROFILE` disabled. +- **Extension** (`extension/`, package `app.playmancala.keycloak.guest`): + - `GuestAuthenticator`: with `guest=true` creates `guest-` + 8 random hex characters, grants the `guest` role and + succeeds; otherwise `attempted()`. + - `GuestCleanupTask` (R1.3): scheduled through Keycloak's `TimerProvider`; deletes guests created more than + `cleanup-min-age-seconds` ago (default 86400) that have neither a user session nor an offline session; runs every + `cleanup-interval-seconds` (default 3600), at most `cleanup-max-deletes-per-run` (default 1000) per run. Options + are set as `KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__…`. +- **Compose**: `keycloak` service on `127.0.0.1:8080`, `KC_HOSTNAME` from `KEYCLOAK_HOSTNAME`, proxy headers on. +- **nginx** (`config/nginx.conf`): `auth.playmancala.app` proxies to Keycloak; the authorization endpoint is limited + to 10 requests per minute per IP with bursts of 20 (R1.4). + +## Backend (`apps/backend/src/auth`, `rtmt/rtmt_websocket.ts`) + +- `KeycloakTokenVerifier` checks signature (RS256, keys from `KEYCLOAK_JWKS_URL` or the issuer's certs endpoint, + cached by `jose`), `iss`, `exp`, `typ: Bearer` and that `azp` is one of `KEYCLOAK_CLIENT_IDS` (R2.2). It returns + `{ id: "kc:" + sub, name: preferred_username, isGuest: has role guest }`. +- WebSocket protocol (channels in `@mancala/core`): + + | Channel | Direction | Payload | + |---|---|---| + | `auth` | client → server | `{ token }`, right after connecting and after every token refresh | + | `on_auth` | server → client | `{ user: AuthUser }` | + | `on_auth_error` | server → client | `{ reason }`, then the socket is closed with code 4001 | + + Messages before `on_auth` are ignored; no `auth` within 5 seconds closes the socket (R2.4). A socket may send a + new token for the same user but not switch users. +- Connections: `Map>`; messages go to every socket of a user; the user is offline when the + set is empty; a closing socket only removes itself (R4). +- Legacy: with `LEGACY_USERKEY_AUTH=true`, `?userKey=` binds the socket to that id directly and `/register` + issues ids; non-UUID keys (e.g. `kc:…`) are refused (R7). +- Games store each player's name and guest flag when they start and send them in the connection info (R5.1). +- Malformed messages are ignored instead of throwing. + +| Variable | Default | +|---|---| +| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/mancala` | +| `KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | +| `KEYCLOAK_CLIENT_IDS` | `mancala-web` | +| `LEGACY_USERKEY_AUTH` | `true` | + +## Shared client code (`packages/core`) + +- `RTMTWS.connectWebSocket(url, getToken?)`: with a token provider it sends `auth` on every (re)connect and reports + `connected` (and emits `open`) only after `on_auth`; `reauthenticate(token)` sends a renewed token on the open + connection (R6.1); `user` holds the authenticated user. +- `getPlayerDisplayName(player, t)` formats names, including guests (R5.1). +- English and Turkish strings for the account menu. + +## Web app (`apps/frontend`) + +- `AuthService` wraps an `oidc-client-ts` `UserManager`: tokens in local storage, `automaticSilentRenew` with the + refresh token, `scope: "openid offline_access"`. + - `loginAsGuest()`: redirect with `guest=true`; `login()`: `prompt=login`, so the form appears even with a guest + session; `signUp()`: `prompt=create`; `logout()`: end-session redirect. + - The page the user started from is passed as `state` and restored after the callback (`/auth/callback`). +- Startup (`MancalaApp`): finish a login redirect if this is the callback, else load the stored session (renewing + an expired one), else redirect as a guest; then connect the WebSocket with `getAccessToken` as token provider. If + the session cannot be renewed, start over as a guest (R6.2). Renewed tokens are passed to `reauthenticate`. +- `AccountMenu` on the home page: guests see Log In and Sign Up, accounts see their name and Log Out (R3, R5.2). +- `KEYCLOAK_URL` is a build variable (default `http://localhost:8080`). + +## Mobile app + +Unchanged apart from calling `connectWebSocket(url)` without a token provider; it keeps the legacy userKey login. + +## Testing + +- Extension: integration tests start Keycloak with the extension and the real realm file in a container + (`testcontainers-keycloak`): guest login without a form and with the `guest` role and an offline refresh token, + a new user per guest login, the login form without `guest=true`, and the cleanup task. +- Backend: `KeycloakTokenVerifier` against locally signed tokens (valid, guest, wrong key, issuer, expiry, ID and + refresh tokens, other client, unsigned); WebSocket tests for the handshake, timeout, token refresh, several + sockets per user, late close after reconnect, legacy on and off, and malformed messages. +- End to end in Chrome against Keycloak, the backend and the dev server: first visit as guest, two guests matched + with names, reload keeping the identity, guest logging in to an account, logging out to a new guest, and token + renewal over an open connection with a 75-second token lifetime. diff --git a/docs/specs/keycloak-auth/requirements.md b/docs/specs/keycloak-auth/requirements.md new file mode 100644 index 0000000..9a70a36 --- /dev/null +++ b/docs/specs/keycloak-auth/requirements.md @@ -0,0 +1,71 @@ +# Login with Keycloak: requirements + +Status: implemented for the backend and the web app on branch `feature/keycloak-auth`; the mobile app still uses the +legacy userKey login (see `tasks.md`). Written after the implementation, from the decisions made while building it. + +## Goals + +People can play without signing up, and can sign up or log in when they want to. Every player has an identity that +cannot be taken over by someone else, and one system handles all of it. + +## Terms + +- **Guest**: a player who has not signed up. A guest is a Keycloak user with the `guest` role. +- **Account**: a Keycloak user who signed up with a username and password. +- **Legacy client**: an app version that identifies itself with a userKey from `GET /register` instead of a token + (the mobile app released before this work). + +## Requirements + +### R1. Playing without signing up +- R1.1 A first-time visitor of the web app is logged in as a new guest without seeing any form; the page briefly + passes through Keycloak and comes back to where the visitor started. +- R1.2 A returning guest keeps the same identity for 30 days after their last visit, also after closing the browser. +- R1.3 Guests who have not been back for 30 days, and are at least a day old, are deleted from Keycloak. +- R1.4 Starting guest logins is rate limited per IP address, since each one creates a user. + +### R2. Identities are secure +- R2.1 The server, not the client, decides who a connection belongs to, based on a token signed by Keycloak. +- R2.2 A token that is forged, expired, issued by another server, issued to another client, or is not an access + token is rejected. +- R2.3 Knowing another player's id (e.g. from a game) is not enough to act as that player. +- R2.4 A connection that does not authenticate within 5 seconds is closed; messages sent before authentication are + ignored. + +### R3. Accounts +- R3.1 A guest can sign up or log in from the home page; afterwards they play as that account. +- R3.2 A logged-in user can log out; they then continue as a new guest. +- R3.3 Logging in and signing up are only offered on the home page, so nobody changes identity during a game. + +### R4. Several devices +- R4.1 A user logged in on several devices or tabs is connected on all of them at once; all of them receive game + updates. +- R4.2 A user counts as offline only when their last connection closes. +- R4.3 Reconnecting does not drop the user's new connection when the old one closes late. + +### R5. Names +- R5.1 Players see each other's names during a game. Accounts show their username; guests show as "Guest" and four + characters of their generated username (e.g. "Guest 4F2A", "Misafir 4F2A" in Turkish). +- R5.2 The account menu on the home page shows the user's own name. + +### R6. Sessions stay valid +- R6.1 Access tokens are renewed before they expire without interrupting the game connection. +- R6.2 If the session can no longer be renewed, the user continues as a new guest. + +### R7. Old app versions keep working +- R7.1 While `LEGACY_USERKEY_AUTH` is on (the default), legacy clients connect with `?userKey=` and use `/register` + as before. +- R7.2 A legacy client cannot use the id of a Keycloak user. +- R7.3 When the setting is off, userKey connections and `/register` are refused. + +### R8. One auth system +- R8.1 Keycloak is the only place where users and sessions live; the backend only checks tokens and does not call + Keycloak per request. +- R8.2 Keycloak, its configuration and its extension are part of this repository and can be run locally with + Docker Compose. + +## Out of scope (see `TODO.md`) +- Login in the mobile app. +- Turning a guest into an account without changing identity. +- Social logins. +- Deleting accounts. diff --git a/docs/specs/keycloak-auth/tasks.md b/docs/specs/keycloak-auth/tasks.md new file mode 100644 index 0000000..e9674b3 --- /dev/null +++ b/docs/specs/keycloak-auth/tasks.md @@ -0,0 +1,17 @@ +# Login with Keycloak: tasks + +Tasks 1 to 4 were built before this spec was written; their commits are listed for reference. + +- [x] 1. **Keycloak**: guest authenticator and cleanup task, realm export, Docker image, compose service, nginx + proxy and rate limit, integration tests. (`7c99a76`) +- [x] 2. **Backend**: token verifier, WebSocket `auth` handshake, several sockets per user, player names in games, + `LEGACY_USERKEY_AUTH`, tests. (`30ad51c`) +- [x] 3. **Local packages**: apps and `@mancala/core` link the packages in this repo instead of the npm releases, so + the auth channels and types can be shared. (`e165b0a`) +- [x] 4. **Web app**: `AuthService` with `oidc-client-ts`, startup flow, account menu, token renewal over the open + connection, names on the game page; auth channels and types moved to core. (`f5dcf93`) +- [ ] 5. **Mobile app**: `mancala-mobile` client in the realm and in `KEYCLOAK_CLIENT_IDS`, `react-native-app-auth`, + guest login on first launch, token login instead of `?userKey=`, account screens, names; tested on a device. + Details in `TODO.md`. +- [ ] 6. **Legacy off**: once old app versions are no longer used, set `LEGACY_USERKEY_AUTH=false`, which also + removes `/register`. -- 2.45.2 From bb329892794a65b743b984d37ac26b301a4265fa Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:44:09 +0300 Subject: [PATCH 20/82] docs: add AGENTS.md with setup, checks and pitfalls, and CLAUDE.md pointing to it --- AGENTS.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 3 ++ 2 files changed, 104 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..59a1d92 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. Keep it current: when you learn something that would have +saved time, add it here. + +## Project + +Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app and a Node backend. + +| Path | What | +|---|---| +| `packages/mancala.js` | Game engine; rules are plug-ins in `src/common/game_rules` | +| `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/frontend` | React web app, built with Parcel | +| `apps/mobile` | React Native app | +| `apps/keycloak` | Keycloak image with the realm and a Java extension for guest login | +| `apps/postgres` | Init script creating the `keycloak` and `mancala` databases | +| `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend | +| `config/nginx.conf` | The production server's nginx config (Certbot managed) | +| `docs/specs/` | Specs: `requirements.md`, `design.md`, `tasks.md` | +| `TODO.md` | Open work, known bugs and deployment steps; read it before starting | + +Specs that exist: `keycloak-auth` (login, guests, tokens) and `game-history` (saved games, live games, replay). + +## 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/frontend && yarn dev # http://localhost:1234 (must be 1234, Keycloak only allows that) +``` + +Postgres is on `localhost:5433` (5432 is often taken), Valkey on `6379`, Keycloak on `8080` +(admin console `/admin`). The backend's and frontend's defaults point at these. The mobile app points at production +unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`. + +## Checks + +| What | Command | Notes | +|---|---|---| +| Engine | `cd packages/mancala.js && npx jest` | | +| Core | `cd packages/core && npx jest` | | +| Backend | `cd apps/backend && npx tsc --noEmit -p . && npx jest` | Needs Docker: Postgres and Valkey run in testcontainers | +| 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. 3 type errors are known and older than recent work | +| Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work | + +There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with +`playwright-core` (installed outside the repo, `executablePath: /usr/bin/google-chrome`) against the local stack. + +## How we work + +- The user writes in Turkish; code, comments, commit messages and specs are in English. +- Larger features start with a spec in `docs/specs//` (requirements with acceptance criteria, design with + decisions and reasons, tasks as a checklist). Implement the tasks in order, one commit each, and tick them. Small + fixes need no spec. When resuming a feature, read its spec and `tasks.md` first. +- Work on feature branches; commit messages follow `type(scope): summary` with a short body. +- Never add AI attribution (Co-Authored-By lines, "Generated with …") to commits or PRs. +- Do not commit files the user changed but did not ask to commit; stage only your own changes (hunks if needed). +- New dependencies get exact versions. + +## Things that are easy to get wrong + +**Packages and builds** +- The 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. +- 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** +- 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. +- 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. +- `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. + +**Web app** +- styled-jsx only scopes JSX written directly in a component's render; elements built in helper functions or + `map` callbacks need `.parent :global(.child)` rules. +- Pages are full-height flex columns: a block with `overflow: hidden` needs `flex-shrink: 0` or it gets squeezed. +- Board components size themselves from the `--board-unit` CSS variable; `BoardView` takes an optional `width`. +- The WebSocket client emits `open` after authentication; anything the server must know about a connection + (spectating a game, live game previews) is sent on `open`, because the server forgets it when a user goes offline. + +**Keycloak** +- The realm file is imported only when the realm does not exist; changes to it must also be made in a running + Keycloak (or recreate the database). +- Listing some `requiredActions` in the realm file replaces Keycloak's whole default list, so the file contains all + of them. +- The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in + sync and run `mvn test` when upgrading. +- Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not. + +**Mobile** +- `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba64777 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +The instructions for this repository are in AGENTS.md, shared by all coding agents: + +@AGENTS.md -- 2.45.2 From d52960fa0a7c303e6e27c06ffd19ad932aa9d33b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:13:20 +0300 Subject: [PATCH 21/82] feat(keycloak): add a login theme in the game's look The mancala login theme extends keycloak.v2 with one stylesheet: the game's app bar and logo, the board's colors, pit-shaped inputs, pill buttons and a board with stones on top of the card. Dark colors follow the system color scheme. The realm turns the theme on and offers English and Turkish. The test container now loads the theme and checks that the login page uses it in the requested language. --- AGENTS.md | 5 +- TODO.md | 2 + apps/keycloak/Dockerfile | 1 + apps/keycloak/README.md | 21 +- .../keycloak/guest/GuestLoginTest.java | 15 +- apps/keycloak/realm/mancala-realm.json | 4 + .../mancala/login/resources/css/mancala.css | 294 ++++++++++++++++++ .../login/resources/img/board-dark.svg | 26 ++ .../mancala/login/resources/img/board.svg | 26 ++ .../mancala/login/resources/img/favicon.ico | Bin 0 -> 2972 bytes .../mancala/login/resources/img/logo.svg | 76 +++++ .../themes/mancala/login/theme.properties | 6 + 12 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 apps/keycloak/themes/mancala/login/resources/css/mancala.css create mode 100644 apps/keycloak/themes/mancala/login/resources/img/board-dark.svg create mode 100644 apps/keycloak/themes/mancala/login/resources/img/board.svg create mode 100644 apps/keycloak/themes/mancala/login/resources/img/favicon.ico create mode 100644 apps/keycloak/themes/mancala/login/resources/img/logo.svg create mode 100644 apps/keycloak/themes/mancala/login/theme.properties diff --git a/AGENTS.md b/AGENTS.md index 59a1d92..b9716d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app | `apps/backend` | Express + `ws`: matchmaking, games, auth, saved games, HTTP API | | `apps/frontend` | React web app, built with Parcel | | `apps/mobile` | React Native app | -| `apps/keycloak` | Keycloak image with the realm and a Java extension for guest login | +| `apps/keycloak` | Keycloak image with the realm, the login theme and a Java extension for guest login | | `apps/postgres` | Init script creating the `keycloak` and `mancala` databases | | `apps/compose.yaml` | Postgres, Valkey, Keycloak, backend, frontend | | `config/nginx.conf` | The production server's nginx config (Certbot managed) | @@ -96,6 +96,9 @@ There is no automated end-to-end suite. Changes to the game flow were checked by - The guest authenticator uses an internal Keycloak SPI: keep the version in `extension/pom.xml` and `Dockerfile` in sync and run `mvn test` when upgrading. - Keycloak sets `Secure` cookies; browsers send them to `http://localhost`, simple HTTP clients do not. +- The login theme (`apps/keycloak/themes/mancala`) is only CSS on top of `keycloak.v2`. Keycloak in `start` mode caches + themes and keeps gzipped resources in `data/tmp/kc-gzip-cache` across restarts; see `apps/keycloak/README.md` for + reloading it in a running container. **Mobile** - `apps/mobile/release_android.sh` contains signing passwords (see `TODO.md`); do not copy them anywhere. diff --git a/TODO.md b/TODO.md index 2688f21..9a11e3a 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,8 @@ The web app has game history (see `docs/specs/game-history`); the mobile app nee - [ ] Let guests turn their guest user into an account without changing identity (Keycloak required action) - [ ] Statistics for logged in users (wins, losses), now that games are saved - [ ] Social logins (Keycloak identity providers) +- [ ] Keycloak email theme in the game's look (password reset emails still use Keycloak's) +- [ ] Login pages follow the system color scheme; they could follow the theme chosen in the app instead - [ ] Restrict the Keycloak admin console in production ## Game history diff --git a/apps/keycloak/Dockerfile b/apps/keycloak/Dockerfile index e3d9541..01512b6 100644 --- a/apps/keycloak/Dockerfile +++ b/apps/keycloak/Dockerfile @@ -18,5 +18,6 @@ FROM quay.io/keycloak/keycloak:26.7.5 COPY --from=builder /opt/keycloak/ /opt/keycloak/ # Imported on startup only if the realm does not exist yet. COPY realm/mancala-realm.json /opt/keycloak/data/import/ +COPY themes/ /opt/keycloak/themes/ ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] CMD ["start", "--optimized", "--import-realm"] diff --git a/apps/keycloak/README.md b/apps/keycloak/README.md index b0a5c21..e96e678 100644 --- a/apps/keycloak/README.md +++ b/apps/keycloak/README.md @@ -33,6 +33,24 @@ are not asked for an email or a name. Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin console too, or delete the realm and restart Keycloak. +## Login theme + +`themes/mancala/login` gives the login, registration and password reset pages the game's look: its app bar and logo, +the board's colors and a board with stones on top of the card. It extends Keycloak's `keycloak.v2` theme and only +adds `resources/css/mancala.css`, so its templates and behaviour stay Keycloak's own and upgrades need no template +changes. The colors come from `LightTheme` and `DarkTheme` in `packages/core/src/theme`; the dark ones are used when +the system prefers a dark color scheme. The realm turns the theme on (`loginTheme`) and offers English and Turkish; +the web app passes its language as `ui_locales`. + +To try a change without rebuilding the image, copy the theme into the running container, clear Keycloak's gzip cache +(it survives restarts) and restart: + +```sh +docker cp apps/keycloak/themes/mancala apps-keycloak-1:/opt/keycloak/themes/ +docker exec apps-keycloak-1 rm -rf /opt/keycloak/data/tmp/kc-gzip-cache +docker restart apps-keycloak-1 +``` + ## Running ```sh @@ -59,7 +77,8 @@ cd apps/keycloak/extension mvn test ``` -The tests start Keycloak with the extension and the real realm file in a container, so they need Docker. +The tests start Keycloak with the extension, the real realm file and the login theme in a container, so they need +Docker. ## Upgrading Keycloak diff --git a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java index 115c46b..5bfb016 100644 --- a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java +++ b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.representations.idm.UserRepresentation; +import org.testcontainers.utility.MountableFile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -34,7 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dasniko.testcontainers.keycloak.KeycloakContainer; /** - * Runs the extension and the real realm export in a Keycloak container. Needs Docker. + * Runs the extension, the real realm export and the login theme in a Keycloak container. Needs Docker. */ class GuestLoginTest { @@ -50,6 +51,7 @@ class GuestLoginTest { keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5") .withProviderClassesFrom("target/classes") .withRealmImportFile("/mancala-realm.json") + .withCopyFileToContainer(MountableFile.forHostPath("../themes/mancala"), "/opt/keycloak/themes/mancala") .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2") .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0"); keycloak.start(); @@ -87,6 +89,17 @@ class GuestLoginTest { assertTrue(response.body().contains("id=\"kc-form-login\"")); } + @Test + void loginFormUsesTheMancalaThemeInTheRequestedLanguage() throws Exception { + HttpResponse response = newHttpClient().send( + HttpRequest.newBuilder(authorizationUri(Map.of("ui_locales", "tr"), randomUrlSafe(32))).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("/login/mancala/css/mancala.css"), "mancala stylesheet missing"); + assertTrue(response.body().contains("lang=\"tr\""), "page is not in Turkish"); + } + @Test void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception { String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText(); diff --git a/apps/keycloak/realm/mancala-realm.json b/apps/keycloak/realm/mancala-realm.json index 38007f4..181f2e1 100644 --- a/apps/keycloak/realm/mancala-realm.json +++ b/apps/keycloak/realm/mancala-realm.json @@ -11,6 +11,10 @@ "rememberMe": true, "verifyEmail": false, "bruteForceProtected": true, + "loginTheme": "mancala", + "internationalizationEnabled": true, + "supportedLocales": ["en", "tr"], + "defaultLocale": "en", "offlineSessionIdleTimeout": 2592000, "browserFlow": "browser with guest", "requiredActions": [ diff --git a/apps/keycloak/themes/mancala/login/resources/css/mancala.css b/apps/keycloak/themes/mancala/login/resources/css/mancala.css new file mode 100644 index 0000000..86d0b7d --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/css/mancala.css @@ -0,0 +1,294 @@ +/* + * Mancala look for keycloak.v2's login pages. Loaded after keycloak.v2's styles.css. + * Colors come from LightTheme and DarkTheme in packages/core/src/theme; text colors are darker (or lighter) than + * the game's where the game's own would not be readable on the board color. + * keycloak.v2 adds pf-v5-theme-dark to when the system prefers a dark color scheme. + */ + +:root { + --mancala-bg: #bbbbbb; + --mancala-bar: #7b7b7b; + --mancala-bar-text: #ebebeb; + --mancala-board: #9b9b9b; + --mancala-pit: #c4c4c4; + --mancala-pit-readonly: #b0b0b0; + --mancala-pit-edge: #4d4d4d; + --mancala-pit-shadow: rgba(0, 0, 0, 0.22); + --mancala-stone: #5b5b5b; + --mancala-stone-hover: #474747; + --mancala-stone-text: #f2f2f2; + --mancala-ink: #262626; + --mancala-ink-soft: #333333; + --mancala-ink-strong: #000000; + --mancala-hover: rgba(0, 0, 0, 0.08); + --mancala-divider: #6b6b6b; + --mancala-danger: #6b0900; + --mancala-board-image: url("../img/board.svg"); + --mancala-bar-height: 57px; + --mancala-font: serif; + + --pf-v5-global--FontFamily--text: var(--mancala-font); + --pf-v5-global--FontFamily--heading: var(--mancala-font); + --pf-v5-global--Color--100: var(--mancala-ink); + --pf-v5-global--Color--200: var(--mancala-ink-soft); + --pf-v5-global--link--Color: var(--mancala-ink); + --pf-v5-global--link--Color--hover: var(--mancala-ink-strong); + --pf-v5-global--danger-color--100: var(--mancala-danger); +} + +:root.pf-v5-theme-dark { + --mancala-bg: #414141; + --mancala-bar: #313131; + --mancala-bar-text: #aaaaaa; + --mancala-board: #313131; + --mancala-pit: #606060; + --mancala-pit-readonly: #4b4b4b; + --mancala-pit-edge: #8a8a8a; + --mancala-pit-shadow: rgba(0, 0, 0, 0.45); + --mancala-stone: #aaaaaa; + --mancala-stone-hover: #bdbdbd; + --mancala-stone-text: #1e1e1e; + --mancala-ink: #e0e0e0; + --mancala-ink-soft: #bbbbbb; + --mancala-ink-strong: #ffffff; + --mancala-hover: rgba(255, 255, 255, 0.08); + --mancala-divider: #555555; + --mancala-danger: #ff8a80; + --mancala-board-image: url("../img/board-dark.svg"); +} + +.login-pf body { + background: var(--mancala-bg); + font-family: var(--mancala-font); +} + +/* Header: the game's app bar with its logo and title */ + +#kc-header { + position: fixed; + inset: 0 0 auto 0; + z-index: 10; + height: var(--mancala-bar-height); + margin: 0; + padding: 0 4vw; + display: flex; + align-items: center; + background: var(--mancala-bar); +} + +#kc-header-wrapper { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + padding: 0; + font-family: var(--mancala-font); + font-size: 2em; + font-weight: bold; + line-height: 1; + letter-spacing: normal; + text-transform: none; + text-align: start; + color: var(--mancala-bar-text) !important; +} + +#kc-header-wrapper::before { + content: ""; + flex: none; + width: 30px; + height: 30px; + background: url("../img/logo.svg") center / contain no-repeat; +} + +.pf-v5-c-login { + min-height: 100vh; + padding-block-start: calc(var(--mancala-bar-height) + 2rem); + padding-block-end: 2rem; +} + +.pf-v5-c-login__container { + max-width: calc(30rem + 32px); + padding-inline: 16px; + grid-template-columns: minmax(0, 30rem); +} + +/* Card: the board, with a small board at the start of a game on top */ + +.pf-v5-c-login__main { + --pf-v5-c-login__main--BackgroundColor: var(--mancala-board); + --pf-v5-c-login__main--BoxShadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --pf-v5-c-login__main-footer-band--BorderTopColor: var(--mancala-divider); + padding-block-start: 2rem; + border-radius: 24px; + color: var(--mancala-ink); +} + +.pf-v5-c-login__main::before { + content: ""; + display: block; + width: min(calc(100% - 4rem), 260px); + aspect-ratio: 312 / 76; + margin: 0 auto; + background: var(--mancala-board-image) center / contain no-repeat; +} + +.pf-v5-c-login__main-header { + --pf-v5-c-login__main-header--PaddingTop: 1.5rem; + border-top: none; +} + +#kc-page-title { + color: var(--mancala-ink); + font-family: var(--mancala-font); + font-size: 1.5rem; + font-weight: bold; +} + +.pf-v5-c-login__main hr { + border-color: var(--mancala-divider); +} + +.pf-v5-c-login__main a:not(.pf-v5-c-button) { + color: var(--mancala-ink); + text-decoration: underline; +} + +.pf-v5-c-login__main a:not(.pf-v5-c-button):hover { + color: var(--mancala-ink-strong); +} + +/* Inputs: pits */ + +.pf-v5-c-form-control { + --pf-v5-c-form-control--BackgroundColor: var(--mancala-pit); + --pf-v5-c-form-control--Color: var(--mancala-ink); + --pf-v5-c-form-control--m-placeholder--Color: var(--mancala-ink-soft); + --pf-v5-c-form-control--m-readonly--BackgroundColor: var(--mancala-pit-readonly); + --pf-v5-c-form-control__toggle-icon--Color: var(--mancala-ink); + --pf-v5-c-form-control--PaddingLeft: 1rem; + --pf-v5-c-form-control--PaddingRight: 1rem; + border: 1px solid var(--mancala-pit-edge); + border-radius: 999px; + box-shadow: inset 0 2px 5px var(--mancala-pit-shadow); + font-family: var(--mancala-font); +} + +.pf-v5-c-form-control:has(textarea) { + border-radius: 16px; +} + +.pf-v5-c-form-control::before, +.pf-v5-c-form-control::after { + content: none; +} + +.pf-v5-c-form-control > :is(input, select, textarea) { + font-family: inherit; + background-color: transparent; + border-radius: inherit; +} + +.pf-v5-c-form-control > select { + padding-inline-start: 1rem; +} + +.pf-v5-c-form-control > :is(input, select, textarea):focus { + outline: none; +} + +.pf-v5-c-form-control:hover { + border-color: var(--mancala-ink); +} + +.pf-v5-c-form-control:focus-within { + border-color: var(--mancala-ink); + outline: 2px solid var(--mancala-ink); + outline-offset: 2px; +} + +.pf-v5-c-form-control.pf-m-error { + border-color: var(--mancala-danger); + box-shadow: inset 0 2px 5px var(--mancala-pit-shadow), 0 0 0 1px var(--mancala-danger); +} + +.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error, +.pf-v5-c-login__main .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text, +.pf-v5-c-login__main .pf-v5-c-helper-text__item-text.pf-m-error { + color: var(--mancala-danger); +} + +/* Password field and its show button form one pill */ + +.pf-v5-c-input-group > .pf-v5-c-input-group__item:first-child:not(:last-child) .pf-v5-c-form-control { + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +.pf-v5-c-input-group > .pf-v5-c-input-group__item:last-child:not(:first-child) .pf-v5-c-button { + border-radius: 0 999px 999px 0; +} + +/* Buttons: pill shaped like the game's */ + +.pf-v5-c-button { + --pf-v5-c-button--BorderRadius: 999px; + --pf-v5-c-button--after--BorderRadius: 999px; + + --pf-v5-c-button--m-primary--BackgroundColor: var(--mancala-stone); + --pf-v5-c-button--m-primary--hover--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--focus--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--active--BackgroundColor: var(--mancala-stone-hover); + --pf-v5-c-button--m-primary--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--hover--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--focus--Color: var(--mancala-stone-text); + --pf-v5-c-button--m-primary--active--Color: var(--mancala-stone-text); + + --pf-v5-c-button--m-secondary--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--focus--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--active--Color: var(--mancala-ink); + --pf-v5-c-button--m-secondary--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--focus--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--active--after--BorderColor: var(--mancala-ink); + --pf-v5-c-button--m-secondary--hover--BackgroundColor: var(--mancala-hover); + --pf-v5-c-button--m-secondary--focus--BackgroundColor: var(--mancala-hover); + --pf-v5-c-button--m-secondary--active--BackgroundColor: var(--mancala-hover); + + --pf-v5-c-button--m-link--Color: var(--mancala-ink); + --pf-v5-c-button--m-link--hover--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-link--focus--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-link--active--Color: var(--mancala-ink-strong); + + --pf-v5-c-button--m-control--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--hover--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--focus--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--active--BackgroundColor: var(--mancala-pit); + --pf-v5-c-button--m-control--Color: var(--mancala-ink); + --pf-v5-c-button--m-control--hover--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-control--focus--Color: var(--mancala-ink-strong); + --pf-v5-c-button--m-control--active--Color: var(--mancala-ink-strong); + font-family: var(--mancala-font); +} + +.pf-v5-c-button.pf-m-primary { + font-weight: bold; +} + +.pf-v5-c-button.pf-m-control::after { + border: 1px solid var(--mancala-pit-edge); + border-radius: inherit; +} + +.pf-v5-c-button.pf-m-control:hover::after { + border-color: var(--mancala-ink); +} + +.pf-v5-c-button:focus-visible { + outline: 2px solid var(--mancala-ink); + outline-offset: 2px; +} + +.pf-v5-c-check__input { + accent-color: var(--mancala-stone); +} diff --git a/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg b/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg new file mode 100644 index 0000000..c1edcbd --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/board-dark.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/resources/img/board.svg b/apps/keycloak/themes/mancala/login/resources/img/board.svg new file mode 100644 index 0000000..2fad955 --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/board.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/resources/img/favicon.ico b/apps/keycloak/themes/mancala/login/resources/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df145b3f81d2ac233ed2459e50b151a44eb16e34 GIT binary patch literal 2972 zcmah~c{tQtAOB&}$k3?KOd5=mrCcWF%9dqNW^T5*;SwTcE3Pd&S;oGFVWhEyvCkxX zw(OFSCQHbA%U0R4hxga*pZAaVdEVc0&iS76Jm-9$?`Jsxzy@#ta5w-y7@&;}0OWs; zFu&p&4glBz=djpc@s}e2PzL@{271?cjtLwCXL$%ZS|qUlxdR-^3AV!Pht>eV7ERF7 zxZzEUOV*7qSBHmZ<*?=6A1C5;%xhPmi$#hYQB*8BlE!8~n%?36eI6x#>wJob$>MTA z2{Agy;2^5_Av@;m!%?=@xhaWgOLUhj#Jl3=y?~?@#agGE#l?7|?(*0TU!LZ#p1I6> zT4a@e0_(jvS5|0Gxt0+hUwZzuzbc18>iJI8jg-Cpz6Fmpf(p54?Y=yR6^fHNd=cnG z%_wYc78mQVzVnB;^YX2dqo92+F24&*7Ff}^!EXaIO}zvQHJL-3rZ(zN*F-{x%+^%I zVVdC4S|eXQ^Bi6)#7DlG<2^7@^~CBH6km|0#!QA)J1Gh!+?PL7TBRm zP@KC>*NnQ?e~6nJB8rR2);g10l}<6m^0HVUsOk2k;^#y=^P4~QmFF*B^o{7n(=ZpYbxG|1%FU*$uaLt5$C8! zenyS=<9vG6N_)FkGGSX`+|PG;++F6M-CeU8WZZ$R3t&|OkZezCL3a#)P$78~3^|)r zB&wH3f#WgpvossY^Ahy853YpH(ZrjE#4vO+b7_h;yR&mTb54U8Wn00;!das@Td{8- z#Wyaj1PIH>$ZY@k0TF>PjJfk2#G0aY{(%-wF_&7aoxC%tJ~O_J@yW?2J?br7`q+Rv z=IrV!Uvf8OeMcoZ(YPdRz#hWFaT6YSAoD^e$H&!)T%G0UT!|1kJC#bE-|z*3A|j5) zcG+M%vmMMhWypXC#KgoTOjOoF9m+O`u67c?lSg@+>mcA^5izUqm((fnb3q@iV`{Q0 z(Uv=9PW4z0F1C$IFrRf5Y$v?Sm1%pSs%hCqDdV^r|P6p zHmA+SI4JM(K9|J8Lq6OO|8T#$d=SCczpn0J?CVB#$(VYbBVl&2wmb*MK;+2gQgR0D z5lWWwcxZaBE$^O_q!<|!efG=pvN)0fGkG>_oo@S%|IA6?pn7C;Q+{_}H3Xx>UaTpp zjqZZur@ntj3_AX9G80G49^KsDMv>v;A3xqT(#lNxj>d~3^Vd~#AenB|U%2Ul89ZFgh|qRLK_V%eOmrKKe*QY!9A z|6GY(im=GUjB)Kk`f*cIc=)jQ?k5QCdI9+tZZL1hK1$27#anK!Mg+hQsSmmZtexZI5}MU z_`*fVaFBg4O^622Q|{vG8jOVv*yFHh-WEmuPWRnHZY6=ZV-dzrd9@756%}${zI~fn zSl|*C76yXM7~y&ap`oE1?XI-akByecDeroz?4-jwnI#M{d&l6K)?ec5Lmyw|lNXi~pb_ zJAu(0uo}^96Dm`#Q#59+2{qN(*8C!eWTPXXB2%=2RtiZc2|`Fhknk6EC6CyA8B%pc z7sv`xf=I_GBoH$!Y}!XC1OtY264xiU(|BmRYpg=N9KQiE_!4I)>S`{ZN`IR@j$H(T zhmKUok&4I?(T_w4B4_2eBTM3-nam5 zbHUe=j^#V#OfKh+eVtibJJGaUAl`)vbs8HPAJ=NM)W6$zR%7iOm@lj?XsHh5ii^eN zswoR(5~;OZ3MatR;JdMSii`1MYk6|Ov1)2hRbeihqE~Bx?gC})=jR8uhKNr|uioHC z*O*JUA3hWYeF@6e=d-(4tHv#Yp|`iRu<7aPS*6?V3bSyoZf+qUIdmjN$N=5rws4;! z8NQ-&d~a{h#R5rNkFt`{0&5|8KD}406-#zV*GUp}uMj1uOCFg+M?^$`4z%E~;d=!w zB$RJF>@3%7Pe{spK6GDRgnqsIlvuXK9H?35QvU1y5MEC~nV&R%>s<4v_FAc;GmM3s zu9J>WNU%m-{ajZBfr>!e;C6O)REWBAChVaLlCQi$tERyEay4_O8JcAwXzL<#IY`YHpS691r%cOwDYlxWc zh(V4_Oz2e*CJ1o+yV2qSd+_qe3!BABy?^_L0}sukcyE0Z1hQER*S{kKh>~P$XrH30 z|2EbTq1X0~%N``#)!bma{>$Es+z7qm>R#O%X|@*6R&I9+pd^C@tvMoXEm!NIqr zE1y4K?e`GrM42&$7?`2n#BkEFxw*O8y1LeH-=r;H2JUZblq%Q%J=yg1qjLdW+c+-Q zFG1#3QxHi>?&NR#XXRiVNF;LJLkW(5Bh}~eXS?FHl0p2-$)h$>>xX~5d^MwRwSd*_ z#J!B4oND?>@z0y0f1Gbdg)Y@C;A}EOqzNZn`doc{qA*Zdo-!Q!D1YFOZA1GeIUWjW zyD4GXCGCU0-H9}#3WN~`8WJ{q5BZ)|UnH9!P(%s2Ys$URHrr}qZ2ZlMY#L6(WSfpv z^Ndv^7h@i5x}uNLNe)b)ss6Z)RIgN$^(A$&6^~;)?EWH!zA@Y|myYoEqsrFT>uBoBwMR!F! zXF0zf#}<{DOB(MZvtX(;e6Zum;?j7uT3`G$suRu)*|Bh?vw z(chP6lRsf$8j>Ad3BrcYhFSe2Vkwh(+sg|{dhHROe*VvkYR(M}o;a@tW(ru{+yUrE zXCw#-iZ`NW;wTyJeegqZqQNKBu}aCXem+g<%9W1cXSaks8dQ^mCTOJe6AN2=D*9?fXNKm62`kWVVXk>%VW9 I{I}Qt2LOOza{vGU literal 0 HcmV?d00001 diff --git a/apps/keycloak/themes/mancala/login/resources/img/logo.svg b/apps/keycloak/themes/mancala/login/resources/img/logo.svg new file mode 100644 index 0000000..cf65dfd --- /dev/null +++ b/apps/keycloak/themes/mancala/login/resources/img/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + diff --git a/apps/keycloak/themes/mancala/login/theme.properties b/apps/keycloak/themes/mancala/login/theme.properties new file mode 100644 index 0000000..ec25cc5 --- /dev/null +++ b/apps/keycloak/themes/mancala/login/theme.properties @@ -0,0 +1,6 @@ +# Login pages in the look of the game. Keeps keycloak.v2's templates and only adds a stylesheet, +# so Keycloak upgrades do not need template changes. +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css css/mancala.css -- 2.45.2 From 6e6d37650d962d49a79cfe7ee2d2df288da5b6d7 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:13:20 +0300 Subject: [PATCH 22/82] feat(frontend): open Keycloak pages in the app's language Login and sign-up redirects pass the current language as ui_locales. --- apps/frontend/src/auth/AuthService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/auth/AuthService.ts b/apps/frontend/src/auth/AuthService.ts index 6629a18..0ad08df 100644 --- a/apps/frontend/src/auth/AuthService.ts +++ b/apps/frontend/src/auth/AuthService.ts @@ -1,3 +1,4 @@ +import i18n from "i18next"; import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; const CALLBACK_PATH = "/auth/callback"; @@ -68,11 +69,11 @@ export class AuthService { // prompt=login shows the login form even though the guest already has a session. public login(returnTo = currentPath()): Promise { - return this.userManager.signinRedirect({ prompt: "login", state: { returnTo } }); + return this.userManager.signinRedirect({ prompt: "login", ui_locales: i18n.language, state: { returnTo } }); } public signUp(returnTo = currentPath()): Promise { - return this.userManager.signinRedirect({ prompt: "create", state: { returnTo } }); + return this.userManager.signinRedirect({ prompt: "create", ui_locales: i18n.language, state: { returnTo } }); } public logout(): Promise { -- 2.45.2 From cb34c032468f166b6d12645008ed035336b1adfb Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:23:32 +0300 Subject: [PATCH 23/82] docs: add game clock spec Each player has 5 minutes; the server keeps the time and a scheduled service ends games whose running clock reaches zero. Replaces the offline turn and inactivity rules. --- docs/specs/game-clock/design.md | 141 ++++++++++++++++++++++++++ docs/specs/game-clock/requirements.md | 71 +++++++++++++ docs/specs/game-clock/tasks.md | 20 ++++ 3 files changed, 232 insertions(+) create mode 100644 docs/specs/game-clock/design.md create mode 100644 docs/specs/game-clock/requirements.md create mode 100644 docs/specs/game-clock/tasks.md diff --git a/docs/specs/game-clock/design.md b/docs/specs/game-clock/design.md new file mode 100644 index 0000000..1732373 --- /dev/null +++ b/docs/specs/game-clock/design.md @@ -0,0 +1,141 @@ +# Game clock: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-history` +(live game snapshots in Valkey, saved games in Postgres). + +## Decisions + +| Decision | Reason | +|---|---| +| The server is the only authority on time; clients only display it | Players cannot gain time by changing their device clock or client. A move arriving too late is refused on the server (R2.3). | +| The clock is stored as "time left when the turn started" plus `turnStartedAt`, not as a ticking value | The state only changes on moves, so nothing is written per second; the Valkey snapshot that is already written on every move carries it. The time left at any moment is computed. | +| A scheduled service checks all live games every second, in addition to the check when a move arrives | It ends games where nobody does anything (R2.2) and is simple to reason about and to restore after a restart. Because late moves are refused, checking once a second never changes a result; it only decides how soon the end is announced. Per-game timers were considered: more precise, but every move, end and restore would have to keep them in sync. A few hundred live games are a trivial loop. | +| The end reason "timeout" is reused | It keeps its meaning: the player whose turn it was ran out of time. The database check constraint, results, texts and the recent games list need no change. Old saved timeouts came from the offline rule; the difference is not shown. | +| The offline rules from `game-history` (`TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, `offlineSince`) are removed | The running clock ends any game where the player to move is away within 5 minutes (R6.1), so the old rules can no longer trigger. One mechanism instead of two. | +| 5 minutes, no increment, set by `GAME_CLOCK_MS` (default `300000`) | R1.1. The variable allows short clocks in tests and later tuning. The initial time travels with each game, so clients do not hard-code it and a running game keeps its time control if the setting changes. | +| Time while the backend is down is not charged (R5.1) | A deploy must not make anyone lose. On restore, the running clock restarts from the time left at the start of the turn; the player gets back what they spent on that turn before the restart. | +| Messages carry the server's time | Clients compute the offset between their clock and the server's and count down from it, so a wrong device clock does not matter (R3.3). | +| Animation and network delay are not compensated | A long move animates for a few seconds (300 ms per step) on the next player's time. This is accepted for now, like network delay; time added per move is the fix if it turns out to matter (out of scope). | + +## Shared types (`packages/core`) + +```ts +// models/GameClock.ts +interface GameClock { + initialMs: number; + // Time left for each player. For the player whose turn it is: at turnStartedAt. + player1Ms: number; + player2Ms: number; + // Server time when the running clock started; undefined when no clock runs (the game has ended). + turnStartedAt?: number; +} +const CLOCK_LOW_TIME_MS = 30_000; // R3.2 +``` + +- `Game` (live game messages) gains `clock?: GameClock` and `serverTime?: number` (server time when the message was + built). Optional, so older servers and clients keep working. +- `GameEndMessage` gains `clock?: GameClock`, the stopped clocks (R3.5). +- `MoveRecord` gains `remainingMs?: number`, the mover's time left after the move (R4.1). +- `FinishedGameSummary` gains `clockInitialMs?: number`; undefined for games saved before this feature (R4.3). + +Helpers in `packages/core/src/clock/`, used by the backend, the web app and the mobile app: +- `getClockTimes(clock, runningSeat, now)` → `{ player1Ms, player2Ms }`, never below zero. +- `ClockSync`: `update(game, receivedAt)` keeps the clock and the server offset (`serverTime - receivedAt`) from the + last message; `getTimes(now)` returns both players' time and the running seat. Framework-free so both apps can use it. +- `getClockAtMove(record, n)` → both players' time after move n of a saved game (n = 0: the initial time), from + `clockInitialMs` and the movers' `remainingMs`; undefined for games without clock data. On a timeout, the last + position shows the loser at zero. +- `formatClock(ms)` → `m:ss`, rounded up so `0:00` only shows when the time is gone. + +## Backend + +### Modules +``` +src/game/GameClock.ts pure functions on GameClock +src/game/GameClockScheduler.ts the scheduled service (R2.2) +src/db/migrations/002_clock.ts clock_initial_ms column +``` + +`GameClock.ts`: +- `startClock(initialMs, now)`: both players at `initialMs`, `turnStartedAt = now`. +- `remainingMs(clock, seat, runningSeat, now)`: the stored time, minus `now - turnStartedAt` for the running seat. +- `chargeMove(clock, seat, now)`: deducts the turn's time from the mover and sets `turnStartedAt = now`, for a turn + change and an extra turn alike (R1.2, R1.3). Returns the mover's time left. +- `stopClock(clock, runningSeat, now)`: deducts the running seat's time (at least down to zero) and clears + `turnStartedAt`. +- `resumeClock(clock, now)`: sets `turnStartedAt = now` without deducting anything (R5.1). + +### Game lifecycle (`GameManager`) +- `BackendGame` gains `clock: GameClock` and `moveRemainingMs: number[]`, aligned with `mancalaGame.history` like + `moveTimes`. +- **Start**: `startClock(config.gameClockMs, now)`. +- **Move**: before the engine is called, if the mover is the turn player and their time is gone, the move is not + played and the game ends with `timeout` (R2.3). After a move that was played (history grew), `chargeMove` and push + the result to `moveRemainingMs`. +- **End** (any reason): `stopClock`, then the final update and `GameEndMessage` carry the stopped clock. +- **`endGamesOutOfTime(now)`** (public): ends with `timeout` every live game whose running clock is at zero; the + winner is the opponent (R2.1). Each game is handled on its own, so an error in one is logged and does not stop the + others. +- **Messages**: `toClientGame` adds `clock` and `serverTime`. Reconnecting players already get `on_game_update`, so + R3.4 needs nothing more. +- **Removed**: `checkTimeouts`, the 10-second timer (`startTimers` / `stopTimers`), the `offlineSince` map and + `UserConnectionInfo.offlineSince`; `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS` in core. + +### Scheduled service (`GameClockScheduler`) +```ts +class GameClockScheduler { + constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now) + start(): void // setInterval → gameManager.endGamesOutOfTime(clock()) + stop(): void +} +``` +`src/index.ts` starts it after `restoreLiveGames()` and stops it on shutdown, replacing `gameManager.startTimers()`. +It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through +`GameRecorder` with retry as today. + +### Restore (R5) +- The snapshot becomes `version: 2` with `clock` and `moveRemainingMs`. `isLiveGameSnapshot` accepts 1 and 2. +- `fromSnapshot`: version 2 → `resumeClock(snapshot.clock, now)`; version 1 → `startClock(config.gameClockMs, now)` + and empty `moveRemainingMs` entries (R5.2). + +### Saving (R4.1) +- `toGameRecord` sets `clockInitialMs` and each move's `remainingMs` (from `moveRemainingMs`). +- Migration `002_clock`: `ALTER TABLE games ADD COLUMN clock_initial_ms integer;` (NULL for older games). + `remainingMs` lives in the existing `moves` jsonb. `PostgresGameHistoryRepository` writes and reads the column. + +### Configuration +`GAME_CLOCK_MS` (default `300000`) in `src/consts/config.ts`, passed into `GameManager`. + +## Web app + +- `hooks/useGameClock(game)`: a `ClockSync` updated on every game message (`on_game_start`, `on_game_update`, + `on_game_end`), re-rendering every 250 ms while a clock runs and not at all once the game has ended. +- `components/GameClockView.tsx`: one player's `m:ss`; highlighted with `playerTurnColor` while running; a warning + style below `CLOCK_LOW_TIME_MS`. +- `GamePage`: a clock beside each `UserStatus` in the board top toolbar, and in the mobile toolbar. The + "opponent is offline" countdown banner and the `OpponentOfflineCountdown` string are removed (R6.2). +- `ReplayView`: both clocks from `getClockAtMove(record, shownMove)`, hidden when `clockInitialMs` is missing (R4.2, + R4.3). +- Texts: `OpponentTimedOut` / `YouTimedOut` become "Opponent ran out of time" / "You ran out of time" in English; the + Turkish ones ("Rakibin süresi doldu", "Süren doldu") already fit. + +## Mobile app + +`GameScreen` shows the two clocks with `ClockSync` and `formatClock` from core, next to the players' names, with the +same running and low-time styles. Old app versions in the stores do not show the clock but are still bound by it; +the update should be released before or with the backend (see `TODO.md`). + +## Testing +- `packages/core`: `getClockTimes`, `ClockSync` (including a device clock that is off by minutes), `getClockAtMove` + (with and without clock data, timeout at the end), `formatClock`. +- Backend unit tests with the fake clock `GameManager` tests already use: + - a move deducts the turn's time; an extra turn keeps the clock running; the other clock does not change; + - a move after the time is gone is refused and ends the game with the opponent as winner; + - `endGamesOutOfTime` ends only games at zero, tells players and spectators, and saves the game; + - a completed game stops the clocks; + - restoring version 2 snapshots does not charge downtime; version 1 snapshots get a new clock; + - `GameClockScheduler` calls the sweep on its interval and stops (Jest fake timers). +- Postgres repository: `clock_initial_ms` and `remainingMs` survive saving and reading. +- End to end in Chrome with `GAME_CLOCK_MS=20000`: two guests play a few moves and the clocks agree on both pages; + one stops moving and loses on time with the right texts on both sides; a reload keeps the clocks; a backend + restart does not charge the time it was down; the replay shows the clocks. diff --git a/docs/specs/game-clock/requirements.md b/docs/specs/game-clock/requirements.md new file mode 100644 index 0000000..2937bd2 --- /dev/null +++ b/docs/specs/game-clock/requirements.md @@ -0,0 +1,71 @@ +# Game clock: requirements + +Status: proposed. +Scope: backend, web app and mobile app. + +## Goals + +Every game has a chess-style clock: each player has 5 minutes for all of their moves. A player whose time runs out +loses. The server keeps the time and ends such games on its own, whether or not anyone is connected. + +## Terms + +- **Clock**: the time a player has left in a game. +- **Running clock**: the clock of the player whose turn it is. Only one clock runs at a time. +- **Turn**: from the moment a player is to move until the server receives their move. + +## Requirements + +### R1. Clock +- R1.1 Each player starts a game with 5 minutes. No time is added after a move. +- R1.2 Only the clock of the player whose turn it is runs. It starts when the game starts, for the player who moves + first. When a move passes the turn, the mover's clock stops and the opponent's starts. +- R1.3 When a move gives the mover an extra turn, the mover's clock keeps running. +- R1.4 The server measures the time: a turn lasts from when it started on the server to when the server receives the + move. Network delay and the time the client spends animating the opponent's move count as the player's time. +- R1.5 The clock runs whether or not the player is online. + +### R2. Running out of time +- R2.1 When a player's clock reaches zero, the game ends: the opponent wins and the end reason is "timeout". +- R2.2 The server ends such games on its own within 1 second of the time running out, even if no client is + connected or sends anything. +- R2.3 A move that reaches the server after the mover's time has run out is not played; the game ends as in R2.1. +- R2.4 Players see "You won · Opponent ran out of time" or "You lost · You ran out of time"; spectators see who + won, as for other endings. + +### R3. Showing the clocks +- R3.1 Players and spectators of a live game see both clocks next to the players' names as `m:ss`. The running + clock is highlighted. +- R3.2 When less than 30 seconds are left, the running clock is shown in a warning style. +- R3.3 The running clock counts down smoothly. It differs from the server's time by no more than about one second, + even if the device's clock is wrong. +- R3.4 After a reload or a reconnect, the clocks show the right times at once. +- R3.5 When the game ends, both clocks stop at the time they had; a player who ran out of time shows `0:00`. +- R3.6 The mobile app shows the clocks in the same way. + +### R4. Saved games and replay +- R4.1 A saved game keeps the time each player started with and, for every move, the time the mover had left after + it. +- R4.2 The replay of a game shows both clocks as they were after the move being shown, and the time each player + started with before the first move. +- R4.3 Games saved before this feature have no clock data; their replay shows no clocks. + +### R5. Restarts and deploys +- R5.1 Clocks survive a backend restart. Time while the backend is down does not count: after a restart, the + running clock goes on from where it was when the current turn started. +- R5.2 Games that are in progress when this feature is deployed get a new clock (5 minutes each) when the backend + restores them. + +### R6. Rules the clock replaces +- R6.1 The clock replaces two rules from `game-history` (R3.3 and R3.4): losing after 5 minutes offline on your turn, + and a game without a winner after both players have been offline for 30 minutes. Because the running clock does not + stop for an offline player, it ends every such game within 5 minutes. +- R6.2 The "opponent is offline" countdown is replaced by the clocks. The online / offline status of the players + stays. +- R6.3 Saved games that ended by the old rules still show as before ("timeout", "inactive"). + +## Out of scope (TODO) +- Other time controls, time added per move, or letting players choose. +- Clocks in the home page's live game previews and in the ongoing-game banner. +- Making up for network delay or animation time. +- Pausing a game, or more time by agreement. diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md new file mode 100644 index 0000000..60abd5b --- /dev/null +++ b/docs/specs/game-clock/tasks.md @@ -0,0 +1,20 @@ +# Game clock: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [ ] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, + `GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`, + `formatClock` with unit tests; English timeout texts. +- [ ] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on + `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and + `serverTime` in client messages; unit tests. +- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove + `checkTimeouts`, the 10-second timer, `offlineSince`, `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS`; unit tests. +- [ ] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and + `clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests. +- [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile + layouts; remove the offline countdown. +- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`. +- [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle. +- [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile + update before or with the backend; the out of scope items); mark `game-history` R3.3 and R3.4 as replaced. -- 2.45.2 From 03e1543d5d8876e7730106596354d60b8ef2629b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:25:15 +0300 Subject: [PATCH 24/82] feat(core): add game clock types and helpers GameClock and optional clock fields on live games, end messages, move records and finished games; getClockTimes, ClockSync, getClockAtMove and formatClock with tests. --- docs/specs/game-clock/tasks.md | 2 +- packages/core/src/clock/Clock.ts | 83 +++++++++++++++ packages/core/src/clock/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/localization/en.ts | 4 +- packages/core/src/models/Game.ts | 5 + packages/core/src/models/GameClock.ts | 20 ++++ packages/core/src/models/GameHistory.ts | 7 ++ packages/core/src/models/index.ts | 1 + packages/core/tests/clock/Clock.test.ts | 136 ++++++++++++++++++++++++ 10 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/clock/Clock.ts create mode 100644 packages/core/src/clock/index.ts create mode 100644 packages/core/src/models/GameClock.ts create mode 100644 packages/core/tests/clock/Clock.test.ts diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index 60abd5b..e5d5882 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -2,7 +2,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. -- [ ] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, +- [x] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, `GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`, `formatClock` with unit tests; English timeout texts. - [ ] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on diff --git a/packages/core/src/clock/Clock.ts b/packages/core/src/clock/Clock.ts new file mode 100644 index 0000000..aef42f2 --- /dev/null +++ b/packages/core/src/clock/Clock.ts @@ -0,0 +1,83 @@ +import { Game } from "../models/Game"; +import { ClockTimes, GameClock, Seat } from "../models/GameClock"; +import { GameRecord } from "../models/GameHistory"; + +export interface ClockState extends ClockTimes { + // Undefined when no clock runs. + runningSeat?: Seat; +} + +export function getSeat(players: { player1Id: string, player2Id: string }, playerId: string): Seat | undefined { + if (playerId === players.player1Id) return "player1"; + if (playerId === players.player2Id) return "player2"; + return undefined; +} + +// Both players' time at `now` (server time); never below zero. +export function getClockTimes(clock: GameClock, runningSeat: Seat | undefined, now: number): ClockTimes { + const elapsed = runningSeat && clock.turnStartedAt !== undefined ? Math.max(0, now - clock.turnStartedAt) : 0; + return { + player1Ms: Math.max(0, clock.player1Ms - (runningSeat === "player1" ? elapsed : 0)), + player2Ms: Math.max(0, clock.player2Ms - (runningSeat === "player2" ? elapsed : 0)), + }; +} + +// "4:59"; rounded up, so "0:00" only shows when the time is gone. +export function formatClock(milliseconds: number): string { + const seconds = Math.ceil(Math.max(0, milliseconds) / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +type ClockRecord = Pick; + +// Both players' time after the given move of a saved game; move 0 is the start. Undefined for games saved before +// the game clock. +export function getClockAtMove(record: ClockRecord, moveNumber: number): ClockTimes | undefined { + if (record.clockInitialMs === undefined) return undefined; + const n = Math.min(Math.max(moveNumber, 0), record.moves.length); + const times: ClockTimes = { player1Ms: record.clockInitialMs, player2Ms: record.clockInitialMs }; + for (const move of record.moves.slice(0, n)) { + if (move.remainingMs === undefined) continue; + if (move.playerId === record.player1.userId) times.player1Ms = move.remainingMs; + else times.player2Ms = move.remainingMs; + } + // The player who ran out of time did not get to move again; their clock ends at zero. + if (n === record.moves.length && record.endReason === "timeout" && record.result !== null) { + if (record.result === "player1") times.player2Ms = 0; + if (record.result === "player2") times.player1Ms = 0; + } + return times; +} + +// Follows the clock of a live game from the server's messages and counts down between them, in server time. +export class ClockSync { + private clock?: GameClock; + private runningSeat?: Seat; + // Server time minus local time. + private offsetMs = 0; + + // From a live game message (game start, update, reconnect). + update(game: Game, receivedAt = Date.now()) { + const { mancalaGame } = game; + this.clock = game.clock; + this.runningSeat = mancalaGame.state === "ended" ? undefined : getSeat(mancalaGame, mancalaGame.turnPlayerId); + if (game.serverTime !== undefined) this.offsetMs = game.serverTime - receivedAt; + } + + // When the game has ended; the server sends the stopped clocks. + stop(clock?: GameClock) { + if (clock) this.clock = clock; + this.runningSeat = undefined; + } + + get isRunning(): boolean { + return this.clock?.turnStartedAt !== undefined && this.runningSeat !== undefined; + } + + // Undefined when the server sends no clock. + getTimes(now = Date.now()): ClockState | undefined { + if (!this.clock) return undefined; + const runningSeat = this.isRunning ? this.runningSeat : undefined; + return { ...getClockTimes(this.clock, runningSeat, now + this.offsetMs), runningSeat }; + } +} diff --git a/packages/core/src/clock/index.ts b/packages/core/src/clock/index.ts new file mode 100644 index 0000000..9b8b971 --- /dev/null +++ b/packages/core/src/clock/index.ts @@ -0,0 +1 @@ +export * from './Clock' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e3baca..72c074e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export * from './factory/index' export * from './viewmodel/index' export * from './service/index' export * from './history/index' +export * from './clock/index' diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 8b2e101..12c08a9 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -54,8 +54,8 @@ export const en = { Draw: "Draw", OpponentLeft: "Opponent left", YouLeft: "You left", - OpponentTimedOut: "Opponent timed out", - YouTimedOut: "You timed out", + OpponentTimedOut: "Opponent ran out of time", + YouTimedOut: "You ran out of time", Abandoned: "Abandoned", PlayerWon: "{{name}} won", Moves: "Moves", diff --git a/packages/core/src/models/Game.ts b/packages/core/src/models/Game.ts index 7a0a2bb..bc496a6 100644 --- a/packages/core/src/models/Game.ts +++ b/packages/core/src/models/Game.ts @@ -1,4 +1,5 @@ import { MancalaGame } from "mancala.js"; +import { GameClock } from "./GameClock"; import { UserConnectionInfo } from "./UserConnectionInfo"; export interface Game { @@ -9,6 +10,10 @@ export interface Game { startedAt?: number; // Time of each move, aligned with mancalaGame.history. moveTimes?: number[]; + // Sent by servers with a game clock. + clock?: GameClock; + // Server time (epoch milliseconds) when the message was built, so clients can correct for their own clock. + serverTime?: number; } export interface GameUsersConnectionInfo { diff --git a/packages/core/src/models/GameClock.ts b/packages/core/src/models/GameClock.ts new file mode 100644 index 0000000..059c211 --- /dev/null +++ b/packages/core/src/models/GameClock.ts @@ -0,0 +1,20 @@ +// Below this, the running clock is shown as low on time. +export const CLOCK_LOW_TIME_MS = 30_000; + +export type Seat = "player1" | "player2"; + +// The players' clocks in a live game. The server keeps the time; clients only display it. +export interface GameClock { + // Time each player started the game with. + initialMs: number; + // Time left for each player. For the player whose turn it is, at turnStartedAt. + player1Ms: number; + player2Ms: number; + // Server time (epoch milliseconds) when the running clock started; undefined when no clock runs. + turnStartedAt?: number; +} + +export interface ClockTimes { + player1Ms: number; + player2Ms: number; +} diff --git a/packages/core/src/models/GameHistory.ts b/packages/core/src/models/GameHistory.ts index 9460093..516ffb0 100644 --- a/packages/core/src/models/GameHistory.ts +++ b/packages/core/src/models/GameHistory.ts @@ -1,4 +1,5 @@ import { GameStep } from "mancala.js"; +import { GameClock } from "./GameClock"; // How long the player whose turn it is may stay offline before the opponent wins. export const TURN_TIMEOUT_MS = 5 * 60 * 1000; @@ -20,6 +21,8 @@ export interface MoveRecord { gameSteps: GameStep[]; // Epoch milliseconds. at: number; + // The mover's clock after the move; undefined for games saved before the game clock. + remainingMs?: number; } export type GameResult = "player1" | "player2" | "draw"; @@ -51,6 +54,8 @@ export interface FinishedGameSummary { endedAt: number; moveCount: number; peakSpectators: number; + // Time each player started with; undefined for games saved before the game clock. + clockInitialMs?: number; } export interface GameRecord extends FinishedGameSummary { @@ -72,4 +77,6 @@ export interface GameEndMessage { endReason: EndReason; winnerId?: string; leftUserId?: string; + // The stopped clocks. + clock?: GameClock; } diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 8e274b5..408e5bb 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -5,3 +5,4 @@ export * from './User' export * from './UserConnectionInfo' export * from './AuthUser' export * from './GameHistory' +export * from './GameClock' diff --git a/packages/core/tests/clock/Clock.test.ts b/packages/core/tests/clock/Clock.test.ts new file mode 100644 index 0000000..4c01529 --- /dev/null +++ b/packages/core/tests/clock/Clock.test.ts @@ -0,0 +1,136 @@ +import { CommonMancalaGame } from "mancala.js"; +import { ClockSync, formatClock, getClockAtMove, getClockTimes } from "../../src/clock"; +import { Game, GameClock, GameRecord, MoveRecord } from "../../src/models"; + +const P1 = "player-1"; +const P2 = "player-2"; +const MINUTE = 60_000; + +const clock = (overrides: Partial = {}): GameClock => + ({ initialMs: 5 * MINUTE, player1Ms: 5 * MINUTE, player2Ms: 5 * MINUTE, turnStartedAt: 1_000_000, ...overrides }); + +const liveGame = (turnPlayerId: string, gameClock: GameClock | undefined, serverTime?: number): Game => { + const mancalaGame = new CommonMancalaGame("game-1", P1, P2); + mancalaGame.turnPlayerId = turnPlayerId; + return { + id: "game-1", + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: P1, isOnline: true }, + user2ConnectionInfo: { userId: P2, isOnline: true }, + }, + clock: gameClock, + serverTime, + }; +}; + +const move = (playerId: string, remainingMs?: number): MoveRecord => + ({ playerId, moveIndex: 0, boardSnapshot: [], gameSteps: [], at: 0, remainingMs }); + +const record = (overrides: Partial): GameRecord => ({ + id: "game-1", + player1: { userId: P1, isGuest: true }, + player2: { userId: P2, isGuest: true }, + board: [], + result: "player1", + endReason: "completed", + startedAt: 0, + endedAt: 0, + moveCount: 0, + peakSpectators: 0, + totalSpectators: 0, + moves: [], + clockInitialMs: 5 * MINUTE, + ...overrides, +}); + +describe("getClockTimes", () => { + it("deducts the time since the turn started from the running seat only", () => { + expect(getClockTimes(clock(), "player1", 1_000_000 + 20_000)).toEqual({ player1Ms: 280_000, player2Ms: 300_000 }); + expect(getClockTimes(clock(), "player2", 1_000_000 + 20_000)).toEqual({ player1Ms: 300_000, player2Ms: 280_000 }); + }); + + it("does not count down when no clock runs", () => { + expect(getClockTimes(clock({ turnStartedAt: undefined }), "player1", 5_000_000)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + expect(getClockTimes(clock(), undefined, 5_000_000)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + }); + + it("never goes below zero", () => { + expect(getClockTimes(clock({ player1Ms: 1_000 }), "player1", 1_000_000 + 60_000).player1Ms).toBe(0); + }); +}); + +describe("formatClock", () => { + it("shows minutes and seconds, rounding up", () => { + expect(formatClock(300_000)).toBe("5:00"); + expect(formatClock(299_001)).toBe("5:00"); + expect(formatClock(299_000)).toBe("4:59"); + expect(formatClock(9_500)).toBe("0:10"); + expect(formatClock(1)).toBe("0:01"); + expect(formatClock(0)).toBe("0:00"); + expect(formatClock(-5)).toBe("0:00"); + }); +}); + +describe("ClockSync", () => { + it("counts down the running clock in server time, whatever the local clock says", () => { + const sync = new ClockSync(); + const localTimeAtReceive = 1_000_000 + 10 * MINUTE; // the device clock is 10 minutes fast + sync.update(liveGame(P2, clock({ turnStartedAt: 1_000_000 }), 1_000_000 + 5_000), localTimeAtReceive); + + expect(sync.isRunning).toBe(true); + expect(sync.getTimes(localTimeAtReceive)).toEqual({ player1Ms: 300_000, player2Ms: 295_000, runningSeat: "player2" }); + expect(sync.getTimes(localTimeAtReceive + 3_000)).toEqual({ player1Ms: 300_000, player2Ms: 292_000, runningSeat: "player2" }); + }); + + it("stops with the clocks the server sends when the game ends", () => { + const sync = new ClockSync(); + sync.update(liveGame(P1, clock(), 1_000_000), 1_000_000); + sync.stop(clock({ player1Ms: 0, turnStartedAt: undefined })); + + expect(sync.isRunning).toBe(false); + expect(sync.getTimes(9_000_000)).toEqual({ player1Ms: 0, player2Ms: 300_000, runningSeat: undefined }); + }); + + it("does not run once the game has ended", () => { + const game = liveGame(P1, clock(), 1_000_000); + game.mancalaGame.state = "ended"; + const sync = new ClockSync(); + sync.update(game, 1_000_000); + + expect(sync.isRunning).toBe(false); + expect(sync.getTimes(2_000_000)?.player1Ms).toBe(300_000); + }); + + it("has no times when the server sends no clock", () => { + const sync = new ClockSync(); + sync.update(liveGame(P1, undefined), 0); + expect(sync.getTimes()).toBeUndefined(); + expect(sync.isRunning).toBe(false); + }); +}); + +describe("getClockAtMove", () => { + const moves = [move(P1, 290_000), move(P2, 280_000), move(P2, 270_000), move(P1, 250_000)]; + + it("starts with the initial time", () => { + expect(getClockAtMove(record({ moves }), 0)).toEqual({ player1Ms: 300_000, player2Ms: 300_000 }); + }); + + it("shows each player's time after their last move so far", () => { + expect(getClockAtMove(record({ moves }), 1)).toEqual({ player1Ms: 290_000, player2Ms: 300_000 }); + expect(getClockAtMove(record({ moves }), 3)).toEqual({ player1Ms: 290_000, player2Ms: 270_000 }); + expect(getClockAtMove(record({ moves }), 4)).toEqual({ player1Ms: 250_000, player2Ms: 270_000 }); + expect(getClockAtMove(record({ moves }), 99)).toEqual({ player1Ms: 250_000, player2Ms: 270_000 }); + }); + + it("shows the player who ran out of time at zero in the final position", () => { + const timedOut = record({ moves, endReason: "timeout", result: "player1" }); + expect(getClockAtMove(timedOut, 4)).toEqual({ player1Ms: 250_000, player2Ms: 0 }); + expect(getClockAtMove(timedOut, 3)).toEqual({ player1Ms: 290_000, player2Ms: 270_000 }); + }); + + it("is undefined for games saved before the game clock", () => { + expect(getClockAtMove(record({ moves: [move(P1)], clockInitialMs: undefined }), 1)).toBeUndefined(); + }); +}); -- 2.45.2 From 999ae79279e6ff70655c85f733c541475b84edb6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:27:28 +0300 Subject: [PATCH 25/82] feat(backend): run a game clock for each player Each player starts with GAME_CLOCK_MS (5 minutes). A move deducts the turn's time from the mover; a move that arrives after the time has run out is not played and the opponent wins on time. Every ending stops the clock, and clients get the clock and the server time with each game message. --- apps/backend/src/consts/config.ts | 10 ++- apps/backend/src/game/GameClock.ts | 33 ++++++++ apps/backend/src/game/GameManager.ts | 60 +++++++++++-- apps/backend/src/game/GameSerialization.ts | 5 +- apps/backend/src/models/Game.ts | 5 +- apps/backend/tests/game/GameManager.test.ts | 94 ++++++++++++++++++++- docs/specs/game-clock/tasks.md | 2 +- 7 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/game/GameClock.ts diff --git a/apps/backend/src/consts/config.ts b/apps/backend/src/consts/config.ts index 7bb3c13..909455f 100644 --- a/apps/backend/src/consts/config.ts +++ b/apps/backend/src/consts/config.ts @@ -1 +1,9 @@ -export const RTMT_WS_PING_INTERVAL = 2000; \ No newline at end of file +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/game/GameClock.ts b/apps/backend/src/game/GameClock.ts new file mode 100644 index 0000000..aa1f8cd --- /dev/null +++ b/apps/backend/src/game/GameClock.ts @@ -0,0 +1,33 @@ +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/GameManager.ts b/apps/backend/src/game/GameManager.ts index 9e0357a..a5cfbd7 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -20,8 +20,10 @@ import { GameEndMessage, GameMove, GameUsersConnectionInfo, + getSeat, INACTIVE_TIMEOUT_MS, TURN_TIMEOUT_MS, + Seat, UserConnectionInfo, } from "@mancala/core"; import { GameCrashManager } from "./GameCrashManager"; @@ -30,11 +32,15 @@ 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"; const TIMEOUT_CHECK_INTERVAL_MS = 10_000; // What players and spectators receive; leaves out spectator ids and other server-side state. -export type ClientGame = Game & { startedAt: number, moveTimes: number[], spectatorCount: number }; +export type ClientGame = Game & { + startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable, serverTime: number, +}; export class GameManager { gameStore: GameStore; @@ -43,6 +49,8 @@ export class GameManager { private readonly liveGameStore: LiveGameStore; private readonly recorder: GameRecorder; private readonly clock: () => number; + // Time each player has for a new game. + private readonly gameClockMs: number; // When each player in a live game went offline; cleared when they reconnect. private readonly offlineSince = new Map(); private readonly liveGamesListeners: (() => void)[] = []; @@ -55,6 +63,7 @@ export class GameManager { liveGameStore: LiveGameStore, recorder: GameRecorder, clock?: () => number, + gameClockMs?: number, }) { this.gameStore = params.gameStore; this.rtmt = params.rtmt; @@ -62,6 +71,7 @@ export class GameManager { this.liveGameStore = params.liveGameStore; this.recorder = params.recorder; this.clock = params.clock ?? Date.now; + this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS; this.initialize(); } @@ -119,9 +129,21 @@ export class GameManager { 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) game.moveTimes.push(this.clock()); + 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") { @@ -152,11 +174,14 @@ export class GameManager { } } - // Ends a live game for any reason: tells everyone, forgets it and saves it. + // 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); - const endMessage: GameEndMessage = { gameId: game.id, result: getResult(game, ending), endReason: ending.endReason }; + 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; @@ -173,6 +198,24 @@ export class GameManager { 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); + } + // Applies the rules for players who stay away: the player whose turn it is loses after TURN_TIMEOUT_MS offline // while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner. public checkTimeouts() { @@ -211,7 +254,7 @@ export class GameManager { this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err)); continue; } - const game = fromSnapshot(snapshot); + const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs }); this.gameStore.set(game.id, game); const now = this.clock(); for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) { @@ -278,6 +321,8 @@ export class GameManager { startedAt: game.startedAt, moveTimes: game.moveTimes, spectatorCount: game.spectatorIds.length, + clock: game.clock, + serverTime: this.clock(), }; } @@ -292,6 +337,7 @@ export class GameManager { 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), @@ -305,8 +351,10 @@ export class GameManager { allSpectatorIds: [], peakSpectators: 0, players, - startedAt: this.clock(), + startedAt: now, moveTimes: [], + clock: startClock(this.gameClockMs, now), + moveRemainingMs: [], }; this.gameStore.set(game.id, game); diff --git a/apps/backend/src/game/GameSerialization.ts b/apps/backend/src/game/GameSerialization.ts index 985de24..0be5e13 100644 --- a/apps/backend/src/game/GameSerialization.ts +++ b/apps/backend/src/game/GameSerialization.ts @@ -2,6 +2,7 @@ import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core"; import { CommonMancalaGame, GameState, GameStep } from "mancala.js"; import { BackendGame, PlayerInfo } from "../models/Game"; import { publicUserId } from "./publicIds"; +import { startClock } from "./GameClock"; type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] }; @@ -56,7 +57,7 @@ export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot { } // Rebuilds a live game with real engine objects (rules, pits), so it can be played on. -export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame { +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; @@ -76,6 +77,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot): BackendGame { players: snapshot.players, startedAt: snapshot.startedAt, moveTimes: snapshot.moveTimes, + clock: startClock(params.gameClockMs, params.now), + moveRemainingMs: [], }; } diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index 3fa1756..ee9c3c0 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -1,4 +1,4 @@ -import { Game } from "@mancala/core"; +import { Game, GameClock } from "@mancala/core"; export interface PlayerInfo { userId: string; @@ -18,4 +18,7 @@ export interface BackendGame extends Game { 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. + moveRemainingMs: number[]; } diff --git a/apps/backend/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts index a454c67..7bb116d 100644 --- a/apps/backend/tests/game/GameManager.test.ts +++ b/apps/backend/tests/game/GameManager.test.ts @@ -26,6 +26,7 @@ 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; @@ -41,6 +42,7 @@ describe("GameManager", () => { const recorder = new GameRecorder(repository, liveGameStore); gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder, clock: () => now, + gameClockMs: CLOCK_MS, }); return recorder; }; @@ -143,6 +145,91 @@ describe("GameManager", () => { }); }); + 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("how games end", () => { it("completed: the winner by the rules, with every move saved", async () => { const game = startGame(); @@ -160,7 +247,7 @@ describe("GameManager", () => { 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 }, + { gameId: game.id, result: aliceSeat, endReason: "completed", winnerId: ALICE.id, clock: expect.any(Object) }, ]); }); @@ -176,7 +263,10 @@ describe("GameManager", () => { 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 }, + { + gameId: game.id, result: bobSeat, endReason: "left", winnerId: BOB.id, leftUserId: ALICE.id, + clock: expect.any(Object), + }, ]); } }); diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index e5d5882..48aed39 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 1. **Core types and helpers**: `GameClock`, `CLOCK_LOW_TIME_MS`, the new optional fields on `Game`, `GameEndMessage`, `MoveRecord` and `FinishedGameSummary`; `getClockTimes`, `ClockSync`, `getClockAtMove`, `formatClock` with unit tests; English timeout texts. -- [ ] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on +- [x] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and `serverTime` in client messages; unit tests. - [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove -- 2.45.2 From 3d4f5d7d594e3dda773632f3fb580a6ab8128442 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:29:22 +0300 Subject: [PATCH 26/82] feat(backend): end games on time with a scheduled service GameClockScheduler checks every live game once a second and ends those whose running clock has reached zero, whether or not anyone is connected. The clock replaces the offline turn timeout and the inactivity rule, so both are removed with the offline times they used. --- apps/backend/src/game/GameClockScheduler.ts | 25 ++++ apps/backend/src/game/GameManager.ts | 56 ++------ apps/backend/src/index.ts | 3 +- .../tests/game/GameClockScheduler.test.ts | 22 +++ apps/backend/tests/game/GameManager.test.ts | 129 ++++++------------ docs/specs/game-clock/design.md | 6 +- docs/specs/game-clock/tasks.md | 7 +- 7 files changed, 111 insertions(+), 137 deletions(-) create mode 100644 apps/backend/src/game/GameClockScheduler.ts create mode 100644 apps/backend/tests/game/GameClockScheduler.test.ts diff --git a/apps/backend/src/game/GameClockScheduler.ts b/apps/backend/src/game/GameClockScheduler.ts new file mode 100644 index 0000000..c8a1ab1 --- /dev/null +++ b/apps/backend/src/game/GameClockScheduler.ts @@ -0,0 +1,25 @@ +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/GameManager.ts b/apps/backend/src/game/GameManager.ts index a5cfbd7..8a67049 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -21,8 +21,6 @@ import { GameMove, GameUsersConnectionInfo, getSeat, - INACTIVE_TIMEOUT_MS, - TURN_TIMEOUT_MS, Seat, UserConnectionInfo, } from "@mancala/core"; @@ -35,8 +33,6 @@ import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock"; import { GAME_CLOCK_MS } from "../consts/config"; -const TIMEOUT_CHECK_INTERVAL_MS = 10_000; - // 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, @@ -51,10 +47,7 @@ export class GameManager { private readonly clock: () => number; // Time each player has for a new game. private readonly gameClockMs: number; - // When each player in a live game went offline; cleared when they reconnect. - private readonly offlineSince = new Map(); private readonly liveGamesListeners: (() => void)[] = []; - private timeoutTimer?: NodeJS.Timeout; constructor(params: { gameStore: GameStore, @@ -86,14 +79,6 @@ export class GameManager { this.liveGamesListeners.push(listener); } - public startTimers() { - this.timeoutTimer = setInterval(() => this.checkTimeouts(), TIMEOUT_CHECK_INTERVAL_MS); - } - - public stopTimers() { - if (this.timeoutTimer) clearInterval(this.timeoutTimer); - } - private listenRtmtMessages() { this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => { const activeGame = this.gameStore.getGameByUser(userKey); @@ -189,8 +174,6 @@ export class GameManager { } this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage); game.spectatorIds = []; - this.offlineSince.delete(game.mancalaGame.player1Id); - this.offlineSince.delete(game.mancalaGame.player2Id); const record = toGameRecord(game, ending, this.clock()); this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err)); @@ -216,23 +199,14 @@ export class GameManager { return getSeat(mancalaGame, mancalaGame.turnPlayerId); } - // Applies the rules for players who stay away: the player whose turn it is loses after TURN_TIMEOUT_MS offline - // while the opponent is online; a game where both are offline for INACTIVE_TIMEOUT_MS ends without a winner. - public checkTimeouts() { - const now = this.clock(); + // 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()) { - const { player1Id, player2Id, turnPlayerId } = game.mancalaGame; - const player1Offline = this.offlineSince.get(player1Id); - const player2Offline = this.offlineSince.get(player2Id); - if (player1Offline !== undefined && player2Offline !== undefined) { - if (now - Math.max(player1Offline, player2Offline) >= INACTIVE_TIMEOUT_MS) { - this.endGame(game, { endReason: "inactive" }); - } - continue; - } - const turnPlayerOffline = this.offlineSince.get(turnPlayerId); - if (turnPlayerOffline !== undefined && now - turnPlayerOffline >= TURN_TIMEOUT_MS) { - this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) }); + try { + if (this.isOutOfTime(game, now)) this.endGameOnTime(game); + } catch (err) { + console.error(`(GameManager) Could not end game ${game.id} on time:`, err); } } } @@ -256,10 +230,8 @@ export class GameManager { } const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs }); this.gameStore.set(game.id, game); - const now = this.clock(); for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) { this.gameStore.setGameByUser(playerId, game); - if (!this.rtmt.isClientOnline(playerId)) this.offlineSince.set(playerId, now); } game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game); restored++; @@ -375,12 +347,8 @@ export class GameManager { } private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { - const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => { - const info: UserConnectionInfo = { ...player, isOnline: this.rtmt.isClientOnline(player.userId) }; - const offlineSince = this.offlineSince.get(player.userId); - if (!info.isOnline && offlineSince !== undefined) info.offlineSince = offlineSince; - return info; - }; + const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => + ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) }); return { user1ConnectionInfo: toConnectionInfo(player1), user2ConnectionInfo: toConnectionInfo(player2) @@ -422,11 +390,7 @@ export class GameManager { private listenUserConnectionChange() { this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => { const game = this.gameStore.getGameByUser(clientId); - if (game) { - if (isOnline) this.offlineSince.delete(clientId); - else if (!this.offlineSince.has(clientId)) this.offlineSince.set(clientId, this.clock()); - this.sendUserConnectionInfo(game); - } + if (game) this.sendUserConnectionInfo(game); if (!isOnline) { const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId); speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId)); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b2af499..5cde744 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -14,6 +14,7 @@ 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"; @@ -48,7 +49,7 @@ async function main() { // Before accepting connections, so reconnecting players find their games. await gameManager.restoreLiveGames(); recorder.start(); - gameManager.startTimers(); + new GameClockScheduler(gameManager).start(); const expressApp = new ExpressApp({ gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth, diff --git a/apps/backend/tests/game/GameClockScheduler.test.ts b/apps/backend/tests/game/GameClockScheduler.test.ts new file mode 100644 index 0000000..c5f8dce --- /dev/null +++ b/apps/backend/tests/game/GameClockScheduler.test.ts @@ -0,0 +1,22 @@ +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 index 7bb116d..d3a11fd 100644 --- a/apps/backend/tests/game/GameManager.test.ts +++ b/apps/backend/tests/game/GameManager.test.ts @@ -9,8 +9,6 @@ import { channel_on_game_user_leave, channel_on_new_game_rejected, channel_on_user_connection_change, - INACTIVE_TIMEOUT_MS, - TURN_TIMEOUT_MS, } from "@mancala/core"; import { GameManager } from "../../src/game/GameManager"; import { GameRecorder } from "../../src/game/GameRecorder"; @@ -230,6 +228,51 @@ describe("GameManager", () => { }); }); + 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(); @@ -271,72 +314,6 @@ describe("GameManager", () => { } }); - it("timeout: the player whose turn it is loses after 5 minutes offline", async () => { - const game = startGame(); - const turnPlayer = game.mancalaGame.turnPlayerId; - const waitingPlayer = turnPlayer === ALICE.id ? BOB.id : ALICE.id; - rtmt.disconnect(turnPlayer); - const offlineAt = now; - const info = rtmt.messagesTo(waitingPlayer, channel_on_user_connection_change).pop(); - const turnPlayerInfo = [info.user1ConnectionInfo, info.user2ConnectionInfo].find((i: any) => i.userId === turnPlayer); - expect(turnPlayerInfo).toMatchObject({ isOnline: false, offlineSince: offlineAt }); - - now = offlineAt + TURN_TIMEOUT_MS - 1; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBe(game); - - now = offlineAt + TURN_TIMEOUT_MS; - gameManager.checkTimeouts(); - await flushPromises(); - expect(gameStore.get(game.id)).toBeUndefined(); - const record = repository.records.get(game.id)!; - expect(record.endReason).toBe("timeout"); - expect(record.result).toBe(game.mancalaGame.player1Id === waitingPlayer ? "player1" : "player2"); - }); - - it("timeout does not apply when the waiting player is offline too, or when it is not the offline player's turn", () => { - const game = startGame(); - const turnPlayer = game.mancalaGame.turnPlayerId; - const waitingPlayer = turnPlayer === ALICE.id ? BOB.id : ALICE.id; - - rtmt.disconnect(waitingPlayer); - now += TURN_TIMEOUT_MS * 2; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBe(game); - - rtmt.disconnect(turnPlayer); - now += TURN_TIMEOUT_MS * 2; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBe(game); - }); - - it("a reconnect resets the offline time", () => { - const game = startGame(); - const turnPlayer = game.mancalaGame.turnPlayerId; - const user = turnPlayer === ALICE.id ? ALICE : BOB; - rtmt.disconnect(turnPlayer); - now += TURN_TIMEOUT_MS - 1000; - rtmt.connect(user); - rtmt.disconnect(turnPlayer); - now += 2000; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBe(game); - }); - - it("inactive: both players offline for 30 minutes ends the game without a winner", async () => { - const game = startGame(); - rtmt.disconnect(ALICE.id); - now += 60_000; - rtmt.disconnect(BOB.id); - now += INACTIVE_TIMEOUT_MS - 1; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBe(game); - - now += 1; - gameManager.checkTimeouts(); - await flushPromises(); - expect(repository.records.get(game.id)).toMatchObject({ endReason: "inactive", result: null }); - }); }); describe("saving", () => { @@ -406,22 +383,6 @@ describe("GameManager", () => { expect(restored.mancalaGame.history).toHaveLength(3); }); - it("counts players as offline from the restart, so the timeout rules start over", () => { - startGame(); - createManager(); - return gameManager.restoreLiveGames().then(() => { - const game = gameStore.getGameByUser(ALICE.id)!; - const waiting = game.mancalaGame.turnPlayerId === ALICE.id ? BOB : ALICE; - rtmt.connect(waiting); - now += TURN_TIMEOUT_MS - 1; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBe(game); - now += 1; - gameManager.checkTimeouts(); - expect(gameStore.get(game.id)).toBeUndefined(); - }); - }); - it("does not bring back a game that has already been saved", async () => { const game = startGame(); await flushPromises(); diff --git a/docs/specs/game-clock/design.md b/docs/specs/game-clock/design.md index 1732373..28cd173 100644 --- a/docs/specs/game-clock/design.md +++ b/docs/specs/game-clock/design.md @@ -84,12 +84,12 @@ src/db/migrations/002_clock.ts clock_initial_ms column ### Scheduled service (`GameClockScheduler`) ```ts class GameClockScheduler { - constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now) - start(): void // setInterval → gameManager.endGamesOutOfTime(clock()) + constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */) + start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock stop(): void } ``` -`src/index.ts` starts it after `restoreLiveGames()` and stops it on shutdown, replacing `gameManager.startTimers()`. +`src/index.ts` starts it after `restoreLiveGames()`, replacing `gameManager.startTimers()`. It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through `GameRecorder` with retry as today. diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index 48aed39..cbcc559 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -8,12 +8,13 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 2. **Clock in the game lifecycle**: `GameClock.ts`, `GAME_CLOCK_MS`, `clock` and `moveRemainingMs` on `BackendGame`, start, charging moves, refusing late moves, stopping the clock on every ending, `clock` and `serverTime` in client messages; unit tests. -- [ ] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove - `checkTimeouts`, the 10-second timer, `offlineSince`, `TURN_TIMEOUT_MS` and `INACTIVE_TIMEOUT_MS`; unit tests. +- [x] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove + `checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests. - [ ] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and `clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests. - [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile - layouts; remove the offline countdown. + layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, + `UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then). - [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`. - [ ] 7. **Mobile app**: clocks in `GameScreen`; type check and bundle. - [ ] 8. **End to end and docs**: Chrome tests from `design.md`; README, `AGENTS.md` and `TODO.md` (release the mobile -- 2.45.2 From c610844e26132f5d911a2fc6fbf973fdb119b70b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:30:39 +0300 Subject: [PATCH 27/82] feat(backend): keep the game clock across restarts and in saved games Live game snapshots become version 2 with the clock and each move's time left. On restore the running clock goes on from the start of the turn, so downtime is not charged; version 1 snapshots get a new clock. Saved games keep the initial time (migration 002_clock) and each move's time left. --- apps/backend/src/db/migrations/002_clock.ts | 4 ++ apps/backend/src/db/migrations/index.ts | 2 + apps/backend/src/game/GameSerialization.ts | 49 ++++++++++----- .../src/history/GameHistoryRepository.ts | 11 ++-- apps/backend/src/models/Game.ts | 4 +- apps/backend/tests/game/GameManager.test.ts | 62 +++++++++++++++++++ .../PostgresGameHistoryRepository.test.ts | 18 +++++- docs/specs/game-clock/tasks.md | 2 +- 8 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 apps/backend/src/db/migrations/002_clock.ts diff --git a/apps/backend/src/db/migrations/002_clock.ts b/apps/backend/src/db/migrations/002_clock.ts new file mode 100644 index 0000000..461ecff --- /dev/null +++ b/apps/backend/src/db/migrations/002_clock.ts @@ -0,0 +1,4 @@ +// 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 index 7c5e1ab..ce0b17f 100644 --- a/apps/backend/src/db/migrations/index.ts +++ b/apps/backend/src/db/migrations/index.ts @@ -1,4 +1,5 @@ import { sql as games001 } from "./001_games"; +import { sql as clock002 } from "./002_clock"; export interface Migration { version: string; @@ -8,4 +9,5 @@ export interface Migration { // 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/game/GameSerialization.ts b/apps/backend/src/game/GameSerialization.ts index 0be5e13..d88374e 100644 --- a/apps/backend/src/game/GameSerialization.ts +++ b/apps/backend/src/game/GameSerialization.ts @@ -1,14 +1,14 @@ -import { EndReason, GameRecord, GameResult, MoveRecord } from "@mancala/core"; +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 { startClock } from "./GameClock"; +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. +// 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; + version: 1 | 2; id: string; player1Id: string; player2Id: string; @@ -21,6 +21,8 @@ export interface LiveGameSnapshot { moveTimes: number[]; peakSpectators: number; allSpectatorIds: string[]; + clock?: GameClock; + moveRemainingMs?: (number | null)[]; } export interface GameEnding { @@ -33,7 +35,7 @@ export interface GameEnding { export function toSnapshot(game: BackendGame): LiveGameSnapshot { const { mancalaGame } = game; return { - version: 1, + version: 2, id: game.id, player1Id: mancalaGame.player1Id, player2Id: mancalaGame.player2Id, @@ -46,17 +48,24 @@ export function toSnapshot(game: BackendGame): LiveGameSnapshot { 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; - return typeof snapshot === "object" && snapshot !== null && snapshot.version === 1 - && typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14 + 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. +// 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)); @@ -77,8 +86,8 @@ export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, players: snapshot.players, startedAt: snapshot.startedAt, moveTimes: snapshot.moveTimes, - clock: startClock(params.gameClockMs, params.now), - moveRemainingMs: [], + clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now), + moveRemainingMs: snapshot.moveRemainingMs ?? snapshot.history.map(() => null), }; } @@ -98,13 +107,18 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num 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) => ({ - playerId: publicUserId(entry.playerId), - moveIndex: entry.moveIndex, - boardSnapshot: entry.boardSnapshot, - gameSteps: entry.gameSteps, - at: game.moveTimes[i] ?? endedAt, - })); + 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), @@ -118,6 +132,7 @@ export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: num 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/history/GameHistoryRepository.ts b/apps/backend/src/history/GameHistoryRepository.ts index 19f6231..055dff9 100644 --- a/apps/backend/src/history/GameHistoryRepository.ts +++ b/apps/backend/src/history/GameHistoryRepository.ts @@ -15,7 +15,7 @@ export interface GameHistoryRepository { 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.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`; @@ -26,7 +26,7 @@ const FROM_GAMES = `FROM games g 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; + 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; }; @@ -48,6 +48,7 @@ function toSummary(row: SummaryRow): FinishedGameSummary { 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; } @@ -58,14 +59,14 @@ export class PostgresGameHistoryRepository implements GameHistoryRepository { 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) + 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) + $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.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( diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index ee9c3c0..c9fd6ff 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -19,6 +19,6 @@ export interface BackendGame extends Game { // Time of each move, aligned with mancalaGame.history. moveTimes: number[]; clock: GameClock; - // The mover's clock after each move, aligned with mancalaGame.history. - moveRemainingMs: number[]; + // 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/tests/game/GameManager.test.ts b/apps/backend/tests/game/GameManager.test.ts index d3a11fd..2ad8a21 100644 --- a/apps/backend/tests/game/GameManager.test.ts +++ b/apps/backend/tests/game/GameManager.test.ts @@ -317,6 +317,20 @@ describe("GameManager", () => { }); 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); @@ -383,6 +397,54 @@ describe("GameManager", () => { 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(); diff --git a/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts b/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts index 4cc0f43..7aaecc0 100644 --- a/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts +++ b/apps/backend/tests/history/PostgresGameHistoryRepository.test.ts @@ -60,7 +60,7 @@ describe("PostgresGameHistoryRepository", () => { 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" }]); + expect(rows).toEqual([{ version: "001_games" }, { version: "002_clock" }]); }); it("saves a game and reads it back unchanged", async () => { @@ -69,6 +69,22 @@ describe("PostgresGameHistoryRepository", () => { 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); diff --git a/docs/specs/game-clock/tasks.md b/docs/specs/game-clock/tasks.md index cbcc559..911337c 100644 --- a/docs/specs/game-clock/tasks.md +++ b/docs/specs/game-clock/tasks.md @@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. `serverTime` in client messages; unit tests. - [x] 3. **Scheduled service**: `endGamesOutOfTime`, `GameClockScheduler` wired in `index.ts`; remove `checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests. -- [ ] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and +- [x] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and `clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests. - [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, -- 2.45.2 From 20359b7a8f77fcbbfd1641a7b49ad247c764488c Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 01:39:36 +0300 Subject: [PATCH 28/82] feat(frontend): show the game clocks in live games Both clocks sit next to the players' names and count down in server time; the running one is highlighted and turns red under 30 seconds. They replace the offline countdown, so core drops TURN_TIMEOUT_MS, INACTIVE_TIMEOUT_MS, offlineSince and its text. Long info texts wrap on phones. --- .../frontend/src/components/GameClockView.tsx | 40 +++++++++++++++++ apps/frontend/src/components/UserStatus.tsx | 7 ++- apps/frontend/src/hooks/useGameClock.ts | 27 ++++++++++++ apps/frontend/src/routes/GamePage.tsx | 43 +++++++------------ docs/specs/game-clock/tasks.md | 2 +- packages/core/src/localization/en.ts | 1 - packages/core/src/localization/tr.ts | 1 - packages/core/src/models/GameHistory.ts | 5 --- .../core/src/models/UserConnectionInfo.ts | 2 - 9 files changed, 88 insertions(+), 40 deletions(-) create mode 100644 apps/frontend/src/components/GameClockView.tsx create mode 100644 apps/frontend/src/hooks/useGameClock.ts diff --git a/apps/frontend/src/components/GameClockView.tsx b/apps/frontend/src/components/GameClockView.tsx new file mode 100644 index 0000000..72660ff --- /dev/null +++ b/apps/frontend/src/components/GameClockView.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core"; +import { Context } from '../context/context'; + +// Readable on every theme; the themes have no warning color of their own. +const LOW_TIME_COLOR = "#b3261e"; +const LOW_TIME_TEXT_COLOR = "#ffffff"; + +// One player's clock: highlighted while it runs, in a warning color when the running clock is low. +const GameClockView: FunctionComponent<{ + context: Context, + timeMs: number, + running: boolean, +}> = ({ context, timeMs, running }) => { + const theme = context.themeManager.theme; + const lowTime = running && timeMs < CLOCK_LOW_TIME_MS; + const background = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent"; + const color = lowTime ? LOW_TIME_TEXT_COLOR : running + ? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor) + : getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); + return ( + + {formatClock(timeMs)} + + + ); +} + +export default GameClockView; diff --git a/apps/frontend/src/components/UserStatus.tsx b/apps/frontend/src/components/UserStatus.tsx index c4f767c..a9ddc98 100644 --- a/apps/frontend/src/components/UserStatus.tsx +++ b/apps/frontend/src/components/UserStatus.tsx @@ -13,8 +13,10 @@ const UserStatus: FunctionComponent<{ user: User, layoutMode: LayoutMode, visible?: boolean, - style?: React.CSSProperties -}> = ({ context, user, layoutMode, visible, style }) => { + style?: React.CSSProperties, + // Shown after the online status, e.g. the player's clock. + accessory?: React.ReactNode, +}> = ({ context, user, layoutMode, visible, style, accessory }) => { if (visible === false) return <>; const { t } = useTranslation(); @@ -29,6 +31,7 @@ const UserStatus: FunctionComponent<{ {user.isAnonymous ? t("Anonymous") : user.name}
    + {accessory && <>{accessory}} - {props.children} ) }; -export default FloatingPanel; \ No newline at end of file +export default FloatingPanel; diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx new file mode 100644 index 0000000..49fadab --- /dev/null +++ b/apps/frontend/src/components/Icons.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; + +// Inline stroke icons in the current text colour. All are decorative; buttons carry their own labels. + +type IconProps = { size?: number, className?: string }; + +const stroke = (size: number, width: number, className?: string) => ({ + width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: width, + strokeLinecap: "round" as const, strokeLinejoin: "round" as const, className, "aria-hidden": true, +}); + +export const LogoIcon = ({ size = 34 }: IconProps) => ( + +); + +export const PlusIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ProfileIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LeaveIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ChevronIcon = ({ size = 20, className }: IconProps) => ( + +); + +export const CloseIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ReplayIcon = ({ size = 18 }: IconProps) => ( + +); + +export const FirstIcon = ({ size = 20 }: IconProps) => ( + +); + +export const PreviousIcon = ({ size = 20 }: IconProps) => ( + +); + +export const NextIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LastIcon = ({ size = 20 }: IconProps) => ( + +); + +export const PlayIcon = ({ size = 22 }: IconProps) => ( + +); + +export const PauseIcon = ({ size = 22 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/LoadingComponent.tsx b/apps/frontend/src/components/LoadingComponent.tsx index ea0e15c..2538447 100644 --- a/apps/frontend/src/components/LoadingComponent.tsx +++ b/apps/frontend/src/components/LoadingComponent.tsx @@ -1,32 +1,33 @@ import * as React from 'react'; import { FunctionComponent } from 'react'; -import { Context } from '../context/context'; import { LoadingState } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import CircularPanel from './CircularPanel'; import { useTranslation } from 'react-i18next'; -const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState }> = ({ context, loadingState }) => { +const LoadingComponent: FunctionComponent<{ loadingState: LoadingState }> = ({ loadingState }) => { const { t } = useTranslation(); - if (loadingState.isUnset() || loadingState.isLoaded()) { - return <> - } - if (loadingState.isLoading() || loadingState.isError()) { - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.boardColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const text = loadingState.isLoading() ? t("Loading") +"..." : loadingState.errorMessage; - return ( - -

    {`${text}`}

    -
    - ); - } - return <> + if (!loadingState.isLoading() && !loadingState.isError()) return <>; + const text = loadingState.isLoading() ? t("Loading") + "..." : loadingState.errorMessage; + return ( +
    + {loadingState.isLoading() && } + {text} + +
    + ); } -export default LoadingComponent; \ No newline at end of file +export default LoadingComponent; diff --git a/apps/frontend/src/components/PageContainer.tsx b/apps/frontend/src/components/PageContainer.tsx index 8740ee2..6db896a 100644 --- a/apps/frontend/src/components/PageContainer.tsx +++ b/apps/frontend/src/components/PageContainer.tsx @@ -1,25 +1,7 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { Theme } from "@mancala/core"; -const PageContainer: FunctionComponent<{ theme: Theme }> = (props) => { - return ( -
    - - {props.children} -
    - ); -} +// A page: the header, then the page's content. +const PageContainer: FunctionComponent = ({ children }) =>
    {children}
    ; -export default PageContainer; \ No newline at end of file +export default PageContainer; diff --git a/apps/frontend/src/components/Row.tsx b/apps/frontend/src/components/Row.tsx deleted file mode 100644 index bc0db42..0000000 --- a/apps/frontend/src/components/Row.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Row: FunctionComponent = ({children}) => { - return ( -
    - {children} - -
    - ); -} - -export default Row; \ No newline at end of file diff --git a/apps/frontend/src/components/headerbar/AccountMenu.tsx b/apps/frontend/src/components/headerbar/AccountMenu.tsx index dc20841..f971cf6 100644 --- a/apps/frontend/src/components/headerbar/AccountMenu.tsx +++ b/apps/frontend/src/components/headerbar/AccountMenu.tsx @@ -4,46 +4,30 @@ import { FunctionComponent } from "react"; import { AuthUser, getPlayerDisplayName } from "@mancala/core"; import { useTranslation } from "react-i18next"; import { Context } from "../../context/context"; +import { ProfileIcon } from "../Icons"; import "@szhsin/react-menu/dist/index.css"; import "@szhsin/react-menu/dist/transitions/slide.css" // Guests can log in or sign up; logged in users can log out. -const AccountMenu: FunctionComponent<{ context: Context, textColor: string, authUser?: AuthUser }> = (props) => { - const { context, textColor, authUser } = props; +const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => { const { t } = useTranslation(); - if (!authUser) return <>; - const authService = context.authService; - const menuButton = - account_circle - ; - const itemStyle = { color: textColor }; + const menuButton = ( + + ); const menuItems = authUser.isGuest ? [ - authService.login()}>{t("LogIn")}, - authService.signUp()}>{t("SignUp")}, + authService.login()}>{t("LogIn")}, + authService.signUp()}>{t("SignUp")}, ] : [ - authService.logout()}>{t("LogOut")}, + authService.logout()}>{t("LogOut")}, ]; return ( -
    - - {getPlayerDisplayName(authUser, t)} - {menuItems} - - -
    + + {getPlayerDisplayName(authUser, t)} + {menuItems} + ); } diff --git a/apps/frontend/src/components/headerbar/AppHeader.tsx b/apps/frontend/src/components/headerbar/AppHeader.tsx new file mode 100644 index 0000000..236e159 --- /dev/null +++ b/apps/frontend/src/components/headerbar/AppHeader.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { AuthUser } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { LogoIcon } from "../Icons"; +import AccountMenu from "./AccountMenu"; +import ThemeSwatches from "./ThemeSwatches"; + +// The header of every page: logo and name, theme swatches, the account menu when a user is given, and the page's +// main action (children). +const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => { + const { t } = useTranslation(); + return ( +
    + + + {t("Mancala")} + + + {authUser && } + {children} +
    + ); +}; + +export default AppHeader; diff --git a/apps/frontend/src/components/headerbar/HeaderBar.tsx b/apps/frontend/src/components/headerbar/HeaderBar.tsx deleted file mode 100644 index 683f8c2..0000000 --- a/apps/frontend/src/components/headerbar/HeaderBar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react";; - -const HeaderBar: FunctionComponent<{ color?: string }> = ({children, color }) => { - return ( -
    - {children} - -
    ) -} -export default HeaderBar; diff --git a/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx b/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx deleted file mode 100644 index b012df0..0000000 --- a/apps/frontend/src/components/headerbar/HeaderbarIcon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -//@ts-ignore -import MancalaIcon from "jsx:../../mancala.svg"; - -const HeaderbarIcon: FunctionComponent = () => { - return ( -
    - - -
    - ); -} - -export default HeaderbarIcon; \ No newline at end of file diff --git a/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx b/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx deleted file mode 100644 index 73e2bb0..0000000 --- a/apps/frontend/src/components/headerbar/HeaderbarTitle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const HeaderbarTitle: FunctionComponent<{ title: string, color: string }> = ({ title, color }) => { - return ( -

    - {title} - -

    - ); -} - -export default HeaderbarTitle; \ No newline at end of file diff --git a/apps/frontend/src/components/headerbar/ThemeSwatches.tsx b/apps/frontend/src/components/headerbar/ThemeSwatches.tsx new file mode 100644 index 0000000..add43d9 --- /dev/null +++ b/apps/frontend/src/components/headerbar/ThemeSwatches.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { getWebTheme, webThemes } from "../../theme/themes"; + +const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t } = useTranslation(); + const current = getWebTheme(context.themeManager.theme); + return ( +
    + {webThemes.map((theme) => ( + + ))} +
    + ); +}; + +export default ThemeSwatches; diff --git a/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx b/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx deleted file mode 100644 index 6e45818..0000000 --- a/apps/frontend/src/components/headerbar/ThemeSwitchMenu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Menu, MenuItem } from "@szhsin/react-menu"; -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import "@szhsin/react-menu/dist/index.css"; -import "@szhsin/react-menu/dist/transitions/slide.css" - -const ThemeSwitchMenu: FunctionComponent<{ context: Context, textColor: string }> = (props) => { - const { context, textColor } = props; - const menuButton = - light_mode - ; - const menuItems = context.themeManager.themes.map((theme, index) => { - return ( - { - const htmlElement: HTMLElement = event.target as HTMLElement; - if (htmlElement.localName === "li") htmlElement.style.background = "transparent"; - }} - //@ts-ignore - onMouseOut={(event) => { - const htmlElement: HTMLElement = event.target as HTMLElement; - if (htmlElement.localName === "li") htmlElement.style.background = "transparent"; - }} - onClick={() => (context.themeManager.theme = theme)}> -
    - {theme.name} - - - ); - }) - return ( -
    - - {menuItems} - - -
    - ); -} - -export default ThemeSwitchMenu; \ No newline at end of file diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index f3a2f4f..0a03491 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -24,13 +24,10 @@ import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat import { getMoveText } from "../../util/MoveText"; import BoardView from "../board/BoardView"; import Button from "../Button"; -import HeaderBar from "../headerbar/HeaderBar"; -import HeaderbarIcon from "../headerbar/HeaderbarIcon"; -import HeaderbarTitle from "../headerbar/HeaderbarTitle"; -import ThemeSwitchMenu from "../headerbar/ThemeSwitchMenu"; import PageContainer from "../PageContainer"; +import AppHeader from "../headerbar/AppHeader"; +import { PlusIcon } from "../Icons"; import GameClockView from "../GameClockView"; -import Row from "../Row"; import MoveList from "./MoveList"; const SPEEDS = [1, 2, 4]; @@ -166,19 +163,12 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga ); return ( - - - - - - - - - - - +
    diff --git a/apps/frontend/src/context/context.tsx b/apps/frontend/src/context/context.tsx index b848039..e881751 100644 --- a/apps/frontend/src/context/context.tsx +++ b/apps/frontend/src/context/context.tsx @@ -5,6 +5,7 @@ import { GameStore, GameStoreImpl } from "../store/GameStore"; import { AuthService } from "../auth/AuthService"; import { LocalStorage } from "../storage"; import { Storage } from '@mancala/core' +import { webThemes } from "../theme/themes"; export type Context = { rtmt: RTMT; @@ -20,7 +21,7 @@ export const initContext = (): Context => { const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId }); const gameStore = new GameStoreImpl({ httpService }); const storage = new LocalStorage(); - const themeManager = new ThemeManager(storage); + const themeManager = new ThemeManager(storage, webThemes); return { rtmt: rtmt, authService, diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html index 3e12ef0..156bef2 100644 --- a/apps/frontend/src/index.html +++ b/apps/frontend/src/index.html @@ -1,31 +1,18 @@ - + Mancala + + + - -
    + +
    - - - - - \ No newline at end of file + diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index e0e776e..44840ee 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -2,20 +2,15 @@ import { MancalaGame, Pit } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { Link } from 'react-router-dom'; import { v4 } from 'uuid'; import { PitAnimator } from '@mancala/core'; import BoardToolbar from '../components/board/BoardToolbar'; import BoardView from '../components/board/BoardView'; -import Button from '../components/Button'; -import HeaderBar from '../components/headerbar/HeaderBar'; -import HeaderbarIcon from '../components/headerbar/HeaderbarIcon'; -import HeaderbarTitle from '../components/headerbar/HeaderbarTitle'; -import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; import InfoPanel from '../components/InfoPanel'; import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; -import Row from '../components/Row'; +import AppHeader from '../components/headerbar/AppHeader'; +import { LeaveIcon, PlusIcon } from '../components/Icons'; import UserStatus from '../components/UserStatus'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core'; import { Context } from '../context/context'; @@ -341,7 +336,7 @@ const GamePage: FunctionComponent<{ } return ( - + {renderHeaderBar()} {renderMobileBoardToolbar()} {buildBoardTopToolbar()} @@ -371,30 +366,21 @@ const GamePage: FunctionComponent<{ collapsedByDefault={isMobile} /> )}
    - +
    ); function renderHeaderBar() { - return - - - - - - - - - - - + : } + ; } function renderMobileBoardToolbar() { diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index d3959cf..b9bc59f 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,20 +1,15 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; -import { getColorByBrightness, LiveGameSummary } from "@mancala/core"; +import { LiveGameSummary } from "@mancala/core"; import { AuthUser, Theme } from "@mancala/core"; -import HeaderBar from "../components/headerbar/HeaderBar"; +import AppHeader from "../components/headerbar/AppHeader"; import PageContainer from "../components/PageContainer"; -import Row from "../components/Row"; -import HeaderbarIcon from "../components/headerbar/HeaderbarIcon"; -import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; -import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; -import AccountMenu from "../components/headerbar/AccountMenu"; +import { PlusIcon } from "../components/Icons"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; -import Button from "../components/Button"; import { Context } from "../context/context"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import Util from "../util/Util"; import { useTranslation } from "react-i18next"; @@ -46,32 +41,13 @@ const Home: FunctionComponent<{ navigate("/loby") }; - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); return ( - - - - - - - - - - - - - - + {activeGame && authUser && ( = ({ context, userKey, theme }) => { +}> = ({ context }) => { let navigate = useNavigate(); @@ -52,58 +44,60 @@ const LobyPage: FunctionComponent<{ context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); } }, []); - - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.boardColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( - - - - - - - - - - - - - - -
    -
    - -

    - {activeGameId ? t("GameInProgress") : `${t("SearchingOpponent")} ${t("PleaseWait")}...`} -

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

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

    + {!activeGameId &&

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

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

    Privacy Policy

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

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

    Contact Us

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

    -
    + +
    ); } -export default PrivacyPage; \ No newline at end of file +export default PrivacyPage; diff --git a/apps/frontend/src/styles/app.css b/apps/frontend/src/styles/app.css new file mode 100644 index 0000000..99c6653 --- /dev/null +++ b/apps/frontend/src/styles/app.css @@ -0,0 +1,244 @@ +/* Themes, base styles and the building blocks shared by the pages. The theme class is set on . */ + +.t-ceviz { + --bg: #1f1712; --surface: #2a2019; --surface2: #382a20; --line: rgba(246, 234, 216, .10); + --ink: #f6ead8; --muted: #c4ad93; --accent: #f2b84b; --onAccent: #2a1a0a; + --accentSoft: rgba(242, 184, 75, .45); --accentGlow: rgba(242, 184, 75, .35); --lastRing: rgba(246, 234, 216, .55); + --chip: #1a110b; --chipInk: #f6ead8; --rim: #8a5d3b; --pit: #2c1d13; --live: #ff8a73; + --miniBg: #5b3d28; --miniPit: #2c1d13; + --boardBg: repeating-linear-gradient(94deg, rgba(255, 255, 255, .025) 0 3px, rgba(0, 0, 0, .04) 3px 9px, rgba(255, 255, 255, .015) 9px 14px), linear-gradient(180deg, #6e4a30, #573822); + --boardShadow: inset 0 1px 0 rgba(255, 255, 255, .12), inset 0 -3px 0 rgba(0, 0, 0, .25), 0 26px 50px rgba(0, 0, 0, .45), 0 0 0 1px #3a2516; + --pitShadow: inset 0 8px 16px rgba(0, 0, 0, .6), inset 0 -2px 0 rgba(255, 255, 255, .07), 0 1px 0 rgba(255, 255, 255, .08); + --stoneShadow: 0 3px 4px rgba(0, 0, 0, .45); + color-scheme: dark; +} + +.t-gece { + --bg: #0c1220; --surface: #141c30; --surface2: #1d2744; --line: rgba(238, 241, 248, .09); + --ink: #eef1f8; --muted: #9aa6c2; --accent: #e2b36a; --onAccent: #241707; + --accentSoft: rgba(226, 179, 106, .45); --accentGlow: rgba(226, 179, 106, .35); --lastRing: rgba(238, 241, 248, .5); + --chip: #0a0f1c; --chipInk: #eef1f8; --rim: #b8925a; --pit: #0b1020; --live: #ff8a7a; + --miniBg: #1d2a4d; --miniPit: #0b1020; + --boardBg: linear-gradient(180deg, #1d2a4d, #141d38); + --boardShadow: inset 0 0 0 2px #b8925a, inset 0 0 0 7px #18223f, inset 0 0 0 8px rgba(184, 146, 90, .35), 0 26px 50px rgba(0, 0, 0, .5); + --pitShadow: inset 0 8px 18px rgba(0, 0, 0, .7), inset 0 0 0 1px rgba(184, 146, 90, .28), 0 1px 0 rgba(255, 255, 255, .06); + --stoneShadow: 0 3px 5px rgba(0, 0, 0, .55); + color-scheme: dark; +} + +.t-yesim { + --bg: #e9ebe3; --surface: #f7f8f3; --surface2: #dde0d3; --line: rgba(29, 39, 33, .12); + --ink: #1d2721; --muted: #4f5d55; --accent: #2a7650; --onAccent: #ffffff; + --accentSoft: rgba(42, 118, 80, .4); --accentGlow: rgba(42, 118, 80, .28); --lastRing: rgba(29, 39, 33, .5); + --chip: #1d2721; --chipInk: #ffffff; --rim: #e9ebe3; --pit: #c3c7b6; --live: #b43a26; + --miniBg: #d3d6c8; --miniPit: #bcc0ae; + --boardBg: linear-gradient(180deg, #dcdfd2, #cfd2c3); + --boardShadow: inset 0 2px 0 rgba(255, 255, 255, .75), inset 0 -3px 0 rgba(0, 0, 0, .08), 0 22px 44px rgba(40, 50, 40, .2), 0 0 0 1px #bcc0ae; + --pitShadow: inset 0 7px 14px rgba(40, 50, 40, .38), inset 0 -2px 0 rgba(255, 255, 255, .55), 0 1px 0 rgba(255, 255, 255, .75); + --stoneShadow: 0 3px 4px rgba(30, 40, 30, .35); + color-scheme: light; +} + +html, body, #main { min-height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: 'Bricolage Grotesque', system-ui, sans-serif; + transition: background .3s, color .3s; +} + +#main { display: flex; flex-direction: column; min-height: 100vh; } + +a { color: inherit; } + +/* Page */ + +.page { display: flex; flex-direction: column; flex: 1; min-width: 0; } + +.wrap { + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: 28px 32px 56px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 26px; +} + +/* Header */ + +.hdr { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 32px; + background: var(--bg); + border-bottom: 1px solid var(--line); + transition: background .3s; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + margin-right: auto; + min-width: 0; + color: var(--ink); + text-decoration: none; + font-weight: 800; + font-size: 24px; + letter-spacing: -.02em; +} + +.logo svg { flex: none; } + +.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); } + +.sw { + width: 44px; + height: 44px; + border-radius: 50%; + border: none; + padding: 0; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); } + +.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); } + +/* Buttons */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 18px; + border-radius: 22px; + border: none; + font: inherit; + font-weight: 700; + font-size: 15px; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + box-sizing: border-box; +} + +.btn.pri { background: var(--accent); color: var(--onAccent); } + +.btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); } + +.btn.icon { width: 44px; padding: 0; } + +.btn:disabled { opacity: .4; cursor: default; } + +.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} + +/* Pills, cards */ + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border-radius: 13px; + font-size: 12px; + font-weight: 700; + background: var(--surface2); + color: var(--ink); + white-space: nowrap; +} + +.pill.live, .pill.lose { color: var(--live); } + +.pill.live::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + animation: kpulse 1.6s ease-in-out infinite; +} + +.pill.win { background: var(--accent); color: var(--onAccent); } + +.panel { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } + +.dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; animation: kpulse 1.6s ease-in-out infinite; } + +@keyframes kpulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .35; transform: scale(.7); } +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Account menu (@szhsin/react-menu) */ + +.szh-menu { + background: var(--surface); + color: var(--ink); + border-radius: 16px; + padding: 6px; + box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25); + font-family: 'Bricolage Grotesque', system-ui, sans-serif; +} + +.szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; } + +.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); } + +.szh-menu__item--hover { background: var(--surface2); } + +/* Dialogs (sweetalert) */ + +.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; } + +.swal-title, .swal-text { color: var(--ink); } + +.swal-button { border-radius: 22px; font-family: inherit; } + +@media (max-width: 640px) { + .hdr { padding: 10px 16px; gap: 8px; } + .logo { font-size: 20px; gap: 8px; } + .logo svg { width: 28px; height: 28px; } + .themes .sw { width: 40px; height: 40px; } + .hide-m { display: none !important; } + .ic-m { width: 44px; padding: 0; } + .wrap { padding: 16px 16px 32px; gap: 16px; } +} + +/* The account button makes the header too wide for the name on phones. */ +@media (max-width: 420px) { + .hdr.has-account .logo span { display: none; } +} + +@media (max-width: 360px) { + .logo span { display: none; } +} diff --git a/apps/frontend/src/theme/themes.ts b/apps/frontend/src/theme/themes.ts new file mode 100644 index 0000000..09baab5 --- /dev/null +++ b/apps/frontend/src/theme/themes.ts @@ -0,0 +1,94 @@ +import { Theme } from "@mancala/core"; + +// The web app's themes. Pages are coloured by the CSS variables of the theme's class (styles/app.css); the colours +// here are for code that still reads core's Theme, such as PitAnimator. +export type WebTheme = Theme & { + // CSS class on . + className: string; + // Translation key of the theme's name. + labelKey: string; + // Colours of the theme's swatch. + swatch: { board: string, accent: string }; +}; + +const walnut: WebTheme = { + id: "ceviz", + name: "Ceviz", + className: "t-ceviz", + labelKey: "ThemeWalnut", + swatch: { board: "#6e4a30", accent: "#f2b84b" }, + themePreviewColor: "#6e4a30", + background: "#1f1712", + appBarBgColor: "#2a2019", + textColor: "#2a1a0a", + textLightColor: "#f6ead8", + playerTurnColor: "#f2b84b", + boardColor: "#6e4a30", + pitColor: "#2c1d13", + pitSelectedColor: "#f2b84b", + stoneColor: "#e39a32", + stoneLightColor: "#fff1c9", + pitGameMoveAnimateColor: "#f2b84b", + pitEmptyPitAnimateColor: "#ff8a73", + pitLastStoneInBankPitAnimateColor: "#f2b84b", + pitGetRivalStonePitAnimateColor: "#ff8a73", +}; + +const night: WebTheme = { + id: "gece", + name: "Gece", + className: "t-gece", + labelKey: "ThemeNight", + swatch: { board: "#1d2a4d", accent: "#e2b36a" }, + themePreviewColor: "#1d2a4d", + background: "#0c1220", + appBarBgColor: "#141c30", + textColor: "#241707", + textLightColor: "#eef1f8", + playerTurnColor: "#e2b36a", + boardColor: "#1d2a4d", + pitColor: "#0b1020", + pitSelectedColor: "#e2b36a", + stoneColor: "#dfe3ee", + stoneLightColor: "#ffffff", + pitGameMoveAnimateColor: "#e2b36a", + pitEmptyPitAnimateColor: "#ff8a7a", + pitLastStoneInBankPitAnimateColor: "#e2b36a", + pitGetRivalStonePitAnimateColor: "#ff8a7a", +}; + +const jade: WebTheme = { + id: "yesim", + name: "Yeşim", + className: "t-yesim", + labelKey: "ThemeJade", + swatch: { board: "#cfd2c3", accent: "#2a7650" }, + themePreviewColor: "#cfd2c3", + background: "#e9ebe3", + appBarBgColor: "#f7f8f3", + textColor: "#1d2721", + textLightColor: "#ffffff", + playerTurnColor: "#2a7650", + boardColor: "#cfd2c3", + pitColor: "#c3c7b6", + pitSelectedColor: "#2a7650", + stoneColor: "#3f9f6f", + stoneLightColor: "#e2ffee", + pitGameMoveAnimateColor: "#2a7650", + pitEmptyPitAnimateColor: "#b43a26", + pitLastStoneInBankPitAnimateColor: "#2a7650", + pitGetRivalStonePitAnimateColor: "#b43a26", +}; + +// The first one is the default. +export const webThemes: WebTheme[] = [walnut, night, jade]; + +export function getWebTheme(theme: Theme): WebTheme { + return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut; +} + +export function applyThemeClass(theme: Theme) { + const root = document.documentElement; + webThemes.forEach((webTheme) => root.classList.remove(webTheme.className)); + root.classList.add(getWebTheme(theme).className); +} diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 4d0e2fc..e79371d 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its checks passing and a commit. - [x] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath` with unit tests; new texts (en, tr). - [x] 2. **Backend**: `GET /games/popular?kind=finished`; test. -- [ ] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu, +- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu, action) on every page; restyle the lobby, privacy page, loading and connection panels. - [ ] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. - [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. -- 2.45.2 From e3e7916265e41a3e929226347f6897ab9e045492 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 09:37:50 +0300 Subject: [PATCH 39/82] feat(frontend): draw the new board Pits and stores are wells with scattered stones and count badges, the viewer's store on the right. Below 600 px the board stands upright. Playable pits preview their move on hover, animated pits are ringed and the pit of the last move has a dashed ring. --- .../src/components/board/BoardView.css | 187 ++++++++++++++++++ .../src/components/board/BoardView.tsx | 135 +++++++------ .../frontend/src/components/board/PitView.tsx | 132 ++++++------- .../src/components/board/StoneView.tsx | 20 -- apps/frontend/src/components/board/Stones.tsx | 37 ++++ .../src/components/board/StoreView.tsx | 71 ++----- .../src/components/history/ReplayView.tsx | 5 +- .../src/components/home/GamePreviewCard.tsx | 6 +- apps/frontend/src/routes/GamePage.tsx | 12 +- docs/specs/web-redesign/tasks.md | 2 +- 10 files changed, 387 insertions(+), 220 deletions(-) create mode 100644 apps/frontend/src/components/board/BoardView.css delete mode 100644 apps/frontend/src/components/board/StoneView.tsx create mode 100644 apps/frontend/src/components/board/Stones.tsx diff --git a/apps/frontend/src/components/board/BoardView.css b/apps/frontend/src/components/board/BoardView.css new file mode 100644 index 0000000..246ad18 --- /dev/null +++ b/apps/frontend/src/components/board/BoardView.css @@ -0,0 +1,187 @@ +/* The board. Sized by its container: pits are a ninth of the width, up to 124 px. Below 600 px the board stands + upright: the top player's store at the top, the viewer's at the bottom, the viewer's pits in the left column. */ + +.bwrap { container-type: inline-size; } + +.board { + --pit: min(124px, calc(100cqw / 9.7)); + --pw: var(--pit); + --ph: var(--pit); + --stone: calc(var(--ph) * .21); + display: grid; + grid-template-columns: calc(var(--pit) * 1.1) repeat(6, var(--pit)) calc(var(--pit) * 1.1); + grid-template-rows: var(--pit) var(--pit); + column-gap: calc(var(--pit) * .13); + row-gap: calc(var(--pit) * .26); + width: max-content; + margin: 0 auto; + padding: calc(var(--pit) * .22); + border-radius: calc(var(--pit) * .34); + background: var(--boardBg); + box-shadow: var(--boardShadow); + transition: background .3s; +} + +.pit { + position: relative; + grid-row: var(--hr); + grid-column: var(--hc); + width: var(--pw); + height: var(--ph); + border-radius: 50%; + border: none; + padding: 0; + margin: 0; + background: var(--pit); + box-shadow: var(--pitShadow); + font: inherit; + color: inherit; + cursor: default; + transition: transform .18s ease, box-shadow .18s ease; +} + +.pit.mine { cursor: pointer; } + +@media (hover: hover) { + .pit.mine:hover { transform: translateY(-2px); box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); } +} + +.pit:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } + +.pit.pv, .store.pv, .pit.sel { box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); } + +.pit.land, .store.land, .pit.drop, .store.drop { box-shadow: var(--pitShadow), 0 0 0 3px var(--accent), 0 0 22px var(--accentGlow); } + +.pit.cap, .store.cap { box-shadow: var(--pitShadow), 0 0 0 3px var(--live), 0 0 22px var(--live); } + +.pit.last::after { + content: ''; + position: absolute; + inset: -7px; + border-radius: 50%; + border: 2px dashed var(--lastRing); + pointer-events: none; +} + +.store { + position: relative; + grid-row: 1 / 3; + border-radius: 999px; + background: var(--pit); + box-shadow: var(--pitShadow); + transition: box-shadow .18s; +} + +.store.l { grid-column: 1; } + +.store.r { grid-column: 8; } + +.st { + position: absolute; + width: var(--stone); + height: var(--stone); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: var(--stoneShadow), inset 0 -2px 4px rgba(0, 0, 0, .25); + transition: left .3s ease, top .3s ease; + pointer-events: none; +} + +.st::after { + content: ''; + position: absolute; + left: 22%; + top: 15%; + width: 32%; + height: 20%; + border-radius: 50%; + background: rgba(255, 255, 255, .6); +} + +.badge { + position: absolute; + left: 50%; + transform: translateX(-50%); + min-width: 30px; + height: 22px; + padding: 0 8px; + border-radius: 11px; + background: var(--chip); + color: var(--chipInk); + font-weight: 700; + font-size: 13px; + line-height: 22px; + box-sizing: border-box; + z-index: 2; + box-shadow: 0 0 0 2px var(--rim); + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.badge.t { top: -11px; } + +.badge.b { bottom: -11px; } + +.sbadge { + position: absolute; + left: 50%; + bottom: -14px; + transform: translateX(-50%); + min-width: 44px; + height: 28px; + padding: 0 10px; + border-radius: 14px; + background: var(--chip); + color: var(--chipInk); + font-weight: 800; + font-size: 16px; + line-height: 28px; + box-sizing: border-box; + box-shadow: 0 0 0 2px var(--rim); + text-align: center; + z-index: 2; + font-variant-numeric: tabular-nums; +} + +.store.r .sbadge { background: var(--accent); color: var(--onAccent); } + +.pvchip { + position: absolute; + right: -6px; + top: -6px; + height: 22px; + min-width: 26px; + padding: 0 6px; + box-sizing: border-box; + border-radius: 11px; + background: var(--accent); + color: var(--onAccent); + font-weight: 800; + font-size: 12px; + line-height: 22px; + z-index: 3; + text-align: center; + pointer-events: none; +} + +@container (max-width: 600px) { + .board { + --pw: min(170px, calc((100cqw - 70px) / 2)); + --ph: clamp(52px, 15cqw, 66px); + --stone: calc(var(--ph) * .24); + grid-template-columns: var(--pw) var(--pw); + grid-template-rows: calc(var(--ph) * 1.05) repeat(6, var(--ph)) calc(var(--ph) * 1.05); + column-gap: 22px; + row-gap: 10px; + padding: 24px 22px; + border-radius: 32px; + } + .pit { grid-row: var(--vr); grid-column: var(--vc); } + .store.l, .store.r { grid-column: 1 / 3; } + .store.l { grid-row: 1; } + .store.r { grid-row: 8; } + .badge.t { top: 50%; left: auto; right: -13px; transform: translateY(-50%); } + .badge.b { top: 50%; bottom: auto; left: -13px; transform: translateY(-50%); } + .store.l .sbadge { bottom: auto; top: -14px; } +} diff --git a/apps/frontend/src/components/board/BoardView.tsx b/apps/frontend/src/components/board/BoardView.tsx index bd8566e..172f965 100644 --- a/apps/frontend/src/components/board/BoardView.tsx +++ b/apps/frontend/src/components/board/BoardView.tsx @@ -1,81 +1,88 @@ import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import { BoardViewModel, PitViewModel } from "@mancala/core"; -import PitView from "./PitView"; -import StoreView from "./StoreView"; -import { Game } from "@mancala/core"; +import { FunctionComponent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BoardViewModel, Game, getSowingPath } from "@mancala/core"; import { Pit } from "mancala.js"; +import { Context } from "../../context/context"; +import { getWebTheme } from "../../theme/themes"; +import PitView, { PitMarks } from "./PitView"; +import StoreView from "./StoreView"; +import "./BoardView.css"; +// The board as the bottom player sees it: their pits along the bottom from left to right, their store on the right. +// It fills its container's width. const BoardView: FunctionComponent<{ game: Game; context: Context; - boardId: string; boardViewModel: BoardViewModel; + // Show player 2 at the bottom. revert: boolean; - onPitSelect: (index: number, pit: Pit) => void; - // Width in px, e.g. for previews; by default the board is as wide as the viewport. - width?: number; -}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect, width }) => { - const mancalaGame = game?.mancalaGame; - const theme = context.themeManager.theme; + topName: string; + bottomName: string; + // Makes the bottom player's pits buttons. + onPitSelect?: (index: number, pit: Pit) => void; + // The bottom player can move now: their pits with stones are highlighted and preview their move. + canPlay?: boolean; + // Board index of the pit the last move was played from. + lastMoveIndex?: number; + // Board index where the shown move's last stone landed. + landIndex?: number; +}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => { + const { t } = useTranslation(); + const [previewIndex, setPreviewIndex] = useState(undefined); + const board = game.mancalaGame.board; + const themeId = getWebTheme(context.themeManager.theme).id; + const stones = boardViewModel.pits.map((pit) => pit.stoneCount); - // The board is 92 units wide (8 columns of 11 plus padding); all board parts are sized in units. - const unit = width !== undefined ? width / 92 : (typeof window !== "undefined" ? window.innerWidth / 100 : 10); - const pitWidth = unit * 11; - const sizeVariables = { - "--board-unit": width !== undefined ? `${unit}px` : "1vw", - "--board-spacing": width !== undefined ? `${unit * 0.3}px` : "5px", - } as React.CSSProperties; + // Pits the hovered move drops stones into, with how many each, and where its last stone lands. + const preview: Record = {}; + const path = canPlay && previewIndex !== undefined ? getSowingPath(stones, previewIndex) : []; + path.forEach((index) => (preview[index] = (preview[index] ?? 0) + 1)); + const previewLand = path.length > 0 ? path[path.length - 1] : undefined; - const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => ( - onPitSelect(pit.index, pit)} - fontSize={pitWidth / 6} - /> - ); + const marksOf = (index: number): PitMarks => ({ + highlight: boardViewModel.pits[index]?.highlight, + preview: preview[index], + land: previewLand !== undefined ? index === previewLand : index === landIndex, + last: index === lastMoveIndex, + }); - const createPitViewList = (pits: Pit[]) => - pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index])); + const topPits = revert ? board.player1Pits : board.player2Pits; + const bottomPits = revert ? board.player2Pits : board.player1Pits; + const [topStore, bottomStore] = revert + ? [board.player1BankIndex(), board.player2BankIndex()] + : [board.player2BankIndex(), board.player1BankIndex()]; - const player1Pits = createPitViewList(mancalaGame?.board.player1Pits); - const player2Pits = createPitViewList(mancalaGame?.board.player2Pits); - const player1BankIndex = mancalaGame?.board.player1BankIndex(); - const player2BankIndex = mancalaGame?.board.player2BankIndex(); - const player1BankViewModel = boardViewModel.pits[player1BankIndex]; - const player2BankViewModel = boardViewModel.pits[player2BankIndex]; + const pitView = (pit: Pit, position: number, top: boolean) => { + const stoneCount = stones[pit.index]; + const playable = !top && !!canPlay && stoneCount > 0; + const pitNumber = (top ? 5 - position : position) + 1; + return ( + onPitSelect(pit.index, board.pits[pit.index]) : undefined} + onPreview={(active) => setPreviewIndex(active ? pit.index : undefined)} /> + ); + }; return ( -
    - - - {revert ? player1Pits?.reverse() : player2Pits?.reverse()} - {revert ? player2Pits : player1Pits} - +
    +
    + + {[...topPits].reverse().map((pit, position) => pitView(pit, position, true))} + {bottomPits.map((pit, position) => pitView(pit, position, false))} + +
    ); }; diff --git a/apps/frontend/src/components/board/PitView.tsx b/apps/frontend/src/components/board/PitView.tsx index 1029084..a55d968 100644 --- a/apps/frontend/src/components/board/PitView.tsx +++ b/apps/frontend/src/components/board/PitView.tsx @@ -1,83 +1,71 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness, PitViewModel } from "@mancala/core"; -import { Context } from "../../context/context"; -import Util from "../../util/Util"; -import StoneView from "./StoneView"; +import { PitHighlight } from "@mancala/core"; +import Stones from "./Stones"; -interface PitViewProps { - pitViewModel: PitViewModel; - context: Context; - onClick: () => void; - fontSize: number; +export type PitMarks = { + highlight?: PitHighlight; + // Stones a hovered move would drop here. + preview?: number; + // The hovered move's last stone lands here, or the shown move's did. + land?: boolean; + // The last move was played from here. + last?: boolean; +}; + +export function getMarkClasses(marks: PitMarks): string { + const highlight = marks.highlight === "capture" ? " cap" : marks.highlight === "selected" ? " sel" + : marks.highlight ? " drop" : ""; + return highlight + (marks.preview ? " pv" : "") + (marks.land ? " land" : "") + (marks.last ? " last" : ""); } -const PitView: FunctionComponent = ({ pitViewModel, context, onClick, fontSize }) => { - const textColor = getColorByBrightness( - pitViewModel.pitColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor +// A pit in the top or bottom row. Position is its column from the left, 0-5. The viewer's pits are buttons. +const PitView: FunctionComponent<{ + index: number; + stoneCount: number; + top: boolean; + position: number; + themeId: string; + label: string; + marks: PitMarks; + // The viewer can play this pit now. + playable?: boolean; + // Set for pits the viewer can click. + onClick?: () => void; + // Called with true when a playable pit is hovered or focused, with false when that ends. + onPreview?: (active: boolean) => void; +}> = ({ index, stoneCount, top, position, themeId, label, marks, playable, onClick, onPreview }) => { + const className = "pit" + (playable ? " mine" : "") + getMarkClasses(marks); + const style = { + "--hr": top ? "1" : "2", + "--hc": String(position + 2), + "--vr": String(position + 2), + "--vc": top ? "2" : "1", + } as React.CSSProperties; + const content = ( + <> + + {stoneCount} + {!!marks.preview && {`+${marks.preview}`}} + ); - - const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( - - )); - + if (!onClick) { + return
    {content}
    ; + } return ( -
    - {stones} - {pitViewModel.stoneCount > 0 && ( - - {pitViewModel.stoneCount} - - )} - -
    + onMouseEnter={() => playable && onPreview?.(true)} + onMouseLeave={() => onPreview?.(false)} + onFocus={() => playable && onPreview?.(true)} + onBlur={() => onPreview?.(false)}> + {content} + ); }; -export default PitView; \ No newline at end of file +export default PitView; diff --git a/apps/frontend/src/components/board/StoneView.tsx b/apps/frontend/src/components/board/StoneView.tsx deleted file mode 100644 index 790546c..0000000 --- a/apps/frontend/src/components/board/StoneView.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; - -const StoneView: FunctionComponent<{ color: string }> = ({ color }) => { - return ( -
    - -
    - ); -}; - -export default StoneView; \ No newline at end of file diff --git a/apps/frontend/src/components/board/Stones.tsx b/apps/frontend/src/components/board/Stones.tsx new file mode 100644 index 0000000..51fbac5 --- /dev/null +++ b/apps/frontend/src/components/board/Stones.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; + +// Stone colours per theme: highlight, body and shadow of a radial gradient. +const PALETTES: Record = { + ceviz: [["#fff1c9", "#e39a32", "#7d4610"], ["#d4fff5", "#2fa597", "#0c4a42"], ["#fffaf0", "#e2d3b6", "#94815f"], ["#ffd0c7", "#c94a3b", "#621a12"], ["#e6ecff", "#6b84c9", "#263a73"]], + gece: [["#ffffff", "#dfe3ee", "#8a92ad"], ["#f3f6ff", "#8fa8ff", "#2b3f92"], ["#fff6e6", "#e8bd78", "#8b5d1f"], ["#ffeef6", "#e7a3c4", "#8a3c62"]], + yesim: [["#e2ffee", "#3f9f6f", "#134a2d"], ["#8d939a", "#2b2f35", "#08090b"], ["#ffffff", "#efe9df", "#a59a88"], ["#ffe8c7", "#d38a3a", "#6e3c0d"]], +}; + +// A repeatable pseudo random number in [0, 1). +function random(seed: number): number { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +// The stones of a pit or store, scattered around its centre on a sunflower spiral. The same count and seed always +// give the same picture, so stones only move when the count changes. +const Stones: FunctionComponent<{ count: number, seed: number, themeId: string }> = ({ count, seed, themeId }) => { + const palette = PALETTES[themeId] ?? PALETTES.ceviz; + const stones = []; + for (let i = 0; i < count; i++) { + const angle = i * 2.39996 + seed; + const distance = count === 1 ? 0 : Math.sqrt((i + 0.6) / count); + const x = 50 + Math.cos(angle) * distance * 33 + (random(seed + i) - 0.5) * 3; + const y = 50 + Math.sin(angle) * distance * 33 + (random(seed + i * 3) - 0.5) * 3; + const [light, body, dark] = palette[Math.floor(random(seed * 5 + i * 7.1) * palette.length)]; + stones.push(); + } + return <>{stones}; +}; + +export default Stones; diff --git a/apps/frontend/src/components/board/StoreView.tsx b/apps/frontend/src/components/board/StoreView.tsx index 358be1a..6d79b1a 100644 --- a/apps/frontend/src/components/board/StoreView.tsx +++ b/apps/frontend/src/components/board/StoreView.tsx @@ -1,59 +1,22 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import { getColorByBrightness } from "@mancala/core"; -import Util from "../../util/Util"; -import { PitViewModel } from "@mancala/core"; -import StoneView from "./StoneView"; +import { getMarkClasses, PitMarks } from "./PitView"; +import Stones from "./Stones"; +// A store: the top player's on the left ("l"), the viewer's on the right ("r"). const StoreView: FunctionComponent<{ - context: Context; - pitViewModel: PitViewModel; - gridColumn: string; - gridRow: string; -}> = ({ context, pitViewModel, gridColumn, gridRow }) => { - const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( - - )); - const textColor = getColorByBrightness( - pitViewModel.pitColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( -
    - {stones} - - {stones.length} - - -
    - ); -}; + index: number; + stoneCount: number; + side: "l" | "r"; + themeId: string; + label: string; + marks: PitMarks; +}> = ({ index, stoneCount, side, themeId, label, marks }) => ( +
    + + {stoneCount} + {!!marks.preview && {`+${marks.preview}`}} +
    +); -export default StoreView; \ No newline at end of file +export default StoreView; diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index 0a03491..530fc1d 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -185,10 +185,11 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga undefined} /> + topName={name(topPlayer)} + bottomName={name(bottomPlayer)} + lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
    {`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}
    diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx index fec02b6..3708e03 100644 --- a/apps/frontend/src/components/home/GamePreviewCard.tsx +++ b/apps/frontend/src/components/home/GamePreviewCard.tsx @@ -55,11 +55,10 @@ const GamePreviewCard: FunctionComponent<{ undefined} - width={PREVIEW_BOARD_WIDTH} /> + topName={name(topPlayer)} + bottomName={name(bottomPlayer)} />
    {lines.map((line, index) =>
    {line}
    )} -
    - ); -} - -export default Center; \ No newline at end of file diff --git a/apps/frontend/src/components/CircularPanel.tsx b/apps/frontend/src/components/CircularPanel.tsx deleted file mode 100644 index 2de9283..0000000 --- a/apps/frontend/src/components/CircularPanel.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const CircularPanel: FunctionComponent<{ - color: string; - style?: React.CSSProperties -}> = (props) => { - return ( -
    - - {props.children} -
    - ); -} - -export default CircularPanel; diff --git a/apps/frontend/src/components/GameClockView.tsx b/apps/frontend/src/components/GameClockView.tsx index 72660ff..e52bb2b 100644 --- a/apps/frontend/src/components/GameClockView.tsx +++ b/apps/frontend/src/components/GameClockView.tsx @@ -1,38 +1,16 @@ import * as React from 'react'; import { FunctionComponent } from 'react'; -import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core"; -import { Context } from '../context/context'; +import { CLOCK_LOW_TIME_MS, formatClock } from "@mancala/core"; -// Readable on every theme; the themes have no warning color of their own. -const LOW_TIME_COLOR = "#b3261e"; -const LOW_TIME_TEXT_COLOR = "#ffffff"; - -// One player's clock: highlighted while it runs, in a warning color when the running clock is low. +// One player's clock: highlighted while it runs, in a warning colour when the running clock is low. const GameClockView: FunctionComponent<{ - context: Context, timeMs: number, running: boolean, -}> = ({ context, timeMs, running }) => { - const theme = context.themeManager.theme; +}> = ({ timeMs, running }) => { const lowTime = running && timeMs < CLOCK_LOW_TIME_MS; - const background = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent"; - const color = lowTime ? LOW_TIME_TEXT_COLOR : running - ? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor) - : getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); return ( - + {formatClock(timeMs)} - ); } diff --git a/apps/frontend/src/components/InfoPanel.tsx b/apps/frontend/src/components/InfoPanel.tsx deleted file mode 100644 index ce68598..0000000 --- a/apps/frontend/src/components/InfoPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -import { Game, User } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import CircularPanel from "./CircularPanel"; -import { useTranslation } from "react-i18next"; - -function getInfoPanelTextByGameState(params: { - context: Context; - t: (text: string) => string; - game?: Game; - currentUser: User; - whitePlayer: User; - blackPlayer: User; - leftPlayer?: User; - isSpectator?: boolean; -}): string | undefined { - const { - context, - t, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - isSpectator - } = params; - - if (leftPlayer) { - return isSpectator ? `${leftPlayer.name} ${t("UserLeftTheGame")}` : - leftPlayer.id == currentUser.id ? t("YouLeftTheGame") : t("OpponentLeftTheGame"); - } - - const isGameEnded = game?.mancalaGame.state == "ended"; - if (isGameEnded) { - const wonPlayerID = game.mancalaGame.getWonPlayerId(); - let whoWon; - if (wonPlayerID) { - const wonPlayer = wonPlayerID == whitePlayer.id ? whitePlayer : blackPlayer; - whoWon = isSpectator ? `${wonPlayer.name} ${t("Won")}` : - game.mancalaGame.getWonPlayerId() === currentUser.id - ? t("YouWon") - : t("YouLost"); - } else { - whoWon = t("GameDraw"); - } - return t("GameEnded") + " " + whoWon; - } - - if (game) { - const playingPlayer = game.mancalaGame.checkIsPlayerTurn(whitePlayer.id) ? whitePlayer : blackPlayer; - return isSpectator ? `${playingPlayer.name} ${t("Playing")}` : game.mancalaGame.checkIsPlayerTurn(currentUser.id) - ? t("YourTurn") - : t("OpponentTurn"); - } - - return undefined; -} - -const InfoPanel: FunctionComponent<{ - context: Context; - game?: Game; - currentUser: User; - whitePlayer: User; - blackPlayer: User; - leftPlayer?: User; - style?: React.CSSProperties; - visible?: boolean; - isSpectator?: boolean; - // Replaces the text derived from the game, e.g. for games that ended by a timeout. - overrideText?: string; -}> = ({ - context, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - style, - visible, - isSpectator, - overrideText -}) => { - - const { t } = useTranslation(); - - if (visible === false) return <>; - const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false; - const containerColor = isUserTurn - ? context.themeManager.theme.playerTurnColor - : context.themeManager.theme.boardColor; - const textColor = getColorByBrightness( - containerColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const text = overrideText ?? getInfoPanelTextByGameState({ - context, - t, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - isSpectator - }); - if (text) { - return ( - -

    - {text} -

    -
    - ); - } else { - return (
    ) - } - }; - -export default InfoPanel; diff --git a/apps/frontend/src/components/Space.tsx b/apps/frontend/src/components/Space.tsx deleted file mode 100644 index c1dfc5f..0000000 --- a/apps/frontend/src/components/Space.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Space: FunctionComponent<{ width?: string, height?: string }> = ({ width, height }) => { - return ( -
    - ); -} - -export default Space; \ No newline at end of file diff --git a/apps/frontend/src/components/UserStatus.tsx b/apps/frontend/src/components/UserStatus.tsx deleted file mode 100644 index a9ddc98..0000000 --- a/apps/frontend/src/components/UserStatus.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; -import { Context } from '../context/context'; -import { User } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import Space from './Space'; -import { useTranslation } from 'react-i18next'; - -export type LayoutMode = "right" | "left"; - -const UserStatus: FunctionComponent<{ - context: Context, - user: User, - layoutMode: LayoutMode, - visible?: boolean, - style?: React.CSSProperties, - // Shown after the online status, e.g. the player's clock. - accessory?: React.ReactNode, -}> = ({ context, user, layoutMode, visible, style, accessory }) => { - if (visible === false) return <>; - - const { t } = useTranslation(); - - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.background, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( -
    - {user.isAnonymous ? t("Anonymous") : user.name} - -
    - {accessory && <>{accessory}} - -
    - ); -} - -export default UserStatus; \ No newline at end of file diff --git a/apps/frontend/src/components/board/BoardToolbar.tsx b/apps/frontend/src/components/board/BoardToolbar.tsx deleted file mode 100644 index f51a029..0000000 --- a/apps/frontend/src/components/board/BoardToolbar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const BoardToolbar: FunctionComponent<{ visible?: boolean, style?: React.CSSProperties }> = ({ children, visible, style }) => { - if(visible === false) return <>; - return ( -
    - {children} - -
    - ); -} - -export default BoardToolbar; \ No newline at end of file diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx new file mode 100644 index 0000000..cd01de7 --- /dev/null +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { CloseIcon } from "../Icons"; + +// Over the board when a game ends: the result, why it ended, the score, and where to go next. +const GameOverCard: FunctionComponent<{ + title: string, + reason?: string, + score: string, + onNewGame: () => void, + onClose: () => void, +}> = ({ title, reason, score, onNewGame, onClose }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + return ( +
    +
    + +
    {title}
    + {reason &&
    {reason}
    } +
    {score}
    +
    + + +
    +
    +
    + ); +}; + +export default GameOverCard; diff --git a/apps/frontend/src/components/game/PlayerCard.tsx b/apps/frontend/src/components/game/PlayerCard.tsx new file mode 100644 index 0000000..b0fcc95 --- /dev/null +++ b/apps/frontend/src/components/game/PlayerCard.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { getInitials } from "../../util/PlayerText"; + +// A player beside the board: avatar with initials and online status, name, a line of details and their clock. +// The player to move is ringed. Aligned to the right, everything is mirrored. +const PlayerCard: FunctionComponent<{ + name: string, + isGuest: boolean, + details: string, + active: boolean, + online?: boolean, + clock?: React.ReactNode, + alignRight?: boolean, +}> = ({ name, isGuest, details, active, online, clock, alignRight }) => { + const { t } = useTranslation(); + return ( +
    + +
    +
    {name}
    +
    + {details} + {online !== undefined && {` · ${online ? t("Online") : t("Offline")}`}} +
    +
    + {clock} +
    + ); +}; + +export default PlayerCard; diff --git a/apps/frontend/src/components/history/MoveList.css b/apps/frontend/src/components/history/MoveList.css new file mode 100644 index 0000000..448ec05 --- /dev/null +++ b/apps/frontend/src/components/history/MoveList.css @@ -0,0 +1,61 @@ +.moves { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; flex-shrink: 0; } + +.mhead { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 54px; + padding: 0 20px; + border: none; + background: transparent; + color: var(--ink); + font: inherit; + font-weight: 800; + font-size: 16px; + cursor: pointer; +} + +.mhead .muted { color: var(--muted); font-weight: 600; } +.mhead:focus-visible, .mrow:focus-visible { outline: 3px solid var(--accent); outline-offset: -3px; } + +.chev { flex: none; transition: transform .2s; } +.chev.closed { transform: rotate(-90deg); } + +.mlist { position: relative; max-height: 330px; overflow: auto; margin: 0; padding: 0 8px 8px; list-style: none; } + +.mrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 12px; + min-height: 44px; + padding: 8px 12px; + border-radius: 14px; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +@media (hover: hover) { .mrow:hover { background: var(--surface2); } } + +.mrow.on { background: var(--surface2); box-shadow: inset 3px 0 0 var(--accent); } +.mrow.on .tag { background: var(--surface); } +.mrow.on .tag.x { background: var(--accent); } +.mrow.on .tag.c { background: var(--ink); } + +.mn { width: 26px; color: var(--muted); font-variant-numeric: tabular-nums; } +.mdot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.mname { font-weight: 700; min-width: 120px; max-width: 14em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mpit { color: var(--muted); } +.mrow .tags { margin-left: auto; } + +@media (max-width: 640px) { + .mname { min-width: 0; } +} diff --git a/apps/frontend/src/components/history/MoveList.tsx b/apps/frontend/src/components/history/MoveList.tsx index ca0016a..5f2d3ed 100644 --- a/apps/frontend/src/components/history/MoveList.tsx +++ b/apps/frontend/src/components/history/MoveList.tsx @@ -1,26 +1,27 @@ import * as React from "react"; import { FunctionComponent, useEffect, useRef, useState } from "react"; -import { getColorByBrightness, PlayedMove } from "@mancala/core"; +import { describeMove, PlayedMove } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { Context } from "../../context/context"; -import { getMoveText } from "../../util/MoveText"; +import { ChevronIcon } from "../Icons"; +import MoveTags from "./MoveTags"; +import "./MoveList.css"; // The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start. const MoveList: FunctionComponent<{ - context: Context, moves: PlayedMove[], // Move whose position is shown. selectedMove: number, onSelect: (moveNumber: number) => void, playerName: (playerId: string) => string, - collapsedByDefault?: boolean, -}> = ({ context, moves, selectedMove, onSelect, playerName, collapsedByDefault }) => { + // Moves of this player get an accent dot, the others a muted one. + accentPlayerId?: string, + // Live games list the newest move first, replays the start first. + newestFirst?: boolean, +}> = ({ moves, selectedMove, onSelect, playerName, accentPlayerId, newestFirst }) => { const { t } = useTranslation(); - const [collapsed, setCollapsed] = useState(!!collapsedByDefault); + const [collapsed, setCollapsed] = useState(false); const selectedRef = useRef(null); const listRef = useRef(null); - const theme = context.themeManager.theme; - const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); // Keep the shown move visible by scrolling the list only, never the page. useEffect(() => { @@ -32,89 +33,36 @@ const MoveList: FunctionComponent<{ } }, [selectedMove, moves.length, collapsed]); - const entry = (moveNumber: number, label: React.ReactNode) => ( -
  • onSelect(moveNumber)}> - {label} -
  • - ); + const entry = (moveNumber: number) => { + const selected = moveNumber === selectedMove; + const move = moveNumber > 0 ? moves[moveNumber - 1] : undefined; + return ( +
  • + +
  • + ); + }; + + const moveNumbers = Array.from({ length: moves.length + 1 }, (_, i) => i); + if (newestFirst) moveNumbers.reverse(); return ( -
    - - {!collapsed && ( -
      - {entry(0, {t("Start")})} - {moves.map((move, i) => entry(i + 1, ( - <> - {i + 1}. - {playerName(move.playerId)} - {getMoveText(moves, i + 1, t)} - - )))} -
    - )} - -
    + {!collapsed &&
      {moveNumbers.map(entry)}
    } + ); }; diff --git a/apps/frontend/src/components/history/MoveTags.tsx b/apps/frontend/src/components/history/MoveTags.tsx new file mode 100644 index 0000000..fab3934 --- /dev/null +++ b/apps/frontend/src/components/history/MoveTags.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { describeMove, PlayedMove } from "@mancala/core"; +import { useTranslation } from "react-i18next"; + +// What happened in a move, as tags: extra turn, capture, stones added to the store, game over. +const MoveTags: FunctionComponent<{ moves: PlayedMove[], moveNumber: number }> = ({ moves, moveNumber }) => { + const { t } = useTranslation(); + const move = describeMove(moves, moveNumber); + return ( + + {move.extraTurn && {t("ExtraTurn")}} + {move.capture && {t("Capture")}} + {move.storeGain > 0 && {t("StoreGain", { count: move.storeGain })}} + {move.gameOver && {t("GameOver")}} + + ); +}; + +export default MoveTags; diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index 530fc1d..0885f72 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -151,7 +151,7 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga // Each player's clock after the shown move; games saved before the game clock have none. const clockTimes = getClockAtMove(record, moveNumber); const renderClock = (player: PlayerSummary) => clockTimes && ( - ); @@ -219,7 +219,6 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga void, -}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => { +}> = ({ moveNumber, isYourTurn, onBackToLive }) => { const { t } = useTranslation(); - const theme = context.themeManager.theme; - const background = isYourTurn ? theme.playerTurnColor : theme.boardColor; - const textColor = getColorByBrightness(background, theme.textColor, theme.textLightColor); return ( -
    +
    - {`⟲ ${t("ViewingMove", { number: moveNumber })}`} + {t("ViewingMove", { number: moveNumber })} {isYourTurn && {` · ${t("YourTurn")}`}} -
    ); }; diff --git a/apps/frontend/src/hooks/useWindowDimensions.ts b/apps/frontend/src/hooks/useWindowDimensions.ts deleted file mode 100644 index 21daa13..0000000 --- a/apps/frontend/src/hooks/useWindowDimensions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useState, useEffect } from 'react'; - -//https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs - -function getWindowDimensions() { - const { innerWidth: width, innerHeight: height } = window; - return { - width, - height - }; -} - -export default function useWindowDimensions() { - const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); - - useEffect(() => { - function handleResize() { - setWindowDimensions(getWindowDimensions()); - } - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - return windowDimensions; -} diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index efebe38..7f33d2e 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -4,22 +4,16 @@ import { FunctionComponent, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { v4 } from 'uuid'; import { PitAnimator } from '@mancala/core'; -import BoardToolbar from '../components/board/BoardToolbar'; import BoardView from '../components/board/BoardView'; -import InfoPanel from '../components/InfoPanel'; import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import AppHeader from '../components/headerbar/AppHeader'; import { LeaveIcon, PlusIcon } from '../components/Icons'; -import UserStatus from '../components/UserStatus'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core'; import { Context } from '../context/context'; -import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; -import { Theme } from '@mancala/core'; -import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core"; +import { Theme, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; -import Center from '../components/Center'; import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; @@ -32,6 +26,9 @@ import { getResultText } from '../util/GameResultText'; import ReplayView from '../components/history/ReplayView'; import GameClockView from '../components/GameClockView'; import useGameClock from '../hooks/useGameClock'; +import PlayerCard from '../components/game/PlayerCard'; +import GameOverCard from '../components/game/GameOverCard'; +import '../styles/game.css'; const GamePage: FunctionComponent<{ context: Context, @@ -65,7 +62,8 @@ const GamePage: FunctionComponent<{ // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); - const { height, width } = useWindowDimensions(); + // Set when the player closes the game over card to look at the board. + const [gameOverClosed, setGameOverClosed] = useState(false); const navigate = useNavigate(); @@ -265,13 +263,6 @@ const GamePage: FunctionComponent<{ }; }, []); - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const isMobile = width < 600; - const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; @@ -292,86 +283,133 @@ const GamePage: FunctionComponent<{ .find((connectionInfo) => connectionInfo?.userId === userId); return { userId, name: info?.name, isGuest: info?.isGuest ?? true }; }; - // A game that ended by a timeout or inactivity is not over by the rules, so say why it ended. - const endText = gameEnd && (gameEnd.endReason === "timeout" || gameEnd.endReason === "inactive") && mancalaGame - ? `${t("GameEnded")} · ${getResultText({ - result: gameEnd.result, endReason: gameEnd.endReason, - player1: playerInfo(mancalaGame.player1Id), - player2: playerInfo(mancalaGame.player2Id), - } as FinishedGameSummary, isSpectator ? undefined : userKey, t)}` - : undefined; + + // How the game ended, as a saved game would say it; from the server's message when it sent one. + const getEndSummary = (): FinishedGameSummary | undefined => { + if (!mancalaGame || !isGameOver) return undefined; + const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id); + const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2"; + if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary; + if (userKeyWhoLeave) { + const winner = userKeyWhoLeave === mancalaGame.player1Id ? "player2" : "player1"; + return { result: winner, endReason: "left", leftUserId: userKeyWhoLeave, player1, player2 } as FinishedGameSummary; + } + const winnerId = mancalaGame.getWonPlayerId(); + return { result: winnerId ? seatOf(winnerId) : "draw", endReason: "completed", player1, player2 } as FinishedGameSummary; + }; + const endSummary = getEndSummary(); + // "You won · Opponent left" becomes the title "You won" and the reason "Opponent left". + const [resultTitle, ...resultReasons] = endSummary + ? getResultText(endSummary, isSpectator ? undefined : userKey, t).split(" · ") + : []; + const leftUserId = userKeyWhoLeave ?? gameEnd?.leftUserId; + const resultReason = resultReasons.join(" · ") + || (isSpectator && leftUserId ? `${getPlayerName(leftUserId) ?? t("Anonymous")} ${t("UserLeftTheGame")}` : undefined); const clockTimes = useGameClock(game, gameEnd); const renderClock = (playerId: string) => { const seat = mancalaGame && getSeat(mancalaGame, playerId); if (!clockTimes || !seat) return undefined; const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms; - return ; + return ; }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; - const topLocatedUserName = getPlayerName(topLocatedUserId); - const bottomLocatedUserName = getPlayerName(bottomLocatedUserId); const topLocatedUser = { id: topLocatedUserId, - name: topLocatedUserName ?? "Anonymous", + name: getPlayerName(topLocatedUserId) ?? t("Anonymous"), + isGuest: playerInfo(topLocatedUserId).isGuest, isOnline: isUserOnline(topLocatedUserId), - isAnonymous: !topLocatedUserName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: bottomLocatedUserName ?? "Anonymous", + name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"), + isGuest: playerInfo(bottomLocatedUserId).isGuest, isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: !bottomLocatedUserName }; - const currentUser = isSpectator ? { - id: "2", - name: "Anonymous", - isOnline: context.rtmt.connectionState === "connected", - isAnonymous: true - } : bottomLocatedUser; - const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined; if (replayRecord) { return ; } + const getTurnText = () => { + if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded"); + if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`; + return isYourTurn ? t("YourTurn") : t("OpponentTurn"); + }; + + const renderGame = () => { + const board = mancalaGame!.board; + const [topStore, bottomStore] = isPlayer2 + ? [board.player1BankIndex(), board.player2BankIndex()] + : [board.player2BankIndex(), board.player1BankIndex()]; + const shownStones = (index: number) => shownBoardViewModel!.pits[index].stoneCount; + const playerCard = (user: typeof topLocatedUser, storeIndex: number, role: string, alignRight: boolean) => ( + + ); + return ( +
    +
    {playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}
    +
    +
    + {!isGameOver && } + {getTurnText()} +
    +
    +
    {playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}
    +
    + {isReviewing && ( + setViewedMove(undefined)} /> + )} + + {isGameOver && !gameOverClosed && ( + setGameOverClosed(true)} /> + )} +
    +
    + getPlayerName(playerId) ?? t("Anonymous")} + accentPlayerId={bottomLocatedUserId} + newestFirst /> +
    +
    + ); + }; + return ( {renderHeaderBar()} - {renderMobileBoardToolbar()} - {buildBoardTopToolbar()} - {showBoardView && isReviewing && ( - setViewedMove(undefined)} /> - )} - {showBoardView && ( - - )} - {showBoardView && ( - getPlayerName(playerId) ?? t("Anonymous")} - collapsedByDefault={isMobile} /> - )} -
    +
    + {showBoardView && renderGame()} -
    +
    ); @@ -386,46 +424,6 @@ const GamePage: FunctionComponent<{ } ; } - - function renderMobileBoardToolbar() { - return - {buildInfoPanel({ visible: isMobile })} - ; - } - - function buildBoardTopToolbar() { - return - - {buildInfoPanel({ visible: !isMobile })} - - ; - } - - function buildInfoPanel(params: { visible: boolean }) { - return ( - - ); - } } export default GamePage; - diff --git a/apps/frontend/src/styles/game.css b/apps/frontend/src/styles/game.css new file mode 100644 index 0000000..208b706 --- /dev/null +++ b/apps/frontend/src/styles/game.css @@ -0,0 +1,174 @@ +/* Layout of the game and replay pages: the top player on the left, the turn in the middle, the bottom player on the + right, then the board and what is below it. On phones the bottom player moves below the board. */ + +.gl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-areas: "opp turn me" "board board board" "moves moves moves"; + gap: 26px 16px; + align-items: center; +} + +.a-opp { grid-area: opp; min-width: 0; } +.a-turn { grid-area: turn; min-width: 0; } +.a-me { grid-area: me; justify-self: end; min-width: 0; } +.a-board { grid-area: board; position: relative; min-width: 0; } +.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; } + +/* Players */ + +.pl { display: flex; align-items: center; gap: 12px; min-width: 0; } +.pl.r { flex-direction: row-reverse; text-align: right; } + +.av { + position: relative; + width: 44px; + height: 44px; + flex: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 15px; + background: var(--chip); + color: var(--chipInk); + box-shadow: 0 0 0 2px var(--rim); + transition: box-shadow .25s; +} + +.pl.on .av { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); } + +.presence { + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + box-sizing: border-box; + background: var(--bg); + border: 2px solid var(--muted); +} + +.presence.online { background: var(--accent); border-color: var(--bg); } + +.pinfo { min-width: 0; } +.pname { font-weight: 700; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.psub { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.clock { + flex: none; + min-width: 56px; + height: 32px; + padding: 0 10px; + box-sizing: border-box; + border-radius: 16px; + font-weight: 800; + font-size: 15px; + line-height: 32px; + text-align: center; + font-variant-numeric: tabular-nums; + background: var(--surface); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--line); + transition: background .25s, color .25s; +} + +.clock.run { background: var(--accent); color: var(--onAccent); box-shadow: none; } +.clock.low { background: var(--live); color: #1a0d08; box-shadow: none; } + +/* Turn */ + +.turn { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 18px; + box-sizing: border-box; + border-radius: 20px; + font-weight: 800; + font-size: 15px; + background: var(--surface); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--line); + white-space: nowrap; + font-variant-numeric: tabular-nums; + transition: background .25s, color .25s; +} + +.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; } + +/* Review banner, above the board */ + +.review { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 760px; + margin: 0 auto 22px; + padding: 6px 6px 6px 18px; + border-radius: 28px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--accentSoft); + font-size: 14px; + font-weight: 600; +} + +.review strong { color: var(--accent); } + +/* Game over card, over the board */ + +.over { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + border-radius: 32px; + background: rgba(0, 0, 0, .38); + backdrop-filter: blur(3px); +} + +.ocard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: calc(100% - 32px); + padding: 28px 32px; + box-sizing: border-box; + border-radius: 28px; + background: var(--surface); + box-shadow: 0 20px 50px rgba(0, 0, 0, .35); + text-align: center; +} + +.ocard .close { position: absolute; top: 8px; right: 8px; } +.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; } +.oreason { font-size: 15px; font-weight: 700; color: var(--muted); } +.oscore { font-size: 18px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 8px; } +.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } + +/* Tags of a move: extra turn, capture, stones to the store */ + +.tags { display: flex; gap: 6px; flex-wrap: wrap; } +.tag { height: 24px; padding: 0 9px; border-radius: 12px; font-size: 12px; font-weight: 700; line-height: 24px; background: var(--surface2); color: var(--ink); white-space: nowrap; } +.tag.x { background: var(--accent); color: var(--onAccent); } +.tag.c { background: var(--ink); color: var(--bg); } + +@media (max-width: 640px) { + .gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; } + .a-me { justify-self: start; } + .pl.r { flex-direction: row; text-align: left; } + .pl { gap: 8px; } + .clock { min-width: 50px; padding: 0 8px; font-size: 14px; } + .turn { min-height: 36px; padding: 6px 14px; font-size: 14px; white-space: normal; text-align: center; } + .review { margin-bottom: 16px; } + .ocard { padding: 26px 20px 20px; } + .otitle { font-size: 26px; } +} diff --git a/apps/frontend/src/util/PlayerText.ts b/apps/frontend/src/util/PlayerText.ts new file mode 100644 index 0000000..d696a30 --- /dev/null +++ b/apps/frontend/src/util/PlayerText.ts @@ -0,0 +1,7 @@ +// Up to two characters for an avatar: a guest's code ("Guest 4F2A" → "4F"), otherwise the first letter. +export function getInitials(name: string | undefined, isGuest: boolean): string { + const trimmed = name?.trim(); + if (!trimmed) return "?"; + const words = trimmed.split(/\s+/); + return isGuest && words.length > 1 ? words[words.length - 1].slice(0, 2).toUpperCase() : trimmed[0].toUpperCase(); +} diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index e83df0f..32734f4 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its checks passing and a commit. - [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu, action) on every page; restyle the lobby, privacy page, loading and connection panels. - [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. -- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. +- [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. - [ ] 6. **Web app replay page**: summary card, controls card, move list. - [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. - [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; -- 2.45.2 From ce27c711765f340a26b6dbf912f20979ec39268b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:19:57 +0300 Subject: [PATCH 41/82] feat(frontend): lay out the replay page in the new design A summary card with the result, date, duration, move count, players and score; players and the move number beside the board; a controls card with the transport buttons, speed, timeline and the shown move with its tags; the landing pit of the shown move is ringed. --- .../src/components/history/ReplayView.css | 32 +++ .../src/components/history/ReplayView.tsx | 265 ++++++++---------- apps/frontend/src/util/GameResultText.ts | 7 + apps/frontend/src/util/MoveText.ts | 15 - docs/specs/web-redesign/tasks.md | 2 +- 5 files changed, 150 insertions(+), 171 deletions(-) create mode 100644 apps/frontend/src/components/history/ReplayView.css delete mode 100644 apps/frontend/src/util/MoveText.ts diff --git a/apps/frontend/src/components/history/ReplayView.css b/apps/frontend/src/components/history/ReplayView.css new file mode 100644 index 0000000..b8d8ffa --- /dev/null +++ b/apps/frontend/src/components/history/ReplayView.css @@ -0,0 +1,32 @@ +.sum { display: flex; align-items: center; gap: 28px; padding: 22px 26px; border-radius: 28px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } +.sum-info { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.sum-info .pill { align-self: flex-start; } +.sum-meta { font-size: 14px; color: var(--muted); } +.sum-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; flex: 1; min-width: 0; } +.sum-vs .pn { font-weight: 700; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sum-vs .pn.r { text-align: right; } +.big { font-weight: 800; font-size: 34px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; } + +.ctl { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } +.ctlrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.tbtns { display: flex; align-items: center; gap: 8px; } +.tb { width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--surface2); color: var(--ink); display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } +.tb.big { width: 54px; height: 54px; background: var(--accent); color: var(--onAccent); } +.tb:disabled { opacity: .35; cursor: default; } +.speed { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); } +.segs { display: flex; gap: 2px; padding: 3px; border-radius: 24px; background: var(--surface2); } +.seg { min-width: 46px; height: 38px; border: none; border-radius: 19px; background: transparent; color: var(--muted); font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; } +.seg.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); } +.range { width: 100%; height: 28px; margin: 0; accent-color: var(--accent); cursor: pointer; } +.desc { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; color: var(--muted); } +.desc b { color: var(--ink); } +.tb:focus-visible, .seg:focus-visible, .range:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } + +@media (max-width: 640px) { + .sum { flex-direction: column; align-items: stretch; gap: 16px; padding: 18px; border-radius: 24px; } + .sum .btn { width: 100%; } + .big { font-size: 28px; } + .sum-vs .pn { font-size: 15px; } + .ctlrow { justify-content: center; } + .tbtns { width: 100%; justify-content: space-between; } +} diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index 0885f72..61e0973 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -7,28 +7,31 @@ import { DEFAULT_ANIMATION_INTERVAL, GameRecord, getBoardAfterMove, + describeMove, getClockAtMove, - getColorByBrightness, getPlayerDisplayName, PitAnimator, PlayerSummary, Theme, } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { v4 } from "uuid"; import { Context } from "../../context/context"; import useMoveKeys from "../../hooks/useMoveKeys"; import { createBoardViewModel } from "../../util/BoardViewModels"; -import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText"; -import { getMoveText } from "../../util/MoveText"; +import { formatDate, formatDuration, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText"; import BoardView from "../board/BoardView"; -import Button from "../Button"; import PageContainer from "../PageContainer"; import AppHeader from "../headerbar/AppHeader"; -import { PlusIcon } from "../Icons"; +import { FirstIcon, LastIcon, NextIcon, PauseIcon, PlayIcon, PlusIcon, PreviousIcon, ReplayIcon } from "../Icons"; +import PlayerCard from "../game/PlayerCard"; import GameClockView from "../GameClockView"; import MoveList from "./MoveList"; +import MoveTags from "./MoveTags"; +import { GAME_STEP_GAME_MOVE } from "mancala.js"; +import "../../styles/game.css"; +import "./ReplayView.css"; const SPEEDS = [1, 2, 4]; // Extra pause after a move's animation while playing, at 1x. @@ -137,29 +140,42 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga togglePlay, }); - const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); - const textColorOnAppBar = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); - const panelTextColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor); const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); const playerName = (playerId: string) => name(playerId === record.player1.userId ? record.player1 : record.player2); const stones = getBoardAfterMove(moves, moveNumber); const [topPlayer, topStore, bottomPlayer, bottomStore] = revert - ? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]] - : [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]]; + ? [record.player1, PLAYER1_STORE, record.player2, PLAYER2_STORE] + : [record.player2, PLAYER2_STORE, record.player1, PLAYER1_STORE]; const atEnd = moveNumber >= total; + const shownMove = moveNumber > 0 ? moves[moveNumber - 1] : undefined; + const nextMove = moves[moveNumber]; + // Where the shown move's last stone landed; not while a move is being animated. + const isAnimating = boardViewModel.pits.some((pit) => pit.highlight); + const sownSteps = shownMove?.gameSteps.filter((step) => step.type === GAME_STEP_GAME_MOVE) ?? []; + const landIndex = !isAnimating && sownSteps.length > 0 + ? sownSteps[sownSteps.length - 1].index % stones.length : undefined; // Each player's clock after the shown move; games saved before the game clock have none. const clockTimes = getClockAtMove(record, moveNumber); const renderClock = (player: PlayerSummary) => clockTimes && ( ); + const playerCard = (player: PlayerSummary, storeIndex: number, alignRight: boolean) => ( + + ); - const controlButton = (label: string, title: string, onClick: () => void, disabled = false) => ( - + const controlButton = (label: string, icon: React.ReactNode, onClick: () => void, disabled = false) => ( + ); return ( @@ -170,146 +186,85 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga -
    -
    -
    {`${getResultText(record, viewerId, t)} · ${getScoreText(record, viewerId)}`}
    -
    {`${formatDate(record.endedAt, i18n.language)} · ${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`}
    +
    +
    +
    + {getResultText(record, viewerId, t)} +
    + {[formatDate(record.endedAt, i18n.language), + `${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`, + t("MoveCount", { count: total })].join(" · ")} +
    +
    +
    + {name(topPlayer)} + {`${record.board[topStore]} – ${record.board[bottomStore]}`} + {name(bottomPlayer)} +
    + {total > 0 && ( + + )} +
    + +
    +
    {playerCard(topPlayer, topStore, false)}
    +
    {t("MoveOf", { number: moveNumber, total })}
    +
    {playerCard(bottomPlayer, bottomStore, true)}
    +
    + +
    +
    +
    +
    +
    + {controlButton(t("FirstMove"), , () => jumpTo(0), moveNumber === 0)} + {controlButton(t("PreviousMove"), , () => jumpTo(moveNumber - 1), moveNumber === 0)} + + {controlButton(t("NextMove"), , () => { setPlaying(false); animateNextMove(); }, atEnd)} + {controlButton(t("LastMove"), , () => jumpTo(total), atEnd)} +
    +
    + {t("Speed")} +
    + {SPEEDS.map((s) => ( + + ))} +
    +
    +
    + jumpTo(Number(event.target.value))} /> +
    + {moveNumber === 0 ? t("Start") : t("MoveNumber", { number: moveNumber })} + {shownMove + ? `${playerName(shownMove.playerId)} · ${t("PitNumber", { pit: describeMove(moves, moveNumber).pit })}` + : t("StartPosition")} + {shownMove && } +
    +
    + + +
    - {atEnd && !playing && total > 0 && ( -
    - -
    {`${name(topPlayer)} · ${topStore}`}{renderClock(topPlayer)}
    - -
    {`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}
    - -
    -
    - {controlButton("⏮", t("Start"), () => jumpTo(0), moveNumber === 0)} - {controlButton("◀", "←", () => jumpTo(moveNumber - 1), moveNumber === 0)} - {controlButton(playing ? "⏸" : "▶", playing ? "Pause" : "Play", togglePlay, total === 0)} - {controlButton("▶|", "→", () => { setPlaying(false); animateNextMove(); }, atEnd)} - {controlButton("⏭", t("GameOver"), () => jumpTo(total), atEnd)} - - {`${t("Speed")}:`} - {SPEEDS.map((s) => ( - - ))} - -
    -
    - {t("MoveOf", { number: moveNumber, total })} - - {moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`} - -
    - jumpTo(Number(event.target.value))} /> -
    - - - - + ); }; diff --git a/apps/frontend/src/util/GameResultText.ts b/apps/frontend/src/util/GameResultText.ts index a1d1607..c843327 100644 --- a/apps/frontend/src/util/GameResultText.ts +++ b/apps/frontend/src/util/GameResultText.ts @@ -20,6 +20,13 @@ export function getResultText(game: FinishedGameSummary, viewerId: string | unde return outcome; } +// Class of the result's pill: "win" when someone won (and the viewer did not lose), "lose" when the viewer lost. +export function getResultPillClass(game: FinishedGameSummary, viewerId: string | undefined): string { + if (game.result === null || game.result === "draw") return ""; + const viewerSeat = getViewerSeat(game, viewerId); + return viewerSeat && viewerSeat !== game.result ? "lose" : "win"; +} + // Final stones in the stores, the viewer's first when they played. export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string { const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE]; diff --git a/apps/frontend/src/util/MoveText.ts b/apps/frontend/src/util/MoveText.ts deleted file mode 100644 index 7c97ec8..0000000 --- a/apps/frontend/src/util/MoveText.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describeMove, PlayedMove } from "@mancala/core"; - -type Translate = (key: string, options?: Record) => string; - -// "Pit 3 · ★ Extra turn · +1 to store" -export function getMoveText(moves: PlayedMove[], moveNumber: number, t: Translate): string { - const move = describeMove(moves, moveNumber); - const events = [ - move.extraTurn ? `★ ${t("ExtraTurn")}` : undefined, - move.capture ? `✂ ${t("Capture")}` : undefined, - move.storeGain > 0 ? t("StoreGain", { count: move.storeGain }) : undefined, - move.gameOver ? `⚑ ${t("GameOver")}` : undefined, - ].filter(Boolean); - return [t("PitNumber", { pit: move.pit }), ...events].join(" · "); -} diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 32734f4..7e39c7f 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -9,7 +9,7 @@ Work through the tasks in order; each ends with its checks passing and a commit. action) on every page; restyle the lobby, privacy page, loading and connection panels. - [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. - [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. -- [ ] 6. **Web app replay page**: summary card, controls card, move list. +- [x] 6. **Web app replay page**: summary card, controls card, move list. - [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. - [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; `AGENTS.md` and `TODO.md`. -- 2.45.2 From 961982560547ab0d82e9ac6ba53ad812bec5e1aa Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:21:46 +0300 Subject: [PATCH 42/82] feat(frontend): lay out the home page in the new design The game in progress comes first with a large mini board; live, popular and recent games are grids of cards with the score, a mini board and a status pill. Live and popular games are shown together; live games are hidden while nothing is being played. --- apps/frontend/src/components/Button.tsx | 41 ------- .../src/components/home/ActiveGameBanner.tsx | 54 ++++----- .../frontend/src/components/home/GameCard.tsx | 42 +++++++ .../src/components/home/GamePreviewCard.tsx | 105 ------------------ apps/frontend/src/components/home/Home.css | 70 ++++++++++++ .../src/components/home/HomeSection.tsx | 42 +------ .../src/components/home/LiveGamesSection.tsx | 62 ++--------- .../src/components/home/MiniBoard.tsx | 29 +++++ .../components/home/PopularGamesSection.tsx | 43 +++++++ .../components/home/RecentGamesSection.tsx | 19 ++-- apps/frontend/src/routes/Home.tsx | 22 ++-- apps/frontend/src/store/GameStore.ts | 10 +- docs/specs/web-redesign/tasks.md | 2 +- 13 files changed, 250 insertions(+), 291 deletions(-) delete mode 100644 apps/frontend/src/components/Button.tsx create mode 100644 apps/frontend/src/components/home/GameCard.tsx delete mode 100644 apps/frontend/src/components/home/GamePreviewCard.tsx create mode 100644 apps/frontend/src/components/home/Home.css create mode 100644 apps/frontend/src/components/home/MiniBoard.tsx create mode 100644 apps/frontend/src/components/home/PopularGamesSection.tsx diff --git a/apps/frontend/src/components/Button.tsx b/apps/frontend/src/components/Button.tsx deleted file mode 100644 index 105917e..0000000 --- a/apps/frontend/src/components/Button.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -import { getColorByBrightness } from "@mancala/core"; - -const Button: FunctionComponent<{ - context: Context; - text: String; - onClick: () => void; - color: string; -}> = ({ context, text, color, onClick }) => { - const textColor = getColorByBrightness( - color, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( - - ); -}; - -export default Button; diff --git a/apps/frontend/src/components/home/ActiveGameBanner.tsx b/apps/frontend/src/components/home/ActiveGameBanner.tsx index e8d0b78..8b6219c 100644 --- a/apps/frontend/src/components/home/ActiveGameBanner.tsx +++ b/apps/frontend/src/components/home/ActiveGameBanner.tsx @@ -1,49 +1,39 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness, getPlayerDisplayName, LiveGameSummary } from "@mancala/core"; +import { getPlayerDisplayName, LiveGameSummary } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { Context } from "../../context/context"; -import Button from "../Button"; +import { Link } from "react-router-dom"; +import MiniBoard, { getStores } from "./MiniBoard"; // Shown at the top of the home page while the user has a game in progress. const ActiveGameBanner: FunctionComponent<{ - context: Context, game: LiveGameSummary, userId: string, onContinue: () => void, -}> = ({ context, game, userId, onContinue }) => { +}> = ({ game, userId, onContinue }) => { const { t } = useTranslation(); - const theme = context.themeManager.theme; - const textColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor); const opponent = game.player1.userId === userId ? game.player2 : game.player1; - const turn = game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn"); + const revert = game.player2.userId === userId; + const stores = getStores(game.board, revert); + const details = [ + game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn"), + t("MoveNumber", { number: game.moveCount }), + t("StoreScores", { mine: stores.bottom, theirs: stores.top }), + ].join(" · "); return ( -
    +
    -
    {t("GameInProgress")}
    -
    {`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}
    + {t("GameInProgress")} +

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

    +

    {details}

    +
    + +
    -
    + + + + ); }; diff --git a/apps/frontend/src/components/home/GameCard.tsx b/apps/frontend/src/components/home/GameCard.tsx new file mode 100644 index 0000000..ce4e445 --- /dev/null +++ b/apps/frontend/src/components/home/GameCard.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getPlayerDisplayName, PlayerSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import MiniBoard, { getStores } from "./MiniBoard"; + +// A game as a card: the players with the score, a mini board, a status pill and details. Links to the game. +const GameCard: FunctionComponent<{ + id: string, + player1: PlayerSummary, + player2: PlayerSummary, + board: number[], + // Show player 2's side at the bottom. + revert?: boolean, + status: string, + // Class of the status pill: "live", "win", "lose" or "". + statusKind: string, + details: string, +}> = ({ id, player1, player2, board, revert, status, statusKind, details }) => { + const { t } = useTranslation(); + const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); + const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2]; + const stores = getStores(board, revert); + return ( + +
    + {name(topPlayer)} + {`${stores.top} – ${stores.bottom}`} + {name(bottomPlayer)} +
    + +
    + {status} + {details} +
    + + ); +}; + +export default GameCard; diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx deleted file mode 100644 index 3708e03..0000000 --- a/apps/frontend/src/components/home/GamePreviewCard.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { FunctionComponent, useMemo } from "react"; -import { - BoardViewModelFactory, - createGameFromBoard, - getColorByBrightness, - getPlayerDisplayName, - PitViewModelFactory, - PlayerSummary, -} from "@mancala/core"; -import { useTranslation } from "react-i18next"; -import { Context } from "../../context/context"; -import BoardView from "../board/BoardView"; - -export const PREVIEW_BOARD_WIDTH = 260; - -// A game as a card: the players, the real board at preview size, and a few lines of details. -const GamePreviewCard: FunctionComponent<{ - context: Context, - id: string, - player1: PlayerSummary, - player2: PlayerSummary, - board: number[], - // Highlighted as the player to move. - turnPlayerId?: string, - // Show player 2's side at the bottom. - revert?: boolean, - lines: string[], - onClick: () => void, -}> = ({ context, id, player1, player2, board, turnPlayerId, revert, lines, onClick }) => { - const { t } = useTranslation(); - const theme = context.themeManager.theme; - const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); - - const game = useMemo(() => createGameFromBoard({ - id, player1Id: player1.userId, player2Id: player2.userId, turnPlayerId: turnPlayerId ?? player1.userId, board, - }), [id, player1.userId, player2.userId, turnPlayerId, board.join(",")]); - const boardViewModel = BoardViewModelFactory.create(id, board.map((stoneCount, index) => PitViewModelFactory.create({ - id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor, - }))); - - const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); - const nameStyle = (player: PlayerSummary): React.CSSProperties => - ({ fontWeight: player.userId === turnPlayerId ? "bold" : "normal" }); - const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2]; - - return ( -
    -
    - {name(topPlayer)} - {t("Versus")} - {name(bottomPlayer)} -
    -
    - -
    - {lines.map((line, index) =>
    {line}
    )} - -
    - ); -}; - -export default GamePreviewCard; diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css new file mode 100644 index 0000000..74fc073 --- /dev/null +++ b/apps/frontend/src/components/home/Home.css @@ -0,0 +1,70 @@ +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); + gap: 40px; + align-items: center; + padding: 32px; + border-radius: 32px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); +} + +.hero h1 { margin: 14px 0 8px; font-size: 34px; line-height: 1.1; letter-spacing: -.02em; } +.hero p { margin: 0 0 22px; color: var(--muted); font-size: 16px; } +.hero .row { display: flex; gap: 10px; flex-wrap: wrap; } +.hero-board { display: block; text-decoration: none; border-radius: 26px; } +.hero-board:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } + +.sec { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 18px 0 -10px; } +.sec h2 { margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -.01em; } +.secmeta { font-size: 14px; color: var(--muted); } +.empty { margin: 0; color: var(--muted); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } + +.card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + border-radius: 24px; + background: var(--surface); + color: var(--ink); + text-decoration: none; + box-shadow: inset 0 0 0 1px var(--line); + transition: transform .2s, box-shadow .2s; +} + +@media (hover: hover) { + .card:hover { transform: translateY(-3px); box-shadow: inset 0 0 0 1px var(--accentSoft), 0 16px 30px rgba(0, 0, 0, .18); } +} + +.vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 10px; font-weight: 700; font-size: 15px; } +.pn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pn.r { text-align: right; } +.score { font-weight: 800; font-size: 17px; font-variant-numeric: tabular-nums; padding: 2px 10px; border-radius: 10px; background: var(--surface2); white-space: nowrap; } +.meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); min-width: 0; } +.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.meta > span:last-child { text-align: right; } + +/* Mini board: the top player's store on the left, the bottom player's on the right */ + +.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } +.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms.l { grid-column: 1; } +.mini .ms.r { grid-column: 8; color: var(--accent); } +.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } +.mini.lg .mp { font-size: 16px; } +.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } + +@media (max-width: 640px) { + .hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; } + .hero h1 { font-size: 25px; } + .hero .btn { flex: 1; } + .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } + .mini.lg .mp { font-size: 13px; } + .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } + .grid { grid-template-columns: 1fr; gap: 14px; } + .sec { margin: 12px 0 -4px; } +} diff --git a/apps/frontend/src/components/home/HomeSection.tsx b/apps/frontend/src/components/home/HomeSection.tsx index 6f7a7a2..778f3f9 100644 --- a/apps/frontend/src/components/home/HomeSection.tsx +++ b/apps/frontend/src/components/home/HomeSection.tsx @@ -1,44 +1,14 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness } from "@mancala/core"; -import { Context } from "../../context/context"; -// A titled block of the home page; children are laid out as wrapping cards. -const HomeSection: FunctionComponent<{ context: Context, title: string, emptyText?: string }> = (props) => { - const theme = props.context.themeManager.theme; - const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); +// A titled block of the home page; children are laid out as a grid of cards. +const HomeSection: FunctionComponent<{ title: string, subtitle: string, emptyText?: string }> = (props) => { const isEmpty = React.Children.count(props.children) === 0; return ( -
    -

    {props.title}

    - {isEmpty && props.emptyText ?

    {props.emptyText}

    :
    {props.children}
    } - -
    + <> +

    {props.title}

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

    {props.emptyText}

    :
    {props.children}
    } + ); }; diff --git a/apps/frontend/src/components/home/LiveGamesSection.tsx b/apps/frontend/src/components/home/LiveGamesSection.tsx index a52ffec..7d8ad65 100644 --- a/apps/frontend/src/components/home/LiveGamesSection.tsx +++ b/apps/frontend/src/components/home/LiveGamesSection.tsx @@ -7,80 +7,42 @@ import { LiveGameSummary, } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { Context } from "../../context/context"; -import { PopularGames } from "../../store/GameStore"; -import { formatDate, getResultText, getScoreText } from "../../util/GameResultText"; -import GamePreviewCard from "./GamePreviewCard"; +import GameCard from "./GameCard"; import HomeSection from "./HomeSection"; -const GAME_COUNT = 5; - -// Live games, updated as they are played; when nothing is live, the most watched finished games. +// Live games, updated as they are played; hidden while nothing is being played. const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => { - const { t, i18n } = useTranslation(); - const navigate = useNavigate(); - const [popular, setPopular] = useState(undefined); + const { t } = useTranslation(); + const [games, setGames] = useState([]); useEffect(() => { - let disposed = false; - const loadPopular = () => context.gameStore.getPopularGames(GAME_COUNT) - .then((games) => !disposed && setPopular(games)) - .catch((error) => console.error("(Home) Could not load popular games", error)); - const onLiveGames = (message: { games: LiveGameSummary[] }) => { - if (message.games.length > 0) setPopular({ kind: "live", games: message.games }); - else loadPopular(); - }; + const onLiveGames = (message: { games: LiveGameSummary[] }) => setGames(message.games); // Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users. const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {}); context.rtmt.addMessageListener(channel_on_live_games, onLiveGames); context.rtmt.on("open", subscribe); if (context.rtmt.connectionState === "connected") subscribe(); - loadPopular(); return () => { - disposed = true; context.rtmt.off("open", subscribe); context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames); if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {}); }; }, []); - if (!popular) return <>; - const openGame = (id: string) => navigate(`/game/${id}`); - - if (popular.kind === "live") { - return ( - - {popular.games.map((game) => ( - openGame(game.id)} /> - ))} - - ); - } + if (games.length === 0) return <>; return ( - - {popular.games.map((game) => ( - + {games.map((game) => ( + openGame(game.id)} /> + status={t("Live")} + statusKind="live" + details={`${t("Watching", { count: game.spectatorCount })} · ${t("MoveNumber", { number: game.moveCount })}`} /> ))} ); diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx new file mode 100644 index 0000000..b6e1332 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; + +const PLAYER1_PITS = [0, 1, 2, 3, 4, 5]; +const PLAYER2_PITS = [7, 8, 9, 10, 11, 12]; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. +const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => { + const [topPits, topStore, bottomPits, bottomStore] = revert + ? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE] + : [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE]; + const pit = (index: number) =>
    {board[index] || ""}
    ; + return ( + + ); +}; + +export const getStores = (board: number[], revert?: boolean) => revert + ? { top: board[PLAYER1_STORE], bottom: board[PLAYER2_STORE] } + : { top: board[PLAYER2_STORE], bottom: board[PLAYER1_STORE] }; + +export default MiniBoard; diff --git a/apps/frontend/src/components/home/PopularGamesSection.tsx b/apps/frontend/src/components/home/PopularGamesSection.tsx new file mode 100644 index 0000000..7e453d7 --- /dev/null +++ b/apps/frontend/src/components/home/PopularGamesSection.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useState } from "react"; +import { FinishedGameSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { formatDate, getResultPillClass, getResultText } from "../../util/GameResultText"; +import GameCard from "./GameCard"; +import HomeSection from "./HomeSection"; + +const GAME_COUNT = 6; + +// The most watched finished games. +const PopularGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t, i18n } = useTranslation(); + const [games, setGames] = useState(undefined); + + useEffect(() => { + let disposed = false; + context.gameStore.getPopularFinishedGames(GAME_COUNT) + .then((popular) => !disposed && setGames(popular)) + .catch((error) => console.error("(Home) Could not load popular games", error)); + return () => { disposed = true; }; + }, []); + + if (!games) return <>; + return ( + + {games.map((game) => ( + + ))} + + ); +}; + +export default PopularGamesSection; diff --git a/apps/frontend/src/components/home/RecentGamesSection.tsx b/apps/frontend/src/components/home/RecentGamesSection.tsx index 240111a..8201fc3 100644 --- a/apps/frontend/src/components/home/RecentGamesSection.tsx +++ b/apps/frontend/src/components/home/RecentGamesSection.tsx @@ -2,10 +2,9 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { FinishedGameSummary } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { Context } from "../../context/context"; -import { formatDate, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText"; -import GamePreviewCard from "./GamePreviewCard"; +import { formatDate, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText"; +import GameCard from "./GameCard"; import HomeSection from "./HomeSection"; const GAME_COUNT = 3; @@ -13,7 +12,6 @@ const GAME_COUNT = 3; // The user's last finished games, each shown from their side of the board. const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => { const { t, i18n } = useTranslation(); - const navigate = useNavigate(); const [games, setGames] = useState(undefined); useEffect(() => { @@ -27,21 +25,18 @@ const RecentGamesSection: FunctionComponent<{ context: Context, userId: string } if (!games) return <>; return ( - + {games.map((game) => ( - navigate(`/game/${game.id}`)} /> + status={getResultText(game, userId, t)} + statusKind={getResultPillClass(game, userId)} + details={formatDate(game.endedAt, i18n.language)} /> ))} ); diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index b9bc59f..e61acf6 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -8,6 +8,8 @@ import { PlusIcon } from "../components/Icons"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; +import PopularGamesSection from "../components/home/PopularGamesSection"; +import "../components/home/Home.css"; import { Context } from "../context/context"; import { useNavigate } from "react-router-dom"; import Util from "../util/Util"; @@ -48,15 +50,17 @@ const Home: FunctionComponent<{ {t("NewGame")} - {activeGame && authUser && ( - navigate(`/game/${activeGame.id}`)} /> - )} - - {authUser && } +
    + {activeGame && authUser && ( + navigate(`/game/${activeGame.id}`)} /> + )} + + + {authUser && } +
    ); }; diff --git a/apps/frontend/src/store/GameStore.ts b/apps/frontend/src/store/GameStore.ts index 9fccc8a..0c8ca73 100644 --- a/apps/frontend/src/store/GameStore.ts +++ b/apps/frontend/src/store/GameStore.ts @@ -4,14 +4,13 @@ import { HttpServiceImpl } from "../service/HttpService"; export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord }; -export type PopularGames = { kind: "live", games: LiveGameSummary[] } | { kind: "finished", games: FinishedGameSummary[] }; - export interface GameStore { // A live game, or the saved record of a finished one; undefined if there is no such game. get(id: string): Promise; getActiveGame(token: string): Promise; getMyRecentGames(token: string, limit: number): Promise; - getPopularGames(limit: number): Promise; + // The most watched finished games, also while games are live. + getPopularFinishedGames(limit: number): Promise; } export class GameStoreImpl implements GameStore { @@ -43,8 +42,9 @@ export class GameStoreImpl implements GameStore { return json.games; } - async getPopularGames(limit: number): Promise { - return this.getJson(`/games/popular?limit=${limit}`); + async getPopularFinishedGames(limit: number): Promise { + const json = await this.getJson(`/games/popular?kind=finished&limit=${limit}`); + return json.games; } private async getJson(route: string, token?: string): Promise { diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 7e39c7f..49ddd0f 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -10,6 +10,6 @@ Work through the tasks in order; each ends with its checks passing and a commit. - [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. - [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. - [x] 6. **Web app replay page**: summary card, controls card, move list. -- [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. +- [x] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. - [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; `AGENTS.md` and `TODO.md`. -- 2.45.2 From 6b08fef1e602858a4da7fd558a6e0fb9e927f1d1 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:26:59 +0300 Subject: [PATCH 43/82] docs: document the web redesign AGENTS.md notes on the themes, plain CSS for container queries and driving the app in Chrome; TODO.md gets the mobile app and rematch as open work; the spec is marked implemented. --- AGENTS.md | 15 +++++++++++---- TODO.md | 7 +++++++ docs/specs/web-redesign/requirements.md | 2 +- docs/specs/web-redesign/tasks.md | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9e6717b..f83e693 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app | `docs/specs/` | Specs: `requirements.md`, `design.md`, `tasks.md` | | `TODO.md` | Open work, known bugs and deployment steps; read it before starting | -Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay) and -`game-clock` (5 minutes per player, ending games on time). +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). ## Running locally @@ -45,7 +46,7 @@ unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`. | Core | `cd packages/core && npx jest` | | | Backend | `cd apps/backend && npx tsc --noEmit -p . && npx jest` | Needs Docker: Postgres and Valkey run in testcontainers | | 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. 3 type errors are known and older than recent work | +| Web app | `cd apps/frontend && yarn build` | There is no `tsconfig.json`, so nothing type-checks it; use a temporary one when needed (with `"types": ["styled-jsx", "react", "react-dom"]`). 1 type error (`react-dom/client`) is known | | Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work | There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with @@ -89,7 +90,13 @@ There is no automated end-to-end suite. Changes to the game flow were checked by - styled-jsx only scopes JSX written directly in a component's render; elements built in helper functions or `map` callbacks need `.parent :global(.child)` rules. - Pages are full-height flex columns: a block with `overflow: hidden` needs `flex-shrink: 0` or it gets squeezed. -- Board components size themselves from the `--board-unit` CSS variable; `BoardView` takes an optional `width`. +- Colours come from CSS variables of the theme class on `` (`styles/app.css`); the web app's themes + (`theme/themes.ts`) are passed to core's `ThemeManager`, which the mobile app uses with its own default themes. +- Styles that need container queries (`@container`, `cqw`), such as the board's, are plain `.css` files imported by the + component; styled-jsx 5.0 does not handle them. `BoardView` fills its container and stands upright below 600 px. +- When a script drives the app in Chrome, wait for the header (`.hdr`) and a few seconds after the first load before + navigating; navigating during the guest login leaves an invalid token. Open the lobby with the "New Game" button, + not by URL, or the matchmaking message goes out before the WebSocket is open. - The WebSocket client emits `open` after authentication; anything the server must know about a connection (spectating a game, live game previews) is sent on `open`, because the server forgets it when a user goes offline. diff --git a/TODO.md b/TODO.md index f93e679..7ab0e8c 100644 --- a/TODO.md +++ b/TODO.md @@ -52,6 +52,13 @@ See `docs/specs/game-clock`. - [ ] Clocks in the home page's live game previews and in the ongoing-game banner - [ ] Try the mobile clocks on a device; only the type check and the bundle were run +## Web redesign + +See `docs/specs/web-redesign`. + +- [ ] Bring the new design to the mobile app (themes, board, game screen) +- [ ] A rematch button on the game-over card (the design has one; there is no rematch yet) + ## Deployment - [ ] Build the backend and frontend images from the repo root in CI: diff --git a/docs/specs/web-redesign/requirements.md b/docs/specs/web-redesign/requirements.md index b7d0b0d..a6360b5 100644 --- a/docs/specs/web-redesign/requirements.md +++ b/docs/specs/web-redesign/requirements.md @@ -1,6 +1,6 @@ # Web redesign: requirements -Status: in progress on branch `feature/web-redesign` (based on `feature/game-clock`). +Status: implemented on branch `feature/web-redesign` (based on `feature/game-clock`). Scope: web app, with small additions to core and the backend. The mobile app keeps its current look. The design is the canvas "Köçürme Board Tasarımı" (claude.ai artifact `8AnZr74jA68LGDBfwA1vyd`): home ("Lobi"), diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 49ddd0f..49bac83 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -11,5 +11,5 @@ Work through the tasks in order; each ends with its checks passing and a commit. - [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. - [x] 6. **Web app replay page**: summary card, controls card, move list. - [x] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. -- [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; +- [x] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; `AGENTS.md` and `TODO.md`. -- 2.45.2 From 27f13e2f9a1889fa3cd03d7b94ab7c20d948547b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:48:19 +0300 Subject: [PATCH 44/82] fix(mancala.js): skip the opponent's store when sowing Mangala skips the opponent's store, but Board.move dropped a stone in every pit and store. Moves with enough stones to pass it gave the opponent a stone and ended one pit too early, so captures and extra turns were decided on the wrong pit. Board.getSowingPath now computes the pits a move sows into, and Board.move and core's getSowingPath (the web board's move preview) both use it. Game step indexes are now always within the board. --- packages/core/src/history/MoveHistory.ts | 11 +++--- .../core/tests/history/MoveHistory.test.ts | 4 +-- packages/mancala.js/src/core/Board.ts | 35 +++++++++++++------ packages/mancala.js/tests/Board.test.ts | 26 +++++++++++++- .../game_rules/GRDoubleStoneInPit.test.ts | 2 +- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts index 97fd21e..fc1c55c 100644 --- a/packages/core/src/history/MoveHistory.ts +++ b/packages/core/src/history/MoveHistory.ts @@ -64,14 +64,11 @@ export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescr }; } -// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them: a single stone goes -// to the next pit; otherwise the first stone goes back into the played pit. Every pit and store is passed, the -// opponent's store too. The last index is where the last stone lands. Empty for an empty pit or a store. +// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them (see +// Board.getSowingPath): the opponent's store is skipped. The last index is where the last stone lands. Empty for an +// empty pit or a store. export function getSowingPath(stones: number[], pitIndex: number): number[] { - const stoneCount = stones[pitIndex]; - if (!stoneCount || referenceBoard.checkPitTypeIsBankByIndex(pitIndex)) return []; - const start = stoneCount === 1 ? pitIndex + 1 : pitIndex; - return Array.from({ length: stoneCount }, (_, step) => (start + step) % stones.length); + return referenceBoard.getSowingPath(pitIndex, stones[pitIndex] ?? 0); } // The game as it was after the given move, for showing or animating that position (e.g. with PitAnimator, diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts index aa4bd2d..3865e79 100644 --- a/packages/core/tests/history/MoveHistory.test.ts +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -145,12 +145,12 @@ describe("getSowingPath", () => { expect(getSowingPath(stones, 4)).toEqual([5]); }); - it("passes both stores and wraps around the board", () => { + it("passes the player's own store, skips the opponent's and wraps around the board", () => { const stones = getInitialBoard(); stones[12] = 4; expect(getSowingPath(stones, 12)).toEqual([12, 13, 0, 1]); stones[5] = 10; - expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 13, 0]); + expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 0, 1]); }); it("is empty for an empty pit or a store", () => { diff --git a/packages/mancala.js/src/core/Board.ts b/packages/mancala.js/src/core/Board.ts index 0e383c1..063186f 100644 --- a/packages/mancala.js/src/core/Board.ts +++ b/packages/mancala.js/src/core/Board.ts @@ -107,19 +107,34 @@ export class Board { `StoneNotFound => index : ${index} stoneCount = ${pit.stoneCount}` ); } - const stepCount = pit.stoneCount; - this.pits[index].stoneCount = 0; - const startIndex = stepCount === 1 ? index + 1 : index; - this.moveInternal(startIndex, stepCount); + const path = this.getSowingPath(index); + pit.stoneCount = 0; + this.fireOnGameMoveStart(path[0]); + path.forEach((pathIndex) => this.onGameMoveStep(pathIndex)); + this.fireOnGameMoveEnd(path[path.length - 1]); } - private moveInternal(startIndex: number, stepCount: number) { - this.fireOnGameMoveStart(startIndex); - for (let i = startIndex; i < startIndex + stepCount; i++) { - this.onGameMoveStep(i); + // Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the first + // stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped. The last + // index is where the last stone lands. Empty for a bank or an empty pit. + public getSowingPath( + index: number, + stoneCount = this.pits[index].stoneCount + ): number[] { + this.checkIndeAndMaybeThrowError(index); + if (stoneCount <= 0 || this.checkPitTypeIsBankByIndex(index)) return []; + const opponentBankIndex = + this.getPitTypeByIndex(index) === 'player1Pit' + ? this.player2BankIndex() + : this.player1BankIndex(); + const path: number[] = []; + let current = + stoneCount === 1 ? this.getNextPitIndexCircularly(index) : index; + while (path.length < stoneCount) { + if (current !== opponentBankIndex) path.push(current); + current = this.getNextPitIndexCircularly(current); } - const stopIndex = startIndex + stepCount - 1; - this.fireOnGameMoveEnd(stopIndex); + return path; } public onGameMoveStep(index: number) { diff --git a/packages/mancala.js/tests/Board.test.ts b/packages/mancala.js/tests/Board.test.ts index 85ca668..5b76ccd 100644 --- a/packages/mancala.js/tests/Board.test.ts +++ b/packages/mancala.js/tests/Board.test.ts @@ -103,9 +103,33 @@ describe('Board Test', () => { expect(board.getStoneArray()).toStrictEqual(initialBoard); board.move(5); expect(board.getStoneArray()).toStrictEqual([ - 5, 5, 5, 5, 5, 2, 1, 5, 5, 5, 5, 5, 5, 1 + 5, 5, 5, 5, 5, 2, 2, 5, 5, 5, 5, 5, 5, 0 ]); }); + test('move skips the opponent bank for both players', () => { + const board = new Board(6, 4); + board.pits[12].stoneCount = 10; + board.move(12); + expect(board.getStoneArray()).toStrictEqual([ + 5, 5, 5, 5, 5, 5, 0, 5, 5, 4, 4, 4, 1, 1 + ]); + }); + test('test getSowingPath', () => { + const board = new Board(6, 4); + expect(board.getSowingPath(0)).toStrictEqual([0, 1, 2, 3]); + expect(board.getSowingPath(0, 1)).toStrictEqual([1]); + expect(board.getSowingPath(5, 2)).toStrictEqual([5, 6]); + expect(board.getSowingPath(5, 9)).toStrictEqual([ + 5, 6, 7, 8, 9, 10, 11, 12, 0 + ]); + expect(board.getSowingPath(12, 3)).toStrictEqual([12, 13, 0]); + expect(board.getSowingPath(11, 10)).toStrictEqual([ + 11, 12, 13, 0, 1, 2, 3, 4, 5, 7 + ]); + expect(board.getSowingPath(0, 0)).toStrictEqual([]); + expect(board.getSowingPath(6)).toStrictEqual([]); + expect(board.getSowingPath(13)).toStrictEqual([]); + }); test('test getOppositePitIndex', () => { const board = new Board(6, 4); diff --git a/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts b/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts index 0d07a12..1698a30 100644 --- a/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts +++ b/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts @@ -33,7 +33,7 @@ describe('GRDoubleStoneInPit Test', () => { expect(game.history[game.history.length - 1].gameSteps).toStrictEqual([ new GameStep(12, GAME_STEP_GAME_MOVE), new GameStep(13, GAME_STEP_GAME_MOVE), - new GameStep(14, GAME_STEP_GAME_MOVE), + new GameStep(0, GAME_STEP_GAME_MOVE), new GameStep(0, GAME_STEP_DOUBLE_STONE_IN_PIT, { pitIndex: 0, bankIndex: 13 -- 2.45.2 From 3ea2ba18e31163b57b047f783a315ff89795c63e Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:49:09 +0300 Subject: [PATCH 45/82] fix(core): empty the first pit when animating a single stone move PitAnimator guessed the played pit of a single stone move from the first sowing step and skipped index 0, so playing one stone from player 1's first pit showed it still holding the stone until the animation ended. It now empties the move's own pit (moveIndex). --- packages/core/src/animation/PitAnimator.ts | 19 ++---- .../core/tests/animation/PitAnimator.test.ts | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 packages/core/tests/animation/PitAnimator.test.ts diff --git a/packages/core/src/animation/PitAnimator.ts b/packages/core/src/animation/PitAnimator.ts index 219cb8c..035e309 100644 --- a/packages/core/src/animation/PitAnimator.ts +++ b/packages/core/src/animation/PitAnimator.ts @@ -7,6 +7,7 @@ import { GAME_STEP_LAST_STONE_IN_BANK, GAME_STEP_DOUBLE_STONE_IN_PIT, MancalaGame, + MoveHistoryItem, } from "mancala.js"; import { v4 } from "uuid"; import { BoardViewModel } from "../viewmodel/BoardViewModel"; @@ -92,12 +93,6 @@ export class PitAnimator { this.animationIndex++; } - getGameMoveStepCount(historyItem: HistoryItem) { - return historyItem.gameSteps.filter( - (gameStep) => gameStep.type === GAME_STEP_GAME_MOVE - ).length; - } - animatePit( index: number, boardViewModel: BoardViewModel, @@ -106,15 +101,9 @@ export class PitAnimator { if (!this.currentHistoryItem || !this.game || !this.mancalaGame) return; const pitViewModel = boardViewModel.pits[index]; if (this.animationIndex === 0) { - //This is one stone move case, TODO: beautify it later - if (this.getGameMoveStepCount(this.currentHistoryItem) === 1) { - const previousPitIndex = gameStep.index - 1; - if (previousPitIndex > 0) { - boardViewModel.pits[previousPitIndex].stoneCount = 0; - } - } else { - pitViewModel.stoneCount = 0; - } + // The played pit empties before its stones are sown (a single stone goes on to the next pit). + const { moveIndex } = this.currentHistoryItem as MoveHistoryItem; + boardViewModel.pits[moveIndex].stoneCount = 0; } const theme = this.themeManager.theme; if (gameStep.type === GAME_STEP_GAME_MOVE) { diff --git a/packages/core/tests/animation/PitAnimator.test.ts b/packages/core/tests/animation/PitAnimator.test.ts new file mode 100644 index 0000000..7a3f3af --- /dev/null +++ b/packages/core/tests/animation/PitAnimator.test.ts @@ -0,0 +1,68 @@ +import { CommonMancalaGame, MancalaGame } from "mancala.js"; +import { PitAnimator } from "../../src/animation/PitAnimator"; +import { Game } from "../../src/models"; +import { ThemeManager } from "../../src/theme"; +import { BoardViewModel } from "../../src/viewmodel/BoardViewModel"; + +const P1 = "player-1"; +const P2 = "player-2"; + +const memoryStorage = () => { + const values = new Map(); + return { + getString: (key: string) => values.get(key), + setString: (key: string, value: string) => void values.set(key, value), + deleteString: (key: string) => void values.delete(key), + }; +}; + +const asGame = (mancalaGame: MancalaGame): Game => ({ + id: mancalaGame.id, + mancalaGame, + gameUsersConnectionInfo: { + user1ConnectionInfo: { userId: P1, isOnline: true }, + user2ConnectionInfo: { userId: P2, isOnline: true }, + }, +}); + +// Plays the move on a copy of the game and returns the stones the animator shows after each step. +const animateMove = (setUp: (game: MancalaGame) => void, playerId: string, pit: number): number[][] => { + jest.useFakeTimers(); + const createGame = () => { + const game = new CommonMancalaGame("game-1", P1, P2); + game.turnPlayerId = playerId; + setUp(game); + return game; + }; + const before = createGame(); + const after = createGame(); + after.moveByPlayerPit(playerId, pit); + + const frames: number[][] = []; + const animator = new PitAnimator(new ThemeManager(memoryStorage()), (boardViewModel: BoardViewModel) => + frames.push(boardViewModel.pits.map((pitViewModel) => pitViewModel.stoneCount)), 10); + animator.setNewGame(asGame(before)); + frames.length = 0; + animator.setUpdatedGame(asGame(after)); + jest.advanceTimersByTime(10 * (after.history[0].gameSteps.length + 1)); + jest.useRealTimers(); + return frames.slice(1); +}; + +describe("PitAnimator", () => { + it("empties the played pit when a single stone moves on", () => { + for (const pit of [0, 3]) { + const frames = animateMove((game) => { + game.board.pits[pit].stoneCount = 1; + }, P1, pit); + expect(frames[0][pit]).toBe(0); + expect(frames[0][pit + 1]).toBe(5); + } + }); + + it("empties the played pit and sows from it when several stones move", () => { + const frames = animateMove(() => undefined, P1, 0); + expect(frames[0].slice(0, 4)).toEqual([1, 4, 4, 4]); + expect(frames[3].slice(0, 4)).toEqual([1, 5, 5, 5]); + }); +}); -- 2.45.2 From e81e0e084123cf5bb3f4a642fc8413e9e5d4962d Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:49:29 +0300 Subject: [PATCH 46/82] docs: update the TODO for the rule fixes Remove the sowing and animation bugs, which are fixed, and add the open question of who gets the remaining stones when a move empties the opponent's side. --- TODO.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index b14ca5a..8472729 100644 --- a/TODO.md +++ b/TODO.md @@ -83,12 +83,16 @@ See `docs/specs/web-redesign`. - [ ] 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`) +## Game rules + +- [ ] Decide who gets the remaining stones when a move empties the opponent's side (e.g. a capture takes their last + stones). Now the player whose side is empty always gets the other side's stones (`GRClearBoardAtEnd`), even + when the opponent emptied it; some Mangala rules give them only to a player who emptied their own side. + Analyse the rule first, then decide + ## Bugs -- [ ] `PitAnimator.animatePit`: `previousPitIndex > 0` should be `>= 0`; a one-stone move from pit 0 does not - empty that pit during the animation - [ ] `PitAnimator.dispose` removes a newly bound function, so the theme listener is never removed -- [ ] Sowing puts stones into the opponent's store; check against the intended Mangala rules and add a test - [ ] `onGameError` broadcasts an `Error`, which serializes to `{}` ## Maintenance @@ -96,7 +100,7 @@ See `docs/specs/web-redesign`. - [ ] The web and mobile apps duplicate components and stores; move shared code into `packages/core` - [ ] Mixed lockfiles: `packages/*` and `apps/mobile` have both `yarn.lock` and `package-lock.json` - [ ] Align TypeScript and Jest versions across packages -- [ ] Tests for `MatchMaker` and `PitAnimator` +- [ ] Tests for `MatchMaker`; `PitAnimator` is only tested for the first frame of a move - [ ] CI that runs the tests - [ ] Pre-existing type errors: 4 in the frontend, 11 in mobile; the frontend has no `tsconfig.json`, so nothing type-checks it -- 2.45.2 From 5b0cddf4161fd9ea975de0113a99d7d154e1f49e Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:52:15 +0300 Subject: [PATCH 47/82] docs: add publishing mancala.js to the TODO The apps use the engine from the repo, so publishing the fixed engine to npm is left as the last deployment step. --- TODO.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TODO.md b/TODO.md index 8472729..21efd86 100644 --- a/TODO.md +++ b/TODO.md @@ -74,6 +74,8 @@ See `docs/specs/web-redesign`. - [ ] Back up the Postgres volume (Keycloak and saved games), e.g. a daily `pg_dumpall` - [ ] Set `MANCALA_DB_PASSWORD` and `POSTGRES_PASSWORD` in `apps/.env` on the server; the `postgres` volume replaces `keycloak-db`, so Keycloak starts with a fresh database there +- [ ] Last step: publish a new `mancala.js` to npm (it is at `0.0.2-beta.3` from 2022), so the published engine skips + the opponent's store when sowing. The apps do not need it; they use `packages/mancala.js` through `link:` ## Security -- 2.45.2 From ec833b4ec59e8c57b6f3b4642f0fa2244d298077 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:00:44 +0300 Subject: [PATCH 48/82] 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`. -- 2.45.2 From f0ffd6ba870542da846ef25efb57e5fff4d2774a Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:06:43 +0300 Subject: [PATCH 49/82] 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. -- 2.45.2 From f0246cca61862700438481267f09a9f5784323b6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:09:16 +0300 Subject: [PATCH 50/82] 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 -- 2.45.2 From 219b50b491fa4fdd8d1233e1d3d70f30527c2338 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:23:35 +0300 Subject: [PATCH 51/82] 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`, -- 2.45.2 From d86fca8c544c69381e0f346399dbf13ca8d05fd4 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:26:28 +0300 Subject: [PATCH 52/82] 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. -- 2.45.2 From 0c112eb71606d0743a031a39e6b1b87a7c71e31a Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:33:07 +0300 Subject: [PATCH 53/82] 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. -- 2.45.2 From 6aeff37eb886a3a767e0b8cbc097074bda515f93 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:47:40 +0300 Subject: [PATCH 54/82] 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`. -- 2.45.2 From 0903f614559bf0de3320697e8edaa05db2146848 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:53:13 +0300 Subject: [PATCH 55/82] 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`. -- 2.45.2 From 91577c08980414d89ca91583ed21b6011e0d4c79 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 12:46:20 +0300 Subject: [PATCH 56/82] docs(game-bot): add the spec Requirements, design and tasks for playing against a bot with four levels and a strength drawn per game. --- docs/specs/game-bot/design.md | 210 ++++++++++++++++++++++++++++ docs/specs/game-bot/requirements.md | 74 ++++++++++ docs/specs/game-bot/tasks.md | 21 +++ 3 files changed, 305 insertions(+) create mode 100644 docs/specs/game-bot/design.md create mode 100644 docs/specs/game-bot/requirements.md create mode 100644 docs/specs/game-bot/tasks.md diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md new file mode 100644 index 0000000..79fd051 --- /dev/null +++ b/docs/specs/game-bot/design.md @@ -0,0 +1,210 @@ +# Game bot: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` +(the clock), `game-history` (saved games, live previews) and `quarkus-backend` (the Java backend and its engine). + +## Decisions + +| Decision | Reason | +|---|---| +| The bot runs in the backend, in Java | The rules are already in Java (`app.playmancala.engine`) and kept equal to the TypeScript engine by `EngineParityTest`. A bot game is then an ordinary server game: the clock, saving, spectators, previews and restarts work without changes in the clients (R4). A bot on the device would only add offline play, at the cost of a second bot in TypeScript (out of scope). | +| A compact board, `Position` (`int[14]` and whose turn it is), in the engine package | Searching means playing millions of moves; `MancalaGame` builds `Pit` objects, lists and animation steps for each, which is far too slow. `Position` plays a move into an existing array without allocating. It is in `engine`, not `bot`, so other code can use it too, e.g. checking a move before running it (see `TODO.md`, Security). It is tested against `MancalaGame` with the recorded parity games and random games. | +| `MancalaGame` is not rebuilt on `Position` now | `MancalaGame` also produces the game steps clients animate, the history and the JSON the Node backend wrote, and it must stay equal to the TypeScript engine. Rebuilding it is a separate refactor once `Position` has proven itself (out of scope). | +| Negamax with alpha-beta pruning, limited by depth and a node budget, not by time | Mangala is small (6 moves at most per turn), so alpha-beta is strong enough: at depth 12 the bot plays close to perfectly. Limits that do not depend on time make a move depend only on the position, the strength and the seed, so a game can be reproduced for debugging; a slower machine only takes longer. | +| Strength (0–100) sets three things: search depth, how much the bot prefers the best move (softmax temperature) and how often it plays a random move | Depth alone gives few, coarse steps and a predictable bot. With softmax, a weaker bot more often plays a move that is nearly as good, which looks like a human mistake; the random moves make Easy beatable by beginners (R2.3). Interpolating a table gives smooth steps between levels. | +| Each level is a range of strengths; the strength is drawn per game and varies by ±5 per move | R2.2. The ranges have gaps between them so neighbouring levels stay clearly apart (R2.4). | +| Randomness comes from a per-game seed and the move number | The bot's choice of a move is reproducible from the saved game, and a restart does not change how it plays (R4.6). The seed and strength are kept with the live game and the saved game. | +| Searching runs on its own small thread pool; the result goes back to the game loop, which checks that the game is still waiting for that move | The game loop must never wait (R3.5, `AGENTS.md`). The pool has a fixed number of threads, so many bot games share the CPU instead of starving the loop and the WebSocket threads. A result is dropped if the game ended or moved on meanwhile. | +| The bot's waiting time is computed on the server: the previous move's animation (300 ms per step, as `PitAnimator`) plus a random 1–3 seconds | R3.2. Clients do not need to know about bots for this. Searching happens during the wait, so the time it takes is not added to it. | +| Bot ids are `bot:`, e.g. `bot:medium` | One stable id per level reads well in saved games and lets later statistics group games by level. It is public and cannot belong to a person: Keycloak ids start with `kc:` and legacy ids must be UUIDs (`RtmtServer`). Because one id plays many games at once, `GameStore` does not map it to a game. Clients recognise bots by the id, so `PlayerSummary` keeps its shape. | +| The offer to play the bot (R1.1) is a timer in the client | The server does not need to know how long someone has waited. Matchmaking stays as it is; choosing a level sends a new message that also ends the search on the server. | +| Bot games are ordinary games everywhere: clock, saving, spectators, live previews, popular games | R4. Fewer special cases. The bot's moves go through the same code as a player's moves (R3.1). | + +## Shared types (`packages/core`) + +```ts +// rtmt/channel_names.ts +export const channel_new_bot_game = "new_bot_game"; // client -> server: { level: BotLevel } + +// models/Bot.ts +export type BotLevel = "easy" | "medium" | "hard" | "master"; +export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; +export const BOT_OFFER_DELAY_MS = 10_000; // R1.1 +export function isBotId(userId: string | undefined): boolean; // starts with "bot:" +export function getBotLevel(userId: string | undefined): BotLevel | undefined; +``` + +- `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for + bots (R4.1). +- Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with + the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `Bot` "Bot" / "Bot", `BotLevelEasy`, + `BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor", + "Usta", `PlayAgain` "Play again" / "Tekrar oyna". + +## Backend + +### Modules +``` +engine/Position.java compact board and the rules on it +bot/BotLevel.java the levels, their wire names and strength ranges +bot/BotSettings.java record (level, strength, seed); kept with a bot game +bot/BotStrength.java strength -> depth, temperature, random move chance +bot/BotSearch.java negamax with alpha-beta; scores of the root moves +bot/BotPolicy.java chooses a move: strength, search, softmax, random moves +bot/BotPlayer.java plays the bot's turns in live games +db/migrations/003_bot.sql bot column +``` +`engine` and `bot` (except `BotPlayer`) have no Quarkus or game loop code, so they can be tested and run on their +own. + +### `Position` +```java +public final class Position { + // CommonBoard layout: player 1's pits 0-5, bank 6; player 2's pits 7-12, bank 13. + public static Position initial(boolean player1ToMove); + public static Position of(int[] stones, boolean player1ToMove); + public static Position of(MancalaGame game); + public int[] stones(); // a copy + public boolean isPlayer1ToMove(); + public int legalPits(int[] into); // pit numbers 0-5 of the mover's non-empty pits; returns the count + // Plays pit 0-5 of the mover into `into` (may be this). Returns true if the mover plays again. + public boolean play(int pit, Position into); + public boolean isOver(); // one side is empty + public int bankDifference(); // mover's bank minus the opponent's +} +``` +`play` applies the rules in the engine's order: sowing (one stone goes to the next pit, otherwise the first stone +goes back into the played pit; the opponent's bank is skipped), last stone in an own empty pit, even count in an +opponent's pit, last stone in the own bank (extra turn) or the turn passes, and clearing the board when a side is +empty. + +### Search (`BotSearch`) +- Negamax with alpha-beta. A move that gives an extra turn keeps the side to move, so its child's score is not + negated. +- Scores are from the mover's view, in hundredths of a stone. An ended game scores + `±100_000 + 100 × final bank difference`; a game where a bank holds more than 24 stones is decided and scores as + ended. Other leaves: `100 × bank difference` plus small terms for stones on each side and moves that give an extra + turn; the weights are tuned in task 3. +- Move order: extra turns, then captures, then the rest, then the best move of the previous iteration. +- Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an + exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed + depth are used. + +### Strength (`BotLevel`, `BotStrength`) + +| Level | Wire name | Strength | +|---|---|---| +| Easy | `easy` | 5–25 | +| Medium | `medium` | 30–55 | +| Hard | `hard` | 60–80 | +| Master | `master` | 85–100 | + +Starting values, interpolated linearly between rows (depth rounded); tuned in task 3 until R2.4 holds: + +| Strength | Depth | Temperature (stones) | Random move chance | +|---|---|---|---| +| 0 | 1 | 4 | 35% | +| 25 | 2 | 2 | 15% | +| 50 | 4 | 1 | 5% | +| 75 | 7 | 0.4 | 1% | +| 100 | 12 | 0.05 | 0% | + +### Choosing a move (`BotPolicy`) +```java +static int chooseMove(Position position, BotSettings bot, int moveNumber); // pit 0-5 +``` +1. A random source from `bot.seed()` and `moveNumber` (number of moves played in the game). +2. This move's strength: `bot.strength()` ±5, kept within 0–100. +3. With the random move chance: a random legal pit. +4. Otherwise search to the depth and pick a root move with probability ∝ `exp(score / (temperature × 100))`. +5. With a single legal pit, play it without searching. + +### Bot games in the game logic (`GameManager`) +- New channel `new_bot_game` with `{ level }`. An unknown level is logged and ignored. A player with a game in + progress gets `on_new_game_rejected` (R1.5). Otherwise `matchMaker.cancel(userId)`, then a game with the player + as player 1 and `bot:` as player 2, a random first mover (R1.6), and + `bot = new BotSettings(level, random strength in the level's range, random seed)`; then `startGame` as usual. +- `BackendGame` gains `BotSettings bot` (null for games between people) and `botId()`. +- The bot's `PlayerSummary` is `(bot:, "Bot", false)`. +- `onGameMove` is split: the socket handler finds the game and calls `playMove(game, playerId, pitIndex)`, which + holds today's logic (clock check, engine, clock charge, updates, ending). The bot calls the same method (R3.1). +- `onTurnStarted(Consumer)`: called after a game starts, after a move that does not end the game + (including an extra turn) and for each restored game. +- Connection info shows the bot online (R3.4); messages to the bot's id are not sent. +- `GameStore.setGameByUser` and `removeGameByPlayer` ignore bot ids. +- `PublicIds.publicUserId` returns bot ids as they are. + +### `BotPlayer` +```java +public BotPlayer(GameManager gameManager, GameStore gameStore, GameLoop loop, Executor searchExecutor) +``` +Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game: +1. On the loop: copy the position (`Position.of`), note the move number and compute + `readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step + of the last move plus 300 ms. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master + 1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3). +2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used, + so the game never waits for a bot that failed. +3. Back on the loop (`loop.execute`, then `loop.schedule` for what is left until `readyAt`): if the game is still + live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call + `gameManager.playMove(game, botId, pit)`; otherwise drop the result. + +`searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least +1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`. + +### Restore (R4.6) +- `LiveGameSnapshot` gains `bot` (`@JsonInclude(NON_NULL)`); the snapshot stays version 2, since the field is + optional. `fromSnapshot` restores it. A snapshot with a bot player but no `bot` field (not expected) gets the + middle strength of the level and a new seed. +- `restoreLiveGames` calls the turn listeners, so a bot whose turn it was moves. + +### Saving (R4.3) +- `GameRecord` gains `bot` (`@JsonInclude(NON_NULL)`): `{ level, strength, seed }`, sent with `GET /game/:id`; + clients do not use it. It allows tuning the levels from real games later. +- Migration `003_bot`: `ALTER TABLE games ADD COLUMN bot jsonb;` (NULL for games between people). + `PostgresGameHistoryRepository` writes and reads it. + +### Configuration +`BOT_SEARCH_THREADS` (default: half the processors, at least 1). + +## Web app +- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is preselected and saved in + local storage (R1.4). +- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; choosing a level + goes to `/loby?bot=` (R1.3). +- `LobyPage`: with `?bot=`, it sends `new_bot_game` instead of `new_game`. Otherwise, after + `BOT_OFFER_DELAY_MS` without a game, it shows `NoOpponentYet` and the picker below the search; choosing a level + sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays. +- `GamePage` / `GameOverCard`: in a bot game, the main button is "Play again" and goes to `/loby?bot=` with + the bot's level (R4.4). +- Bot names: every place that shows a player uses `getPlayerDisplayName` with the player's `userId` (R4.1). + +## Mobile app +- `LobyScreen`: the same offer after `BOT_OFFER_DELAY_MS`, and a `botLevel` route parameter that sends + `new_bot_game` at once. +- `HomeScreen`: a "Play with bot" button with the level choice; the last level is kept in MMKV. +- Bot names through `getPlayerDisplayName`. + +## Testing +- `Position`: replays the recorded parity games (`/engine/recorded-games.json`) and a few thousand random games + move by move against `MancalaGame`, comparing stones, whose turn it is and the end. +- `BotSearch`: finds an extra turn, a capture and a win in one or two moves; the same position, depth and budget + give the same scores; the node budget is respected. +- `BotStrength` and `BotLevel`: interpolation, the ranges, strengths drawn stay in range. +- `BotPolicy`: the same seed and move number give the same move; random moves only at the expected rate; a + single legal pit is played; R2.5 with a fixed opponent. +- Calibration: `BotTournamentTest`, tagged `calibration` and left out of `./mvnw test` + (`./mvnw test -Dgroups=calibration`), plays 200 games for each pair of neighbouring levels and Master against + Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per + level. A quick version (20 games, Master against Easy) runs with the normal tests. +- `BotPlayer` with `FakeRtmt`, `ManualScheduler` and a direct executor: the bot moves after the delay; extra turns; + a game that ended or moved on while searching drops the result; a failing search plays the first legal pit; + low time shortens the wait; a restored game where it is the bot's turn gets a move. +- `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game; + unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and + `bot`; leaving and running out of time end the game as usual. +- Postgres repository: the `bot` column is saved and read. Snapshots with and without `bot` restore. +- End to end in Chrome with `GAME_CLOCK_MS=60000`: the offer appears after 10 s in the lobby and starts a game; + "Play with bot" on the home page; the bot moves after the animation; the game ends and shows in recent games and + the replay as "Bot · "; "Play again" starts a new bot game; a backend restart during the bot's turn; a + second browser watches a bot game from the live previews. diff --git a/docs/specs/game-bot/requirements.md b/docs/specs/game-bot/requirements.md new file mode 100644 index 0000000..f3dfb95 --- /dev/null +++ b/docs/specs/game-bot/requirements.md @@ -0,0 +1,74 @@ +# Game bot: requirements + +Status: spec, not implemented yet. Branch `feature/game-bot` (based on `develop`). +Scope: backend, `packages/core`, web app and mobile app. + +## Goals + +Players can always get a game: when no other player is looking for one, they can play against a bot. They choose +how strong the bot is, but within a level the bot is not always equally strong, so games against it differ from +each other. A game against the bot is a normal game: it has the clock, can be watched and is saved like any other. + +## Terms + +- **Bot**: a player run by the server. +- **Level**: what the player chooses: Easy, Medium, Hard or Master (Turkish: Kolay, Orta, Zor, Usta). +- **Strength**: a number from 0 to 100 that sets how well the bot plays. Each level is a range of strengths; the bot + takes a strength from it at the start of each game. +- **Bot game**: a game between a player and the bot. + +## Requirements + +### R1. Starting a bot game +- R1.1 When a player has been looking for an opponent for 10 seconds without one being found, the lobby offers a + game against the bot with the four levels to choose from. The search goes on while the offer is shown; if an + opponent is found first, the game with them starts as usual. +- R1.2 Choosing a level ends the search and starts a bot game at that level at once. +- R1.3 The home page also has a "Play with bot" button that opens the same choice of levels, without searching for + an opponent first. +- R1.4 The level chosen last is preselected the next time (kept on the device). +- R1.5 A player who has a game in progress cannot start a bot game; they are offered to continue that game, as when + they look for an opponent. +- R1.6 Who moves first is chosen at random, as in other games. + +### R2. Levels and strength +- R2.1 There are four levels, from weakest to strongest: Easy, Medium, Hard, Master. +- R2.2 At the start of each game the bot takes a random strength within the level's range. Within a game, it plays + each move with a strength that varies a little around that value. +- R2.3 Easy makes obvious mistakes and can be beaten by a beginner. Master plays close to perfectly and is rarely + beaten. +- R2.4 Each level is clearly stronger than the one below: played against each other by the server, the stronger + of two neighbouring levels wins at least 65% of the games, and Master wins at least 95% against Easy. +- R2.5 Two games at the same level from the same starting position do not play out the same way: in 20 such games + against the same sequence of the opponent's moves, the bot's first five moves are not all the same. + +### R3. How the bot plays +- R3.1 The bot plays by the same rules as players; its moves go through the same game logic. +- R3.2 The bot does not move instantly: it waits until the previous move's animation has finished, then about 1 to + 3 seconds more, a little longer at the higher levels. An extra turn is played the same way. +- R3.3 The bot has a clock like any player and its waiting counts on it. It never runs out of time. +- R3.4 The bot never leaves a game and is always shown as online. +- R3.5 Working out the bot's moves never delays other games: other players' moves are handled as fast as without + bots, even while many bot games are being played. + +### R4. During and after the game +- R4.1 The bot is shown as "Bot" with its level, e.g. "Bot · Medium" / "Bot · Orta", wherever players are shown: + the game page, live game previews, recent and popular games, and replays. +- R4.2 Everything that ends a game works as in other games: a player who leaves loses, a player whose time runs out + loses, a completed game is won by the player with more stones. +- R4.3 Bot games are saved like other games and appear in the player's recent games and can be replayed. +- R4.4 At the end of a bot game, the game over card offers "Play again", which starts a new bot game at the same + level. +- R4.5 Bot games can be watched by others and appear in live game previews like other games. +- R4.6 A bot game survives a backend restart: the bot goes on at the same strength, and if it was its turn, it moves. + +### R5. Mobile app +- R5.1 The mobile app has the offer in the lobby (R1.1, R1.2), the button on the home screen (R1.3) and shows the + bot's name with its level (R4.1). + +## Out of scope (TODO) +- Playing against the bot without a connection (a bot in the TypeScript engine on the device). +- Ratings or statistics; when they come, bot games should be kept apart from games against people. +- Hints or move analysis for players. +- Choosing an exact strength or who moves first. +- Rebuilding the game engine (`MancalaGame`, `Board`, the rules) on the compact board this feature adds. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md new file mode 100644 index 0000000..6e44062 --- /dev/null +++ b/docs/specs/game-bot/tasks.md @@ -0,0 +1,21 @@ +# Game bot: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [ ] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity + games and random games. +- [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit + tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the + strength table until R2.4 holds, and write the final values into `design.md`. +- [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, + `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in + `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. +- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [ ] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, + `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. +- [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot + game, bot names everywhere players are shown. +- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, + `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items). -- 2.45.2 From f9a96fc5f54560f7a61493162046ea652d7a5482 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 12:47:32 +0300 Subject: [PATCH 57/82] feat(backend): add a compact board for searching moves Position keeps the board as 14 stone counts and plays the Mangala rules on it without allocating. Tested against MancalaGame with the recorded games and random games. --- .../java/app/playmancala/engine/Position.java | 176 ++++++++++++++++++ .../app/playmancala/engine/PositionTest.java | 106 +++++++++++ docs/specs/game-bot/tasks.md | 2 +- 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/main/java/app/playmancala/engine/Position.java create mode 100644 apps/backend/src/test/java/app/playmancala/engine/PositionTest.java diff --git a/apps/backend/src/main/java/app/playmancala/engine/Position.java b/apps/backend/src/main/java/app/playmancala/engine/Position.java new file mode 100644 index 0000000..454ecac --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Position.java @@ -0,0 +1,176 @@ +package app.playmancala.engine; + +import java.util.Arrays; + +// A board as 14 stone counts and whose turn it is, with the rules of CommonMancalaGame played on it directly. Fast +// enough for searching millions of moves: playing a move allocates nothing. Kept equal to MancalaGame by +// PositionTest; MancalaGame stays the engine for live games, since it also records the steps clients animate. +public final class Position { + public static final int SIZE = 14; + public static final int PIT_COUNT = 6; + public static final int PLAYER1_BANK = 6; + public static final int PLAYER2_BANK = 13; + private static final int INITIAL_STONES_IN_PIT = 4; + + // Player 1's pits 0-5 and bank 6; player 2's pits 7-12 and bank 13, as in CommonBoard. + private final int[] stones; + private boolean player1ToMove; + + private Position(int[] stones, boolean player1ToMove) { + this.stones = stones; + this.player1ToMove = player1ToMove; + } + + public static Position initial(boolean player1ToMove) { + int[] stones = new int[SIZE]; + for (int pit = 0; pit < PIT_COUNT; pit++) { + stones[pit] = INITIAL_STONES_IN_PIT; + stones[PLAYER1_BANK + 1 + pit] = INITIAL_STONES_IN_PIT; + } + return new Position(stones, player1ToMove); + } + + public static Position of(int[] stones, boolean player1ToMove) { + if (stones.length != SIZE) throw new IllegalArgumentException("A board has " + SIZE + " pits: " + stones.length); + return new Position(stones.clone(), player1ToMove); + } + + public static Position of(MancalaGame game) { + return of(game.getBoard().getStoneArray(), game.isTurnPlayer1()); + } + + public Position copy() { + return new Position(stones.clone(), player1ToMove); + } + + public int[] stones() { + return stones.clone(); + } + + public int stonesAt(int index) { + return stones[index]; + } + + public boolean isPlayer1ToMove() { + return player1ToMove; + } + + // Writes the mover's playable pits (0-5) into `into` and returns how many there are. + public int legalPits(int[] into) { + int first = firstPitIndex(player1ToMove); + int count = 0; + for (int pit = 0; pit < PIT_COUNT; pit++) { + if (stones[first + pit] > 0) into[count++] = pit; + } + return count; + } + + // Plays the mover's pit (0-5) into `into`, which may be this position. Returns true if the mover plays again. + public boolean play(int pit, Position into) { + if (pit < 0 || pit >= PIT_COUNT) throw new IllegalArgumentException("No such pit: " + pit); + int index = firstPitIndex(player1ToMove) + pit; + int stoneCount = stones[index]; + if (stoneCount <= 0) throw new IllegalArgumentException("Pit " + pit + " is empty"); + int[] board = into.stones; + if (into != this) System.arraycopy(stones, 0, board, 0, SIZE); + boolean player1 = player1ToMove; + int ownBank = player1 ? PLAYER1_BANK : PLAYER2_BANK; + int opponentBank = player1 ? PLAYER2_BANK : PLAYER1_BANK; + + // Sowing: a single stone goes to the next pit; otherwise the first stone goes back into the played pit. + board[index] = 0; + int current = stoneCount == 1 ? next(index) : index; + int last = current; + for (int dropped = 0; dropped < stoneCount; current = next(current)) { + if (current == opponentBank) continue; + board[current]++; + last = current; + dropped++; + } + + // The last stone in an own empty pit takes the stones opposite, if there are any. + if (board[last] == 1 && isOwnPit(last, player1)) { + int opposite = 12 - last; + if (board[opposite] > 0) { + board[ownBank] += 1 + board[opposite]; + board[opposite] = 0; + board[last] = 0; + } + } + // The last stone making an opponent's pit even takes them. + if (board[last] % 2 == 0 && isOwnPit(last, !player1)) { + board[ownBank] += board[last]; + board[last] = 0; + } + boolean extraTurn = last == ownBank; + into.player1ToMove = extraTurn == player1; + + // When a side is empty, its player gets the stones left on the other side. + int player1Stones = sumPits(board, 0); + int player2Stones = sumPits(board, PLAYER1_BANK + 1); + if (player1Stones == 0) { + board[PLAYER1_BANK] += player2Stones; + Arrays.fill(board, PLAYER1_BANK + 1, PLAYER2_BANK, 0); + } else if (player2Stones == 0) { + board[PLAYER2_BANK] += player1Stones; + Arrays.fill(board, 0, PLAYER1_BANK, 0); + } + return extraTurn; + } + + // One side is empty, so the game has ended. + public boolean isOver() { + return sumPits(stones, 0) == 0 || sumPits(stones, PLAYER1_BANK + 1) == 0; + } + + // The mover's bank minus the opponent's. + public int bankDifference() { + int difference = stones[PLAYER1_BANK] - stones[PLAYER2_BANK]; + return player1ToMove ? difference : -difference; + } + + // The mover's stones in pits minus the opponent's. + public int pitStoneDifference() { + int difference = sumPits(stones, 0) - sumPits(stones, PLAYER1_BANK + 1); + return player1ToMove ? difference : -difference; + } + + // Board index of the mover's pit (0-5). + public int boardIndex(int pit) { + return firstPitIndex(player1ToMove) + pit; + } + + private static int firstPitIndex(boolean player1) { + return player1 ? 0 : PLAYER1_BANK + 1; + } + + private static boolean isOwnPit(int index, boolean player1) { + return player1 ? index < PLAYER1_BANK : index > PLAYER1_BANK && index < PLAYER2_BANK; + } + + private static int next(int index) { + return index == SIZE - 1 ? 0 : index + 1; + } + + private static int sumPits(int[] board, int first) { + int sum = 0; + for (int index = first; index < first + PIT_COUNT; index++) sum += board[index]; + return sum; + } + + @Override + public boolean equals(Object other) { + return other instanceof Position position && player1ToMove == position.player1ToMove + && Arrays.equals(stones, position.stones); + } + + @Override + public int hashCode() { + return 31 * Arrays.hashCode(stones) + Boolean.hashCode(player1ToMove); + } + + @Override + public String toString() { + return Arrays.toString(stones) + (player1ToMove ? " player 1 to move" : " player 2 to move"); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java new file mode 100644 index 0000000..566d3ae --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java @@ -0,0 +1,106 @@ +package app.playmancala.engine; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.Json; + +// Position must play exactly like MancalaGame: the recorded games from the TypeScript engine and random games are +// played on both, move by move. +class PositionTest { + + @Test + void startsWithFourStonesInEveryPit() { + Position position = Position.initial(true); + assertArrayEquals(new CommonMancalaGame("g", "a", "b").getBoard().getStoneArray(), position.stones()); + assertTrue(position.isPlayer1ToMove()); + int[] pits = new int[Position.PIT_COUNT]; + assertEquals(6, position.legalPits(pits)); + } + + @Test + void lastStoneInOwnBankGivesAnExtraTurn() { + Position position = Position.initial(true); + // Pit 3 with 4 stones: 3, 4, 5 and the bank. + assertTrue(position.play(3, position)); + assertArrayEquals(new int[] { 4, 4, 4, 1, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0 }, position.stones()); + assertTrue(position.isPlayer1ToMove()); + } + + @Test + void playingIntoAnotherPositionLeavesThisOneAlone() { + Position position = Position.initial(false); + Position next = Position.initial(true); + assertFalse(position.play(0, next)); + assertEquals(Position.initial(false), position); + assertTrue(next.isPlayer1ToMove()); + } + + @Test + void refusesEmptyPitsAndBanks() { + Position position = Position.of(new int[] { 0, 1, 0, 0, 0, 0, 20, 1, 0, 0, 0, 0, 0, 26 }, true); + assertThrows(IllegalArgumentException.class, () -> position.play(0, position)); + assertThrows(IllegalArgumentException.class, () -> position.play(6, position)); + } + + @Test + void playsTheRecordedGamesLikeTheEngine() throws IOException { + JsonNode games; + try (InputStream in = getClass().getResourceAsStream("/engine/recorded-games.json")) { + games = Json.MAPPER.readTree(in).get("games"); + } + for (JsonNode recorded : games) { + String player1 = recorded.get("player1Id").asText(); + String player2 = recorded.get("player2Id").asText(); + MancalaGame game = new CommonMancalaGame("g", player1, player2); + game.setTurnPlayerId(recorded.get("turnPlayerId").asText()); + int number = 0; + for (JsonNode move : recorded.get("moves")) { + number++; + // Refused moves, and moves after the end, which the engine ignores. + if (move.has("error") || game.getState() == GameState.ENDED) continue; + String player = move.get("playerId").asText(); + playOnBoth(game, player, move.get("pitIndex").asInt(), recorded.get("id").asText() + " move " + number); + } + } + } + + @Test + void playsRandomGamesLikeTheEngine() { + SplittableRandom random = new SplittableRandom(7); + int[] pits = new int[Position.PIT_COUNT]; + for (int gameNumber = 0; gameNumber < 3000; gameNumber++) { + MancalaGame game = new CommonMancalaGame("g", "p1", "p2"); + game.setTurnPlayerId(random.nextBoolean() ? "p1" : "p2"); + int moves = 0; + while (game.getState() != GameState.ENDED) { + Position position = Position.of(game); + int count = position.legalPits(pits); + int pit = pits[random.nextInt(count)]; + playOnBoth(game, game.getTurnPlayerId(), pit, "game " + gameNumber + " move " + ++moves); + } + } + } + + private static void playOnBoth(MancalaGame game, String player, int pit, String where) { + Position position = Position.of(game); + assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where); + boolean extraTurn = position.play(pit, position); + game.moveByPlayerPit(player, pit); + assertArrayEquals(game.getBoard().getStoneArray(), position.stones(), where); + assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where); + assertEquals(player.equals(game.getTurnPlayerId()), extraTurn, where); + assertEquals(game.checkGameIsEnded(), position.isOver(), where); + } +} diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 6e44062..20f91e8 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/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. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity +- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity games and random games. - [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the -- 2.45.2 From 31b51d04af22a2c6b51a054bc6954b319a0f71a4 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:11:46 +0300 Subject: [PATCH 58/82] feat(backend): choose bot moves by strength Negamax with alpha-beta on Position scores every move; the strength sets the depth, the softmax temperature and the chance of a random move. The levels are ranges of strengths drawn per game. A calibration tournament checks that each level beats the one below. --- apps/backend/pom.xml | 2 + .../java/app/playmancala/bot/BotLevel.java | 80 ++++++++++ .../java/app/playmancala/bot/BotPolicy.java | 58 ++++++++ .../java/app/playmancala/bot/BotSearch.java | 140 ++++++++++++++++++ .../java/app/playmancala/bot/BotSettings.java | 16 ++ .../java/app/playmancala/bot/BotStrength.java | 34 +++++ .../app/playmancala/bot/BotPolicyTest.java | 126 ++++++++++++++++ .../app/playmancala/bot/BotSearchTest.java | 82 ++++++++++ .../playmancala/bot/BotTournamentTest.java | 88 +++++++++++ docs/specs/game-bot/design.md | 26 +++- docs/specs/game-bot/tasks.md | 2 +- 11 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotLevel.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotSearch.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotSettings.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotStrength.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index 52f430f..7293f4c 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -17,6 +17,8 @@ 3.5.6 5006 + + calibration diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java new file mode 100644 index 0000000..574b293 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java @@ -0,0 +1,80 @@ +package app.playmancala.bot; + +import java.util.random.RandomGenerator; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +// The levels players choose from. Each is a range of strengths (0-100); the bot draws its strength for a game from +// it. The ranges leave gaps so neighbouring levels stay clearly apart. +public enum BotLevel { + EASY("easy", 5, 25, 800, 1800), + MEDIUM("medium", 30, 55, 1000, 2200), + HARD("hard", 60, 80, 1200, 2600), + MASTER("master", 85, 100, 1400, 3000); + + private static final String BOT_ID_PREFIX = "bot:"; + + private final String wireName; + private final int minStrength; + private final int maxStrength; + // How long the bot seems to think before a move, on top of the previous move's animation. + private final int minThinkMs; + private final int maxThinkMs; + + BotLevel(String wireName, int minStrength, int maxStrength, int minThinkMs, int maxThinkMs) { + this.wireName = wireName; + this.minStrength = minStrength; + this.maxStrength = maxStrength; + this.minThinkMs = minThinkMs; + this.maxThinkMs = maxThinkMs; + } + + @JsonValue + public String wireName() { + return wireName; + } + + public int minStrength() { + return minStrength; + } + + public int maxStrength() { + return maxStrength; + } + + public int middleStrength() { + return (minStrength + maxStrength) / 2; + } + + public int drawStrength(RandomGenerator random) { + return random.nextInt(minStrength, maxStrength + 1); + } + + public long drawThinkMs(RandomGenerator random) { + return random.nextLong(minThinkMs, maxThinkMs + 1); + } + + // The user id the bot plays with at this level, e.g. bot:medium. + public String botId() { + return BOT_ID_PREFIX + wireName; + } + + // The level from its wire name, or null. + @JsonCreator + public static BotLevel fromWireName(String wireName) { + for (BotLevel level : values()) { + if (level.wireName.equals(wireName)) return level; + } + return null; + } + + public static boolean isBotId(String userId) { + return userId != null && userId.startsWith(BOT_ID_PREFIX); + } + + // The level of a bot id, or null if it is not one. + public static BotLevel fromBotId(String userId) { + return isBotId(userId) ? fromWireName(userId.substring(BOT_ID_PREFIX.length())) : null; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java new file mode 100644 index 0000000..c6a8992 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java @@ -0,0 +1,58 @@ +package app.playmancala.bot; + +import java.util.SplittableRandom; + +import app.playmancala.engine.Position; + +// Chooses the bot's move. Everything random comes from the game's seed and the move number, so a move can be +// reproduced from the saved game. +public final class BotPolicy { + // How far the strength of a single move may stray from the game's strength. + static final int MOVE_STRENGTH_SPREAD = 5; + private static final long MOVE_NUMBER_MIX = 0x9E3779B97F4A7C15L; + + private BotPolicy() { + } + + // The pit (0-5) of the side to move to play. `moveNumber` is the number of moves played in the game so far. + public static int chooseMove(Position position, BotSettings bot, int moveNumber) { + return chooseMove(position, bot, moveNumber, BotSearch.DEFAULT_NODE_BUDGET); + } + + static int chooseMove(Position position, BotSettings bot, int moveNumber, long nodeBudget) { + SplittableRandom random = new SplittableRandom(bot.seed() + MOVE_NUMBER_MIX * (moveNumber + 1)); + int strength = Math.clamp(bot.strength() + random.nextInt(-MOVE_STRENGTH_SPREAD, MOVE_STRENGTH_SPREAD + 1), 0, 100); + BotStrength.Params params = BotStrength.at(strength); + int[] pits = new int[Position.PIT_COUNT]; + int count = position.legalPits(pits); + if (count == 0) throw new IllegalArgumentException("No moves in " + position); + // Drawn every time, so the draws that follow do not depend on how many moves there are. + double randomMoveDraw = random.nextDouble(); + double choiceDraw = random.nextDouble(); + if (count == 1) return pits[0]; + if (randomMoveDraw < params.randomMoveChance()) return pits[(int) (choiceDraw * count)]; + + BotSearch.Result result = BotSearch.scoreMoves(position, params.depth(), nodeBudget); + return pick(result, params.temperature(), choiceDraw); + } + + // A move with probability proportional to exp(score / temperature); temperature in stones. + static int pick(BotSearch.Result result, double temperature, double draw) { + int[] scores = result.scores(); + int best = Integer.MIN_VALUE; + for (int score : scores) best = Math.max(best, score); + double scale = Math.max(temperature, 0.001) * 100; + double[] weights = new double[scores.length]; + double total = 0; + for (int i = 0; i < scores.length; i++) { + weights[i] = Math.exp((scores[i] - best) / scale); + total += weights[i]; + } + double target = draw * total; + for (int i = 0; i < scores.length; i++) { + target -= weights[i]; + if (target < 0) return result.pits()[i]; + } + return result.pits()[scores.length - 1]; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java new file mode 100644 index 0000000..e844587 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java @@ -0,0 +1,140 @@ +package app.playmancala.bot; + +import java.util.Arrays; + +import app.playmancala.engine.Position; + +// Scores every move of a position with negamax and alpha-beta pruning. Scores are from the mover's view in +// hundredths of a stone; a move that gives an extra turn keeps the side to move, so its score is not negated. +// Deepens one ply at a time up to the depth; when the node budget runs out, the scores of the last completed depth +// are returned. Nothing depends on time, so the same position, depth and budget always give the same scores. +public final class BotSearch { + public static final long DEFAULT_NODE_BUDGET = 3_000_000; + // An ended (or decided) game scores beyond any bank difference. + public static final int WIN = 100_000; + // A bank with more than half of the 48 stones has won. + private static final int DECIDING_BANK = 24; + private static final int STONE = 100; + // Leaf score for each stone more in the mover's pits than in the opponent's. + static final int PIT_STONE_WEIGHT = 0; + + public record Result(int[] pits, int[] scores, int depth, long nodes) { + public int count() { + return pits.length; + } + } + + private static final class OutOfBudget extends RuntimeException { + OutOfBudget() { + super(null, null, false, false); + } + } + + private final long nodeBudget; + private Position[][] children; + private int[][] order; + private boolean[][] extraTurns; + private int[][] orderKeys; + private long nodes; + private boolean budgetApplies; + + private BotSearch(long nodeBudget) { + this.nodeBudget = nodeBudget; + } + + public static Result scoreMoves(Position root, int maxDepth) { + return scoreMoves(root, maxDepth, DEFAULT_NODE_BUDGET); + } + + public static Result scoreMoves(Position root, int maxDepth, long nodeBudget) { + return new BotSearch(nodeBudget).run(root, Math.max(1, maxDepth)); + } + + private Result run(Position root, int maxDepth) { + children = new Position[maxDepth + 1][Position.PIT_COUNT]; + order = new int[maxDepth + 1][Position.PIT_COUNT]; + extraTurns = new boolean[maxDepth + 1][Position.PIT_COUNT]; + orderKeys = new int[maxDepth + 1][Position.PIT_COUNT]; + for (Position[] ply : children) { + for (int i = 0; i < ply.length; i++) ply[i] = root.copy(); + } + int[] pits = new int[Position.PIT_COUNT]; + int count = root.legalPits(pits); + if (count == 0) throw new IllegalArgumentException("No moves in " + root); + pits = Arrays.copyOf(pits, count); + Position[] rootChildren = new Position[count]; + boolean[] rootExtraTurns = new boolean[count]; + for (int i = 0; i < count; i++) { + rootChildren[i] = root.copy(); + rootExtraTurns[i] = root.play(pits[i], rootChildren[i]); + } + + int[] scores = null; + int completedDepth = 0; + for (int depth = 1; depth <= maxDepth; depth++) { + budgetApplies = depth > 1; + int[] depthScores = new int[count]; + try { + for (int i = 0; i < count; i++) { + nodes++; + int value = negamax(rootChildren[i], depth - 1, -Integer.MAX_VALUE, Integer.MAX_VALUE, 1); + depthScores[i] = rootExtraTurns[i] ? value : -value; + } + } catch (OutOfBudget e) { + break; + } + scores = depthScores; + completedDepth = depth; + } + return new Result(pits, scores, completedDepth, nodes); + } + + private int negamax(Position position, int depth, int alpha, int beta, int ply) { + if (budgetApplies && nodes > nodeBudget) throw new OutOfBudget(); + if (position.isOver()) return endScore(position.bankDifference()); + int ownBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK); + int opponentBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER2_BANK : Position.PLAYER1_BANK); + if (ownBank > DECIDING_BANK || opponentBank > DECIDING_BANK) return endScore(ownBank - opponentBank); + if (depth == 0) return STONE * (ownBank - opponentBank) + PIT_STONE_WEIGHT * position.pitStoneDifference(); + + // Play every move once to order them: extra turns first, then by the stones they bring to the bank. + Position[] next = children[ply]; + int[] moveOrder = order[ply]; + boolean[] extra = extraTurns[ply]; + int[] keys = orderKeys[ply]; + int count = 0; + for (int pit = 0; pit < Position.PIT_COUNT; pit++) { + if (position.stonesAt(position.boardIndex(pit)) == 0) continue; + boolean extraTurn = position.play(pit, next[pit]); + extra[pit] = extraTurn; + int gain = next[pit].stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK) - ownBank; + int key = (extraTurn ? 1000 : 0) + gain; + int at = count++; + while (at > 0 && keys[at - 1] < key) { + keys[at] = keys[at - 1]; + moveOrder[at] = moveOrder[at - 1]; + at--; + } + keys[at] = key; + moveOrder[at] = pit; + } + + int best = -Integer.MAX_VALUE; + for (int i = 0; i < count; i++) { + int pit = moveOrder[i]; + nodes++; + int value = extra[pit] + ? negamax(next[pit], depth - 1, alpha, beta, ply + 1) + : -negamax(next[pit], depth - 1, -beta, -alpha, ply + 1); + if (value > best) best = value; + if (best > alpha) alpha = best; + if (alpha >= beta) break; + } + return best; + } + + private static int endScore(int bankDifference) { + int sign = Integer.signum(bankDifference); + return sign * WIN + STONE * bankDifference; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java new file mode 100644 index 0000000..e91b567 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java @@ -0,0 +1,16 @@ +package app.playmancala.bot; + +import java.util.random.RandomGenerator; + +// How the bot plays one game: its level, the strength drawn for the game and the seed its choices come from. Kept +// with the live game and the saved game, so a restart does not change how it plays and its moves can be reproduced. +public record BotSettings(BotLevel level, int strength, long seed) { + + public static BotSettings draw(BotLevel level, RandomGenerator random) { + return new BotSettings(level, level.drawStrength(random), random.nextLong()); + } + + public String botId() { + return level.botId(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java new file mode 100644 index 0000000..20d5207 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java @@ -0,0 +1,34 @@ +package app.playmancala.bot; + +// What a strength (0-100) means for one move: how deep the bot searches, how strongly it prefers the best move +// (softmax temperature in stones; lower is more exact) and how often it plays a random move instead. Values between +// the rows of the table are interpolated linearly. +public final class BotStrength { + + public record Params(int depth, double temperature, double randomMoveChance) { + } + + private static final double[] STRENGTHS = { 0, 25, 50, 75, 100 }; + private static final double[] DEPTHS = { 1, 2, 4, 7, 16 }; + private static final double[] TEMPERATURES = { 4, 2, 1, 0.4, 0.05 }; + private static final double[] RANDOM_MOVE_CHANCES = { 0.35, 0.15, 0.05, 0.01, 0 }; + + private BotStrength() { + } + + public static Params at(double strength) { + double s = Math.clamp(strength, 0, 100); + return new Params((int) Math.round(interpolate(DEPTHS, s)), interpolate(TEMPERATURES, s), + interpolate(RANDOM_MOVE_CHANCES, s)); + } + + private static double interpolate(double[] values, double strength) { + for (int row = 1; row < STRENGTHS.length; row++) { + if (strength <= STRENGTHS[row]) { + double t = (strength - STRENGTHS[row - 1]) / (STRENGTHS[row] - STRENGTHS[row - 1]); + return values[row - 1] + t * (values[row] - values[row - 1]); + } + } + return values[values.length - 1]; + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java new file mode 100644 index 0000000..68bf92e --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java @@ -0,0 +1,126 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +class BotPolicyTest { + + @Test + void levelsHaveWireNamesAndBotIds() { + assertEquals(BotLevel.MEDIUM, BotLevel.fromWireName("medium")); + assertNull(BotLevel.fromWireName("impossible")); + assertEquals("bot:master", BotLevel.MASTER.botId()); + assertEquals(BotLevel.EASY, BotLevel.fromBotId("bot:easy")); + assertTrue(BotLevel.isBotId("bot:hard")); + assertNull(BotLevel.fromBotId("kc:someone")); + } + + @Test + void strengthsAreDrawnWithinTheLevel() { + SplittableRandom random = new SplittableRandom(1); + for (BotLevel level : BotLevel.values()) { + for (int i = 0; i < 500; i++) { + int strength = BotSettings.draw(level, random).strength(); + assertTrue(strength >= level.minStrength() && strength <= level.maxStrength(), level + " " + strength); + } + } + } + + @Test + void strengthIsInterpolatedFromTheTable() { + assertParams(1, 4, 0.35, BotStrength.at(0)); + assertParams(4, 1, 0.05, BotStrength.at(50)); + assertParams(16, 0.05, 0, BotStrength.at(100)); + BotStrength.Params between = BotStrength.at(37.5); + assertEquals(3, between.depth()); + assertEquals(1.5, between.temperature(), 1e-9); + assertEquals(BotStrength.at(100), BotStrength.at(130)); + } + + private static void assertParams(int depth, double temperature, double randomMoveChance, BotStrength.Params params) { + assertEquals(depth, params.depth()); + assertEquals(temperature, params.temperature(), 1e-9); + assertEquals(randomMoveChance, params.randomMoveChance(), 1e-9); + } + + @Test + void sameSeedAndMoveNumberGiveTheSameMove() { + Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, true); + for (BotLevel level : BotLevel.values()) { + BotSettings bot = new BotSettings(level, level.middleStrength(), 42); + int first = BotPolicy.chooseMove(position, bot, 10); + for (int i = 0; i < 5; i++) assertEquals(first, BotPolicy.chooseMove(position, bot, 10)); + } + } + + @Test + void playsTheOnlyMove() { + Position position = Position.of(new int[] { 0, 0, 0, 0, 0, 3, 20, 1, 1, 1, 1, 1, 1, 19 }, true); + assertEquals(5, BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, 1), 0)); + } + + @Test + void easyMissesAClearBestMoveMuchMoreOftenThanMaster() { + // Pit 0 captures 10 stones; no other move comes close. + Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true); + int[] master = new int[Position.PIT_COUNT]; + int[] easy = new int[Position.PIT_COUNT]; + for (int seed = 0; seed < 200; seed++) { + master[BotPolicy.chooseMove(position, new BotSettings(BotLevel.MASTER, 100, seed), 0)]++; + easy[BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, seed), 0)]++; + } + assertTrue(master[0] >= 195, "Master: " + Arrays.toString(master)); + assertTrue(easy[0] <= 180, "Easy: " + Arrays.toString(easy)); + } + + @Test + void gamesAtTheSameLevelDiffer() { + // R2.5: 20 games against an opponent that always plays its first pit; the bot moves first. + Set> openings = new HashSet<>(); + SplittableRandom random = new SplittableRandom(3); + int[] pits = new int[Position.PIT_COUNT]; + for (int game = 0; game < 20; game++) { + BotSettings bot = BotSettings.draw(BotLevel.MEDIUM, random); + Position position = Position.initial(true); + List botMoves = new ArrayList<>(); + int moveNumber = 0; + while (!position.isOver() && botMoves.size() < 5) { + int pit; + if (position.isPlayer1ToMove()) { + pit = BotPolicy.chooseMove(position, bot, moveNumber); + botMoves.add(pit); + } else { + position.legalPits(pits); + pit = pits[0]; + } + position.play(pit, position); + moveNumber++; + } + openings.add(botMoves); + } + assertTrue(openings.size() > 1, "every game opened with " + openings); + } + + @Test + void picksMovesByTheirScores() { + BotSearch.Result result = new BotSearch.Result(new int[] { 0, 2, 4 }, new int[] { 0, 500, 100 }, 1, 0); + // At a low temperature the best move takes almost all the weight. + assertEquals(2, BotPolicy.pick(result, 0.05, 0.01)); + assertEquals(2, BotPolicy.pick(result, 0.05, 0.999)); + // At a high temperature every move can be drawn. + assertEquals(0, BotPolicy.pick(result, 100, 0.0)); + assertEquals(4, BotPolicy.pick(result, 100, 0.999)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java new file mode 100644 index 0000000..d8fc568 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java @@ -0,0 +1,82 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +class BotSearchTest { + + private static int bestPit(BotSearch.Result result) { + int best = 0; + for (int i = 1; i < result.count(); i++) { + if (result.scores()[i] > result.scores()[best]) best = i; + } + return result.pits()[best]; + } + + @Test + void scoresEveryLegalMove() { + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 3); + assertArrayEquals(new int[] { 0, 1, 2, 3, 4, 5 }, result.pits()); + assertEquals(6, result.scores().length); + assertEquals(3, result.depth()); + } + + @Test + void takesTheExtraTurn() { + // Pit 3 ends in the bank and gives another move; pits 4 and 5 gain a stone too but pass the turn. + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 2); + assertEquals(3, bestPit(result)); + } + + @Test + void capturesTheStonesOpposite() { + // Pit 0's single stone lands in the empty pit 1, opposite pit 11 with 9 stones. + Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true); + assertEquals(0, bestPit(BotSearch.scoreMoves(position, 1))); + } + + @Test + void takesAnEvenPitOfTheOpponent() { + // Pit 5 with 3 stones: 5, the bank and pit 7, which then holds 6. + Position position = Position.of(new int[] { 1, 1, 1, 1, 1, 3, 10, 5, 1, 1, 1, 1, 1, 20 }, true); + BotSearch.Result result = BotSearch.scoreMoves(position, 1); + assertEquals(5, bestPit(result)); + } + + @Test + void seesAWinTwoMovesAhead() { + // Pit 5's stone goes to the bank; with the extra turn, pit 4's stone captures pit 7, the side is empty and + // takes the opponent's stones. Playing pit 4 first passes the turn. + Position position = Position.of(new int[] { 0, 0, 0, 0, 1, 1, 21, 1, 1, 1, 1, 1, 1, 19 }, true); + BotSearch.Result result = BotSearch.scoreMoves(position, 2); + int best = 0; + for (int i = 1; i < result.count(); i++) { + if (result.scores()[i] > result.scores()[best]) best = i; + } + assertEquals(5, result.pits()[best]); + assertTrue(result.scores()[best] > BotSearch.WIN, "a won game: " + result.scores()[best]); + } + + @Test + void sameInputGivesTheSameScores() { + Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, false); + BotSearch.Result first = BotSearch.scoreMoves(position, 8); + BotSearch.Result second = BotSearch.scoreMoves(position, 8); + assertArrayEquals(first.scores(), second.scores()); + assertEquals(first.nodes(), second.nodes()); + } + + @Test + void stopsAtTheNodeBudgetWithTheLastCompletedDepth() { + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 30, 20_000); + assertTrue(result.depth() >= 1 && result.depth() < 30, "depth " + result.depth()); + assertEquals(6, result.scores().length); + // Stops within the depth that ran out of budget. + assertTrue(result.nodes() < 20_000 + 20_000, "nodes " + result.nodes()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java new file mode 100644 index 0000000..bcbad53 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java @@ -0,0 +1,88 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +// Plays the levels against each other (R2.4). The full tournament is slow and tagged `calibration`, so it is left +// out of the normal tests; run it with ./mvnw test -Dgroups=calibration -DexcludedGroups=none after changing the +// search or the strength table. +class BotTournamentTest { + + record Score(int wins, int losses, int draws, long moves, long nanos) { + int games() { + return wins + losses + draws; + } + + double winRate() { + return (wins + draws / 2.0) / games(); + } + } + + @Test + void masterBeatsEasy() { + Score score = play(BotLevel.MASTER, BotLevel.EASY, 20, 1); + assertTrue(score.wins() >= 19, () -> "Master against Easy: " + score); + } + + @Test + @Tag("calibration") + void strongerLevelsWin() { + int games = Integer.getInteger("bot.tournament.games", 200); + BotLevel[] levels = BotLevel.values(); + StringBuilder report = new StringBuilder(); + boolean ok = true; + for (int i = 1; i < levels.length; i++) { + Score score = play(levels[i], levels[i - 1], games, 100 + i); + report.append(line(levels[i], levels[i - 1], score)); + ok &= score.winRate() >= 0.65; + } + Score masterEasy = play(BotLevel.MASTER, BotLevel.EASY, games, 200); + report.append(line(BotLevel.MASTER, BotLevel.EASY, masterEasy)); + ok &= masterEasy.winRate() >= 0.95; + System.out.println(report); + assertTrue(ok, report::toString); + } + + private static String line(BotLevel stronger, BotLevel weaker, Score score) { + return String.format("%-6s vs %-6s won %3d lost %3d drawn %3d rate %.2f %.1f ms/move%n", stronger, weaker, + score.wins(), score.losses(), score.draws(), score.winRate(), + score.nanos() / 1e6 / Math.max(1, score.moves())); + } + + // Plays `games` games with strengths drawn per game, alternating who moves first. + static Score play(BotLevel first, BotLevel second, int games, long seed) { + SplittableRandom random = new SplittableRandom(seed); + int wins = 0, losses = 0, draws = 0; + long moves = 0, nanos = 0; + for (int game = 0; game < games; game++) { + BotSettings a = BotSettings.draw(first, random); + BotSettings b = BotSettings.draw(second, random); + boolean aIsPlayer1 = game % 2 == 0; + Position position = Position.initial(random.nextBoolean()); + int moveNumber = 0; + while (!position.isOver()) { + boolean aToMove = position.isPlayer1ToMove() == aIsPlayer1; + long start = System.nanoTime(); + int pit = BotPolicy.chooseMove(position, aToMove ? a : b, moveNumber); + if (aToMove) { + nanos += System.nanoTime() - start; + moves++; + } + position.play(pit, position); + moveNumber++; + } + int player1Lead = position.stonesAt(Position.PLAYER1_BANK) - position.stonesAt(Position.PLAYER2_BANK); + int aLead = aIsPlayer1 ? player1Lead : -player1Lead; + if (aLead > 0) wins++; + else if (aLead < 0) losses++; + else draws++; + } + return new Score(wins, losses, draws, moves, nanos); + } +} diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 79fd051..e5fdf23 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -83,12 +83,13 @@ empty. negated. - Scores are from the mover's view, in hundredths of a stone. An ended game scores `±100_000 + 100 × final bank difference`; a game where a bank holds more than 24 stones is decided and scores as - ended. Other leaves: `100 × bank difference` plus small terms for stones on each side and moves that give an extra - turn; the weights are tuned in task 3. -- Move order: extra turns, then captures, then the rest, then the best move of the previous iteration. + ended. Other leaves: `100 × bank difference`. A term for the stones on each side (`PIT_STONE_WEIGHT`) is there + but set to 0: the levels met R2.4 without it, and stones on a side may still go to either player. +- Move order: extra turns first, then by the stones a move brings to the bank. Each node plays its moves once to + order them, into arrays kept per ply, so searching allocates nothing. - Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed - depth are used. + depth are used; depth 1 always completes. ### Strength (`BotLevel`, `BotStrength`) @@ -99,7 +100,7 @@ empty. | Hard | `hard` | 60–80 | | Master | `master` | 85–100 | -Starting values, interpolated linearly between rows (depth rounded); tuned in task 3 until R2.4 holds: +Interpolated linearly between rows (depth rounded): | Strength | Depth | Temperature (stones) | Random move chance | |---|---|---|---| @@ -107,7 +108,20 @@ Starting values, interpolated linearly between rows (depth rounded); tuned in ta | 25 | 2 | 2 | 15% | | 50 | 4 | 1 | 5% | | 75 | 7 | 0.4 | 1% | -| 100 | 12 | 0.05 | 0% | +| 100 | 16 | 0.05 | 0% | + +The first table had depth 12 at strength 100 and already met R2.4. Searching deeper still helped: with best moves +only, depth 16 scored 64% against depth 12 (depth 14: 54%), at about 60 ms per move on average and 1 s at most +without a budget. So the top row is 16, and the node budget keeps the slowest moves near 0.2 s. + +`BotTournamentTest` with these values, 200 games per pair (October 2026, 24 cores, one thread): + +| Pair | Won | Lost | Drawn | Score | Stronger level's time per move | +|---|---|---|---|---|---| +| Medium vs Easy | 155 | 34 | 11 | 0.80 | < 0.1 ms | +| Hard vs Medium | 158 | 29 | 13 | 0.82 | 0.2 ms | +| Master vs Hard | 177 | 16 | 7 | 0.90 | 20 ms | +| Master vs Easy | 200 | 0 | 0 | 1.00 | 22 ms | ### Choosing a move (`BotPolicy`) ```java diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 20f91e8..b7290c3 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/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. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity games and random games. -- [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit +- [x] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the strength table until R2.4 holds, and write the final values into `design.md`. - [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, -- 2.45.2 From f0b75e5cee4271bffd286a8373494eea908a9b62 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:18:45 +0300 Subject: [PATCH 59/82] feat(backend): play games against the bot new_bot_game starts a game against the bot at the chosen level and ends the search for an opponent. BotPlayer works out the bot's moves on its own thread pool and plays them on the game loop after the previous move's animation and a thinking time. Bot games keep their settings across restarts. --- .../main/java/app/playmancala/Backend.java | 14 + .../java/app/playmancala/bot/BotPlayer.java | 106 ++++++++ .../app/playmancala/game/BackendGame.java | 10 +- .../app/playmancala/game/GameManager.java | 86 +++++- .../playmancala/game/GameSerialization.java | 20 +- .../java/app/playmancala/game/GameStore.java | 4 + .../java/app/playmancala/game/PublicIds.java | 6 +- .../java/app/playmancala/model/Channels.java | 2 + .../src/main/resources/application.properties | 3 + .../app/playmancala/bot/BotPlayerTest.java | 249 ++++++++++++++++++ docs/specs/game-bot/design.md | 12 +- docs/specs/game-bot/tasks.md | 2 +- 12 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java diff --git a/apps/backend/src/main/java/app/playmancala/Backend.java b/apps/backend/src/main/java/app/playmancala/Backend.java index 2386982..2be5492 100644 --- a/apps/backend/src/main/java/app/playmancala/Backend.java +++ b/apps/backend/src/main/java/app/playmancala/Backend.java @@ -1,6 +1,7 @@ package app.playmancala; import java.sql.SQLException; +import java.util.SplittableRandom; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import app.playmancala.auth.TokenVerifier; +import app.playmancala.bot.BotPlayer; import app.playmancala.game.GameClockScheduler; import app.playmancala.game.GameManager; import app.playmancala.game.GameRecorder; @@ -55,6 +57,10 @@ public class Backend { @ConfigProperty(name = "mancala.game-clock-ms") long gameClockMs; + // 0: half the processors. + @ConfigProperty(name = "mancala.bot-search-threads") + int botSearchThreads; + private GameLoop loop; // Valkey writes, in order. private ExecutorService storeWriter; @@ -62,6 +68,8 @@ public class Backend { private ScheduledExecutorService recorderIo; // Token checks. private ExecutorService verifier; + // Working out the bot's moves. + private ExecutorService botSearch; private RtmtServer rtmt; private GameStore gameStore; @@ -76,6 +84,10 @@ public class Backend { storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory()); recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory()); verifier = Executors.newVirtualThreadPerTaskExecutor(); + int searchThreads = botSearchThreads > 0 ? botSearchThreads + : Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + botSearch = Executors.newFixedThreadPool(searchThreads, + Thread.ofPlatform().name("bot-search-", 0).daemon().factory()); ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis); PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource); @@ -90,6 +102,7 @@ public class Backend { gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter, loop::now, gameClockMs); liveGames = new LiveGames(rtmt, gameStore, gameManager, loop); + new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom()); clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop, GameClockScheduler.CLOCK_CHECK_INTERVAL_MS); @@ -112,6 +125,7 @@ public class Backend { storeWriter.shutdown(); recorderIo.shutdownNow(); verifier.shutdownNow(); + botSearch.shutdownNow(); } private void migrate() { diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java new file mode 100644 index 0000000..5203605 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java @@ -0,0 +1,106 @@ +package app.playmancala.bot; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.random.RandomGenerator; + +import org.jboss.logging.Logger; + +import app.playmancala.engine.GameState; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; +import app.playmancala.engine.Position; +import app.playmancala.game.BackendGame; +import app.playmancala.game.GameClocks; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameStore; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.Seat; + +// Plays the bot's turns in live games. The move is worked out on `searchExecutor`, never on the game loop; it is +// played on the loop once the previous move's animation and the bot's thinking time are over, if the game is still +// waiting for it. Everything except the search runs on the loop. +public class BotPlayer { + private static final Logger LOG = Logger.getLogger(BotPlayer.class); + // PitAnimator's DEFAULT_ANIMATION_INTERVAL in packages/core: clients animate a move one step at a time. + static final long ANIMATION_STEP_MS = 300; + // The game page needs a moment to open before the bot's first move. + static final long GAME_START_DELAY_MS = 1_500; + // With less time than this on its clock, the bot stops pretending to think. + static final long LOW_TIME_MS = 30_000; + static final long LOW_TIME_THINK_MS = 300; + + private final GameManager gameManager; + private final GameStore gameStore; + private final Scheduler loop; + private final Executor searchExecutor; + private final RandomGenerator random; + // Games whose bot move is being worked out; by identity, since a restored game is a new object with the same id. + private final Set pending = Collections.newSetFromMap(new IdentityHashMap<>()); + + public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor, + RandomGenerator random) { + this.gameManager = gameManager; + this.gameStore = gameStore; + this.loop = loop; + this.searchExecutor = searchExecutor; + this.random = random; + gameManager.onTurnStarted(this::onTurnStarted); + } + + private void onTurnStarted(BackendGame game) { + if (!game.isBotTurn() || game.mancalaGame.getState() == GameState.ENDED || !pending.add(game)) return; + Position position = Position.of(game.mancalaGame); + int moveNumber = game.mancalaGame.getHistory().size(); + BotSettings bot = game.bot; + long readyAt = loop.now() + delayMs(game); + searchExecutor.execute(() -> { + int pit = choose(position, bot, moveNumber); + loop.execute(() -> loop.schedule(() -> play(game, moveNumber, pit), Math.max(0, readyAt - loop.now()))); + }); + } + + private static int choose(Position position, BotSettings bot, int moveNumber) { + try { + return BotPolicy.chooseMove(position, bot, moveNumber); + } catch (RuntimeException e) { + // A failed search must not leave the game waiting: play any move. + LOG.errorf(e, "(BotPlayer) Search failed in %s", position); + int[] pits = new int[Position.PIT_COUNT]; + position.legalPits(pits); + return pits[0]; + } + } + + private void play(BackendGame game, int moveNumber, int pit) { + pending.remove(game); + // The game may have ended (the player left or ran out of time) or been replaced by a restore meanwhile. + if (gameStore.get(game.id) != game || game.mancalaGame.getHistory().size() != moveNumber || !game.isBotTurn()) { + return; + } + gameManager.playMove(game, game.bot.botId(), pit); + } + + // The previous move's animation plus the bot's thinking time, shorter when its clock runs low. + long delayMs(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + Seat seat = GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), game.bot.botId()); + if (game.clock.turnStartedAt() != null + && GameClocks.getRemainingMs(game.clock, seat, seat, loop.now()) < LOW_TIME_MS) { + return LOW_TIME_THINK_MS; + } + List history = mancalaGame.getHistory(); + long waitBefore = history.isEmpty() ? GAME_START_DELAY_MS : animationMs(history.getLast()); + return waitBefore + game.bot.level().drawThinkMs(random); + } + + static long animationMs(MoveHistoryItem move) { + long steps = move.getGameSteps().stream().map(GameStep::type) + .filter(MancalaGame.GAME_STEP_GAME_MOVE::equals).count(); + return (steps + 1) * ANIMATION_STEP_MS; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java index 570bbf4..0a4ac6f 100644 --- a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java +++ b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.MancalaGame; import app.playmancala.model.GameClock; import app.playmancala.model.GameUsersConnectionInfo; @@ -27,10 +28,12 @@ public class BackendGame { public GameClock clock; // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. public final List moveRemainingMs; + // How the bot plays in a game against the bot; null in games between people. + public final BotSettings bot; public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, List allSpectatorIds, int peakSpectators, Map players, long startedAt, - List moveTimes, GameClock clock, List moveRemainingMs) { + List moveTimes, GameClock clock, List moveRemainingMs, BotSettings bot) { this.id = mancalaGame.getId(); this.mancalaGame = mancalaGame; this.gameUsersConnectionInfo = gameUsersConnectionInfo; @@ -41,6 +44,11 @@ public class BackendGame { this.moveTimes = new ArrayList<>(moveTimes); this.clock = clock; this.moveRemainingMs = new ArrayList<>(moveRemainingMs); + this.bot = bot; + } + + public boolean isBotTurn() { + return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId()); } public PlayerSummary player(String userId) { diff --git a/apps/backend/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java index 545155a..a92d460 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameManager.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameManager.java @@ -7,12 +7,16 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.function.IntSupplier; import java.util.function.LongSupplier; import org.jboss.logging.Logger; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.GameState; import app.playmancala.engine.MancalaGame; @@ -35,6 +39,8 @@ import app.playmancala.rtmt.Rtmt; // Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop. public class GameManager { private static final Logger LOG = Logger.getLogger(GameManager.class); + // Clients show the bot by its id ("Bot · Medium"); the name is for anything that only shows names. + static final String BOT_NAME = "Bot"; private final GameStore gameStore; private final Rtmt rtmt; @@ -46,6 +52,7 @@ public class GameManager { // Time each player has for a new game. private final long gameClockMs; private final List liveGamesListeners = new ArrayList<>(); + private final List> turnListeners = new ArrayList<>(); public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { @@ -67,6 +74,12 @@ public class GameManager { liveGamesListeners.add(listener); } + // Called whenever a player is to move: when a game starts or is restored, and after a move that did not end it, + // including one that gives an extra turn. + public void onTurnStarted(Consumer listener) { + turnListeners.add(listener); + } + private void listenRtmtMessages() { rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { BackendGame activeGame = gameStore.getGameByUser(userId); @@ -76,6 +89,7 @@ public class GameManager { } matchMaker.join(userId); }); + rtmt.listenMessage(Channels.NEW_BOT_GAME, this::onNewBotGame); rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId)); rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove); rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId)); @@ -87,12 +101,44 @@ public class GameManager { }); } + private void onNewBotGame(String userId, JsonNode message) { + BotLevel level = BotLevel.fromWireName(message.path("level").asText(null)); + if (level == null) { + LOG.debugf("(GameManager) Unknown bot level: %s", message); + return; + } + BackendGame activeGame = gameStore.getGameByUser(userId); + if (activeGame != null) { + rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id)); + return; + } + matchMaker.cancel(userId); + BotSettings bot = BotSettings.draw(level, ThreadLocalRandom.current()); + startGame(createMancalaGame(userId, bot.botId(), bot)); + } + private void onGameMove(String userId, JsonNode gameMove) { BackendGame game = gameStore.getGameByUser(userId); if (game == null) { LOG.debug("Game not found!"); return; } + playMove(game, userId, () -> { + JsonNode index = gameMove.path("index"); + if (!index.canConvertToInt() || !index.isIntegralNumber()) { + throw new IllegalStateException("Invalid move: " + gameMove); + } + return index.asInt(); + }); + } + + // Plays a player's pit (0-5) in a live game; the bot's moves come here too. + public void playMove(BackendGame game, String userId, int pitIndex) { + playMove(game, userId, () -> pitIndex); + } + + // The pit is read after the clock check, so an invalid move after the time has run out still ends the game on time. + private void playMove(BackendGame game, String userId, IntSupplier pitIndex) { MancalaGame mancalaGame = game.mancalaGame; try { long now = clock.getAsLong(); @@ -101,13 +147,10 @@ public class GameManager { endGameOnTime(game); return; } - JsonNode index = gameMove.path("index"); - if (!index.canConvertToInt() || !index.isIntegralNumber()) { - throw new IllegalStateException("Invalid move: " + gameMove); - } + int index = pitIndex.getAsInt(); int movesBefore = mancalaGame.getHistory().size(); Seat moverSeat = seatOf(mancalaGame, userId); - mancalaGame.moveByPlayerPit(userId, index.asInt()); + mancalaGame.moveByPlayerPit(userId, index); if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { game.moveTimes.add(now); GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); @@ -121,6 +164,7 @@ public class GameManager { } else { saveLiveGame(game); notifyLiveGamesChanged(); + notifyTurnStarted(game); } } catch (RuntimeException e) { onGameError(game, e); @@ -226,6 +270,7 @@ public class GameManager { if (restored > 0) { LOG.infof("(GameManager) Restored %d live game(s)", restored); notifyLiveGamesChanged(); + gameStore.getAll().forEach(this::notifyTurnStarted); } return restored; } @@ -256,17 +301,27 @@ public class GameManager { } private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { - rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); - rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message); List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message)); } + // The bot has no connection to send to. + private void sendMessageToPlayer(String playerId, String channel, Object message) { + if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, channel, message); + } + public ClientGame toClientGame(BackendGame game) { return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong()); } public BackendGame createMancalaGame(String userId1, String userId2) { + return createMancalaGame(userId1, userId2, null); + } + + // `bot` is set for a game against the bot, which plays as one of the two users. + public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) { MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); long now = clock.getAsLong(); @@ -276,7 +331,7 @@ public class GameManager { 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()); + players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot); gameStore.set(game.id, game); gameStore.setGameByUser(userId1, game); gameStore.setGameByUser(userId2, game); @@ -284,6 +339,7 @@ public class GameManager { } private PlayerSummary getPlayerInfo(String userId) { + if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false); AuthUser user = rtmt.getUser(userId); return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest()); } @@ -295,8 +351,13 @@ public class GameManager { private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) { return new GameUsersConnectionInfo( - UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())), - UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId()))); + UserConnectionInfo.of(player1, isOnline(player1.userId())), + UserConnectionInfo.of(player2, isOnline(player2.userId()))); + } + + // The bot is always there. + private boolean isOnline(String userId) { + return BotLevel.isBotId(userId) || rtmt.isClientOnline(userId); } public void startGame(BackendGame game) { @@ -304,6 +365,7 @@ public class GameManager { sendUserConnectionInfo(game); saveLiveGame(game); notifyLiveGamesChanged(); + notifyTurnStarted(game); } public void sendUserConnectionInfo(BackendGame game) { @@ -337,6 +399,10 @@ public class GameManager { liveGamesListeners.forEach(Runnable::run); } + private void notifyTurnStarted(BackendGame game) { + turnListeners.forEach(listener -> listener.accept(game)); + } + private void listenUserConnectionChange() { rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { BackendGame game = gameStore.getGameByUser(clientId); diff --git a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java index 7331021..97990d6 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -5,12 +5,15 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import org.jboss.logging.Logger; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.GameState; import app.playmancala.engine.MancalaGame; @@ -53,7 +56,9 @@ public final class GameSerialization { int peakSpectators, List allSpectatorIds, GameClock clock, - @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs) { + @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs, + // Games against the bot only. + BotSettings bot) { } public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { @@ -69,7 +74,7 @@ public final class GameSerialization { 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))); + Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot); } // The snapshot in the JSON, or null if it is not one this backend can restore. @@ -112,7 +117,16 @@ public final class GameSerialization { 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); + orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot)); + } + + // The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at + // the middle of its level. + private static BotSettings restoreBot(LiveGameSnapshot snapshot) { + if (snapshot.bot() != null) return snapshot.bot(); + BotLevel level = BotLevel.fromBotId(snapshot.player2Id()); + if (level == null) level = BotLevel.fromBotId(snapshot.player1Id()); + return level == null ? null : new BotSettings(level, level.middleStrength(), ThreadLocalRandom.current().nextLong()); } // Null when the game ended without a winner and was not completed, e.g. both players left. diff --git a/apps/backend/src/main/java/app/playmancala/game/GameStore.java b/apps/backend/src/main/java/app/playmancala/game/GameStore.java index 2387f78..e957e50 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameStore.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameStore.java @@ -6,6 +6,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import app.playmancala.bot.BotLevel; + // Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop. public class GameStore { // In the order games started. @@ -28,7 +30,9 @@ public class GameStore { games.remove(id); } + // Bots play many games at once, so they are not mapped to one. public void setGameByUser(String userId, BackendGame game) { + if (BotLevel.isBotId(userId)) return; gameIdsByUser.put(userId, game.id); } diff --git a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java index 9ee5652..498e91b 100644 --- a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java +++ b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java @@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import app.playmancala.auth.KeycloakTokenVerifier; +import app.playmancala.bot.BotLevel; // Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash -// instead. Keycloak ids are not secret and stay as they are. +// instead. Keycloak ids and bot ids are not secret and stay as they are. public final class PublicIds { private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; @@ -16,7 +17,8 @@ public final class PublicIds { } public static String publicUserId(String userId) { - if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) { + if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX) + || BotLevel.isBotId(userId)) { return userId; } try { diff --git a/apps/backend/src/main/java/app/playmancala/model/Channels.java b/apps/backend/src/main/java/app/playmancala/model/Channels.java index 3336683..25fb9e2 100644 --- a/apps/backend/src/main/java/app/playmancala/model/Channels.java +++ b/apps/backend/src/main/java/app/playmancala/model/Channels.java @@ -4,6 +4,8 @@ package app.playmancala.model; public final class Channels { public static final String NEW_GAME = "new_game"; public static final String CANCEL_NEW_GAME = "cancel_new_game"; + // client -> server: { level }, starts a game against the bot at that level (docs/specs/game-bot). + public static final String NEW_BOT_GAME = "new_bot_game"; public static final String ON_GAME_START = "on_game_start"; public static final String GAME_MOVE = "game_move"; public static final String ON_GAME_UPDATE = "on_game_update"; diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index b55fd58..a6e3fe4 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -23,6 +23,9 @@ mancala.auth.legacy-userkey-auth=${LEGACY_USERKEY_AUTH:true} # Time each player has for a whole game (docs/specs/game-clock). mancala.game-clock-ms=${GAME_CLOCK_MS:300000} +# Threads working out the bot's moves (docs/specs/game-bot); 0 is half the processors. +mancala.bot-search-threads=${BOT_SEARCH_THREADS:0} + # Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried. quarkus.datasource.db-kind=postgresql %dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java new file mode 100644 index 0000000..ae5faa8 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -0,0 +1,249 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import app.playmancala.engine.GameState; +import app.playmancala.engine.Pit; +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +import app.playmancala.game.BackendGame; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameRecorder; +import app.playmancala.game.GameStore; +import app.playmancala.game.MatchMaker; +import app.playmancala.game.PublicIds; +import app.playmancala.loop.ManualScheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; +import app.playmancala.model.PlayerSummary; + +// Games against the bot, with the real game logic, a manual clock and searches that run at once. +class BotPlayerTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false); + static final long CLOCK_MS = 5 * 60_000; + // Longer than any wait: the start delay or a long animation plus the longest thinking time. + static final long LONGEST_WAIT_MS = 20_000; + + ManualScheduler loop; + FakeRtmt rtmt; + GameStore gameStore; + InMemoryLiveGameStore liveGameStore; + InMemoryGameHistoryRepository repository; + GameManager gameManager; + BotPlayer botPlayer; + + void createBackend() { + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + GameRecorder recorder = new GameRecorder(repository, liveGameStore, Runnable::run); + gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run, + loop::now, CLOCK_MS); + botPlayer = new BotPlayer(gameManager, gameStore, loop, Runnable::run, new SplittableRandom(1)); + rtmt.connect(ALICE); + rtmt.connect(CAROL); + } + + @BeforeEach + void setUp() { + loop = new ManualScheduler(Instant.parse("2026-10-16T12:00:00Z").toEpochMilli()); + liveGameStore = new InMemoryLiveGameStore(); + repository = new InMemoryGameHistoryRepository(); + createBackend(); + } + + // A game between Alice and the bot, with the given player to move first. + BackendGame startBotGame(BotSettings bot, String firstPlayerId, int... stones) { + BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot); + game.mancalaGame.setTurnPlayerId(firstPlayerId); + setBoard(game, stones); + gameManager.startGame(game); + return game; + } + + static void setBoard(BackendGame game, int... stones) { + List pits = game.mancalaGame.getBoard().getPits(); + for (int i = 0; i < stones.length; i++) pits.get(i).setStoneCount(stones[i]); + } + + void playAnyMove(BackendGame game, String playerId) { + List pits = game.mancalaGame.getBoard().getPits(); + for (int pit = 0; pit < 6; pit++) { + if (pits.get(game.mancalaGame.getBoardIndexByPlayerId(playerId, pit)).getStoneCount() > 0) { + rtmt.receive(playerId, Channels.GAME_MOVE, Map.of("index", pit)); + return; + } + } + throw new IllegalStateException("no move"); + } + + @Test + void startsAGameAgainstTheBotAtTheChosenLevel() { + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "medium")); + BackendGame game = gameStore.getGameByUser(ALICE.id()); + assertNotNull(game); + assertEquals("bot:medium", game.mancalaGame.getPlayer2Id()); + assertEquals(BotLevel.MEDIUM, game.bot.level()); + assertTrue(game.bot.strength() >= 30 && game.bot.strength() <= 55); + assertEquals(new PlayerSummary("bot:medium", "Bot", false), game.player("bot:medium")); + + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(game.id, start.get("id").asText()); + JsonNode bot = start.at("/gameUsersConnectionInfo/user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", "bot:medium", "name", "Bot", "isGuest", false, "isOnline", true)), bot); + assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); + // The bot is not mapped to the game; it plays many at once. + assertNull(gameStore.getGameByUser("bot:medium")); + } + + @Test + void endsTheSearchForAnOpponent() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "easy")); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + assertEquals("bot:easy", gameStore.getGameByUser(ALICE.id()).mancalaGame.getPlayer2Id()); + assertNull(gameStore.getGameByUser(CAROL.id())); + } + + @Test + void refusesABotGameDuringAnotherGame() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + BackendGame game = gameStore.getGameByUser(ALICE.id()); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "hard")); + assertSame(game, gameStore.getGameByUser(ALICE.id())); + assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), + rtmt.messagesTo(ALICE.id(), Channels.ON_NEW_GAME_REJECTED)); + } + + @Test + void ignoresUnknownLevels() { + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "impossible")); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, "master"); + assertNull(gameStore.getGameByUser(ALICE.id())); + } + + @Test + void movesAfterTheStartDelayAndThinkingTime() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), "bot:easy"); + loop.advance(BotPlayer.GAME_START_DELAY_MS + 800 - 1); + assertEquals(0, game.mancalaGame.getHistory().size()); + loop.advance(1000 + 1); + assertEquals(1, game.mancalaGame.getHistory().size()); + assertEquals("bot:easy", game.mancalaGame.getHistory().getFirst().getPlayerId()); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_UPDATE).size()); + } + + @Test + void waitsForTheAnimationOfThePlayersMove() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), ALICE.id()); + // Pit 2 with 4 stones passes the turn: four steps to animate. + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2)); + assertTrue(game.isBotTurn()); + long animation = 5 * BotPlayer.ANIMATION_STEP_MS; + loop.advance(animation + 800 - 1); + assertEquals(1, game.mancalaGame.getHistory().size()); + loop.advance(1000 + 1); + assertEquals(2, game.mancalaGame.getHistory().size()); + } + + @Test + void playsAgainAfterAnExtraTurn() { + // The only move, pit 11 with 3 stones, ends in the bank and leaves stones in pits 11 and 12. + BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 100, 3), "bot:master", + 3, 3, 3, 3, 3, 3, 10, 0, 0, 0, 0, 3, 0, 10); + loop.advance(LONGEST_WAIT_MS); + List movers = game.mancalaGame.getHistory().stream().map(move -> move.getPlayerId()).toList(); + assertEquals(List.of("bot:master", "bot:master"), movers.subList(0, 2)); + assertEquals(11, game.mancalaGame.getHistory().getFirst().getMoveIndex()); + } + + @Test + void dropsTheMoveWhenTheGameEndedMeanwhile() { + BackendGame game = startBotGame(new BotSettings(BotLevel.HARD, 70, 1), "bot:hard"); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + loop.advance(LONGEST_WAIT_MS); + assertEquals(0, game.mancalaGame.getHistory().size()); + assertEquals(0, loop.pendingTimers()); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.LEFT, record.endReason()); + } + + @Test + void hurriesWhenItsClockRunsLow() { + BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 90, 1), ALICE.id()); + game.clock = new GameClock(CLOCK_MS, CLOCK_MS, 20_000, loop.now()); + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2)); + loop.advance(BotPlayer.LOW_TIME_THINK_MS); + assertEquals(2, game.mancalaGame.getHistory().size()); + } + + @Test + void playsAWholeGameThatIsSavedWithTheBot() { + BackendGame game = startBotGame(new BotSettings(BotLevel.MEDIUM, 40, 11), ALICE.id()); + for (int turn = 0; turn < 200 && game.mancalaGame.getState() != GameState.ENDED; turn++) { + if (game.isBotTurn()) loop.advance(LONGEST_WAIT_MS); + else playAnyMove(game, ALICE.id()); + } + GameRecord record = repository.records.get(game.id); + assertNotNull(record, "the game did not end"); + assertEquals(EndReason.COMPLETED, record.endReason()); + assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2()); + assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium"))); + assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); + } + + @Test + void goesOnAfterARestartAtTheSameStrength() { + BotSettings bot = new BotSettings(BotLevel.HARD, 66, 99); + BackendGame game = startBotGame(bot, "bot:hard"); + assertEquals(Json.tree(Map.of("level", "hard", "strength", 66, "seed", 99L)), + liveGameStore.games.get(game.id).get("bot")); + + // A new process: the old loop and its timers are gone. + loop = new ManualScheduler(loop.now()); + createBackend(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.get(game.id); + assertEquals(bot, restored.bot); + loop.advance(LONGEST_WAIT_MS); + assertEquals(1, restored.mancalaGame.getHistory().size()); + } + + @Test + void restoresABotGameWithoutSettingsAtTheMiddleOfItsLevel() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 6, 5), ALICE.id()); + ((ObjectNode) liveGameStore.games.get(game.id)).remove("bot"); + + loop = new ManualScheduler(loop.now()); + createBackend(); + gameManager.restoreLiveGames(); + assertEquals(BotLevel.EASY, gameStore.get(game.id).bot.level()); + assertEquals(BotLevel.EASY.middleStrength(), gameStore.get(game.id).bot.strength()); + } + + @Test + void botIdsArePublic() { + assertEquals("bot:easy", PublicIds.publicUserId("bot:easy")); + } +} diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index e5fdf23..3d14fa8 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -150,12 +150,13 @@ static int chooseMove(Position position, BotSettings bot, int moveNumber); // ### `BotPlayer` ```java -public BotPlayer(GameManager gameManager, GameStore gameStore, GameLoop loop, Executor searchExecutor) +public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor, + RandomGenerator random) // random: thinking times only; used on the loop ``` Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game: 1. On the loop: copy the position (`Position.of`), note the move number and compute `readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step - of the last move plus 300 ms. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master + of the last move plus 300 ms; before the first move it is 1.5 s, so the game page has opened. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master 1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3). 2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used, so the game never waits for a bot that failed. @@ -163,6 +164,8 @@ Listens to `onTurnStarted`. When it is the bot's turn and no move is being worke live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call `gameManager.playMove(game, botId, pit)`; otherwise drop the result. +Games being worked on are kept in a set by identity, so a game restored with the same id is a new game for it. + `searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least 1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`. @@ -212,8 +215,9 @@ Listens to `onTurnStarted`. When it is the bot's turn and no move is being worke Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per level. A quick version (20 games, Master against Easy) runs with the normal tests. - `BotPlayer` with `FakeRtmt`, `ManualScheduler` and a direct executor: the bot moves after the delay; extra turns; - a game that ended or moved on while searching drops the result; a failing search plays the first legal pit; - low time shortens the wait; a restored game where it is the bot's turn gets a move. + a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where + it is the bot's turn gets a move. (The fallback for a failing search has no test: `BotPolicy` is static and cannot + be made to fail from outside.) - `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game; unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and `bot`; leaving and running out of time end the game as usual. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index b7290c3..abd5d58 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the strength table until R2.4 holds, and write the final values into `design.md`. -- [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, +- [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. - [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. -- 2.45.2 From 4a018c7b53a187e525e81f5ad6c35cc29c74b827 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:19:56 +0300 Subject: [PATCH 60/82] feat(backend): save how the bot played Saved games against the bot keep the bot's level, strength and seed in a new bot column, so the levels can later be tuned from real games. --- .../playmancala/game/GameSerialization.java | 2 +- .../app/playmancala/history/Migrations.java | 2 +- .../history/PostgresGameHistoryRepository.java | 11 +++++++---- .../java/app/playmancala/model/GameRecord.java | 17 ++++++++++++++--- .../main/resources/db/migrations/003_bot.sql | 2 ++ .../app/playmancala/bot/BotPlayerTest.java | 1 + .../PostgresGameHistoryRepositoryTest.java | 18 +++++++++++++++++- docs/specs/game-bot/tasks.md | 2 +- 8 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/main/resources/db/migrations/003_bot.sql diff --git a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java index 97990d6..b394159 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -152,7 +152,7 @@ public final class GameSerialization { 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()); + moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size(), game.bot); } // The player with a public id, for anything saved or listed. diff --git a/apps/backend/src/main/java/app/playmancala/history/Migrations.java b/apps/backend/src/main/java/app/playmancala/history/Migrations.java index 136b54c..779afc2 100644 --- a/apps/backend/src/main/java/app/playmancala/history/Migrations.java +++ b/apps/backend/src/main/java/app/playmancala/history/Migrations.java @@ -23,7 +23,7 @@ public final class Migrations { 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"); + public static final List VERSIONS = List.of("001_games", "002_clock", "003_bot"); private Migrations() { } diff --git a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java index b3ed987..13f70fc 100644 --- a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java +++ b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java @@ -14,6 +14,7 @@ import javax.sql.DataSource; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import app.playmancala.bot.BotSettings; import app.playmancala.model.EndReason; import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.GameRecord; @@ -58,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { 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) + move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot) VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0), - ?, ?::jsonb, ?::jsonb, ?, ?, ?) + ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb) ON CONFLICT (id) DO NOTHING""")) { statement.setString(1, record.id()); statement.setString(2, record.player1().userId()); @@ -77,6 +78,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { statement.setInt(13, record.totalSpectators()); if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER); else statement.setInt(14, Math.toIntExact(record.clockInitialMs())); + statement.setString(15, record.bot() == null ? null : Json.write(record.bot())); inserted = statement.executeUpdate(); } if (inserted == 0) return null; @@ -103,7 +105,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { 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")) { + "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot " + FROM_GAMES + " WHERE g.id = ?::uuid")) { statement.setString(1, id); try (ResultSet row = statement.executeQuery()) { if (!row.next()) return null; @@ -111,7 +113,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { 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")); + Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"), + row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class)); } } }); diff --git a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java index 325ab9c..a3014f3 100644 --- a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java +++ b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java @@ -5,10 +5,12 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import app.playmancala.bot.BotSettings; + // A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it -// is a public id. +// is a public id. `bot` is how the bot played, in games against the bot. @JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", - "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" }) public record GameRecord( String id, PlayerSummary player1, @@ -23,7 +25,16 @@ public record GameRecord( int peakSpectators, @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, List moves, - int totalSpectators) { + int totalSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot) { + + // A game between people. + public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List board, GameResult result, + EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators, + Long clockInitialMs, List moves, int totalSpectators) { + this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators, + clockInitialMs, moves, totalSpectators, null); + } public FinishedGameSummary toSummary() { return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, diff --git a/apps/backend/src/main/resources/db/migrations/003_bot.sql b/apps/backend/src/main/resources/db/migrations/003_bot.sql new file mode 100644 index 0000000..0119f3d --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/003_bot.sql @@ -0,0 +1,2 @@ +-- How the bot played in a game against the bot: { level, strength, seed }; NULL for games between people. +ALTER TABLE games ADD COLUMN bot jsonb; diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java index ae5faa8..a6ae07b 100644 --- a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -209,6 +209,7 @@ class BotPlayerTest { assertNotNull(record, "the game did not end"); assertEquals(EndReason.COMPLETED, record.endReason()); assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2()); + assertEquals(new BotSettings(BotLevel.MEDIUM, 40, 11), record.bot()); assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium"))); assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); } diff --git a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java index dcf8bb9..a68a211 100644 --- a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java +++ b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.GameStep; import app.playmancala.model.EndReason; import app.playmancala.model.FinishedGameSummary; @@ -88,7 +90,7 @@ class PostgresGameHistoryRepositoryTest { 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); + assertEquals(List.of("001_games", "002_clock", "003_bot"), versions); } @Test @@ -115,6 +117,20 @@ class PostgresGameHistoryRepositoryTest { assertFalse(saved.at("/moves/0").has("remainingMs")); } + @Test + void keepsHowTheBotPlayed() { + GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false)); + GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), + game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, + game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L)); + repository.save(botGame); + assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id()))); + assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId()); + GameRecord betweenPeople = record(); + repository.save(betweenPeople); + assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot")); + } + @Test void keepsLeftUserIdForGamesAPlayerLeft() { GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index abd5d58..6d688c6 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -11,7 +11,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. -- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [ ] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. - [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot -- 2.45.2 From 32d1ec98e7123deb46db66fee1acd229bb51aa73 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:20:31 +0300 Subject: [PATCH 61/82] feat(core): add bot levels, the bot game channel and bot names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPlayerDisplayName shows the bot as e.g. "Bot · Medium" from its id. --- docs/specs/game-bot/tasks.md | 2 +- packages/core/src/localization/en.ts | 9 ++++ packages/core/src/localization/tr.ts | 9 ++++ packages/core/src/models/Bot.ts | 42 +++++++++++++++++++ packages/core/src/models/index.ts | 1 + packages/core/src/rtmt/channel_names.ts | 2 + packages/core/src/util/PlayerUtil.ts | 8 +++- packages/core/tests/models/Bot.test.ts | 55 +++++++++++++++++++++++++ 8 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/models/Bot.ts create mode 100644 packages/core/tests/models/Bot.test.ts diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 6d688c6..432a5fc 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. - [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. -- [ ] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, +- [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. - [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot game, bot names everywhere players are shown. diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 58ed73e..22ef873 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -106,4 +106,13 @@ export const en = { StoreLabel: "{{name}}'s store, {{count}} stones", OpenGame: "Open the game", Score: "Score", + PlayWithBot: "Play with bot", + NoOpponentYet: "No opponent yet. Play with the bot?", + ChooseBotLevel: "Choose how strong the bot is", + Bot: "Bot", + BotLevelEasy: "Easy", + BotLevelMedium: "Medium", + BotLevelHard: "Hard", + BotLevelMaster: "Master", + PlayAgain: "Play again", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index 153a1b5..d6c07c0 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -106,4 +106,13 @@ export const tr = { StoreLabel: "{{name}} hazinesi, {{count}} taş", OpenGame: "Oyunu aç", Score: "Skor", + PlayWithBot: "Bota karşı oyna", + NoOpponentYet: "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", + ChooseBotLevel: "Botun ne kadar güçlü olacağını seç", + Bot: "Bot", + BotLevelEasy: "Kolay", + BotLevelMedium: "Orta", + BotLevelHard: "Zor", + BotLevelMaster: "Usta", + PlayAgain: "Tekrar oyna", }; \ No newline at end of file diff --git a/packages/core/src/models/Bot.ts b/packages/core/src/models/Bot.ts new file mode 100644 index 0000000..90254e7 --- /dev/null +++ b/packages/core/src/models/Bot.ts @@ -0,0 +1,42 @@ +// Games against the bot (docs/specs/game-bot). The bot plays with the user id `bot:`. +export type BotLevel = "easy" | "medium" | "hard" | "master"; + +// Weakest first. +export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; + +// How long the lobby searches for an opponent before it offers the bot. +export const BOT_OFFER_DELAY_MS = 10_000; + +const BOT_ID_PREFIX = "bot:"; + +export function isBotId(userId: string | undefined): boolean { + return userId?.startsWith(BOT_ID_PREFIX) ?? false; +} + +// The level of a bot id, or undefined if it is not one. +export function getBotLevel(userId: string | undefined): BotLevel | undefined { + if (!userId || !isBotId(userId)) return undefined; + const level = userId.slice(BOT_ID_PREFIX.length); + return (BOT_LEVELS as string[]).includes(level) ? level as BotLevel : undefined; +} + +export function isBotLevel(value: unknown): value is BotLevel { + return typeof value === "string" && (BOT_LEVELS as string[]).includes(value); +} + +// Translation key of a level's name, e.g. BotLevelMedium. +export function getBotLevelTextKey(level: BotLevel): string { + return `BotLevel${level.charAt(0).toUpperCase()}${level.slice(1)}`; +} + +const LAST_BOT_LEVEL_KEY = "botLevel"; + +// The level chosen last on this device; Medium at first. +export function getLastBotLevel(storage: { getString(key: string): string | undefined }): BotLevel { + const saved = storage.getString(LAST_BOT_LEVEL_KEY); + return isBotLevel(saved) ? saved : "medium"; +} + +export function setLastBotLevel(storage: { setString(key: string, value: string): void }, level: BotLevel): void { + storage.setString(LAST_BOT_LEVEL_KEY, level); +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 408e5bb..9745bb5 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -6,3 +6,4 @@ export * from './UserConnectionInfo' export * from './AuthUser' export * from './GameHistory' export * from './GameClock' +export * from './Bot' diff --git a/packages/core/src/rtmt/channel_names.ts b/packages/core/src/rtmt/channel_names.ts index e3b532d..ecb9e34 100644 --- a/packages/core/src/rtmt/channel_names.ts +++ b/packages/core/src/rtmt/channel_names.ts @@ -1,5 +1,7 @@ export const channel_new_game = "new_game" export const channel_cancel_new_game = "cancel_new_game" +// client -> server: { level: BotLevel }, starts a game against the bot; ends a search for an opponent. +export const channel_new_bot_game = "new_bot_game" export const channel_on_game_start = "on_game_start" export const channel_game_move = "game_move" export const channel_on_game_update = "on_game_update" diff --git a/packages/core/src/util/PlayerUtil.ts b/packages/core/src/util/PlayerUtil.ts index 3aafc5d..1c169d6 100644 --- a/packages/core/src/util/PlayerUtil.ts +++ b/packages/core/src/util/PlayerUtil.ts @@ -1,11 +1,15 @@ +import { getBotLevel, getBotLevelTextKey } from "../models/Bot"; + const GUEST_USERNAME_PREFIX = "guest-"; // Name to show for a player, or undefined if the server sent none (legacy userKey players). -// Guests show as e.g. "Guest 4F2A" instead of their generated username. +// Guests show as e.g. "Guest 4F2A" instead of their generated username, the bot as e.g. "Bot · Medium". export function getPlayerDisplayName( - player: { name?: string, isGuest?: boolean } | undefined, + player: { userId?: string, name?: string, isGuest?: boolean } | undefined, t: (key: string) => string ): string | undefined { + const botLevel = getBotLevel(player?.userId); + if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`; if (!player?.name) return undefined; if (player.isGuest) { const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name; diff --git a/packages/core/tests/models/Bot.test.ts b/packages/core/tests/models/Bot.test.ts new file mode 100644 index 0000000..e711d97 --- /dev/null +++ b/packages/core/tests/models/Bot.test.ts @@ -0,0 +1,55 @@ +import { BOT_LEVELS, getBotLevel, getBotLevelTextKey, getLastBotLevel, isBotId, isBotLevel, setLastBotLevel } from "../../src/models"; +import { en, tr } from "../../src/localization"; +import { getPlayerDisplayName } from "../../src/util"; + +describe("bot ids", () => { + it("recognises bot ids and their levels", () => { + expect(isBotId("bot:medium")).toBe(true); + expect(isBotId("kc:alice")).toBe(false); + expect(isBotId(undefined)).toBe(false); + expect(getBotLevel("bot:master")).toBe("master"); + expect(getBotLevel("bot:impossible")).toBeUndefined(); + expect(getBotLevel("kc:alice")).toBeUndefined(); + }); + + it("checks levels", () => { + expect(isBotLevel("hard")).toBe(true); + expect(isBotLevel("impossible")).toBe(false); + expect(isBotLevel(3)).toBe(false); + }); + + it("has a name for every level in both languages", () => { + for (const level of BOT_LEVELS) { + const key = getBotLevelTextKey(level) as keyof typeof en; + expect(en[key]).toBeTruthy(); + expect(tr[key as keyof typeof tr]).toBeTruthy(); + } + }); +}); + +describe("last bot level", () => { + it("is Medium until one is chosen, then the chosen one", () => { + const values = new Map(); + const storage = { getString: (key: string) => values.get(key), setString: (key: string, value: string) => { values.set(key, value); } }; + expect(getLastBotLevel(storage)).toBe("medium"); + setLastBotLevel(storage, "master"); + expect(getLastBotLevel(storage)).toBe("master"); + values.set("botLevel", "impossible"); + expect(getLastBotLevel(storage)).toBe("medium"); + }); +}); + +describe("getPlayerDisplayName", () => { + const t = (key: string) => (tr as Record)[key] ?? key; + + it("shows the bot with its level", () => { + expect(getPlayerDisplayName({ userId: "bot:medium", name: "Bot", isGuest: false }, t)).toBe("Bot · Orta"); + expect(getPlayerDisplayName({ userId: "bot:easy" }, t)).toBe("Bot · Kolay"); + }); + + it("still shows people and guests as before", () => { + expect(getPlayerDisplayName({ userId: "kc:alice", name: "alice", isGuest: false }, t)).toBe("alice"); + expect(getPlayerDisplayName({ userId: "kc:g", name: "guest-4f2a9b", isGuest: true }, t)).toBe("Misafir 4F2A"); + expect(getPlayerDisplayName({ userId: "0b9f6d1e" }, t)).toBeUndefined(); + }); +}); -- 2.45.2 From e66c4a3a41cfebae437e48d64367c146e22dcbc5 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:22:04 +0300 Subject: [PATCH 62/82] feat(frontend): offer games against the bot The lobby offers the bot levels after 10 seconds without an opponent, the home page has a Play with bot menu, and a game against the bot ends with Play again at the same level. --- apps/frontend/src/components/BotLevelMenu.tsx | 36 ++++++++++++++++ .../src/components/BotLevelPicker.tsx | 34 +++++++++++++++ apps/frontend/src/components/Icons.tsx | 4 ++ .../src/components/game/GameOverCard.tsx | 6 ++- apps/frontend/src/routes/GamePage.tsx | 13 +++++- apps/frontend/src/routes/Home.tsx | 9 +++- apps/frontend/src/routes/LobyPage.tsx | 42 +++++++++++++++++-- docs/specs/game-bot/design.md | 15 ++++--- docs/specs/game-bot/tasks.md | 2 +- 9 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 apps/frontend/src/components/BotLevelMenu.tsx create mode 100644 apps/frontend/src/components/BotLevelPicker.tsx diff --git a/apps/frontend/src/components/BotLevelMenu.tsx b/apps/frontend/src/components/BotLevelMenu.tsx new file mode 100644 index 0000000..6a43278 --- /dev/null +++ b/apps/frontend/src/components/BotLevelMenu.tsx @@ -0,0 +1,36 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; +import { BotIcon } from "./Icons"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// "Play with bot" in the header: a menu of the levels, the one chosen last marked. +const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const menuButton = ( + + ); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( + + {t("ChooseBotLevel")} + {BOT_LEVELS.map((level) => ( + choose(level)}> + {t(getBotLevelTextKey(level))} + + ))} + + ); +}; + +export default BotLevelMenu; diff --git a/apps/frontend/src/components/BotLevelPicker.tsx b/apps/frontend/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..e190d71 --- /dev/null +++ b/apps/frontend/src/components/BotLevelPicker.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; + +// The four bot levels as buttons; the level chosen last is highlighted and remembered on this device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( +
    + {BOT_LEVELS.map((level) => ( + + ))} + +
    + ); +}; + +export default BotLevelPicker; diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 49fadab..3f7e5c0 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => ( export const PauseIcon = ({ size = 22 }: IconProps) => ( ); + +export const BotIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx index cd01de7..23e3789 100644 --- a/apps/frontend/src/components/game/GameOverCard.tsx +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{ title: string, reason?: string, score: string, + // "New Game" unless given, e.g. "Play again" after a game against the bot. + newGameLabel?: string, onNewGame: () => void, onClose: () => void, -}> = ({ title, reason, score, onNewGame, onClose }) => { +}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => { const { t } = useTranslation(); const navigate = useNavigate(); return ( @@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{ {reason &&
    {reason}
    }
    {score}
    - +
    diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 7f33d2e..bd3f812 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -12,7 +12,7 @@ import { LeaveIcon, PlusIcon } from '../components/Icons'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core'; import { Context } from '../context/context'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; -import { Theme, getPlayerDisplayName } from '@mancala/core'; +import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; import notyf from '../util/Notyf'; import swal from 'sweetalert'; @@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{ navigate("/loby") }; + // In a game against the bot, the game over card starts another one at the same level. + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + + const onPlayAgainClick = () => { + if (Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${opponentBotLevel}`) + }; + const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; @@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{ title={resultTitle ?? t("GameEnded")} reason={resultReason} score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`} - onNewGame={onNewGameClick} + newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined} + onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick} onClose={() => setGameOverClosed(true)} /> )}
    diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index e61acf6..d6e0690 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,10 +1,11 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { LiveGameSummary } from "@mancala/core"; -import { AuthUser, Theme } from "@mancala/core"; +import { AuthUser, BotLevel, Theme } from "@mancala/core"; import AppHeader from "../components/headerbar/AppHeader"; import PageContainer from "../components/PageContainer"; import { PlusIcon } from "../components/Icons"; +import BotLevelMenu from "../components/BotLevelMenu"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; @@ -43,9 +44,15 @@ const Home: FunctionComponent<{ navigate("/loby") }; + const onBotLevelChosen = (level: BotLevel) => { + if(Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${level}`) + }; + return ( + diff --git a/apps/frontend/src/routes/LobyPage.tsx b/apps/frontend/src/routes/LobyPage.tsx index f5e7f38..49371f3 100644 --- a/apps/frontend/src/routes/LobyPage.tsx +++ b/apps/frontend/src/routes/LobyPage.tsx @@ -1,10 +1,11 @@ import { CommonMancalaGame } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import AppHeader from '../components/headerbar/AppHeader'; import PageContainer from '../components/PageContainer'; -import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import { Context } from '../context/context'; import { Theme } from '@mancala/core'; import { useTranslation } from 'react-i18next'; @@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{ const { t } = useTranslation(); + // /loby?bot= starts a game against the bot at once instead of searching for an opponent. + const [searchParams] = useSearchParams(); + const botParam = searchParams.get("bot"); + const botLevel: BotLevel | undefined = isBotLevel(botParam) ? botParam : undefined; + // Set when the server refuses a new game because the user has one in progress. const [activeGameId, setActiveGameId] = useState(undefined); + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = useState(false); + const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId); const onGameStart = (message: Object) => { @@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{ navigate(`/game/${newGame.id}`) } + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } + const onCancelNewGameClick = () => { if (context.rtmt.connectionState === "connected") { context.rtmt.sendMessage(channel_cancel_new_game, ""); @@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{ useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); } @@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
    {!activeGameId && } -

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

    +

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

    {!activeGameId &&

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

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

    {t("NoOpponentYet")}

    + +
    + )}
    {activeGameId && ( @@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{ margin: 0; color: var(--muted); } + .offer { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; + padding-top: 16px; + border-top: 1px solid var(--line); + } .actions { display: flex; gap: 10px; diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 3d14fa8..7d25bae 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -32,12 +32,17 @@ export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; export const BOT_OFFER_DELAY_MS = 10_000; // R1.1 export function isBotId(userId: string | undefined): boolean; // starts with "bot:" export function getBotLevel(userId: string | undefined): BotLevel | undefined; +export function isBotLevel(value: unknown): value is BotLevel; +export function getBotLevelTextKey(level: BotLevel): string; // e.g. BotLevelMedium +export function getLastBotLevel(storage): BotLevel; // Medium until one is chosen (R1.4) +export function setLastBotLevel(storage, level: BotLevel): void; ``` - `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for bots (R4.1). - Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with - the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `Bot` "Bot" / "Bot", `BotLevelEasy`, + the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `ChooseBotLevel` "Choose how strong the bot + is" / "Botun ne kadar güçlü olacağını seç", `Bot` "Bot" / "Bot", `BotLevelEasy`, `BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor", "Usta", `PlayAgain` "Play again" / "Tekrar oyna". @@ -185,10 +190,10 @@ Games being worked on are kept in a set by identity, so a game restored with the `BOT_SEARCH_THREADS` (default: half the processors, at least 1). ## Web app -- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is preselected and saved in - local storage (R1.4). -- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; choosing a level - goes to `/loby?bot=` (R1.3). +- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with + `getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4). +- Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account + menu, with the last level checked; choosing a level goes to `/loby?bot=` (R1.3). - `LobyPage`: with `?bot=`, it sends `new_bot_game` instead of `new_game`. Otherwise, after `BOT_OFFER_DELAY_MS` without a game, it shows `NoOpponentYet` and the picker below the search; choosing a level sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 432a5fc..dc3a7d3 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -14,7 +14,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. -- [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot +- [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot game, bot names everywhere players are shown. - [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, -- 2.45.2 From d1918fed10c760dec127df3668c516fb3abd11f2 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:23:22 +0300 Subject: [PATCH 63/82] feat(mobile): offer games against the bot The lobby offers the bot levels after 10 seconds without an opponent, the home screen has Play with bot, the bot is shown with its level, and a new game after a bot game is one at the same level. --- apps/mobile/src/components/BotLevelPicker.tsx | 31 +++++++++++++++++++ apps/mobile/src/screens/GameScreen.tsx | 19 +++++++----- apps/mobile/src/screens/HomeScreen.tsx | 13 ++++++++ apps/mobile/src/screens/LobyScreen.tsx | 28 ++++++++++++++--- apps/mobile/src/types/index.ts | 4 ++- docs/specs/game-bot/tasks.md | 2 +- 6 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/components/BotLevelPicker.tsx diff --git a/apps/mobile/src/components/BotLevelPicker.tsx b/apps/mobile/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..6cbda7a --- /dev/null +++ b/apps/mobile/src/components/BotLevelPicker.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from '@mancala/core'; +import { Context } from '../context/context'; +import CircularPanel from './CircularPanel'; + +// The four bot levels; the level chosen last is highlighted and remembered on the device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const theme = context.themeManager.theme; + const lastLevel = getLastBotLevel(context.storage); + return ( + + {BOT_LEVELS.map((level) => ( + { + setLastBotLevel(context.storage, level); + onChoose(level); + }}> + {t(getBotLevelTextKey(level))}} /> + + ))} + + ); +}; + +export default BotLevelPicker; diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx index d05709e..404b432 100644 --- a/apps/mobile/src/screens/GameScreen.tsx +++ b/apps/mobile/src/screens/GameScreen.tsx @@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native' import { useTranslation } from 'react-i18next'; import { GameScreenProps } from '../types'; import { useState } from 'react'; -import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } from "@mancala/core"; +import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat, getBotLevel, getPlayerDisplayName } from "@mancala/core"; import { MancalaGame, Pit } from 'mancala.js'; import { v4 } from 'uuid'; import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move, channel_on_game_end } from '@mancala/core'; @@ -159,10 +159,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) { }); }; + // In a game against the bot, a new game is another one at the same level. const onNewGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; context.storage.deleteString('lastGameId'); - navigation.replace("Loby", { context }) + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + navigation.replace("Loby", { context, botLevel: opponentBotLevel }) }; const onPitSelect = (index: number, pit: Pit) => { @@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { onLeaveGameClick(); } }}> - {isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}} /> + {isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}} /> { navigation.push("Help", { context }) @@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) { const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; + // Players have no names on mobile yet (legacy users), except the bot. + const topBotName = getPlayerDisplayName({ userId: topLocatedUserId }, t); + const bottomBotName = getPlayerDisplayName({ userId: bottomLocatedUserId }, t); const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: topBotName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true + isAnonymous: !topBotName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: bottomBotName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true + isAnonymous: !bottomBotName }; const currentUser = isSpectator ? { id: "2", diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index aa7757b..0bbf6b4 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native'; import { useTranslation } from 'react-i18next'; import { HomeScreenProps } from '../types'; import CircularPanel from '../components/CircularPanel'; +import BotLevelPicker from '../components/BotLevelPicker'; import { SvgXml } from 'react-native-svg'; import helpSvg from '../svg/help'; import { getColorByBrightness } from "@mancala/core"; @@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { const { context } = route.params; const { t } = useTranslation(); const [lastGameId, setLastGameId] = React.useState(undefined); + const [choosingBotLevel, setChoosingBotLevel] = React.useState(false); const textColorOnAppBar = getColorByBrightness( context.themeManager.theme.appBarBgColor, @@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { navigation.replace('Loby', { context })}> {t('NewGame')}} /> + + {choosingBotLevel ? ( + + {t('ChooseBotLevel')} + navigation.replace('Loby', { context, botLevel })} /> + + ) : ( + setChoosingBotLevel(true)}> + {t('PlayWithBot')}} /> + + )} {lastGameId && ( <> diff --git a/apps/mobile/src/screens/LobyScreen.tsx b/apps/mobile/src/screens/LobyScreen.tsx index 7c5bf78..76648d4 100644 --- a/apps/mobile/src/screens/LobyScreen.tsx +++ b/apps/mobile/src/screens/LobyScreen.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import { LobyScreenProps } from '../types'; import { useEffect } from 'react'; import { CommonMancalaGame } from 'mancala.js'; -import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import CircularPanel from '../components/CircularPanel'; import { getColorByBrightness } from "@mancala/core"; import { SvgXml } from 'react-native-svg'; @@ -12,7 +13,13 @@ import helpSvg from '../svg/help'; export default function LobyScreen({ navigation, route }: LobyScreenProps) { - const { context } = route.params; + const { context, botLevel } = route.params; + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = React.useState(false); + + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } const { t } = useTranslation(); const onGameStart = async (message: Object) => { @@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); } }, []); @@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { return ( - {t('SearchingOpponent') + " " + t('PleaseWait') + "..."}} /> + {(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}} /> + {botOffered && ( + + {t('NoOpponentYet')} + + + )} {t('Cancel')}} /> diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 4b2db02..190597a 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -1,9 +1,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { BotLevel } from '@mancala/core'; import { Context } from '../context/context'; export type RootStackParamList = { Home: { context: Context }, - Loby: { context: Context }, + // botLevel: start a game against the bot at once instead of searching for an opponent. + Loby: { context: Context, botLevel?: BotLevel }, Game: { context: Context, gameId: string, userKey: string } Help: { context: Context } }; diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index dc3a7d3..3459e5f 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -16,6 +16,6 @@ Work through the tasks in order; each ends with its tests passing and a commit. `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. - [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot game, bot names everywhere players are shown. -- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items). -- 2.45.2 From f7a76c861e5a59a2f8e1bd7463e8e3c298acbcec Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:27:49 +0300 Subject: [PATCH 64/82] docs: describe the game bot The bot in the backend README with the calibration command, AGENTS.md notes on bot ids and test tags, and the open work in TODO.md. --- AGENTS.md | 13 ++++++++++--- TODO.md | 11 +++++++++++ apps/backend/README.md | 21 ++++++++++++++++++++- docs/specs/game-bot/design.md | 6 ++++++ docs/specs/game-bot/requirements.md | 2 +- docs/specs/game-bot/tasks.md | 2 +- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ac959d..033a853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,8 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay), `game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board, -pages) and `quarkus-backend` (the backend's move from Node to Java and Quarkus). +pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus) and `game-bot` (playing against a bot +at four levels). ## Running locally @@ -44,13 +45,15 @@ unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`. |---|---|---| | Engine | `cd packages/mancala.js && npx jest` | After changing the rules, change the Java port too and record the parity games again (`apps/backend/README.md`) | | Core | `cd packages/core && npx jest` | | -| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services | +| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services. The bot's calibration tournament is tagged `calibration` and skipped; see `apps/backend/README.md` | | Keycloak extension | `cd apps/keycloak/extension && mvn test` | Needs Docker; starts Keycloak with the real realm file | | Web app | `cd apps/frontend && yarn build` | There is no `tsconfig.json`, so nothing type-checks it; use a temporary one when needed (with `"types": ["styled-jsx", "react", "react-dom"]`). 1 type error (`react-dom/client`) is known | | Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work | There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with -`playwright-core` (installed outside the repo, `executablePath: /usr/bin/google-chrome`) against the local stack. +`playwright-core` (installed outside the repo, e.g. `npm install playwright-core@1.48.2` in a scratch folder; +`executablePath: /usr/bin/google-chrome`) against the local stack. A live game is `GET /game/:id` on the backend, +which helps to check what the server holds. ## How we work @@ -92,6 +95,10 @@ There is no automated end-to-end suite. Changes to the game flow were checked by left with `GameClocks.getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for manual tests. - `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed). +- The bot plays as `bot:` (`BotLevel.isBotId`) in many games at once: `GameStore` does not map it to a game, + nothing is sent to it, it is always online and `PublicIds` keeps its id. The bot's search must never run on the + game loop; `BotPlayer` runs it on its own pool and plays the move back on the loop. +- `-Dgroups=...` runs only the tests with that tag; run the untagged tests separately too. - Unit tests build the logic with fakes (`FakeRtmt`, in-memory stores, `ManualScheduler` instead of timers and direct executors); only storage, HTTP and WebSocket tests are `@QuarkusTest`s. `FakeTokenVerifier` replaces Keycloak in all `@QuarkusTest`s. diff --git a/TODO.md b/TODO.md index 7b73634..7ab5374 100644 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,17 @@ See `docs/specs/game-clock`. - [ ] Clocks in the home page's live game previews and in the ongoing-game banner - [ ] Try the mobile clocks on a device; only the type check and the bundle were run +## Game bot + +See `docs/specs/game-bot`. + +- [ ] Play against the bot without a connection (a bot in the TypeScript engine on the device) +- [ ] Keep bot games apart once there are statistics or ratings +- [ ] Hints or move analysis for players, with the same search +- [ ] Rebuild `MancalaGame` on `Position`, or use `Position` to check moves before running them (see Security) +- [ ] Tune the levels from real games (`games.bot` holds each game's level, strength and seed) +- [ ] Try the bot on a mobile device; only the type check and the bundle were run + ## Web redesign See `docs/specs/web-redesign`. diff --git a/apps/backend/README.md b/apps/backend/README.md index fb5c3ef..ac0a548 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -21,7 +21,8 @@ The Docker image is built from the repo root: `docker build -f apps/backend/Dock | Package | What | |---|---| -| `engine` | The game rules, a port of `packages/mancala.js` | +| `engine` | The game rules, a port of `packages/mancala.js`; `Position`, a compact board for searching moves | +| `bot` | The bot: levels, strength, the search and `BotPlayer`, which plays its turns in live games | | `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 | @@ -93,6 +94,24 @@ backend is down is not charged. The design is in `docs/specs/game-clock`. |---|---|---| | `GAME_CLOCK_MS` | `300000` | Time each player has for a game; a short value such as `40000` helps when testing by hand | +## Bot + +Players can play against a bot at four levels (`new_bot_game` with `{ level }`); the lobby offers it after 10 seconds +without an opponent. Each level is a range of strengths, and the bot draws one per game, so games at the same level +differ. It plays as `bot:` through the same game logic as players, with the clock, saving and spectators, and +waits for the previous move's animation and a thinking time before moving. Its moves are worked out with alpha-beta +search on `Position` on a thread pool of their own, never on the game loop. The design is in `docs/specs/game-bot`. + +| Variable | Default | Purpose | +|---|---|---| +| `BOT_SEARCH_THREADS` | half the processors | Threads working out the bot's moves | + +After changing the search or the strength table, check that every level still beats the one below: + +```sh +./mvnw test -Dtest=BotTournamentTest -Dgroups=calibration -DexcludedGroups=none +``` + | Route | Auth | Response | |---|---|---| | `GET /game/:id` | – | A live game, or `{ status: "finished", record }` | diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 7d25bae..82f9c69 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -231,3 +231,9 @@ Games being worked on are kept in a set by identity, so a game restored with the "Play with bot" on the home page; the bot moves after the animation; the game ends and shows in recent games and the replay as "Bot · "; "Play again" starts a new bot game; a backend restart during the bot's turn; a second browser watches a bot game from the live previews. + +Checked end to end in Chrome on 2026-10-16 with the default clock: the home menu and the lobby offer (shown after +10.3 s) start games at the chosen level; "Bot · Easy" / "Bot · Hard" on the game page, in recent games and in a +second browser's live previews; an Easy game played to the end (the bot waited 1.45–4 s per move, longer after long +animations); "Play again" starts a new game at the same level; the backend stopped right after a Master game started +and the bot made its first move after the restart. diff --git a/docs/specs/game-bot/requirements.md b/docs/specs/game-bot/requirements.md index f3dfb95..20d2cf3 100644 --- a/docs/specs/game-bot/requirements.md +++ b/docs/specs/game-bot/requirements.md @@ -1,6 +1,6 @@ # Game bot: requirements -Status: spec, not implemented yet. Branch `feature/game-bot` (based on `develop`). +Status: implemented on branch `feature/game-bot` (based on `develop`). Scope: backend, `packages/core`, web app and mobile app. ## Goals diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 3459e5f..5ef03f9 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -17,5 +17,5 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot game, bot names everywhere players are shown. - [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. -- [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, +- [x] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items). -- 2.45.2 From 9522224bf9045c7aa8fee9258226afdc2215d100 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:46:50 +0300 Subject: [PATCH 65/82] docs(tutorial): add the spec Requirements, design and tasks for a rules page, interactive lessons and a practice game with hints against the Easy bot. --- docs/specs/tutorial/design.md | 145 ++++++++++++++++++++++++++++ docs/specs/tutorial/requirements.md | 83 ++++++++++++++++ docs/specs/tutorial/tasks.md | 16 +++ 3 files changed, 244 insertions(+) create mode 100644 docs/specs/tutorial/design.md create mode 100644 docs/specs/tutorial/requirements.md create mode 100644 docs/specs/tutorial/tasks.md diff --git a/docs/specs/tutorial/design.md b/docs/specs/tutorial/design.md new file mode 100644 index 0000000..2c35773 --- /dev/null +++ b/docs/specs/tutorial/design.md @@ -0,0 +1,145 @@ +# Tutorial: design + +See `requirements.md`; requirement ids (R1.1, …) are referenced below. Builds on `web-redesign` (board, pages, +themes) and `game-bot` (the Easy bot for the practice game). + +## Decisions + +| Decision | Reason | +|---|---| +| Rules, lessons and hints are defined once in `packages/core` | The web and mobile apps show the same rules (R5.1) and the texts stay in one place. | +| The "after" board of every example and the outcome of every lesson move come from running the engine (`mancala.js`), never written by hand | The texts and pictures cannot drift from what the game does (R1.3). A rule change shows up in the tutorial at once, and a test fails if a lesson no longer works. | +| Lessons run entirely in the client on the TypeScript engine | No server, no login, no waiting (R2.5). The engine is already in every client. | +| A lesson's task is an outcome ("end in your store"), not a pit | Players who find another move with the same effect are right too; the check is the engine's game steps, the same ones the move list uses. | +| The practice game is an ordinary Easy bot game with hints on; hints are a client feature of any bot game | No new server state (R3.1). Hints only show the immediate effect of a move, which the board's preview mostly shows already, so they give little away; still, they are limited to bot games (R3.5). | +| Hints look one move ahead only | They explain the rules, not strategy. Looking further would need the bot's search on the client or a new server message (out of scope). | +| Progress, "not now" and the hints setting are kept in the device's storage | Enough for learning the rules (R2.6, R3.4, R4.1); accounts are not needed for any of it. | +| Mobile gets the rules screen only for now | The mobile app still has the old board and game screen; lessons and hints there would be built twice (R5.2). | +| The end-of-game lesson teaches the current rule | `TODO.md` (Game rules) asks whether the remaining stones should always go to the player whose side is empty. The tutorial follows the engine; because its boards come from the engine, a rule change only needs the texts updated. | + +## The rules as taught (R1.3) + +Checked against `packages/mancala.js` (`Board.move`, the four `GR…` rules): + +1. **Board and goal**: two rows of 6 pits with 4 stones each, a store on each side. Stones in your store are yours; + at the end, more stones in your store wins (48 stones: 25 always wins). A draw is possible. +2. **Sowing**: take all stones from one of your pits; put the first back into the same pit and one into each pit + after it. A single stone moves to the next pit. Your own store gets a stone on the way, the opponent's is + skipped. +3. **Extra turn**: if the last stone lands in your store, you play again. +4. **Even capture**: if the last stone lands in an opponent's pit and makes its count even, you take all stones in + it. +5. **Empty pit capture**: if the last stone lands in an empty pit of yours and the opponent's pit opposite has + stones, you take both. +6. **End of the game**: the game ends when one side's pits are empty; the player whose side is empty gets the + stones left on the other side. Then the stores are counted. +7. **Clock**: 5 minutes per player for the whole game; the clock of the player to move runs; when it reaches zero, + that player loses. + +## Shared code (`packages/core/src/tutorial/`) + +```ts +type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock"; + +interface RuleExample { + board: number[]; // 14 stone counts, player 1 (bottom) to move + pit: number; // board index played +} + +type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame"; + +interface Lesson { + board: number[]; // player 1 (bottom) to move + task: LessonTask; + taskKey: string; // e.g. "LessonTaskExtraTurn": "End your move in your store" + opponentReply?: number; // board index the opponent plays after a fulfilled move, if the lesson needs one +} + +interface Rule { + id: RuleId; + titleKey: string; + textKeys: string[]; // at most three sentences (R1.2) + example?: RuleExample; + lesson?: Lesson; // every rule except the clock (R2.1) +} + +export const RULES: Rule[]; + +// The immediate effect of playing a pit, by running the engine on a copy of the board. +interface MoveOutcome { + path: number[]; // where the stones went (getSowingPath) + extraTurn: boolean; + capture?: "even" | "emptyPit"; + captured: number; // stones taken from the opponent's side + storeGain: number; + endsGame: boolean; + board: number[]; // after the move +} +export function getMoveOutcome(board: number[], player1ToMove: boolean, pit: number): MoveOutcome; +export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean; + +// "Why did that happen" for a wrong lesson move and for the practice game (R2.3, R3.3). +export function explainMove(outcome: MoveOutcome, mover: "you" | "opponent" | "bot", t): string; +``` + +`getMoveOutcome` builds the position with `createGameFromBoard` (core history helpers), plays the pit and reads the +game steps, as `describeMove` does for the move list. `describeMove` gains `capture` kind and `captured`, so the +move list, hints and explanations share one reading of the steps. + +Lesson positions (player 1, bottom, to move; pits 0–5, store 6), checked with the rules above. Task 2 tests that +each has a move that fulfils its task and, where there is more than one legal move, one that does not: + +| Lesson | Board | Task | Moves that fulfil it | +|---|---|---|---| +| Board and goal | start position | `anyMove` | all | +| Sowing | `[4,4,1,4,4,4, 1, 4,4,4,4,4,4, 2]` | `anyMove`; the text points at pit 3's single stone, which moves to pit 4 | all | +| Extra turn | start position | `extraTurn` | pit 4 | +| Even capture | `[4,4,4,4,4,4, 0, 4,3,4,4,4,4, 1]` | `evenCapture` | pit 6: its last stone makes the opponent's 3 a 4 | +| Empty pit capture | `[2,0,4,4,4,4, 3, 4,4,4,4,5,4, 2]` | `emptyPitCapture` | pit 1: its last stone lands in the empty pit 2, opposite 5 stones | +| End of the game | `[0,0,0,0,0,1, 20, 2,3,1,0,2,1, 18]` | `endGame` | pit 6 (the only move): your side is empty and the 9 stones opposite are yours, 30–18 | + +Texts: `RuleTitle`, `RuleText1..3`, `LessonTask…`, `MoveExplain…`, `HowToPlay`, `Learn`, `Hints`, +`NewToMangala`, `StartTutorial`, `NotNow`, `PracticeGame`, in `en.ts` and `tr.ts`. + +Storage helpers next to the rules: `getCompletedLessons` / `completeLesson`, `isTutorialBannerHidden` / +`hideTutorialBanner`, `getHintsEnabled` / `setHintsEnabled`, `markGamePlayed`. + +## Web app + +Routes: +- `/rules`: `RulesPage` with `RulesView`. +- `/learn`: the lesson list with progress, then the practice game button. +- `/learn/:ruleId`: `LessonPage`. + +Components: +- `RulesView`: the rules in order; each with its texts, a `RuleBoard` and "Try it" linking to the lesson (R1.5). +- `RuleBoard`: two `MiniBoard`s, before and after the example move, the played pit and the landing pit marked. +- `RulesPanel`: `RulesView` in a side panel (full screen below 600 px) over the game page; opened from a "How to + play" button in the game page's header; the game keeps running behind it (R1.4). +- `AppHeader`: a help icon button "How to play" on every page, going to `/rules` (or opening `RulesPanel` on the game + page). +- `LessonPage`: the rule's text and task above a `BoardView` on a local game (`createGameFromBoard`), animated with + `PitAnimator` like a live game. On a move: `getMoveOutcome`, animate, then praise and "Next lesson" or + `explainMove` and "Try again", which resets the board. A scripted `opponentReply` is played after a short pause. +- `TutorialBanner` on the home page for newcomers (R4). +- Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map + (pit → "Extra turn" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with + `explainMove` of the last move. A "Hints" switch in the game page's toolbar, only in bot games. +- Practice game: the lesson list's last button sets hints on and goes to `/loby?bot=easy`. + +## Mobile app + +`RulesScreen`: the rules' texts and a simple stone-count drawing of each example (before/after), reachable from +`HelpScreen` and from the game screen's header (R5.1). + +## Testing +- Core: every lesson position fulfils its task with some move and (except `anyMove`) fails it with another; + `getMoveOutcome` against the engine on random positions; every rule example produces the effect its rule is about + (the even capture example really captures, etc.); `explainMove` for each kind of move in both languages; every + text key exists in `en` and `tr`. +- Web: build and the temporary type check. +- End to end in Chrome: a newcomer sees the banner and "Not now" hides it; all six lessons, each first with a wrong + move (explanation, reset) and then a right one; the list shows them done; the rules page and the rules panel during + a game (the clock goes on); a practice game shows hints and explanations, and a game between two people shows + none. +- Mobile: type check and bundle; the rules screen on a device when one is available. diff --git a/docs/specs/tutorial/requirements.md b/docs/specs/tutorial/requirements.md new file mode 100644 index 0000000..eab42ad --- /dev/null +++ b/docs/specs/tutorial/requirements.md @@ -0,0 +1,83 @@ +# Tutorial: requirements + +Status: approved, being implemented on branch `feature/tutorial` (based on `develop`). +Scope: `packages/core`, web app; the mobile app gets the rules only (see R5). + +## Goals + +Someone who has never played Mangala can learn it in a few minutes, and anyone can look a rule up later, also in +the middle of a game. Three parts, from reading to playing: + +1. **Rules**: one page with every rule, a short text and a small board for each. Always reachable. +2. **Lessons**: one short interactive step per rule; the player makes the move the rule is about on a prepared + board and sees what happens. Runs in the browser, without the server. +3. **Practice game**: a real game against the Easy bot with hints: which moves give an extra turn or capture, and + after every move a sentence about what happened. + +## Terms + +- **Store**: the large pit on each side (in the texts "hazine"); stones in it count for the player. +- **Lesson**: one rule taught on a prepared position with a task, e.g. "End your move in your store". +- **Hint**: in the practice game, the effect a move would have right away (extra turn, capture, stones to store). + Hints never look further ahead than the move itself. +- **Newcomer**: someone on a device where no game has been played and the tutorial has not been opened or + dismissed. + +## Requirements + +### R1. Rules +- R1.1 A rules page ("How to play" / "Nasıl oynanır") lists, in order: the board and the goal, sowing, the extra + turn, capturing an even pit, capturing from an empty pit, the end of the game and who wins, and the clock. +- R1.2 Each rule has at most three sentences and a small board that shows it (before and after the move). +- R1.3 The texts describe exactly what the game does (`packages/mancala.js` and the backend's port), including the + details beginners miss: the first stone goes back into the pit it was taken from, a single stone moves to the next + pit, the opponent's store is skipped. +- R1.4 The rules can be reached from the header on every page, and from the game page without leaving the game + (a panel over the game; the game and its clock go on). +- R1.5 Each rule links to its lesson. + +### R2. Lessons +- R2.1 There is a lesson for each rule in R1.1 except the clock: six in all, in the order of R1.1. +- R2.2 A lesson shows the rule's text, a board with a prepared position and a task. The player plays on the real + board (same look, animation and move preview as a game). +- R2.3 A move that fulfils the task is praised and the next lesson is offered. Any other move is animated, then + explained ("This move ended in the opponent's pit, so the turn passed") and the position is set back so they can + try again. +- R2.4 Where a lesson needs the opponent to move, the opponent's move is scripted and shown like the bot's. +- R2.5 Lessons work without a connection to the server and need no login. +- R2.6 Completed lessons are remembered on the device and marked in the list. Lessons can be taken in any order + and repeated. +- R2.7 After the last lesson, the player is offered the practice game. + +### R3. Practice game +- R3.1 The practice game is a game against the Easy bot with hints turned on, started from the tutorial. It is + otherwise a normal bot game (clock, saving, history). +- R3.2 While it is the player's turn, every pit whose move gives an extra turn or captures is marked, with what it + does ("Extra turn", "Captures 5"). +- R3.3 After every move, of either side, one sentence says what happened, built from the move: e.g. "The bot's + last stone landed in its store: it plays again" or "You captured 6 stones from an even pit". +- R3.4 Hints can be turned on and off in every game against the bot; the choice is remembered on the device. +- R3.5 Hints are only available in games against the bot. Games between people never show them, also not to + spectators. + +### R4. Newcomers +- R4.1 A newcomer sees a banner on the home page: "New to Mangala? Learn it in 3 minutes" with "Start" and + "Not now". "Not now" hides it for good on that device. +- R4.2 The banner does not show once the tutorial has been opened or a game has been played on the device. +- R4.3 Nothing is forced: a newcomer can start a game without the tutorial. + +### R5. Mobile app +- R5.1 The mobile app shows the same rules (same texts, from `packages/core`) on a rules screen reachable from the + help screen and from the game screen's header. +- R5.2 Lessons and the practice game's hints on mobile are out of scope until the mobile app gets the new design + (`TODO.md`, Web redesign). + +### R6. Languages +- R6.1 All texts exist in English and Turkish. Turkish uses the usual Mangala words (kuyu, hazine, taş). + +## Out of scope (TODO) +- Lessons and hints in the mobile app (R5.2). +- Hints that look ahead (warning about a move that gives the opponent a capture); the bot's search could provide + them later. +- Strategy lessons beyond the rules. +- Keeping tutorial progress per account instead of per device. diff --git a/docs/specs/tutorial/tasks.md b/docs/specs/tutorial/tasks.md new file mode 100644 index 0000000..0ca7ebe --- /dev/null +++ b/docs/specs/tutorial/tasks.md @@ -0,0 +1,16 @@ +# Tutorial: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [ ] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and + the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests. +- [ ] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the + game page. +- [ ] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and + reset, scripted opponent replies. +- [ ] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button. +- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`. +- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle. +- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope + items). -- 2.45.2 From 3a46833e86eb7a163b6505d5e1625cf4b4d0249b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:50:42 +0300 Subject: [PATCH 66/82] feat(core): add the rules, lessons and move explanations RULES defines every rule with an example and a lesson; getMoveOutcome works out a move's effect with the engine, explainMove says what happened, and describeMove now tells the kind of capture and how many stones it took. --- docs/specs/tutorial/design.md | 14 +- docs/specs/tutorial/tasks.md | 2 +- packages/core/src/history/MoveHistory.ts | 24 +++ packages/core/src/index.ts | 1 + packages/core/src/localization/en.ts | 61 ++++++ packages/core/src/localization/tr.ts | 61 ++++++ packages/core/src/tutorial/MoveOutcome.ts | 107 ++++++++++ packages/core/src/tutorial/Rules.ts | 83 ++++++++ packages/core/src/tutorial/TutorialStorage.ts | 45 ++++ packages/core/src/tutorial/index.ts | 3 + .../core/tests/history/MoveHistory.test.ts | 4 +- packages/core/tests/tutorial/Tutorial.test.ts | 200 ++++++++++++++++++ 12 files changed, 595 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/tutorial/MoveOutcome.ts create mode 100644 packages/core/src/tutorial/Rules.ts create mode 100644 packages/core/src/tutorial/TutorialStorage.ts create mode 100644 packages/core/src/tutorial/index.ts create mode 100644 packages/core/tests/tutorial/Tutorial.test.ts diff --git a/docs/specs/tutorial/design.md b/docs/specs/tutorial/design.md index 2c35773..bd5da0c 100644 --- a/docs/specs/tutorial/design.md +++ b/docs/specs/tutorial/design.md @@ -51,8 +51,7 @@ type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | interface Lesson { board: number[]; // player 1 (bottom) to move task: LessonTask; - taskKey: string; // e.g. "LessonTaskExtraTurn": "End your move in your store" - opponentReply?: number; // board index the opponent plays after a fulfilled move, if the lesson needs one + taskKey: string; // e.g. "LessonTaskExtraTurn": "Find the pit whose last stone lands in your store" } interface Rule { @@ -75,15 +74,16 @@ interface MoveOutcome { endsGame: boolean; board: number[]; // after the move } -export function getMoveOutcome(board: number[], player1ToMove: boolean, pit: number): MoveOutcome; +export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome; // the mover follows from the pit export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean; // "Why did that happen" for a wrong lesson move and for the practice game (R2.3, R3.3). -export function explainMove(outcome: MoveOutcome, mover: "you" | "opponent" | "bot", t): string; +export function explainMove(outcome: MoveOutcome, mover: "you" | "bot", t): string; ``` -`getMoveOutcome` builds the position with `createGameFromBoard` (core history helpers), plays the pit and reads the -game steps, as `describeMove` does for the move list. `describeMove` gains `capture` kind and `captured`, so the +`getMoveOutcome` sets the stones on a `CommonMancalaGame`, plays the pit and reads the game steps, as `describeMove` +does for the move list. None of the six lessons needs the opponent to move, so lessons have no scripted replies +(R2.4 has nothing to do yet). `describeMove` gains `capture` kind and `captured`, so the move list, hints and explanations share one reading of the steps. Lesson positions (player 1, bottom, to move; pits 0–5, store 6), checked with the rules above. Task 2 tests that @@ -120,7 +120,7 @@ Components: page). - `LessonPage`: the rule's text and task above a `BoardView` on a local game (`createGameFromBoard`), animated with `PitAnimator` like a live game. On a move: `getMoveOutcome`, animate, then praise and "Next lesson" or - `explainMove` and "Try again", which resets the board. A scripted `opponentReply` is played after a short pause. + `explainMove` and "Try again", which resets the board. - `TutorialBanner` on the home page for newcomers (R4). - Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map (pit → "Extra turn" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with diff --git a/docs/specs/tutorial/tasks.md b/docs/specs/tutorial/tasks.md index 0ca7ebe..5f87c40 100644 --- a/docs/specs/tutorial/tasks.md +++ b/docs/specs/tutorial/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. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and +- [x] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests. - [ ] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the game page. diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts index fc1c55c..3af04c3 100644 --- a/packages/core/src/history/MoveHistory.ts +++ b/packages/core/src/history/MoveHistory.ts @@ -20,6 +20,8 @@ export interface PlayedMove { gameSteps: GameStep[]; } +export type CaptureKind = "even" | "emptyPit"; + export interface MoveDescription { // 1-based. number: number; @@ -28,6 +30,9 @@ export interface MoveDescription { pit: number; extraTurn: boolean; capture: boolean; + captureKind?: CaptureKind; + // Stones taken from the opponent's pit by a capture. + captured: number; // Stones the mover's store gained with this move. storeGain: number; gameOver: boolean; @@ -59,11 +64,30 @@ export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescr pit: move.moveIndex - firstPitIndex + 1, extraTurn: stepTypes.includes(GAME_STEP_LAST_STONE_IN_BANK), capture: stepTypes.includes(GAME_STEP_LAST_STONE_IN_EMPTY_PIT) || stepTypes.includes(GAME_STEP_DOUBLE_STONE_IN_PIT), + captureKind: getCaptureKind(move.gameSteps), + captured: getCapturedStones(before, move.moveIndex, move.gameSteps), storeGain: move.boardSnapshot[storeIndex] - before[storeIndex], gameOver: stepTypes.includes(GAME_STEP_BOARD_CLEARED), }; } +// Stones a capture took from the opponent's pit: what was there plus what this move dropped in. 0 without a capture. +export function getCapturedStones(before: number[], pitIndex: number, gameSteps: GameStep[]): number { + const path = getSowingPath(before, pitIndex); + const capturedFrom = (index: number) => before[index] + path.filter((i) => i === index).length; + for (const step of gameSteps) { + if (step.type === GAME_STEP_DOUBLE_STONE_IN_PIT) return capturedFrom(step.data.pitIndex); + if (step.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) return capturedFrom(step.data.oppositeIndex); + } + return 0; +} + +export function getCaptureKind(gameSteps: GameStep[]): CaptureKind | undefined { + if (gameSteps.some((step) => step.type === GAME_STEP_DOUBLE_STONE_IN_PIT)) return "even"; + if (gameSteps.some((step) => step.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT)) return "emptyPit"; + return undefined; +} + // Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them (see // Board.getSowingPath): the opponent's store is skipped. The last index is where the last stone lands. Empty for an // empty pit or a store. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72c074e..2670e24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,3 +10,4 @@ export * from './viewmodel/index' export * from './service/index' export * from './history/index' export * from './clock/index' +export * from './tutorial/index' diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 22ef873..dec8451 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -115,4 +115,65 @@ export const en = { BotLevelHard: "Hard", BotLevelMaster: "Master", PlayAgain: "Play again", + RuleBoardTitle: "The board and the goal", + RuleBoardText1: "Each player has the 6 pits on their side of the board and a store, the large pit at the end of their row. Every pit starts with 4 stones.", + RuleBoardText2: "Stones that reach your store are yours. On your turn you play one of your pits, then it is the opponent's turn.", + RuleBoardText3: "When the game ends, the player with more stones in their store wins. There are 48 stones, so 25 always win.", + RuleSowingTitle: "Sowing", + RuleSowingText1: "Take all the stones from one of your pits. Put the first back into the same pit and one into each pit after it, towards your store.", + RuleSowingText2: "A pit with a single stone moves it to the next pit.", + RuleSowingText3: "Your store gets a stone when you pass it; the opponent's store is skipped.", + RuleExtraTurnTitle: "Extra turn", + RuleExtraTurnText1: "If your last stone lands in your store, you play again.", + RuleExtraTurnText2: "Count the stones: a pit whose stones end exactly in your store gives you another move.", + RuleEvenCaptureTitle: "Capturing an even pit", + RuleEvenCaptureText1: "If your last stone lands in one of the opponent's pits and makes the stones there even, you take all of them into your store.", + RuleEvenCaptureText2: "If the number is odd, nothing is taken and the turn passes.", + RuleEmptyPitCaptureTitle: "Capturing from an empty pit", + RuleEmptyPitCaptureText1: "If your last stone lands in an empty pit of yours and the opponent's pit opposite has stones, you take that stone and all the stones opposite.", + RuleEmptyPitCaptureText2: "If the pit opposite is empty, nothing is taken.", + RuleEndOfGameTitle: "End of the game", + RuleEndOfGameText1: "The game ends as soon as all pits on one side are empty.", + RuleEndOfGameText2: "The player whose side is empty gets all the stones left on the other side.", + RuleEndOfGameText3: "Then the stores are counted: more stones wins, the same number is a draw.", + RuleClockTitle: "The clock", + RuleClockText1: "Each player has 5 minutes for the whole game. Only the clock of the player whose turn it is runs.", + RuleClockText2: "A player whose time runs out loses.", + LessonTaskAnyMove: "Play one of your pits in the bottom row and watch the stones.", + LessonTaskSowing: "Play any pit. Try the pit with a single stone too.", + LessonTaskExtraTurn: "Find the pit whose last stone lands in your store.", + LessonTaskEvenCapture: "Make one of the opponent's pits even with your last stone.", + LessonTaskEmptyPitCapture: "End your move in your empty pit to take the stones opposite.", + LessonTaskEndGame: "Play your last stone and see who gets the stones that are left.", + MoveExplainExtraTurnYou: "Your last stone landed in your store: you play again.", + MoveExplainExtraTurnBot: "The bot's last stone landed in its store: it plays again.", + MoveExplainEvenYou: "Your last stone made an opponent's pit even: you took {{count}} stones.", + MoveExplainEvenBot: "The bot's last stone made one of your pits even: it took {{count}} stones.", + MoveExplainEmptyPitYou: "Your last stone landed in an empty pit of yours: you took it and the {{count}} stones opposite.", + MoveExplainEmptyPitBot: "The bot's last stone landed in an empty pit of its own: it took it and your {{count}} stones opposite.", + MoveExplainOddYou: "Your last stone left {{count}} stones in the opponent's pit, an odd number: nothing was taken and the turn passes.", + MoveExplainOddBot: "The bot's last stone left {{count}} stones in your pit, an odd number: it took nothing.", + MoveExplainOwnPitYou: "Your last stone landed in one of your pits that was not empty: the turn passes.", + MoveExplainOwnPitBot: "The bot's last stone landed in one of its own pits: your turn.", + MoveExplainEmptyOppositeYou: "Your last stone landed in an empty pit of yours, but the pit opposite was empty: nothing to take.", + MoveExplainEmptyOppositeBot: "The bot's last stone landed in its empty pit, but your pit opposite was empty: it took nothing.", + MoveExplainGameOver: "One side is empty, so the game is over.", + HowToPlay: "How to play", + LearnMangala: "Learn Mangala", + Lessons: "Lessons", + TryIt: "Try it", + Hints: "Hints", + HintExtraTurn: "Extra turn", + HintCaptures: "Takes {{count}}", + NewToMangala: "New to Mangala? Learn it in 3 minutes.", + StartTutorial: "Start", + NotNow: "Not now", + PracticeGame: "Practice game", + PracticeGameText: "Play the Easy bot with hints on.", + WellDone: "Well done!", + NextLesson: "Next lesson", + TryAgain: "Try again", + LessonDone: "Done", + AllLessons: "All lessons", + LessonNumber: "Lesson {{number}} of {{count}}", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index d6c07c0..fe64c7b 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -115,4 +115,65 @@ export const tr = { BotLevelHard: "Zor", BotLevelMaster: "Usta", PlayAgain: "Tekrar oyna", + RuleBoardTitle: "Tahta ve amaç", + RuleBoardText1: "Her oyuncunun tahtada kendi tarafında 6 kuyusu ve sırasının sonunda büyük bir hazinesi vardır. Her kuyuda başta 4 taş bulunur.", + RuleBoardText2: "Hazinene giren taşlar senindir. Sıran geldiğinde kendi kuyularından birini oynarsın, sonra sıra rakibe geçer.", + RuleBoardText3: "Oyun bittiğinde hazinesinde daha çok taş olan kazanır. Toplam 48 taş var, yani 25 taş her zaman kazandırır.", + RuleSowingTitle: "Taşları dağıtmak", + RuleSowingText1: "Kuyularından birindeki bütün taşları al. İlkini aynı kuyuya geri bırak, kalanları hazinene doğru sıradaki kuyulara birer birer bırak.", + RuleSowingText2: "Kuyuda tek taş varsa o taş bir sonraki kuyuya geçer.", + RuleSowingText3: "Hazinenin üzerinden geçerken ona da bir taş bırakırsın; rakibin hazinesi atlanır.", + RuleExtraTurnTitle: "Bir hamle daha", + RuleExtraTurnText1: "Son taşın hazinene düşerse bir kez daha oynarsın.", + RuleExtraTurnText2: "Taşları say: taşları tam hazinende biten kuyu sana bir hamle daha kazandırır.", + RuleEvenCaptureTitle: "Çift yapıp almak", + RuleEvenCaptureText1: "Son taşın rakibin bir kuyusuna düşer ve oradaki taş sayısını çift yaparsa, o kuyudaki bütün taşları hazinene alırsın.", + RuleEvenCaptureText2: "Sayı tek olursa bir şey alınmaz, sıra rakibe geçer.", + RuleEmptyPitCaptureTitle: "Boş kuyudan almak", + RuleEmptyPitCaptureText1: "Son taşın kendi tarafındaki boş bir kuyuya düşer ve rakibin karşıdaki kuyusunda taş varsa, o taşı ve karşıdaki bütün taşları hazinene alırsın.", + RuleEmptyPitCaptureText2: "Karşıdaki kuyu boşsa bir şey alınmaz.", + RuleEndOfGameTitle: "Oyunun sonu", + RuleEndOfGameText1: "Bir taraftaki bütün kuyular boşaldığı anda oyun biter.", + RuleEndOfGameText2: "Tarafı boşalan oyuncu, karşı tarafta kalan bütün taşları alır.", + RuleEndOfGameText3: "Sonra hazineler sayılır: çok taşı olan kazanır, eşitse oyun berabere biter.", + RuleClockTitle: "Süre", + RuleClockText1: "Her oyuncunun bütün oyun için 5 dakikası vardır. Yalnızca sırası gelen oyuncunun süresi işler.", + RuleClockText2: "Süresi biten oyuncu kaybeder.", + LessonTaskAnyMove: "Alt sıradaki kuyularından birini oyna ve taşları izle.", + LessonTaskSowing: "Herhangi bir kuyuyu oyna. Tek taşlı kuyuyu da dene.", + LessonTaskExtraTurn: "Son taşı hazinene düşen kuyuyu bul.", + LessonTaskEvenCapture: "Son taşınla rakibin bir kuyusunu çift yap.", + LessonTaskEmptyPitCapture: "Hamleni boş kuyunda bitir ve karşıdaki taşları al.", + LessonTaskEndGame: "Son taşını oyna ve kalan taşların kime gittiğini gör.", + MoveExplainExtraTurnYou: "Son taşın hazinene düştü: bir kez daha oynuyorsun.", + MoveExplainExtraTurnBot: "Botun son taşı kendi hazinesine düştü: bir kez daha oynuyor.", + MoveExplainEvenYou: "Son taşın rakibin kuyusunu çift yaptı: {{count}} taş aldın.", + MoveExplainEvenBot: "Botun son taşı senin bir kuyunu çift yaptı: {{count}} taş aldı.", + MoveExplainEmptyPitYou: "Son taşın kendi boş kuyuna düştü: onu ve karşıdaki {{count}} taşı aldın.", + MoveExplainEmptyPitBot: "Botun son taşı kendi boş kuyusuna düştü: onu ve karşısındaki {{count}} taşını aldı.", + MoveExplainOddYou: "Son taşın rakibin kuyusunda {{count}} taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti.", + MoveExplainOddBot: "Botun son taşı senin kuyunda {{count}} taş bıraktı, bu tek sayı: bir şey almadı.", + MoveExplainOwnPitYou: "Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti.", + MoveExplainOwnPitBot: "Botun son taşı kendi kuyusuna düştü: sıra sende.", + MoveExplainEmptyOppositeYou: "Son taşın boş kuyuna düştü ama karşıdaki kuyu da boştu: alınacak taş yok.", + MoveExplainEmptyOppositeBot: "Botun son taşı boş kuyusuna düştü ama karşısındaki kuyun boştu: bir şey almadı.", + MoveExplainGameOver: "Bir taraf boşaldı, oyun bitti.", + HowToPlay: "Nasıl oynanır", + LearnMangala: "Mangala öğren", + Lessons: "Dersler", + TryIt: "Dene", + Hints: "İpuçları", + HintExtraTurn: "Bir hamle daha", + HintCaptures: "{{count}} taş alır", + NewToMangala: "Mangala'da yeni misin? 3 dakikada öğren.", + StartTutorial: "Başla", + NotNow: "Şimdi değil", + PracticeGame: "Alıştırma oyunu", + PracticeGameText: "İpuçları açıkken Kolay bota karşı oyna.", + WellDone: "Harika!", + NextLesson: "Sonraki ders", + TryAgain: "Tekrar dene", + LessonDone: "Tamamlandı", + AllLessons: "Tüm dersler", + LessonNumber: "Ders {{number}} / {{count}}", }; \ No newline at end of file diff --git a/packages/core/src/tutorial/MoveOutcome.ts b/packages/core/src/tutorial/MoveOutcome.ts new file mode 100644 index 0000000..ea4abb8 --- /dev/null +++ b/packages/core/src/tutorial/MoveOutcome.ts @@ -0,0 +1,107 @@ +import { + CommonMancalaGame, + GAME_STEP_BOARD_CLEARED, + GAME_STEP_LAST_STONE_IN_BANK, +} from "mancala.js"; +import { CaptureKind, getCapturedStones, getCaptureKind, getSowingPath } from "../history/MoveHistory"; + +const PLAYER1 = "player1"; +const PLAYER2 = "player2"; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +// Where a move's last stone landed, from the mover's point of view. +export type Landing = "ownStore" | "ownPit" | "opponentPit"; + +// The immediate effect of playing a pit, worked out by the engine (docs/specs/tutorial). +export interface MoveOutcome { + // Board index that was played; 0-5 for player 1, 7-12 for player 2. + pitIndex: number; + player1Moved: boolean; + // Board indexes the stones were dropped into, in order. + path: number[]; + landing: Landing; + // Stones in the landing pit before the last stone arrived. + stonesBeforeLanding: number; + extraTurn: boolean; + capture?: CaptureKind; + // Stones taken from the opponent's pit by a capture. + captured: number; + // Stones the mover's store gained. + storeGain: number; + endsGame: boolean; + // Stones in every pit after the move. + board: number[]; +} + +export const isPlayer1Pit = (index: number) => index >= 0 && index < PLAYER1_STORE; +export const isPlayer2Pit = (index: number) => index > PLAYER1_STORE && index < PLAYER2_STORE; + +// Plays the pit on a copy of the board with the real rules. Throws for a store or an empty pit. +export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome { + const player1Moved = isPlayer1Pit(pitIndex); + if (!player1Moved && !isPlayer2Pit(pitIndex)) throw new Error(`Not a pit: ${pitIndex}`); + if (!(board[pitIndex] > 0)) throw new Error(`Pit ${pitIndex} is empty`); + const game = new CommonMancalaGame("move-outcome", PLAYER1, PLAYER2); + board.forEach((stoneCount, index) => (game.board.pits[index].stoneCount = stoneCount)); + const mover = player1Moved ? PLAYER1 : PLAYER2; + game.turnPlayerId = mover; + game.moveByPlayerPit(mover, player1Moved ? pitIndex : pitIndex - PLAYER1_STORE - 1); + const gameSteps = game.history[game.history.length - 1].gameSteps; + const after = game.board.getStoneArray(); + const path = getSowingPath(board, pitIndex); + const last = path[path.length - 1]; + const ownStore = player1Moved ? PLAYER1_STORE : PLAYER2_STORE; + const isOwnPit = player1Moved ? isPlayer1Pit : isPlayer2Pit; + const dropsBeforeLast = path.slice(0, -1).filter((i) => i === last).length; + return { + pitIndex, + player1Moved, + path, + landing: last === ownStore ? "ownStore" : isOwnPit(last) ? "ownPit" : "opponentPit", + stonesBeforeLanding: (last === pitIndex ? 0 : board[last]) + dropsBeforeLast, + extraTurn: gameSteps.some((step) => step.type === GAME_STEP_LAST_STONE_IN_BANK), + capture: getCaptureKind(gameSteps), + captured: getCapturedStones(board, pitIndex, gameSteps), + storeGain: after[ownStore] - board[ownStore], + endsGame: gameSteps.some((step) => step.type === GAME_STEP_BOARD_CLEARED), + board: after, + }; +} + +export type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame"; + +export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean { + switch (task) { + case "anyMove": return true; + case "extraTurn": return outcome.extraTurn; + case "evenCapture": return outcome.capture === "even"; + case "emptyPitCapture": return outcome.capture === "emptyPit"; + case "endGame": return outcome.endsGame; + } +} + +export type Mover = "you" | "bot"; + +type Translate = (key: string, options?: Record) => string; + +// What happened in a move and why, in one or two sentences (lessons and hints). +export function explainMove(outcome: MoveOutcome, mover: Mover, t: Translate): string { + const who = mover === "you" ? "You" : "Bot"; + const sentences: string[] = []; + if (outcome.capture === "even") { + sentences.push(t(`MoveExplainEven${who}`, { count: outcome.captured })); + } else if (outcome.capture === "emptyPit") { + sentences.push(t(`MoveExplainEmptyPit${who}`, { count: outcome.captured })); + } else if (outcome.landing === "ownStore") { + sentences.push(t(`MoveExplainExtraTurn${who}`)); + } else if (outcome.landing === "opponentPit") { + sentences.push(t(`MoveExplainOdd${who}`, { count: outcome.stonesBeforeLanding + 1 })); + } else if (outcome.stonesBeforeLanding === 0) { + sentences.push(t(`MoveExplainEmptyOpposite${who}`)); + } else { + sentences.push(t(`MoveExplainOwnPit${who}`)); + } + if (outcome.endsGame) sentences.push(t("MoveExplainGameOver")); + return sentences.join(" "); +} diff --git a/packages/core/src/tutorial/Rules.ts b/packages/core/src/tutorial/Rules.ts new file mode 100644 index 0000000..936e052 --- /dev/null +++ b/packages/core/src/tutorial/Rules.ts @@ -0,0 +1,83 @@ +import { LessonTask } from "./MoveOutcome"; + +export type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock"; + +// A rule shown on a board: the position, player 1 (bottom) to move, and the pit played, if any. +export interface RuleExample { + board: number[]; + pitIndex?: number; +} + +// The rule taught on a prepared position (player 1, bottom, to move) with a task. +export interface Lesson { + board: number[]; + task: LessonTask; + taskKey: string; +} + +export interface Rule { + id: RuleId; + titleKey: string; + // At most three sentences. + textKeys: string[]; + example?: RuleExample; + lesson?: Lesson; +} + +const START = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]; +const SOWING = [4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2]; +const EVEN_CAPTURE = [4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1]; +const EMPTY_PIT_CAPTURE = [2, 0, 4, 4, 4, 4, 3, 4, 4, 4, 4, 5, 4, 2]; +const END_OF_GAME = [0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18]; + +const texts = (id: string, count: number) => Array.from({ length: count }, (_, i) => `Rule${id}Text${i + 1}`); + +// The rules of the game in the order they are taught (docs/specs/tutorial). Every board here is checked against the +// engine by the tests; the boards after a move are never written by hand. +export const RULES: Rule[] = [ + { + id: "board", titleKey: "RuleBoardTitle", textKeys: texts("Board", 3), + example: { board: START }, + lesson: { board: START, task: "anyMove", taskKey: "LessonTaskAnyMove" }, + }, + { + id: "sowing", titleKey: "RuleSowingTitle", textKeys: texts("Sowing", 3), + example: { board: SOWING, pitIndex: 0 }, + lesson: { board: SOWING, task: "anyMove", taskKey: "LessonTaskSowing" }, + }, + { + id: "extraTurn", titleKey: "RuleExtraTurnTitle", textKeys: texts("ExtraTurn", 2), + example: { board: START, pitIndex: 3 }, + lesson: { board: START, task: "extraTurn", taskKey: "LessonTaskExtraTurn" }, + }, + { + id: "evenCapture", titleKey: "RuleEvenCaptureTitle", textKeys: texts("EvenCapture", 2), + example: { board: EVEN_CAPTURE, pitIndex: 5 }, + lesson: { board: EVEN_CAPTURE, task: "evenCapture", taskKey: "LessonTaskEvenCapture" }, + }, + { + id: "emptyPitCapture", titleKey: "RuleEmptyPitCaptureTitle", textKeys: texts("EmptyPitCapture", 2), + example: { board: EMPTY_PIT_CAPTURE, pitIndex: 0 }, + lesson: { board: EMPTY_PIT_CAPTURE, task: "emptyPitCapture", taskKey: "LessonTaskEmptyPitCapture" }, + }, + { + id: "endOfGame", titleKey: "RuleEndOfGameTitle", textKeys: texts("EndOfGame", 3), + example: { board: END_OF_GAME, pitIndex: 5 }, + lesson: { board: END_OF_GAME, task: "endGame", taskKey: "LessonTaskEndGame" }, + }, + { + id: "clock", titleKey: "RuleClockTitle", textKeys: texts("Clock", 2), + }, +]; + +export const LESSON_RULES: Rule[] = RULES.filter((rule) => rule.lesson); + +export function getRule(id: string | undefined): Rule | undefined { + return RULES.find((rule) => rule.id === id); +} + +// The lesson after the given one, or undefined after the last. +export function getNextLessonRule(id: RuleId): Rule | undefined { + const index = LESSON_RULES.findIndex((rule) => rule.id === id); + return index >= 0 ? LESSON_RULES[index + 1] : undefined; +} diff --git a/packages/core/src/tutorial/TutorialStorage.ts b/packages/core/src/tutorial/TutorialStorage.ts new file mode 100644 index 0000000..4a944a1 --- /dev/null +++ b/packages/core/src/tutorial/TutorialStorage.ts @@ -0,0 +1,45 @@ +import { Storage } from "../storage/storage"; +import { RuleId } from "./Rules"; + +// What the tutorial remembers on the device (docs/specs/tutorial, R2.6, R3.4, R4). +const COMPLETED_LESSONS_KEY = "tutorialCompletedLessons"; +const BANNER_HIDDEN_KEY = "tutorialBannerHidden"; +const HINTS_KEY = "botGameHints"; +const GAME_PLAYED_KEY = "gamePlayed"; + +export function getCompletedLessons(storage: Storage): RuleId[] { + try { + const saved = JSON.parse(storage.getString(COMPLETED_LESSONS_KEY) ?? "[]"); + return Array.isArray(saved) ? saved : []; + } catch { + return []; + } +} + +export function completeLesson(storage: Storage, id: RuleId): void { + const completed = getCompletedLessons(storage); + if (!completed.includes(id)) storage.setString(COMPLETED_LESSONS_KEY, JSON.stringify([...completed, id])); +} + +export function hideTutorialBanner(storage: Storage): void { + storage.setString(BANNER_HIDDEN_KEY, "true"); +} + +export function markGamePlayed(storage: Storage): void { + storage.setString(GAME_PLAYED_KEY, "true"); +} + +// For newcomers only: nobody played a game on this device, opened the tutorial or dismissed the banner (R4). +export function shouldShowTutorialBanner(storage: Storage): boolean { + return storage.getString(BANNER_HIDDEN_KEY) !== "true" && storage.getString(GAME_PLAYED_KEY) !== "true" + && getCompletedLessons(storage).length === 0; +} + +// Hints in games against the bot; off until turned on. +export function getHintsEnabled(storage: Storage): boolean { + return storage.getString(HINTS_KEY) === "true"; +} + +export function setHintsEnabled(storage: Storage, enabled: boolean): void { + storage.setString(HINTS_KEY, String(enabled)); +} diff --git a/packages/core/src/tutorial/index.ts b/packages/core/src/tutorial/index.ts new file mode 100644 index 0000000..d5458f3 --- /dev/null +++ b/packages/core/src/tutorial/index.ts @@ -0,0 +1,3 @@ +export * from './MoveOutcome' +export * from './Rules' +export * from './TutorialStorage' diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts index 3865e79..e76fb28 100644 --- a/packages/core/tests/history/MoveHistory.test.ts +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -59,7 +59,7 @@ describe("describeMove", () => { const game = newGame(); game.moveByPlayerPit(P1, 3); // 4 stones from the 4th pit: pits 4, 5, 6 and the store expect(describeMove(movesOf(game), 1)).toEqual({ - number: 1, playerId: P1, pit: 4, extraTurn: true, capture: false, storeGain: 1, gameOver: false, + number: 1, playerId: P1, pit: 4, extraTurn: true, capture: false, captured: 0, storeGain: 1, gameOver: false, }); }); @@ -85,7 +85,7 @@ describe("describeMove", () => { game.turnPlayerId = P1; game.history[0].boardSnapshot = game.board.getStoneArray(); game.moveByPlayerPit(P1, 0); - expect(describeMove(movesOf(game), 2)).toMatchObject({ pit: 1, capture: true, storeGain: 6 }); + expect(describeMove(movesOf(game), 2)).toMatchObject({ pit: 1, capture: true, captureKind: "emptyPit", captured: 5, storeGain: 6 }); }); it("marks only the last move of a finished game as game over, and store gains add up to the final stores", () => { diff --git a/packages/core/tests/tutorial/Tutorial.test.ts b/packages/core/tests/tutorial/Tutorial.test.ts new file mode 100644 index 0000000..3b5db88 --- /dev/null +++ b/packages/core/tests/tutorial/Tutorial.test.ts @@ -0,0 +1,200 @@ +import { CommonMancalaGame } from "mancala.js"; +import { en, tr } from "../../src/localization"; +import { + completeLesson, + explainMove, + fulfilsTask, + getCompletedLessons, + getHintsEnabled, + getMoveOutcome, + getNextLessonRule, + hideTutorialBanner, + LESSON_RULES, + markGamePlayed, + MoveOutcome, + RULES, + setHintsEnabled, + shouldShowTutorialBanner, +} from "../../src/tutorial"; +import { describeMove, PlayedMove } from "../../src/history"; + +const player1Pits = (board: number[]) => [0, 1, 2, 3, 4, 5].filter((index) => board[index] > 0); + +const translator = (texts: Record) => (key: string, options?: Record) => { + const text = texts[key]; + if (text === undefined) throw new Error(`Missing text ${key}`); + return text.replace(/{{(\w+)}}/g, (_, name) => String(options?.[name])); +}; + +const memoryStorage = () => { + const values = new Map(); + return { + getString: (key: string) => values.get(key), + setString: (key: string, value: string) => { values.set(key, value); }, + deleteString: (key: string) => { values.delete(key); }, + }; +}; + +describe("lessons", () => { + it("teach every rule but the clock, in order", () => { + expect(LESSON_RULES.map((rule) => rule.id)) + .toEqual(["board", "sowing", "extraTurn", "evenCapture", "emptyPitCapture", "endOfGame"]); + expect(getNextLessonRule("sowing")?.id).toBe("extraTurn"); + expect(getNextLessonRule("endOfGame")).toBeUndefined(); + }); + + it.each(LESSON_RULES.map((rule) => [rule.id, rule.lesson!] as const))("%s can be done and failed", (_, lesson) => { + expect(lesson.board.reduce((a, b) => a + b, 0)).toBe(48); + const pits = player1Pits(lesson.board); + const results = pits.map((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit))); + expect(results).toContain(true); + if (lesson.task !== "anyMove" && pits.length > 1) expect(results).toContain(false); + }); + + it("are done by the moves the spec names", () => { + const fulfilling = (id: string) => { + const lesson = LESSON_RULES.find((rule) => rule.id === id)!.lesson!; + return player1Pits(lesson.board).filter((pit) => fulfilsTask(lesson.task, getMoveOutcome(lesson.board, pit))); + }; + expect(fulfilling("extraTurn")).toEqual([3]); + expect(fulfilling("evenCapture")).toEqual([5]); + expect(fulfilling("emptyPitCapture")).toEqual([0]); + expect(fulfilling("endOfGame")).toEqual([5]); + }); +}); + +describe("rule examples", () => { + const example = (id: string) => { + const rule = RULES.find((r) => r.id === id)!; + return getMoveOutcome(rule.example!.board, rule.example!.pitIndex!); + }; + + it("show what their rule is about", () => { + const sowing = example("sowing"); + expect(sowing.path).toEqual([0, 1, 2, 3]); + expect(example("extraTurn").extraTurn).toBe(true); + expect(example("evenCapture")).toMatchObject({ capture: "even", captured: 4, storeGain: 5 }); + expect(example("emptyPitCapture")).toMatchObject({ capture: "emptyPit", captured: 5, storeGain: 6 }); + expect(example("endOfGame")).toMatchObject({ endsGame: true, extraTurn: true, storeGain: 10 }); + expect(example("endOfGame").board).toEqual([0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18]); + }); + + it("moves a single stone to the next pit", () => { + const single = getMoveOutcome([4, 4, 1, 4, 4, 4, 1, 4, 4, 4, 4, 4, 4, 2], 2); + expect(single.path).toEqual([3]); + expect(single).toMatchObject({ landing: "ownPit", stonesBeforeLanding: 4 }); + }); + + it("has a title and texts in both languages", () => { + for (const rule of RULES) { + for (const key of [rule.titleKey, ...rule.textKeys, ...(rule.lesson ? [rule.lesson.taskKey] : [])]) { + expect(en).toHaveProperty(key); + expect(tr).toHaveProperty(key); + } + expect(rule.textKeys.length).toBeLessThanOrEqual(3); + } + }); +}); + +describe("getMoveOutcome", () => { + it("matches the engine in random games, for both players", () => { + let seed = 7; + const random = (n: number) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; }; + for (let gameNumber = 0; gameNumber < 300; gameNumber++) { + const game = new CommonMancalaGame("g", "p1", "p2"); + game.turnPlayerId = random(2) ? "p1" : "p2"; + const moves: PlayedMove[] = []; + const isEnded = () => (game.state as string) === "ended"; + while (!isEnded()) { + const before = game.board.getStoneArray(); + const player1 = game.turnPlayerId === "p1"; + const first = player1 ? 0 : 7; + const pits = [0, 1, 2, 3, 4, 5].filter((pit) => before[first + pit] > 0); + const pit = pits[random(pits.length)]; + const outcome = getMoveOutcome(before, first + pit); + const mover = game.turnPlayerId; + game.moveByPlayerPit(mover, pit); + expect(outcome.board).toEqual(game.board.getStoneArray()); + if (!isEnded()) expect(outcome.extraTurn).toBe(game.turnPlayerId === mover); + expect(outcome.endsGame).toBe(isEnded()); + moves.push({ playerId: mover, moveIndex: first + pit, boardSnapshot: game.board.getStoneArray(), + gameSteps: game.history[game.history.length - 1].gameSteps }); + const description = describeMove(moves, moves.length); + expect(description.captured).toBe(outcome.captured); + expect(description.captureKind).toBe(outcome.capture); + } + } + }); + + it("refuses stores and empty pits", () => { + expect(() => getMoveOutcome([4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0], 6)).toThrow(); + expect(() => getMoveOutcome([0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0], 0)).toThrow(); + }); +}); + +describe("explainMove", () => { + const outcome = (board: number[], pit: number): MoveOutcome => getMoveOutcome(board, pit); + const start = [4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0]; + + it("says why in Turkish and English", () => { + const t = translator(tr); + expect(explainMove(outcome(start, 3), "you", t)).toBe("Son taşın hazinene düştü: bir kez daha oynuyorsun."); + expect(explainMove(outcome(start, 4), "you", t)) + .toBe("Son taşın rakibin kuyusunda 5 taş bıraktı, bu tek sayı: bir şey alınmadı, sıra rakibe geçti."); + expect(explainMove(outcome(start, 0), "you", t)).toBe("Son taşın kendi dolu kuyuna düştü: sıra rakibe geçti."); + expect(explainMove(outcome([4, 4, 4, 4, 4, 4, 0, 4, 3, 4, 4, 4, 4, 1], 5), "you", translator(en))) + .toBe("Your last stone made an opponent's pit even: you took 4 stones."); + expect(explainMove(outcome([0, 0, 0, 0, 0, 1, 20, 2, 3, 1, 0, 2, 1, 18], 5), "you", translator(en))) + .toBe("Your last stone landed in your store: you play again. One side is empty, so the game is over."); + }); + + it("explains the bot's moves from the player's side", () => { + // The bot is player 2: pit 7 with 2 stones ends in its empty pit 8, opposite player 1's pit 4. + const board = [4, 4, 4, 4, 3, 4, 0, 2, 0, 4, 4, 4, 4, 9]; + expect(explainMove(outcome(board, 7), "bot", translator(tr))) + .toBe("Botun son taşı kendi boş kuyusuna düştü: onu ve karşısındaki 3 taşını aldı."); + }); + + it("has every sentence in both languages", () => { + for (const texts of [en, tr]) { + for (const who of ["You", "Bot"]) { + for (const kind of ["ExtraTurn", "Even", "EmptyPit", "Odd", "OwnPit", "EmptyOpposite"]) { + expect(texts).toHaveProperty(`MoveExplain${kind}${who}`); + } + } + } + }); +}); + +describe("tutorial storage", () => { + it("remembers completed lessons once each", () => { + const storage = memoryStorage(); + expect(getCompletedLessons(storage)).toEqual([]); + completeLesson(storage, "sowing"); + completeLesson(storage, "sowing"); + completeLesson(storage, "board"); + expect(getCompletedLessons(storage)).toEqual(["sowing", "board"]); + storage.setString("tutorialCompletedLessons", "not json"); + expect(getCompletedLessons(storage)).toEqual([]); + }); + + it("shows the banner to newcomers only", () => { + const dismissed = memoryStorage(); + expect(shouldShowTutorialBanner(dismissed)).toBe(true); + hideTutorialBanner(dismissed); + expect(shouldShowTutorialBanner(dismissed)).toBe(false); + const played = memoryStorage(); + markGamePlayed(played); + expect(shouldShowTutorialBanner(played)).toBe(false); + const learning = memoryStorage(); + completeLesson(learning, "board"); + expect(shouldShowTutorialBanner(learning)).toBe(false); + }); + + it("keeps hints off until turned on", () => { + const storage = memoryStorage(); + expect(getHintsEnabled(storage)).toBe(false); + setHintsEnabled(storage, true); + expect(getHintsEnabled(storage)).toBe(true); + }); +}); -- 2.45.2 From 8511e0b3f2ef36ca81b9eb0a6a8e156844255af7 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:53:18 +0300 Subject: [PATCH 67/82] feat(frontend): show the rules A How to play page with every rule and a before/after board computed by the engine, a help button in the header, and the rules in a side panel over the game page so a game can go on while a rule is looked up. --- apps/frontend/src/MancalaApp.tsx | 2 + apps/frontend/src/components/Icons.tsx | 4 ++ .../src/components/headerbar/AppHeader.tsx | 12 ++-- apps/frontend/src/components/home/Home.css | 14 ----- .../src/components/home/MiniBoard.css | 19 ++++++ .../src/components/home/MiniBoard.tsx | 15 +++-- .../src/components/rules/RuleBoard.tsx | 44 ++++++++++++++ .../src/components/rules/RulesPanel.tsx | 52 +++++++++++++++++ .../src/components/rules/RulesView.tsx | 58 +++++++++++++++++++ apps/frontend/src/routes/GamePage.tsx | 6 +- apps/frontend/src/routes/RulesPage.tsx | 37 ++++++++++++ docs/specs/tutorial/tasks.md | 2 +- 12 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 apps/frontend/src/components/home/MiniBoard.css create mode 100644 apps/frontend/src/components/rules/RuleBoard.tsx create mode 100644 apps/frontend/src/components/rules/RulesPanel.tsx create mode 100644 apps/frontend/src/components/rules/RulesView.tsx create mode 100644 apps/frontend/src/routes/RulesPage.tsx diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index bf2a1f8..a2faa37 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -14,6 +14,7 @@ import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core'; import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; import PrivacyPage from './routes/PrivacyPage'; +import RulesPage from './routes/RulesPage'; import swal from 'sweetalert'; import Util from './util/Util'; import { server } from './const/config'; @@ -130,6 +131,7 @@ const MancalaApp: FunctionComponent = () => { }> + } /> } diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 3f7e5c0..37f93d1 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -71,3 +71,7 @@ export const PauseIcon = ({ size = 22 }: IconProps) => ( export const BotIcon = ({ size = 20 }: IconProps) => ( ); + +export const HelpIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/headerbar/AppHeader.tsx b/apps/frontend/src/components/headerbar/AppHeader.tsx index 236e159..bb22127 100644 --- a/apps/frontend/src/components/headerbar/AppHeader.tsx +++ b/apps/frontend/src/components/headerbar/AppHeader.tsx @@ -4,13 +4,14 @@ import { Link } from "react-router-dom"; import { AuthUser } from "@mancala/core"; import { useTranslation } from "react-i18next"; import { Context } from "../../context/context"; -import { LogoIcon } from "../Icons"; +import { HelpIcon, LogoIcon } from "../Icons"; import AccountMenu from "./AccountMenu"; import ThemeSwatches from "./ThemeSwatches"; -// The header of every page: logo and name, theme swatches, the account menu when a user is given, and the page's -// main action (children). -const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => { +// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and +// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the +// rules over the game instead). +const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => { const { t } = useTranslation(); return (
    @@ -19,6 +20,9 @@ const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = {t("Mancala")} + {onHelp + ? + : } {authUser && } {children}
    diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css index 74fc073..3cdda22 100644 --- a/apps/frontend/src/components/home/Home.css +++ b/apps/frontend/src/components/home/Home.css @@ -47,24 +47,10 @@ .meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .meta > span:last-child { text-align: right; } -/* Mini board: the top player's store on the left, the bottom player's on the right */ - -.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } -.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } -.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } -.mini .ms.l { grid-column: 1; } -.mini .ms.r { grid-column: 8; color: var(--accent); } -.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } -.mini.lg .mp { font-size: 16px; } -.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } - @media (max-width: 640px) { .hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; } .hero h1 { font-size: 25px; } .hero .btn { flex: 1; } - .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } - .mini.lg .mp { font-size: 13px; } - .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } .grid { grid-template-columns: 1fr; gap: 14px; } .sec { margin: 12px 0 -4px; } } diff --git a/apps/frontend/src/components/home/MiniBoard.css b/apps/frontend/src/components/home/MiniBoard.css new file mode 100644 index 0000000..17d5c87 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.css @@ -0,0 +1,19 @@ +/* Mini board: the top player's store on the left, the bottom player's on the right */ + +.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } +.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms.l { grid-column: 1; } +.mini .ms.r { grid-column: 8; color: var(--accent); } +.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } +.mini.lg .mp { font-size: 16px; } +.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } +/* A move shown on the board: the pit played and where its last stone landed */ +.mini .played { box-shadow: inset 0 0 0 2px var(--accent); } +.mini .land, .mini .ms.r.land { background: var(--accent); color: var(--onAccent); } + +@media (max-width: 640px) { + .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } + .mini.lg .mp { font-size: 13px; } + .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } +} diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx index b6e1332..8923dd3 100644 --- a/apps/frontend/src/components/home/MiniBoard.tsx +++ b/apps/frontend/src/components/home/MiniBoard.tsx @@ -1,22 +1,27 @@ import * as React from "react"; import { FunctionComponent } from "react"; +import "./MiniBoard.css"; const PLAYER1_PITS = [0, 1, 2, 3, 4, 5]; const PLAYER2_PITS = [7, 8, 9, 10, 11, 12]; const PLAYER1_STORE = 6; const PLAYER2_STORE = 13; -// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. -const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => { +// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. `played` and `landed` mark +// a move: the pit played and where its last stone landed. +const MiniBoard: FunctionComponent<{ + board: number[], revert?: boolean, large?: boolean, played?: number, landed?: number, +}> = ({ board, revert, large, played, landed }) => { const [topPits, topStore, bottomPits, bottomStore] = revert ? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE] : [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE]; - const pit = (index: number) =>
    {board[index] || ""}
    ; + const marks = (index: number) => (index === played ? " played" : "") + (index === landed ? " land" : ""); + const pit = (index: number) =>
    {board[index] || ""}
    ; return ( ); diff --git a/apps/frontend/src/components/rules/RuleBoard.tsx b/apps/frontend/src/components/rules/RuleBoard.tsx new file mode 100644 index 0000000..5d925ef --- /dev/null +++ b/apps/frontend/src/components/rules/RuleBoard.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getMoveOutcome, RuleExample } from "@mancala/core"; +import MiniBoard from "../home/MiniBoard"; +import { NextIcon } from "../Icons"; + +// A rule's example: the board before the move with the pit played marked, and after it with the landing pit marked. +// The board after the move comes from the engine. +const RuleBoard: FunctionComponent<{ example: RuleExample }> = ({ example }) => { + if (example.pitIndex === undefined) { + return
    ; + } + const outcome = getMoveOutcome(example.board, example.pitIndex); + return ( +
    + + + + +
    + ); +}; + +export default RuleBoard; diff --git a/apps/frontend/src/components/rules/RulesPanel.tsx b/apps/frontend/src/components/rules/RulesPanel.tsx new file mode 100644 index 0000000..566b0be --- /dev/null +++ b/apps/frontend/src/components/rules/RulesPanel.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseIcon } from "../Icons"; +import RulesView from "./RulesView"; + +// The rules over the game page, so a player can look a rule up without leaving the game (R1.4). +const RulesPanel: FunctionComponent<{ onClose: () => void }> = ({ onClose }) => { + const { t } = useTranslation(); + return ( +
    + + +
    + ); +}; + +export default RulesPanel; diff --git a/apps/frontend/src/components/rules/RulesView.tsx b/apps/frontend/src/components/rules/RulesView.tsx new file mode 100644 index 0000000..2ca7727 --- /dev/null +++ b/apps/frontend/src/components/rules/RulesView.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { RULES } from "@mancala/core"; +import RuleBoard from "./RuleBoard"; + +// Every rule in order with its text and example (docs/specs/tutorial, R1). `showLessons` adds a link to each lesson; +// left out where following it would leave a game. +const RulesView: FunctionComponent<{ showLessons?: boolean }> = ({ showLessons }) => { + const { t } = useTranslation(); + return ( +
      + {RULES.map((rule) => ( +
    1. +

      {t(rule.titleKey)}

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

      {t(key)}

      )} + {rule.example && } + {showLessons && rule.lesson && ( + {t("TryIt")} + )} +
    2. + ))} + +
    + ); +}; + +export default RulesView; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index bd3f812..cfd8a47 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -28,6 +28,7 @@ import GameClockView from '../components/GameClockView'; import useGameClock from '../hooks/useGameClock'; import PlayerCard from '../components/game/PlayerCard'; import GameOverCard from '../components/game/GameOverCard'; +import RulesPanel from '../components/rules/RulesPanel'; import '../styles/game.css'; const GamePage: FunctionComponent<{ @@ -61,6 +62,8 @@ const GamePage: FunctionComponent<{ // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); + // The rules over the game; the game goes on behind them. + const [rulesOpen, setRulesOpen] = useState(false); // Set when the player closes the game over card to look at the board. const [gameOverClosed, setGameOverClosed] = useState(false); @@ -419,11 +422,12 @@ const GamePage: FunctionComponent<{ {showBoardView && renderGame()}
    + {rulesOpen && setRulesOpen(false)} />}
    ); function renderHeaderBar() { - return + return setRulesOpen(true)}> {renderNewGameBtn ? + )} + {fulfilled && !next && ( + + )} + + + + )} + + {game && boardViewModel && ( +
    + onPitSelect(index)} + canPlay={phase === "playing"} + lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} /> +
    + )} + + + + ); +}; + +export default LessonPage; diff --git a/apps/frontend/src/routes/RulesPage.tsx b/apps/frontend/src/routes/RulesPage.tsx index 0256cbc..752dbde 100644 --- a/apps/frontend/src/routes/RulesPage.tsx +++ b/apps/frontend/src/routes/RulesPage.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Theme } from "@mancala/core"; import AppHeader from "../components/headerbar/AppHeader"; @@ -14,8 +15,11 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
    -

    {t("HowToPlay")}

    - +
    +

    {t("HowToPlay")}

    + {t("Lessons")} +
    +
    + + ); +}; + +export default PracticeGameCard; diff --git a/apps/frontend/src/components/home/TutorialBanner.tsx b/apps/frontend/src/components/home/TutorialBanner.tsx new file mode 100644 index 0000000..7d3d1da --- /dev/null +++ b/apps/frontend/src/components/home/TutorialBanner.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { FunctionComponent, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { hideTutorialBanner, shouldShowTutorialBanner } from "@mancala/core"; +import { Context } from "../../context/context"; + +// For newcomers: an offer to learn the game, until they open the lessons, play a game or say not now (R4). +const TutorialBanner: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [visible, setVisible] = useState(() => shouldShowTutorialBanner(context.storage)); + if (!visible) return null; + return ( +
    + {t("NewToMangala")} +
    + + +
    + +
    + ); +}; + +export default TutorialBanner; diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index d6e0690..cb7f733 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -7,6 +7,7 @@ import PageContainer from "../components/PageContainer"; import { PlusIcon } from "../components/Icons"; import BotLevelMenu from "../components/BotLevelMenu"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; +import TutorialBanner from "../components/home/TutorialBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; import PopularGamesSection from "../components/home/PopularGamesSection"; @@ -58,6 +59,7 @@ const Home: FunctionComponent<{
    + {activeGame && authUser && ( = ({ context }) => { @@ -31,6 +32,7 @@ const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont ); })} + {t("HowToPlay")}