diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 16eb5bc0..0621e2f1 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -144,6 +144,7 @@ "moonsquad": "Your mining company has found rich ore on this small moon, but you need buyers. Connect your mine to all 3 surface settlements, and hire squads to defend your mine and attack your rival.", "mvolcano": "An Icehouse puzzle game for 2 players. Stacks of pyramids are volcanos, some of which are capped. As you move caps around, you cause eruptions that may lead to you capturing pieces. This is an older version of the game. It is played on a 6x6 board, and the game ends as soon as someone captures at least one piece of each colour or all three white pieces. Scores are then calculated. Highest score wins.", "nakatta": "A square connection game where two specific piece configurations are forbidden.", + "narrows": "Link all your pieces by empty regions.", "necklace": "An orthogonal-only square connection game where you try to connect your sides of the board. Crosscut formations are illegal, and every free space must always have a path to an edge.", "nex": "Nex (or Neutral Hex) is a variant of the Hex connection game where there are neutral pieces. On your turn, you may either (1) place a piece of your colour and a neutral colour on an empty space, or (2) swap two neutral pieces to your colour and one of your piece to the neutral colour.", "omny": "Generalized connection game where players try to split star cells into different regions so that no single region contains a majority of star cells.", @@ -292,6 +293,7 @@ "magnate": "The terminology of some Magnate actions has been altered for clarity and brevity. Completely developing a new property is called the \"Buy\" action; purchasing a deed for a new property is called \"Deed\", developing deeds (that is, adding tokens to a deeded property, whether it results in the deed becoming fully developed or not) is called \"Add\". (Selling a card and trading suit tokens 3 for 1 are unchanged.)\n\nIn order to speed up the process of rolling for resources, there are two additional actions:\n* \"Prefer\" is for setting your preference of which suit token to take when a deed pays out on your opponent's roll. If you do not set an explicit preference, the code will choose the rarer token for you based on your non-crown suits and current supply of tokens. The currently preferred token is circled in the UI, but your personal preference is never visible to the other player.\n* \"Choose\" is a mandatory first action for collecting suit tokens when a deed pays out on your own roll. (In all cases where you need to choose a suit token that is not already among your tokens, you still click on the appropriate token pile.)\n\nBecause you can perform several actions during a ply in any order, there is also an \"Undo\" action to back out your most recent action, whether or not it was complete.\n\nNote that only the final resource die result is displayed, but the distribution of expected outcomes is still that of rolling 2d10 and taking the higher value. Taxation happens when the lower of 2d10 comes up 1; a suit die is rolled (or two, in the double taxation variant), and the suits will be displayed underneath the resource result. The roll is logged at the end of a player's turn, and is attributed to the next player (who would have rolled in the physical game). Except for a \"Choose\", no user action is required; resources are added or removed automatically by the server in between turns.\n\nWhen a player is ahead in a district, the Pawn or Excuse for that district is outlined in that player's color. The first tiebreaker score (total property value) is displayed in parentheses after the district score. The second tiebreaker is total number of tokens remaining.", "mchess": "If there have been seven consecutive turns without a capture, someone can \"call the clock\" by adding an asterisk (*) to the end of their move. This can only be done by selecting the move from the drop-down list. After another seven turns with no capture, the game will end and be scored.", "murus": "The default ruleset is \"Advanced Murus Gallicus\" (with catapults). By default, your first move is to redistribute one of your starting towers as walls on your second row. Additionally, the standard pie rule is also available. There are three variants you can mix and match:\n\n* \"Basic\" reverts the game to the \"no catapult\" state.\n* \"Static\" disables the initial tower redistribution.\n* \"Escape\" eliminates the breakthrough win condition.", + "narrows": "Narrows, designed by Mark Steere in 2026, is an unification game inspired by the ancient Hawiian game of Kōnane. True to the spirit of Kōnane, Ūnane begins with a checkerboard pattern of stones and is extremely simple. In this case, the unification goal includes the use of orthogonal paths of empty cells to connect different groups of friendly pieces.", "oonpia": "Either use the legend to select a piece, or click the same location multiple times to cycle through all possible pieces. Blocked cells are highlighted: a translucent dot means only a dotted stone can be placed there (i.e. blocked for plain pieces), a translucent piece means only a plain piece can be placed there (i.e. blocked for dotted pieces). If both highlights are present, then the cell is blocked for all pieces.", "oware": "This implementation follows the common tournament rule that grand slam moves are allowed, but no pieces are captured. Depicting state changes in sowing games is challenging. The initial chosen pit is marked, as is any capture. Small numbers appear to show the change in the number of stones in each pit. If you believe you have encountered a bug, please let us know in Discord.", "pacru": "This implementation adheres to the 2011 rule change that requires at least one opponent to have at least nine tiles on the board before meetings will trigger.", @@ -1968,6 +1970,26 @@ "name": "29x29 board" } }, + "narrows": { + "size-6": { + "name": "5x6 board" + }, + "#board": { + "name": "7x8 board" + }, + "size-10": { + "name": "9x10 board" + }, + "size-12": { + "name": "11x12 board" + }, + "size-14": { + "name": "13x14 board" + }, + "size-16": { + "name": "15x16 board" + } + }, "nex": { "#board": { "name": "11x11 board" @@ -5565,6 +5587,13 @@ "BAD_PLACE": "You may not create hard corners or naked attachments (see the rules for details).", "INITIAL_INSTRUCTIONS": "Select a point to place a piece." }, + "narrows": { + "INITIAL_INSTRUCTIONS": "Select a friendly piece.", + "INSTRUCTIONS": "Click on an orthogonally opposing piece to capture by replacement.", + "INVALID_SELECTION": "Click on a friendly piece.", + "CANNOT_MOVE": "This piece cannot move!", + "INVALID_MOVE": "Click on an opposing piece that is in the same row or column, adjacent or separated by a line of empty cells!" + }, "necklace": { "BAD_PASS": "You may not pass if legal moves are available.", "BLOCKS_PATH": "Placement at {{where}} blocks the only path of some free spaces to the edge.", @@ -5759,7 +5788,7 @@ "INITIAL_INSTRUCTIONS_ORIGINAL": "Place a stone on an empty cell. If a pinch capture is made, the captured stone flips color. This flip might cause subsequent flips, starting a chain-reaction until the board stabilizes." }, "pippinzip": { - "INITIAL_INSTRUCTIONS": "This is the auction phase. Either place one to three pieces on empty cells to improve 'Zip' position, or press 'pass' to finish the auction and be 'Zip'. 'Zip' then places one piece per turn and tries to connect all four board edges. Pip then places two non-adjacent pieces per turn and must orthogonally connect two opposite edges.", + "INITIAL_INSTRUCTIONS": "This is the auction phase. Either place one to three pieces on empty cells to improve 'Zip' position, or press 'pass' to finish the auction and be 'Zip'. 'Pip' then places two non-adjacent pieces per turn and must orthogonally connect two opposite edges. 'Zip' then places one piece per turn and tries to connect all four board edges.", "INSTRUCTIONS_PIP": "Place two friendly pieces not orthogonally adjacent to each other.", "INSTRUCTIONS_PIP_2": "Place the second friendly piece not orthogonally adjacent to the previous one.", "INSTRUCTIONS_ZIP": "Place one friendly piece.", diff --git a/src/games/index.ts b/src/games/index.ts index 3eb4c86f..2aa08e4a 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -256,6 +256,7 @@ import { UnaneGame, IUnaneState } from "./unane"; import { LinageGame, ILinageState } from "./linage"; import { PolluxGame, IPolluxState } from "./pollux"; import { PippinzipGame, IPippinzipState } from "./pippinzip"; +import { NarrowsGame, INarrowsState } from "./narrows"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -515,6 +516,7 @@ export { LinageGame, ILinageState, PolluxGame, IPolluxState, PippinzipGame, IPippinzipState, + NarrowsGame, INarrowsState, }; const games = new Map(); // Manually add each game to the following array [ @@ -642,7 +645,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."); @@ -1166,6 +1169,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new PolluxGame(...args); case "pippinzip": return new PippinzipGame(...args); + case "narrows": + return new NarrowsGame(...args); } return; } diff --git a/src/games/narrows.ts b/src/games/narrows.ts new file mode 100644 index 00000000..16e2aaa4 --- /dev/null +++ b/src/games/narrows.ts @@ -0,0 +1,439 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError, SquareOrthGraph } from "../common"; +import { connectedComponents } from "graphology-components"; +import i18next from "i18next"; + +type playerid = 1 | 2; // regarding pieces: 1 is the ball, 2 are the walls +type Direction = "N"|"E"|"S"|"W"; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface INarrowsState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class NarrowsGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Narrows", + uid: "narrows", + playercounts: [2], + version: "20260521", + dateAdded: "2026-05-27", + // i18next.t("apgames:descriptions.narrows") + description: "apgames:descriptions.narrows", + notes: "apgames:notes.narrows", + urls: [ + "https://www.marksteeregames.com/Narrows_rules.pdf", + ], + people: [ + { + type: "designer", + name: "Mark Steere", + urls: ["http://www.marksteeregames.com/"], + apid: "e7a3ebf6-5b05-4548-ae95-299f75527b3f", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>unify", "mechanic>move", "mechanic>capture", "board>shape>rect", "board>connect>rect", "components>simple>1per"], + variants: [ + { uid: "size-6", group: "board" }, // 5x6 + { uid: "#board", }, // 7 rows x 8 cols + { uid: "size-10", group: "board" }, // 9x10 + { uid: "size-12", group: "board" }, // 11x12 + { uid: "size-14", group: "board" }, // 13x14 + { uid: "size-16", group: "board" }, // 15x16 + ], + flags: ["pie", "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 boardSize = this.getBoardSize(); + //private grid: RectGrid; + private dots: [number, number][] = []; // if there are points here, the renderer will show them + + constructor(state?: INarrowsState | string, variants?: string[]) { + super(); + if (state === undefined) { + if ( (variants !== undefined) && (variants.length > 0) ) { + this.variants = [...variants]; + } + const board = new Map(); + const sz = this.getBoardSize(); + const g = new SquareOrthGraph(sz, sz-1); + + for (let x=0; x= 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.results = [...state._results]; + return this; + } + + public 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 8; + } + + public get graph(): SquareOrthGraph { + return new SquareOrthGraph(this.boardSize, this.boardSize-1); + } + + // return all the groups/roots of a given player (which may include empty cells) + private getGroups(player?: playerid): string[][] { + if (player === undefined) { player = this.currplayer; } + const oppPieces = [...this.board.entries()].filter(([,owner]) => owner !== player).map(pair => pair[0]); + const g = this.graph; + + for (const node of g.graph.nodes()) { + if (oppPieces.includes(node)) { // remove intersections/nodes occupied by the adversary + g.graph.dropNode(node); + } + } + + const groups : Array> = connectedComponents(g.graph); + const res: string[][] = []; + for (const group of groups) { + // remove groups with only empty spaces + if ( group.some(c => this.board.has(c)) ) { + res.push( group ); + } + } + return res; + } + + public moves(player?: playerid): string[] { + if (this.gameover) { return []; } + if (player === undefined) { player = this.currplayer; } + const g = this.graph; + const dirs: Direction[] = ["N", "S", "E", "W"]; + const moves = []; + + // Pieces capture by replacement an enemy stone in any orthogonal direction. + // The enemy stone must either be adjacent to the capturing stone, or + // separated from it by empty points. + for (const cell of g.graph.nodes()) { + if (this.board.has(cell) && this.board.get(cell) === player) { + const [x, y] = g.algebraic2coords(cell); + for (const dir of dirs) { + const ray = g.ray(x, y, dir).map(n => g.coords2algebraic(...n)); + for (const cell1 of ray) { + if ( this.board.has(cell1) ) { + if ( this.board.get(cell1) !== player ) { // a capturable opponent piece + moves.push(`${cell}-${cell1}`); + } + break; + } + } + } + } + } + + return moves.sort((a,b) => a.localeCompare(b)); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.graph.coords2algebraic(col, row); + let newmove = ""; + + if (move === "") { + newmove = cell; + } else if (move === cell) { + newmove = ""; + } else { + newmove = `${move}-${cell}`; + } + + const result = this.validateMove(newmove) as IClickResult; + 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}) + } + } + } + + private hasPrefix(moves: string[], partial: string): boolean { + return moves.some(str => str.startsWith(partial)); + } + + 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; + result.canrender = true; + result.message = i18next.t("apgames:validation.narrows.INITIAL_INSTRUCTIONS"); + return result; + } + + const moves = m.split('-'); + const allMoves = this.moves(); + + if (moves.length === 1) { + if (!this.board.has(m) || this.board.get(m) !== this.currplayer) { + result.valid = false; + result.message = i18next.t("apgames:validation.narrows.INVALID_SELECTION"); + return result; + } + if (! this.hasPrefix(allMoves, m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.narrows.CANNOT_MOVE"); + return result + } + result.valid = true; + result.complete = -1; // player still needs to move the piece + result.canrender = true; + result.message = i18next.t("apgames:validation.narrows.INSTRUCTIONS"); + return result; + } + + if (! allMoves.includes(m) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.narrows.INVALID_MOVE"); + return result + } + + result.valid = true; + result.complete = 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + private findPoints(cell: string): string[] { + return this.moves().filter(mv => mv.startsWith(cell)) + .map(mv => mv.split('-')[1]); + } + + public move(m: string, {partial = false, trusted = false} = {}): NarrowsGame { + 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) + } + if ( (! partial) && (! this.moves().includes(m)) ) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + this.results = []; + this.dots = []; + if (m === "") { return this; } + + if (partial) { + this.dots = this.findPoints(m).map(c => this.graph.algebraic2coords(c)); + return this; + } else { + this.dots = []; // otherwise delete the points and process the full move + } + + const moves = m.split('-'); + this.board.delete(moves[0]); + this.board.set(moves[1], this.currplayer); + this.results.push({ type: "move", from: moves[0], to: moves[1]}); + + if (partial) { return this; } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): NarrowsGame { + const p1Groups = this.getGroups(1); + const p2Groups = this.getGroups(2); + + if (p1Groups.length === 1 || p2Groups.length === 1) { + this.gameover = true; + if (p1Groups.length === 1 && p2Groups.length === 1) { + const prevplayer = this.currplayer % 2 + 1 as playerid; + this.winner = [prevplayer]; + } else { + this.winner = p1Groups.length === 1 ? [1] : [2]; + } + } + + if ( this.gameover ) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + + return this; + } + + public state(): INarrowsState { + return { + game: NarrowsGame.gameinfo.uid, + numplayers: this.numplayers, + variants: [...this.variants], + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: NarrowsGame.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 + let pstr = ""; + for (const row of g.listCells(true) as string[][]) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const contents = this.board.get(cell); + if (contents === 1) { + pieces.push("A"); + } else { + pieces.push("B"); + } + } else { + pieces.push("-"); + } + } + pstr += pieces.join(""); + } + + // Build rep + const rep: APRenderRep = { + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize-1 + }, + legend: { + A: { name: "piece", colour: 1 }, + B: { name: "piece", colour: 2 }, + }, + pieces: pstr + }; + + rep.annotations = []; + for (const move of this.results) { + if (move.type === "place") { + const [toX, toY] = g.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: toY, col: toX}]}); + } else 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}]}); + } + } + + // show the dots where the selected piece can move to + if (this.dots.length > 0) { + const points = []; + for (const [x,y] of this.dots) { + points.push({row: y, col: x}); + } + rep.annotations.push({type: "dots", + targets: points as [{row: number; col: number;}, ...{row: number; col: number;}[]]}); + } + + return rep; + } + + public clone(): NarrowsGame { + return new NarrowsGame(this.serialize()); + } +} diff --git a/src/games/pippinzip.ts b/src/games/pippinzip.ts index 2c16df14..1694667a 100644 --- a/src/games/pippinzip.ts +++ b/src/games/pippinzip.ts @@ -349,10 +349,11 @@ export class PippinzipGame extends GameBase { this.zipPlayer = this.currplayer; // auction phase ended, Zip is the 'taker' this.results = [{ type: "pass" }]; } else { + this.results = []; const p = this.inAuctionPhase() || this.isZipTurn() ? 3 : this.currplayer; for (const cell of m.split(',')) { this.board.set(cell, p); - this.results = [{type: "place", where:cell}]; + this.results.push( {type: "place", where:cell} ); } } @@ -455,11 +456,12 @@ export class PippinzipGame extends GameBase { let path = []; if ( this.inAuctionPhase() ) { - // if, strangely, the Zip pieces make a connection, the game is a draw + // if, strangely, the Zip pieces make a connection before the auction ends, + // the game is a win for the player that made the connection path = this.connectedZip(); if ( path.length > 0 ) { this.gameover = true; - this.winner = [1, 2]; + this.winner = [prevPlayer]; this.connPath = [...path]; this.results.push({ type: "eog" }); }