diff --git a/locales/en/apgames.json b/locales/en/apgames.json index f10a620e..753ef117 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -20,6 +20,7 @@ "attangle": "Attangle is the final entry in Dieter Stein's stacking trilogy. Place and move pieces to build stacks. First person to build three triple stacks wins. The \"Grand Attangle\" variant is also implemented.", "ayu": "Unification game where snaky groups of pieces crawl toward their friends and eventually coalesce. If you cannot make a move on your turn, you win. This occurs when you have only one group or there is no free path between any of your groups.", "azacru": "A territory-control game where directional pieces navigate a subdivided board to claim cells.", + "bamboo": "Place stones such that no group has more pieces than the number of your groups on the board. Then, be the last player able to move.", "bao": "A traditional mancala-style sowing game from East Africa where you attempt to eliminate all pieces from the opposing front row or leave them with no legal moves.", "basalt": "A triangular connection game played on the side of a volcano, where lava and basalt vye to connect the three sides first.", "bide": "Up to six players take turns placing pieces on the board, pushing adjacent pieces outward. One can also pass to build up a number of pieces that can be placed at once later. Pieces closer to the centre are more valuable. Highest-scoring group wins!", @@ -517,6 +518,14 @@ "name": "17x17 board" } }, + "bamboo": { + "hex6": { + "name": "Hexhex 6 (91 spaces)" + }, + "#board": { + "name": "Hexhex 7 (127 spaces)" + } + }, "bao": { "kujifunza": { "description": "The \"beginner\" version with no \"kunamua\" setup phase. The game starts with two stones in each pit.", @@ -2800,6 +2809,12 @@ "name": "Hide threatened" } }, + "asli": { + "swap-prison": { + "description": "Swap the color of the pieces in the prison.", + "name": "Swap prison colors" + } + }, "atoll": { "show-labels": { "description": "Show labels for each cell.", @@ -3622,6 +3637,10 @@ "REORIENT": "Click a neighbouring cell or edge zone to select the direction you want to face.", "TOO_FAR": "You can't move further than your power of movement (currently {{pom}})." }, + "bamboo": { + "BAD_PLACE": "None of your groups may contain more stones than number of groups of your colour on the board.", + "INITIAL_INSTRUCTIONS": "Select an empty cell to place a stone." + }, "bao": { "BAD_TAX": "You may only \"tax\" the nyumba if it is the only occupied space on your front row.", "BLOCKED": "You may not begin a kutakata turn with a pit that is blocked (is in kutakatia).", diff --git a/src/games/asli.ts b/src/games/asli.ts index 2354206b..b9bffb6d 100644 --- a/src/games/asli.ts +++ b/src/games/asli.ts @@ -1,4 +1,4 @@ -import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IValidationResult, IScores } from "./_base"; +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IValidationResult, IScores, IRenderOpts } from "./_base"; import { APGamesInformation } from "../schemas/gameinfo"; import { APRenderRep, BoardBasic, MarkerDots, RowCol } from "@abstractplay/renderer/src/schemas/schema"; import { APMoveResult } from "../schemas/moveresults"; @@ -67,7 +67,8 @@ export class AsliGame extends GameBase { {uid: "setkomi", group: "komi"}, ], categories: ["goal>immobilize", "mechanic>place", "mechanic>capture", "board>shape>rect", "board>connect>rect", "components>simple>1per"], - flags: ["custom-buttons", "no-moves", "custom-randomization", "scores", "custom-colours"] + flags: ["custom-buttons", "no-moves", "custom-randomization", "scores", "custom-colours"], + displays: [{uid: "swap-prison"}] }; public coords2algebraic(x: number, y: number): string { @@ -683,7 +684,9 @@ export class AsliGame extends GameBase { }; } - public render(): APRenderRep { + public render(opts?: IRenderOpts): APRenderRep { + const swapPrison = (opts !== undefined && opts.altDisplay !== undefined && opts.altDisplay === "swap-prison"); + // Build piece string let pstr = ""; for (let row = 0; row < this.boardsize; row++) { @@ -713,7 +716,7 @@ export class AsliGame extends GameBase { if (hasPrison) { prisonPiece.push({ name: "piece", - colour: this.prison[0] > 0 ? this.getPlayerColour(1) : this.getPlayerColour(2), + colour: this.prison[0] > 0 ? this.getPlayerColour(swapPrison ? 2 : 1) : this.getPlayerColour(swapPrison ? 1 : 2), scale: 0.85, }); prisonPiece.push({ @@ -721,7 +724,7 @@ export class AsliGame extends GameBase { colour: { func: "bestContrast", fg: ["_context_background", "_context_fill", "_context_label"], - bg: this.prison[0] > 0 ? this.getPlayerColour(1) : this.getPlayerColour(2), + bg: this.prison[0] > 0 ? this.getPlayerColour(swapPrison ? 2 : 1) : this.getPlayerColour(swapPrison ? 1 : 2), }, scale: 0.75, rotate: null, diff --git a/src/games/bamboo.ts b/src/games/bamboo.ts new file mode 100644 index 00000000..bf43cc19 --- /dev/null +++ b/src/games/bamboo.ts @@ -0,0 +1,374 @@ +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 { HexTriGraph, reviver, UserFacingError } from "../common"; +import i18next from "i18next"; +import { IGraph } from "../common/graphs"; +import { connectedComponents } from "graphology-components"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const deepclone = require("rfdc/default"); + +export type playerid = 1|2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface IBambooState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class BambooGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Bamboo", + uid: "bamboo", + playercounts: [2], + version: "20250119", + dateAdded: "2025-01-20", + // i18next.t("apgames:descriptions.bamboo") + description: "apgames:descriptions.bamboo", + urls: ["https://www.marksteeregames.com/Bamboo_rules.pdf"], + people: [ + { + type: "designer", + name: "Mark Steere", + urls: ["http://www.marksteeregames.com/"], + apid: "e7a3ebf6-5b05-4548-ae95-299f75527b3f", + }, + { + type: "coder", + name: "Aaron Dalton (Perlkönig)", + urls: [], + apid: "124dd3ce-b309-4d14-9c8e-856e56241dfe", + }, + ], + variants: [ + {uid: "hex6", group: "board"}, + {uid: "#board"}, + ], + categories: ["goal>immobilize", "mechanic>place", "board>shape>hex", "board>connect>hex", "components>simple>1per"], + flags: ["automove", "limited-pieces", "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 = []; + + constructor(state?: IBambooState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const board = new Map(); + const fresh: IMoveState = { + _version: BambooGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IBambooState; + } + if (state.game !== BambooGame.gameinfo.uid) { + throw new Error(`The Bamboo 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): BambooGame { + 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.results = [...state._results]; + this.currplayer = state.currplayer; + this.board = deepclone(state.board) as Map; + this.lastmove = state.lastmove; + return this; + } + + public get graph(): IGraph { + if (this.variants.includes("hex6")) { + return new HexTriGraph(6, 11); + } else { + return new HexTriGraph(7, 13); + } + } + + public get boardsize(): number { + if (this.variants.includes("hex6")) { + return 6; + } + return 7; + } + + private getGroups(player?: playerid, board?: Map): string[][] { + if (board === undefined) { + board = this.board; + } + if (player === undefined) { + player = this.currplayer; + } + const g = this.graph.graph; + for (const node of [...g.nodes()]) { + if (!board.has(node) || board.get(node)! !== player) { + g.dropNode(node); + } + } + const conn = connectedComponents(g); + return conn; + } + + private canPlaceAt(cell: string, player?: playerid): boolean { + if (player === undefined) { + player = this.currplayer; + } + const board = deepclone(this.board) as Map; + board.set(cell, player); + const conn = this.getGroups(player, board); + const found = conn.find(grp => grp.includes(cell))!; + return found.length <= conn.length; + } + + public moves(): string[] { + if (this.gameover) { return []; } + + const moves: string[] = []; + + const g = this.graph; + const empties = g.graph.nodes().filter(c => !this.board.has(c)); + for (const cell of empties) { + if (this.canPlaceAt(cell)) { + moves.push(cell); + } + } + + return moves.sort((a,b) => a.localeCompare(b)); + } + + public randomMove(): string { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const g = this.graph; + const cell = g.coords2algebraic(col, row); + const newmove = cell; + const result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.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}) + } + } + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.bamboo.INITIAL_INSTRUCTIONS") + return result; + } + + const allMoves = this.moves(); + if (!allMoves.includes(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.bamboo.BAD_PLACE"); + return result; + } + + // Looks good + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {trusted = false} = {}): BambooGame { + 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 (! this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + this.results = []; + this.board.set(m, this.currplayer); + this.results.push({type: "place", where: m}); + + // update currplayer + this.lastmove = m; + let newplayer = (this.currplayer as number) + 1; + if (newplayer > this.numplayers) { + newplayer = 1; + } + this.currplayer = newplayer as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): BambooGame { + const prev = this.currplayer === 1 ? 2 : 1; + if (this.moves().length === 0) { + this.gameover = true; + this.winner = [prev]; + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): IBambooState { + return { + game: BambooGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: BambooGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: deepclone(this.board) as Map + }; + } + + 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"; + } + pstr += row.map(c => this.board.has(c) ? this.board.get(c)! === 1 ? "A" : "B" : "-").join(""); + } + + // Build rep + const rep: APRenderRep = { + board: { + style: "hex-of-hex", + minWidth: this.boardsize, + maxWidth: (this.boardsize * 2) - 1, + }, + legend: { + A: { + name: "piece", + colour: 1 + }, + B: { + name: "piece", + colour: 2 + } + }, + pieces: pstr + }; + + // Add annotations + if (this.results.length > 0) { + rep.annotations = []; + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = g.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + } + } + + return rep; + } + + public status(): string { + let status = super.status(); + + if (this.variants !== undefined) { + status += "**Variants**: " + this.variants.join(", ") + "\n\n"; + } + + status += "**Group Counts**: " + this.getPlayersScores()[0].scores.join(", ") + "\n\n"; + + return status; + } + + public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { + let resolved = false; + switch (r.type) { + case "place": + node.push(i18next.t("apresults:PLACE.nowhat", {player, where: r.where})); + resolved = true; + break; + } + return resolved; + } + + public getPlayersScores(): IScores[] { + return [ + { name: i18next.t("apgames:status.GROUPCOUNT"), scores: [this.getGroups(1).length, this.getGroups(2).length] } + ] + } + + public clone(): BambooGame { + return Object.assign(new BambooGame(), deepclone(this) as BambooGame); + } +} diff --git a/src/games/frogger.ts b/src/games/frogger.ts index 38d3f64f..469f0a0f 100644 --- a/src/games/frogger.ts +++ b/src/games/frogger.ts @@ -1722,6 +1722,9 @@ export class FroggerGame extends GameBase { if (subIFM.from) { results.push({type: "move", from: subIFM.from, to: subIFM.to!, what: subIFM.card!, how: "back"}); + } else { + //This is the blocked case. + results.push({type: "claim", what: subIFM.card!}); } } else if (subIFM.to) { if (partial) { diff --git a/src/games/index.ts b/src/games/index.ts index 0da6c409..bb96f31c 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -223,6 +223,7 @@ import { EnsoGame, IEnsoState } from "./enso"; import { RincalaGame, IRincalaState } from "./rincala"; import { WaldMeisterGame, IWaldMeisterState } from "./waldmeister"; import { WunchunkGame, IWunchunkState } from "./wunchunk"; +import { BambooGame, IBambooState } from "./bamboo"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -449,6 +450,7 @@ export { RincalaGame, IRincalaState, WaldMeisterGame, IWaldMeisterState, WunchunkGame, IWunchunkState, + BambooGame, IBambooState, }; const games = new Map(); // Manually add each game to the following array [ @@ -561,7 +564,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."); @@ -1019,6 +1022,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new WaldMeisterGame(...args); case "wunchunk": return new WunchunkGame(args[0], ...args.slice(1)); + case "bamboo": + return new BambooGame(...args); } return; }