From 491039416e4f2f7160f6267023addbc400970958 Mon Sep 17 00:00:00 2001 From: Aaron Dalton Date: Thu, 29 Jan 2026 14:32:51 -0700 Subject: [PATCH 1/3] Bamboo: Live testing --- locales/en/apgames.json | 13 ++ src/games/bamboo.ts | 374 ++++++++++++++++++++++++++++++++++++++++ src/games/index.ts | 9 +- 3 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 src/games/bamboo.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index f10a620e..5f553df6 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.", @@ -3622,6 +3631,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/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/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; } From 2156e62327e40ee06d32da730eadd4642462eb91 Mon Sep 17 00:00:00 2001 From: Christopher Field Date: Fri, 30 Jan 2026 10:23:44 -0500 Subject: [PATCH 2/3] Adding a proper prison swap display option to Asli --- locales/en/apgames.json | 6 ++++++ src/games/asli.ts | 13 ++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 5f553df6..753ef117 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -2809,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.", 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, From d2953db462638d42bd41c576cad9fe55164f215f Mon Sep 17 00:00:00 2001 From: "M. C. DeMarco" Date: Fri, 30 Jan 2026 11:03:10 -0500 Subject: [PATCH 3/3] Frogger: Add missing claim log message --- src/games/frogger.ts | 3 +++ 1 file changed, 3 insertions(+) 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) {