From 5423ee62371a09e6d2bc942629e5009e17b620e5 Mon Sep 17 00:00:00 2001 From: Perlkonig <4873640+Perlkonig@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:40:44 +0000 Subject: [PATCH 01/15] deps: sync AP pins [skip ci] --- ci-deps.prod.json | 6 ++-- package-lock.json | 80 ++++++++++++++++------------------------------- package.json | 6 ++-- 3 files changed, 33 insertions(+), 59 deletions(-) diff --git a/ci-deps.prod.json b/ci-deps.prod.json index 93a214792..6f0bde6b8 100644 --- a/ci-deps.prod.json +++ b/ci-deps.prod.json @@ -1,6 +1,6 @@ { - "renderer": "1.0.0-ci-33584881740.0", + "renderer": "1.0.0-ci-33912121649.0", "recranks": "1.0.0-ci-33687599936.0", - "updatedAt": "2026-09-03T20:28:15.425Z", - "source": "ci-deps.prod.json" + "updatedAt": "2026-09-04T19:39:36.903Z", + "source": "repository_dispatch" } diff --git a/package-lock.json b/package-lock.json index 24b3c7ee8..d4246b87f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33687678487.0", + "version": "1.0.0-ci-33912245274.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33687678487.0", + "version": "1.0.0-ci-33912245274.0", "license": "MIT", "dependencies": { - "@abstractplay/recranks": "1.0.0-ci-33686810882.0", - "@abstractplay/renderer": "1.0.0-ci-33584630705.0", + "@abstractplay/recranks": "1.0.0-ci-33687599936.0", + "@abstractplay/renderer": "1.0.0-ci-33912121649.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -78,9 +78,9 @@ } }, "node_modules/@abstractplay/recranks": { - "version": "1.0.0-ci-33686810882.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/recranks/1.0.0-ci-33686810882.0/5f40862b7d958433fe9adea466e2dbd18b1d0284", - "integrity": "sha512-gAOCF7UuxzoA2jfBEJjeJEiLwxomINvzigYXmZ+DHG4UkVWCNKq7AqxCcHB06lky81mvIlh0WEVERDuZiyBsWA==", + "version": "1.0.0-ci-33687599936.0", + "resolved": "https://npm.pkg.github.com/download/@abstractplay/recranks/1.0.0-ci-33687599936.0/7e6b3dde13ef3847370af8aabf259420972de32c", + "integrity": "sha512-kh0Jn3v1WdeaL0wyHR8vJpXPCMn7MYTDTDw/Eg8LogfsLBkYqJuakIdO+J/u8P598J5ASDYpZdv7DzF132WQgw==", "license": "MIT", "dependencies": { "ajv": "^8.12.0", @@ -94,9 +94,9 @@ } }, "node_modules/@abstractplay/renderer": { - "version": "1.0.0-ci-33584630705.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33584630705.0/c406f84ce69459a3aeb2640760880bad6537bac5", - "integrity": "sha512-mEBkrvvJRE5yLsTB55EM5vzEcBpZXegkYOF4GwZui00XZ7TSUqmqEhNbf1QnTzLzHfYFnnkSH9MoDtSZXbywnA==", + "version": "1.0.0-ci-33912121649.0", + "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33912121649.0/11ef184d0dd26c261b3b5fb140feba4dcbcc6113", + "integrity": "sha512-vqnQd8u+KfI2IrI1k4/9k2KGOK5Y4wjRdSFkmkT8LjvDQxSd5ETC9J0VpnuwH3pAeXmdFAAag9QQs0N8PKWB7w==", "license": "MIT", "dependencies": { "@svgdotjs/svg.js": "3.2.5", @@ -2913,6 +2913,7 @@ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", @@ -2972,6 +2973,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -2994,9 +2996,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3236,9 +3235,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3253,9 +3249,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3270,9 +3263,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3287,9 +3277,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3304,9 +3291,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3321,9 +3305,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3338,9 +3319,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3355,9 +3333,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3372,9 +3347,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3389,9 +3361,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3406,9 +3375,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3423,9 +3389,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3440,9 +3403,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5063,6 +5023,7 @@ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5134,6 +5095,7 @@ "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -5341,6 +5303,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6114,6 +6077,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7897,6 +7861,7 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8535,6 +8500,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9868,7 +9834,8 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -9998,6 +9965,7 @@ "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11427,6 +11395,7 @@ "integrity": "sha512-ek8NRg/OPvS9ISOJNWNAz5vZcpYacWNFDWNJjj5OXsc6YuKacfey6wF04cXz/tOJIVrZ2nGSkHpAY5qKtF6ISg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "d": "^1.0.2", "duration": "^0.2.2", @@ -14745,6 +14714,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15001,6 +14971,7 @@ "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15206,6 +15177,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15597,6 +15569,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15823,6 +15796,7 @@ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, diff --git a/package.json b/package.json index 137e83262..6a2f26bc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33687678487.0", + "version": "1.0.0-ci-33912245274.0", "description": "TypeScript implementations of the core Abstract Play games, intended to be wrapped by the front- and backends", "type": "module", "main": "build/index.js", @@ -130,8 +130,8 @@ "vite": "^6.3.5" }, "dependencies": { - "@abstractplay/recranks": "1.0.0-ci-33686810882.0", - "@abstractplay/renderer": "1.0.0-ci-33584630705.0", + "@abstractplay/recranks": "1.0.0-ci-33687599936.0", + "@abstractplay/renderer": "1.0.0-ci-33912121649.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", From 930c6b03e70e1393b3812611c2e9adfff20c7115 Mon Sep 17 00:00:00 2001 From: Aaron Dalton Date: Sat, 5 Sep 2026 09:29:07 -0600 Subject: [PATCH 02/15] Lockfile --- ci-deps.prod.json | 6 +++--- package-lock.json | 28 ++++++---------------------- package.json | 4 ++-- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/ci-deps.prod.json b/ci-deps.prod.json index 6f0bde6b8..38e5c313b 100644 --- a/ci-deps.prod.json +++ b/ci-deps.prod.json @@ -1,6 +1,6 @@ { "renderer": "1.0.0-ci-33912121649.0", - "recranks": "1.0.0-ci-33687599936.0", - "updatedAt": "2026-09-04T19:39:36.903Z", - "source": "repository_dispatch" + "updatedAt": "2026-09-05T15:28:56.033Z", + "source": "ci-deps.prod.json", + "recranks": "1.0.0-ci-33687599936.0" } diff --git a/package-lock.json b/package-lock.json index eb612c01c..006ed7b6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0-ci-33972497185.0", "license": "MIT", "dependencies": { - "@abstractplay/recranks": "1.0.0-ci-33686810882.0", - "@abstractplay/renderer": "1.0.0-ci-33972155230.0", + "@abstractplay/recranks": "1.0.0-ci-33687599936.0", + "@abstractplay/renderer": "1.0.0-ci-33912121649.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -110,9 +110,9 @@ } }, "node_modules/@abstractplay/renderer": { - "version": "1.0.0-ci-33972155230.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33972155230.0/c1e90e2b0ab8269db868d26c458af5c78811526c", - "integrity": "sha512-qqF10Rt0kYhSxnlh41r+x2JaR/QsJDlQ/mY5J0N5UZMjuS8t2ZgvQpZ0Drwz3swkud1ZLPXp3FF2ot/MKo7LOQ==", + "version": "1.0.0-ci-33912121649.0", + "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33912121649.0/11ef184d0dd26c261b3b5fb140feba4dcbcc6113", + "integrity": "sha512-vqnQd8u+KfI2IrI1k4/9k2KGOK5Y4wjRdSFkmkT8LjvDQxSd5ETC9J0VpnuwH3pAeXmdFAAag9QQs0N8PKWB7w==", "license": "MIT", "dependencies": { "@svgdotjs/svg.js": "3.2.5", @@ -2929,7 +2929,6 @@ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", @@ -2989,7 +2988,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5039,7 +5037,6 @@ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5111,7 +5108,6 @@ "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -5319,7 +5315,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6093,7 +6088,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7877,7 +7871,6 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8516,7 +8509,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9850,8 +9842,7 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -9981,7 +9972,6 @@ "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -11411,7 +11401,6 @@ "integrity": "sha512-ek8NRg/OPvS9ISOJNWNAz5vZcpYacWNFDWNJjj5OXsc6YuKacfey6wF04cXz/tOJIVrZ2nGSkHpAY5qKtF6ISg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d": "^1.0.2", "duration": "^0.2.2", @@ -14730,7 +14719,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14987,7 +14975,6 @@ "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15193,7 +15180,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15585,7 +15571,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15812,7 +15797,6 @@ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, diff --git a/package.json b/package.json index 45140a5bd..b11c3d691 100644 --- a/package.json +++ b/package.json @@ -131,8 +131,8 @@ "vite": "^6.3.5" }, "dependencies": { - "@abstractplay/recranks": "1.0.0-ci-33686810882.0", - "@abstractplay/renderer": "1.0.0-ci-33972155230.0", + "@abstractplay/recranks": "1.0.0-ci-33687599936.0", + "@abstractplay/renderer": "1.0.0-ci-33912121649.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", From 702b12b75a35249086df2d35a899a342329a19ba Mon Sep 17 00:00:00 2001 From: Perlkonig <4873640+Perlkonig@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:26:50 +0000 Subject: [PATCH 03/15] deps: sync AP pins [skip ci] --- ci-deps.prod.json | 6 +++--- package-lock.json | 30 +++++++++++++++++++++++------- package.json | 4 ++-- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/ci-deps.prod.json b/ci-deps.prod.json index 38e5c313b..886760c73 100644 --- a/ci-deps.prod.json +++ b/ci-deps.prod.json @@ -1,6 +1,6 @@ { - "renderer": "1.0.0-ci-33912121649.0", - "updatedAt": "2026-09-05T15:28:56.033Z", - "source": "ci-deps.prod.json", + "renderer": "1.0.0-ci-33995787246.0", + "updatedAt": "2026-09-05T22:25:38.071Z", + "source": "repository_dispatch", "recranks": "1.0.0-ci-33687599936.0" } diff --git a/package-lock.json b/package-lock.json index 006ed7b6f..837cc3cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33972497185.0", + "version": "1.0.0-ci-33995846150.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33972497185.0", + "version": "1.0.0-ci-33995846150.0", "license": "MIT", "dependencies": { "@abstractplay/recranks": "1.0.0-ci-33687599936.0", - "@abstractplay/renderer": "1.0.0-ci-33912121649.0", + "@abstractplay/renderer": "1.0.0-ci-33995787246.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", @@ -110,9 +110,9 @@ } }, "node_modules/@abstractplay/renderer": { - "version": "1.0.0-ci-33912121649.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33912121649.0/11ef184d0dd26c261b3b5fb140feba4dcbcc6113", - "integrity": "sha512-vqnQd8u+KfI2IrI1k4/9k2KGOK5Y4wjRdSFkmkT8LjvDQxSd5ETC9J0VpnuwH3pAeXmdFAAag9QQs0N8PKWB7w==", + "version": "1.0.0-ci-33995787246.0", + "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33995787246.0/2e2a606d3f26ca711b38b858d4bd40cc4e244f02", + "integrity": "sha512-ThatYaHNprHRppgkXPYWzuoSpTfcOkrDyR23DLP0eDuSCSbwZwZchvjg8KsXZsWPNSJ9NF+o20F97kDKNRdFOw==", "license": "MIT", "dependencies": { "@svgdotjs/svg.js": "3.2.5", @@ -2929,6 +2929,7 @@ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", @@ -2988,6 +2989,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5037,6 +5039,7 @@ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5108,6 +5111,7 @@ "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -5315,6 +5319,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6088,6 +6093,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7871,6 +7877,7 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8509,6 +8516,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9842,7 +9850,8 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -9972,6 +9981,7 @@ "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11401,6 +11411,7 @@ "integrity": "sha512-ek8NRg/OPvS9ISOJNWNAz5vZcpYacWNFDWNJjj5OXsc6YuKacfey6wF04cXz/tOJIVrZ2nGSkHpAY5qKtF6ISg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "d": "^1.0.2", "duration": "^0.2.2", @@ -14719,6 +14730,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14975,6 +14987,7 @@ "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15180,6 +15193,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15571,6 +15585,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15797,6 +15812,7 @@ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, diff --git a/package.json b/package.json index b11c3d691..84b45fb1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33972497185.0", + "version": "1.0.0-ci-33995846150.0", "description": "TypeScript implementations of the core Abstract Play games, intended to be wrapped by the front- and backends", "type": "module", "main": "build/index.js", @@ -132,7 +132,7 @@ }, "dependencies": { "@abstractplay/recranks": "1.0.0-ci-33687599936.0", - "@abstractplay/renderer": "1.0.0-ci-33912121649.0", + "@abstractplay/renderer": "1.0.0-ci-33995787246.0", "@turf/boolean-contains": "^6.5.0", "@turf/boolean-intersects": "^6.5.0", "@turf/boolean-point-in-polygon": "^6.5.0", From 02f15cbdb53e6e090d5a99b6c53cd5f23ea9e256 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:19:04 -0400 Subject: [PATCH 04/15] Add missing Esperanto game translations --- locales/eo/apgames.json | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 3e29ea39c..447f1bcf5 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -44,6 +44,10 @@ "scrambled": { "description": "La reĝinoj ŝanĝas koloron ĉirkaŭ la tabulo.", "name": "Malkonstruita" + }, + "noFirstShot": { + "description": "Anstataŭ la kutima interŝanĝa regulo, la unua ludanto ne rajtas pafi sagon dum sia unua vico.", + "name": "Sen unua pafo" } }, "boom": { @@ -4860,7 +4864,8 @@ "INITIAL_INSTRUCTIONS": "Elektu unu el viaj amazonoj por moviĝi.", "POTENTIAL_BLOCK": "Ĝis nun bone. Provizi la spacon, kiun vi volas bloki.", "POTENTIAL_MOVE": "Ĝis nun bone. Provu la spacon, en kiu vi volas translokiĝi.", - "STRAIGHTLINE": "Vi povas moviĝi kaj bloki nur en rektaj linioj." + "STRAIGHTLINE": "Vi povas moviĝi kaj bloki nur en rektaj linioj.", + "NO_FIRST_SHOT": "La unua ludanto ne rajtas pafi sagon dum sia unua vico." }, "amoeba": { "BAD_DISTANCE": "La stakoj devas moviĝi precize laŭ sia alteco.", @@ -7758,7 +7763,13 @@ }, "bridges": { "INITIAL_INSTRUCTIONS_FIRST_TURN": "Metu novan bazon sur ajnan malplenan spacon krom la centra.", - "INITIAL_INSTRUCTIONS": "Faru du movojn. En ĉiu movo, aŭ metu novan bazon sur malplenan spacon, kiu nek apudas ekzistantan bazon nek estas la centra spaco, aŭ kunligu du el viaj bazoj per ponto." + "INITIAL_INSTRUCTIONS": "Faru du movojn. En ĉiu movo, aŭ metu novan bazon sur malplenan spacon, kiu nek apudas ekzistantan bazon nek estas la centra spaco, aŭ kunligu du el viaj bazoj per ponto.", + "BASE_HAS_BRIDGE_one": "Bazoj ne povas subteni pli ol unu ponton. Vi ankoraŭ devas fari 1 plian movon.", + "BASE_HAS_BRIDGE_other": "Bazoj ne povas subteni pli ol unu ponton. Vi ankoraŭ devas fari {{count}} pliajn movojn.", + "NO_LINE_OF_SIGHT_one": "Ĉi tiu bazo ne havas vidlinion al alia amika bazo. Vi ankoraŭ devas fari 1 plian movon.", + "NO_LINE_OF_SIGHT_other": "Ĉi tiu bazo ne havas vidlinion al alia amika bazo. Vi ankoraŭ devas fari {{count}} pliajn movojn.", + "ONE_MORE": "Vi ankoraŭ devas fari unu plian movon. Elektu malplenan spacon, kiu nek apudas ekzistantan bazon nek estas la centra spaco, por meti novan bazon, aŭ elektu unu el viaj ekzistantaj bazoj por komenci konstrui ponton. Aŭ vi povas preterpasi vian tutan vicon.", + "SELECT_BRIDGE_END": "Elektu alian amikan bazon laŭ vidlinio por konstrui la ponton." }, "elOso": { "INITIAL_INSTRUCTIONS": "Elektu unu el viaj stakoj por movi. Alklaku stakon dufoje por preterpasi.", From d3b374a6793880d9943048ff801ccd804635d04c Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:19:11 -0400 Subject: [PATCH 05/15] Add missing Esperanto move translations --- locales/eo/apresults.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/locales/eo/apresults.json b/locales/eo/apresults.json index d74fc8c0c..fe4f93abd 100644 --- a/locales/eo/apresults.json +++ b/locales/eo/apresults.json @@ -186,7 +186,8 @@ "volo_other": "{{player}} movis aron de {{count}} birdoj de {{from}} en la direkto {{how}}.", "complete_what": "{{player}} movis {{what}} de {{from}} al {{to}}.", "elOso_one": "{{player}} movis unu pecon de {{from}} al {{to}}.", - "elOso_other": "{{player}} movis {{count}} pecojn de {{from}} al {{to}}." + "elOso_other": "{{player}} movis {{count}} pecojn de {{from}} al {{to}}.", + "amazonsNoFirstShot": "{{player}} moviĝis de {{from}} al {{to}}." }, "ABANDONED": "La ludo estis forlasita.", "ADD": { @@ -267,7 +268,8 @@ "veletas": "La pafanto ĉe {{where}} estas kaptita kaj estis alproprigita de {{who}}." }, "CONNECT": { - "pontedd": "{{player}} metis ponton inter {{left}} kaj {{right}}." + "pontedd": "{{player}} metis ponton inter {{left}} kaj {{right}}.", + "bridges": "{{player}} konstruis ponton inter {{left}} kaj {{right}}." }, "CONVERT": { "complete": "{{player}} transformis {{what}} en {{into}} ĉe {{where}}.", From 9f373b817661ea3da3218e75e694b79033def5c1 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:14:38 -0400 Subject: [PATCH 06/15] Correct Esperanto variant labels for Arimaa and Irensei --- locales/eo/apgames.json | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 4aca87d2f..bdf71a970 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -224,9 +224,12 @@ "#board": { "name": "Tabulo 19×19" }, + "#opening": { + "name": "Malfermo" + }, "#overline": { "description": "La unua ludanto perdas se ili faras superlinion, sed la dua ludanto povas gajni per superlinio.", - "name": "Asimetria ĉeftempo" + "name": "Nesimetria superlinia perdo" }, "#tiebreaker": { "name": "Sen egalecrompo" @@ -237,23 +240,23 @@ }, "sym-no-overline": { "description": "La regulo de superlinio validas por ambaŭ ludantoj: ĉiu ludanto perdas se ili faras superlinion, do nur linio de precize sep venkoj.", - "name": "Simetriaj superliniperdoj" + "name": "Simetria superlinia perdo" }, "sym-overline": { "description": "La regulo de superlinio validas por neniu el la ludantoj: ĉiu ludanto povas gajni per superlinio, do ĉiu linio de sep aŭ pli gajnas.", - "name": "Simetriaj gajnadoj" + "name": "Simetria superlinia gajno" }, "swap-1st": { "description": "La unua ludanto metas ŝtonon ie ajn sur la tabulo. La venonta ludanto havas la eblon interŝanĝi kolorojn.", - "name": "Malfermo Swap-1st" + "name": "Interŝanĝo-1-a" }, "swap-2": { "description": "La unua ludanto metas tri ŝtonojn. La venonta ludanto povas fari unu el (1) meti unu ŝtonon por daŭrigi kiel ludanto 2, (2) ŝanĝi kolorojn por ludi kiel ludanto 1, aŭ (3) meti du pli da ŝtonoj kaj permesi al la kontraŭulo decidi ludi kiel ludanto 1 aŭ ludanto 2.", - "name": "Malfermo Swap-2" + "name": "Interŝanĝo-2" }, "swap-5": { "description": "Por la unuaj kvin ŝtonoj sur la tabulo, post metado, la venonta ludanto havas la opcion interŝanĝi kolorojn.", - "name": "Malfermo Swap-5" + "name": "Interŝanĝo-5" } }, "veletas": { @@ -619,7 +622,7 @@ "description": "Metu viajn 16 pecojn sur viajn plej proksimajn du rangojn laŭ la maniero, kiun vi deziras" }, "eee": { - "name": "Senfina fina ludo", + "name": "Senfina finludo", "description": "Hazarde generita preparo kun reduktita armeo grandeco" }, "free": { From 6d644eda383d59002640dc713f89c541d2df5c46 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:45:23 -0400 Subject: [PATCH 07/15] Add unbounded cloud banks variant to Crosshairs --- locales/en/apgames.json | 5 +++++ locales/eo/apgames.json | 5 +++++ src/games/crosshairs.ts | 9 ++++++++- test/games/crosshairs.test.ts | 21 +++++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 7df881147..a6bc5185b 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1678,6 +1678,10 @@ "random-start": { "name": "Random clouds", "description": "There is no cloud placing phase. They are placed randomly by the system." + }, + "unbounded-cloud-banks": { + "name": "Unbounded cloud banks", + "description": "Cloud banks may contain any number of adjacent cloud hexes." } }, "crossway": { @@ -5736,6 +5740,7 @@ }, "crosshairs": { "PLACE_CLOUD": "Click on an empty cell to place a cloud. Clouds cannot be larger than 2 hexes.", + "PLACE_CLOUD_UNBOUNDED": "Click on an empty cell to place a cloud.", "CLOUD_OCCUPIED": "There is already a cloud at {{cell}}.", "CLOUD_TOO_BIG": "You cannot place a cloud at {{cell}}. Only clouds of size 2 are allowed. This cloud would be too big.", "INITIAL_INSTRUCTIONS_one": "Select a plane to move, or enter a new plane on your starting edge. You must move/enter {{count}} plane this turn.", diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 920557ed3..242c2f063 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1262,6 +1262,10 @@ "random-start": { "name": "Hazardaj nuboj", "description": "Ne ekzistas nubo-moviĝanta fazo. Ili estas hazarde metitaj de la sistemo." + }, + "unbounded-cloud-banks": { + "name": "Senlimaj nubaroj", + "description": "Nubaroj povas enhavi ajnan nombron da apudaj nubaj seslateroj." } }, "cubeo": { @@ -5547,6 +5551,7 @@ }, "crosshairs": { "PLACE_CLOUD": "Klaku sur malplena ĉelo por meti nubon. Nuboj ne povas esti pli grandaj ol du hexoj.", + "PLACE_CLOUD_UNBOUNDED": "Klaku sur malplena ĉelo por meti nubon.", "CLOUD_OCCUPIED": "Jam estas nubo ĉe {{cell}}.", "CLOUD_TOO_BIG": "Vi ne povas meti nubojn ĉe {{cell}}. Nur nuboj de grandeco 2 estas permesitaj. Tiu nubo estus tro granda.", "INITIAL_INSTRUCTIONS_one": "Elektu aviadilon por movi, aŭ eniri novan aviadilon sur via komenca rando. Vi devas movi / eniri {{count}} aviadilo tiun vicon.", diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index ed49fd86b..3a080a639 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -151,6 +151,9 @@ export class CrosshairsGame extends GameBase { uid: "random-start", group: "setup", }, + { + uid: "unbounded-cloud-banks", + }, ], displays: [{uid: "abstract"}, {uid: "numeric"}, {uid: "numeric-abstract"}], }; @@ -284,6 +287,8 @@ export class CrosshairsGame extends GameBase { // Check if placing a cloud at cell would create a cloud group > 2 hexes private wouldCreateLargeCloud(cell: string, existingClouds: Set): boolean { + if (this.variants.includes("unbounded-cloud-banks")) return false; + const [x, y] = this.graph.algebraic2coords(cell); let adjacentCloudCount = 0; @@ -1757,7 +1762,9 @@ export class CrosshairsGame extends GameBase { if (this.inCloudPhase()) { result.valid = true; result.complete = -1; - result.message = i18next.t("apgames:validation.crosshairs.PLACE_CLOUD"); + result.message = this.variants.includes("unbounded-cloud-banks") + ? i18next.t("apgames:validation.crosshairs.PLACE_CLOUD_UNBOUNDED") + : i18next.t("apgames:validation.crosshairs.PLACE_CLOUD"); return result; } const requiredPlanes = this.getPlanesToMove(); diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index a5d87dc4d..c3bbadd20 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -64,6 +64,19 @@ describe("Crosshairs", () => { // But placing an isolated cloud elsewhere should still work expect(moves.some(m => m === 'cloud:a1')).to.be.true; }); + + it("should allow clouds bigger than 2 hexes with the unbounded cloud banks variant", () => { + const g = new CrosshairsGame(undefined, ["unbounded-cloud-banks"]); + + g.move("cloud:f5"); + g.move("cloud:f6"); + + expect(g.moves()).to.include("cloud:f4"); + expect(g.validateMove("cloud:f4")).to.include({ valid: true, complete: 1 }); + + g.move("cloud:f4"); + expect(g.clouds).to.include("f4"); + }); }); describe("Random Start Variant", () => { @@ -76,6 +89,14 @@ describe("Crosshairs", () => { expect(moves[0]).to.match(/^enter:/); }); + it("should combine with the unbounded cloud banks variant", () => { + const g = new CrosshairsGame(undefined, ["random-start", "unbounded-cloud-banks"]); + + expect(g.clouds.size).to.equal(16); + expect(g.turnNumber).to.equal(1); + expect(g.variants).to.include.members(["random-start", "unbounded-cloud-banks"]); + }); + it("clouds should be placed symmetrically", () => { const g = new CrosshairsGame(undefined, ["random-start"]); const clouds = Array.from(g.clouds); From 9f087db5f1f3b13c7765d03b9821059ea5755a9f Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:04:53 -0400 Subject: [PATCH 08/15] Clarify unbounded cloud banks variant --- locales/en/apgames.json | 2 +- locales/eo/apgames.json | 2 +- src/games/crosshairs.ts | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index a6bc5185b..99b912735 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1681,7 +1681,7 @@ }, "unbounded-cloud-banks": { "name": "Unbounded cloud banks", - "description": "Cloud banks may contain any number of adjacent cloud hexes." + "description": "Cloud banks may contain any number of clouds." } }, "crossway": { diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 242c2f063..b3f8b316b 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1265,7 +1265,7 @@ }, "unbounded-cloud-banks": { "name": "Senlimaj nubaroj", - "description": "Nubaroj povas enhavi ajnan nombron da apudaj nubaj seslateroj." + "description": "Nubaroj povas enhavi ajnan nombron da nuboj." } }, "cubeo": { diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index 3a080a639..a1b9dd3df 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -285,9 +285,10 @@ export class CrosshairsGame extends GameBase { return Array.from(cloudSet); } - // Check if placing a cloud at cell would create a cloud group > 2 hexes + // Check whether placing a cloud would violate the default maximum bank size of 2 hexes. + // Under the unbounded-cloud-banks variant, banks may be any size. private wouldCreateLargeCloud(cell: string, existingClouds: Set): boolean { - if (this.variants.includes("unbounded-cloud-banks")) return false; + if (this.variants.includes("unbounded-cloud-banks")) return false; // Short-circuit the default size check for this variant. const [x, y] = this.graph.algebraic2coords(cell); let adjacentCloudCount = 0; From 91a979c3a5c06a6640a25733a2f693d855ea07a6 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:14:08 -0400 Subject: [PATCH 09/15] Update crosshairs.ts Manual editing of comments for the Unbounded Cloud Banks variant --- src/games/crosshairs.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index a1b9dd3df..65c0ca377 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -285,10 +285,11 @@ export class CrosshairsGame extends GameBase { return Array.from(cloudSet); } - // Check whether placing a cloud would violate the default maximum bank size of 2 hexes. - // Under the unbounded-cloud-banks variant, banks may be any size. + // Check whether placing a cloud would violate the default maximum bank size of 2 hexes + // Under the unbounded-cloud-banks variant, banks may be any size private wouldCreateLargeCloud(cell: string, existingClouds: Set): boolean { - if (this.variants.includes("unbounded-cloud-banks")) return false; // Short-circuit the default size check for this variant. + // For the Unbounded Cloud Banks variant, short-circuit this function to skip the size <= 2 check + if (this.variants.includes("unbounded-cloud-banks")) return false; const [x, y] = this.graph.algebraic2coords(cell); let adjacentCloudCount = 0; From 1b77610583b3a0b5c3ba8322e231b0ed4a09899b Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:52:36 -0400 Subject: [PATCH 10/15] Add selectable cloud counts to Crosshairs --- locales/en/apgames.json | 10 +++ locales/eo/apgames.json | 10 +++ src/games/crosshairs.ts | 154 ++++++++++++++++++++++++---------- test/games/crosshairs.test.ts | 97 +++++++++++++++++++++ 4 files changed, 226 insertions(+), 45 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 99b912735..8f9f25ae7 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1675,6 +1675,15 @@ } }, "crosshairs": { + "#clouds": { + "name": "16 clouds" + }, + "clouds-22": { + "name": "22 clouds" + }, + "clouds-28": { + "name": "28 clouds" + }, "random-start": { "name": "Random clouds", "description": "There is no cloud placing phase. They are placed randomly by the system." @@ -5743,6 +5752,7 @@ "PLACE_CLOUD_UNBOUNDED": "Click on an empty cell to place a cloud.", "CLOUD_OCCUPIED": "There is already a cloud at {{cell}}.", "CLOUD_TOO_BIG": "You cannot place a cloud at {{cell}}. Only clouds of size 2 are allowed. This cloud would be too big.", + "MUST_PASS_BEFORE_ENTRY": "You must pass so player 1 enters the first plane.", "INITIAL_INSTRUCTIONS_one": "Select a plane to move, or enter a new plane on your starting edge. You must move/enter {{count}} plane this turn.", "INITIAL_INSTRUCTIONS_other": "Select a plane to move, or enter a new plane on your starting edge. You must move/enter {{count}} planes this turn.", "INITIAL_INSTRUCTIONS_NO_ENTRY_one": "Select a plane to move. You must move {{count}} plane this turn. Click on enemy planes if you think they can be shot down.", diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index b3f8b316b..5045012a1 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1259,6 +1259,15 @@ } }, "crosshairs": { + "#clouds": { + "name": "16 nuboj" + }, + "clouds-22": { + "name": "22 nuboj" + }, + "clouds-28": { + "name": "28 nuboj" + }, "random-start": { "name": "Hazardaj nuboj", "description": "Ne ekzistas nubo-moviĝanta fazo. Ili estas hazarde metitaj de la sistemo." @@ -5554,6 +5563,7 @@ "PLACE_CLOUD_UNBOUNDED": "Klaku sur malplena ĉelo por meti nubon.", "CLOUD_OCCUPIED": "Jam estas nubo ĉe {{cell}}.", "CLOUD_TOO_BIG": "Vi ne povas meti nubojn ĉe {{cell}}. Nur nuboj de grandeco 2 estas permesitaj. Tiu nubo estus tro granda.", + "MUST_PASS_BEFORE_ENTRY": "Vi devas preterpasi, por ke ludanto 1 enigu la unuan aviadilon.", "INITIAL_INSTRUCTIONS_one": "Elektu aviadilon por movi, aŭ eniri novan aviadilon sur via komenca rando. Vi devas movi / eniri {{count}} aviadilo tiun vicon.", "INITIAL_INSTRUCTIONS_other": "Elektu aviadilon por movi, aŭ enigu novan aviadilon ĉe via komenca rando. Vi devas movi aŭ enigi {{count}} aviadilojn dum ĉi tiu vico.", "INITIAL_INSTRUCTIONS_NO_ENTRY_one": "Elektu aviadilon por moviĝi. Vi devas movi {{count}} aviadilo tiun vicon. Klaku sur malamikaj aviadiloj se vi opinias ke ili povas esti sinkitaj.", diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index 65c0ca377..91a04d1f0 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -154,6 +154,9 @@ export class CrosshairsGame extends GameBase { { uid: "unbounded-cloud-banks", }, + { uid: "#clouds" }, + { uid: "clouds-22", group: "clouds" }, + { uid: "clouds-28", group: "clouds" }, ], displays: [{uid: "abstract"}, {uid: "numeric"}, {uid: "numeric-abstract"}], }; @@ -184,7 +187,7 @@ export class CrosshairsGame extends GameBase { const clouds = new Set(); let turnNumber = 0; - // If random-start variant, place 8 pairs of symmetric clouds + // If random-start variant, place the selected number of clouds in symmetric pairs if (this.variants.includes("random-start")) { const placedClouds = this.placeRandomSymmetricClouds(); for (const cell of placedClouds) { @@ -254,35 +257,64 @@ export class CrosshairsGame extends GameBase { return this.graph.rot180(cell); } - // Place 8 pairs of symmetric clouds for the random-start variant - private placeRandomSymmetricClouds(): string[] { - const cloudSet = new Set(); - const untried = this.graph.listCells() as string[]; - - // Keep picking random cells until we have 8 pairs - while (cloudSet.size < 16) { - // Pick a random cell we haven't tried yet - if (untried.length === 0) throw new Error("Failed to place symmetric clouds - no valid cells left."); - - const cell = untried[Math.floor(Math.random() * untried.length)]; - // Remove from untried - const index = untried.indexOf(cell); - untried.splice(index, 1); + private getTargetCloudCount(): number { + if (this.variants.includes("clouds-28")) return 28; + if (this.variants.includes("clouds-22")) return 22; + return 16; + } + // Place symmetric clouds up to the selected target for the random-start variant + private placeRandomSymmetricClouds(): string[] { + const target = this.getTargetCloudCount(); + const seen = new Set(); + const pairs: Array<[string, string]> = []; + for (const cell of this.graph.listCells() as string[]) { + if (seen.has(cell)) continue; const mirror = this.getSymmetricCell(cell); - untried.splice(untried.indexOf(mirror), 1); // Remove mirror from untried too + seen.add(cell); + seen.add(mirror); + if (cell !== mirror) { + pairs.push([cell, mirror]); + } + } - if (cell === mirror) continue; // Skip center cell + // Randomize pair order, then backtrack if a legal choice later blocks the target. + for (let i = pairs.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [pairs[i], pairs[j]] = [pairs[j], pairs[i]]; + } - // Check if this pair would create clouds > 2 hexes - if (this.wouldCreateLargeCloud(cell, cloudSet)) continue; + const cloudSet = new Set(); + let best = new Set(); + let visited = 0; + const maxVisited = 100_000; + const placePairs = (start: number): boolean => { + if (cloudSet.size > best.size) { + best = new Set(cloudSet); + } + if (cloudSet.size >= target) return true; + if (visited++ >= maxVisited) return false; + if (cloudSet.size + 2 * (pairs.length - start) < target) return false; + + for (let i = start; i < pairs.length; i++) { + const [cell, mirror] = pairs[i]; + if (this.wouldCreateLargeCloud(cell, cloudSet)) continue; + cloudSet.add(cell); + if (this.wouldCreateLargeCloud(mirror, cloudSet)) { + cloudSet.delete(cell); + continue; + } + cloudSet.add(mirror); - // Valid pair - add both - cloudSet.add(cell); - cloudSet.add(mirror); - } + if (placePairs(i + 1)) return true; + + cloudSet.delete(cell); + cloudSet.delete(mirror); + } + return false; + }; - return Array.from(cloudSet); + return Array.from(placePairs(0) ? cloudSet : best); } // Check whether placing a cloud would violate the default maximum bank size of 2 hexes @@ -321,6 +353,12 @@ export class CrosshairsGame extends GameBase { return adjacentCloudCount > 1; } + private getLegalCloudPlacements(): string[] { + return (this.graph.listCells() as string[]).filter(cell => + !this.clouds.has(cell) && !this.wouldCreateLargeCloud(cell, this.clouds) + ); + } + // Split a move string by commas, but don't split inside parentheses. // Uses regex negative lookahead: ,(?![^(]*\)) means "comma not followed by // a closing paren without an opening paren in between" - i.e., commas inside @@ -381,7 +419,13 @@ export class CrosshairsGame extends GameBase { // Check if we're in cloud placement phase private inCloudPhase(): boolean { - return this.clouds.size < 16; + return this.turnNumber === 0 && this.clouds.size < this.getTargetCloudCount(); + } + + // If manual cloud placement ends after an odd number of clouds, player 2 + // must pass so player 1 still enters the first plane. + private mustPassBeforeEntry(): boolean { + return this.turnNumber === 1 && this.currplayer === 2; } // Check if we're in entry phase (first few turns after clouds) @@ -1309,6 +1353,8 @@ export class CrosshairsGame extends GameBase { public moves(): string[] { if (this.gameover) return []; + if (this.mustPassBeforeEntry()) return ["pass"]; + // Cloud phase: each cloud placement is a complete move if (this.inCloudPhase()) { return this.actions(); @@ -1428,13 +1474,12 @@ export class CrosshairsGame extends GameBase { const actions: string[] = []; - // Cloud placement phase - players alternate placing clouds until all 16 are placed + if (this.mustPassBeforeEntry()) return ["pass"]; + + // Cloud placement phase - players alternate placing clouds until the target is reached if (this.inCloudPhase()) { - for (const cell of this.graph.listCells() as string[]) { - if (this.clouds.has(cell)) continue; - if (!this.wouldCreateLargeCloud(cell, this.clouds)) { - actions.push(`cloud:${cell}`); - } + for (const cell of this.getLegalCloudPlacements()) { + actions.push(`cloud:${cell}`); } return actions; } @@ -1761,6 +1806,12 @@ export class CrosshairsGame extends GameBase { const result: IValidationResult = { valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER") }; if (m.length === 0) { + if (this.mustPassBeforeEntry()) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.crosshairs.MUST_PASS_BEFORE_ENTRY"); + return result; + } if (this.inCloudPhase()) { result.valid = true; result.complete = -1; @@ -1797,6 +1848,17 @@ export class CrosshairsGame extends GameBase { m = m.toLowerCase(); + if (this.mustPassBeforeEntry()) { + if (m === "pass") { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + } else { + result.message = i18next.t("apgames:validation.crosshairs.MUST_PASS_BEFORE_ENTRY"); + } + return result; + } + // Cloud phase: single action per turn if (this.inCloudPhase()) { // Must be cloud placement @@ -2590,6 +2652,9 @@ export class CrosshairsGame extends GameBase { m = m.toLowerCase(); + const wasCloudPhase = this.inCloudPhase(); + const forcedEntryPass = this.mustPassBeforeEntry() && m === "pass"; + if (!trusted) { const result = this.validateMove(m); if (!result.valid) { @@ -2606,8 +2671,11 @@ export class CrosshairsGame extends GameBase { this.results = []; - // Cloud phase: single action - if (this.inCloudPhase()) { + // Player 2 may need one pass to preserve player 1's first entry turn. + if (forcedEntryPass) { + this.results.push({ type: "pass" }); + } else if (wasCloudPhase) { + // Cloud phase: single action const parsed = this.parseMove(m); if (parsed.type === "cloud") { this.clouds.add(parsed.cell!); @@ -2633,8 +2701,10 @@ export class CrosshairsGame extends GameBase { // Update turn number and switch players this.lastmove = m; - // After cloud phase ends (16 clouds placed), set turn number to 1 - const justEndedCloudPhase = this.clouds.size === 16 && this.turnNumber === 0; + // End setup at the selected target, or earlier if the bank-size rule + // leaves no legal cloud placement. + const justEndedCloudPhase = wasCloudPhase && + (this.clouds.size >= this.getTargetCloudCount() || this.getLegalCloudPlacements().length === 0); if (justEndedCloudPhase) { this.turnNumber = 1; } @@ -2642,7 +2712,7 @@ export class CrosshairsGame extends GameBase { // In non-cloud phases, increment turn number after each player's turn // (Turn 1: P1 moves 1, Turn 2: P2 moves 2, Turn 3: P1 moves 3, etc.) // Don't increment on the move that just ended cloud phase - if (!this.inCloudPhase() && this.turnNumber > 0 && !justEndedCloudPhase) { + if (!this.inCloudPhase() && this.turnNumber > 0 && !justEndedCloudPhase && !forcedEntryPass) { this.turnNumber++; } @@ -2675,17 +2745,11 @@ export class CrosshairsGame extends GameBase { } public randomMove(sillymoves = false): string { + if (this.mustPassBeforeEntry()) return "pass"; + // Cloud phase: pick a random valid cloud cell if (this.inCloudPhase()) { - const validCells: string[] = []; - for (const cell of this.graph.listCells() as string[]) { - if (!this.clouds.has(cell) && !this.wouldCreateLargeCloud(cell, this.clouds)) { - validCells.push(cell); - } - } - if (validCells.length === 0) { - throw new Error("No valid cloud placements available"); - } + const validCells = this.getLegalCloudPlacements(); return `cloud:${validCells[Math.floor(Math.random() * validCells.length)]}`; } diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index c3bbadd20..a5a52c860 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -47,6 +47,20 @@ describe("Crosshairs", () => { expect(moves[0]).to.not.match(/^cloud:/); }); + for (const [variant, target] of [["clouds-22", 22], ["clouds-28", 28]] as const) { + it(`should end cloud phase after ${target} clouds with the ${variant} variant`, () => { + const g = new CrosshairsGame(undefined, [variant, "unbounded-cloud-banks"]); + for (let i = 0; i < target; i++) { + g.move(g.moves()[0]); + } + + expect(g.clouds.size).to.equal(target); + expect(g.turnNumber).to.equal(1); + expect(g.currplayer).to.equal(1); + expect(g.moves()[0]).to.match(/^enter:/); + }); + } + it("should not allow clouds bigger than 2 hexes", () => { const g = new CrosshairsGame(); @@ -77,6 +91,58 @@ describe("Crosshairs", () => { g.move("cloud:f4"); expect(g.clouds).to.include("f4"); }); + + it("should force player 2 to pass when legal placements run out after an odd number of clouds", () => { + const g = new CrosshairsGame(); + (g as unknown as { wouldCreateLargeCloud: () => boolean }).wouldCreateLargeCloud = () => true; + + g.move("cloud:a1", { trusted: true }); + + expect(g.clouds.size).to.equal(1); + expect(g.turnNumber).to.equal(1); + expect(g.currplayer).to.equal(2); + expect(g.moves()).to.deep.equal(["pass"]); + expect(g.randomMove()).to.equal("pass"); + expect(g.validateMove("pass")).to.include({ valid: true, complete: 1 }); + const playerTwoEntry = g.graph.getEdges().get("N")![0]; + expect(g.validateMove(`enter:${playerTwoEntry}/N`).valid).to.be.false; + + g.move("pass"); + + expect(g.turnNumber).to.equal(1); + expect(g.currplayer).to.equal(1); + expect(g.results).to.deep.include({ type: "pass" }); + expect(g.moves()[0]).to.match(/^enter:/); + + g.move(g.moves()[0]); + expect([...g.board.values()].filter(([owner]) => owner === 1)).to.have.length(1); + expect(g.turnNumber).to.equal(2); + expect(g.currplayer).to.equal(2); + + const firstEntry = g.actions()[0]; + const secondEntry = g.actions(2, firstEntry)[0]; + g.move(`${firstEntry},${secondEntry}`); + expect([...g.board.values()].filter(([owner]) => owner === 2)).to.have.length(2); + }); + + it("should let player 1 enter immediately when placements run out after an even number of clouds", () => { + const g = new CrosshairsGame(); + g.move("cloud:a1"); + (g as unknown as { wouldCreateLargeCloud: () => boolean }).wouldCreateLargeCloud = () => true; + + g.move("cloud:b1", { trusted: true }); + + expect(g.clouds.size).to.equal(2); + expect(g.turnNumber).to.equal(1); + expect(g.currplayer).to.equal(1); + expect(g.moves()[0]).to.match(/^enter:/); + expect(g.validateMove("pass").valid).to.be.false; + }); + + it("should not allow passing during ordinary cloud or entry turns", () => { + expect(new CrosshairsGame().validateMove("pass").valid).to.be.false; + expect(new CrosshairsGame(undefined, ["random-start"]).validateMove("pass").valid).to.be.false; + }); }); describe("Random Start Variant", () => { @@ -97,6 +163,37 @@ describe("Crosshairs", () => { expect(g.variants).to.include.members(["random-start", "unbounded-cloud-banks"]); }); + for (const [variant, target] of [["clouds-22", 22], ["clouds-28", 28]] as const) { + it(`should place ${target} symmetric clouds with the ${variant} variant`, () => { + const g = new CrosshairsGame(undefined, ["random-start", variant]); + + expect(g.clouds.size).to.equal(target); + for (const cloud of g.clouds) { + expect(g.clouds.has(g.graph.rot180(cloud))).to.be.true; + } + }); + } + + it("should continue to the entry phase if no symmetric cloud pair can be placed", () => { + const prototype = CrosshairsGame.prototype as unknown as { + wouldCreateLargeCloud: (cell: string, clouds: Set) => boolean; + }; + const original = prototype.wouldCreateLargeCloud; + prototype.wouldCreateLargeCloud = () => true; + + let g: CrosshairsGame; + try { + g = new CrosshairsGame(undefined, ["random-start", "clouds-28"]); + } finally { + prototype.wouldCreateLargeCloud = original; + } + + expect(g!.clouds.size).to.equal(0); + expect(g!.turnNumber).to.equal(1); + expect(g!.currplayer).to.equal(1); + expect(g!.moves()[0]).to.match(/^enter:/); + }); + it("clouds should be placed symmetrically", () => { const g = new CrosshairsGame(undefined, ["random-start"]); const clouds = Array.from(g.clouds); From c769ff12cf65182203c56c8d0628d188d06f08ff Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:11:22 -0400 Subject: [PATCH 11/15] Update crosshairs.ts Manual comments --- src/games/crosshairs.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index 91a04d1f0..ec5bcf895 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -257,6 +257,8 @@ export class CrosshairsGame extends GameBase { return this.graph.rot180(cell); } + // Return the total number of clouds to be placed according to the selected variant + // Just a helper function to make the code in placeRandomSymmetricClouds() cleaner private getTargetCloudCount(): number { if (this.variants.includes("clouds-28")) return 28; if (this.variants.includes("clouds-22")) return 22; From 55f8f1689b9368728bf01f637e107a45e9a41f81 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:25:05 -0400 Subject: [PATCH 12/15] Finish Crosshairs cloud setup safeguards --- src/games/crosshairs.ts | 32 ++++++++++++++++--- test/games/crosshairs.test.ts | 59 ++++++++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index ec5bcf895..5476fd624 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -1,4 +1,4 @@ -import { GameBase, IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IRenderOpts, IScores, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; import { APRenderRep, Glyph, MarkerFlood, RowCol } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; @@ -145,7 +145,7 @@ export class CrosshairsGame extends GameBase { }, ], categories: ["goal>annihilate", "mechanic>move", "mechanic>capture", "mechanic>block", "board>shape>hex", "board>connect>hex", "components>special"], - flags: ["no-moves", "custom-randomization", "custom-rotation"], + flags: ["no-moves", "custom-randomization", "custom-rotation", "custom-buttons"], variants: [ { uid: "random-start", @@ -265,11 +265,16 @@ export class CrosshairsGame extends GameBase { return 16; } - // Place symmetric clouds up to the selected target for the random-start variant + // Place symmetric clouds up to the selected target for the random-start variant. + // Random setups are invariant under a 180-degree rotation, so placements are + // made in pairs rather than as individual clouds. private placeRandomSymmetricClouds(): string[] { const target = this.getTargetCloudCount(); const seen = new Set(); const pairs: Array<[string, string]> = []; + + // Collapse the board into unique rotational pairs. The centre cell maps + // to itself and is omitted because all available cloud counts are even. for (const cell of this.graph.listCells() as string[]) { if (seen.has(cell)) continue; const mirror = this.getSymmetricCell(cell); @@ -280,15 +285,22 @@ export class CrosshairsGame extends GameBase { } } - // Randomize pair order, then backtrack if a legal choice later blocks the target. + // Shuffle the pairs with Fisher-Yates. The backtracking search below then + // prefers a different randomized setup each time it is run. for (let i = pairs.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pairs[i], pairs[j]] = [pairs[j], pairs[i]]; } + // A greedy search can choose a legal pair that prevents the requested + // number of clouds from being reached later. Backtracking lets the setup + // reconsider those choices while still following the randomized order. const cloudSet = new Set(); + // Keep the largest legal partial setup so an impossible future board or + // cloud-count variant can still advance to plane entry without crashing. let best = new Set(); let visited = 0; + // Cap the exponential search so pathological future variants cannot hang. const maxVisited = 100_000; const placePairs = (start: number): boolean => { if (cloudSet.size > best.size) { @@ -296,10 +308,14 @@ export class CrosshairsGame extends GameBase { } if (cloudSet.size >= target) return true; if (visited++ >= maxVisited) return false; + // Even selecting every remaining pair could not reach the target. if (cloudSet.size + 2 * (pairs.length - start) < target) return false; for (let i = start; i < pairs.length; i++) { const [cell, mirror] = pairs[i]; + // Check each half after the other has been added. The shared rule + // helper short-circuits these checks for Unbounded Cloud Banks, + // so random setup respects that variant as manual setup does. if (this.wouldCreateLargeCloud(cell, cloudSet)) continue; cloudSet.add(cell); if (this.wouldCreateLargeCloud(mirror, cloudSet)) { @@ -310,12 +326,15 @@ export class CrosshairsGame extends GameBase { if (placePairs(i + 1)) return true; + // This pair led to a dead end; remove it before trying the next. cloudSet.delete(cell); cloudSet.delete(mirror); } return false; }; + // Prefer a complete setup, but gracefully use the largest legal partial + // setup if the requested total cannot be placed within the search limit. return Array.from(placePairs(0) ? cloudSet : best); } @@ -1466,6 +1485,11 @@ export class CrosshairsGame extends GameBase { return completeMoves; } + public getButtons(): ICustomButton[] { + if (this.mustPassBeforeEntry()) return [{ label: "pass", move: "pass" }]; + return []; + } + // Get possible next actions given what's already been done // This returns individual actions, not complete moves public actions(player?: playerid, partialMove?: string): string[] { diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index a5a52c860..88cd84f71 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -102,6 +102,7 @@ describe("Crosshairs", () => { expect(g.turnNumber).to.equal(1); expect(g.currplayer).to.equal(2); expect(g.moves()).to.deep.equal(["pass"]); + expect(g.getButtons()).to.deep.equal([{ label: "pass", move: "pass" }]); expect(g.randomMove()).to.equal("pass"); expect(g.validateMove("pass")).to.include({ valid: true, complete: 1 }); const playerTwoEntry = g.graph.getEdges().get("N")![0]; @@ -113,6 +114,7 @@ describe("Crosshairs", () => { expect(g.currplayer).to.equal(1); expect(g.results).to.deep.include({ type: "pass" }); expect(g.moves()[0]).to.match(/^enter:/); + expect(g.getButtons()).to.deep.equal([]); g.move(g.moves()[0]); expect([...g.board.values()].filter(([owner]) => owner === 1)).to.have.length(1); @@ -140,8 +142,13 @@ describe("Crosshairs", () => { }); it("should not allow passing during ordinary cloud or entry turns", () => { - expect(new CrosshairsGame().validateMove("pass").valid).to.be.false; - expect(new CrosshairsGame(undefined, ["random-start"]).validateMove("pass").valid).to.be.false; + const manual = new CrosshairsGame(); + const random = new CrosshairsGame(undefined, ["random-start"]); + + expect(manual.validateMove("pass").valid).to.be.false; + expect(manual.getButtons()).to.deep.equal([]); + expect(random.validateMove("pass").valid).to.be.false; + expect(random.getButtons()).to.deep.equal([]); }); }); @@ -155,12 +162,50 @@ describe("Crosshairs", () => { expect(moves[0]).to.match(/^enter:/); }); - it("should combine with the unbounded cloud banks variant", () => { - const g = new CrosshairsGame(undefined, ["random-start", "unbounded-cloud-banks"]); + it("should respect the unbounded cloud banks variant", () => { + const largestCloudBank = (g: CrosshairsGame): number => { + const seen = new Set(); + let largest = 0; - expect(g.clouds.size).to.equal(16); - expect(g.turnNumber).to.equal(1); - expect(g.variants).to.include.members(["random-start", "unbounded-cloud-banks"]); + for (const cloud of g.clouds) { + if (seen.has(cloud)) continue; + const pending = [cloud]; + seen.add(cloud); + let size = 0; + while (pending.length > 0) { + const current = pending.pop()!; + size++; + for (const neighbour of g.graph.neighbours(current)) { + if (g.clouds.has(neighbour) && !seen.has(neighbour)) { + seen.add(neighbour); + pending.push(neighbour); + } + } + } + largest = Math.max(largest, size); + } + + return largest; + }; + + const originalRandom = Math.random; + let bounded: CrosshairsGame; + let unbounded: CrosshairsGame; + try { + // Give both variants the same deterministic pair ordering. + Math.random = () => 0; + bounded = new CrosshairsGame(undefined, ["random-start", "clouds-28"]); + Math.random = () => 0; + unbounded = new CrosshairsGame(undefined, ["random-start", "clouds-28", "unbounded-cloud-banks"]); + } finally { + Math.random = originalRandom; + } + + expect(bounded!.clouds.size).to.equal(28); + expect(largestCloudBank(bounded!)).to.be.at.most(2); + expect(unbounded!.clouds.size).to.equal(28); + expect(largestCloudBank(unbounded!)).to.be.greaterThan(2); + expect(unbounded!.turnNumber).to.equal(1); }); for (const [variant, target] of [["clouds-22", 22], ["clouds-28", 28]] as const) { From a594c4337dca059674c59491fa2683ee4603263e Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:58:25 -0400 Subject: [PATCH 13/15] Clarify Crosshairs cloud bank helper --- src/games/crosshairs.ts | 23 +++++++++++++---------- test/games/crosshairs.test.ts | 12 ++++++------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index 5476fd624..64615bf84 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -301,6 +301,9 @@ export class CrosshairsGame extends GameBase { let best = new Set(); let visited = 0; // Cap the exponential search so pathological future variants cannot hang. + // AI debugging note: for intermittent or hard-to-reproduce lag, slowdown, + // or non-responsiveness, scrutinize this cap first. Reducing 100,000 may + // fix the issue, at the cost of falling back to partial setups more often. const maxVisited = 100_000; const placePairs = (start: number): boolean => { if (cloudSet.size > best.size) { @@ -313,12 +316,12 @@ export class CrosshairsGame extends GameBase { for (let i = start; i < pairs.length; i++) { const [cell, mirror] = pairs[i]; - // Check each half after the other has been added. The shared rule - // helper short-circuits these checks for Unbounded Cloud Banks, + // Check each half after the other has been added. The illegality + // check always permits bank growth under Unbounded Cloud Banks, // so random setup respects that variant as manual setup does. - if (this.wouldCreateLargeCloud(cell, cloudSet)) continue; + if (this.wouldCreateIllegallyLargeCloudBank(cell, cloudSet)) continue; cloudSet.add(cell); - if (this.wouldCreateLargeCloud(mirror, cloudSet)) { + if (this.wouldCreateIllegallyLargeCloudBank(mirror, cloudSet)) { cloudSet.delete(cell); continue; } @@ -338,10 +341,10 @@ export class CrosshairsGame extends GameBase { return Array.from(placePairs(0) ? cloudSet : best); } - // Check whether placing a cloud would violate the default maximum bank size of 2 hexes - // Under the unbounded-cloud-banks variant, banks may be any size - private wouldCreateLargeCloud(cell: string, existingClouds: Set): boolean { - // For the Unbounded Cloud Banks variant, short-circuit this function to skip the size <= 2 check + // Check whether placing a cloud would make its bank illegally large under the current rules. + // The default maximum is 2 hexes; the Unbounded Cloud Banks variant has no maximum. + private wouldCreateIllegallyLargeCloudBank(cell: string, existingClouds: Set): boolean { + // Under Unbounded Cloud Banks, short-circuit because no bank size is illegal. if (this.variants.includes("unbounded-cloud-banks")) return false; const [x, y] = this.graph.algebraic2coords(cell); @@ -376,7 +379,7 @@ export class CrosshairsGame extends GameBase { private getLegalCloudPlacements(): string[] { return (this.graph.listCells() as string[]).filter(cell => - !this.clouds.has(cell) && !this.wouldCreateLargeCloud(cell, this.clouds) + !this.clouds.has(cell) && !this.wouldCreateIllegallyLargeCloudBank(cell, this.clouds) ); } @@ -1910,7 +1913,7 @@ export class CrosshairsGame extends GameBase { return result; } - if (this.wouldCreateLargeCloud(cell, this.clouds)) { + if (this.wouldCreateIllegallyLargeCloudBank(cell, this.clouds)) { result.valid = false; result.message = i18next.t("apgames:validation.crosshairs.CLOUD_TOO_BIG", { cell }); return result; diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index 88cd84f71..aaa46782c 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -94,7 +94,7 @@ describe("Crosshairs", () => { it("should force player 2 to pass when legal placements run out after an odd number of clouds", () => { const g = new CrosshairsGame(); - (g as unknown as { wouldCreateLargeCloud: () => boolean }).wouldCreateLargeCloud = () => true; + (g as unknown as { wouldCreateIllegallyLargeCloudBank: () => boolean }).wouldCreateIllegallyLargeCloudBank = () => true; g.move("cloud:a1", { trusted: true }); @@ -130,7 +130,7 @@ describe("Crosshairs", () => { it("should let player 1 enter immediately when placements run out after an even number of clouds", () => { const g = new CrosshairsGame(); g.move("cloud:a1"); - (g as unknown as { wouldCreateLargeCloud: () => boolean }).wouldCreateLargeCloud = () => true; + (g as unknown as { wouldCreateIllegallyLargeCloudBank: () => boolean }).wouldCreateIllegallyLargeCloudBank = () => true; g.move("cloud:b1", { trusted: true }); @@ -221,16 +221,16 @@ describe("Crosshairs", () => { it("should continue to the entry phase if no symmetric cloud pair can be placed", () => { const prototype = CrosshairsGame.prototype as unknown as { - wouldCreateLargeCloud: (cell: string, clouds: Set) => boolean; + wouldCreateIllegallyLargeCloudBank: (cell: string, clouds: Set) => boolean; }; - const original = prototype.wouldCreateLargeCloud; - prototype.wouldCreateLargeCloud = () => true; + const original = prototype.wouldCreateIllegallyLargeCloudBank; + prototype.wouldCreateIllegallyLargeCloudBank = () => true; let g: CrosshairsGame; try { g = new CrosshairsGame(undefined, ["random-start", "clouds-28"]); } finally { - prototype.wouldCreateLargeCloud = original; + prototype.wouldCreateIllegallyLargeCloudBank = original; } expect(g!.clouds.size).to.equal(0); From 0c0e6611672b8d493750b99448255d5122f621b6 Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:13:13 -0400 Subject: [PATCH 14/15] Update crosshairs.ts maxVisited Reduced `maxVisited` cap to 100 and expanded the comment advising on how to tweak it --- src/games/crosshairs.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index 64615bf84..d58adfbe3 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -301,10 +301,15 @@ export class CrosshairsGame extends GameBase { let best = new Set(); let visited = 0; // Cap the exponential search so pathological future variants cannot hang. - // AI debugging note: for intermittent or hard-to-reproduce lag, slowdown, - // or non-responsiveness, scrutinize this cap first. Reducing 100,000 may + // AI debugging notes: for intermittent or hard-to-reproduce lag, slowdown, + // or non-responsiveness, scrutinize this cap first. Reducing it's size may // fix the issue, at the cost of falling back to partial setups more often. - const maxVisited = 100_000; + // Don't ever set it at less than half the size of the maximum number of clouds that might need to be placed, + // or placing all clouds will be impossible + // If random setups seem to be failing to place all clouds a lot, + // you can try increasing this cap, but I doubt that will be necessary, + // and the cost is risking more compute for unclear gain. + const maxVisited = 100; const placePairs = (start: number): boolean => { if (cloudSet.size > best.size) { best = new Set(cloudSet); From 417567a8f5ff2f5fc8162eec0c3e013c63c8f3fa Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:40:37 -0400 Subject: [PATCH 15/15] Add asymmetric random clouds to Crosshairs --- locales/en/apgames.json | 8 +- locales/eo/apgames.json | 8 +- src/games/crosshairs.ts | 98 +++++++++++++------- test/games/crosshairs.test.ts | 164 +++++++++++++++++++++------------- 4 files changed, 182 insertions(+), 96 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 8f9f25ae7..e9e77354c 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1685,8 +1685,12 @@ "name": "28 clouds" }, "random-start": { - "name": "Random clouds", - "description": "There is no cloud placing phase. They are placed randomly by the system." + "name": "Symmetric random clouds", + "description": "There is no cloud placing phase. The system places the clouds randomly with 180-degree rotational symmetry." + }, + "asymmetric-random-start": { + "name": "Asymmetric random clouds", + "description": "There is no cloud placing phase. The system places the clouds randomly without requiring symmetry." }, "unbounded-cloud-banks": { "name": "Unbounded cloud banks", diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 5045012a1..e4a1471cf 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1269,8 +1269,12 @@ "name": "28 nuboj" }, "random-start": { - "name": "Hazardaj nuboj", - "description": "Ne ekzistas nubo-moviĝanta fazo. Ili estas hazarde metitaj de la sistemo." + "name": "Simetriaj hazardaj nuboj", + "description": "Ne ekzistas fazo de nubometado. La sistemo metas la nubojn hazarde kun 180-grada rotacia simetrio." + }, + "asymmetric-random-start": { + "name": "Nesimetriaj hazardaj nuboj", + "description": "Ne ekzistas fazo de nubometado. La sistemo metas la nubojn hazarde sen deviga simetrio." }, "unbounded-cloud-banks": { "name": "Senlimaj nubaroj", diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index d58adfbe3..dedfad762 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -151,6 +151,10 @@ export class CrosshairsGame extends GameBase { uid: "random-start", group: "setup", }, + { + uid: "asymmetric-random-start", + group: "setup", + }, { uid: "unbounded-cloud-banks", }, @@ -187,9 +191,14 @@ export class CrosshairsGame extends GameBase { const clouds = new Set(); let turnNumber = 0; - // If random-start variant, place the selected number of clouds in symmetric pairs - if (this.variants.includes("random-start")) { - const placedClouds = this.placeRandomSymmetricClouds(); + // Random setup variants place the selected number of clouds and skip manual placement. + let placedClouds: string[] | undefined; + if (this.variants.includes("asymmetric-random-start")) { + placedClouds = this.placeRandomAsymmetricClouds(); + } else if (this.variants.includes("random-start")) { + placedClouds = this.placeRandomSymmetricClouds(); + } + if (placedClouds !== undefined) { for (const cell of placedClouds) { clouds.add(cell); } @@ -258,7 +267,7 @@ export class CrosshairsGame extends GameBase { } // Return the total number of clouds to be placed according to the selected variant - // Just a helper function to make the code in placeRandomSymmetricClouds() cleaner + // Helper shared by both random cloud placement functions private getTargetCloudCount(): number { if (this.variants.includes("clouds-28")) return 28; if (this.variants.includes("clouds-22")) return 22; @@ -269,7 +278,6 @@ export class CrosshairsGame extends GameBase { // Random setups are invariant under a 180-degree rotation, so placements are // made in pairs rather than as individual clouds. private placeRandomSymmetricClouds(): string[] { - const target = this.getTargetCloudCount(); const seen = new Set(); const pairs: Array<[string, string]> = []; @@ -285,14 +293,29 @@ export class CrosshairsGame extends GameBase { } } - // Shuffle the pairs with Fisher-Yates. The backtracking search below then + return this.placeRandomCloudGroups(pairs); + } + + // Place clouds independently for the asymmetric-random-start variant. + // Each cell is its own candidate group, so no symmetry is imposed. + private placeRandomAsymmetricClouds(): string[] { + const singles = (this.graph.listCells() as string[]).map(cell => [cell]); + return this.placeRandomCloudGroups(singles); + } + + // Randomly place groups of one cloud (asymmetric) or two rotationally paired + // clouds (symmetric), using the same legality checks and search limit. + private placeRandomCloudGroups(groups: string[][]): string[] { + const target = this.getTargetCloudCount(); + + // Shuffle the groups with Fisher-Yates. The backtracking search below then // prefers a different randomized setup each time it is run. - for (let i = pairs.length - 1; i > 0; i--) { + for (let i = groups.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); - [pairs[i], pairs[j]] = [pairs[j], pairs[i]]; + [groups[i], groups[j]] = [groups[j], groups[i]]; } - // A greedy search can choose a legal pair that prevents the requested + // A greedy search can choose a legal group that prevents the requested // number of clouds from being reached later. Backtracking lets the setup // reconsider those choices while still following the randomized order. const cloudSet = new Set(); @@ -302,48 +325,61 @@ export class CrosshairsGame extends GameBase { let visited = 0; // Cap the exponential search so pathological future variants cannot hang. // AI debugging notes: for intermittent or hard-to-reproduce lag, slowdown, - // or non-responsiveness, scrutinize this cap first. Reducing it's size may + // or non-responsiveness, scrutinize this cap first. Reducing its size may // fix the issue, at the cost of falling back to partial setups more often. - // Don't ever set it at less than half the size of the maximum number of clouds that might need to be placed, - // or placing all clouds will be impossible + // Don't ever set it below the maximum number of clouds that might need to + // be placed, or asymmetric setup cannot place them all even on a direct path. // If random setups seem to be failing to place all clouds a lot, // you can try increasing this cap, but I doubt that will be necessary, // and the cost is risking more compute for unclear gain. const maxVisited = 100; - const placePairs = (start: number): boolean => { + const remainingCapacity = new Array(groups.length + 1).fill(0); + for (let i = groups.length - 1; i >= 0; i--) { + remainingCapacity[i] = remainingCapacity[i + 1] + groups[i].length; + } + + const placeGroups = (start: number): boolean => { if (cloudSet.size > best.size) { best = new Set(cloudSet); } if (cloudSet.size >= target) return true; if (visited++ >= maxVisited) return false; - // Even selecting every remaining pair could not reach the target. - if (cloudSet.size + 2 * (pairs.length - start) < target) return false; - - for (let i = start; i < pairs.length; i++) { - const [cell, mirror] = pairs[i]; - // Check each half after the other has been added. The illegality - // check always permits bank growth under Unbounded Cloud Banks, - // so random setup respects that variant as manual setup does. - if (this.wouldCreateIllegallyLargeCloudBank(cell, cloudSet)) continue; - cloudSet.add(cell); - if (this.wouldCreateIllegallyLargeCloudBank(mirror, cloudSet)) { - cloudSet.delete(cell); + // Even selecting every remaining group could not reach the target. + if (cloudSet.size + remainingCapacity[start] < target) return false; + + for (let i = start; i < groups.length; i++) { + const group = groups[i]; + if (cloudSet.size + group.length > target) continue; + + // Add each cloud in turn so later clouds in a symmetric pair are + // checked against earlier ones. Under Unbounded Cloud Banks, the + // illegality check permits every otherwise-unused cell. + const placed: string[] = []; + let legal = true; + for (const cell of group) { + if (this.wouldCreateIllegallyLargeCloudBank(cell, cloudSet)) { + legal = false; + break; + } + cloudSet.add(cell); + placed.push(cell); + } + if (!legal) { + for (const cell of placed) cloudSet.delete(cell); continue; } - cloudSet.add(mirror); - if (placePairs(i + 1)) return true; + if (placeGroups(i + 1)) return true; - // This pair led to a dead end; remove it before trying the next. - cloudSet.delete(cell); - cloudSet.delete(mirror); + // This group led to a dead end; remove it before trying the next. + for (const cell of placed) cloudSet.delete(cell); } return false; }; // Prefer a complete setup, but gracefully use the largest legal partial // setup if the requested total cannot be placed within the search limit. - return Array.from(placePairs(0) ? cloudSet : best); + return Array.from(placeGroups(0) ? cloudSet : best); } // Check whether placing a cloud would make its bank illegally large under the current rules. diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index aaa46782c..de7b5ec07 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -152,7 +152,47 @@ describe("Crosshairs", () => { }); }); - describe("Random Start Variant", () => { + describe("Random Cloud Variants", () => { + const largestCloudBank = (g: CrosshairsGame): number => { + const seen = new Set(); + let largest = 0; + + for (const cloud of g.clouds) { + if (seen.has(cloud)) continue; + const pending = [cloud]; + seen.add(cloud); + let size = 0; + while (pending.length > 0) { + const current = pending.pop()!; + size++; + for (const neighbour of g.graph.neighbours(current)) { + if (g.clouds.has(neighbour) && !seen.has(neighbour)) { + seen.add(neighbour); + pending.push(neighbour); + } + } + } + largest = Math.max(largest, size); + } + + return largest; + }; + + it("should put both random modes in the setup variant group", () => { + const setupVariants = new CrosshairsGame().allvariants()! + .filter(variant => variant.group === "setup"); + + expect(setupVariants.map(variant => variant.uid)).to.deep.equal([ + "#setup", + "random-start", + "asymmetric-random-start", + ]); + expect(setupVariants.find(variant => variant.uid === "random-start")!.name) + .to.equal("Symmetric random clouds"); + expect(setupVariants.find(variant => variant.uid === "asymmetric-random-start")!.name) + .to.equal("Asymmetric random clouds"); + }); + it("should place 16 symmetric clouds and skip cloud phase", () => { const g = new CrosshairsGame(undefined, ["random-start"]); expect(g.clouds.size).to.equal(16); @@ -162,82 +202,84 @@ describe("Crosshairs", () => { expect(moves[0]).to.match(/^enter:/); }); - it("should respect the unbounded cloud banks variant", () => { - const largestCloudBank = (g: CrosshairsGame): number => { - const seen = new Set(); - let largest = 0; - - for (const cloud of g.clouds) { - if (seen.has(cloud)) continue; - const pending = [cloud]; - seen.add(cloud); - let size = 0; - while (pending.length > 0) { - const current = pending.pop()!; - size++; - for (const neighbour of g.graph.neighbours(current)) { - if (g.clouds.has(neighbour) && !seen.has(neighbour)) { - seen.add(neighbour); - pending.push(neighbour); - } - } - } - largest = Math.max(largest, size); - } - - return largest; - }; - + it("should place 16 clouds asymmetrically and skip cloud phase", () => { const originalRandom = Math.random; - let bounded: CrosshairsGame; - let unbounded: CrosshairsGame; + let g: CrosshairsGame; try { - // Give both variants the same deterministic pair ordering. - Math.random = () => 0; - bounded = new CrosshairsGame(undefined, ["random-start", "clouds-28"]); Math.random = () => 0; - unbounded = new CrosshairsGame(undefined, ["random-start", "clouds-28", "unbounded-cloud-banks"]); + g = new CrosshairsGame(undefined, ["asymmetric-random-start"]); } finally { Math.random = originalRandom; } - expect(bounded!.clouds.size).to.equal(28); - expect(largestCloudBank(bounded!)).to.be.at.most(2); - expect(unbounded!.clouds.size).to.equal(28); - expect(largestCloudBank(unbounded!)).to.be.greaterThan(2); - expect(unbounded!.turnNumber).to.equal(1); + expect(g!.clouds.size).to.equal(16); + expect(g!.turnNumber).to.equal(1); + expect([...g!.clouds].some(cloud => !g!.clouds.has(g!.graph.rot180(cloud)))).to.be.true; + expect(g!.moves()[0]).to.match(/^enter:/); }); - for (const [variant, target] of [["clouds-22", 22], ["clouds-28", 28]] as const) { - it(`should place ${target} symmetric clouds with the ${variant} variant`, () => { - const g = new CrosshairsGame(undefined, ["random-start", variant]); - - expect(g.clouds.size).to.equal(target); - for (const cloud of g.clouds) { - expect(g.clouds.has(g.graph.rot180(cloud))).to.be.true; + for (const setup of ["random-start", "asymmetric-random-start"] as const) { + it(`should make ${setup} respect the unbounded cloud banks variant`, () => { + const originalRandom = Math.random; + let bounded: CrosshairsGame; + let unbounded: CrosshairsGame; + try { + // Give both bank rules the same deterministic candidate ordering. + Math.random = () => 0; + bounded = new CrosshairsGame(undefined, [setup, "clouds-28"]); + Math.random = () => 0; + unbounded = new CrosshairsGame(undefined, [setup, "clouds-28", "unbounded-cloud-banks"]); + } finally { + Math.random = originalRandom; } + + expect(bounded!.clouds.size).to.equal(28); + expect(largestCloudBank(bounded!)).to.be.at.most(2); + expect(unbounded!.clouds.size).to.equal(28); + expect(largestCloudBank(unbounded!)).to.be.greaterThan(2); + expect(unbounded!.turnNumber).to.equal(1); }); } - it("should continue to the entry phase if no symmetric cloud pair can be placed", () => { - const prototype = CrosshairsGame.prototype as unknown as { - wouldCreateIllegallyLargeCloudBank: (cell: string, clouds: Set) => boolean; - }; - const original = prototype.wouldCreateIllegallyLargeCloudBank; - prototype.wouldCreateIllegallyLargeCloudBank = () => true; + for (const setup of ["random-start", "asymmetric-random-start"] as const) { + for (const [variant, target] of [["clouds-22", 22], ["clouds-28", 28]] as const) { + it(`should place ${target} clouds with the ${setup} and ${variant} variants`, () => { + const g = new CrosshairsGame(undefined, [setup, variant]); - let g: CrosshairsGame; - try { - g = new CrosshairsGame(undefined, ["random-start", "clouds-28"]); - } finally { - prototype.wouldCreateIllegallyLargeCloudBank = original; + expect(g.clouds.size).to.equal(target); + if (setup === "random-start") { + for (const cloud of g.clouds) { + expect(g.clouds.has(g.graph.rot180(cloud))).to.be.true; + } + } + }); } + } - expect(g!.clouds.size).to.equal(0); - expect(g!.turnNumber).to.equal(1); - expect(g!.currplayer).to.equal(1); - expect(g!.moves()[0]).to.match(/^enter:/); - }); + for (const [setup, partialCount] of [ + ["random-start", 4], + ["asymmetric-random-start", 5], + ] as const) { + it(`should keep a partial ${setup} setup and continue to the entry phase`, () => { + const prototype = CrosshairsGame.prototype as unknown as { + wouldCreateIllegallyLargeCloudBank: (cell: string, clouds: Set) => boolean; + }; + const original = prototype.wouldCreateIllegallyLargeCloudBank; + prototype.wouldCreateIllegallyLargeCloudBank = (_cell, clouds) => clouds.size >= 5; + + let g: CrosshairsGame; + try { + g = new CrosshairsGame(undefined, [setup, "clouds-28"]); + } finally { + prototype.wouldCreateIllegallyLargeCloudBank = original; + } + + expect(g!.clouds.size).to.equal(partialCount); + expect(g!.turnNumber).to.equal(1); + expect(g!.currplayer).to.equal(1); + expect(g!.moves()[0]).to.match(/^enter:/); + }); + } it("clouds should be placed symmetrically", () => { const g = new CrosshairsGame(undefined, ["random-start"]);