diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 48900166..4d53637a 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -91,6 +91,7 @@ "focus": "A Sid Sackon classic. One may move stacks of pieces as many spaces as pieces in the stack. Once stacks become higher than 5 pieces, the bottom-most pieces are captured. The last person able to move a stack wins.", "four": "A game in which four shapes in four colours are placed in a virtual grid according to four constraints. Be the last player able to place.", "fourinarow": "A simple game of getting four (or more) in a row with the twist: there is gravity.", + "forms": "Players collapse their own groups to stalemate themselves.", "frames": "A simple \"get into the head of your opponent\" game where both players simultaneously select an empty cell on a Go board. The placements define a rectangle. The player with the most stones within that rectangle gets a point. Play to a certain number of points.", "frogger": "A racing game for the Decktet inspired by the eponymous videogame and the classic boardgame Cartagena. Move your frogs along a dangerous path to reach home first.", "furl": "Either furl up a row of your discs, or unfurl a stack. If you have a disc in your end row at the start of your turn, you win.", @@ -283,6 +284,7 @@ "emu": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com). Cards in players' hands are hidden from observers, and they are hidden from opponents until the deck is empty, at which point the players have perfect information, so the hands are revealed. Cards drawn from the discard pile are also always visible to opponents.", "entropy": "In this implementation, the players play two games simultaneously but with a single shared stream of randomized pieces. Each player places a piece on their *opponent's* Order board and then makes a move on their *own* Order board; players thus act as both Order and Chaos at the same time. The player with the greatest score wins! Since both players had the exact same placement choices, this provides the cleanest measure of relative skill.", "exxit": "Translations of the rules tend to omit certain nuances. This implementation conforms with the original French edition of the rules.\n\nBecause the board is built out as you play in irregular shapes, the hexes are labelled numerically instead of algebraically. This ensures that the labels don't change as the map grows.", + "forms": "Forms was designed by Steven Meyers in 2007. After each move, all pieces outside the group that contains the current moved pieces are captured. The last player to move loses (so Forms is a misère game). The first version allowed a piece to capture any opponent's piece. Later, in 2011, Meyers developed a second version, **Advanced Forms**, where captures were only possible to orthogonal adjacent pieces, but a piece could also move to an empty cell if both were connected by a sequence of orthogonal steps over empty cells. The setup also changed to a checkered pattern. This second version is the default variant here in AP. More information can be found at [WAG](https://jpneto.github.io/world_abstract_games/forms.htm).", "frogger": "As in other Decktet games at Abstract Play, the deck is displayed at the bottom of the board and includes both cards in the deck and unknown cards in other players' hands. After the first hand, all cards are drawn from the open draw pool, so hands gradually become open. The discards pile is also displayed.\n\nDue to how randomization works at Abstract Play, forced passes are needed for a player to refill the draw pool in the middle of his turn. Passes are handled automatically by the server, but a there's also a draw pool variant that avoids forced passing if desired.\n\nThe Crocodiles variant is by Jorge Arroyo, the translator of the English rules. The Advanced rules and other minor variants are by P. D. Magnus; they appear in The Decktet Book, where the game is called Xing.", "garden": "To make it very clear what happened on a previous turn, each move is displayed over four separate boards. The first board shows the game after the piece was first placed. The second board shows the state after adjacent pieces were flipped. The third board shows any harvests. The fourth board is the final game state and is where you make your moves.\n\nIn our implementation, black is always the \"tome\" or tie-breaker colour. The last player to harvest black will have a `0.1` after their score.", "gyges": "The goal squares are adjacent to all the cells in the back row. The renderer cannot currently handle \"floating\" cells.", @@ -1312,6 +1314,34 @@ "name": "Larger board: 11x11" } }, + "forms": { + "#board": { + "name": "8x8 board" + }, + "size-10": { + "name": "10x10 board" + }, + "size-12": { + "name": "12x12 board" + }, + "size-14": { + "name": "14x14 board" + }, + "size-16": { + "name": "16x16 board" + }, + "size-18": { + "name": "18x18 board" + }, + "#ruleset": { + "description": "Pieces capture or path-slide", + "name": "Advanced Forms" + }, + "original": { + "description": "Pieces can only capture", + "name": "Forms (original rules)" + } + }, "four": { "simplified": { "description": "You only play with three pieces of each colour, and the grid is constrained to 7x7 instead of 9x9.", @@ -5110,6 +5140,14 @@ "frames": { "INITIAL_INSTRUCTIONS": "Click an empty cell to add a piece to the board." }, + "forms": { + "INITIAL_INSTRUCTIONS": "Click a friendly piece.", + "INSTRUCTIONS": "Click on an orthogonally opposing piece to capture by replacement, or move to an path-adjacent empty cell.", + "INVALID_SELECTION": "Click on a friendly piece!", + "NO_MOVES": "This piece does not have valid moves!", + "INVALID_MOVE": "The piece must either: (a) capture an orthogonally adjacent enemy piece, (b) move to an empty cell which is connected by an orthogonal sequence of steps over empty cells!", + "INVALID_NUM_GROUPS": "A move must create, at least, two separate groups of pieces!" + }, "frogger": { "ADDITIONAL_INSTRUCTIONS": "You may make up to three moves. Choose another card or frog to start another move, or press the complete move button to finish your turn.", "CARD_FIRST": "Please select a hand card before moving a frog forward.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index a3dc73f1..ee3238e2 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -59,6 +59,7 @@ "entrapment_self": "{{player}} captured their own roamer at {{where}}.", "fightopia_1": "{{player}} captured a pawn at {{where}}.", "fightopia_2": "{{player}} captured a tank at {{where}}.", + "forms": "{{player}} move eliminates {{count}} piece(s).", "furl": "{{player}} captured a stack of size {{size}} at {{where}}.", "gess_one": "{{count}} stone is removed from the board.", "gess_other": "{{count}} stones are removed from the board.", @@ -248,6 +249,7 @@ "consecutive_passes": "The game has ended because both players passed consecutively.", "deckfish": "The game has ended because the last player was unable to move.", "default": "The game has ended.", + "forms": "The game has ended because the next player is unable to move.", "material": "The game has ended because both players have insufficient material.", "repetition_one": "The game has ended because the same position has occurred by the same player.", "repetition_other": "The game has ended because the same position has occurred {{count}} times by the same player.", diff --git a/src/games/forms.ts b/src/games/forms.ts new file mode 100644 index 00000000..18ec34e2 --- /dev/null +++ b/src/games/forms.ts @@ -0,0 +1,523 @@ +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 } from "../common"; +import { SquareOrthGraph } from "../common/graphs"; +import {connectedComponents} from 'graphology-components'; +import i18next from "i18next"; + +export type playerid = 1 | 2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface IFormsState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class FormsGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Forms", + uid: "forms", + playercounts: [2], + version: "20260610", + dateAdded: "2026-06-10", + // i18next.t("apgames:descriptions.forms") + description: "apgames:descriptions.forms", + notes: "apgames:notes.forms", + urls: [ + "https://boardgamegeek.com/boardgame/36917/forms", + "https://jpneto.github.io/world_abstract_games/forms.htm", + ], + people: [ + { + type: "designer", + name: "Steven Meyers", + urls: ["https://boardgamegeek.com/boardgamedesigner/6984/steven-meyers", + "https://web.archive.org/web/20140216114207/https://home.fuse.net/swmeyers/home.htm"], + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>immobilize", "mechanic>move", "mechanic>capture", "board>shape>rect", "board>connect>rect", "components>simple>1per"], + flags: ["no-moves", "experimental"], + variants: [ + { uid: "#board", }, // 8x8 + { uid: "size-10", group: "board" }, + { uid: "size-12", group: "board" }, + { uid: "size-14", group: "board" }, + { uid: "size-16", group: "board" }, + { uid: "size-18", group: "board" }, + { uid: "original", group: "ruleset" }, + ] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public boardSize = this.getBoardSize(); + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + + private ruleset: "default" | "original"; + private dots: [number, number][] = []; // if there are points here, the renderer will show them + + constructor(state?: IFormsState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const board = new Map(); + const sz = this.getBoardSize(); // this.boardSize is not yet defined + + 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]; + if (state === undefined) { + throw new Error(`Could not load state index ${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; + } + + private coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.boardSize); + } + private algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.boardSize); + } + + private getBoardSize(): number { + 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; + } + + private getRuleset(): "default" | "original" { + if (this.variants.includes("original")) { return "original"; } + return "default"; + } + + private getGraph(): SquareOrthGraph { // just orthogonal connections + return new SquareOrthGraph(this.boardSize, this.boardSize); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.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}) + } + } + } + + // return the groups of pieces on the board if move `from-to` was made + private getGroups(from: string, to: string): string[][] { + // simulate move + const wasTo : playerid|undefined = this.board.has(to) ? this.board.get(to) : undefined; + this.board.delete(from); + this.board.set(to, this.currplayer) + + // find number of mutually exclusive groups of pieces + const graph = this.getGraph(); + for (const node of graph.graph.nodes()) { + if (! this.board.has(node) ) { + graph.graph.dropNode(node); + } + } + const groups = connectedComponents(graph.graph); + + // undo simulated actions + this.board.set(from, this.currplayer); + if (wasTo === undefined) { + this.board.delete(to); + } else { + this.board.set(to, wasTo); + } + + return groups; + } + + // returns all moves that `cell` can capture/move + // requires: all pieces (of either color) at gathered in a single orthogonal group + private validMoves(cell: string, player?: playerid): string[] { + const allPieces = [...this.board.entries()].map(pair => pair[0]); + const g = this.getGraph(); + player ??= this.currplayer; + + if (this.ruleset === 'original' ) { + // the player moves the selected stone to any cell occupied by an opponent's stone + return [...this.board.entries()].filter(([,owner]) => owner !== player).map(pair => pair[0]); + } + + // compute empty areas + const gEmpties = this.getGraph(); + for (const node of gEmpties.graph.nodes()) { + if ( allPieces.includes(node) ) { // remove intersections/nodes with pieces + gEmpties.graph.dropNode(node); + } + } + const emptyAreas : Array> = connectedComponents(gEmpties.graph); + + // find empty area(s) adjacent to `cell` (there are, at most, two areas that can be adjacent) + const myArea: string[] = []; + const myNeighs = g.neighbours(cell); + for (const area of emptyAreas) { + for (const cellArea of area) { + if ( myNeighs.includes(cellArea) ) { + myArea.push(...area); + } + } + } + + // get all empty cells from myArea adjacent to at least one piece + const myAreaSet: Set = new Set(myArea); // for faster look-up + const emptyNeighs = new Set(); + for (const piece of allPieces) { + for (const neigh of g.neighbours(piece)) { + if ( myAreaSet.has(neigh) ) { + emptyNeighs.add(neigh); + } + } + } + // only include moves that split the main group of pieces [costly operation: O(n³)] + const moves: string[] = [...emptyNeighs].filter(to => this.getGroups(cell, to).length > 1); + + // add captures moves to `moves` + for (const neigh of myNeighs) { + if ( this.board.has(neigh) && this.board.get(neigh)! !== player) { + moves.push(neigh); + } + } + + return moves; + } + + 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.forms.INITIAL_INSTRUCTIONS"); + return result; + } + + const moves = m.split('-'); + + try { // check cell validity + for (const cell of moves) { this.algebraic2coords(cell); } + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); + return result; + } + + if (moves.length === 1) { + if ( !this.board.has(m) || this.board.get(m) !== this.currplayer ) { + result.valid = false; + result.message = i18next.t("apgames:validation.forms.INVALID_SELECTION"); + return result; + } + if ( this.validMoves(m).length === 0 ) { + result.valid = false; + result.message = i18next.t("apgames:validation.forms.NO_MOVES"); + return result; + } + result.valid = true; + result.complete = -1; // player still needs to move the piece + result.canrender = true; + result.message = i18next.t("apgames:validation.forms.INSTRUCTIONS"); + return result; + } + + const from = moves[0]; + const to = moves[1]; + + if (! this.validMoves(from).includes(to) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.forms.INVALID_MOVE"); + return result; + } + + result.valid = true; + result.complete = 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): FormsGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } + } + + this.results = []; + this.dots = []; + if ( m.length === 0 ) { return this; } + + if ( partial && !m.includes('-') ) { + if (this.ruleset !== 'original' ) { + this.dots = this.validMoves(m).map(c => this.algebraic2coords(c)); + } + return this; + } else { + this.dots = []; // otherwise delete the points and process the full move + } + + const moves = m.split('-'); + + // mark which groups are to be captured + const captures = []; + for (const group of this.getGroups(moves[0], moves[1])) { + if ( group.includes(moves[1]) ) { continue; } + captures.push(...group); + } + + this.board.delete(moves[0]); + this.board.set(moves[1], this.currplayer); + this.results.push({ type: "move", from: moves[0], to: moves[1]}); + + if (captures.length > 0) { + for (const cell of captures) { + this.board.delete(cell); + } + this.results.push({ type: "capture", where: captures.join(), count: captures.length }); + } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): FormsGame { + const pieces = [...this.board.entries()].filter(([,owner]) => owner === this.currplayer) + .map(pair => pair[0]); + let foundValidMove = false; + + for (const piece of pieces) { + const valid = this.validMoves(piece); + if ( valid.length > 0 ) { + foundValidMove = true; + break; + } + } + + if (! foundValidMove ) { // the last player to move loses (Forms is a misère game) + this.gameover = true; + this.winner = [this.currplayer]; + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + + return this; + } + + public render(): APRenderRep { + let pstr = ""; + for (let row = 0; row < this.boardSize; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, 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(""); + } + pstr = pstr.replace(new RegExp(`-{${this.boardSize}}`, "g"), "_"); + + // Build rep + const rep: APRenderRep = { + board: { + style: "squares", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: { name: "piece", colour: 1 }, + B: { name: "piece", colour: 2 }, + }, + pieces: pstr, + }; + + // Add annotations + rep.annotations = []; + for (const move of this.results) { + if (move.type === "move") { + const [fromX, fromY] = this.algebraic2coords(move.from); + const [toX, toY] = this.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "capture") { + for (const cell of move.where!.split(",")) { + const [x, y] = this.algebraic2coords(cell); + rep.annotations.push({ type: "exit", targets: [{ row: y, col: x }] }); + } + } + } + + // 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 state(): IFormsState { + return { + game: FormsGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: FormsGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { + let resolved = false; + switch (r.type) { + case "capture": + node.push(i18next.t("apresults:CAPTURE.forms", { player, count: r.count })); + resolved = true; + break; + case "eog": + node.push(i18next.t("apresults:EOG.forms")); + resolved = true; + break; + } + return resolved; + } + + public clone(): FormsGame { + return new FormsGame(this.serialize()); + } +} diff --git a/src/games/index.ts b/src/games/index.ts index 992027be..af68dad7 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -261,6 +261,7 @@ import { InvectorGame, IInvectorState } from "./invector"; import { TricouleurGame, ITricouleurState } from "./tricouleur"; import { PositGame, IPositState } from "./posit"; import { VirusWarGame, IVirusWarState } from "./viruswar"; +import { FormsGame, IFormsState } from "./forms"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -525,6 +526,7 @@ export { TricouleurGame, ITricouleurState, PositGame, IPositState, VirusWarGame, IVirusWarState, + FormsGame, IFormsState, }; const games = new Map(); // Manually add each game to the following array [ @@ -655,7 +657,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."); @@ -1189,6 +1191,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new PositGame(...args); case "viruswar": return new VirusWarGame(args[0], ...args.slice(1)); + case "forms": + return new FormsGame(...args); } return; } diff --git a/src/games/linage.ts b/src/games/linage.ts index 54a0b534..2aae6150 100644 --- a/src/games/linage.ts +++ b/src/games/linage.ts @@ -593,7 +593,7 @@ export class LinageGame extends GameBase { resolved = true; break; case "pass": - node.push(i18next.t("apresults:PLACE.linage", { player })); + node.push(i18next.t("apresults:PASS.linage", { player })); resolved = true; break; case "eog":