diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 2244d22f..e0b843d4 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -35,6 +35,7 @@ "bounce": "Manoeuvre your disconnected checkers into a single, orthogonally connected whole.", "boxes": "A territorial game where you try to complete boxes by drawing lines to connect the dots. If a box is claimed, you may make another move.", "breakthrough": "One of the simplest \"get to your opponent's home row\" games around. Pieces move and capture like chess pawns. First to the other home row wins. Also includes a \"Bombardment\" variant where instead of regular capture moves, one can detonate a piece, which destroys it and all pieces around it.", + "btt": "A game of pyramids branching across a chessboard, in which you gain points when opponents point at your pyramids, and lose them when you point at theirs.", "bug": "Fill the ecosystem with your bugs so much that you can no longer expand. On your turn, place a piece to start a new bug or grow an existing bug. Then, if any of your bugs are adjacent to enemy bugs of the same shape, they must eat them, and perform a bonus growth (if bonus growth is not possible, no eating occurs). This can result in chain reactions. If there are multiple choices of bugs that can eat, you may determine which bugs do it and in which order.", "buku": "Buku is a two-dimensional sowing game on a checkerboard where the colour corresponds to the pits of each player. The first player selects a row to collect, then pieces are sown from any square towards any orthogonal direction but never passing the same square twice. The second player does the same, but collects from columns. After sowing, any square with 3 or 4 pieces on their colour are captured. The game ends when one player gets more than half the total number of possible points, or all pieces are singletons (each player claims one point for every piece in their pit), or when the board position is repeated (other player claims the rest of the pieces). The first player gets an additonal piece on the first sow.", "byte": "Manoeuvre and merge stacks of checkers while attempting to be the one on top when the stack reaches a certain height.", @@ -242,6 +243,7 @@ "bao": "Moves in Bao can be very complex, involving multiple laps around the board and changing directions. The annotations are, therefore, sparse. The initial cell and direction are highlighted, and captured cells are also marked. But detailed annotation of movement is not possible. If you believe you have encountered a bug, please let us know in Discord.", "biscuit": "Game ends when, at the end of a round, someone has reached or surpassed the target score. Highest score wins. Draws are possible.\n\nWhen calculating biscuits, a single card cannot be counted more than once. For example, if the root card is a 6, and the end of the main line is also a 6 (a cross card), and you play a 6 above that cross card, you score the \"hot cross biscuit\" (top and bottom of the cross line match) but you don't *also* get a regular \"biscuit\" for matching the end of the main line (they're both the same card). Furthermore, cross lines don't get counted at all unless there are at least two cards in the line. So playing a new cross card (in our example, a 6) does not automatically score you a \"biscuit\" (playing a card on the main line that matches a cross line).\n\nMore 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.", "blooms": "The threshold value is X(n) = 5n, where n is the base of the hexhex board (e.g., the default board is base 6, so the threshold is 30).", + "btt": "Unusual numbers of players (3, 5, or 6) are supported, but on a normal rectangular board of an appropriate size rather than a Martian Chessboard.", "bug": "In this implementation, after the initial placement, if the player has bugs that must eat, the edible bugs will be highlighted. Eating and bonus grow will be performed simultaneously, so performing a bonus grow on a bug by selecting a legal space next to it will cause all highlighted edible bugs adjacent to it to disappear. It is possible to bonus grow onto a space that an edible bug is on. Note that this implies that when selecting a bug to perform the eating action, ALL edible bugs of the same shape next to it will be removed, and this might differ from some other implementations where there a separate edible bug selection phase, which may allow some edible bugs to be left behind for another bug to eat.", "calculus": "Click on an existing piece to move it, or click on a free point to place a new piece. There must be a sufficiently wide path from some edge of the board to the placement point. If your placed piece would overlap existing pieces, the system will try to \"snap\" the placed piece so it is touching all overlapped pieces.\n\nDashed lines show pieces that are touching. Solid lines show perimeters around legal areas. Pieces connected by solid lines may not be moved.", "chase": "Currently, most exchange moves will need to be hand edited. We're working on fixing this.", @@ -695,6 +697,16 @@ "name": "Bombardment" } }, + "btt": { + "arcade": { + "description": "Scaled down to 9 pyramids per player (from 15) for Pyramid Arcade and/or a quicker game.", + "name": "Arcade" + }, + "martian-go": { + "description": "The original game used slightly different rules for setup (large central roots with no null squares) and scoring (penalties only). When playing with more than 4 players, setup will instead be as in the standard game, but scoring will be as in Martian Go.", + "name": "Martian Go" + } + }, "bug": { "#board": { "name": "Size-4 board" @@ -3957,6 +3969,20 @@ "PARTIAL": "Provide the destination.", "TOOFAR": "Pieces can only move one space at a time." }, + "btt": { + "BAD_NULL": "A null square may not isolate a part of the board.", + "INITIAL_INSTRUCTIONS": "Select a cell to place one of your pyramids there.", + "NULL_INSTRUCTIONS": "Select a null square.", + "ROOT_INSTRUCTIONS": "Select a root square.", + "MALFORMED_MOVE": "The move \"{{move}}\" could not be parsed.", + "NO_DIAGONALS": "The pyramid cannot point at the cell \"{{cell}}\" because it is not orthogonally adjacent.", + "NO_STASH": "The requested pyramid is not in your stash.", + "NO_TARGET": "The pyramid is pointing at an empty cell.", + "NULL_TARGET": "The pyramid is pointing at a null square.", + "OCCUPIED": "The cell \"{{cell}}\" is already occupied.", + "OUT_OF_BOUNDS": "The pyramid is pointing off the board.", + "PARTIAL_MOVE": "Click again to change pyramid size, or select a neighboring cell to point at." + }, "bug": { "BAD_GROW": "Placement at {{where}} does not result in a valid bonus grow.", "GROW": "You have bugs that must eat and perform bonus grow. Select a space adjacent to a bug that eats to perform a bonus grow.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 2589bac6..3fccc761 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -194,6 +194,10 @@ "DELTA_SCORE_LOSS_one": "{{player}} lost {{delta}} point.", "DELTA_SCORE_LOSS_other": "{{player}} lost {{delta}} points.", "DELTASCORE": { + "btt_default": "{{player}} lost {{delta}} points to an opponent.", + "btt_default_one": "{{player}} lost {{delta}} point to an opponent.", + "btt_go": "{{player}} lost {{delta}} points.", + "btt_go_one": "{{player}} lost {{delta}} point.", "emu_excuse": "{{player}} plays The Excuse to offset their lowest-scoring bird ({{delta}} points).", "emu_score": "{{player}}'s bird with the following ranks scored {{delta}} points: {{ranks}}.", "quincunx": { @@ -381,6 +385,10 @@ "blockade_h": "{{player}} placed a horizontal fence at {{where}}.", "blockade_v": "{{player}} placed a vertical fence at {{where}}.", "blooms": "{{player}} placed a {{what}} piece at {{where}}.", + "btt": "{{player}} placed a {{what}} at {{where}}.", + "btt_small": "{{player}} placed a small at {{where}} facing {{how}}.", + "btt_medium": "{{player}} placed a medium at {{where}} facing {{how}}.", + "btt_large": "{{player}} placed a large at {{where}} facing {{how}}.", "bug_bonus_grow": "Upon capture, a bug grew at {{where}} to a size of {{size}}.", "bug_grow": "{{player}} grew a bug at {{where}} to a size of {{size}}.", "bug_new": "{{player}} introduced a new bug at {{where}}.", diff --git a/src/games/btt.ts b/src/games/btt.ts new file mode 100644 index 00000000..711cbd15 --- /dev/null +++ b/src/games/btt.ts @@ -0,0 +1,993 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IScores, IValidationResult, IStashEntry } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, Glyph } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { Direction, DirectionCardinal, RectGrid, reviver, oppositeDirections, orthDirections, UserFacingError } from "../common"; +import i18next from "i18next"; +import { SquareOrthGraph } from "../common/graphs"; +import {connectedComponents} from 'graphology-components'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const deepclone = require("rfdc/default"); + +interface ILegendObj { + [key: string]: Glyph | [Glyph, ...Glyph[]]; +} + +export type playerid = 1 | 2 | 3 | 4; +export type Size = 1 | 2 | 3; +export type CellContents = [playerid, Size, DirectionCardinal] | "NULL" | "ROOT"; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + scores: number[]; + stashes: Map; // sizes 1,2,3 +}; + +export interface IBTTState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +interface IBTTMove { + cell: string; + piece?: string; + size?: number; + direction?: string; + incomplete?: boolean; + valid: boolean; +} + +export class BTTGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Branches and Twigs and Thorns", + uid: "btt", + playercounts: [2, 3, 4, 5, 6], + version: "20260308", + dateAdded: "2026-03-08", + // i18next.t("apgames:descriptions.btt") + description: "apgames:descriptions.btt", + urls: [ + "https://boardgamegeek.com/boardgame/17298/branches-and-twigs-and-thorns", + "https://www.eblong.com/zarf/barsoom-go.html" + ], + people: [ + { + type: "designer", + name: "Andrew Plotkin", + urls: ["https://www.eblong.com"] + }, + { + type: "coder", + name: "mcd", + urls: ["https://mcdemarco.net/games/"], + apid: "4bd8317d-fb04-435f-89e0-2557c3f2e66c", + }, + ], + variants: [ + { uid: "arcade", group: "setup" }, + { uid: "martian-go", group: "setup" } + ], + categories: ["goal>score>maximize", "mechanic>place", "board>shape>rect", "board>connect>rect", "components>pyramids", "other>2+players"], + flags: ["player-stashes", "scores", "experimental"] + }; + + public numplayers!: number; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public scores!: number[]; + public stashes!: Map; + public stack!: Array; + public results: Array = []; + private highlight?: IBTTMove; + + constructor(state: number | IBTTState | string, variants?: string[]) { + super(); + if (typeof state === "number") { + this.numplayers = state; + if (variants !== undefined) { + this.variants = [...variants]; + } + + const fresh: IMoveState = { + _version: BTTGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + scores: [], + stashes: new Map() + }; + if ( this.variants.includes("martian-go") && this.numplayers < 5 ) { + //There are no nulls, and the roots are prefab. + fresh.board.set("d4", "ROOT"); + fresh.board.set("e4", "ROOT"); + if (this.numplayers === 3) { + fresh.board.set("d3", "ROOT"); + } else if (this.numplayers === 4) { + fresh.board.set("d5", "ROOT"); + fresh.board.set("e5", "ROOT"); + } + } + + for (let pid = 1; pid <= state; pid++) { + fresh.scores.push(0); + if ( this.variants.includes("arcade") ) + fresh.stashes.set(pid as playerid, [3,3,3]); + else + fresh.stashes.set(pid as playerid, [5,5,5]); + } + + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IBTTState; + } + if (state.game !== BTTGame.gameinfo.uid) { + throw new Error(`The BTT engine cannot process a game of '${state.game}'.`); + } + this.numplayers = state.numplayers; + this.variants = state.variants; + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): BTTGame { + 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 = deepclone(state.board) as Map; + this.lastmove = state.lastmove; + this.scores = [...state.scores]; + this.stashes = deepclone(state.stashes) as Map; + this.results = [...state._results]; + return this; + } + + public get boardHeight(): number { + if ( this.variants.includes("arcade") ) + return this.numplayers < 6 ? 5 : 10; + else + return this.numplayers * 2; + } + + public get boardWidth(): number { + if (this.variants.includes("arcade")) + return this.numplayers < 6 ? this.numplayers * 2 : 6; + else + return 8; + } + + public coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.boardHeight); + } + public algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.boardHeight); + } + + + /* helper functions */ + + private checkNull(cell: string): boolean { + //Determine whether a second (or third) null is legal + // (that is, it doesn't isolate any squares). + //Also returns true if there is no first null. + const firstNull = [...this.board.values()].filter(c => c === "NULL"); + if ( firstNull === undefined || firstNull.length === 0 ) + return true; + + //Because of the 6p case, make a graph in order to check + // that there aren't multiple connected components. + const gEmpties = this.getGraph(); + for (const node of gEmpties.graph.nodes()) { + if (this.board.has(node)) + gEmpties.graph.dropNode(node); + if (node === cell) + gEmpties.graph.dropNode(node); + } + + const emptyAreas : Array> = connectedComponents(gEmpties.graph); + + return emptyAreas.length < 2; + } + + private getGraph(): SquareOrthGraph { // just orthogonal connections + return new SquareOrthGraph(this.boardWidth, this.boardHeight); + } + + private getNeighborDir(cell: string): Direction { + //Returns a single direction if the move is unambiguous. + const nadirs = this.getNeighborDirs(cell); + if (nadirs.length === 1) + return nadirs[0]; + else + return "NE"; + } + + private getNeighborDirs(cell: string): DirectionCardinal[] { + const grid = new RectGrid(this.boardWidth, this.boardHeight); + const [x, y] = this.algebraic2coords(cell); + const neighdirs: DirectionCardinal[] = []; + + orthDirections.forEach((d) => { + const [xNext, yNext] = RectGrid.move(x, y, d); + if (grid.inBounds(xNext, yNext)) { + const neicell = this.coords2algebraic(xNext, yNext); + if ( this.board.has(neicell) && this.board.get(neicell) !== "NULL" ) + neighdirs.push(d); + } + }); + + return neighdirs; + } + + private getNextPyramid(previous: number): number { + //Gets the next size from the player's stash. + //The weirdness comes from size vs. stash index. + const stash = this.stashes.get(this.currplayer)!; + if (stash[previous % 3] > 0) + return previous % 3 + 1; + else + return this.getNextPyramid( previous + 1 ) + } + + //TODO: these get called alot; add a list of nulls/roots to the state instead? + private needNull(): boolean { + if ( this.variants.includes("martian-go") && this.numplayers < 5 ) + return false; + const nulls = [...this.board.values()].filter(c => c === "NULL").length; + return nulls < Math.floor(this.numplayers / 2); + } + + private needRoot(): boolean { + const roots = [...this.board.values()].filter(c => c === "ROOT").length; + return roots < Math.ceil(this.numplayers / 2); + } + + public parseMove(move: string): IBTTMove { + //Parse a move into an IBTTMove object. + //Does only structural validation. + //Expects at leat a cell. + + //Pretreat. + move = move.toUpperCase(); + move = move.replace(/\s+/g, ""); + + //Regexes. + const illegalChars = /[^A-JLNORSTUW0-9-]/; + const cellex = /^[a-j][1-9][0-2]?$/; + const sizex = /^[123]$/; + const direx = /^[NESW]$/; + + const mm: IBTTMove = { + cell: "", + incomplete: true, + valid: false + } + + //Check for legal characters. + if (move === "" || illegalChars.test(move)) { + mm.valid = false; + return mm; + } + + const parts = move.split("-"); + //Test for length. + if (parts.length > 2) { + mm.valid = false; + return mm; + } + + const cell = parts.shift()!.toLowerCase(); + if (! cellex.test(cell) ) { + //Malformed cell. + mm.valid = false; + return mm; + } else { + mm.cell = cell; + mm.valid = true; + } + + if ( parts.length > 0 ) { + const pisces = parts.shift(); + if (! pisces || pisces === "") { + //Malformed piece. + mm.valid = false; + return mm; + } else if ( pisces === "ROOT" || pisces === "NULL" ) { + mm.piece = pisces; + mm.incomplete = false; + return mm; + } else { + //Pisces has a length and is not root/null. + const size = pisces.charAt(0); + if (! sizex.test(size) ) { + //Malformed piece. + mm.valid = false; + return mm; + } else { + mm.size = Number(size); + } + if ( pisces.length > 1 ) { + //Pisces has a direction. + const dir = pisces.substring(1); + mm.direction = dir; + mm.incomplete = false; + if (! direx.test(dir) ) { + //We permit a bad direction for highlights. + mm.valid = false; + } + return mm; + } else { + //No direction. + mm.incomplete = true; + return mm; + } + } + } else { + //No piece. + mm.incomplete = true; + return mm; + } + + return mm; + } + + public pickleMove(pm: IBTTMove): string { + if ( ! pm.cell || pm.cell === "" ) { + //Trouble with highlights. + throw new Error("Could not pickle the move because it included no cell."); + } + + const move = [pm.cell]; + + if (pm.piece) + move.push(pm.piece); + else if (pm.size) { + if ( pm.direction ) + move.push(pm.size.toString() + pm.direction); + else + move.push(pm.size.toString()); + } + + return move.join("-"); + } + + + /* end helper functions */ + + public moves(player?: playerid): string[] { + const moves: string[] = []; + + if (this.gameover) { + return moves; + } + + if ( this.needNull() ) { + + for (let y = 0; y < this.boardHeight; y++) { + for (let x = 0; x < this.boardWidth; x++) { + const cell = this.coords2algebraic(x, y); + if ( this.board.has(cell) ) + continue; + if (! this.checkNull(cell) ) { + continue; + } + moves.push(`${cell}-NULL`); + } + } + return moves; + + } else if ( this.needRoot() ) { + + for (let y = 0; y < this.boardHeight; y++) { + for (let x = 0; x < this.boardWidth; x++) { + const cell = this.coords2algebraic(x, y); + if (! this.board.has(cell) ) { + moves.push(`${cell}-ROOT`); + } + } + } + return moves; + + } else { + + if (player === undefined) + player = this.currplayer; + + // Normal placement phase + const stashes = this.stashes.get(player)!; + const sizes: Size[] = []; + + for (let n = 0; n < 3; n++) + if (stashes[n] > 0) + sizes.push((n + 1) as Size); + + const grid = new RectGrid(this.boardWidth, this.boardHeight); + + for (const [cell, contents] of this.board.entries()) { + if (contents === "NULL") continue; + + const [x, y] = this.algebraic2coords(cell); + + for (const dir of orthDirections) { + + const [nx, ny] = RectGrid.move(x, y, dir); + if ( grid.inBounds(nx, ny) ) { + const nextCell = this.coords2algebraic(nx, ny); + if (!this.board.has(nextCell)) { + const oppDir = oppositeDirections.get(dir); + for (const size of sizes) { + moves.push(`${nextCell}-${size}${oppDir}`); + } + } + } + } + } + } + + return moves; + } + + 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 { + //Preliminary move format: cell-NULL|ROOT + //Preliminary move format: cell|cell|cell-size-direction + //Final move format: cell-size-direction + const cell = this.coords2algebraic(col, row); + + let newmove = ""; + + if ( this.needNull() ) { + newmove = `${cell}-NULL`; + } else if ( this.needRoot() ) { + newmove = `${cell}-ROOT`; + } else { + if (move === "") { + // Test if the cell is empty. + if ( this.board.has(cell) ) + return { + move, + valid: false, + message: i18next.t("apgames:validation.btt.OCCUPIED", { cell: cell }) + } + //Else start with the cell and the player's smallest pyramid size. + const firstsize = this.getNextPyramid(0); + newmove = `${cell}-${firstsize}`; + + //We always make the user click a direction to show that the pyramid is the intended size. + //But we guess the direction for display purposes. + this.highlight = this.parseMove(newmove + this.getNeighborDir(cell)); + } else { + const mm = this.parseMove(move); + if ( mm.cell === cell ) { + // We clicked on the same cell, change pyramid size. + const newsize = mm.size ? this.getNextPyramid(mm.size) : 1; + mm.size = newsize; + //This should work regardless of whether the move was already complete: + newmove = this.pickleMove(mm); + this.highlight = this.parseMove(newmove + this.getNeighborDir(cell)); + } else { + // We clicked on an adjacent piece (at col, row). + const [cx, cy] = this.algebraic2coords(mm.cell); + const bearing = RectGrid.bearing(cx, cy, col, row); + if (bearing && bearing.length === 2) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.btt.NO_DIAGONALS", { cell: cell }) + } + } else if (bearing) { + //This should work regardless of whether the move was already complete: + mm.direction = bearing; + newmove = this.pickleMove(mm); + } else { + newmove = move; // Do nothing. + } + } + } + } + + 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 }) + } + } + } + + public validateMove(mo: string): IValidationResult { + const result: IValidationResult = { valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER") }; + + mo = mo.replace(/\s+/g, ""); + + const nn = this.needNull(); + const nr = this.needRoot(); + + if (mo.length === 0) { + result.valid = true; + result.complete = -1; + if ( nn ) + result.message = i18next.t("apgames:validation.btt.NULL_INSTRUCTIONS"); + else if ( nr ) + result.message = i18next.t("apgames:validation.btt.ROOT_INSTRUCTIONS"); + else + result.message = i18next.t("apgames:validation.btt.INITIAL_INSTRUCTIONS"); + return result; + } + + const m = mo.toLowerCase(); + + //First, sanity test. + const movex = /^[a-j][1-9]?[0-2]?-?([123]?[nesw]?|null|root)?$/; + if (!movex.test(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.MALFORMED_MOVE", { move: mo }); + return result; + } + + const mm = this.parseMove(m); + + if (! mm.valid ) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", { move: mo }); + return result; + } + + if ( this.board.has(mm.cell) ) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.OCCUPIED", { cell: mm.cell }); + return result; + } + + if ( nn ) { + if (! mm.piece || mm.piece !== "NULL" ) { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.NULL_INSTRUCTIONS"); + return result; + } else if (! this.checkNull(mm.cell) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.BAD_NULL"); + return result; + } else { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + } else if ( nr ) { + if (! mm.piece || mm.piece !== "ROOT" ) { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.ROOT_INSTRUCTIONS"); + return result; + } else { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + } else if ( mm.piece !== undefined ) { + //Too unlikely an error for its own message. + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", { move: mo }); + return result; + } + + if (! mm.size ) { + result.valid = true; + result.complete = -1; + //Don't think we can render this case, but it doesn't happen IRL. + result.message = i18next.t("apgames:validation.btt.PARTIAL_MOVE"); + return result; + } else { + const stash = this.stashes.get(this.currplayer)!; + if ( stash[mm.size - 1] === 0 ) { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.NO_STASH"); + return result; + } + } + + if (! mm.direction ) { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.btt.PARTIAL_MOVE"); + return result; + } + + //Now we can check for an appropriate target in the direction. + const grid = new RectGrid(this.boardWidth, this.boardHeight); + + const [cx, cy] = this.algebraic2coords(mm.cell); + const [tx, ty] = RectGrid.move(cx, cy, mm.direction as Direction); + if ( grid.inBounds(tx, ty) ) { + const tcell = this.coords2algebraic(tx, ty); + if ( this.board.has(tcell) ) { + const target = this.board.get(tcell); + if ( target === "NULL" ) { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.NULL_TARGET"); + return result; + } else { + //valid! + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.NO_TARGET"); + return result; + } + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.btt.OUT_OF_BOUNDS"); + return result; + } + } + + public move(m: string, { partial = false, trusted = false } = {}): BTTGame { + 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); + } + } + + this.results = []; + + const mm = this.parseMove(m); + if ( mm.valid === false ) + return this; + + if ( mm.piece ) {// "NULL" || "ROOT" + this.board.set(mm.cell, mm.piece as CellContents); + this.results.push({ type: "place", where: mm.cell, what: mm.piece }); + } else { + const size = mm.size as Size; + if ( mm.direction ) { + const dir = mm.direction as DirectionCardinal; + + const stash = this.stashes.get(this.currplayer)!; + stash[size - 1]--; + this.stashes.set(this.currplayer, stash); + this.board.set(mm.cell, [this.currplayer, size, dir]); + this.results.push({ type: "place", where: mm.cell, what: size.toString(), how: dir }); + + // Handle pointing penalties + const grid = new RectGrid(this.boardWidth, this.boardHeight); + const [cx, cy] = this.algebraic2coords(mm.cell); + const [px, py] = RectGrid.move(cx, cy, dir); + + if ( grid.inBounds(px, py) ) { + const pcell = this.coords2algebraic(px, py); + const pcontents = this.board.get(pcell); + if (pcontents && Array.isArray(pcontents)) { + const opponent = pcontents[0]; + if (opponent !== this.currplayer) { + const oppSize = pcontents[1]; + this.scores[this.currplayer - 1] -= oppSize; + if (! this.variants.includes("martian-go") ) + this.scores[opponent - 1] += size; + this.results.push({ type: "deltaScore", delta: -oppSize }); + } + } + } + } + } + + if (partial) { return this; } + + //Reset highlight here. + this.highlight = undefined; + + 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(): BTTGame { + const maxPieces = this.numplayers * 16; + + if (this.board.size === maxPieces) { + this.gameover = true; + } else if (this.moves().length === 0) { + this.gameover = true; + } + + if (this.gameover === true) { + const maxScore = Math.max(...this.scores); + for (let i = 0; i < this.numplayers; i++) { + if (this.scores[i] === maxScore) { + this.winner.push((i + 1) as playerid); + } + } + this.results.push( + { type: "eog" }, + { type: "winners", players: [...this.winner] } + ); + } + + return this; + } + + public state(): IBTTState { + return { + game: BTTGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: BTTGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: deepclone(this.board) as Map, + scores: [...this.scores], + stashes: deepclone(this.stashes) as Map + }; + } + + public render(): APRenderRep { + // Build piece string + let pstr = ""; + let hX = -1; + let hY = -1; + if (this.highlight !== undefined) + [hX, hY] = this.algebraic2coords(this.highlight.cell); + + for (let row = 0; row < this.boardHeight; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (let col = 0; col < this.boardWidth; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { + const contents = this.board.get(cell)!; + if (contents === "NULL") { + pieces.push("X"); + } else if (contents === "ROOT") { + pieces.push("R"); + } else { + const [player, size, dir] = contents; + pieces.push("P" + player.toString() + size.toString() + dir); + } + } else if (hX === col && hY === row) { + pieces.push("H" + this.highlight!.size + this.highlight!.direction); + } else { + pieces.push("-"); + } + } + pstr += pieces.join(","); + } + + const token: [Glyph, ...Glyph[]] = [ + { name: "piece-borderless", colour: "_context_fill", scale: 0.5 }, + { name: "piece-borderless", colour: "_context_background", scale: 0.3 } + ] + + const tokens: [Glyph, ...Glyph[]] = [ + { + name: "piece-square-borderless", + colour: "_context_background", + opacity: 0 + } + ]; + + const nudges: [number,number][] = [[-1, -1], [-1, 1], [1, -1], [1, 1]]; + + nudges.forEach( nudge => { + tokens.push({ + name: "piece-borderless", + colour: "_context_fill", + scale: 0.5, + nudge: { + dx: nudge[0] * 225, + dy: nudge[1] * 225, + } + }); + tokens.push({ + name: "piece-borderless", + colour: "_context_background", + scale: 0.3, + nudge: { + dx: nudge[0] * 375, + dy: nudge[1] * 375, + } + }); + }); + + const myLegend: ILegendObj = { + "X": token, + "R": tokens + }; + + + + + const rotations: Map = new Map([ + ["N", 0], + ["E", 90], + ["S", 180], + ["W", -90], + ]); + const sizeNames = ["small", "medium", "large"]; + for (let player = 1; player <= this.numplayers; player++) { + for (const size of [1, 2, 3]) { + for (const [dir, angle] of rotations.entries()) { + const pyraglyph: Glyph = { + name: "pyramid-flat-" + sizeNames[size - 1], + colour: player, + rotate: angle, + }; + myLegend["P" + player.toString() + size.toString() + dir] = pyraglyph; + } + } + } + + if (this.highlight !== undefined) { + //The shadow pyramid knows... + myLegend["H" + this.highlight.size!.toString() + this.highlight.direction] = { + name: "pyramid-flat-" + sizeNames[this.highlight.size! - 1], + colour: this.currplayer, + rotate: rotations.has(this.highlight.direction!) ? rotations.get(this.highlight.direction!) : 45, + opacity: 0.2 + }; + } + + // Build rep + const rep: APRenderRep = { + board: { + style: "squares-checkered", + width: this.boardWidth, + height: this.boardHeight, + }, + legend: myLegend, + pieces: pstr + }; + + // Add annotations for the last move + if (this.results.length > 0) { + rep.annotations = []; + for (const move of this.results) { + if (move.type === "place" || move.type === "move") { + const mSafe = move as { where?: string; to?: string }; + const [x, y] = this.algebraic2coords(mSafe.where || mSafe.to!); + 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 += "**Stashes**\n\n"; + for (let n = 1; n <= this.numplayers; n++) { + const stash = this.stashes.get(n as playerid); + if (stash) { + status += `Player ${n}: ${stash[0]} small, ${stash[1]} medium, ${stash[2]} large\n\n`; + } + } + + status += "**Scores**\n\n"; + for (let n = 1; n <= this.numplayers; n++) { + const score = this.scores[n - 1]; + status += `Player ${n}: ${score}\n\n`; + } + + return status; + } + + public getPlayersScores(): IScores[] { + return [{ name: i18next.t("apgames:status.SCORES"), scores: this.scores }] + } + + public getPlayerStash(player: number): IStashEntry[] | undefined { + const stash = this.stashes.get(player as playerid); + if (stash !== undefined) { + return [ + { count: stash[0], glyph: { name: "pyramid-flat-small", colour: player }, movePart: "1" }, + { count: stash[1], glyph: { name: "pyramid-flat-medium", colour: player }, movePart: "2" }, + { count: stash[2], glyph: { name: "pyramid-flat-large", colour: player }, movePart: "3" } + ]; + } + return; + } + + protected getMoveList(): APMoveResult[] { + return this.getMovesAndResults(["move", "capture", "orient", "eog", "winners"]) as APMoveResult[]; + } + + public getPlayerScore(player: number): number { + return this.scores[player - 1]; + } + + public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { + let resolved = false; + switch (r.type) { + case "deltaScore": + if ( this.variants.includes("martian-go") && r.delta === -1 ) + node.push(i18next.t("apresults:DELTASCORE.btt_go_one", {player, delta: r.delta! * -1})); + else if ( this.variants.includes("martian-go") ) + node.push(i18next.t("apresults:DELTASCORE.btt_go", {player, delta: r.delta! * -1})); + else if ( r.delta === -1 ) + node.push(i18next.t("apresults:DELTASCORE.btt_default_one", {player, delta: r.delta! * -1})); + else + node.push(i18next.t("apresults:DELTASCORE.btt_default", {player, delta: r.delta! * -1})); + resolved = true; + break; + } + switch (r.type) { + case "place": + if (r.what === "1") + node.push(i18next.t("apresults:PLACE.btt_small", {player, what: r.what, where: r.where, how: r.how})); + else if (r.what === "2") + node.push(i18next.t("apresults:PLACE.btt_medium", {player, what: r.what, where: r.where, how: r.how})); + else if (r.what === "3") + node.push(i18next.t("apresults:PLACE.btt_large", {player, what: r.what, where: r.where, how: r.how})); + else + node.push(i18next.t("apresults:PLACE.btt", {player, what: r.what!.toLowerCase(), where: r.where, how: r.how})); + resolved = true; + break; + } + return resolved; + } + + public clone(): BTTGame { + return new BTTGame(this.serialize()); + } +} diff --git a/src/games/index.ts b/src/games/index.ts index 107b3ab8..3957f70e 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -231,6 +231,7 @@ import { MagnateGame, IMagnateState } from "./magnate"; import { ProductGame, IProductState } from "./product"; import { GoGame, IGoState } from "./go"; import { StilettoGame, IStilettoState } from "./stiletto"; +import { BTTGame, IBTTState } from "./btt"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -464,7 +465,8 @@ export { MagnateGame, IMagnateState, ProductGame, IProductState, GoGame, IGoState, - StilettoGame, IStilettoState + StilettoGame, IStilettoState, + BTTGame, IBTTState }; const games = new Map(); // Manually add each game to the following array [ @@ -581,7 +583,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."); @@ -1055,6 +1057,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new GoGame(...args); case "stiletto": return new StilettoGame(...args); + case "btt": + return new BTTGame(args[0], ...args.slice(1)); } return; } diff --git a/test/games/btt.test.ts b/test/games/btt.test.ts new file mode 100644 index 00000000..633f1af6 --- /dev/null +++ b/test/games/btt.test.ts @@ -0,0 +1,211 @@ +import "mocha"; +import { expect } from "chai"; +import { BTTGame } from "../../src/games"; + +describe("Branches and Twigs and Thorns", () => { + + it("Does initial board config", () => { + for (let p = 2; p <= 6; p++) { + const g = new BTTGame(p); + expect(g.moves().length).eq(p * 16); + + const ga = new BTTGame(p, ["arcade"]); + expect(ga.moves().length).eq(p * 10); + + const gg = new BTTGame(p, ["martian-go"]); + if (p > 4) { + //No Martian Go setup in these cases. + expect(gg.moves().length).eq(p * 16); + } else if (p === 2 || p === 4) { + //These two cases are symmetric. (No nulls.) + //(2 spots per player, three pyramid types) + expect(gg.moves().length).eq(p * 2 * 3); + } else { + //Three players get a funky root. (No nulls.) + expect(gg.moves().length).eq(8 * 3); + } + } + }); + + const g = new BTTGame(4); + it("Parses moves", () => { + //The parser is structural so don't need to layout or conform to + // any particular board setup. + + expect(g.parseMove("d4-NULL")).to.deep.equal({ + cell: "d4", + incomplete: false, + piece: "NULL", + valid: true + }); + expect(g.parseMove("f3-ROOT")).to.deep.equal({ + cell: "f3", + incomplete: false, + piece: "ROOT", + valid: true + }); + + expect(g.parseMove("c2-2W")).to.deep.equal({ + cell: "c2", + direction: "W", + incomplete: false, + size: 2, + valid: true + }); + expect(g.parseMove("d12-3E")).to.deep.equal({ + cell: "d12", + direction: "E", + incomplete: false, + size: 3, + valid: true + }); + + + expect(g.parseMove("")).to.deep.equal({ + cell: "", + incomplete: true, + valid: false + }); + expect(g.parseMove("b2")).to.deep.equal({ + cell: "b2", + incomplete: true, + valid: true + }); + expect(g.parseMove("b2-")).to.deep.equal({ + cell: "b2", + incomplete: true, + valid: false + }); + expect(g.parseMove("b2-1")).to.deep.equal({ + cell: "b2", + incomplete: true, + size: 1, + valid: true + }); + expect(g.parseMove("b2-1-")).to.deep.equal({ + cell: "", + incomplete: true, + valid: false + }); + expect(g.parseMove("b2-1S")).to.deep.equal({ + cell: "b2", + direction: "S", + incomplete: false, + size: 1, + valid: true + }); + expect(g.parseMove("b2-1SW")).to.deep.equal({ + cell: "b2", + direction: "SW", + incomplete: false, + size: 1, + valid: false + }); + }); + + it("Pickles moves", () => { + expect(g.pickleMove(g.parseMove(" a1-null "))).eq("a1-NULL"); + expect(g.pickleMove(g.parseMove(" B2- 1 s "))).eq("b2-1S"); + }); + + it("Does initial setup moves (2P)", () => { + const g = new BTTGame(2); + + let moves = g.moves(); + expect(moves.length).eq(32); // 4x8 board + expect(moves[0].endsWith("-NULL")).eq(true); + + g.move(moves[0]); // Place NULL + + moves = g.moves(); + expect(moves.length).eq(31); + expect(moves[0].endsWith("-ROOT")).eq(true); + + g.move(moves[0]); // Place ROOT + + moves = g.moves(); + // The ROOT placed allows placement of pieces facing it + // A root has up to 4 empty neighbors, and for each we can place size 1, 2, or 3. + expect(moves[0]).match(/^[a-h][1-4]-[123][NESW]$/); + }); + + it("Does initial setup moves (4P)", () => { + const g = new BTTGame(4); + + // P1 places Null + let moves = g.moves(); + expect(moves.length).eq(64); // 8x8 board + expect(moves[0].endsWith("-NULL")).eq(true); + + g.move("a1-NULL"); // Place first NULL at a1 + + // P2 places second Null + moves = g.moves(); + // Since P1 placed at a1, a2 and b1 might be restricted if the badnulls logic triggers. + // Wait, for 4P, badnulls from a2 are b1, from a7 are b8, etc. a1 has no badnulls. + expect(moves.length).eq(63); + expect(moves[0].endsWith("-NULL")).eq(true); + + g.move("a2-NULL"); // Place second NULL at a2 + + // P3 places first Root + moves = g.moves(); + expect(moves.length).eq(62); + expect(moves[0].endsWith("-ROOT")).eq(true); + + g.move("h1-ROOT"); // Place ROOT + + // P4 places second Root + moves = g.moves(); + expect(moves.length).eq(61); + expect(moves[0].endsWith("-ROOT")).eq(true); + + g.move("h2-ROOT"); // Place ROOT + + moves = g.moves(); + expect(moves[0]).match(/^[a-h][1-8]-[123][NESW]$/); + }); + + it("Scores and validates", () => { + const g = new BTTGame(2); + + g.move("h4-NULL"); + g.move("g4-ROOT"); + + // P1 places a size 1 piece at f4 pointing E at the root (g4) + expect(g.validateMove("f4-1E").valid).eq(true); + g.move("f4-1E"); + + expect(g.scores[0]).eq(0); // Pointing at ROOT has no penalty + + // P2 places a size 2 piece at e4 pointing E at P1's size 1 piece (f4) + expect(g.validateMove("e4-2E").valid).eq(true); + g.move("e4-2E"); + + // P2 pointed at P1's size 1. P2 loses 1, P1 gains 2. + expect(g.scores[1]).eq(-1); + expect(g.scores[0]).eq(2); + + // However, if P1's size 1 piece had a friendly piece adjacent, pointing at it would be illegal! + // Right now P1's size 1 unit handles itself. + // Let P1 place a size 3 piece at f3 pointing N at P1's size 1 piece (f4). + g.move("f3-3N"); + + // Now P1 has pieces at f4 and f3. + // If P2 tries to point something at f3... wait, pointing at someone else's piece is only forbidden + // if they ALSO have a friendly piece adjacent. + // Let's verify standard validity + expect(g.validateMove("f2-1N").valid).eq(true); + }); + + it("Goes to eleven", () => { + const g = new BTTGame(6); + g.parseMove("b11-NULL"); + g.validateMove("b11-NULL"); + for (let i = 0; i < 96; i++) + g.move(g.randomMove()); + + expect(g.gameover).eq(true); + }); + +});