diff --git a/locales/en/apgames.json b/locales/en/apgames.json index df52720f..f342a264 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -103,6 +103,7 @@ "gyges": "A breakthrough game where nobody owns any pieces, and pieces rebound off of each other.", "gyve": "Drawless unification game where you try to form fewer groups than your opponent. On each turn, you place two stones in sequence so that each one is adjacent to the same number of friendly groups at the moment of placement.", "halma": "The original traversal game. Players need to be the first to move all their armies to the opponent's home base.", + "halmaclimbers": "An original scoring game inspired by Halma movement dynamics.", "havannah": "A connection game where you vye to form either a ring, a bridge, or a fork. A ring is a chain around at least one cell. A bridge is a chain linking two corners. A fork is a chain linking three sides. Corners do not belong to either side.", "hens": "Hens and Chicks is a draughts-style game with two types of pieces and a unique capturing mechanic. The winner is the first to land a piece in their opponent's home row or to eliminate all the opposing hens.", "hex": "In Hex, two players attempt to connect opposite sides of a rhombus-shaped board made of hexagonal cells", @@ -132,6 +133,7 @@ "meridians": "Meridians is a territorial game with the goal of annihilating the opponent's stones. Starting with an empty board, players alternate turns placing a stone on an empty point as in Go. Groups should be \"seen\" by other friendly stone through a straight path or they will be removed on your opponent's turn.", "mimic": "A game where enemy pieces mimic your moves while you race to be the first player to reach the back row.", "minefield": "Connect opposite sides of the board, but certain piece configurations are forbidden.", + "minimize": "The player with the largest smallest group wins the game.", "mirador": "Mirador is a fast and exhilarating connection game played on a 27 x 27 square grid by placing 2 x 2 squares to represent watchtowers. It uses line-of-sight connections and equivalent goals (making it more of a race than a pure connection game).", "mixtour": "Two players create towers with the goal of creating a tower at least five high with your colour at the top. The twist here is two-fold: (1) you can move opponent's pieces and (2) movement range is determined by the *target* stack height and not the moving stack height.", "monkey": "You start with a single queen, composed of a stack of checkers. When you make noncapturing moves, the queen leaves a singleton behind. Queens and singletons move and capture like chess queens. To win, capture the opposing queen or leave them with no legal moves.", @@ -273,6 +275,7 @@ "garden": "To make it very clear what happened on a previous turn, each move is displayed over four separate boards. The first board shows the game after the piece was first placed. The second board shows the state after adjacent pieces were flipped. The third board shows any harvests. The fourth board is the final game state and is where you make your moves.\n\nIn our implementation, black is always the \"tome\" or tie-breaker colour. The last player to harvest black will have a `0.1` after their score.", "gyges": "The goal squares are adjacent to all the cells in the back row. The renderer cannot currently handle \"floating\" cells.", "halma": "To prevent the [drawish nature](https://boardgamegeek.com/thread/3706389/unspoiling-halma-redux) of the game, the following rules apply: (a) a player wins if the opposite home-base is complete with at least one friendly stone (David Parlett's criteria); (b) Any piece in the player's home-base must make progress towards the enemy camp whenever this is possible by jumping over an enemy piece. (Zillions rule, to remove drawish strategies); (c) No stone can return to his home-base.\n\n[Halma](https://en.wikipedia.org/wiki/Halma) was one of the first commercial successes for an abstract game. The game was designed by [George Howard Monks](https://en.wikipedia.org/wiki/George_Howard_Monks) in 1883/4. It is said that Halma was inspired by an older British game called *Hoppity*. However, this game has no documentation or surviving boards, turning this lineal statement into a historical mystery. Halma was later adapted (c.1892/3) into an even bigger success: [Chinese Checkers](https://boardgamegeek.com/boardgame/2386/chinese-checkers) (which could easily be played by three or six players).\n\nSuper Halma is a more dynamic, uncredited variant, presented in the 1992's book **New Rules for Classic Games** by Wayne Schmittberger. This variant proposes long jumps, where the number of empty cells before and after the jumped stone must be equal. The base's move restrictions still apply in this implementation of Super Halma.", + "halmaclimbers": "Halma Climbers was designed by Alexander Brady in 2021.\n\nThis implementation allows players to move at will, rather than restricting them to moves that strictly increase the overall score (has mentioned in David Ploog's [ruleset](https://blackandwhite.develz.org/games/HalmaClimbers.pdf)). This design choice removes the burden of calculating every possible combination just to find out that no more moves exist that increase the overall score. This also prevents the software from having to compute an excessive number of permutations, which can be quite high given the two-action turn structure and complex jump sequences. The game ends when both players pass their turns consecutively.", "homeworlds": "The win condition is what's called \"Sinister Homeworlds.\" You only win by defeating the opponent to your left. If someone else does that, the game continues, but your left-hand opponent now shifts clockwise. For example, in a four-player game, if I'm South, then I win if I eliminate West. But if the North player ends up eliminating West, the game continues, but now my left-hand opponent is North.", "jacynth": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com). Cards in players' hands are hidden from observers and opponents.", "konane": "Several competing opening protocols exist, but the most common ruleset is the Naihe Ruleset, used by tournaments at the Bishop Museum in Hawaii and described in the BGG reference. This is what is implemented here.", @@ -1456,6 +1459,11 @@ "name": "Super Halma (allows long-jumps)" } }, + "halmaclimbers": { + "#board": { + "name": "Hexagonal board (base-6)" + } + }, "havannah": { "#board": { "name": "Size-8 board" @@ -1798,6 +1806,23 @@ "description": "Allows 2x4 switches but forbids rare 4x4 pinwheels (by Luis Bolaños Mures)" } }, + "minimize": { + "size-4": { + "name": "Hexagonal board (base-4)" + }, + "size-5": { + "name": "Hexagonal board (base-5)" + }, + "#board": { + "name": "Hexagonal board (base-6)" + }, + "size-7": { + "name": "Hexagonal board (base-7)" + }, + "size-8": { + "name": "Hexagonal board (base-8)" + } + }, "mixtour": { "five": { "description": "The winner is the first to score three points.", @@ -4992,6 +5017,14 @@ "FORCED_MOVES": "Pieces still at home-base are forced to forward-jump over enemy neighbors: pick {{forced}}.", "BAD_MOVE": "This movement is illegal!" }, + "halmaclimbers": { + "INITIAL_INSTRUCTIONS": "Either move to an adjacent empty cell, or make a multiple short-jump over pieces of either color. The sequence of jumps must start inside the player's home-base.", + "INSTRUCTIONS": "An action is a movement to an adjacent empty cell, or a multiple short-jump over pieces of either color. The sequence of jumps must start inside the player's home-base. Each player makes two actions.", + "NONEXISTENT": "Trying to interact with a friendly piece that doesn't exist at {{where}}!", + "ILLEGAL_JUMP": "The sequence of jumps must start inside the player's home-base!", + "TOO_MANY_ACTIONS": "This number of actions is not allowed: only one action at the beggining, then two!", + "BAD_MOVE": "This movement is illegal!" + }, "havannah": { "INITIAL_INSTRUCTIONS": "Select a point to place a piece." }, @@ -5269,6 +5302,14 @@ "INITIAL_INSTRUCTIONS": "Select a point to place a piece.", "FORBIDDEN": "You may not form forbidden glyphs." }, + "minimize": { + "INITIAL_INSTRUCTIONS": "At the start, place one stone of either color. Click current friendly stone to flip color, click again to empty hex.", + "INSTRUCTIONS": "Players place, on empty hexes, one or two stones of either color. Click current friendly stone to flip color, click again to empty hex.", + "INVALID_PLACEMENT": "Unable to interpret the move notation: {{move}}.", + "NORMALISE": "The move needs to be normalised. Try {{normalised}}.", + "TOO_MANY_MOVES": "Only one or two placements are allowed.", + "TOO_MANY_MOVES_START": "At the start, only one placement is allowed." + }, "mirador": { "ALREADY_DECLARED": "Someone has already declared. You can not declare now.", "BAD_PLACEMENT": "You can not play at {{where}}. It would be adjacent or overlapping with another mirador. The only adjacency allowed is diagonal to one of your own miradors.", diff --git a/src/games/halma.ts b/src/games/halma.ts index 79104e90..1a589461 100644 --- a/src/games/halma.ts +++ b/src/games/halma.ts @@ -52,7 +52,7 @@ export class HalmaGame extends GameBase { { uid: "#board", }, { uid: "superhalma", group: "ruleset" }, ], - categories: ["goal>evacuate", "other>traditional", "mechanic>move", "board>shape>rect", "components>simple>1per", "other>2+players"], + categories: ["goal>evacuate", "other>traditional", "mechanic>move", "board>shape>rect", "components>simple>1per"], flags: ["no-moves", "experimental"] }; @@ -579,9 +579,9 @@ export class HalmaGame extends GameBase { }; // Add annotations - if (this.stack[this.stack.length - 1]._results.length > 0) { - rep.annotations = []; - for (const move of this.stack[this.stack.length - 1]._results) { + rep.annotations = []; + if (this.results.length > 0) { + for (const move of this.results) { if (move.type === "move") { const [fromX, fromY] = g.algebraic2coords(move.from); const [toX, toY] = g.algebraic2coords(move.to); @@ -594,9 +594,6 @@ export class HalmaGame extends GameBase { } if (this.dots.length > 0) { - if (!("annotations" in rep) || rep.annotations === undefined) { - rep.annotations = []; - } rep.annotations.push({ type: "dots", targets: this.dots.map(cell => { diff --git a/src/games/halmaclimbers.ts b/src/games/halmaclimbers.ts new file mode 100644 index 00000000..903802a2 --- /dev/null +++ b/src/games/halmaclimbers.ts @@ -0,0 +1,634 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IScores, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, RowCol, Colourfuncs, MarkerFlood } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError, HexTriGraph } from "../common"; +import { HexDir } from "../common/graphs/hextri"; +import i18next from "i18next"; + +export type playerid = 1|2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface IHalmaClimbersState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class HalmaClimbersGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Halma Climbers", + uid: "halmaclimbers", + playercounts: [2], + version: "20260514", + dateAdded: "2026-05-14", + // i18next.t("apgames:descriptions.halmaclimbers") + description: "apgames:descriptions.halmaclimbers", + // i18next.t("apgames:notes.halmaclimbers") + notes: "apgames:notes.halmaclimbers", + urls: [ + "https://boardgamegeek.com/thread/2750218", + "https://blackandwhite.develz.org/games/HalmaClimbers.pdf", + ], + people: [ + { + type: "designer", + name: "Alexander Brady", + urls: ["https://boardgamegeek.com/boardgamedesigner/159374/alexander-brady"], + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>score", "mechanic>move", "board>shape>hex", "components>simple>1per"], + flags: ["no-moves", "custom-buttons", "experimental"] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private dots: string[] = []; + + constructor(state: IHalmaClimbersState | string, variants?: string[]) { + super(); + if (state === undefined) { + if ( (variants !== undefined) && (variants.length > 0) ) { + this.variants = [...variants]; + } + + const board = new Map([ + ["a6", 1], ["a1", 2], + ["b7", 1], ["b1", 2], + ["c8", 1], ["c7", 1], ["c2", 2], ["c1", 2], + ["d9", 1], ["d8", 1], ["d2", 2], ["d1", 2], + ["e10",1], ["e9", 1], ["e8", 1], ["e3", 2], ["e2", 2], ["e1", 2], + ["f11",1], ["f10",1], ["f9", 1], ["f3", 2], ["f2", 2], ["f1", 2], + ["g10",1], ["g9", 1], ["g8", 1], ["g3", 2], ["g2", 2], ["g1", 2], + ["h9", 1], ["h8", 1], ["h2", 2], ["h1", 2], + ["i8", 1], ["i7", 1], ["i2", 2], ["i1", 2], + ["j7", 1], ["j1", 2], + ["k6", 1], ["k1", 2], + ]); + + const fresh: IMoveState = { + _version: HalmaClimbersGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IHalmaClimbersState; + } + if (state.game !== HalmaClimbersGame.gameinfo.uid) { + throw new Error(`The HalmaClimbers engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): HalmaClimbersGame { + if (idx < 0) { + idx += this.stack.length; + } + if ( (idx < 0) || (idx >= this.stack.length) ) { + throw new Error("Could not load the requested state from the stack."); + } + + const state = this.stack[idx]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + this.results = [...state._results]; + return this; + } + + public get boardsize(): number { + return 6; + } + + public get graph(): HexTriGraph { + return new HexTriGraph(this.boardsize, this.boardsize * 2 - 1); + } + + public getButtons(): ICustomButton[] { + return [{ label: "pass", move: "pass" }]; + } + + private homeBase(player?: playerid): string[] { + if (player === undefined) { player = this.currplayer; } + return player === 1 ? + ["a6", "b7", "c7", "c8", "d8", "d9", "e8", "e9", "e10", "f9", "f10", "f11", + "g8", "g9", "g10", "h8", "h9", "i7", "i8", "j7", "k6"] : + ["a1", "b1", "c1", "c2", "d1", "d2", "e1", "e2", "e3", "f1", "f2", "f3", + "g1", "g2", "g3", "h1", "h2", "i1", "i2", "j1", "k1"]; + } + + private jumpNeighbors(cell: string, board: Map): string[] { + const res: string[] = []; + const g = this.graph; + const [x, y] = g.algebraic2coords(cell); + + for (const dir of ["NE","E","SE","SW","W","NW"] as HexDir[]) { + const ray = g.ray(x, y, dir).map(c => g.coords2algebraic(...c)); + if (ray.length >= 2) { + if (board.has(ray[0]) && !board.has(ray[1])) { + res.push(ray[1]); + } + } + } + return res; + } + + private trimIfRepeated(moves: string[]): string[] { + if (moves.length === 0) { return []; } + const last = moves[moves.length - 1]; + const firstIndex = moves.indexOf(last); + + if (firstIndex !== moves.length - 1) { // if the last element appears earlier + return moves.slice(0, firstIndex+1); + } + return moves; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const g = this.graph; + const cell = g.coords2algebraic(col, row); + let newmove:string; + + if ( move === "" ) { + newmove = cell; + } else if ( move === cell ) { // reclick resets 1st action + newmove = ""; + } else if ( this.board.has(cell) ) { + // there are the following possible *valid* events for a player to click an occupied cell: + // 1) the piece from 2nd action is moving where the 1st piece was + // 2) the jumping piece is going back from where it started + // 3) the player is reclicking the piece to reset the action + // 4) the player is just starting the 2nd action + const actions = move.split(','); + const lastAction = actions[actions.length-1]; + const cells = lastAction.split('-'); + // 1 + if ( actions.length === 2 && actions[0].split('-')[0] === cell ) { + newmove = `${move}-${cell}`; + } + // 2 + else if ( cells.length > 1 && cells[0] === cell ) { + actions[actions.length-1] = this.trimIfRepeated(`${actions[actions.length-1]}-${cell}`.split("-")).join("-"); + newmove = actions.join(","); // remove all jumps after its first occurrence + } + // 3 + else if ( cells.length === 1 && cells[0] === cell ) { + newmove = actions.slice(0, -1).join(","); // reset last action + } + // 4 + else { + newmove = `${move},${cell}`; + } + } else if ( move.includes(',') && move.split(',')[1] === cell ) { // reclick resets 2nd action + newmove = move.split(',')[0]; + } else { + const actions = move.split(','); + // for the current action: + // if an empty cell appears again, remove all jumps after its first occurrence + actions[actions.length-1] = this.trimIfRepeated(`${actions[actions.length-1]}-${cell}`.split("-")).join("-"); + newmove = actions.join(","); + } + + const result = this.validateMove(newmove) as IClickResult; + //console.debug('handleclick() move', move, 'cell', cell, 'newmove', newmove, "isValid?", result.valid); + result.move = result.valid ? newmove : move; + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + public hasPrefix(moves: string[], partial: string): boolean { // TODO: delete? + return moves.some(str => str.startsWith(partial)); + } + + // returns all legal fallback moves from a given player, and a given (possibly cloned) board + private fallbackmoves(player: playerid, board: Map): string[] { + const backDirs: HexDir[] = player === 1 ? ["NE", "SE", "E"] : ["NW", "SW", "W"]; + const g = this.graph; + const res = []; + const friendlyPieces = [...board.entries()].filter(e => e[1] === player) + .map(e => e[0]); + for (const cell of friendlyPieces) { + const [x, y] = g.algebraic2coords(cell); + for (const dir of backDirs) { + const ray = g.ray(x, y, dir).map(c => g.coords2algebraic(...c)); + if (ray.length >= 1 && !board.has(ray[0]) ) { + res.push(`${cell}-${ray[0]}`); + } + } + } + return res; + } + + // check if an action is a fallback + private isFallback(action: string): boolean { + const cells = action.split('-'); + if ( cells.length === 2 ) { + const backDirs: HexDir[] = this.currplayer === 1 ? ["NE", "SE", "E"] : ["NW", "SW", "W"]; + const g = this.graph; + const [x, y] = g.algebraic2coords(cells[0]); + + for (const dir of backDirs) { + const ray = g.ray(x, y, dir).map(c => g.coords2algebraic(...c)); + if (cells[1] === ray[0] ) { + return true; + } + } + } + return false; + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + if (this.stack.length === 1) { + result.message = i18next.t("apgames:validation.halmaclimbers.INITIAL_INSTRUCTIONS") + } else { + result.message = i18next.t("apgames:validation.halmaclimbers.INSTRUCTIONS") + } + return result; + } + + if (m === "pass") { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + const actions = m.split(','); + + // At ply 1 the first player can only have one action + if ( this.stack.length === 1 && actions.length > 1 ) { + result.valid = false; + result.message = i18next.t("apgames:validation.halmaclimbers.TOO_MANY_ACTIONS", {where: actions}); + return result; + } + + // players must make two actions + if ( actions.length > 2 ) { + result.valid = false; + result.message = i18next.t("apgames:validation.halmaclimbers.TOO_MANY_ACTIONS", {where: actions}); + return result; + } + + // need to move through all the moves, and check if they follow the rules + // for that we need a copy of the board, to keep the effects of the previous actions + const clone = new Map(this.board); + let isJump = true; + + // if one action, it is ok for now, it makes a partial move + for (const action of actions) { + // drop or start of move + if (!action.includes("-")) { + + if (!this.board.has(action)) { // must be occupied + result.valid = false; + result.message = i18next.t("apgames:validation.halmaclimbers.NONEXISTENT", {where: action}); + return result; + } + + if (this.board.get(action)! !== this.currplayer) { // must be a friendly stone + result.valid = false; + result.message = i18next.t("apgames:validation._general.UNCONTROLLED"); + return result; + } + + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.NEED_DESTINATION"); + return result; + + } else { + + const cells = action.split("-"); + isJump = true; + if ( cells.length === 2 ) { + const fallbackMoves = this.fallbackmoves(this.currplayer, clone); + if ( fallbackMoves.includes(action) ) { // it is a fall-back move + clone.delete(cells[0]); + clone.set(cells[1], this.currplayer); // simulate move + isJump = false; + } + } + if ( isJump ) { + if (! this.homeBase().includes(cells[0]) ) { + // The sequence of jumps must start inside the player's home-base + result.valid = false; + result.message = i18next.t("apgames:validation.halmaclimbers.ILLEGAL_JUMP"); + return result; + } + for (let i = 0; i < cells.length - 1; i++) { + const from = cells[i]; + const to = cells[i+1]; + //console.debug('bad move', 'from', from, 'to', to, 'neighbors', ...this.jumpNeighbors(from, clone)); + if (! this.jumpNeighbors(from, clone).includes(to) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.halmaclimbers.BAD_MOVE", {from, to}); + return result; + } + } + const last = cells.at(-1)!; + clone.delete(cells[0]); + clone.set(last, this.currplayer); // simulate move + const penultimate = cells.at(-2)!; + const neighborsLast = this.jumpNeighbors(last, clone); + if ( (neighborsLast.length === 0) || + (neighborsLast.length === 1 && neighborsLast.includes(penultimate)) ) { + isJump = false; // ie, this last jump is final; let's pretend it is a move to finish the sequence + } + } + } + } + + result.valid = true; + if ( this.stack.length === 1 && !isJump ) { + result.complete = 1; // a fall-back on ply 1 is final + } else if ( this.stack.length > 1 && actions.length === 1 ) { + result.complete = 0; // still one action to make + } else if ( this.stack.length > 1 && actions.length === 2 && !isJump ) { + result.complete = 1; + } else { + result.complete = isJump ? 0 : 1; // moves are final, jumps can be multiple + } + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + + } + + public move(m: string, {trusted = false, partial = false} = {}): HalmaClimbersGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + } + + this.results = []; + this.dots = []; + + if (m === "") { return this; } + + if (m === "pass") { + this.results.push({type: "pass"}); + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + const actions = m.split(','); + + for (const action of actions) { + if (action.includes("-")) { + const steps = action.split("-"); + const from = steps[0]; + const to = steps[steps.length - 1]; + this.board.delete(from); + this.board.set(to, this.currplayer); + for (let i = 0; i < steps.length-1; i++) { + this.results.push({type: "move", from: steps[i], to: steps[i+1]}); + } + } else { + //this.board.set(action, this.currplayer); + this.results.push({type: "place", where: action}); + } + } + + if (partial) { // if partial, populate dots and get out + + const cells = actions.at(-1)!.split("-"); + //console.debug('fallbacks', fallbacks, 'actions', actions); + // if just starting, add fall-back moves + if (cells.length === 1) { + const start = cells[0]; + const fallbacks = this.fallbackmoves(this.currplayer, this.board); + const possibleFallbacks = fallbacks.filter(mv => mv.split('-')[0] === start).map(mv => mv.split('-')[1]); + this.dots.push(...possibleFallbacks); + //console.debug('dots moves', 'm', m, 'cells', cells, 'fallback', ...possibleFallbacks); + } + // if the first move is a fallback and was concluded, don't show jump dots + if (! (actions.length === 1 && this.isFallback(actions[0])) ) { + // now add jumps + this.dots.push(...this.jumpNeighbors(cells[cells.length - 1], this.board)); + //console.debug('dots jumps', ...this.jumpNeighbors(cells[cells.length - 1], this.board)); + } + return this; + } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): HalmaClimbersGame { + // game ends if two consecutive passes occurred + this.gameover = this.lastmove === "pass" && + this.stack[this.stack.length - 1].lastmove === "pass"; + + if ( this.gameover ) { + const scoreP1 = this.getPlayerScore(1); + const scoreP2 = this.getPlayerScore(2); + if (scoreP1 === scoreP2) { + this.winner = [1, 2]; + } else { + this.winner = scoreP1 > scoreP2 ? [1] : [2]; + } + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): IHalmaClimbersState { + return { + game: HalmaClimbersGame.gameinfo.uid, + numplayers: this.numplayers, + variants: [...this.variants], + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: HalmaClimbersGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public render(): APRenderRep { + const g = this.graph; + // Build piece string + const pstr: string[][] = []; + const cells = this.graph.listCells(true); + for (const row of cells) { + const pieces: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const owner = this.board.get(cell)!; + if (owner === 1) { + pieces.push("A") + } else { + pieces.push("B"); + } + } else { + pieces.push("-"); + } + } + pstr.push(pieces); + } + + // paint home-bases + const markers: MarkerFlood[] = []; + for (const player of [1, 2] as playerid[]) { + for (const cell of this.homeBase(player)) { + const [x, y] = this.graph.algebraic2coords(cell); + markers.push({ + type: "flood", + colour: this.getPlayerColour(player), + opacity: 0.5, + points: [{ row: y, col: x }], + }); + } + } + + // Build rep + const rep: APRenderRep = { + board: { + style: "hex-of-hex", + minWidth: this.boardsize, + maxWidth: 2 * this.boardsize - 1, + rotate: 90, + markers, + }, + legend: { + A: { name: "piece", colour: this.getPlayerColour(1) }, + B: { name: "piece", colour: this.getPlayerColour(2) }, + }, + pieces: pstr.map(p => p.join("")).join("\n"), + }; + + // Add annotations + rep.annotations = []; + + if (this.results.length > 0) { + for (const move of this.results) { + if (move.type === "move") { + const [fromX, fromY] = g.algebraic2coords(move.from); + const [toX, toY] = g.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "place") { + const [x, y] = g.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + } + } + + if (this.dots.length > 0) { + rep.annotations.push({ + type: "dots", + targets: this.dots.map(cell => { + const [x, y] = g.algebraic2coords(cell); + return {row: y, col: x}; + }) as [RowCol, ...RowCol[]], + }); + } + + return rep; + } + + public getPlayerColour(p: playerid): Colourfuncs { + if (p === 1) { + return { func: "custom", default: 1, palette: 1 }; + } else { + return { func: "custom", default: 2, palette: 2 }; + } + } + + public getPlayerScore(player: playerid): number { + const starts = player === 1 ? + ["a6", "b7", "c7", "d8", "e8", "f9", "g8", "h8", "i7", "j7", "k6" ] : + ["a1", "b1", "c2", "d2", "e3", "f3", "g3", "h2", "i2", "j1", "k1" ]; + const dir: HexDir = player === 1 ? "W" : "E"; + const g = this.graph; + let score = 0; + + for (const start of starts) { + const [x, y] = g.algebraic2coords(start); + const ray = g.ray(x, y, dir).map(c => g.coords2algebraic(...c)); + let maxCount = 0; + for (let i=0; i(); // Manually add each game to the following array [ @@ -620,7 +625,7 @@ const games = new Map { if (games.has(g.gameinfo.uid)) { throw new Error("Another game with the UID '" + g.gameinfo.uid + "' has already been used. Duplicates are not allowed."); @@ -1126,6 +1131,10 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new CourtGame(...args); case "halma": return new HalmaGame(args[0], ...args.slice(1)); + case "minimize": + return new MinimizeGame(...args); + case "halmaclimbers": + return new HalmaClimbersGame(args[0], ...args.slice(1)); } return; } diff --git a/src/games/minimize.ts b/src/games/minimize.ts new file mode 100644 index 00000000..323a7439 --- /dev/null +++ b/src/games/minimize.ts @@ -0,0 +1,537 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IScores, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError } from "../common"; +import { HexTriGraph } from "../common/graphs"; +import i18next from "i18next"; + +export type playerid = 1|2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface IMinimizeState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class MinimizeGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Minimize", + uid: "minimize", + playercounts: [2], + version: "20260514", + dateAdded: "2026-05-14", + // i18next.t("apgames:descriptions.minimize") + description: "apgames:descriptions.minimize", + urls: [ + "https://boardgamegeek.com/boardgame/169096/minimize", + "https://jpneto.github.io/world_abstract_games/modern_rules/2014_Minimize.pdf" + ], + people: [ + { + type: "designer", + name: "Brian Whitman", + urls: ["https://boardgamegeek.com/boardgamedesigner/63351/brian-whitman"], + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>score>eog", "mechanic>place", "board>shape>hex", "components>simple>1per"], + variants: [ + { uid: "size-4", group: "board" }, + { uid: "size-5", group: "board" }, + { uid: "#board", }, + { uid: "size-7", group: "board" }, + { uid: "size-8", group: "board" }, + ], + flags: [] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public graph: HexTriGraph = new HexTriGraph(7, 13); + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + public boardSize = 6; + + constructor(state?: IMinimizeState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: MinimizeGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IMinimizeState; + } + if (state.game !== MinimizeGame.gameinfo.uid) { + throw new Error(`The Minimize engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): MinimizeGame { + if (idx < 0) { + idx += this.stack.length; + } + if ( (idx < 0) || (idx >= this.stack.length) ) { + throw new Error("Could not load the requested state from the stack."); + } + + const state = this.stack[idx]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + this.boardSize = this.getBoardSize(); + this.buildGraph(); + return this; + } + + private getBoardSize(): number { + // Get board size from variants. + if ( (this.variants !== undefined) && (this.variants.length > 0) && + (this.variants[0] !== undefined) && (this.variants[0].length > 0) ) { + const sizeVariants = this.variants.filter(v => v.includes("size")); + if (sizeVariants.length > 0) { + const size = sizeVariants[0].match(/\d+/); + return parseInt(size![0], 10); + } + if (isNaN(this.boardSize)) { + throw new Error(`Could not determine the board size from variant "${this.variants[0]}"`); + } + } + return 6; + } + + private getGraph(): HexTriGraph { + return new HexTriGraph(this.boardSize, this.boardSize * 2 - 1); + } + + private buildGraph(): MinimizeGame { + this.graph = this.getGraph(); + return this; + } + + /** + * For sorting movements using the game notation + * Notation eg: '1c3' means player 1 placed at hex c3 + */ + private sort(a: string, b: string): number { + // First sort by player id + if (a[0] < b[0]) { return -1; } + if (a[0] > b[0]) { return +1; } + + // If same player, sort the two cells; necessary because "a10" should come after "a9" + const [ax, ay] = this.graph.algebraic2coords(a.slice(1)); + const [bx, by] = this.graph.algebraic2coords(b.slice(1)); + if (ay < by) { return 1; } + if (ay > by) { return -1; } + if (ax < bx) { return -1; } + if (ax > bx) { return 1; } + return 0; + } + + // Get all groups of pieces for `player`, sorted by increasing size + private getGroupSizes(player: playerid): number[] { + const groups: Set[] = []; + const pieces = [...this.board.entries()].filter(e => e[1] === player).map(e => e[0]); + const seen: Set = new Set(); + for (const piece of pieces) { + if (seen.has(piece)) { + continue; + } + const group: Set = new Set(); + const todo: string[] = [piece]; + while (todo.length > 0) { + const cell = todo.pop()!; + if (seen.has(cell)) { + continue; + } + group.add(cell); + seen.add(cell); + const neighbours = this.graph.neighbours(cell); + for (const n of neighbours) { + if (pieces.includes(n)) { + todo.push(n); + } + } + } + groups.push(group); + } + + return groups.map(g => g.size).sort((a, b) => a - b); + } + + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = []; + + if (this.stack.length === 1) { + // At ply 1, there's just one move + for (const cell of this.graph.listCells(false) as string[]) { + if (this.board.has(cell)) { continue; } + moves.push(this.normaliseMove(`1${cell}`)); + moves.push(this.normaliseMove(`2${cell}`)); + } + } else { + // At ply 2+, pick two different empty hexes and add placement + // for all four color permutations + for (const cell1 of this.graph.listCells(false) as string[]) { + if (this.board.has(cell1)) { continue; } + for (const cell2 of this.graph.listCells(false) as string[]) { + if (cell1 === cell2 || this.board.has(cell2)) { continue; } + moves.push(this.normaliseMove(`1${cell1},1${cell2}`)); + moves.push(this.normaliseMove(`1${cell1},2${cell2}`)); + moves.push(this.normaliseMove(`2${cell1},1${cell2}`)); + moves.push(this.normaliseMove(`2${cell1},2${cell2}`)); + } + } + } + + return moves.sort((a,b) => a.localeCompare(b)); + } + + /** + * Updates a list of coordinates based on the selection cycle: + * Current Player's Piece -> Enemy Piece -> Empty (Delete) + * So that users can choose which color they prefer placing on an empty hex + * @param coordinates - Current list of prefixed coordinates (e.g., ['1c1', '2d3']) + * @param newCoord - The raw coordinate selected (e.g., 'c1') + * @param currentPlayer - The player making the selection (1 or 2) + * @returns The updated list of coordinates + */ + private processMoves(coordinates: string[], + newCoord: string, + currentPlayer: number): string[] { + const enemyPlayer = currentPlayer === 1 ? 2 : 1; + // check if the new cell already exists in the coordinates list + const existingEntry = coordinates.find(c => c.endsWith(newCoord)); + + if (!existingEntry) { + // if not in the list, add it with the current player's prefix + return [...coordinates, `${currentPlayer}${newCoord}`]; + } + + const currentPrefix = existingEntry[0]; // get the first digit ('1' or '2') + const otherCoordinates = coordinates.filter(c => !c.endsWith(newCoord)); + + if (currentPrefix === currentPlayer.toString()) { + // Own Piece -> Enemy Piece: replace the entry with the enemy prefix + return [...otherCoordinates, `${enemyPlayer}${newCoord}`]; + } + // Enemy Piece -> Empty: return the list without this coordinate + return otherCoordinates; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + let newmove: string = ""; + const cell = this.graph.coords2algebraic(col, row); + + if (move === "") { + // a placement includes the current player id as a prefix + newmove = `${this.currplayer}${cell}`; + } else { + const moves : string[] = move.split(","); + newmove = this.processMoves(moves, cell, this.currplayer) + .sort((a, b) => this.sort(a, b)) + .join(","); + } + const result = this.validateMove(newmove) as IClickResult; + if (!result.valid) { + result.move = move; + } else { + result.move = newmove; + } + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + private spacesLeft(): number { + // Count the number of empty cells. + return this.graph.listCells().length - this.board.size; + } + + private normaliseMove(move: string): string { + // Sort the move list so that there is a unique representation. + move = move.toLowerCase(); + move = move.replace(/\s+/g, ""); + return move.split(",").sort((a, b) => this.sort(a, b)).join(","); + } + + public sameMove(move1: string, move2: string): boolean { + return this.normaliseMove(move1) === this.normaliseMove(move2); + } + + public validateMove(m: string): IValidationResult { + const nMovesTurn = 2; + const result: IValidationResult = {valid: false, + message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + if (this.stack.length == 1) { + result.message = i18next.t("apgames:validation.minimize.INITIAL_INSTRUCTIONS"); + } else { + result.message = i18next.t("apgames:validation.minimize.INSTRUCTIONS"); + } + return result; + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + const moves = m.split(','); + + if (moves.length > nMovesTurn) { + result.valid = false; + result.message = i18next.t("apgames:validation.minimize.TOO_MANY_MOVES"); + return result; + } + + // Is it a valid cell? + let currentMove; + try { + for (const move of moves) { + currentMove = move.slice(1); + if (! (this.graph.listCells() as string []).includes(currentMove)) { + throw new Error("Invalid cell."); + } + } + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALIDCELL", {cell: currentMove}); + return result; + } + + // Is is an empty cell? + let notEmpty; + for (const move of moves) { + if (this.board.has(move.slice(1))) { notEmpty = move.slice(1); break; } + } + if (notEmpty) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.OCCUPIED", {where: notEmpty}); + return result; + } + + const regex = new RegExp(`^[12][a-z]\\d+(,[12][a-z]\\d+)?$`); + if (!regex.test(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.minimize.INVALID_PLACEMENT", {move: m}); + return result; + } + + // is move normalised? (sanity check, in case user types the move) + const normalised = this.normaliseMove(m); + if (! this.sameMove(m, normalised)) { + result.valid = false; + result.message = i18next.t("apgames:validation.minimize.NORMALISED", {move: normalised}); + return result; + } + + if (this.stack.length === 1) { + if (moves.length === 1) { + // initially, the first player can only move once (either color) + result.valid = true; + result.complete = 0; // 0 so the player may flip before submitting + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.minimize.TOO_MANY_MOVES_START"); + } + return result; + } + + result.valid = true; + result.complete = 0; // 0 so the player may flip also the last placement + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + result.canrender = true; + return result; + } + + public move(m: string, { partial = false, trusted = false } = {}): MinimizeGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + if (!trusted) { + const result = this.validateMove(m); + if (!result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + } + + if (m.length === 0) { return this; } + + const nMovesTurn = 2; + m = this.normaliseMove(m); + const moves = m.split(","); + + this.results = []; + for (const move of moves) { + const thePlayer = move[0]; + const theMove = move.slice(1); + this.board.set(theMove, thePlayer == '1' ? 1 : 2); + this.results.push({type: "place", where: theMove}); + } + + this.lastmove = m; + + if (partial) { return this; } + // the game should not accept a single placement if the game is after ply 1 + if (this.stack.length > 1 && moves.length < nMovesTurn) { return this; } + + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + // compare two lists using lexicographic order (+1 if a>b, -1 if a b[i]) return 1; + } + // all equal so far, so shorter one is "smaller" + if (a.length < b.length) return -1; + if (a.length > b.length) return 1; + return 0; + } + + protected checkEOG(): MinimizeGame { + this.gameover = this.spacesLeft() === 0; + + if (this.gameover) { + const result = this.compare(this.getGroupSizes(1), this.getGroupSizes(2)); + if ( result === 0 ) { + this.winner = [1,2]; // with regular hexhex boards, it never happens + } else { + this.winner = result > 0 ? [1] : [2]; + } + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + + return this; + } + + public state(): IMinimizeState { + return { + game: MinimizeGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public moveState(): IMoveState { + return { + _version: MinimizeGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public render(): APRenderRep { + // Build piece string + const pstr: string[][] = []; + const cells = this.graph.listCells(true); + for (const row of cells) { + const pieces: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const owner = this.board.get(cell)!; + if (owner === 1) { + pieces.push("A") + } else { + pieces.push("B"); + } + } else { + pieces.push("-"); + } + } + pstr.push(pieces); + } + + // Build rep + const rep: APRenderRep = { + board: { + style: "hex-of-hex", + minWidth: this.boardSize, + maxWidth: (this.boardSize * 2) - 1, + }, + legend: { + A: {name: "hex-pointy", scale: 1.25, colour: 1 }, + B: {name: "hex-pointy", scale: 1.25, colour: 2 }, + }, + pieces: pstr.map(p => p.join("")).join("\n"), + }; + + // Add annotations + if (this.stack[this.stack.length - 1]._results.length > 0) { + rep.annotations = []; + for (const move of this.stack[this.stack.length - 1]._results) { + if (move.type === "place") { + const [x, y] = this.graph.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + } + } + return rep; + } + + public sidebarScores(): IScores[] { + return [ + { name: i18next.t("apgames:status.SCORES"), + scores: [this.getGroupSizes(1).join(","), + this.getGroupSizes(2).join(",")] } + ] + } + + public clone(): MinimizeGame { + return new MinimizeGame(this.serialize()); + } +}