From 1706c4a62e2119f66ccf6b6a4387f619383222ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:09:52 +0000 Subject: [PATCH] Add Crosshairs turbulence and concealed fire variants Turbulence: entering a cloud costs an additional height, applied after the maneuver's normal height change; a plane already at height 0 crashes instead. Planes are exempt until they first occupy a non-cloud cell, so clouds on a starting hex can't trap every entering plane. Concealed fire: planes may shoot from inside a cloud, though shots still cannot enter or pass through cloud cells. Both variants are marked experimental pending dev-server testing before they're exposed in production. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BK3bL3XekxLsqgRWgYBv7W --- locales/en/apgames.json | 9 + locales/eo/apgames.json | 9 + src/games/crosshairs.ts | 301 ++++++++++++++++++++++++++++------ test/games/crosshairs.test.ts | 172 +++++++++++++++++++ 4 files changed, 442 insertions(+), 49 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 3ffc43f1..3d63f29e 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -595,6 +595,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.", "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.", @@ -1707,6 +1708,14 @@ "unbounded-cloud-banks": { "name": "Unbounded cloud banks", "description": "Cloud banks may contain any number of clouds." + }, + "turbulence": { + "name": "Turbulence", + "description": "Each time a plane enters a cloud, it loses 1 height." + }, + "concealed-fire": { + "name": "Concealed fire", + "description": "Planes may shoot out of clouds, but still cannot shoot into or through them." } }, "crossway": { diff --git a/locales/eo/apgames.json b/locales/eo/apgames.json index 98d5acaa..01b3fa2a 100644 --- a/locales/eo/apgames.json +++ b/locales/eo/apgames.json @@ -1305,6 +1305,14 @@ "unbounded-cloud-banks": { "name": "Senlimaj nubaroj", "description": "Nubaroj povas enhavi ajnan nombron da nuboj." + }, + "turbulence": { + "name": "Turbulento", + "description": "Ĉiufoje kiam aviadilo eniras nubon, ĝia alto malpliiĝas je 1." + }, + "concealed-fire": { + "name": "Kaŝita pafado", + "description": "Aviadiloj povas pafi el nuboj, sed ankoraŭ ne povas pafi en aŭ tra ili." } }, "cubeo": { @@ -4518,6 +4526,7 @@ "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.", "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 cee1957a..9fd5c3c3 100644 --- a/src/games/crosshairs.ts +++ b/src/games/crosshairs.ts @@ -13,8 +13,9 @@ 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, and height -export type PlaneInfo = [playerid, HexDir, number]; +// 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?]; const allDirections: HexDir[] = ["N", "NE", "SE", "S", "SW", "NW"]; @@ -158,6 +159,14 @@ export class CrosshairsGame extends GameBase { { uid: "unbounded-cloud-banks", }, + { + uid: "turbulence", + experimental: true, + }, + { + uid: "concealed-fire", + experimental: true, + }, { uid: "#clouds" }, { uid: "clouds-22", group: "clouds" }, { uid: "clouds-28", group: "clouds" }, @@ -490,6 +499,37 @@ 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. + private resolveCloudEntry( + enteredCells: string[], + heightAfterManeuver: number, + hasEnteredClearCell = true, + ): { heightAfterEntry: number; hasEnteredClearCell: boolean; crashCell?: string } { + let heightAfterEntry = heightAfterManeuver; + 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 (heightAfterEntry <= 0) { + return { heightAfterEntry: 0, hasEnteredClearCell, crashCell: cell }; + } + heightAfterEntry--; + } + } + return { heightAfterEntry, hasEnteredClearCell }; + } + // Check if we're in cloud placement phase private inCloudPhase(): boolean { return this.turnNumber === 0 && this.clouds.size < this.getTargetCloudCount(); @@ -553,8 +593,9 @@ export class CrosshairsGame extends GameBase { // Get line of fire from a plane (cells it can shoot) private getLineOfFire(cell: string, dir: HexDir, board?: Map): string[] { - // Can't shoot out of clouds - if (this.clouds.has(cell)) return []; + // Concealed fire permits shooting out of clouds, but clouds still block + // shots into or through their cells below. + if (this.clouds.has(cell) && !this.variants.includes("concealed-fire")) return []; const checkBoard = board ?? this.board; const [x, y] = this.graph.algebraic2coords(cell); @@ -768,7 +809,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] = info; + const [, dir, height, hasEnteredClearCell = true] = info; const moves: string[] = []; // (a) Climb: +1 height (capped at 6), move 1 forward, optional 60° turn @@ -802,7 +843,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, board); + const diveSequences = this.generateDiveSequences(cell, height, dir, hasEnteredClearCell, board); for (const seq of diveSequences) { moves.push(`${cell}v${seq}`); } @@ -816,14 +857,27 @@ export class CrosshairsGame extends GameBase { } // Generate all possible dive sequences from a position - private generateDiveSequences(cell: string, height: number, dir: HexDir, board?: Map): string[] { + private generateDiveSequences( + cell: string, + height: number, + dir: HexDir, + hasEnteredClearCell: boolean, + board?: Map, + ): string[] { if (height === 0) return []; const sequences: string[] = []; - // Helper to generate sequences recursively - // Dive steps are separated by ">" (not comma, which separates actions in a turn) - const generate = (currentCell: string, currentHeight: number, currentDir: HexDir, path: string) => { + // 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, + ) => { // 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) @@ -834,14 +888,32 @@ export class CrosshairsGame extends GameBase { if (currentHeight >= 1) { const forward = this.moveForward(currentCell, currentDir, board); if (forward !== undefined) { - const newHeight = currentHeight - 1; - // Keep direction - const newPath = path === "" ? `${forward}` : `${path}>${forward}`; - generate(forward, newHeight, currentDir, newPath); - // Turn left or right + // 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 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, + ); + } + }; + + // Keep direction or turn left or right. + continueSwoop(currentDir, path === "" ? `${forward}` : `${path}>${forward}`); const [left, right] = adjacentDirs(currentDir); - generate(forward, newHeight, left, path === "" ? `${forward}/${left}` : `${path}>${forward}/${left}`); - generate(forward, newHeight, right, path === "" ? `${forward}/${right}` : `${path}>${forward}/${right}`); + continueSwoop(left, path === "" ? `${forward}/${left}` : `${path}>${forward}/${left}`); + continueSwoop(right, path === "" ? `${forward}/${right}` : `${path}>${forward}/${right}`); } } @@ -849,15 +921,24 @@ export class CrosshairsGame extends GameBase { if (currentHeight >= 2) { const newHeight = currentHeight - 2; // Keep direction - generate(currentCell, newHeight, currentDir, path === "" ? `P` : `${path}>P`); + generate( + currentCell, newHeight, currentDir, currentHasEnteredClearCell, + path === "" ? `P` : `${path}>P`, + ); // Turn const [left, right] = adjacentDirs(currentDir); - generate(currentCell, newHeight, left, path === "" ? `P/${left}` : `${path}>P/${left}`); - generate(currentCell, newHeight, right, path === "" ? `P/${right}` : `${path}>P/${right}`); + generate( + currentCell, newHeight, left, currentHasEnteredClearCell, + path === "" ? `P/${left}` : `${path}>P/${left}`, + ); + generate( + currentCell, newHeight, right, currentHasEnteredClearCell, + path === "" ? `P/${right}` : `${path}>P/${right}`, + ); } }; - generate(cell, height, dir, ""); + generate(cell, height, dir, hasEnteredClearCell, ""); return sequences; } @@ -1121,33 +1202,60 @@ export class CrosshairsGame extends GameBase { } // Apply a single bare action (no shot notation) to a board in-place. - // Lightweight version of applyActions for just board mutation. + // This lightweight simulation is used while expanding move-and-shoot + // choices, so its cloud-entry effects must match applyActions exactly. private applyActionToBoard(action: string, board: Map): void { const parsed = this.parseMove(action); if (parsed.type === "enter") { - board.set(parsed.cell!, [this.currplayer, parsed.dir!, 0]); + 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, + ]); + } } else if (parsed.type === "move") { const fromCell = parsed.cell!; const info = board.get(fromCell); if (!info) return; - const [owner, currentDir, currentHeight] = info; + const [owner, currentDir, currentHeight, hasEnteredClearCell = true] = info; if (parsed.moveType === "crash") { board.delete(fromCell); } else if (parsed.moveType === "climb") { const newDir = parsed.dir || currentDir; - const newHeight = Math.min(currentHeight + 1, 6); + const cloudEntry = this.resolveCloudEntry( + [parsed.target!], Math.min(currentHeight + 1, 6), hasEnteredClearCell, + ); board.delete(fromCell); - board.set(parsed.target!, [owner, newDir, newHeight]); + if (cloudEntry.crashCell === undefined) { + board.set(parsed.target!, [ + owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, + ]); + } } 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. + const enteredCells = oneAhead === parsed.target ? [parsed.target!] : [oneAhead!, parsed.target!]; + const cloudEntry = this.resolveCloudEntry(enteredCells, currentHeight, hasEnteredClearCell); board.delete(fromCell); - board.set(parsed.target!, [owner, newDir, currentHeight]); + if (cloudEntry.crashCell === undefined) { + board.set(parsed.target!, [ + owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, + ]); + } } 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) { @@ -1162,18 +1270,25 @@ export class CrosshairsGame extends GameBase { const parts = step.split("/"); const nextCell = parts[0].toLowerCase(); if (nextCell && nextCell.length > 0) { - if (cell !== fromCell) board.delete(cell); + board.delete(cell); cell = nextCell; height--; if (parts[1]) { const d = parts[1].toUpperCase() as HexDir; if (allDirections.includes(d)) dir = d; } + const cloudEntry = this.resolveCloudEntry([cell], height, enteredClearCell); + 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]); + board.set(cell, [owner, dir, height, enteredClearCell]); } } } @@ -1190,12 +1305,12 @@ 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] = info; + const [owner, currentDir, currentHeight, hasEnteredClearCell = true] = info; const sequence = parsed.diveSequence || ""; const steps = sequence.split(">"); - // Process each step, tracking board state and branching on shots. - // We build the action string incrementally: "g6v" + step1 + ">" + step2 + ... + // Process each manoeuvre before branching on the shots it enables. We + // build the action string incrementally: "g6v" + step1 + ">" + step2 + ... type DiveVariant = { prefix: string; // built action so far (e.g., "g6vf6(e5)>e5") board: Map; @@ -1203,6 +1318,8 @@ export class CrosshairsGame extends GameBase { cell: string; height: number; dir: HexDir; + hasEnteredClearCell: boolean; + crashed: boolean; }; let variants: DiveVariant[] = [{ @@ -1212,6 +1329,8 @@ export class CrosshairsGame extends GameBase { cell: fromCell, height: currentHeight, dir: currentDir, + hasEnteredClearCell, + crashed: false, }]; for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { @@ -1219,11 +1338,15 @@ export class CrosshairsGame extends GameBase { const nextVariants: DiveVariant[] = []; for (const v of variants) { + if (v.crashed) continue; + // Apply this step to the variant's board const newBoard = this.copyBoard(v.board); let newCell = v.cell; let newHeight = v.height; let newDir = v.dir; + let newHasEnteredClearCell = v.hasEnteredClearCell; + let crashed = false; const stepLower = step.toLowerCase(); if (stepLower.startsWith("p")) { @@ -1234,7 +1357,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]); + newBoard.set(newCell, [owner, newDir, newHeight, newHasEnteredClearCell]); } else { const parts = stepLower.split("/"); const targetCell = parts[0]; @@ -1252,7 +1375,17 @@ export class CrosshairsGame extends GameBase { const d = parts[1].toUpperCase() as HexDir; if (allDirections.includes(d)) newDir = d; } - newBoard.set(newCell, [owner, newDir, newHeight]); + const cloudEntry = this.resolveCloudEntry( + [newCell], newHeight, newHasEnteredClearCell, + ); + newHeight = cloudEntry.heightAfterEntry; + newHasEnteredClearCell = cloudEntry.hasEnteredClearCell; + crashed = cloudEntry.crashCell !== undefined; + if (crashed) { + newBoard.delete(newCell); + } else { + newBoard.set(newCell, [owner, newDir, newHeight, newHasEnteredClearCell]); + } } } // Also clean up fromCell if plane has moved @@ -1278,6 +1411,8 @@ export class CrosshairsGame extends GameBase { cell: newCell, height: newHeight, dir: newDir, + hasEnteredClearCell: newHasEnteredClearCell, + crashed, }); } else { // Generate all shoot/pass combinations for newly shootable planes. @@ -1293,6 +1428,8 @@ export class CrosshairsGame extends GameBase { cell: newCell, height: newHeight, dir: newDir, + hasEnteredClearCell: newHasEnteredClearCell, + crashed, }); } } @@ -1375,7 +1512,9 @@ export class CrosshairsGame extends GameBase { private boardFingerprint(board: Map, planesRemaining: [number, number]): string { const entries: string[] = []; for (const [cell, info] of board) { - entries.push(`${cell}:${info[0]},${info[1]},${info[2]}`); + // 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.sort(); return `${entries.join(";")}|${planesRemaining[0]},${planesRemaining[1]}`; @@ -2227,12 +2366,25 @@ export class CrosshairsGame extends GameBase { if (!startingHexes.includes(cell)) { error = inv(action); break; } if (!allDirections.includes(dir)) { error = inv(action); break; } } - board.set(cell, [this.currplayer, dir, 0]); + // 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, + ]); + } 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); @@ -2252,7 +2404,7 @@ export class CrosshairsGame extends GameBase { partialState = { type: "next_action" }; continue; } - const [owner, currentDir, currentHeight] = info; + const [owner, currentDir, currentHeight, hasEnteredClearCell = true] = info; if (parsed.moveType === "crash") { if (validate) { @@ -2307,13 +2459,23 @@ export class CrosshairsGame extends GameBase { if (!isOneAhead(fromCell, currentDir, toCell)) { error = inv(action); break; } if (!validateDir(parsed.dir, currentDir, action)) break; } - const newHeight = Math.min(currentHeight + 1, 6); + // The climb is resolved before effects caused by entering + // the destination cell. + const cloudEntry = this.resolveCloudEntry( + [toCell], Math.min(currentHeight + 1, 6), hasEnteredClearCell, + ); board.delete(fromCell); - board.set(toCell, [owner, newDir, newHeight]); + if (cloudEntry.crashCell === undefined) { + board.set(toCell, [ + owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, + ]); + } movedPlanes.add(toCell); if (generateResults) { results.push({ type: "move", from: fromCell, to: toCell, what: "plane" }); - if (newDir !== currentDir) { + if (cloudEntry.crashCell !== undefined) { + results.push({ type: "destroy", what: "plane", where: cloudEntry.crashCell }); + } else if (newDir !== currentDir) { results.push({ type: "orient", where: toCell, facing: newDir }); } } @@ -2366,12 +2528,23 @@ export class CrosshairsGame extends GameBase { } if (!validateDir(parsed.dir, currentDir, action)) break; } + const oneAhead = this.moveForward(fromCell, currentDir, board)!; + // 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); board.delete(fromCell); - board.set(toCell, [owner, newDir, currentHeight]); - movedPlanes.add(toCell); + if (cloudEntry.crashCell === undefined) { + board.set(toCell, [ + owner, newDir, cloudEntry.heightAfterEntry, cloudEntry.hasEnteredClearCell, + ]); + } + movedPlanes.add(cloudEntry.crashCell ?? toCell); if (generateResults) { - results.push({ type: "move", from: fromCell, to: toCell, what: "plane" }); - if (newDir !== currentDir) { + results.push({ type: "move", from: fromCell, to: cloudEntry.crashCell ?? toCell, what: "plane" }); + if (cloudEntry.crashCell !== undefined) { + results.push({ type: "destroy", what: "plane", where: cloudEntry.crashCell }); + } else if (newDir !== currentDir) { results.push({ type: "orient", where: toCell, facing: newDir }); } } @@ -2425,10 +2598,16 @@ 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; - for (const stepWithShoot of stepsWithShoots) { + // Fully resolve movement, height, direction, cloud entry, + // board state, shooting, and crashes for one manoeuvre + // before beginning the next loop iteration. + for (let stepIdx = 0; stepIdx < stepsWithShoots.length; stepIdx++) { + const stepWithShoot = stepsWithShoots[stepIdx]; const stepTargets = this.extractShootTargets(stepWithShoot); const step = this.removeShootNotation(stepWithShoot).toLowerCase(); const stepIsPartial = step.endsWith("/") || step === ""; @@ -2471,6 +2650,13 @@ export class CrosshairsGame extends GameBase { dir = dirPart; } } + + // Resolve each swoop separately so a dive can + // suffer turbulence from several clouds. + const cloudEntry = this.resolveCloudEntry([cell], height, enteredClearCell); + height = cloudEntry.heightAfterEntry; + enteredClearCell = cloudEntry.hasEnteredClearCell; + crashed = cloudEntry.crashCell !== undefined; } } if (error) break; @@ -2483,7 +2669,14 @@ export class CrosshairsGame extends GameBase { if (prevCell !== cell) { board.delete(prevCell); } - board.set(cell, [owner, dir, height]); + if (crashed) { + board.delete(cell); + if (generateResults) { + results.push({ type: "destroy", what: "plane", where: cell }); + } + } else { + board.set(cell, [owner, dir, height, enteredClearCell]); + } prevCell = cell; // Apply shooting after this step @@ -2491,6 +2684,14 @@ export class CrosshairsGame extends GameBase { const shotErr = applyShots(stepTargets, board, rawAction); if (shotErr) { error = shotErr; break; } } + + if (crashed) { + if (validate && stepIdx < stepsWithShoots.length - 1) { + error = inv(rawAction); + } + diveIsPartial = false; + break; + } } if (error) break; @@ -2498,16 +2699,18 @@ export class CrosshairsGame extends GameBase { if (fromCell !== cell) { board.delete(fromCell); } - board.set(cell, [owner, dir, height]); + if (!crashed) { + board.set(cell, [owner, dir, height, enteredClearCell]); + } - movedPlanes.add(cell); + movedPlanes.add(crashed ? fromCell : cell); // Build dive state — use rawAction for origSequence to preserve // shooting notation and case (e.g. "f6(e5)>P/SW") const rawVIdx = rawAction.toLowerCase().indexOf("v"); const rawOrigSequence = rawAction.substring(rawVIdx + 1); const cleanSequence = this.removeShootNotation(rawOrigSequence).toLowerCase(); - diveState = { + diveState = crashed ? null : { fromCell, currentCell: cell, currentDir: dir, diff --git a/test/games/crosshairs.test.ts b/test/games/crosshairs.test.ts index de7b5ec0..df1b87e0 100644 --- a/test/games/crosshairs.test.ts +++ b/test/games/crosshairs.test.ts @@ -1261,6 +1261,178 @@ describe("Crosshairs", () => { }); }); + describe("Optional Rules Variants", () => { + const makeFlightGame = ( + height: number, + clouds: string[], + hasEnteredClearCell = true, + 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.planesRemaining = [0, 0]; + (g as unknown as { saveState: () => void }).saveState(); + return g; + }; + + const shootablePlanes = (g: CrosshairsGame): string[] => + (g as unknown as { + getShootablePlanes: (p: number, b: Map) => string[]; + }).getShootablePlanes(1, g.board); + + it("should expose turbulence and concealed fire as independent variants", () => { + const variants = new CrosshairsGame().allvariants()!; + const turbulence = variants.find(variant => variant.uid === "turbulence")!; + const concealedFire = variants.find(variant => variant.uid === "concealed-fire")!; + + expect(turbulence.name).to.equal("Turbulence"); + expect(concealedFire.name).to.equal("Concealed fire"); + expect(turbulence.group).to.be.undefined; + expect(concealedFire.group).to.be.undefined; + + const combined = new CrosshairsGame(undefined, [ + "turbulence", "concealed-fire", "random-start", "clouds-22", "unbounded-cloud-banks", + ]); + expect(combined.variants).to.include.members(["turbulence", "concealed-fire"]); + expect(combined.clouds.size).to.equal(22); + }); + + it("should apply a climb's height gain before turbulence", () => { + const g = makeFlightGame(0, ["f6"]); + + g.move("f5+f6"); + + expect(g.board.get("f6")).to.deep.equal([1, "S", 0, true]); + }); + + 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(); + 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.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); + + g.move("f5vf6>f7"); + + // Only the two normal swoop losses apply; neither cloud activates turbulence. + expect(g.board.get("f7")).to.deep.equal([1, "S", 1, false]); + }); + + it("should activate turbulence mid-dive after a swoop ends in a clear cell", () => { + const g = makeFlightGame(5, ["f5", "f7"], false); + + 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; + }); + + it("should end the exemption at an intermediate clear cell during level flight", () => { + const g = makeFlightGame(3, ["f5", "f7"], false); + + 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]); + }); + + it("should apply turbulence for both clouds crossed in a two-space level flight", () => { + const g = makeFlightGame(3, ["f6", "f7"]); + + g.move("f5-f7"); + + expect(g.board.get("f7")).to.deep.equal([1, "S", 1, true]); + }); + + it("should crash at the second turbulent cloud when the first loss leaves height 0", () => { + const g = makeFlightGame(1, ["f6", "f7"]); + + g.move("f5-f7"); + + expect(g.board.has("f7")).to.be.false; + expect(g.results).to.deep.include({ type: "destroy", what: "plane", where: "f7" }); + }); + + it("should apply turbulence at every cloud entered during a dive", () => { + const g = makeFlightGame(5, ["f6", "f7"]); + + g.move("f5vf6>f7"); + + expect(g.board.get("f7")).to.deep.equal([1, "S", 1, true]); + }); + + it("should crash immediately during a dive and reject later manoeuvres", () => { + const g = makeFlightGame(1, ["f6"]); + + expect(g.actions()).to.include("f5vf6"); + expect(g.actions().some(action => action.startsWith("f5vf6>"))).to.be.false; + expect(g.validateMove("f5vf6>f7").valid).to.be.false; + g.move("f5vf6"); + + expect(g.board.has("f6")).to.be.false; + expect(g.results).to.deep.include({ type: "destroy", what: "plane", where: "f6" }); + }); + + it("should not reapply turbulence during a power dive inside a cloud", () => { + const g = makeFlightGame(4, ["f5"]); + + g.move("f5vP"); + + expect(g.board.get("f5")).to.deep.equal([1, "S", 2, true]); + }); + + it("should let planes shoot out of clouds with concealed fire", () => { + const g = new CrosshairsGame(undefined, ["concealed-fire"]); + g.clouds.clear(); + g.clouds.add("f3"); + g.clouds.add("f9"); + g.board.clear(); + g.board.set("f3", [1, "S", 3]); + g.board.set("f9", [1, "N", 3]); + g.board.set("f6", [2, "NE", 3]); + + expect(shootablePlanes(g)).to.include("f6"); + }); + + it("should still prevent shooting into or through clouds with concealed fire", () => { + const g = new CrosshairsGame(undefined, ["concealed-fire"]); + g.clouds.clear(); + g.board.clear(); + g.board.set("f3", [1, "S", 3]); + g.board.set("f9", [1, "N", 3]); + g.board.set("f6", [2, "NE", 3]); + + g.clouds.add("f6"); + expect(shootablePlanes(g)).to.not.include("f6"); + + g.clouds.delete("f6"); + g.clouds.add("f5"); + expect(shootablePlanes(g)).to.not.include("f6"); + }); + }); + describe("Rendering", () => { it("should show click hints during dive direction selection", () => { const g = new CrosshairsGame();