diff --git a/ci-deps.prod.json b/ci-deps.prod.json index 93a21479..886760c7 100644 --- a/ci-deps.prod.json +++ b/ci-deps.prod.json @@ -1,6 +1,6 @@ { - "renderer": "1.0.0-ci-33584881740.0", - "recranks": "1.0.0-ci-33687599936.0", - "updatedAt": "2026-09-03T20:28:15.425Z", - "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/locales/en/apgames.json b/locales/en/apgames.json index 7df88114..e9e77354 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1675,9 +1675,26 @@ } }, "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." + "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", + "description": "Cloud banks may contain any number of clouds." } }, "crossway": { @@ -5736,8 +5753,10 @@ }, "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.", + "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 920557ed..e4a1471c 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1259,9 +1259,26 @@ } }, "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." + "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", + "description": "Nubaroj povas enhavi ajnan nombron da nuboj." } }, "cubeo": { @@ -5547,8 +5564,10 @@ }, "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.", + "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/package-lock.json b/package-lock.json index e4f29041..837cc3cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33996014004.0", + "version": "1.0.0-ci-33995846150.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33996014004.0", + "version": "1.0.0-ci-33995846150.0", "license": "MIT", "dependencies": { - "@abstractplay/recranks": "1.0.0-ci-33686810882.0", - "@abstractplay/renderer": "1.0.0-ci-33995779968.0", + "@abstractplay/recranks": "1.0.0-ci-33687599936.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", @@ -94,9 +94,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", @@ -110,9 +110,9 @@ } }, "node_modules/@abstractplay/renderer": { - "version": "1.0.0-ci-33995779968.0", - "resolved": "https://npm.pkg.github.com/download/@abstractplay/renderer/1.0.0-ci-33995779968.0/cb036cafbf7210296420b30371492cb4f460cfda", - "integrity": "sha512-s+7tbtWRLmK3fGeueHzqygZVNz9YQf9VaCAxTh7aOT4dk7gDK1GUTrYOMAY516a/oQcynB+BTmo9jq2iqLQEbA==", + "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", diff --git a/package.json b/package.json index 6b2af18b..d2a09e42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@abstractplay/gameslib", - "version": "1.0.0-ci-33996014004.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", @@ -131,8 +131,8 @@ "vite": "^6.3.5" }, "dependencies": { - "@abstractplay/recranks": "1.0.0-ci-33686810882.0", - "@abstractplay/renderer": "1.0.0-ci-33995779968.0", + "@abstractplay/recranks": "1.0.0-ci-33687599936.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", diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index ed49fd86..dedfad76 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,12 +145,22 @@ 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", group: "setup", }, + { + uid: "asymmetric-random-start", + group: "setup", + }, + { + uid: "unbounded-cloud-banks", + }, + { uid: "#clouds" }, + { uid: "clouds-22", group: "clouds" }, + { uid: "clouds-28", group: "clouds" }, ], displays: [{uid: "abstract"}, {uid: "numeric"}, {uid: "numeric-abstract"}], }; @@ -181,9 +191,14 @@ export class CrosshairsGame extends GameBase { const clouds = new Set(); let turnNumber = 0; - // If random-start variant, place 8 pairs of symmetric clouds - 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); } @@ -251,39 +266,128 @@ 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."); + // Return the total number of clouds to be placed according to the selected variant + // 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; + return 16; + } - const cell = untried[Math.floor(Math.random() * untried.length)]; - // Remove from untried - const index = untried.indexOf(cell); - untried.splice(index, 1); + // 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 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); - untried.splice(untried.indexOf(mirror), 1); // Remove mirror from untried too + seen.add(cell); + seen.add(mirror); + if (cell !== mirror) { + pairs.push([cell, mirror]); + } + } + + return this.placeRandomCloudGroups(pairs); + } - if (cell === mirror) continue; // Skip center cell + // 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); + } - // Check if this pair would create clouds > 2 hexes - if (this.wouldCreateLargeCloud(cell, cloudSet)) continue; + // 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(); - // Valid pair - add both - cloudSet.add(cell); - cloudSet.add(mirror); + // Shuffle the groups with Fisher-Yates. The backtracking search below then + // prefers a different randomized setup each time it is run. + for (let i = groups.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [groups[i], groups[j]] = [groups[j], groups[i]]; } - return Array.from(cloudSet); + // 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(); + // 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. + // AI debugging notes: for intermittent or hard-to-reproduce lag, slowdown, + // 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 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 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 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; + } + + if (placeGroups(i + 1)) return true; + + // 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(placeGroups(0) ? cloudSet : best); } - // Check if placing a cloud at cell would create a cloud group > 2 hexes - private wouldCreateLargeCloud(cell: string, existingClouds: Set): boolean { + // 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); let adjacentCloudCount = 0; @@ -314,6 +418,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.wouldCreateIllegallyLargeCloudBank(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 @@ -374,7 +484,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) @@ -1302,6 +1418,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(); @@ -1411,6 +1529,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[] { @@ -1421,13 +1544,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; } @@ -1754,10 +1876,18 @@ 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; - 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(); @@ -1788,6 +1918,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 @@ -1813,7 +1954,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; @@ -2581,6 +2722,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) { @@ -2597,8 +2741,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!); @@ -2624,8 +2771,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; } @@ -2633,7 +2782,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++; } @@ -2666,17 +2815,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 a5d87dc4..de7b5ec0 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(); @@ -64,9 +78,121 @@ 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"); + }); + + 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 { wouldCreateIllegallyLargeCloudBank: () => boolean }).wouldCreateIllegallyLargeCloudBank = () => 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.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]; + 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:/); + expect(g.getButtons()).to.deep.equal([]); + + 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 { wouldCreateIllegallyLargeCloudBank: () => boolean }).wouldCreateIllegallyLargeCloudBank = () => 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", () => { + 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([]); + }); }); - 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); @@ -76,6 +202,85 @@ describe("Crosshairs", () => { expect(moves[0]).to.match(/^enter:/); }); + it("should place 16 clouds asymmetrically and skip cloud phase", () => { + const originalRandom = Math.random; + let g: CrosshairsGame; + try { + Math.random = () => 0; + g = new CrosshairsGame(undefined, ["asymmetric-random-start"]); + } finally { + Math.random = originalRandom; + } + + 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 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); + }); + } + + 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]); + + 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; + } + } + }); + } + } + + 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"]); const clouds = Array.from(g.clouds);