diff --git a/locales/en/apgames.json b/locales/en/apgames.json index b5c701db..32ee7ca4 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -93,6 +93,7 @@ "gess": "In Gess, the 'pieces' are 3x3 patterns stones. A player can use any 3×3 portion of the board that contains at least one of their stones and none of their opponent's stones. If a 3x3 piece moves in such a way that it overlaps one or more stones (of either player), all stones that the piece moves onto are captured. Each player must maintain one piece that is a ring of eight stones around an empty square at all times. If one player is in a position where they no longer have such a ring anywhere on the board for any reason, they lose.", "gliss": "Establish bases and deploy gliders to capture opposing gliders and control towers. Eliminate all opposing bases or establish a critical number of control towers to win.", "gomoku": "A classic game where you try to get exactly five stones in a row. Six in a row does not count as a win. The game ends in a draw if no player gets a five-in-a-row or on two consecutive passes.", + "go": "The traditional game of Go", "gonnect": "Gonnect is a connection game where players try to connect any two opposite sides. All rules of Go apply except that players may not pass.", "gorogo": "A Go variant played on a small board where the goal is to capture more pieces than your opponent, rather than to claim territory.", "gyges": "A breakthrough game where nobody owns any pieces, and pieces rebound off of each other.", @@ -190,6 +191,7 @@ "stairs": "Stack higher than your opponent in this game of one-space, one-level jumps where you also have to move your lowest pieces first. To take the lead and win, you must surpass your opponent's tallest stack height or, failing that, their number of stacks at the tallest height.", "stawvs": "A set collection game for 2-4 players that's similar to Amazons, with old-style Volcano scoring. On a checkerboard randomly filled with individual pyramids, player pieces can move and capture pyramids in a straight line (orthogonally or diagonally), if and only if all intervening spaces and the target space contain pyramids but not pieces.", "stibro": "Win by forming a loop anywhere on the board, while adhering to the placement restriction that ensures loop-forming is always possible.", + "stiletto": "Make a 5 in-a-row where players alternate the power of the dagger, i.e., an option to make a double placement.", "stigmergy": "Territorial line-of-sight game. Cells are controlled and pieces on them are flipped based on how many pieces of a color can see them. The winner is the player who occupies more of the board at the end.", "storisende": "A unique territory control game where the movement of pieces create territories and walls, eventually creating enclosed nations. Control the most territory to win.", "stormc": "An annihilation game with asymmetrical piece movement.", @@ -1301,6 +1303,32 @@ "name": "Swap-5 opening" } }, + "go": { + "size-5": { + "name": "5x5 board" + }, + "size-9": { + "name": "9x9 board" + }, + "size-13": { + "name": "13x13 board" + }, + "size-17": { + "name": "17x17 board" + }, + "#board": { + "name": "19x19 board" + }, + "size-21": { + "name": "21x21 board" + }, + "size-25": { + "name": "25x25 board" + }, + "size-37": { + "name": "37x37 board" + } + }, "gonnect": { "#board": { "name": "Default 13x13 board" @@ -2376,6 +2404,11 @@ "name": "Size 8 board" } }, + "stiletto": { + "#board": { + "name": "19x19 board" + } + }, "stigmergy": { "#board": { "name": "Default board (base 8)" @@ -4527,6 +4560,16 @@ "NO_MOVES": "The glider at {{where}} has no legal moves.", "PARTIAL": "Select one of the highlighted cells to move the glider. If a bar has appeared at the edge of the board, you can click it to move the glider off the board." }, + "go": { + "INITIAL_SETUP": "Choose a number of points to add to the second player's score, and the next player will choose sides (0.5 will be added to prevent draws). This implementation uses Chinese area rules. Scores are based on the current area ownership. Players do need to capture all dead stones before ending the game with two consecutive passes.", + "INSTRUCTIONS": "Select an intersection to place a piece.", + "INVALID_KOMI": "You must choose an integer number of points to add to the second player's score.", + "INVALID_PIE": "You cannot accept or reject a pie offer now.", + "INVALID_PLAYSECOND": "You cannot choose to play second from this board state.", + "KOMI_CHOICE": "You may either make the first move on the board and let your opponent keep the bonus points (an integer) or you may choose \"Play second\" and take the bonus points for yourself.", + "KO": "Ko rule: you may not immediately recapture your opponent's single stone if they just used it to capture one of yours.", + "SELF_CAPTURE": "You may not place your stones in self-capture." + }, "gonnect": { "INITIAL_INSTRUCTIONS": "Select an intersection to place a piece.", "KO": "Ko rule: you may not immediately recapture your opponent's single stone if they just used it to capture one of yours.", @@ -5564,6 +5607,15 @@ "FIRST_PLACEMENT": "The first placement cannot be on the edge.", "FREE_GROUPS_AFTER": "You must always have at least one free group: a group that does not touch the edge, and has two spaces between itself and an opponent group that does not touch the edge." }, + "stiletto": { + "INITIAL_INSTRUCTIONS": "Place a piece onto an empty space. The second player initially has the active dagger, i.e., the power to make two placements. The dagger is temporarily inactive if it was used in the player's last turn (and the opponent just used it). The only exception is if there is the need to protect against an immediate winning threat by the opponent.", + "INSTRUCTIONS": "Place a piece onto an empty space.", + "INSTRUCTIONS_DAGGER": "Place a piece onto an empty space. You have the dagger and can use it to place a second stone on an empty square (note that the dagger will then go to the opponent).", + "INSTRUCTIONS_INACTIVE_DAGGER": "Place a piece onto an empty space. You have the dagger but it is inactive, since you used it last turn and there is no immediate losing threat.", + "EXCESS": "You may only place two stones if you have an active dagger. Place just one stone.", + "EXCESS_FIRST": "You have placed too many stones. At the start you may only place one stone as the first player.", + "EXTENSION": "It is illegal to use the dagger to extend three friendly stones, in the same line, to make a 5 in-a-row." + }, "stigmergy": { "INITIAL_INSTRUCTIONS": "Place a piece onto an empty space not controlled by the opponent, replace an enemy piece on a space you control, or 'pass' if all spaces are occupied or controlled. The game ends when both players pass in sequence.", "INITIAL_INSTRUCTIONS_BUTTON": "Place a piece onto an empty space not controlled by the opponent, replace an enemy piece on a space you control, or \"Take button\" (worth .5 points).", diff --git a/src/games/go.ts b/src/games/go.ts new file mode 100644 index 00000000..6f8617dd --- /dev/null +++ b/src/games/go.ts @@ -0,0 +1,749 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IScores, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, BoardBasic, MarkerDots, RowCol } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { RectGrid, replacer, reviver, UserFacingError, SquareOrthGraph } from "../common"; +//import { UndirectedGraph } from "graphology"; +import { connectedComponents } from "graphology-components"; + +import i18next from "i18next"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Buffer = require('buffer/').Buffer // note: the trailing slash is important! +import pako, { Data } from "pako"; + +type playerid = 1 | 2 | 3; // 3 is for neutral owned areas + +type Territory = { + cells: string[]; + owner: playerid|undefined; +}; + +interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + scores: [number, number]; + komi?: number; + swapped: boolean; +} + +export interface IGoState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class GoGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Go", + uid: "go", + playercounts: [2], + version: "20260225", + dateAdded: "2026-02-25", + // i18next.t("apgames:descriptions.go") + description: "apgames:descriptions.go", + urls: ["https://boardgamegeek.com/boardgame/12146/go"], + people: [ + { + type: "designer", + name: "Traditional", + urls: ["https://boardgamegeek.com/boardgamedesigner/"], + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + variants: [ + { uid: "size-5", group: "board" }, + { uid: "size-9", group: "board" }, + { uid: "size-13", group: "board" }, + { uid: "size-17", group: "board" }, + { uid: "#board", }, + { uid: "size-21", group: "board" }, + { uid: "size-25", group: "board" }, + { uid: "size-37", group: "board" }, + ], + categories: ["goal>connect", "mechanic>place", "mechanic>capture", "mechanic>enclose", "board>connect>rect", "components>simple"], + flags: ["scores", "custom-buttons", "custom-colours", "experimental"], + }; + + public coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.boardSize); + } + + public algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.boardSize); + } + + public numplayers = 2; + public currplayer!: playerid; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public stack!: Array; + public results: Array = []; + public variants: string[] = []; + public scores: [number, number] = [0, 0.5]; + public komi?: number; + public swapped = true; + + private boardSize = 19; + private grid: RectGrid; + + constructor(state?: IGoState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: GoGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + scores: [0, 0.5], + swapped: true + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + // is the state a raw JSON obj + if (state.startsWith("{")) { + state = JSON.parse(state, reviver) as IGoState; + } else { + const decoded = Buffer.from(state, "base64") as Data; + const decompressed = pako.ungzip(decoded, {to: "string"}); + state = JSON.parse(decompressed, reviver) as IGoState; + } + } + if (state.game !== GoGame.gameinfo.uid) { + throw new Error(`The Go game code 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(); + this.grid = new RectGrid(this.boardSize, this.boardSize); + } + + public load(idx = -1): GoGame { + 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]; + if (state === undefined) { + throw new Error(`Could not load state index ${idx}`); + } + this.results = [...state._results]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + this.boardSize = this.getBoardSize(); + this.scores = [...state.scores]; + this.komi = state.komi; + this.swapped = false; + // We have to check the first state because we store the updated version in later states + if (state.swapped === undefined) { + this.swapped = this.stack.length < 3 || this.stack[2].lastmove !== "play-second"; + } else { + this.swapped = state.swapped; + } + return this; + } + + private getBoardSize(): number { + // Get board size from variants. + if (this.variants !== undefined && this.variants.length > 0 && this.variants[0] !== undefined && this.variants[0].length > 0) { + const sizeVariants = this.variants.filter(v => v.includes("size")) + if (sizeVariants.length > 0) { + const size = sizeVariants[0].match(/\d+/); + return parseInt(size![0], 10); + } + if (isNaN(this.boardSize)) { + throw new Error(`Could not determine the board size from variant "${this.variants[0]}"`); + } + } + return 19; + } + + public isKomiTurn(): boolean { + return this.stack.length === 1; + } + + public isPieTurn(): boolean { + return this.stack.length === 2; + } + + public moves(player?: playerid): string[] { + if (player === undefined) { + player = this.currplayer; + } + if (this.gameover) { return []; } + + const moves: string[] = []; + + if (this.isKomiTurn()) { + return []; + } else if (this.isPieTurn()) { + moves.push("play-second"); + } else { + moves.push("pass"); + } + + for (let row = 0; row < this.boardSize; row++) { + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { continue; } + if (this.isSelfCapture(cell, player)) { continue; } + if (this.checkKo(cell, player)) { continue; } + moves.push(cell); + } + } + return moves; + } + + private hasMoves(player?: playerid): boolean { + // Check if the player has any valid moves. + if (player === undefined) { + player = this.currplayer; + } + for (let row = 0; row < this.boardSize; row++) { + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { continue; } + if (this.isSelfCapture(cell, player)) { continue; } + if (this.checkKo(cell, player)) { continue; } + return true; + } + } + return false; + } + + public randomMove(): string { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + public getButtons(): ICustomButton[] { + if (this.moves().includes("pass")) + return [{ label: "pass", move: "pass" }]; + if (this.moves().includes("play-second")) + return [{ label: "playsecond", move: "play-second" }]; + return []; // no buttons should appear when typing Komi at start + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + if (this.isKomiTurn()) { // Komi time, so no clicks are acceptable + const dummyResult = this.validateMove("") as IClickResult; + dummyResult.move = ""; + dummyResult.valid = false; + return dummyResult; + } + + const cell = this.coords2algebraic(col, row); + let newmove = ""; + 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")}; + + if (this.isKomiTurn()) { + if (m.length === 0) { + // game is starting, show initial KOMI message + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.go.INITIAL_SETUP"); + return result; + } + + // player typed something in the move textbox, check if it is an integer + if (! /^-?\d+$/.test(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.go.INVALID_KOMI"); + return result + } + result.valid = true; + result.complete = 0; // partial because player can continue typing for abs(Komi) > 9 + result.message = i18next.t("apgames:validation.go.INSTRUCTIONS"); + return result; + } + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + //result.canrender = true; + if (this.isPieTurn()) { + result.message = i18next.t("apgames:validation.go.KOMI_CHOICE"); + } else { + result.message = i18next.t("apgames:validation.go.INSTRUCTIONS") + } + return result; + } + + if (m === "play-second") { + if (this.isPieTurn()) { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.go.INVALID_PLAYSECOND"); + } + return result; + } + + // get all valid complete moves (so each move will be like "a1,b1,c1") + const allMoves = this.moves(); + + if (m === "pass") { + if (allMoves.includes("pass")) { + 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.plurality.INVALID_PASS"); + return result; + } + } + + // Valid cell + try { + this.algebraic2coords(m); + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALIDCELL", { cell: m }); + return result; + } + if (this.board.has(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.OCCUPIED", { where: m }); + return result; + } + if (this.isSelfCapture(m, this.currplayer)) { + result.valid = false; + result.message = i18next.t("apgames:validation.go.SELF_CAPTURE", { where: m }); + return result; + } + if (this.checkKo(m, this.currplayer)) { + result.valid = false; + result.message = i18next.t("apgames:validation.go.KO"); + return result; + } + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + private orthNeighbours(cell: string): string[] { + const [x, y] = this.algebraic2coords(cell); + const neighbours = this.grid.adjacencies(x, y, false); + return neighbours.map(n => this.coords2algebraic(...n)); + } + + private getGroupLiberties(cell: string, opponentPlaced: string[], player: playerid): [Set, number] { + // Get all groups associated with `cell` and the liberties of the group. + // The `cell` does not need to be placed on the `board`. We assume that it's already there. + const seen: Set = new Set(); + const liberties = new Set(); + const todo: string[] = [cell] + while (todo.length > 0) { + const cell1 = todo.pop()!; + if (seen.has(cell1)) { continue; } + seen.add(cell1); + for (const n of this.orthNeighbours(cell1)) { + if (!this.board.has(n) && !opponentPlaced.includes(n) && n !== cell) { + liberties.add(n); + continue; + } + if (this.board.get(n) === player) { todo.push(n); + } + } + } + return [seen, liberties.size]; + } + + private getCaptures(cell: string, player: playerid): Set[] { + // Get all captured cells if `cell` is placed on the board. + const allCaptures: Set[] = [] + for (const n of this.orthNeighbours(cell)) { + if (allCaptures.some(x => x.has(n)) || !this.board.has(n) || this.board.get(n) === player) { continue; } + const [group, liberties] = this.getGroupLiberties(n, [cell], player % 2 + 1 as playerid); + if (liberties === 0) { + const captures = new Set(); + for (const c of group) { + captures.add(c); + } + if (captures.size > 0) { allCaptures.push(captures); } + } + } + return allCaptures; + } + + private isSelfCapture(cell: string, player: playerid): boolean { + // Check if placing `cell` would result in a self-capture. + if (this.getCaptures(cell, player).length > 0) { return false; } + return this.getGroupLiberties(cell, [], player)[1] === 0; + } + + private checkKo(cell: string, player: playerid): boolean { + // Check if the move is a ko. + if (this.stack.length < 2) { return false; } + const captures = this.getCaptures(cell, player); + if (captures.length !== 1) { return false; } + if (captures[0].size !== 1) { return false; } + const previous = this.stack[this.stack.length - 1]; + const previousMove = previous.lastmove!; + if (!captures.some(x => x.has(previousMove))) { return false; } + const previousCaptures = previous._results.filter(r => r.type === "capture") + if (previousCaptures.length !== 1) { return false; } + return (previousCaptures[0] as Extract).count! === 1; + } + + // --- These next methods are helpers to find territories and their eventual owners ---- // + + public getGraph(): SquareOrthGraph { // just orthogonal connections + return new SquareOrthGraph(this.boardSize, this.boardSize); + } + + /** + * What pieces are orthogonally adjacent to a given area? + */ + public getAdjacentPieces(area: string[], pieces: string[]): string[] { + // convert area strings to numeric coordinates + const areaCoords = area.map(cell => this.algebraic2coords(cell)); + + return pieces.filter(pieceStr => { // Filter the pieces array + const piece = this.algebraic2coords(pieceStr); + + return areaCoords.some(square => { // check adjacency + const dx = Math.abs(piece[0] - square[0]); + const dy = Math.abs(piece[1] - square[1]); + return (dx == 1 && dy == 0) || (dx == 0 && dy == 1); + }); + }); + } + + /** + * Get all available territories (based in Asli) + * This is used in (1) computing scores, and (2) in the render process + */ + public getTerritories(): Territory[] { + const p1Pieces = [...this.board.entries()].filter(([,owner]) => owner === 1).map(pair => pair[0]); + const p2Pieces = [...this.board.entries()].filter(([,owner]) => owner === 2).map(pair => pair[0]); + const allPieces = [...p1Pieces, ...p2Pieces]; + + // 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); + + const territories: Territory[] = []; + for(const area of emptyAreas) { + let owner : playerid = 3; // default value: neutral area + // find who owns it + const p1AdjacentCells = this.getAdjacentPieces(area, p1Pieces); + const p2AdjacentCells = this.getAdjacentPieces(area, p2Pieces); + if (p2AdjacentCells.length == 0) { + owner = 1; + } + if (p1AdjacentCells.length == 0) { + owner = 2; + } + territories.push({cells: area, owner}); + } + return territories; + } + + public move(m: string, {partial = false, trusted = false} = {}): GoGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + let result; + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (!trusted) { + result = this.validateMove(m); + if (!result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message); + } + if (!partial && ! this.isKomiTurn() && !this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", { move: m })); + } + } + if (m.length === 0) { return this; } + this.results = []; + + if (this.isKomiTurn()) { + // first move, get the Komi proposed value, and add komi to game state + this.komi = parseInt(m, 10); + this.results.push({type: "komi", value: this.komi}); + this.komi *= -1; // Invert it for backwards compatibility reasons + } else if (m === "play-second") { + this.komi! *= -1; + this.swapped = false; + this.results.push({type: "play-second"}); + } else if (m === "pass") { + this.results.push({type: "pass"}); + } else { + this.results.push({ type: "place", where: m }); + this.board.set(m, this.currplayer); + const allCaptures = this.getCaptures(m, this.currplayer); + if (allCaptures.length > 0) { + for (const captures of allCaptures) { + for (const capture of captures) { this.board.delete(capture); } + this.results.push({ type: "capture", where: [...captures].join(), count: captures.size }); + } + } + } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): GoGame { + if (this.stack.length < 4) { + return this; // no time for komi and two consecutive passes + } + + // game ends if two consecutive passes occurred + this.gameover = this.lastmove === "pass" && + this.stack[this.stack.length - 1].lastmove === "pass"; + + const otherPlayer = this.currplayer % 2 + 1 as playerid; + + if (!this.gameover && !this.hasMoves(this.currplayer)) { + this.gameover = true; + this.winner = [otherPlayer]; + this.results.push({ type: "eog", reason: "stalemate" }); + return this; + } + + // if a cycle is found, the game ends in a draw + if (!this.gameover) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const count = this.stateCount(new Map([["board", this.board], ["currplayer", this.currplayer]])); + if (count >= 1) { + this.gameover = true; + this.winner = [1, 2]; + this.results.push({ type: "eog", reason: "repetition" }); + return this; + } + } + + if (this.gameover) { + this.scores = [this.getPlayerScore(1), this.getPlayerScore(2)]; + // draws by score are impossible + this.winner = this.scores[0] > this.scores[1] ? [1] : [2]; + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): IGoState { + return { + game: GoGame.gameinfo.uid, + numplayers: 2, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + protected moveState(): IMoveState { + return { + _version: GoGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + scores: [...this.scores], + komi: this.komi, + swapped: this.swapped + }; + } + + public getPlayerColour(player: playerid): number | string { + return (player == 1 && !this.swapped) || (player == 2 && this.swapped) ? 1 : 2; + } + + public render(): APRenderRep { + // Build piece string + let pstr = ""; + for (let row = 0; row < this.boardSize; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + 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) { + pstr += "A"; + } else if (contents === 2) { + pstr += "B"; + } + } else { + pstr += "-"; + } + } + } + pstr = pstr.replace(new RegExp(`-{${this.boardSize}}`, "g"), "_"); + + // Build rep + const rep: APRenderRep = { + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: [{ name: "piece", colour: this.getPlayerColour(1) }], + B: [{ name: "piece", colour: this.getPlayerColour(2) }], + }, + pieces: pstr, + }; + + rep.annotations = []; + if (this.results.length > 0) { + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = this.algebraic2coords(move.where!); + rep.annotations.push({ type: "enter", targets: [{ row: y, col: x }] }); + } 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 }] }); + } + } + } + } + + if (this.gameover) { + const territories = this.getTerritories(); + const markers: Array = [] + for (const t of territories) { + if (t.owner !== undefined) { + const points = t.cells.map(c => this.algebraic2coords(c)); + if (t.owner !== 3) { + markers.push({type: "dots", + colour: this.getPlayerColour(t.owner), + points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); + } + } + } + if (markers.length > 0) { + (rep.board as BoardBasic).markers = markers; + } + } + + return rep; + } + + public getPlayerScore(player: playerid): number { + const playerPieces = + [...this.board.entries()].filter(([,owner]) => owner === player) + .map(pair => pair[0]); + let komi = 0.0; + if (player === 1 && this.komi !== undefined && this.komi < 0) + komi = -this.komi + 0.5; // 0.5 is to prevent draws + if (player === 2 && this.komi !== undefined && this.komi > 0) + komi = this.komi + 0.5; + + const terr = this.getTerritories(); + return terr.filter(t => t.owner === player).reduce((prev, curr) => prev + curr.cells.length, komi + playerPieces.length); + } + + public getPlayersScores(): IScores[] { + return [{ name: i18next.t("apgames:status.SCORES"), + scores: [this.getPlayerScore(1), this.getPlayerScore(2)] }]; + } + + public status(): string { + let status = super.status(); + + if (this.variants !== undefined) { + status += "**Variants**: " + this.variants.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; + case "capture": + node.push(i18next.t("apresults:CAPTURE.noperson.group_nowhere", { player, count: r.count })); + resolved = true; + break; + case "eog": + if (r.reason === "repetition") { + node.push(i18next.t("apresults:EOG.repetition", { count: 1 })); + } else if (r.reason === "stalemate") { + node.push(i18next.t("apresults:EOG.stalemate")); + } else { + node.push(i18next.t("apresults:EOG.default")); + } + resolved = true; + break; + } + return resolved; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public serialize(opts?: {strip?: boolean, player?: number}): string { + const json = JSON.stringify(this.state(), replacer); + const compressed = pako.gzip(json); + + return Buffer.from(compressed).toString("base64") as string; + } + + public clone(): GoGame { + return new GoGame(this.serialize()); + } +} diff --git a/src/games/index.ts b/src/games/index.ts index 132f00b6..f0766adb 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -229,6 +229,8 @@ import { PluralityGame, IPluralityState } from "./plurality"; import { CrosshairsGame, ICrosshairsState } from "./crosshairs"; import { MagnateGame, IMagnateState } from "./magnate"; import { ProductGame, IProductState } from "./product"; +import { GoGame, IGoState } from "./go"; +import { StilettoGame, IStilettoState } from "./stiletto"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -461,6 +463,8 @@ export { CrosshairsGame, ICrosshairsState, MagnateGame, IMagnateState, ProductGame, IProductState, + GoGame, IGoState, + StilettoGame, IStilettoState, }; const games = new Map(); // Manually add each game to the following array [ @@ -576,7 +581,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."); @@ -1046,6 +1051,10 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new MagnateGame(...args); case "product": return new ProductGame(...args); + case "go": + return new GoGame(...args); + case "stiletto": + return new StilettoGame(...args); } return; } diff --git a/src/games/product.ts b/src/games/product.ts index f4cb09f8..4a5aba73 100644 --- a/src/games/product.ts +++ b/src/games/product.ts @@ -393,6 +393,7 @@ export class ProductGame extends GameBase { result.valid = true; result.complete = 0; // 0 so the player may flip also the last placement result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + result.canrender = true; return result; } diff --git a/src/games/stiletto.ts b/src/games/stiletto.ts new file mode 100644 index 00000000..209339dc --- /dev/null +++ b/src/games/stiletto.ts @@ -0,0 +1,605 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError } from "../common"; +import { InARowBase } from "./in_a_row/InARowBase"; +import { APRenderRep } from "@abstractplay/renderer"; + +import i18next from "i18next"; + +type playerid = 1 | 2; + +interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + winningLines: string[][]; + swapped: boolean; + lastDaggerUse : number[]; +} + +export interface IStilettoState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class StilettoGame extends InARowBase { + public static readonly gameinfo: APGamesInformation = { + name: "Stiletto", + uid: "stiletto", + playercounts: [2], + version: "20260221", + dateAdded: "2026-02-21", + // i18next.t("apgames:descriptions.Stiletto") + description: "apgames:descriptions.stiletto", + urls: ["https://jpneto.github.io/world_abstract_games/dagger_gomoku.htm"], + people: [ + { + type: "designer", + name: "Bill Taylor", + urls: ["https://boardgamegeek.com/boardgamedesigner/9249/bill-taylor"], + }, + { + type: "designer", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>align", "mechanic>place", "board>shape>rect", + "board>connect>rect", "components>simple>1per"], + flags: ["no-moves", "experimental"], + }; + + public coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.boardSize); + } + + public algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.boardSize); + } + + public numplayers = 2; + public currplayer!: playerid; + public board!: Map; + public winningLines: string[][] = []; + public winningLineLength = 5; + public defaultBoardSize = 19; + public boardSize = 0; + public gameover = false; + public winner: playerid[] = []; + public stack!: Array; + public results: Array = []; + public variants: string[] = []; + public lastDaggerUse = [0, -1]; // last #turn each player used the dagger + public swapped = false; // abstract attribute of InARowBase + + constructor(state?: IStilettoState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: StilettoGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + winningLines: [], + swapped: false, + lastDaggerUse : [0, -1] // second player starts with dagger + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IStilettoState; + } + if (state.game !== StilettoGame.gameinfo.uid) { + throw new Error(`The Stiletto game code 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): StilettoGame { + 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]; + if (state === undefined) { + throw new Error(`Could not load state index ${idx}`); + } + this.results = [...state._results]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.winningLines = state.winningLines.map(a => [...a]); + this.swapped = state.swapped; + this.boardSize = this.getBoardSize(); + this.lastDaggerUse = [...state.lastDaggerUse]; + return this; + } + + private currentTurn(): number { + return this.stack.length; + } + + private whoHasDagger(): playerid { + // the last player to have used the dagger has a higher lastDaggerUse turn + const [turn_p1, turn_p2] = this.lastDaggerUse; + return turn_p1 > turn_p2 ? 2 : 1; + } + + private hasDagger(): boolean { + return this.currplayer == this.whoHasDagger(); + } + + private hasActiveDagger(): boolean { + // an active dagger means the player must have the dagger... + if (! this.hasDagger() ) { + return false; + } + + // ...and the player must not have used it in his previous turn... + const lastDaggerUsePlayer = this.lastDaggerUse[this.currplayer - 1]; + if (lastDaggerUsePlayer < this.currentTurn() - 2) { + return true; + } + + /* ..._unless_ there are immediate loss threats! + * We'll cycle thru all cells (x,y) and count how many hasInARow(x,y) result + * in a winning line; if there are more than one, the dagger becomes active + * note: if there are more than two threats, the dagger is not enough, + * but the player can use it nonetheless + * Use: InARowBase.hasInARow(x: number, y: number, player: playerid, + * inARow: number, exact: boolean): boolean + */ + const otherplayer = (this.currplayer % 2 + 1) as playerid; + let countThreats = 0; + + for (let row = 0; row < this.boardSize; row++) { + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { continue; } + if (this.hasInARow(col, row, otherplayer, this.winningLineLength, false)) { + countThreats += 1; + } + } + } + return countThreats > 1; + } + + private isIllegalExtension(moves : string[]): boolean { + // check if moves extend three stones in the same line, to make a 5 in-a-row + const [cell1, cell2] = moves; + + // temporarily add stones so that a InARowBase method can return all + // winning patterns that these new two stones could be part + this.board.set(cell1, this.currplayer); // temporary add cell1 and cell2 + this.board.set(cell2, this.currplayer); + const winningLinesMap = this.getWinningLinesMap(); + this.board.delete(cell1); // remove them + this.board.delete(cell2); + + if (winningLinesMap.get(this.currplayer)!.length > 0) { + const winningLines : string[][] = [...winningLinesMap.get(this.currplayer)!]; + for (const winningLine of winningLines) { + if (winningLine.includes(cell1) && winningLine.includes(cell2)) { + // if both stones are part of a winning line, + // it's an illegal use of the dagger + return true; + } + } + } + + return false; + } + + private shuffle(xs: string[]): void { + // Fisher-Yates Shuffle + for (let i = xs.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [xs[i], xs[j]] = [xs[j], xs[i]]; + } + } + + public moves(): string[] { + const moves: string[] = []; + + // players can always place one stone + for (let row = 0; row < this.boardSize; row++) { + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { continue; } + moves.push(this.normaliseMove(cell)); + } + } + + if (this.hasActiveDagger()) { // player can also place two stones + // select a fraction of available moves (too costly to find them all) + const emptyCells: string[] = []; + for (let row = 0; row < this.boardSize; row++) { + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (! this.board.has(cell)) { + emptyCells.push(cell); + } + } + } + let nDaggerMoves = Math.floor(this.boardSize * this.boardSize / 2); + while (nDaggerMoves-- > 0) { + this.shuffle(emptyCells); + const cell1 = emptyCells[0]; + const cell2 = emptyCells[1]; + if (this.isIllegalExtension([cell1, cell2])) { continue; } + moves.push(this.normaliseMove(cell1 + "," + cell2)); + } + } + + /* complete moves: too slow + if (this.hasActiveDagger()) { // player can also place two stones + for (let row = 0; row < this.boardSize; row++) { + for (let col = 0; col < this.boardSize; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { continue; } + for (let row1 = row; row1 < this.boardSize; row1++) { + for (let col1 = row1 === row ? col + 1 : 0; col1 < this.boardSize; col1++) { + const cell1 = this.coords2algebraic(col1, row1); + if (this.board.has(cell1)) { continue; } + if (this.isIllegalExtension([cell, cell1])) { continue; } + moves.push(this.normaliseMove(cell + "," + cell1)); + } + } + } + } + } + */ + + return moves; + } + + public randomMove(): string { + const moves: string[] = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + private sort(a: string, b: string): number { + // Sort two cells; necessary because "a10" should come after "a9". + const [ax, ay] = this.algebraic2coords(a); + const [bx, by] = this.algebraic2coords(b); + if (ax < bx) { return -1; } + if (ax > bx) { return 1; } + if (ay < by) { return 1; } + if (ay > by) { return -1; } + return 0; + } + + private normaliseMove(move: string): string { + move = move.toLowerCase(); + move = move.replace(/\s+/g, ""); + // sort the move list so that there is a unique representation. + return move.split(",").sort((a, b) => this.sort(a, b)).join(","); + } + + public sameMove(move1: string, move2: string): boolean { + return this.normaliseMove(move1) === this.normaliseMove(move2); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + let newmove = ""; + const cell = this.renderCoords2algebraic(col, row); + if (move === "") { + newmove = cell; + } else { + newmove = this.normaliseMove(move + "," + cell); + } + 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(m: string): IValidationResult { + const result: IValidationResult = + {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (m.length === 0) { + let message = i18next.t("apgames:validation.stiletto.INITIAL_INSTRUCTIONS"); + if (this.stack.length > 1) { + if (this.hasActiveDagger()) { + message = i18next.t("apgames:validation.stiletto.INSTRUCTIONS_DAGGER"); + } else if (this.hasDagger()) { + message = i18next.t("apgames:validation.stiletto.INSTRUCTIONS_INACTIVE_DAGGER"); + } else { + message = i18next.t("apgames:validation.stiletto.INSTRUCTIONS"); + } + } + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = message; + return result; + } + + const moves = m.split(","); + + // are all valid cells? + let currentMove; + try { + for (const cell of moves) { + currentMove = cell; + const [x, y] = this.algebraic2coords(cell); + // `algebraic2coords` does not check if the cell is on the board. + if (x < 0 || x >= this.boardSize || y < 0 || y >= this.boardSize) { + throw new Error("Invalid cell"); + } + } + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALIDCELL", { cell: currentMove }); + return result; + } + + // is move normalised? (sanity check, in case user types the move) + const normalised = this.normaliseMove(m); + if (! this.sameMove(m, normalised)) { + result.valid = false; + result.message = i18next.t("apgames:validation.product.NORMALISED", {move: normalised}); + return result; + } + + // are all placements on empty cells? + let notEmpty; + for (const cell of moves) { + if (this.board.has(cell)) { notEmpty = cell; break; } + } + if (notEmpty) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.OCCUPIED", { where: notEmpty }); + return result; + } + + // at ply 1 only one placement is legal + if (this.currentTurn() === 1 && moves.length > 1) { + result.valid = false; + result.message = i18next.t("apgames:validation.stiletto.EXCESS_FIRST"); + return result; + } + + if (moves.length == 2) { + if (moves[0] == moves[1]) { // placements must be on different cells + result.valid = false; + result.message = i18next.t("apgames:validation._inarow.DUPLICATE", + { where: moves[0] }); + return result; + } + if (! this.hasActiveDagger()) { // two placements are only valid with an active dagger + result.valid = false; + result.message = i18next.t("apgames:validation.stiletto.EXCESS"); + return result; + } + + if (this.isIllegalExtension(moves)) { + result.valid = false; + result.message = i18next.t("apgames:validation.stiletto.EXTENSION"); + return result; + } + } + + // no more than two placements is possible + if (moves.length > 2) { + result.valid = false; + result.message = i18next.t("apgames:validation.stiletto.EXCESS"); + return result; + } + + // one placement on an empty cell is always valid + if (moves.length == 1) { + // but the player can still decide to use his dagger (if active) + // otherwise, the move is complete + result.complete = this.hasActiveDagger() ? 0 : 1; + } else { // two legal placements were made + result.complete = 1; + } + + result.valid = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): StilettoGame { + 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 (m.length === 0) { return this; } + + const moves = m.split(","); + + this.results = []; + for (const cell of moves) { + this.results.push({ type: "place", where: cell }); + this.board.set(cell, this.currplayer); + } + + if (partial) { return this; } + + if (moves.length === 2) { + // update state regarding current player's last use of dagger + this.lastDaggerUse[this.currplayer - 1] = this.currentTurn(); + } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): StilettoGame { + const winner: playerid[] = []; + const winningLinesMap = this.getWinningLinesMap(); + this.winningLines = []; + + for (const player of [1, 2] as playerid[]) { + if (winningLinesMap.get(player)!.length > 0) { + winner.push(player); + this.winningLines.push(...winningLinesMap.get(player)!); + } + } + + if (winner.length === 0) { + if (! this.hasEmptySpace()) { // board is full and there's no 5 in-a-row + this.gameover = true; + this.winner = [1, 2]; // it is a draw + } + } else { // there's at least one 5 in-a-row --> game ends with a winner + this.gameover = true; + this.winner = winner; + } + + if (this.gameover) { + this.results.push({ type: "eog" }); + this.results.push({ type: "winners", players: [...this.winner] }); + } + return this; + } + + public render(): APRenderRep { + // Build piece string + let pstr = ""; + const renderBoardSize = this.boardSize; + for (let row = 0; row < renderBoardSize; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + for (let col = 0; col < renderBoardSize; col++) { + const cell = this.renderCoords2algebraic(col, row); + if (this.board.has(cell)) { + const contents = this.board.get(cell); + if (contents === 1) { + pstr += "A"; + } else if (contents === 2) { + pstr += "B"; + } + } else { + pstr += "-"; + } + } + } + pstr = pstr.replace(new RegExp(`-{${renderBoardSize}}`, "g"), "_"); + + // Build rep + const rep: APRenderRep = { + board: { + style: "vertex", + width: renderBoardSize, + height: renderBoardSize, + }, + legend: { + A: [{ name: "piece", colour: this.getPlayerColour(1) as playerid }], + B: [{ name: "piece", colour: this.getPlayerColour(2) as playerid }], + }, + pieces: pstr, + }; + + rep.annotations = []; + if (this.results.length > 0) { + for (const move of this.results) { + if (move.type === "place") { + const coordsAll = this.renderAlgebraic2coords(move.where!); + for (const [x, y] of coordsAll) { + rep.annotations.push({ type: "enter", targets: [{ row: y, col: x }] }); + } + } + } + const renderWinningLines = this.renderWinningLines(this.winningLines); + if (renderWinningLines.length > 0) { + for (const connPath of renderWinningLines) { + if (connPath.length === 1) { continue; } + type RowCol = {row: number; col: number;}; + const targets: RowCol[] = []; + for (const coords of connPath) { + targets.push({row: coords[1], col: coords[0]}) + } + rep.annotations.push({type: "move", targets: targets as [RowCol, ...RowCol[]], arrow: false}); + } + } + } + + return rep; + } + + public state(): IStilettoState { + return { + game: StilettoGame.gameinfo.uid, + numplayers: 2, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + protected moveState(): IMoveState { + return { + _version: StilettoGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + winningLines: this.winningLines.map(a => [...a]), + swapped: this.swapped, + lastDaggerUse: [...this.lastDaggerUse], + }; + } + + public status(): string { + let status = super.status(); + if (this.variants !== undefined) { + status += "**Variants**: " + this.variants.join(", ") + "\n\n"; + } + return status; + } + + public clone(): StilettoGame { + return new StilettoGame(this.serialize()); + } +}