From 343954e619dcc75a5505165041e4fe1188c1cdb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:19:45 +0000 Subject: [PATCH] Apply designer's Crosshairs turbulence rule; add spread-cloud setups Turbulence now follows mrraow's clarified rule: after a manoeuvre's own movement and height change, the plane loses one height if it moved from a non-cloud hex into a cloud hex along the way, crashing if already at 0. Flying within a cloud bank costs nothing, placing a new plane is never penalised, and each swoop in a dive is resolved separately. This drops the optional fourth PlaneInfo field used by the earlier exemption. Clouds render darker when Turbulence is active, standing in for a storm-cloud glyph, and the 2026 rules PDF is linked. Two experimental setup variants, symmetric and asymmetric spread clouds, place clouds with the designer's coverage-first algorithm, which blocks open lines of fire before filling gaps. A symmetric board with a centre cloud ends one short of the target, as the designer intends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BK3bL3XekxLsqgRWgYBv7W --- locales/en/apgames.json | 10 +- locales/eo/apgames.json | 12 +- src/games/crosshairs.ts | 270 +++++++++++++++++----------------- test/games/crosshairs.test.ts | 152 +++++++++++++------ 4 files changed, 258 insertions(+), 186 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 725deca1..1fd21370 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -610,7 +610,7 @@ "circleOfLife": "Species are numbered 0–11 counterclockwise on the ring, starting with the single-hex species at the top. Species 0 eats 11; each other species eats the one numbered directly below it. Predation only occurs when you place a stone that forms a species adjacent to prey. Groups may not exceed four stones. The first player to capture 20 opponent stones wins, or the player who cannot place on their turn wins (having filled the ecosystem).", "clearpath": "Clear Path was designed by Mark Steere in September 2026, in collaboration with Claude Fable 5.1, an AI model made by Anthropic.\n\nThe vital points are marked on the board. A red dot marks a point that every possible Red connection passes through, a blue dot marks a point that every possible Blue connection passes through, and a blue dot inside a red dot marks a point vital to both players. A point vital only to your opponent is illegal for you unless your placement completes your connection.\n\nPassing is not allowed, but a player with no legal placement has their turn skipped. The pass is submitted automatically.\n\nThis implementation shortens the game by acknowledging that a player who no longer has any possible connection has lost by force.", "conspirateurs": "In the base game, pieces may move through sanctuaries as part of a multistep jump. They're not frozen until they end their move in a sanctuary. In the strict variant, pieces are frozen as soon as they enter a sanctuary.\n\nIn four-player games, players 1 and 2 are partnered, as are players 3 and 4.", - "crosshairs": "The published Turbulence rule is ambiguous about two points: it does not specify whether a maneuver's normal height change occurs before or after the Turbulence loss, nor what happens if the plane is already at height 0. This implementation interprets the rule by applying each maneuver's normal height change first, then applying one Turbulence loss for each cloud cell entered, in travel order. A plane at height 0 when a Turbulence loss would apply crashes immediately.\n\nTo prevent clouds on a starting edge from trapping every entering plane, Turbulence does not affect a plane until it has entered a non-cloud cell. Once it has done so, it remains subject to Turbulence. Every cell traversed during movement counts as entered, including the intermediate cell of a two-space level flight; a clear intermediate cell therefore removes the exemption before the plane reaches a later cloudy cell in the same maneuver. Each maneuver in a dive is resolved completely before the next, so a swoop that enters a non-cloud cell removes the exemption before the following swoop.", + "crosshairs": "Turbulence follows the designer's clarified rule: resolve the maneuver first (movement, turning, and its normal height change), then lose one height if the plane moved from a non-cloud hex into a cloud hex at any point during that maneuver. A plane already at height 0 when that loss applies crashes. Flying from cloud to cloud within the same bank costs nothing, and placing a new plane on a cloudy starting hex costs nothing. Every hex traversed counts, including the middle hex of a two-space level flight. Each swoop in a dive is a separate maneuver resolved before the next, so a dive can pay Turbulence more than once.", "deckfish": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com).\n\nThe suit powers (token movement abilities) are:\n* Moons: Jump orthogonally over gaps. \n* Suns: Move one or two cards diagonally. \n* Waves: Move orthogonally until the last unobstructed card. \n* Leaves: Move orthogonally onto any card with no neighboring obstacles.\n* Wyrms: Move orthogonally onto the first occupied card, bumping that piece to the next card.\n* Knots: Make three orthogonal one-card moves, without backtracking.\n\nIn the rules, the \"first\" player is the player who places second in the placement phase but moves first in the movement phase, but here the first player is the one who places first but moves second. This is implemented using an automatic pass for the appropriate player.\n\nThe rule is enforced that, even when the last player is already winning, he must continue playing until he is out of moves.", "elOso": "Setup is performed automatically when you launch the game. Sky, Cave, Ground, and Pit counts appear in the sidebar. After each move, use the frame controls to step through the Bear's response. Ten or more pieces in the Sky is a win.", "domineering": "Domineering, aka, Stop-Gate, is a partisan combinatorial game. The game was designed by Göran Andersson, c.1973, and was presented in the seminal CGT book *Winning Ways for Your Mathematical Plays*. There is an impartial version, called Cram, where players can both place horizontal or vertical dominoes. The variant \"Quelhas\" was proposed by Carlos P. Santos and Alfie Davies in 2025, being a misère version (the player without options wins the game) and using longer domino-like polyominoes.", @@ -1728,6 +1728,14 @@ "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." + }, + "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." + }, "unbounded-cloud-banks": { "name": "Unbounded cloud banks", "description": "Cloud banks may contain any number of clouds." diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index cdecb2d0..f3c836fe 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1302,6 +1302,14 @@ "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." + }, + "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." + }, "unbounded-cloud-banks": { "name": "Senlimaj nubaroj", "description": "Nubaroj povas enhavi ajnan nombron da nuboj." @@ -4575,7 +4583,9 @@ "catsdogs": "La origina ludo, *SNORT*, estis projektita de Simon Norton en 1970. Ĝia sola regulo estis malebligi, ke diverskoloraj ŝtonoj estu ortangule apudaj. Patigita ludanto malgajnas. Norton proponis ankaŭ la miserludan version (pato estas venko). Tamen spegula strategio solvas la originan ludon. Chris Huntoon proponis pli grandan tabulon 11×11, sur kiu la unua ludanto ne rajtas komenci en la centro (por malhelpi speguladon); li nomis ĝin *Cats and Dogs*, kaj ĝi estas la ĉefa ludo ĉi tie. Disponeblas ankaŭ la portugala turnira regularo sur la malpli granda tabulo 8×8: la unua ludanto devas komenci en la centra areo 2×2, kaj la dua devas respondi ekster tiu areo. Tio rompas la spegulan strategion kaj tamen permesas ludi sur la origina malpli granda tabulo. Vidu ankaŭ la artikolon pri Snort en [AGM#15](https://www.abstractgames.org/uploads/1/1/6/4/116462923/abstract_games_issue_15.pdf).", "chase": "Nuntempe la plimulto de interŝanĝaj movoj devas esti mane redaktitaj. Ni laboras por korekti tion.", "conspirateurs": "En la baza ludo, pecoj povas trairi rifuĝejojn kadre de plurpaŝa salto. Ili fiksiĝas nur kiam ili finas sian movon en rifuĝejo. En la strikta variaĵo, pecoj fiksiĝas tuj kiam ili eniras rifuĝejon.\n\nEn kvar-ludantaj partioj, ludantoj 1 kaj 2 estas partneroj, same kiel ludantoj 3 kaj 4.", - "crosshairs": "La publikigita regulo pri Turbulento estas ambigua pri du punktoj: ĝi ne specifas, ĉu la normala altoŝanĝo de manovro okazas antaŭ aŭ post la perdo pro Turbulento, nek kio okazas se la aviadilo jam estas je alto 0. Ĉi tiu realigo interpretas la regulon jene: unue aplikiĝas la normala altoŝanĝo de ĉiu manovro, kaj poste aplikiĝas unu perdo pro Turbulento por ĉiu nubĉelo, kiun la aviadilo eniras, laŭ la ordo de trairo. Aviadilo je alto 0, kiam perdo pro Turbulento aplikiĝus, tuj kraŝas.\n\nPor eviti, ke nuboj ĉe komenca rando kaptu ĉiun enirantan aviadilon, Turbulento ne efikas sur aviadilon antaŭ ol ĝi eniris sennuban ĉelon. Post tio ĝi restas submetita al Turbulento. Ĉiu ĉelo trairata dum movo kalkuliĝas kiel enirita, ankaŭ la meza ĉelo de duĉela samalta flugo; sennuba meza ĉelo do nuligas la sendevigon antaŭ ol la aviadilo atingas postan nuban ĉelon dum la sama manovro. Ĉiu manovro de plonĝo estas plene traktata antaŭ la sekva, do plonĝpaŝo eniranta sennuban ĉelon nuligas la sendevigon antaŭ la posta plonĝpaŝo.", + "crosshairs": "Turbulento sekvas la klarigitan regulon de la aŭtoro: unue oni plenumas la manovron (movon, turnon kaj ĝian normalan altoŝanĝon), kaj poste la aviadilo perdas unu alton, se ĝi iam dum tiu manovro moviĝis el sennuba seslatero en nuban seslateron. Aviadilo jam je alto 0, kiam tiu perdo aplikiĝas, kraŝas. Flugi de nubo al nubo ene de la sama nubaro kostas nenion, kaj meti novan aviadilon sur nuban komencan seslateron kostas nenion. Ĉiu trairata seslatero kalkuliĝas, ankaŭ la meza seslatero de duĉela samalta flugo. Ĉiu plonĝpaŝo estas aparta manovro, traktata antaŭ la sekva, do plonĝo povas pagi Turbulenton pli ol unu fojon.", + + "deckfish": "Pliaj informoj pri la sistemo Decktet troveblas ĉe la [oficiala retejo de Decktet](https://www.decktet.com).\n\nLa povoj de la emblemoj (kapabloj movi ludmarkon) estas:\n* Lunoj: saltu ortangule trans breĉojn.\n* Sunoj: movu unu aŭ du kartojn diagonale.\n* Ondoj: movu ortangule ĝis la lasta nebarita karto.\n* Folioj: movu ortangule sur ajnan karton sen najbaraj baroj.\n* Lindvormoj: movu ortangule sur la unuan okupatan karton, puŝante ties ludmarkon al la sekva karto.\n* Nodoj: faru tri ortajn movojn de po unu karto, sen retropaŝi.\n\nLaŭ la reguloj, la \"unua\" ludanto estas tiu, kiu metas due en la metfazo sed movas unue en la movfazo. Ĉi tie la unua ludanto estas tiu, kiu metas unue sed movas due. Tion realigas aŭtomata preterpaso por la koncerna ludanto.\n\nEstas plenumata ankaŭ la regulo, ke eĉ se la lasta ludanto jam venkas, li devas plu ludi ĝis mankas al li movoj.", "domineering": "Domineering, ankaŭ nomata Stop-Gate, estas partia kombineca ludo. Göran Andersson projektis ĝin ĉirkaŭ 1973, kaj ĝi aperis en la pionira libro pri kombineca ludteorio *Winning Ways for Your Mathematical Plays*. Ekzistas senpartia versio nomata Cram, en kiu ambaŭ ludantoj povas meti horizontalajn aŭ vertikalajn domenojn. La variaĵon \"Quelhas\" proponis Carlos P. Santos kaj Alfie Davies en 2025; ĝi estas miserluda versio (la ludanto sen ebla movo venkas) kaj uzas pli longajn, domenecajn poliominojn.", "emu": "Pliaj informoj pri la sistemo Decktet troveblas ĉe la [oficiala retejo de Decktet](https://www.decktet.com). La kartoj en la manoj de la ludantoj estas kaŝitaj de observantoj, kaj de kontraŭuloj ĝis la kartaro malpleniĝas. Tiam la ludantoj havas kompletan informon, do la manoj estas malkaŝitaj. Kartoj prenitaj el la forĵetamaso ankaŭ estas ĉiam videblaj al kontraŭuloj.", diff --git a/src/games/crosshairs.ts b/src/games/crosshairs.ts index 9fd5c3c3..a2023dd8 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -13,9 +13,8 @@ export type HexDir = "N" | "NE" | "SE" | "S" | "SW" | "NW"; // Internal directions used by HexTriGraph (flat-top hex) type InternalDir = "NE" | "E" | "SE" | "SW" | "W" | "NW"; -// A plane has owner, direction, height, and whether it has ever entered a -// non-cloud cell. The last field is optional for compatibility with old saves. -export type PlaneInfo = [playerid, HexDir, number, boolean?]; +// A plane has owner, direction, and height +export type PlaneInfo = [playerid, HexDir, number]; const allDirections: HexDir[] = ["N", "NE", "SE", "S", "SW", "NW"]; @@ -130,6 +129,7 @@ export class CrosshairsGame extends GameBase { description: "apgames:descriptions.crosshairs", urls: [ "http://mrraow.com/uploads/MyDesigns/Crosshairs2020.pdf", + "http://mrraow.com/uploads/MyDesigns/Crosshairs_rules_2026.pdf", ], bggid: "78093", people: [ @@ -156,6 +156,16 @@ export class CrosshairsGame extends GameBase { uid: "asymmetric-random-start", group: "setup", }, + { + uid: "spread-start", + group: "setup", + experimental: true, + }, + { + uid: "asymmetric-spread-start", + group: "setup", + experimental: true, + }, { uid: "unbounded-cloud-banks", }, @@ -214,6 +224,10 @@ 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); } if (placedClouds !== undefined) { for (const cell of placedClouds) { @@ -320,6 +334,61 @@ 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]]; + } + + 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++; + } + return blocked; + }; + + for (let pass = 0; pass <= allDirections.length && clouds.size < target; pass++) { + 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; + + // 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); + } + if (!legal) { + for (const c of placed) clouds.delete(c); + } + } + } + return Array.from(clouds); + } + // 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[] { @@ -499,35 +568,32 @@ export class CrosshairsGame extends GameBase { return new Map(Array.from(board, ([k, v]) => [k, [...v] as PlaneInfo])); } - // Resolve effects caused by entering cells after the manoeuvre's intrinsic - // height change has already been applied. Cells must be in travel order so - // a two-space level flight can enter several cells. Eligibility changes as - // soon as any clear cell is entered, including an intermediate level-flight - // cell. Dives call this once per swoop, allowing the same transition between - // manoeuvres. Missing flags come from old saved states and retain the earlier - // behaviour by defaulting to turbulence-eligible. + // Resolve turbulence for one manoeuvre after its intrinsic movement and + // height change have been applied. Per the designer, turbulence costs one + // height when the plane moves from a non-cloud cell into a cloud cell; a + // plane already inside a cloud bank pays nothing for staying in it. Cells + // must be in travel order. Dives call this once per swoop. Placing a new + // plane is not flying, so the enter action never calls this. private resolveCloudEntry( + fromCell: string, enteredCells: string[], heightAfterManeuver: number, - hasEnteredClearCell = true, - ): { heightAfterEntry: number; hasEnteredClearCell: boolean; crashCell?: string } { + ): { heightAfterEntry: number; crashCell?: string } { + if (!this.variants.includes("turbulence")) { + return { heightAfterEntry: heightAfterManeuver }; + } let heightAfterEntry = heightAfterManeuver; + let prev = fromCell; for (const cell of enteredCells) { - if (!this.clouds.has(cell)) { - hasEnteredClearCell = true; - continue; - } - - if (this.variants.includes("turbulence") && hasEnteredClearCell) { - // Each cloud is a separate trigger. Stop at the first one that - // would make an already-ground-level plane lose another height. + if (this.clouds.has(cell) && !this.clouds.has(prev)) { if (heightAfterEntry <= 0) { - return { heightAfterEntry: 0, hasEnteredClearCell, crashCell: cell }; + return { heightAfterEntry: 0, crashCell: cell }; } heightAfterEntry--; } + prev = cell; } - return { heightAfterEntry, hasEnteredClearCell }; + return { heightAfterEntry }; } // Check if we're in cloud placement phase @@ -809,7 +875,7 @@ export class CrosshairsGame extends GameBase { // Generate all possible moves for a single plane // Optional board parameter for checking against a specific board state (e.g., after partial moves) private getPlaneMovements(cell: string, info: PlaneInfo, board?: Map): string[] { - const [, dir, height, hasEnteredClearCell = true] = info; + const [, dir, height] = info; const moves: string[] = []; // (a) Climb: +1 height (capped at 6), move 1 forward, optional 60° turn @@ -843,7 +909,7 @@ export class CrosshairsGame extends GameBase { // (c) Dive: series of swoops and power dives // This is complex - we generate all possible dive sequences - const diveSequences = this.generateDiveSequences(cell, height, dir, hasEnteredClearCell, board); + const diveSequences = this.generateDiveSequences(cell, height, dir, board); for (const seq of diveSequences) { moves.push(`${cell}v${seq}`); } @@ -857,13 +923,7 @@ export class CrosshairsGame extends GameBase { } // Generate all possible dive sequences from a position - private generateDiveSequences( - cell: string, - height: number, - dir: HexDir, - hasEnteredClearCell: boolean, - board?: Map, - ): string[] { + private generateDiveSequences(cell: string, height: number, dir: HexDir, board?: Map): string[] { if (height === 0) return []; const sequences: string[] = []; @@ -871,13 +931,7 @@ export class CrosshairsGame extends GameBase { // Each recursive call receives the fully resolved state after one // manoeuvre. Dive steps are separated by ">" (not comma, which // separates actions in a turn). - const generate = ( - currentCell: string, - currentHeight: number, - currentDir: HexDir, - currentHasEnteredClearCell: boolean, - path: string, - ) => { + const generate = (currentCell: string, currentHeight: number, currentDir: HexDir, path: string) => { // A dive must lose height, so we must do at least one maneuver if (path !== "") { // Add current state as a valid sequence (can stop diving at any point) @@ -890,22 +944,14 @@ export class CrosshairsGame extends GameBase { if (forward !== undefined) { // Resolve cloud entry after applying the swoop's normal // one-height loss. Power dives do not enter a new cell. - const cloudEntry = this.resolveCloudEntry( - [forward], currentHeight - 1, currentHasEnteredClearCell, - ); + const cloudEntry = this.resolveCloudEntry(currentCell, [forward], currentHeight - 1); const continueSwoop = (newDir: HexDir, newPath: string) => { if (cloudEntry.crashCell !== undefined) { // A crash is a valid terminal dive sequence, but no // later swoops or power dives may follow it. sequences.push(newPath); } else { - generate( - forward, - cloudEntry.heightAfterEntry, - newDir, - cloudEntry.hasEnteredClearCell, - newPath, - ); + generate(forward, cloudEntry.heightAfterEntry, newDir, newPath); } }; @@ -921,24 +967,15 @@ export class CrosshairsGame extends GameBase { if (currentHeight >= 2) { const newHeight = currentHeight - 2; // Keep direction - generate( - currentCell, newHeight, currentDir, currentHasEnteredClearCell, - path === "" ? `P` : `${path}>P`, - ); + generate(currentCell, newHeight, currentDir, path === "" ? `P` : `${path}>P`); // Turn const [left, right] = adjacentDirs(currentDir); - generate( - currentCell, newHeight, left, currentHasEnteredClearCell, - path === "" ? `P/${left}` : `${path}>P/${left}`, - ); - generate( - currentCell, newHeight, right, currentHasEnteredClearCell, - path === "" ? `P/${right}` : `${path}>P/${right}`, - ); + generate(currentCell, newHeight, left, path === "" ? `P/${left}` : `${path}>P/${left}`); + generate(currentCell, newHeight, right, path === "" ? `P/${right}` : `${path}>P/${right}`); } }; - generate(cell, height, dir, hasEnteredClearCell, ""); + generate(cell, height, dir, ""); return sequences; } @@ -1207,55 +1244,38 @@ export class CrosshairsGame extends GameBase { private applyActionToBoard(action: string, board: Map): void { const parsed = this.parseMove(action); if (parsed.type === "enter") { - const cell = parsed.cell!; - // A new plane is exempt until it has occupied a clear cell. - const cloudEntry = this.resolveCloudEntry([cell], 0, false); - if (cloudEntry.crashCell === undefined) { - board.set(cell, [ - this.currplayer, - parsed.dir!, - cloudEntry.heightAfterEntry, - cloudEntry.hasEnteredClearCell, - ]); - } + board.set(parsed.cell!, [this.currplayer, parsed.dir!, 0]); } else if (parsed.type === "move") { const fromCell = parsed.cell!; const info = board.get(fromCell); if (!info) return; - const [owner, currentDir, currentHeight, hasEnteredClearCell = true] = info; + const [owner, currentDir, currentHeight] = info; if (parsed.moveType === "crash") { board.delete(fromCell); } else if (parsed.moveType === "climb") { const newDir = parsed.dir || currentDir; - const cloudEntry = this.resolveCloudEntry( - [parsed.target!], Math.min(currentHeight + 1, 6), hasEnteredClearCell, - ); + const cloudEntry = this.resolveCloudEntry(fromCell, [parsed.target!], Math.min(currentHeight + 1, 6)); board.delete(fromCell); if (cloudEntry.crashCell === undefined) { - board.set(parsed.target!, [ - owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, - ]); + board.set(parsed.target!, [owner, newDir, cloudEntry.heightAfterEntry]); } } else if (parsed.moveType === "level") { const newDir = parsed.dir || currentDir; const oneAhead = this.moveForward(fromCell, currentDir, board); // Preserve travel order because both cells of a two-space move - // can independently trigger cloud-entry effects. + // can independently trigger turbulence. const enteredCells = oneAhead === parsed.target ? [parsed.target!] : [oneAhead!, parsed.target!]; - const cloudEntry = this.resolveCloudEntry(enteredCells, currentHeight, hasEnteredClearCell); + const cloudEntry = this.resolveCloudEntry(fromCell, enteredCells, currentHeight); board.delete(fromCell); if (cloudEntry.crashCell === undefined) { - board.set(parsed.target!, [ - owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, - ]); + board.set(parsed.target!, [owner, newDir, cloudEntry.heightAfterEntry]); } } else if (parsed.moveType === "dive") { // Process full dive sequence let cell = fromCell; let height = currentHeight; let dir = currentDir; - let enteredClearCell = hasEnteredClearCell; const sequence = parsed.diveSequence || ""; const steps = sequence.split(">"); for (const step of steps) { @@ -1270,6 +1290,7 @@ export class CrosshairsGame extends GameBase { const parts = step.split("/"); const nextCell = parts[0].toLowerCase(); if (nextCell && nextCell.length > 0) { + const swoopFrom = cell; board.delete(cell); cell = nextCell; height--; @@ -1277,18 +1298,17 @@ export class CrosshairsGame extends GameBase { const d = parts[1].toUpperCase() as HexDir; if (allDirections.includes(d)) dir = d; } - const cloudEntry = this.resolveCloudEntry([cell], height, enteredClearCell); + const cloudEntry = this.resolveCloudEntry(swoopFrom, [cell], height); if (cloudEntry.crashCell !== undefined) { board.delete(fromCell); return; } height = cloudEntry.heightAfterEntry; - enteredClearCell = cloudEntry.hasEnteredClearCell; } } } if (fromCell !== cell) board.delete(fromCell); - board.set(cell, [owner, dir, height, enteredClearCell]); + board.set(cell, [owner, dir, height]); } } } @@ -1305,7 +1325,7 @@ export class CrosshairsGame extends GameBase { const fromCell = parsed.cell!; const info = boardBefore.get(fromCell); if (!info) return [{ action, newPassed: new Set(passedPlanes) }]; - const [owner, currentDir, currentHeight, hasEnteredClearCell = true] = info; + const [owner, currentDir, currentHeight] = info; const sequence = parsed.diveSequence || ""; const steps = sequence.split(">"); @@ -1318,7 +1338,6 @@ export class CrosshairsGame extends GameBase { cell: string; height: number; dir: HexDir; - hasEnteredClearCell: boolean; crashed: boolean; }; @@ -1329,7 +1348,6 @@ export class CrosshairsGame extends GameBase { cell: fromCell, height: currentHeight, dir: currentDir, - hasEnteredClearCell, crashed: false, }]; @@ -1345,7 +1363,6 @@ export class CrosshairsGame extends GameBase { let newCell = v.cell; let newHeight = v.height; let newDir = v.dir; - let newHasEnteredClearCell = v.hasEnteredClearCell; let crashed = false; const stepLower = step.toLowerCase(); @@ -1357,7 +1374,7 @@ export class CrosshairsGame extends GameBase { if (allDirections.includes(d)) newDir = d; } // Update board: plane stays in same cell with new height/dir - newBoard.set(newCell, [owner, newDir, newHeight, newHasEnteredClearCell]); + newBoard.set(newCell, [owner, newDir, newHeight]); } else { const parts = stepLower.split("/"); const targetCell = parts[0]; @@ -1375,16 +1392,13 @@ export class CrosshairsGame extends GameBase { const d = parts[1].toUpperCase() as HexDir; if (allDirections.includes(d)) newDir = d; } - const cloudEntry = this.resolveCloudEntry( - [newCell], newHeight, newHasEnteredClearCell, - ); + const cloudEntry = this.resolveCloudEntry(v.cell, [newCell], newHeight); newHeight = cloudEntry.heightAfterEntry; - newHasEnteredClearCell = cloudEntry.hasEnteredClearCell; crashed = cloudEntry.crashCell !== undefined; if (crashed) { newBoard.delete(newCell); } else { - newBoard.set(newCell, [owner, newDir, newHeight, newHasEnteredClearCell]); + newBoard.set(newCell, [owner, newDir, newHeight]); } } } @@ -1411,7 +1425,6 @@ export class CrosshairsGame extends GameBase { cell: newCell, height: newHeight, dir: newDir, - hasEnteredClearCell: newHasEnteredClearCell, crashed, }); } else { @@ -1428,7 +1441,6 @@ export class CrosshairsGame extends GameBase { cell: newCell, height: newHeight, dir: newDir, - hasEnteredClearCell: newHasEnteredClearCell, crashed, }); } @@ -1512,9 +1524,7 @@ export class CrosshairsGame extends GameBase { private boardFingerprint(board: Map, planesRemaining: [number, number]): string { const entries: string[] = []; for (const [cell, info] of board) { - // Eligibility is part of the game state: otherwise two positions - // that react differently to a future cloud could be deduplicated. - entries.push(`${cell}:${info[0]},${info[1]},${info[2]},${info[3] ?? true}`); + entries.push(`${cell}:${info[0]},${info[1]},${info[2]}`); } entries.sort(); return `${entries.join(";")}|${planesRemaining[0]},${planesRemaining[1]}`; @@ -2366,25 +2376,14 @@ export class CrosshairsGame extends GameBase { if (!startingHexes.includes(cell)) { error = inv(action); break; } if (!allDirections.includes(dir)) { error = inv(action); break; } } - // A newly entered plane is protected from turbulence until it - // has occupied a non-cloud cell. - const cloudEntry = this.resolveCloudEntry([cell], 0, false); - if (cloudEntry.crashCell === undefined) { - board.set(cell, [ - this.currplayer, - dir, - cloudEntry.heightAfterEntry, - cloudEntry.hasEnteredClearCell, - ]); - } + // Placing a plane is not flying into a cell, so turbulence + // never applies to the enter action. + board.set(cell, [this.currplayer, dir, 0]); planesRemaining[this.currplayer - 1]--; movedPlanes.add(cell); if (generateResults) { results.push({ type: "place", what: "plane", where: cell }); results.push({ type: "orient", where: cell, facing: dir }); - if (cloudEntry.crashCell !== undefined) { - results.push({ type: "destroy", what: "plane", where: cloudEntry.crashCell }); - } } if (shootTargets.length > 0) { const shotErr = applyShots(shootTargets, board, rawAction); @@ -2404,7 +2403,7 @@ export class CrosshairsGame extends GameBase { partialState = { type: "next_action" }; continue; } - const [owner, currentDir, currentHeight, hasEnteredClearCell = true] = info; + const [owner, currentDir, currentHeight] = info; if (parsed.moveType === "crash") { if (validate) { @@ -2459,16 +2458,11 @@ export class CrosshairsGame extends GameBase { if (!isOneAhead(fromCell, currentDir, toCell)) { error = inv(action); break; } if (!validateDir(parsed.dir, currentDir, action)) break; } - // The climb is resolved before effects caused by entering - // the destination cell. - const cloudEntry = this.resolveCloudEntry( - [toCell], Math.min(currentHeight + 1, 6), hasEnteredClearCell, - ); + // The climb's height gain is applied before turbulence. + const cloudEntry = this.resolveCloudEntry(fromCell, [toCell], Math.min(currentHeight + 1, 6)); board.delete(fromCell); if (cloudEntry.crashCell === undefined) { - board.set(toCell, [ - owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, - ]); + board.set(toCell, [owner, newDir, cloudEntry.heightAfterEntry]); } movedPlanes.add(toCell); if (generateResults) { @@ -2532,12 +2526,10 @@ export class CrosshairsGame extends GameBase { // Keep both entered cells in travel order: turbulence may // apply twice, and a crash can occur in the first cell. const enteredCells = oneAhead === toCell ? [toCell] : [oneAhead, toCell]; - const cloudEntry = this.resolveCloudEntry(enteredCells, currentHeight, hasEnteredClearCell); + const cloudEntry = this.resolveCloudEntry(fromCell, enteredCells, currentHeight); board.delete(fromCell); if (cloudEntry.crashCell === undefined) { - board.set(toCell, [ - owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, - ]); + board.set(toCell, [owner, newDir, cloudEntry.heightAfterEntry]); } movedPlanes.add(cloudEntry.crashCell ?? toCell); if (generateResults) { @@ -2598,7 +2590,6 @@ export class CrosshairsGame extends GameBase { let cell = fromCell; let height = currentHeight; let dir = currentDir; - let enteredClearCell = hasEnteredClearCell; let prevCell = fromCell; // track previous cell for board cleanup let diveIsPartial = false; let crashed = false; @@ -2652,10 +2643,9 @@ export class CrosshairsGame extends GameBase { } // Resolve each swoop separately so a dive can - // suffer turbulence from several clouds. - const cloudEntry = this.resolveCloudEntry([cell], height, enteredClearCell); + // suffer turbulence from several cloud banks. + const cloudEntry = this.resolveCloudEntry(prevCell, [cell], height); height = cloudEntry.heightAfterEntry; - enteredClearCell = cloudEntry.hasEnteredClearCell; crashed = cloudEntry.crashCell !== undefined; } } @@ -2675,7 +2665,7 @@ export class CrosshairsGame extends GameBase { results.push({ type: "destroy", what: "plane", where: cell }); } } else { - board.set(cell, [owner, dir, height, enteredClearCell]); + board.set(cell, [owner, dir, height]); } prevCell = cell; @@ -2700,7 +2690,7 @@ export class CrosshairsGame extends GameBase { board.delete(fromCell); } if (!crashed) { - board.set(cell, [owner, dir, height, enteredClearCell]); + board.set(cell, [owner, dir, height]); } movedPlanes.add(crashed ? fromCell : cell); @@ -3295,17 +3285,19 @@ export class CrosshairsGame extends GameBase { }; // Cloud glyph (defined first so we can use it in plane legends) - // Abstract mode: white disc + // Abstract mode: disc // Regular mode: fluffy cloud shape + // Turbulent clouds are drawn darker to stand in for a storm-cloud glyph. + const cloudColour = this.variants.includes("turbulence") ? "#999999" : "#ffffff"; const cloudGlyph: Glyph = abstractMode ? { name: "piece", - colour: "#ffffff", + colour: cloudColour, opacity: 0.5, } : { name: "cloud", - colour: "#ffffff", + colour: cloudColour, opacity: 0.7, scale: 1.5, orientation: "vertical", diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index df1b87e0..a78cda98 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -186,6 +186,8 @@ describe("Crosshairs", () => { "#setup", "random-start", "asymmetric-random-start", + "spread-start", + "asymmetric-spread-start", ]); expect(setupVariants.find(variant => variant.uid === "random-start")!.name) .to.equal("Symmetric random clouds"); @@ -256,6 +258,35 @@ 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); + + 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); + for (const cloud of g.clouds) { + expect(g.clouds.has(g.graph.rot180(cloud))).to.be.true; + } + } else { + expect(g.clouds.size).to.equal(target); + } + }); + } + } + + 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"]); + + expect(g.clouds.size).to.equal(28); + expect(g.turnNumber).to.equal(1); + }); + for (const [setup, partialCount] of [ ["random-start", 4], ["asymmetric-random-start", 5], @@ -1262,25 +1293,33 @@ describe("Crosshairs", () => { }); describe("Optional Rules Variants", () => { - const makeFlightGame = ( - height: number, - clouds: string[], - hasEnteredClearCell = true, - variants = ["turbulence"], - ): CrosshairsGame => { + const makeFlightGame = (height: number, clouds: string[], variants = ["turbulence"]): CrosshairsGame => { const g = new CrosshairsGame(undefined, variants); g.clouds.clear(); for (const cloud of clouds) g.clouds.add(cloud); g.turnNumber = 10; g.currplayer = 1; g.board.clear(); - g.board.set("f5", [1, "S", height, hasEnteredClearCell]); - g.board.set("k5", [2, "N", 3, true]); + g.board.set("f5", [1, "S", height]); + g.board.set("k5", [2, "N", 3]); g.planesRemaining = [0, 0]; (g as unknown as { saveState: () => void }).saveState(); return g; }; + const makeEntryGame = (clouds: string[]): { g: CrosshairsGame; entryCell: string } => { + const g = new CrosshairsGame(undefined, ["turbulence"]); + const entryCell = g.graph.getEdges().get("S")![0]; + g.clouds.clear(); + for (const cloud of clouds) g.clouds.add(cloud); + g.turnNumber = 1; + g.currplayer = 1; + g.board.clear(); + g.planesRemaining = [1, 1]; + (g as unknown as { saveState: () => void }).saveState(); + return { g, entryCell }; + }; + const shootablePlanes = (g: CrosshairsGame): string[] => (g as unknown as { getShootablePlanes: (p: number, b: Map) => string[]; @@ -1308,79 +1347,102 @@ describe("Crosshairs", () => { g.move("f5+f6"); - expect(g.board.get("f6")).to.deep.equal([1, "S", 0, true]); + expect(g.board.get("f6")).to.deep.equal([1, "S", 0]); }); - it("should exempt a plane entered directly into a cloud from turbulence", () => { - const g = new CrosshairsGame(undefined, ["turbulence"]); - const entryCell = g.graph.getEdges().get("S")![0]; - g.clouds.clear(); + it("should never apply turbulence to placing a plane into a cloud", () => { + const { g, entryCell } = makeEntryGame([]); g.clouds.add(entryCell); - g.turnNumber = 1; - g.currplayer = 1; - g.board.clear(); - g.planesRemaining = [1, 1]; - (g as unknown as { saveState: () => void }).saveState(); g.move(`enter:${entryCell}/N`); - expect(g.board.get(entryCell)).to.deep.equal([1, "N", 0, false]); + expect(g.board.get(entryCell)).to.deep.equal([1, "N", 0]); expect(g.results).not.to.deep.include({ type: "destroy", what: "plane", where: entryCell }); }); - it("should keep a plane exempt while successive dive manoeuvres end in clouds", () => { - const g = makeFlightGame(3, ["f5", "f6", "f7"], false); + it("should store no extra plane state after entering", () => { + const { g, entryCell } = makeEntryGame([]); - g.move("f5vf6>f7"); + g.move(`enter:${entryCell}/N`); - // Only the two normal swoop losses apply; neither cloud activates turbulence. - expect(g.board.get("f7")).to.deep.equal([1, "S", 1, false]); + expect(new CrosshairsGame(g.serialize()).board.get(entryCell)).to.have.lengthOf(3); }); - it("should activate turbulence mid-dive after a swoop ends in a clear cell", () => { - const g = makeFlightGame(5, ["f5", "f7"], false); + it("should only apply turbulence when moving from a non-cloud cell into a cloud", () => { + // Level flight within one cloud bank costs nothing. + const inside = makeFlightGame(0, ["f5", "f6"]); + inside.move("f5-f6"); + expect(inside.board.get("f6")).to.deep.equal([1, "S", 0]); - g.move("f5vf6>f7"); - - // The first swoop ends on clear f6 and expires the exemption. The - // second swoop therefore loses one height normally and one at f7. - expect(g.board.get("f7")).to.deep.equal([1, "S", 2, true]); - expect(new CrosshairsGame(g.serialize()).board.get("f7")![3]).to.be.true; + // The same flight from a clear cell into the cloud at height 0 crashes. + const entering = makeFlightGame(0, ["f6"]); + entering.move("f5-f6"); + expect(entering.board.has("f6")).to.be.false; + expect(entering.results).to.deep.include({ type: "destroy", what: "plane", where: "f6" }); }); - it("should end the exemption at an intermediate clear cell during level flight", () => { - const g = makeFlightGame(3, ["f5", "f7"], false); + it("should keep a plane in a cloud bank at the same height through a two-space level flight", () => { + const g = makeFlightGame(3, ["f5", "f6", "f7"]); g.move("f5-f7"); - // Crossing clear f6 ends the exemption before the plane enters - // cloudy f7 later in the same manoeuvre. - expect(g.board.get("f7")).to.deep.equal([1, "S", 2, true]); + expect(g.board.get("f7")).to.deep.equal([1, "S", 3]); }); - it("should apply turbulence for both clouds crossed in a two-space level flight", () => { + it("should apply turbulence once for entering a cloud bank and staying in it", () => { const g = makeFlightGame(3, ["f6", "f7"]); g.move("f5-f7"); - expect(g.board.get("f7")).to.deep.equal([1, "S", 1, true]); + expect(g.board.get("f7")).to.deep.equal([1, "S", 2]); + }); + + it("should apply turbulence once for passing through a cloud back into clear air", () => { + const g = makeFlightGame(3, ["f6"]); + + g.move("f5-f7"); + + expect(g.board.get("f7")).to.deep.equal([1, "S", 2]); }); - it("should crash at the second turbulent cloud when the first loss leaves height 0", () => { - const g = makeFlightGame(1, ["f6", "f7"]); + it("should crash a height-0 plane on a two-space level flight through a cloud", () => { + const g = makeFlightGame(0, ["f6"]); g.move("f5-f7"); expect(g.board.has("f7")).to.be.false; - expect(g.results).to.deep.include({ type: "destroy", what: "plane", where: "f7" }); + expect(g.board.has("f6")).to.be.false; + expect(g.results).to.deep.include({ type: "destroy", what: "plane", where: "f6" }); }); - it("should apply turbulence at every cloud entered during a dive", () => { + it("should not apply turbulence to swoops that stay within a cloud bank", () => { + const g = makeFlightGame(3, ["f5", "f6", "f7"]); + + g.move("f5vf6>f7"); + + expect(g.board.get("f7")).to.deep.equal([1, "S", 1]); + }); + + it("should apply turbulence once per swoop that enters a cloud bank", () => { + // Clear -> cloud (-1 swoop, -1 turbulence) -> cloud (-1 swoop only). const g = makeFlightGame(5, ["f6", "f7"]); g.move("f5vf6>f7"); - expect(g.board.get("f7")).to.deep.equal([1, "S", 1, true]); + expect(g.board.get("f7")).to.deep.equal([1, "S", 2]); + }); + + it("should resolve each swoop before allowing the next", () => { + // Clear -> cloud f6 -> clear f7 -> cloud f8 from height 3: after two + // swoops the plane is at 0, so it cannot swoop into f8 at all. + const g = makeFlightGame(3, ["f6", "f8"]); + + expect(g.actions()).to.include("f5vf6>f7"); + expect(g.actions().some(action => action.startsWith("f5vf6>f7>"))).to.be.false; + expect(g.validateMove("f5vf6>f7>f8").valid).to.be.false; + g.move("f5vf6>f7"); + + expect(g.board.get("f7")).to.deep.equal([1, "S", 0]); }); it("should crash immediately during a dive and reject later manoeuvres", () => { @@ -1400,7 +1462,7 @@ describe("Crosshairs", () => { g.move("f5vP"); - expect(g.board.get("f5")).to.deep.equal([1, "S", 2, true]); + expect(g.board.get("f5")).to.deep.equal([1, "S", 2]); }); it("should let planes shoot out of clouds with concealed fire", () => {