From 38e4ec10e006e0e49f7c86c98a561e30efbd5dab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:17:22 +0000 Subject: [PATCH] Crosshairs: experimental random cloud setups and paint glyphs Replace the experimental spread setups with three experimental setup variants: - Symmetric / Asymmetric random clouds (new) (split-start, asymmetric-split-start): the designer's placement, intended to replace the current random setups once accepted. Each round, every interior cell touching at most one cloud is scored by how evenly it splits an open line of fire, and a random top-scoring cell is chosen. The symmetric version places 180-degree pairs and ends one cloud short when the centre is chosen. Works with or without Unbounded cloud banks. - Clumping random clouds (clumping-start, community variant): the "Triple Beta" weighted placement, favouring the middle of the board while keeping clouds a little apart. Respects the bank-size rule. Also colour the sheet glyphs (cloud, piece, plane, arrowhead, wedge) with paint.fill instead of the deprecated colour property. Existing prod setups and base-game behaviour are unchanged, so there is no version bump. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BK3bL3XekxLsqgRWgYBv7W --- locales/en/apgames.json | 16 ++- locales/eo/apgames.json | 16 ++- src/games/crosshairs.ts | 239 ++++++++++++++++++++++++++-------- test/games/crosshairs.test.ts | 213 +++++++++++++++++++++++++++--- 4 files changed, 402 insertions(+), 82 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 463eef07d..4503b304c 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1788,13 +1788,17 @@ "name": "Asymmetric random clouds", "description": "There is no cloud placing phase. The system places the clouds randomly without requiring symmetry." }, - "spread-start": { - "name": "Symmetric spread clouds", - "description": "There is no cloud placing phase. The system places the clouds with 180-degree rotational symmetry using the designer's algorithm, which blocks open lines of fire first and then fills in the gaps." + "split-start": { + "name": "Symmetric random clouds (new)", + "description": "There is no cloud placing phase. The system places clouds in 180-degree rotationally symmetric pairs away from the board edge, each time choosing among the cells that split an open line of fire most evenly. A cloud in the centre counts as a pair, leaving the board one cloud short." }, - "asymmetric-spread-start": { - "name": "Asymmetric spread clouds", - "description": "There is no cloud placing phase. The system places the clouds without requiring symmetry using the designer's algorithm, which blocks open lines of fire first and then fills in the gaps." + "asymmetric-split-start": { + "name": "Asymmetric random clouds (new)", + "description": "There is no cloud placing phase. The system places clouds one at a time away from the board edge, each time choosing among the cells that split an open line of fire most evenly." + }, + "clumping-start": { + "name": "Clumping random clouds", + "description": "There is no cloud placing phase. The system places clouds one at a time without requiring symmetry, at random but favouring the middle of the board, so the clouds gather toward the centre while keeping a little space from one another." }, "unbounded-cloud-banks": { "name": "Unbounded cloud banks", diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index d60eb56e4..173555c08 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1707,13 +1707,17 @@ "name": "Nesimetriaj hazardaj nuboj", "description": "Ne estas fazo por meti nubojn. La sistemo hazarde metas ilin sen postuli simetrion." }, - "spread-start": { - "name": "Simetriaj disvastigitaj nuboj", - "description": "Ne estas fazo por meti nubojn. La sistemo metas ilin kun rotacia simetrio je 180 gradoj laŭ la algoritmo de la aŭtoro, kiu unue baras liberajn pafliniojn kaj poste plenigas la breĉojn." + "split-start": { + "name": "Simetriaj hazardaj nuboj (nova)", + "description": "Ne estas fazo por meti nubojn. La sistemo metas nubojn en paroj kun rotacia simetrio je 180 gradoj for de la rando de la tabulo, ĉiufoje elektante inter la ĉeloj, kiuj plej egale dividas liberan paflinion. Nubo en la centro kalkuliĝas kiel paro, do al la tabulo mankas unu nubo." }, - "asymmetric-spread-start": { - "name": "Nesimetriaj disvastigitaj nuboj", - "description": "Ne estas fazo por meti nubojn. La sistemo metas ilin sen postuli simetrion laŭ la algoritmo de la aŭtoro, kiu unue baras liberajn pafliniojn kaj poste plenigas la breĉojn." + "asymmetric-split-start": { + "name": "Nesimetriaj hazardaj nuboj (nova)", + "description": "Ne estas fazo por meti nubojn. La sistemo metas nubojn unu post alia for de la rando de la tabulo, ĉiufoje elektante inter la ĉeloj, kiuj plej egale dividas liberan paflinion." + }, + "clumping-start": { + "name": "Amasiĝantaj hazardaj nuboj", + "description": "Ne estas fazo por meti nubojn. La sistemo metas nubojn unu post alia sen postuli simetrion, hazarde sed preferante la mezon de la tabulo, do la nuboj kolektiĝas al la centro, tamen lasante iom da spaco inter si." }, "unbounded-cloud-banks": { "name": "Senlimaj nubaroj", diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index e0a93910b..4a4776d69 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -157,15 +157,21 @@ export class CrosshairsGame extends GameBase { group: "setup", }, { - uid: "spread-start", + uid: "split-start", group: "setup", experimental: true, }, { - uid: "asymmetric-spread-start", + uid: "asymmetric-split-start", group: "setup", experimental: true, }, + { + uid: "clumping-start", + group: "setup", + experimental: true, + fans: true, + }, { uid: "unbounded-cloud-banks", }, @@ -224,10 +230,12 @@ export class CrosshairsGame extends GameBase { placedClouds = this.placeRandomAsymmetricClouds(); } else if (this.variants.includes("random-start")) { placedClouds = this.placeRandomSymmetricClouds(); - } else if (this.variants.includes("asymmetric-spread-start")) { - placedClouds = this.placeSpreadRandomClouds(false); - } else if (this.variants.includes("spread-start")) { - placedClouds = this.placeSpreadRandomClouds(true); + } else if (this.variants.includes("asymmetric-split-start")) { + placedClouds = this.placeSplitClouds(false); + } else if (this.variants.includes("split-start")) { + placedClouds = this.placeSplitClouds(true); + } else if (this.variants.includes("clumping-start")) { + placedClouds = this.placeClumpingClouds(); } if (placedClouds !== undefined) { for (const cell of placedClouds) { @@ -334,57 +342,186 @@ export class CrosshairsGame extends GameBase { return this.placeRandomCloudGroups(singles); } - // Place clouds with the designer's coverage-first algorithm. Cells are - // visited in one random order over several passes, and a cell qualifies in - // pass N only if at most N of its six rays already contain a cloud, so the - // earliest clouds block completely open lines and later passes fill the - // gaps. The final pass accepts anything the bank-size rule allows. Symmetric - // mode places rotational pairs; the centre is its own mirror and places a - // single cloud, after which no pair fits the even target, so a board with - // a centre cloud ends one short (15, 21, or 27), as the designer intends. - private placeSpreadRandomClouds(symmetric: boolean): string[] { - const target = this.getTargetCloudCount(); - const cells = this.graph.listCells() as string[]; - for (let i = cells.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [cells[i], cells[j]] = [cells[j], cells[i]]; + // The six rays from each cell, indexed like allDirections, so ray d and + // ray d + 3 lie on the same axis in opposite directions. + private getAllCellRays(): Map { + const rays = new Map(); + for (const cell of this.graph.listCells() as string[]) { + const [x, y] = this.graph.algebraic2coords(cell); + rays.set(cell, allDirections.map(dir => this.getRay(x, y, dir))); } + return rays; + } + // Number of cells along a ray before the first cloud or the board edge. + private openLength(ray: string[], clouds: Set): number { + let count = 0; + for (const cell of ray) { + if (clouds.has(cell)) break; + count++; + } + return count; + } + + // The designer's random cloud placement, intended to replace the current + // random setups ("split" is its working title). Each round, every + // interior cell touching at most one cloud is scored by how evenly it + // splits its best axis, halved with integer division so that many cells + // tie, and a random tied cell is chosen. Mirrored mode also places the + // 180-degree rotation and counts two clouds even when the centre is its + // own mirror, so such boards end one short. Cells that would break the + // bank-size rule are skipped, as is, in mirrored mode, a cell whose mirror + // would then break it; under Unbounded Cloud Banks that skips nothing. If + // no cell qualifies, the board is returned as it stands. + private placeSplitClouds(mirrored: boolean): string[] { + const rays = this.getAllCellRays(); + const cells = this.graph.listCells() as string[]; const clouds = new Set(); - const raysBlocked = (cell: string): number => { - const [x, y] = this.graph.algebraic2coords(cell); - let blocked = 0; - for (const dir of allDirections) { - if (this.getRay(x, y, dir).some(c => clouds.has(c))) blocked++; + let remaining = this.getTargetCloudCount(); + + const score = (cell: string): number => { + if (clouds.has(cell)) return -1; + const cellRays = rays.get(cell)!; + if (cellRays.some(ray => ray.length === 0)) return -1; + const adjacent = cellRays.filter(ray => clouds.has(ray[0])).length; + if (adjacent > 1) return -1; + if (this.wouldCreateIllegallyLargeCloudBank(cell, clouds)) return -1; + if (mirrored) { + const mirror = this.getSymmetricCell(cell); + if (mirror !== cell) { + clouds.add(cell); + const illegal = this.wouldCreateIllegallyLargeCloudBank(mirror, clouds); + clouds.delete(cell); + if (illegal) return -1; + } + } + let bestSplit = 0; + for (let axis = 0; axis < 3; axis++) { + bestSplit = Math.max(bestSplit, Math.min( + this.openLength(cellRays[axis], clouds), + this.openLength(cellRays[axis + 3], clouds), + )); } - return blocked; + return Math.trunc((bestSplit - adjacent + Math.floor(Math.random() * 2)) / 2); }; - for (let pass = 0; pass <= allDirections.length && clouds.size < target; pass++) { + while (remaining > 0) { + let topCells: string[] = []; + let topScore = 0; for (const cell of cells) { - if (clouds.size >= target) break; - if (clouds.has(cell)) continue; - const mirror = this.getSymmetricCell(cell); - const group = symmetric && mirror !== cell ? [cell, mirror] : [cell]; - if (clouds.size + group.length > target) continue; - if (group.some(c => raysBlocked(c) > pass)) continue; + const s = score(cell); + if (s > topScore) { + topCells = []; + topScore = s; + } + if (s === topScore) topCells.push(cell); + } + if (topCells.length === 0) break; - // Check each cell of a pair against the growing set so a pair - // straddling an existing cloud cannot form a three-bank. - const placed: string[] = []; - let legal = true; - for (const c of group) { - if (this.wouldCreateIllegallyLargeCloudBank(c, clouds)) { - legal = false; - break; - } - clouds.add(c); - placed.push(c); + const cell = topCells[Math.floor(Math.random() * topCells.length)]; + clouds.add(cell); + remaining--; + if (mirrored) { + clouds.add(this.getSymmetricCell(cell)); + remaining--; + } + } + return Array.from(clouds); + } + + // Cube coordinates (q, r, s), summing to 0, of every cell, with the centre + // at the origin. Consecutive visual directions map to cube unit vectors a + // sixth of a turn apart, so a cell's largest coordinate is its distance + // from the centre and, between two cells, the largest coordinate + // difference is their hex distance. + private getCubeCoords(): Map { + const units: [number, number, number][] = [[1, -1, 0], [1, 0, -1], [0, 1, -1], [-1, 1, 0], [-1, 0, 1], [0, -1, 1]]; + const cells = this.graph.listCells() as string[]; + const centre = cells.find(cell => this.getSymmetricCell(cell) === cell)!; + const cube = new Map([[centre, [0, 0, 0]]]); + const queue = [centre]; + while (queue.length > 0) { + const cell = queue.shift()!; + const [q, r, s] = cube.get(cell)!; + const [x, y] = this.graph.algebraic2coords(cell); + allDirections.forEach((dir, i) => { + const next = this.getRay(x, y, dir)[0]; + if (next !== undefined && !cube.has(next)) { + cube.set(next, [q + units[i][0], r + units[i][1], s + units[i][2]]); + queue.push(next); } - if (!legal) { - for (const c of placed) clouds.delete(c); + }); + } + return cube; + } + + // The "Triple Beta" algorithm from the obstacle-gen-playground repository + // (commit 56530ec), with the parameters chosen for Clumping random clouds. A + // cell's weight is its position weight times its distance weight, and each + // cloud goes on a cell that respects the bank-size rule, drawn in + // proportion to its weight. + // + // Position weight: the product over q, r and s of the Beta(2.5, 2.5) + // density at the coordinate scaled to the middle of its band, + // (x + R + 1/2) / (2R + 1) on a board of radius R, which favours the centre. + // + // Distance weight: against the nearest existing cloud by hex distance, the + // product over q, r and s of the Beta(3, 2) density at |dx| scaled to + // (|dx| + 1/2) / (2R + 1), averaged over clouds tied for nearest. Before + // the first cloud it is 1 for every cell. + // + // Beta normalising constants are left out: within a round they scale + // every cell's weight by the same factor, so the draw is unchanged. If no + // cell qualifies, the board is returned as it stands. + private placeClumpingClouds(): string[] { + const POSITION_ALPHA = 2.5; + const POSITION_BETA = 2.5; + const DISTANCE_ALPHA = 3; + const DISTANCE_BETA = 2; + const betaKernel = (x: number, a: number, b: number): number => x ** (a - 1) * (1 - x) ** (b - 1); + + const cube = this.getCubeCoords(); + const cells = this.graph.listCells() as string[]; + const radius = Math.max(...[...cube.values()].map(c => Math.max(...c.map(Math.abs)))); + const bands = 2 * radius + 1; + const position = new Map(cells.map(cell => [ + cell, + cube.get(cell)!.reduce((w, x) => w * betaKernel((x + radius + 0.5) / bands, POSITION_ALPHA, POSITION_BETA), 1), + ])); + const axisWeight = Array.from({ length: bands }, (_, d) => + betaKernel((d + 0.5) / bands, DISTANCE_ALPHA, DISTANCE_BETA)); + + const clouds = new Set(); + const target = this.getTargetCloudCount(); + while (clouds.size < target) { + const obstacles = [...clouds].map(cell => cube.get(cell)!); + const candidates: string[] = []; + const weights: number[] = []; + for (const cell of cells) { + if (clouds.has(cell) || this.wouldCreateIllegallyLargeCloudBank(cell, clouds)) continue; + const c = cube.get(cell)!; + let distanceWeight = 1; + if (obstacles.length > 0) { + const deltas = obstacles.map(o => [Math.abs(c[0] - o[0]), Math.abs(c[1] - o[1]), Math.abs(c[2] - o[2])]); + const nearest = Math.min(...deltas.map(d => Math.max(...d))); + const tied = deltas.filter(d => Math.max(...d) === nearest); + distanceWeight = tied.reduce((sum, d) => sum + d.reduce((w, x) => w * axisWeight[x], 1), 0) / tied.length; + } + const w = position.get(cell)! * distanceWeight; + if (w > 0) { + candidates.push(cell); + weights.push(w); } } + if (candidates.length === 0) break; + + let pick = Math.random() * weights.reduce((a, b) => a + b, 0); + let idx = 0; + while (idx < weights.length - 1 && pick >= weights[idx]) { + pick -= weights[idx]; + idx++; + } + clouds.add(candidates[idx]); } return Array.from(clouds); } @@ -3292,12 +3429,12 @@ export class CrosshairsGame extends GameBase { const cloudGlyph: Glyph = abstractMode ? { name: "piece", - colour: cloudColour, + paint: { fill: cloudColour }, opacity: 0.5, } : { name: "cloud", - colour: cloudColour, + paint: { fill: cloudColour }, opacity: 0.7, scale: 1.5, orientation: "vertical", @@ -3324,12 +3461,12 @@ export class CrosshairsGame extends GameBase { const planeGlyph: Glyph = abstractMode ? { name: "arrowhead", - colour: player, + paint: { fill: player }, rotate: planeRotation, } : { name: "plane", - colour: player, + paint: { fill: player }, rotate: planeRotation, }; @@ -3388,7 +3525,7 @@ export class CrosshairsGame extends GameBase { name: spec.type, rotate: spec.rotation, scale: wedgeScale, - colour: altitudeColor, + paint: { fill: altitudeColor }, }); } } diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index e8a83113d..d53776abd 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -186,8 +186,9 @@ describe("Crosshairs", () => { "#setup", "random-start", "asymmetric-random-start", - "spread-start", - "asymmetric-spread-start", + "split-start", + "asymmetric-split-start", + "clumping-start", ]); expect(setupVariants.find(variant => variant.uid === "random-start")!.name) .to.equal("Symmetric random clouds"); @@ -258,33 +259,190 @@ describe("Crosshairs", () => { } } - for (const setup of ["spread-start", "asymmetric-spread-start"] as const) { - for (const [variant, target] of [[undefined, 16], ["clouds-22", 22], ["clouds-28", 28]] as const) { - it(`should place ${target} legal clouds with ${setup}${variant ? ` and ${variant}` : ""}`, () => { - const variants = variant ? [setup, variant] : [setup]; - const g = new CrosshairsGame(undefined, variants); + const isEdgeCell = (g: CrosshairsGame, cell: string): boolean => { + const [x, y] = g.graph.algebraic2coords(cell); + const getRay = (g as unknown as { getRay: (x: number, y: number, d: string) => string[] }).getRay.bind(g); + return ["N", "NE", "SE", "S", "SW", "NW"].some(dir => getRay(x, y, dir).length === 0); + }; - expect(g.turnNumber).to.equal(1); - expect(largestCloudBank(g)).to.be.at.most(2); - if (setup === "spread-start") { - // A centre cloud is its own mirror, leaving the board one short. - const hasCentre = [...g.clouds].some(cloud => g.graph.rot180(cloud) === cloud); - expect(g.clouds.size).to.equal(hasCentre ? target - 1 : target); + it("should keep prod setups unflagged and mark the newer setups experimental", () => { + const setup = CrosshairsGame.gameinfo.variants!.filter(v => v.group === "setup"); + const experimental = setup.filter(v => v.experimental).map(v => v.uid); + + expect(setup.filter(v => !v.experimental).map(v => v.uid)) + .to.deep.equal(["random-start", "asymmetric-random-start"]); + expect(experimental).to.deep.equal([ + "split-start", + "asymmetric-split-start", + "clumping-start", + ]); + }); + + it("should tag only Clumping random clouds as a community variant", () => { + const fans = (uid: string) => CrosshairsGame.gameinfo.variants!.find(v => v.uid === uid)!.fans; + + expect(fans("clumping-start")).to.equal(true); + for (const uid of ["random-start", "asymmetric-random-start", "split-start", "asymmetric-split-start"]) { + expect(fans(uid), uid).to.not.equal(true); + } + }); + + it("should let the split setups combine with either cloud bank rule", () => { + for (const uid of ["split-start", "asymmetric-split-start"]) { + const v = CrosshairsGame.gameinfo.variants!.find(variant => variant.uid === uid)!; + expect(v.requires, uid).to.be.undefined; + } + }); + + for (const [variant, target] of [[undefined, 16], ["clouds-22", 22], ["clouds-28", 28]] as const) { + for (const unbounded of [false, true]) { + const extra = [...(variant ? [variant] : []), ...(unbounded ? ["unbounded-cloud-banks"] : [])]; + const banks = unbounded ? "unbounded" : "bounded"; + + it(`should place split clouds away from the edge without overshooting (${target}, ${banks})`, () => { + for (let run = 0; run < 10; run++) { + const g = new CrosshairsGame(undefined, ["asymmetric-split-start", ...extra]); + + expect(g.turnNumber).to.equal(1); + expect(g.clouds.size).to.be.at.most(target); + expect(g.clouds.size).to.be.greaterThan(0); + for (const cloud of g.clouds) { + expect(isEdgeCell(g, cloud), `edge cloud at ${cloud}`).to.be.false; + } + if (!unbounded) expect(largestCloudBank(g)).to.be.at.most(2); + } + }); + + it(`should mirror symmetric split clouds and only fall short as designed (${target}, ${banks})`, () => { + for (let run = 0; run < 10; run++) { + const g = new CrosshairsGame(undefined, ["split-start", ...extra]); + + expect(g.turnNumber).to.equal(1); + expect(g.clouds.size).to.be.at.most(target); for (const cloud of g.clouds) { expect(g.clouds.has(g.graph.rot180(cloud))).to.be.true; + expect(isEdgeCell(g, cloud)).to.be.false; } - } else { - expect(g.clouds.size).to.equal(target); + if (!unbounded) expect(largestCloudBank(g)).to.be.at.most(2); } }); } } - it("should let spread placement form larger banks under unbounded cloud banks", () => { - const g = new CrosshairsGame(undefined, ["asymmetric-spread-start", "clouds-28", "unbounded-cloud-banks"]); + it("should end a symmetric split board one short when the centre is chosen", () => { + const centre = (new CrosshairsGame().graph.listCells() as string[]) + .find(cell => new CrosshairsGame().graph.rot180(cell) === cell)!; + let sawCentre = false; + for (let run = 0; run < 40 && !sawCentre; run++) { + const g = new CrosshairsGame(undefined, ["split-start", "unbounded-cloud-banks"]); + if (g.clouds.has(centre)) { + sawCentre = true; + expect(g.clouds.size).to.equal(15); + } else { + expect(g.clouds.size).to.equal(16); + } + } + // The centre splits every line evenly, so it is chosen on most boards. + expect(sawCentre).to.be.true; + }); - expect(g.clouds.size).to.equal(28); - expect(g.turnNumber).to.equal(1); + it("should count an adjacent cloud or the board edge as an open run of 0", () => { + const g = new CrosshairsGame(); + const openLength = (g as unknown as { + openLength: (ray: string[], clouds: Set) => number; + }).openLength.bind(g); + + expect(openLength(["f6", "f7", "f8"], new Set(["f6"]))).to.equal(0); + expect(openLength(["f6", "f7", "f8"], new Set(["f8"]))).to.equal(2); + expect(openLength(["f6", "f7", "f8"], new Set())).to.equal(3); + expect(openLength([], new Set())).to.equal(0); + }); + + it("should have five open cells in every direction from the centre and a zero run on edge cells", () => { + const g = new CrosshairsGame(); + const centre = (g.graph.listCells() as string[]).find(cell => g.graph.rot180(cell) === cell)!; + const rayLengths = (cell: string): number[] => { + const [x, y] = g.graph.algebraic2coords(cell); + const getRay = (g as unknown as { getRay: (x: number, y: number, d: string) => string[] }).getRay.bind(g); + return ["N", "NE", "SE", "S", "SW", "NW"].map(dir => getRay(x, y, dir).length); + }; + + // Every direction from the centre runs 5 cells to the edge, which is v. + expect(rayLengths(centre)).to.deep.equal([5, 5, 5, 5, 5, 5]); + // An edge cell has a zero-length run, so its geometric mean is 0. + const edgeCell = (g.graph.listCells() as string[]).find(cell => isEdgeCell(g, cell))!; + expect(rayLengths(edgeCell)).to.include(0); + }); + + for (const [variant, target] of [[undefined, 16], ["clouds-22", 22], ["clouds-28", 28]] as const) { + const extra = variant ? [variant] : []; + + it(`should place ${target} clumping clouds within the bank limit`, () => { + for (let run = 0; run < 10; run++) { + const g = new CrosshairsGame(undefined, ["clumping-start", ...extra]); + + expect(g.turnNumber).to.equal(1); + expect(g.clouds.size).to.equal(target); + expect(largestCloudBank(g)).to.be.at.most(2); + } + }); + + it(`should place ${target} clumping clouds under unbounded cloud banks`, () => { + for (let run = 0; run < 10; run++) { + const g = new CrosshairsGame(undefined, ["clumping-start", "unbounded-cloud-banks", ...extra]); + + expect(g.turnNumber).to.equal(1); + expect(g.clouds.size).to.equal(target); + } + }); + } + + it("should give every cell consistent cube coordinates centred on the middle cell", () => { + const g = new CrosshairsGame(); + const cube = (g as unknown as { getCubeCoords: () => Map }).getCubeCoords(); + const cells = g.graph.listCells() as string[]; + const hexDistance = (a: number[], b: number[]): number => + Math.max(...a.map((x, i) => Math.abs(x - b[i]))); + + expect(cube.size).to.equal(cells.length); + expect(new Set([...cube.values()].map(c => c.join(","))).size).to.equal(cells.length); + const centre = cells.find(cell => g.graph.rot180(cell) === cell)!; + expect(cube.get(centre)).to.deep.equal([0, 0, 0]); + for (const cell of cells) { + const c = cube.get(cell)!; + expect(c[0] + c[1] + c[2]).to.equal(0); + // Edge cells are exactly those 5 steps from the centre. + expect(Math.max(...c.map(Math.abs)) === 5).to.equal(isEdgeCell(g, cell)); + const [x, y] = g.graph.algebraic2coords(cell); + const getRay = (g as unknown as { getRay: (x: number, y: number, d: string) => string[] }).getRay.bind(g); + for (const dir of ["N", "NE", "SE", "S", "SW", "NW"]) { + const next = getRay(x, y, dir)[0]; + if (next !== undefined) { + expect(hexDistance(c, cube.get(next)!)).to.equal(1); + } + } + } + }); + + it("should pull clumping clouds away from the board edge", () => { + // Uniform random placement averages about 5.7 edge clouds on a + // 16-cloud board; this setup averages about 2.3. + let edgeClouds = 0; + for (let run = 0; run < 100; run++) { + const g = new CrosshairsGame(undefined, ["clumping-start"]); + edgeClouds += [...g.clouds].filter(cloud => isEdgeCell(g, cloud)).length; + } + expect(edgeClouds / 100).to.be.below(4); + }); + + it("should return a partial split board instead of throwing when cells run out", () => { + // With the edge excluded and the one-neighbour rule, 28 clouds + // exhausts the qualifying cells on almost every run. + for (let run = 0; run < 10; run++) { + const g = new CrosshairsGame(undefined, ["asymmetric-split-start", "unbounded-cloud-banks", "clouds-28"]); + expect(g.turnNumber).to.equal(1); + expect(g.moves()[0]).to.match(/^enter:/); + } }); for (const [setup, partialCount] of [ @@ -1730,6 +1888,23 @@ describe("Crosshairs", () => { expect(Array.isArray(p1s3)).to.be.true; expect((p1s3 as unknown[]).length).to.equal(4); // 3 wedges + 1 plane }); + + it("should colour sheet glyphs with paint rather than colour", () => { + const g = new CrosshairsGame(); + g.clouds.clear(); + g.clouds.add('d5'); + g.board.set('d5', [1, 'S', 3]); + g.board.set('e5', [2, 'N', 0]); + + const render = g.render(); + const glyphs = Object.values(render.legend!).flat() as Record[]; + const named = glyphs.filter(glyph => "name" in glyph); + expect(named.map(glyph => glyph.name)).to.include.members(["wedge", "plane", "cloud"]); + for (const glyph of named) { + expect(glyph, String(glyph.name)).to.not.have.property("colour"); + expect(glyph, String(glyph.name)).to.have.nested.property("paint.fill"); + } + }); }); describe("Game End", () => {